从零开始:用Vue 3和Coze工作流打造AI冰球运动员生成器
# 从零开始:用Vue 3和Coze工作流打造AI冰球运动员生成器
之前我使用coze生成了工作流内容是:为冰球协会开发一个AI应用,让会员上传自己的照片,一键生成酷炫的冰球运动员形象。用户觉得有趣还可以分享到朋友圈,达到传播效果。 但我每次想使用它时都得打开coze使用,这非常麻烦,现在我想把它完善一些,让我们在浏览器就能直接调用它,现在我就使用vue让我们再浏览器端就能直接使用了,接下来我将与大家分析我的代码历程
关于Coze工作流的具体配置,可以参考我之前发表的文章:《保姆级教程:用 Coze 打造宠物→冰球运动员拟人化工作流》。链接我放在了文章末尾
第一章:认识我们的项目
1.1 项目故事
想象一下,你是冰球协会的网站开发者。协会想做一个有趣的活动:会员上传自己的照片,AI就能生成一张冰球运动员的形象照。用户觉得好玩就会分享到朋友圈,为协会带来更多关注。
1.2 项目功能
我们的应用需要实现:
- 上传照片:用户选择自己的照片
- 预览照片:立即看到选中的照片
- 选择参数:队服颜色、号码、位置等
- AI生成:调用Coze平台的工作流
- 显示结果:展示生成的冰球运动员形象
1.3 最终效果预览
第二章:Vue基础概念小课堂
在开始写代码前,我先给大家讲几个重要的概念。这些是Vue的基石,一定要理解透彻。
2.1 什么是响应式数据?
场景引入:想象你有一个计分板,比分变化时,所有人都能看到新的比分。在传统JavaScript中,你需要手动更新每个显示比分的地方:
// 传统方式:手动更新
let score = 0;
document.getElementById('score').innerHTML = score;
// 比分变了
score = 1;
document.getElementById('score').innerHTML = score; // 又要手动更新
// 如果页面上有10处显示比分,就要更新10次!
Vue的解决方式:
// Vue方式:数据是响应式的
const score = ref(0); // 创建响应式数据
// 比分变了
score.value = 1; // 页面上所有显示score的地方自动更新!
解释:ref就像一个魔法盒子,把普通数据包装起来。Vue会时刻监视这个盒子里面的变化,一旦变化就自动更新页面。
2.2 模板语法三兄弟
Vue的模板里有三种重要的语法,我们通过对比来理解:
<!-- 1. 文本插值 {{}}:专门显示文字内容 -->
<div>用户姓名:{{ username }}</div>
<p>年龄:{{ age }}岁</p>
<h1>欢迎,{{ fullName }}!</h1>
<!-- 2. 属性绑定 ::动态设置标签属性 -->
<img :src="avatarUrl" />
<a :href="linkUrl">点击访问</a>
<div :class="boxClass"></div>
<!-- 3. 条件渲染 v-if:控制显示隐藏 -->
<div v-if="isLoading">加载中...</div>
<div v-else>加载完成</div>
2.3 重要知识点:为什么不能用{{}}绑定src?
错误示范:
<!-- 同学A的错误写法 -->
<img src="{{ imageUrl }}" />
<!-- 同学B的错误写法 -->
<img src="imageUrl" />
正确写法:
<!-- 正确的写法 -->
<img :src="imageUrl" />
我使用生活例子解释:
想象你在填写一份表格:
-
{{}}就像表格的内容区域,你可以在里面填写文字 -
:就像表格的选项框,你需要勾选或填写特定的选项 - HTML属性就像表格的标题栏,是固定的
<!-- 类比理解 -->
<div>姓名:{{ name }}</div> <!-- 这是内容区域,可以填文字 -->
<img src="logo.png" /> <!-- 这是固定标题,不能动态 -->
<img :src="userPhoto" /> <!-- 这是选项框,可以动态选择 -->
技术原理解释:
- 浏览器解析HTML时,
src="xxx"期望得到一个字符串 - 如果写
src="{{url}}",浏览器看到的是字符串"{{url}}" -
:是Vue的特殊标记,告诉Vue:"这个属性的值要从JavaScript变量获取"
第三章:开始写代码 - 搭建页面结构
3.1 创建项目
打开终端,跟着我一步步操作:
# 创建项目
npm create vue@latest ice-hockey-ai
# 进入项目目录
cd ice-hockey-ai
# 安装依赖
npm install
# 启动开发服务器
npm run dev
3.2 编写页面模板
打开 src/App.vue,我们先搭建页面结构:
<template>
<div class="container">
<!-- 左侧:输入区域 -->
<div class="input-panel">
<h2>上传你的照片</h2>
<!-- 文件上传 -->
<div class="upload-area">
<input
type="file"
ref="fileInput"
accept="image/*"
@change="handleFileSelect"
/>
<p>支持jpg、png格式</p>
</div>
<!-- 预览图区域 -->
<div class="preview" v-if="previewUrl">
<img :src="previewUrl" alt="预览图" />
</div>
<!-- 参数设置 -->
<div class="settings">
<h3>选择参数:</h3>
<div class="setting-item">
<label>队服号码:</label>
<input type="number" v-model="number" />
</div>
<div class="setting-item">
<label>队服颜色:</label>
<select v-model="color">
<option value="红">红色</option>
<option value="蓝">蓝色</option>
<option value="白">白色</option>
</select>
</div>
<div class="setting-item">
<label>位置:</label>
<select v-model="position">
<option value="0">守门员</option>
<option value="1">前锋</option>
<option value="2">后卫</option>
</select>
</div>
<div class="setting-item">
<label>持杆手:</label>
<select v-model="hand">
<option value="0">左手</option>
<option value="1">右手</option>
</select>
</div>
<div class="setting-item">
<label>风格:</label>
<select v-model="style">
<option value="写实">写实</option>
<option value="乐高">乐高</option>
<option value="国漫">国漫</option>
</select>
</div>
</div>
<!-- 生成按钮 -->
<button @click="generate" :disabled="isGenerating">
{{ isGenerating ? '生成中...' : '生成形象' }}
</button>
</div>
<!-- 右侧:输出区域 -->
<div class="output-panel">
<h2>生成结果</h2>
<div class="result-box">
<img :src="resultUrl" v-if="resultUrl" />
<div v-else-if="status" class="status">{{ status }}</div>
<div v-else class="placeholder">点击生成按钮开始</div>
</div>
</div>
</div>
</template>
3.3 添加基础样式
<style scoped>
.container {
display: flex;
min-height: 100vh;
font-family: 'Microsoft YaHei', sans-serif;
}
.input-panel {
width: 350px;
padding: 20px;
background: #f5f5f5;
border-right: 1px solid #ddd;
}
.output-panel {
flex: 1;
padding: 20px;
}
.upload-area {
margin: 20px 0;
padding: 20px;
background: white;
border: 2px dashed #ccc;
text-align: center;
}
.preview {
margin: 20px 0;
}
.preview img {
width: 100%;
max-height: 200px;
object-fit: contain;
}
.settings {
margin: 20px 0;
}
.setting-item {
margin: 10px 0;
display: flex;
align-items: center;
}
.setting-item label {
width: 80px;
}
.setting-item input,
.setting-item select {
flex: 1;
padding: 5px;
}
button {
width: 100%;
padding: 10px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.result-box {
width: 400px;
height: 400px;
border: 2px solid #ddd;
display: flex;
justify-content: center;
align-items: center;
}
.result-box img {
width: 100%;
height: 100%;
object-fit: contain;
}
.status {
color: #007bff;
}
.placeholder {
color: #999;
}
</style>
第四章:JavaScript部分 - 让页面动起来
4.1 导入和定义数据
<script setup>
import { ref } from 'vue'
// ref是Vue 3中创建响应式数据的函数
// 什么是响应式?数据变化,页面自动更新
// 1. 用户选择的参数
const number = ref(10) // 队服号码,默认10号
const color = ref('红') // 队服颜色,默认红色
const position = ref('0') // 位置,默认守门员
const hand = ref('0') // 持杆手,默认左手
const style = ref('写实') // 风格,默认写实
// 2. UI状态
const fileInput = ref(null) // 引用文件输入框
const previewUrl = ref('') // 预览图地址
const resultUrl = ref('') // 生成结果图地址
const status = ref('') // 状态信息
const isGenerating = ref(false) // 是否正在生成
</script>
4.2 实现图片预览
这是很多人觉得难的地方,我一步步讲解:
<script setup>
// ... 前面的代码
// 处理文件选择
const handleFileSelect = () => {
// 1. 获取文件输入元素
const input = fileInput.value
// 2. 检查用户是否真的选了文件
if (!input.files || input.files.length === 0) {
return // 没选文件就退出
}
// 3. 获取选中的文件
const file = input.files[0]
console.log('选中的文件:', file)
// 4. 创建FileReader对象读取文件
const reader = new FileReader()
// 5. 告诉FileReader我们要把文件读成DataURL格式
// DataURL是什么?就是把图片变成一长串字符,可以直接用在img标签的src上
reader.readAsDataURL(file)
// 6. 设置读取完成后的处理函数
reader.onload = (e) => {
// e.target.result 就是读取到的DataURL
previewUrl.value = e.target.result
console.log('预览图已生成')
}
}
</script>
4.3 深入讲解:为什么是files[0]?
你可能有疑惑:为什么用files[0],不是files[1]或别的?
接下来我给你解答:
// 假设你选择了文件,在控制台看看
console.log(input.files)
// 输出结果:
FileList {
0: File {name: "myphoto.jpg", size: 12345},
length: 1
}
// 看到没?files是一个类数组对象,里面只有一个元素
// 所以用[0]获取第一个(也是唯一一个)文件
// 如果想支持多选,HTML要加multiple属性
<input type="file" multiple />
// 这时files里可能有多个文件
for(let i = 0; i < input.files.length; i++) {
console.log(`第${i+1}个文件:`, input.files[i])
}
4.4 添加API配置
<script setup>
// ... 前面的代码
// 老师讲解:这些配置要放在.env文件里,不要直接写在代码中
// 创建.env文件,写上:
// VITE_PAT_TOKEN=你的token
// VITE_WORKFLOW_ID=你的工作流ID
const patToken = import.meta.env.VITE_PAT_TOKEN
const workflowId = import.meta.env.VITE_WORKFLOW_ID
// Coze平台的接口地址
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'
</script>
4.5 实现文件上传功能
<script setup>
// ... 前面的代码
// 上传文件到Coze平台
const uploadFile = async () => {
// 1. 创建FormData对象
// FormData是浏览器自带的,用于模拟表单提交
const formData = new FormData()
// 2. 获取文件
const input = fileInput.value
if (!input.files || input.files.length === 0) {
throw new Error('请先选择图片')
}
// 3. 把文件添加到FormData
// 'file'是字段名,要和Coze平台要求的保持一致
formData.append('file', input.files[0])
// 4. 发送上传请求
// fetch是现代浏览器内置的发送HTTP请求的方法
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}` // 身份验证
},
body: formData // 把表单数据作为请求体
})
// 5. 解析返回的JSON
const result = await response.json()
console.log('上传结果:', result)
// 6. 检查是否成功
// Coze API规范:code为0表示成功
if (result.code !== 0) {
throw new Error(result.msg || '上传失败')
}
// 7. 返回文件ID,后续要用
return result.data.id
}
</script>
4.6 实现主要生成功能
<script setup>
// ... 前面的代码
// 主要的生成函数
const generate = async () => {
try {
// 防止重复点击
if (isGenerating.value) return
// 开始生成
isGenerating.value = true
status.value = '正在上传图片...'
// 第一步:上传图片获取file_id
const fileId = await uploadFile()
status.value = '图片上传成功,AI正在绘制...'
// 第二步:准备工作流参数
const parameters = {
// Coze要求picture字段是JSON字符串
picture: JSON.stringify({
file_id: fileId
}),
style: style.value,
uniform_color: color.value,
uniform_number: number.value,
position: position.value,
shooting_hand: hand.value
}
console.log('工作流参数:', parameters)
// 第三步:调用工作流
const response = await fetch(workflowUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
workflow_id: workflowId,
parameters: parameters
})
})
const result = await response.json()
console.log('工作流结果:', result)
// 第四步:检查结果
if (result.code !== 0) {
throw new Error(result.msg || '生成失败')
}
// 第五步:解析数据
// 注意:result.data是JSON字符串,需要解析
const data = JSON.parse(result.data)
console.log('解析后的数据:', data)
// 第六步:显示结果
status.value = ''
resultUrl.value = data.ouput // 注意字段名是ouput
} catch (error) {
// 出错时显示错误信息
status.value = '错误:' + error.message
console.error('生成失败:', error)
} finally {
// 不管成功失败,都要结束生成状态
isGenerating.value = false
}
}
</script>
第五章:我来讲解异步编程
5.1 为什么要用async/await?
可能有人疑惑:为什么代码里到处都是async/await,这是什么意思?
你可以想象你去餐厅吃饭:
// 同步方式(不用await):就像你自己做饭
function cook() {
// 必须一步一步来,不能同时做别的
washVegetables() // 洗菜
cutVegetables() // 切菜
cookVegetables() // 炒菜
// 做完才能吃饭
}
// 异步方式(用await):就像去餐厅点菜
async function eat() {
// 点完菜就可以玩手机
await order() // 服务员去后厨做菜
// await表示"等待",等菜好了服务员会端上来
eat() // 菜来了就可以吃
}
5.2 async/await的实际应用
// 不用await的写法(回调地狱)
function uploadFile() {
fetch(url)
.then(response => response.json())
.then(data => {
fetch(workflowUrl)
.then(response => response.json())
.then(result => {
console.log('最终结果:', result)
})
})
}
// 用await的写法(清晰易懂)
async function uploadFile() {
const response = await fetch(url)
const data = await response.json()
const result = await fetch(workflowUrl)
const final = await result.json()
console.log('最终结果:', final)
}
第六章:重要知识点
6.1 关于v-model
v-model是Vue提供的一个语法糖,让表单处理变得简单:
<!-- v-model的写法 -->
<input v-model="username" />
<!-- 等价于 -->
<input
:value="username"
@input="username = $event.target.value"
/>
<!-- 所以v-model其实是两件事: -->
<!-- 1. 把数据绑定到输入框(显示) -->
<!-- 2. 监听输入事件更新数据(收集) -->
6.2 关于ref
ref为什么有时候是数据,有时候是DOM元素?
// ref两种用途:
// 1. 创建响应式数据
const count = ref(0) // count是个响应式数据
count.value = 1 // 修改数据
// 2. 获取DOM元素
const inputRef = ref(null) // 初始null
// 在模板中:<input ref="inputRef" />
// 组件挂载后,inputRef.value就是那个input元素
// 为什么两种用法?
// 因为ref就像一个"万能引用":
// - 可以引用数据(响应式数据)
// - 可以引用DOM元素(DOM引用)
// - 可以引用组件(组件实例)
第七章:完整代码整合
<template>
<div class="container">
<!-- 左侧面板 -->
<div class="input-panel">
<h2>⚡ AI冰球运动员生成器</h2>
<!-- 上传区域 -->
<div class="upload-area">
<input
type="file"
ref="fileInput"
accept="image/*"
@change="handleFileSelect"
/>
<p>支持jpg、png格式</p>
</div>
<!-- 预览图 -->
<div class="preview" v-if="previewUrl">
<img :src="previewUrl" alt="预览图" />
</div>
<!-- 参数设置 -->
<div class="settings">
<h3>选择参数:</h3>
<div class="setting-item">
<label>队服号码:</label>
<input type="number" v-model="number" />
</div>
<div class="setting-item">
<label>队服颜色:</label>
<select v-model="color">
<option value="红">红色</option>
<option value="蓝">蓝色</option>
<option value="白">白色</option>
<option value="黑">黑色</option>
</select>
</div>
<div class="setting-item">
<label>位置:</label>
<select v-model="position">
<option value="0">守门员</option>
<option value="1">前锋</option>
<option value="2">后卫</option>
</select>
</div>
<div class="setting-item">
<label>持杆手:</label>
<select v-model="hand">
<option value="0">左手</option>
<option value="1">右手</option>
</select>
</div>
<div class="setting-item">
<label>风格:</label>
<select v-model="style">
<option value="写实">写实</option>
<option value="乐高">乐高</option>
<option value="国漫">国漫</option>
<option value="日漫">日漫</option>
</select>
</div>
</div>
<!-- 生成按钮 -->
<button
@click="generate"
:disabled="isGenerating"
:class="{ generating: isGenerating }"
>
{{ isGenerating ? '🎨 绘制中...' : '✨ 生成形象' }}
</button>
</div>
<!-- 右侧面板 -->
<div class="output-panel">
<h2>生成结果</h2>
<div class="result-box">
<img :src="resultUrl" v-if="resultUrl" />
<div v-else-if="status" class="status">{{ status }}</div>
<div v-else class="placeholder">
👆 上传照片,选择参数,点击生成
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
// ========== 响应式数据 ==========
const number = ref(10)
const color = ref('红')
const position = ref('0')
const hand = ref('0')
const style = ref('写实')
const fileInput = ref(null)
const previewUrl = ref('')
const resultUrl = ref('')
const status = ref('')
const isGenerating = ref(false)
// ========== API配置 ==========
const patToken = import.meta.env.VITE_PAT_TOKEN
const workflowId = import.meta.env.VITE_WORKFLOW_ID
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'
// ========== 方法 ==========
const handleFileSelect = () => {
const input = fileInput.value
if (!input.files || input.files.length === 0) return
const file = input.files[0]
const reader = new FileReader()
reader.onload = (e) => {
previewUrl.value = e.target.result
}
reader.readAsDataURL(file)
}
const uploadFile = async () => {
const formData = new FormData()
const input = fileInput.value
if (!input.files || input.files.length === 0) {
throw new Error('请先选择图片')
}
formData.append('file', input.files[0])
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}`
},
body: formData
})
const result = await response.json()
if (result.code !== 0) {
throw new Error(result.msg || '上传失败')
}
return result.data.id
}
const generate = async () => {
try {
if (isGenerating.value) return
isGenerating.value = true
status.value = '正在上传图片...'
const fileId = await uploadFile()
status.value = '图片上传成功,AI正在绘制...'
const parameters = {
picture: JSON.stringify({ file_id: fileId }),
style: style.value,
uniform_color: color.value,
uniform_number: number.value,
position: position.value,
shooting_hand: hand.value
}
const response = await fetch(workflowUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
workflow_id: workflowId,
parameters: parameters
})
})
const result = await response.json()
if (result.code !== 0) {
throw new Error(result.msg || '生成失败')
}
const data = JSON.parse(result.data)
status.value = ''
resultUrl.value = data.ouput
} catch (error) {
status.value = '错误:' + error.message
console.error('生成失败:', error)
} finally {
isGenerating.value = false
}
}
</script>
<style scoped>
/* 样式代码同上,为了节省篇幅就不重复了 */
/* 在实际项目中,把前面的样式复制到这里 */
</style>
- 效果图
第八章:常见错误与解决方法
错误1:忘记写.value
// 错误
const count = ref(0)
count++ // ❌ 不行,count是对象
// 正确
count.value++ // ✅ 要操作.value
错误2:v-model绑定错误
<!-- 错误 -->
<input v-model="ref(10)" /> <!-- ❌ v-model要绑定变量,不是值 -->
<!-- 正确 -->
<input v-model="number" /> <!-- ✅ 绑定定义的变量 -->
错误3:忘记await
// 错误
const fileId = uploadFile() // ❌ 返回Promise,不是结果
// 正确
const fileId = await uploadFile() // ✅ 等待结果
总结
整个项目虽然简单,但涵盖了Vue开发的核心概念,希望能帮助初学者快速上手。
如果你对Coze工作流的配置感兴趣,可以参考我的以前文章:《 保姆级教程:用 Coze 打造宠物→冰球运动员拟人化工作流》