🚀 谁说程序员只会写代码?用 Vue3 + 多模态大模型打造你的 "AI 翻译神器" 📸

6 阅读11分钟

摘要:在 AI 时代,Coding 的方式变了,但创意的价值无限放大。本文将带你从 0 到 1,用 Vue3、TypeScript 结合 Kimi 多模态大模型和火山引擎 TTS,开发一款“拍照记单词”应用。不只是代码,更是产品思维的觉醒。

关键词:Vue3, TypeScript, 多模态大模型, Kimi API, TTS, 独立开发, 掘金


👋 Vibe Coding 与 OPC 时代

兄弟们,时代变了!

以前我们谈论开发,满嘴都是 "高并发"、"微服务"、"底层源码"。但在 AI 席卷而来的今天,代码不再是唯一的壁垒,创意和执行力才是

大家最近有没有听到一个词叫 "Vibe Coding"?简单说,就是一种“感觉流”编程。代码和项目开发变得前所未有的快速且靠谱。你只要有想法,AI 就能帮你落地。这直接催生了 OPC (One Person Company) —— 一人公司。

以前做一个产品需要产品经理、UI 设计、前端、后端、运维。现在?

  • 创意、规划、商业、共情 -> 你(AI 产品经理)
  • 代码实现 -> 你 + AI Copilot

所以,今天我们不只是写代码,我们要从产品经理的角度出发,来做一款属于自己的 AI 产品。


💡 产品构思:不做下一个“百词斩”

1. 痛点在哪里?

市面上的单词 App 多如牛毛。

  • 百词斩:主打“背单词”,图片联想(比如 awkward 是一只尴尬的长颈鹿),很棒,但它的场景是学习
  • 扇贝:主打“记忆算法”,不仅要背,还要抗遗忘。

但是,试想这样一个场景:

你独自走在东京的街头,或者坐在巴黎的餐厅里。菜单全是法文,路牌全是日文。你不需要“背诵”这些单词,你只需要即时知道这是什么,怎么读,最好还能教你一句应景的例句,让你能跟服务员显摆一下。

2. 我们的定位

我们要做的,不是“背单词 App”,而是 “跨国生活生存指南”

  • 场景:旅游、点餐、购物、求助。
  • 核心功能:拍照 -> 识别物体/文字 -> 给出单词 -> 教你怎么说 -> 给出一句实用的社交例句。
  • 痛点:足够痛!在国外看不懂菜单是真饿肚子啊!

3. 技术方案

  • 大模型能力:我们需要一个能“看懂图”的脑子。这里我们选用 Kimi (Moonshot AI) 的多模态模型 moonshot-v1-8k-vision-preview
  • 语音能力:我们需要一张“会说话”的嘴。这里选用 火山引擎 (ByteDance) 的 TTS (Text to Speech)。
  • 前端栈Vue 3 + TypeScript。轻量、高效、快。

🛠️ 实战开始:从组件设计到 AI 接入

好了,产品经理的角色结束,现在带上你的“全栈工程师”帽子,我们要开工了!

🏗️ 第一步:Vue 3 组件化思维与父子通信

我们的应用结构非常简单:

  1. App.vue:主页面,负责统筹全局,调用 AI 接口。
  2. PictureCard.vue:子组件,负责展示图片、播放声音、上传文件。

在 Vue 中,父子组件通信是基础中的基础。很多从 React 转过来的同学可能会不习惯,Vue 把“属性传递”和“事件触发”分得非常开。

看看我们的 App.vue 是怎么调用子组件的:

<!-- src/App.vue -->
<template>
  <div class="container">
    <!-- 
      :word="word"   -> Props: 父传子,把 AI 分析出的单词传给卡片显示
      :audio="audio" -> Props: 父传子,把生成的音频链接传给卡片播放
      @update-image="submit" -> Emits: 子传父,子组件图片变了,通知父组件去提交 API
    -->
    <PictureCard 
      :word="word" 
      :audio="audio"
      @update-image="submit"
    />
    <!-- 省略其他代码... -->
  </div>
