用 Vue 3 + Coze API 快速打造一个爆款 AI 趣味应用:把宠物照变成冰球运动员!
大家好,今天和大家聊的是基于 Vue 3 + Composition API + Coze Workflow 做了一个小而美的 AI 趣味应用——“AI 冰球运动员生成器”。
核心玩法非常简单:用户上传一张宠物的照片,选择队服颜色、号码、位置、持杆手、艺术风格后,一键生成一张“穿冰球装备”的酷炫形象图。生成结果特别适合分享到朋友圈或小红书,传播性极强。
项目整体架构
前端(Vue 3 + Vite) → Coze 文件上传 API → Coze Workflow 执行
↑ (获取 file_id) (传入 file_id + 参数)
└─────────────── fetch ───────────────────────────────┘
↓
返回最终生成的图片 URL
核心流程只有两步:
- 先把用户上传的图片通过 Coze 的
/v1/files/upload接口上传,拿到file_id。 - 把
file_id和其他参数(队服颜色、号码、位置等)一起传给事先配置好的 Workflow,Workflow 内部调用大模型 + 图像生成模型完成图生图任务,最终返回生成图片的 URL。
整个前端只需要维护几个响应式状态和两个异步函数,就能实现完整的交互体验。
项目初始化与环境准备
npm init vite
cd ai-hockey
npm install
npm run dev
在项目根目录创建 .env 文件(Vite 默认支持):
VITE_PAT_TOKEN=pat_xxxxxxxxxxxxxx # Coze 个人访问令牌(Personal Access Token)
提醒:PAT 令牌在 Coze 控制台 → 个人中心 → API 访问中生成,记得勾选「Workflow 执行」和「文件上传」权限。千万不要把 .env 文件提交到 GitHub!
核心组件:App.vue 完整解析
下面我们逐段拆解优化后的
App.vue 代码,我会把每一段的关键逻辑、为什么要这样写、容易踩的坑都讲清楚。
1. 模板结构优化
<template>
<div class="container">
<!-- 左侧输入区 -->
<div class="input">
<div class="file-input">
<input
type="file"
ref="uploadImage"
accept="image/*"
@change="updateImagePreview"
required
/>
</div>
<!-- 上传后立即预览 -->
<img :src="imgPreview" alt="预览图" class="preview" v-if="imgPreview" />
<!-- 参数设置区 -->
<div class="settings">
<div class="selection">
<label>队服编号:</label>
<input type="number" min="0" max="99" v-model.number="uniform_number" />
</div>
<div class="selection">
<label>队服颜色:</label>
<select v-model="uniform_color">
<option value="红">红</option>
<option value="蓝">蓝</option>
<option value="粉">粉</option>
<option value="黑">黑</option>
</select>
</div>
</div>
<div class="settings">
<div class="selection">
<label>位置:</label>
<select v-model="position">
<option value="0">守门员</option>
<option value="1">前锋</option>
<option value="2">后卫</option>
</select>
</div>
<div class="selection">
<label>持杆:</label>
<select v-model="shooting_hand">
<option value="0">左手</option>
<option value="1">右手</option>
</select>
</div>
<div class="selection">
<label>风格:</label>
<select v-model="style">
<option value="写实">写实</option>
<option value="乐高">乐高</option>
<option value="国漫">国漫</option>
<option value="日漫">日漫</option>
<option value="油画">油画</option>
<option value="涂鸦">涂鸦</option>
<option value="素描">素描</option>
</select>
</div>
</div>
<div class="generate">
<button @click="generate" :disabled="isGenerating">
{{ isGenerating ? '生成中...' : '生成' }}
</button>
</div>
</div>
<!-- 右侧输出区 -->
<div class="output">
<div class="generated">
<img :src="imgUrl" alt="生成结果" v-if="imgUrl" />
<div class="status" v-if="status">{{ status }}</div>
</div>
</div>
</div>
</template>
优化点说明:
- 给数字输入加了
min/max,防止用户输入 999 这种不合理号码。 - 按钮在生成过程中禁用并显示“生成中...”,避免重复点击。
- 预览图加了
class="preview"方便控制大小。 - 状态提示单独用
.status类,便于后续加 loading 动画。
2. Script Setup 核心逻辑
<script setup>
import { ref, computed, onMounted } from 'vue'
const patToken = import.meta.env.VITE_PAT_TOKEN
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'
const workflow_id = '7584046150103547958' // 替换成你自己的 workflow id
// 参数状态
const uniform_number = ref(10)
const uniform_color = ref('红')
const position = ref('0') // 注意:这里用字符串更安全
const shooting_hand = ref('0')
const style = ref('写实')
// UI 状态
const status = ref('')
const imgUrl = ref('')
const imgPreview = ref('')
const uploadImage = ref(null)
const isGenerating = computed(() => !!status) // 有 status 文字就视为生成中
// 图片本地预览(关键!提升用户体验)
const updateImagePreview = () => {
const input = uploadImage.value
if (!input?.files?.length) {
imgPreview.value = ''
return
}
const file = input.files[0]
const reader = new FileReader()
reader.onload = (e) => {
imgPreview.value = e.target.result
}
reader.readAsDataURL(file)
}
// 第一步:上传图片到 Coze 获取 file_id
const uploadFile = async () => {
const input = uploadImage.value
if (!input?.files?.length) {
status.value = '请先选择一张图片'
return null
}
const formData = new FormData()
formData.append('file', input.files[0])
try {
const res = await fetch(uploadUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${patToken}`
// 注意:FormData 时不要手动设置 Content-Type,浏览器会自动加上 boundary
},
body: formData
})
const ret = await res.json()
if (ret.code !== 0) {
status.value = `上传失败:${ret.msg}`
return null
}
return ret.data.id
} catch (err) {
status.value = '网络错误,请检查网络后重试'
console.error(err)
return null
}
}
// 第二步:调用 Workflow 生成图片
const generate = async () => {
if (!imgPreview.value) {
status.value = '请先上传一张图片'
return
}
status.value = '图片上传中...'
const file_id = await uploadFile()
if (!file_id) return
status.value = '上传成功,正在生成图片(可能需要 20-40 秒)...'
const parameters = {
picture: JSON.stringify({ file_id }),
style: style.value,
uniform_color: uniform_color.value,
uniform_number: uniform_number.value,
position: position.value,
shooting_hand: shooting_hand.value
}
try {
const res = await fetch(workflowUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${patToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
workflow_id,
parameters
})
})
const ret = await res.json()
if (ret.code !== 0) {
status.value = `生成失败:${ret.msg}`
return
}
// Coze Workflow 返回的 data 是字符串,需要解析
const data = JSON.parse(ret.data)
imgUrl.value = data.data // 假设 workflow 最后输出的是图片 URL
status.value = '生成成功!'
setTimeout(() => { status.value = '' }, 3000) // 3秒后自动隐藏成功提示
} catch (err) {
status.value = '调用失败,请检查网络或稍后重试'
console.error(err)
}
}
onMounted(() => {
console.log('组件已挂载,可访问 DOM')
})
</script>
关键知识点
1. FileReader + readAsDataURL 的妙用
这是提升用户体验的黄金技巧!很多新手直接等上传完成再显示图片,用户会觉得“卡住了”。而 FileReader 能在文件还未上传时就把图片转成 base64 显示预览,几乎是即时响应。
易错提醒:
- 一定要在
reader.onload里赋值,不要在readAsDataURL后面同步取值(它是异步的!) - 大图片 base64 会很长,但现代浏览器都能轻松处理 10MB 以内的照片,不用担心。
2. FormData 上传时不要手动设置 Content-Type
很多人会习惯性地写:
headers: {
'Content-Type': 'multipart/form-data'
}
这是错误的!
为什么?
因为真正的 Content-Type 应该是这样的:
text
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
那个 boundary 是一串随机字符串,用来分隔不同字段。只有浏览器自动生成才正确。
如果你手动写了,boundary 就没了,服务器根本拆不开你的“包裹”,直接上传失败。
正确做法:只传 Authorization,Content-Type 完全不写。
3. Coze Workflow 参数传递技巧
Coze 要求 parameters 里的值必须是字符串。如果你直接传对象:
picture: { file_id: 'xxx' }
会报错“参数格式不合法”。所以必须:
picture: JSON.stringify({ file_id })
在 Workflow 里再用 JSON 解析节点还原成对象。
4. 返回数据双重 JSON.parse 的坑
Coze 的 /workflow/run 接口返回的结构是:
{
"code": 0,
"msg": "success",
"data": "{\"data\":\"https://xxx.jpg\"}"
}
注意 ret.data 是一个字符串化的 JSON!所以必须:
const data = JSON.parse(ret.data)
imgUrl.value = data.data
很多人只 parse 一次就直接取 ret.data.data,结果是 undefined。
5. 状态管理与用户体验
我用了 status 一个 ref 就搞定了所有提示:
- “图片上传中...”
- “正在生成(可能需要 20-40 秒)...”
- 错误信息
- “生成成功!”
配合 computed 的 isGenerating 禁用按钮,避免重复提交。
6.核心思维
这个 AI 冰球运动员生成器项目看起来功能挺酷,有文件上传、有 API 调用、有图片预览、有生成按钮……但如果剥开所有表象,最核心的思维其实就是 Vue 3 的响应式数据状态管理。
整个应用的所有交互、反馈、加载状态、显示隐藏,全都围绕着几个 ref 定义的响应式变量在转。
核心响应式状态一览(就是项目的“大脑”)
const imgPreview = ref('') // 本地预览图(base64)
const status = ref('') // 当前提示文字(上传中/生成中/错误/成功)
const imgUrl = ref('') // 最终生成的图片 URL
const uniform_number = ref(10) // 队服号码
const uniform_color = ref('红') // 队服颜色
const position = ref('0') // 位置
const shooting_hand = ref('0') // 持杆手
const style = ref('写实') // 艺术风格
这 8 个 ref,就是整个应用的全部状态。没有 Redux、没有 Pinia、没有 Vuex,就靠这几个小 ref,驱动了所有 UI 变化。
响应式状态如何驱动 UI(模板直接绑定)
<img :src="imgPreview" v-if="imgPreview" /> <!-- 状态变 → 预览图出现/消失 -->
<div v-if="status">{{ status }}</div> <!-- 状态变 → 提示文字显示 -->
<img :src="imgUrl" v-if="imgUrl" /> <!-- 状态变 → 生成结果显示 -->
<input v-model="uniform_number" /> <!-- 双向绑定,用户改 → 状态变 -->
<select v-model="style">...</select> <!-- 同上 -->
<button :disabled="!!status">生成</button> <!-- 状态有值 → 按钮禁用 -->
你看,模板里完全没有 if-else 逻辑判断、没有手动操作 DOM,全是声明式地直接绑定到状态上。
只要状态变了,Vue 自动帮你更新对应的 UI。这就是响应式的真正威力!
用户操作 → 状态变化 → UI 自动更新 的完整链路
举几个典型例子:
-
用户选择图片
- 触发
@change="updateImagePreview" - 函数里用 FileReader 读取文件 →
imgPreview.value = base64字符串 - imgPreview 变 →
<img :src="imgPreview">自动显示预览图 - 完全不需要你手动创建 img 元素、设置 src
- 触发
-
用户点击“生成”按钮
- 调用
generate()异步函数 - 第一步:
status.value = '图片上传中...'→ UI 立即显示提示 + 按钮禁用 - 第二步:上传成功 →
status.value = '正在生成中...'→ 提示文字实时更新 - 第三步:生成成功 →
imgUrl.value = 生成的图片URL,同时status.value = '' - imgUrl 变 → 生成结果图自动出现;status 清空 → 提示消失、按钮恢复
- 调用
-
用户修改队服号码
- 输入框 v-model 直接改
uniform_number.value - 下次点击生成时,
parameters.uniform_number自动取最新值 - 不需要额外保存、不需要事件传参
- 输入框 v-model 直接改
为什么这种方式这么牛?
因为它“把什么状态下该显示什么” 这个复杂逻辑,彻底简化成了:
“我只管维护状态,UI 怎么变 Vue 帮我搞定”
你不再需要写:
document.getElementById('preview').src = ...button.disabled = truestatusDiv.innerText = '上传中...'if (success) showResult() else showError()
这些命令式操作全都没了,取而代之的是声明式的模板绑定。
核心思维总结
Vue 3 Composition API 的终极开发思维就是:
- 先思考需要哪些状态(ref)
- 模板直接绑定这些状态(v-model / :src / v-if / {{}})
- 所有操作(用户交互、API 调用)只做一件事:改变这些状态
- UI 自动跟随状态变化更新
状态是单向数据流的源头,一切皆由状态驱动。
这个项目虽然小,但完美体现了这个思维:
- 没有复杂的组件拆分
- 没有状态管理库
- 没有手动 DOM 操作
- 代码清晰、可维护性极高
以后做更大更复杂的项目,这个思维依然适用——只是状态会从几个 ref 变成 Pinia store,但本质没变:状态驱动视图。
项目总结
这个小项目虽然代码量不到 200 行,却完整展示了现代前端 AI 应用的典型开发模式:
- 本地即时预览 → 提升体验
- 分步异步调用第三方 API → 文件上传 + Workflow 执行
- 优雅的状态管理 → 一个 status 搞定所有提示
- 细致错误处理 → 网络错误、API 错误都要友好提示