摘要:在 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 组件化思维与父子通信
我们的应用结构非常简单:
App.vue:主页面,负责统筹全局,调用 AI 接口。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>
🔍 深入解析:defineProps 与 defineEmits
转到子组件 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"> 长得那是相当随意(丑)。在我们的设计稿里,上传按钮应该是一个漂亮的卡片,或者是一个相机图标。怎么破?
核心思路:隐身术 + 替身攻击
- 把真正的
input藏起来 (display: none)。 - 用一个
label标签通过for属性绑定这个 input。 - 点击
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>
🚀 总结与展望
至此,我们的“拍照记单词”应用就跑通了!
回顾一下我们做了什么:
- 产品侧:定义了一个“生存级”的单词应用场景。
- Vue3 侧:掌握了
setup语法糖、Props/Emits通信、ref响应式。 - 交互侧:用
label替换原生input优化上传体验。 - AI 侧:
- Prompt 工程化(JSON 输出)。
- Multimodal API 格式(Image + Text)。
- 前端图片转 Base64。
- TTS 音频流处理与 Blob URL。
这只是一个开始。
你可以继续扩展:
- 增加数据库:把用户拍过的单词存起来,生成生词本。
- 优化模型:尝试让 AI 返回更详细的语法分析。
- 出海实战:打包成 PWA 或用 Capacitor 封装成 App,真正去国外用一用。
在 AI 时代,技术栈只是工具箱里的锤子和扳手。你的创意、你对生活的观察、你解决问题的决心,才是最核心的竞争力。
Vibe Coding, Happy Coding! 🎉
附录:文中涉及的完整代码可在项目仓库中查看。建议读者手动敲一遍,感受数据在组件与 AI 模型之间流动的过程。