</template>

🔍 深入解析:definePropsdefineEmits

转到子组件 PictureCard.vue,看看它是如何接收这些参数的。在 Vue 3 <script setup> 语法糖中,我们不需要像以前那样写 export default 对象了,一切都变得非常函数式。

1. 接收数据 (defineProps)

// src/components/PictureCard.vue

const props = defineProps({
    word: {
        type: String,
        default: '' // 默认值为空,防止页面渲染 undefined 报错
    },
    audio: {
        type: String,
        default: ''
    }
})

// 使用时:
// 在 template 中直接用 {{ props.word }} 或者简写 {{ word }}
// 在 script 中使用 props.word

2. 定义事件 (defineEmits) —— 重点来了!🚨

很多新手在这里容易晕。在 React 中,我们通常是把一个函数作为 Prop 传下去(比如 onClick={handleClick})。但在 Vue 中,事件是独立的通信频道。

// src/components/PictureCard.vue

// 声明:我这个组件,会对外触发一个叫 'updateImage' 的事件
const emit = defineEmits(['updateImage']);

// 使用场景:当用户选好图片后
const updateImageData = async (e: Event) => {
    // ...处理图片逻辑...
    const data = reader.result as string;
    
    // 💥 触发事件!
    // 参数1: 事件名 'updateImage' (必须和 defineEmits 里定义的一致)
    // 参数2: 载荷 (Payload), 这里是图片的 base64 数据
    emit('updateImage', data); 
}

这里发生了什么? 当子组件执行 emit('updateImage', data) 时,就像是发出了一个广播。父组件在模板里监听的 @update-image="submit" 就会捕捉到这个广播,并且把 data 作为参数传给 submit 函数。

💡 小贴士:Vue 推荐事件名在 emits 定义和触发时用 camelCase (如 updateImage),而在父组件模板监听时用 kebab-case (如 @update-image)。虽然 Vue 会自动处理转换,但遵循规范能少踩坑。


🎨 第二步:优雅的图片上传 (File Input Hack)

原生 <input type="file"> 长得那是相当随意(丑)。在我们的设计稿里,上传按钮应该是一个漂亮的卡片,或者是一个相机图标。怎么破?

核心思路:隐身术 + 替身攻击

  1. 把真正的 input 藏起来 (display: none)。
  2. 用一个 label 标签通过 for 属性绑定这个 input。
  3. 点击 label 就等于点击了 input
<!-- src/components/PictureCard.vue -->
<template>
    <div class="card">
        <!-- 1. 真正的上传控件,被隐藏了 -->
        <input 
            type="file" 
            id="selecteImage" 
            class="input"
            accept="image/*"  // 限制只能上传图片
            @change="updateImageData"
        >
        
        <!-- 2. 替身:一个带有 for 属性的 label -->
        <!-- 点击这个 label,浏览器会自动寻找 id="selecteImage" 的 input 并触发点击 -->
        <label for="selecteImage" class="upload">
            <!-- 这里可以放任何你想要的精美图片或图标 -->
            <img :src="imgPreview" alt="camera" class="img"/>
        </label>
        
        <!-- 省略其他... -->
    </div>
</template>

<style scoped>
/* 隐藏原生控件 */
#selecteImage {
    display: none;
}
</style>

知识点延伸:无障碍访问 (A11y) 这种 label for + input#id 的写法不仅是为了样式 hack,它也是 Web 无障碍访问的标准实践。对于使用读屏器的盲人用户来说,label 能够准确地描述这个 input 的用途。


🖼️ 第三步:图片转 Base64 —— 多模态模型的入场券

用户选了图,我们拿到了一个 File 对象。但是,Kimi(以及大多数 LLM API)并不支持直接上传二进制文件流,它们通常需要图片的 Base64 编码 或者是网络 URL。

既然我们是本地上传,没有服务器做中转,那就必须在前端把图片转成 Base64 字符串。

// src/components/PictureCard.vue

const updateImageData = async (e: Event): Promise<any> => {
    // 1. 获取文件对象
    // 类型断言 as HTMLInputElement 是因为 TS 不知道 e.target 必定是 input
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return 

    return new Promise((resolve, reject) => {
        // 2. 召唤 FileReader —— 浏览器读取本地文件的神器
        const reader = new FileReader();
        
        // 3. 开始读取,并要求转化成 Data URL (即 Base64 格式)
        reader.readAsDataURL(file);
        
        // 4. 监听:读取完成了吗?
        reader.onload = () => {
            // result 就是我们要的 "data:image/png;base64,....." 长字符串
            const data = reader.result as string;
            
            // 本地预览更新
            imgPreview.value = data;
            
            // 传给父组件去调用 API
            emit('updateImage', data);
            
            resolve(data);
        }
        
        reader.onerror = (error) => {
            reject(error);
        }
    })
}

为什么是 Base64? Base64 是一种将二进制数据(图片、音频)编码成 ASCII 字符的方法。虽然体积会膨胀约 33%,但它可以直接嵌入到 JSON 文本中通过 HTTP 发送,非常适合 API 交互。


🧠 第四步:接入 Kimi 多模态大模型

一切准备就绪,终于要让 AI 登场了!回到 App.vue

1. Prompt 设计:AI 的指挥棒

一个好的 Prompt 决定了产品的生死。我们需要明确告诉 AI:你是谁?你要做什么?输出什么格式?

// src/App.vue
const userPrompt = `
  分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。

  返回JSON 数据:
  {
    "image_discription": "图片描述",
    "representative_word": "图片代表的英文单词",
    "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
    "explaination": "结合图片解释英文单词,段落以Look at ...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句",
    "explanation_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"],
  }
`

Prompt 技巧解析

  • 角色/难度限定更简单的A1~A2的词汇。避免 AI 给你整一个 GRE 词汇,把用户吓跑。
  • 结构化输出 (Structured Output)返回JSON 数据。这是做 AI 应用开发最重要的一点!不要让 AI 跟你聊天,要让它执行指令并返回程序可读的数据。

2. 构建多模态请求

这部分代码是核心中的核心。注意看 messages 里的 content 结构,它不再是简单的字符串,而是一个数组。

// src/App.vue

const update = async (imageDate: string) => {
  // 环境变量处理,安全第一
  const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions';
  
  // ⚠️ 构造请求体
  const body = JSON.stringify({
      model: 'moonshot-v1-8k-vision-preview', // 认准这个 Vision 模型
      messages: [
        {
          role: 'user',
          content: [
            // 第一部分:图片
            {
              type: 'image_url',
              image_url: {
                url: imageDate // 这里就是我们刚才转好的 Base64
              }
            }, 
            // 第二部分:Prompt 文本
            {
              type: 'text',
              text: userPrompt
            }
          ]
        }
      ],
      stream: false // 咱们演示简单点,先不用流式输出
    })
    
    // ... fetch 请求 ...
}

3. 处理响应

AI 返回的是字符串,我们需要把它变回 JSON 对象,赋值给响应式变量,驱动 UI 更新。

  const data = await response.json();
  // Kimi 返回的 content 是一个字符串形式的 JSON,需要 parse 一下
  const replyData = JSON.parse(data.choices[0].message.content);
  
  // 响应式更新 UI
  word.value = replyData.representative_word;
  sentence.value = replyData.example_sentence;
  // ...

🔊 第五步:让应用“开口说话” —— TTS 集成

没有声音的单词 App 是没有灵魂的。我们使用火山引擎的 TTS 服务。这块逻辑比较复杂,我们把它封装在 src/lib/audio.ts 中。

1. 那些坑爹的鉴权配置 💣

TTS 的 API 调用通常比较繁琐。注意看下面的 Header 配置:

// src/lib/audio.ts

const headers={
    'Content-Type': 'application/json',
    // 🚨 高能预警!🚨
    // 这里的 Bearer 后面居然有个分号 ";" 
    // 这是火山引擎 API 的特殊癖好,少了这个分号,401 报错让你怀疑人生
    'Authorization': `Bearer;${token}` 
}

2. 处理二进制音频流

API 返回的是音频的二进制数据(通常经过 Base64 编码)。我们需要把它转换成浏览器能播放的 URL。

// src/lib/audio.ts

// 这是一个通用的 Base64 转 Blob URL 的函数
function createBlobURL(base64AudioData: string): string {
    // 1. 解码 Base64 -> 二进制字符串
    const byteCharacters = atob(base64AudioData);
    // 2. 转成字节数组
    const byteArrays: number[] = [];
    for (let offset = 0; offset < byteCharacters.length; offset++) {
        byteArrays.push(byteCharacters.charCodeAt(offset));
    }
    // 3. 创建 Blob 对象 (MIME 类型很重要)
    const audioBlob = new Blob([new Uint8Array(byteArrays)], { 
        type: 'audio/mp3' 
    });
    // 4. 生成临时的 Blob URL (例如: blob:http://localhost:3000/xxxx-xxxx)
    return URL.createObjectURL(audioBlob);
}

3. 解决跨域问题 (CORS)

由于我们在本地开发 (localhost),直接请求火山引擎的 API 会遇到跨域拦截。这时候就需要 Vite 的代理功能出马了。

// vite.config.ts
export default defineConfig({
  plugins: [vue()],
  server: { 
    proxy: { 
      // 这里的 /tts 是一个暗号
      '/tts': 
      { 
        target: 'https://openspeech.bytedance.com', // 真实的目标地址
        changeOrigin: true, // 欺骗服务器:我是从你家发出的请求
        rewrite: path => path.replace(/^\/tts/, ''), // 发送前把暗号 /tts 去掉
      } 
    }, 
  },
})

这样,我们在前端请求 /tts/api/v1/... 时,Vite 就会偷偷帮我们转发到 https://openspeech.bytedance.com/api/v1/...,完美绕过浏览器同源策略。

4. UI 上的小细节

回到 PictureCard.vue,我们希望只有当音频生成好了之后,才显示播放按钮。

<!-- src/components/PictureCard.vue -->
<!-- v-if="audio":只有当 audio 变量有值(URL)时,这个 div 才会被渲染 -->
<div class="playAudio" v-if="audio" @click="playAudio">
    <img :src="voiceIcon" alt="play" width="20px"/>
</div>

🚀 总结与展望

至此,我们的“拍照记单词”应用就跑通了!

回顾一下我们做了什么:

  1. 产品侧:定义了一个“生存级”的单词应用场景。
  2. Vue3 侧:掌握了 setup 语法糖、Props/Emits 通信、ref 响应式。
  3. 交互侧:用 label 替换原生 input 优化上传体验。
  4. AI 侧
    • Prompt 工程化(JSON 输出)。
    • Multimodal API 格式(Image + Text)。
    • 前端图片转 Base64。
    • TTS 音频流处理与 Blob URL。

这只是一个开始。

你可以继续扩展:

  • 增加数据库:把用户拍过的单词存起来,生成生词本。
  • 优化模型:尝试让 AI 返回更详细的语法分析。
  • 出海实战:打包成 PWA 或用 Capacitor 封装成 App,真正去国外用一用。

在 AI 时代,技术栈只是工具箱里的锤子和扳手。你的创意、你对生活的观察、你解决问题的决心,才是最核心的竞争力。

Vibe Coding, Happy Coding! 🎉


附录:文中涉及的完整代码可在项目仓库中查看。建议读者手动敲一遍,感受数据在组件与 AI 模型之间流动的过程。