前言
最近,我基于 Vue 3 开发了一个有趣的小应用——宠物冰球运动员形象生成器。用户只需上传一张宠物的照片,选择队服颜色、号码、位置、持杆手以及艺术风格,点击“生成”,就能得到一张宠物穿上冰球装备、化身专业运动员的趣味图像。
这个项目灵感来源于冰球协会的活动:让宠物主人上传宠物照片,生成冰球主题的卡通或写实形象,分享到朋友圈,既有趣味性又能增加互动。整个应用的前端使用 Vue 3 + Composition API 实现,后端 AI 能力完全依赖 Coze 平台的工作流(Workflow) 调用,无需自己训练模型或部署复杂的后端服务。
一、项目背景与技术选型
为什么要做这个应用?
- 宠物经济火热,萌宠内容永远有流量。
- 冰球在国内虽小众,但装备帅气、动作酷炫,结合宠物能产生强烈的反差萌。
- AI 图像生成技术成熟,用户对个性化生成内容需求强烈。
- 想做一个“即传即生成”的轻量级 Web 应用,适合分享到微信、朋友圈。
技术选型
-
前端框架:Vue 3 + Composition API +
- 代码组织更清晰,响应式逻辑集中
- Vite 构建,开发体验极佳
-
UI:纯手写 CSS,无第三方 UI 库,保持轻量
-
AI 能力:Coze(扣子)平台的工作流
- Coze 是字节跳动旗下的国内 AI 应用开发平台,类似 LangChain + Agent 的可视化搭建方式
- 支持直接调用大模型(如豆包、GPT、Claude 等)以及多种插件
- 提供 Workflow API,可将复杂多节点逻辑封装成一个 HTTP 接口
- 优势:无需服务器,调用简单,成本低,国内访问速度快
为什么不用 Midjourney 或 Stable Diffusion WebUI?
- 需要用户自己操作 Discord 或部署服务器,门槛高
- Coze 工作流可以完美结合用户上传的图片 + 结构化参数,生成更可控的结果
二、项目整体架构
text
用户浏览器
│
├── Vue3 前端(上传图片 + 表单参数)
│ │
│ ├── 上传图片 → Coze 文件上传 API → 获取 file_id
│ └── 参数(号码、颜色、位置、风格等)
│
└── 调用 Coze Workflow Run API
│
└── Coze 工作流内部节点:
├── 图片理解节点(识别宠物主体)
├── 多轮提示词工程(Prompt Engineering)
├── 文生图 / 图生图模型(豆包·绘图 / Flux 等)
└── 输出最终图像 URL
核心流程只有两步:
- 前端先把用户上传的图片上传到 Coze 云端,获取 file_id
- 把 file_id + 其他参数一起传给 Workflow,触发执行,返回生成好的图片 URL
三、Vue 前端代码详解
项目使用 Vite + Vue 3 创建,核心文件只有 App.vue。
1. 模板结构(template)
<template>
<div class="container">
<!-- 左侧输入区 -->
<div class="input">
<!-- 文件上传 -->
<div class="file-input">
<input type="file" ref="uploadImage" accept="image/*" @change="updateImageData" required />
</div>
<!-- 上传后本地预览 -->
<img :src="imgPreview" alt="" v-if="imgPreview" class="preview" />
<!-- 参数设置 -->
<div class="settings">
<div class="selection">
<label>队服编号:</label>
<input type="number" v-model="uniform_number" />
</div>
<div class="selection">
<label>队服颜色:</label>
<select v-model="uniform_color">...</select>
</div>
<!-- 位置、持杆手、风格类似 -->
</div>
<!-- 生成按钮 -->
<div class="generate">
<button @click="generate">生成</button>
</div>
</div>
<!-- 右侧输出区 -->
<div class="output">
<div class="generated">
<img :src="imgUrl" v-if="imgUrl" />
<div v-if="status">{{ status }}</div>
</div>
</div>
</div>
</template>
布局采用 flex 横向排列,左侧固定宽度输入区,右侧占满剩余空间用于展示结果。
2. 核心响应式状态(script setup)
const uniform_number = ref(10)
const uniform_color = ref('红')
const position = ref(0) // 0:守门员 1:前锋 2:后卫
const shooting_hand = ref(0) // 0:左手 1:右手
const style = ref('写实')
const status = ref('') // 状态提示:上传中、生成中...
const imgUrl = ref('') // 最终生成的图片 URL
const imgPreview = ref('') // 本地预览 URL
const uploadImage = ref(null) // 文件输入 DOM 引用
所有用户可控参数都用 ref 声明,天然响应式。
3. 图片本地预览实现
这是提升用户体验的关键点:用户选图后立即看到预览,避免“上传了但看不到”的焦虑。
const updateImageData = () => {
const input = uploadImage.value
if (!input.files || input.files.length === 0) return
const file = input.files[0]
const reader = new FileReader()
reader.readAsDataURL(file) // 异步读取为 base64
reader.onload = (e) => {
imgPreview.value = e.target.result // 直接赋值给 img src
}
}
FileReader 是 HTML5 引入的一个非常重要的 Web API,它允许 Web 应用程序异步读取用户本地文件系统中的文件内容(通常是通过 选择的图片、文本、视频等文件)。最常见的场景就是在上传文件前,先在页面中预览图片、读取文本内容,或者处理 CSV/JSON 等数据文件,而不需要真正把文件上传到服务器。
3.1 为什么需要 FileReader?
在 HTML5 之前,前端几乎无法直接访问用户本地文件的内容。为了安全考虑,浏览器不允许 JavaScript 直接读取磁盘文件。
HTML5 引入了 File、Blob 和 FileReader 对象,解决了这个问题:
- 用户通过 或拖拽选择文件后,浏览器会生成一个 File 对象(继承自 Blob)。
- FileReader 专门负责把这个 File 或 Blob 对象读取成开发者需要的格式(如 base64 字符串、文本、ArrayBuffer 等)。
3.2 FileReader 的基本用法
<input type="file" id="fileInput" accept="image/*" />
<img id="preview" />
const input = document.getElementById('fileInput');
const preview = document.getElementById('preview');
input.addEventListener('change', function(e) {
const file = e.target.files[0]; // 获取第一个选择的文件
if (!file) return;
const reader = new FileReader(); // 创建 FileReader 实例
// 读取完成后触发
reader.onload = function(event) {
preview.src = event.target.result; // result 就是读取到的数据
};
// 开始读取:以 DataURL 形式(base64)读取,适合图片预览
reader.readAsDataURL(file);
});
这就是最经典的“上传图片即时预览”实现方式。
3. FileReader 提供的四种读取方法
FileReader 提供了 4 种不同的读取方式,适用于不同场景:
| 方法 | 返回结果类型 | 典型用途 |
|---|---|---|
| readAsDataURL(file) | String(data: 开头的 base64) | 图片、视频预览(最常用) |
| readAsText(file, encoding?) | String(纯文本) | 读取 .txt、.csv、.json、.html 等文本文件 |
| readAsArrayBuffer(file) | ArrayBuffer | 处理二进制数据(如音频、视频、压缩文件) |
| readAsBinaryString(file) | String(原始二进制字符串) | 已废弃,不推荐使用 |
1). readAsDataURL —— 最常用(图片预览)
reader.readAsDataURL(file);
// onload 中:
console.log(event.target.result);
// 输出类似:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
优点:直接可以赋值给 < img src="转存失败,建议直接上传图片文件 " alt="转存失败,建议直接上传图片文件">、、CSS background-image 等。
缺点:base64 编码会让数据体积膨胀约 33%,不适合大文件上传(仅用于预览没问题)。
2). readAsText —— 读取文本文件
reader.readAsText(file, 'UTF-8'); // 可指定编码,默认 UTF-8
reader.onload = function(e) {
const content = e.target.result;
console.log(content); // 直接就是文件文本内容
};
常用于:
- 读取本地 JSON 配置
- 解析 CSV 文件
- 显示 .txt 或 .md 文件内容
3). readAsArrayBuffer —— 处理二进制
reader.readAsArrayBuffer(file);
reader.onload = function(e) {
const arrayBuffer = e.target.result;
const uint8Array = new Uint8Array(arrayBuffer);
// 可以进一步处理二进制数据
};
常用于:
- 音频/视频处理
- 文件分片上传(配合 Blob.slice)
- 使用 File System Access API 或 WebAssembly 处理大文件
4. FileReader 的重要事件
FileReader 实例会触发以下事件:
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
| onloadstart | 开始读取时 | 显示“读取中”进度条 |
| onprogress | 读取过程中(有 progress 事件对象) | 显示读取进度(大文件时有用) |
| onload | 成功读取完成 | 最常用,处理读取结果 |
| onabort | 调用 abort() 中止读取 | 用户取消操作时 |
| onerror | 读取出错 | 显示错误提示 |
| onloadend | 读取结束(无论成功或失败) | 隐藏加载动画 |
5. 取消读取
reader.abort(); // 中止当前读取操作,会触发 onabort 和 onloadend
6. 实际项目中的典型应用
本项目中,就使用了 FileReader 实现图片预览:
const updateImageData = () => {
const file = uploadImage.value.files[0];
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
imgPreview.value = e.target.result; // 响应式更新预览图
};
};
这带来了极佳的用户体验:
- 用户选择图片后立即看到预览
- 不需要等待上传到服务器
- 即使网络慢或生成失败,用户也能确认选对了图片
4. 图片上传到 Coze 云端
Coze 要求先上传文件获取 file_id,才能在工作流中使用。
const uploadFile = async () => {
const formData = new FormData()
const input = uploadImage.value
if (!input.files || input.files.length === 0) return
formData.append('file', input.files[0])
const res = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}`
},
body: formData
})
const ret = await res.json()
if (ret.code !== 0) {
status.value = ret.msg
return
}
return ret.data.id
}
注意:
- 使用 FormData 构造 multipart/form-data 请求体
- 请求头只需携带 Authorization: Bearer <PAT_TOKEN>
- 成功后返回 { data: { id: "xxx" } }
1). 什么是 FormData?
FormData 是一个内置的 JavaScript 对象,用于模拟 HTML 表单的提交数据,特别是当表单的 enctype="multipart/form-data" 时(这就是浏览器原生表单上传文件时使用的格式)。
它可以:
- 存储键值对(字符串或文件)
- 自动处理边界(boundary)和 Content-Type
- 完美支持二进制文件上传(如图片、视频、PDF 等)
2). 为什么需要 FormData?
在早期,我们手动上传文件时可能会这样做(错误示例):
// 错误方式:手动拼接字符串
const body = '------boundary\r\n' +
'Content-Disposition: form-data; name="file"; filename="cat.jpg"\r\n' +
'Content-Type: image/jpeg\r\n\r\n' +
fileContent + '\r\n------boundary--';
这非常麻烦、容易出错,而且需要自己读取文件二进制内容。
而使用 FormData,一切都变得简单:
const formData = new FormData();
formData.append('file', file); // file 是 File 对象
formData.append('username', '小明');
浏览器会自动生成正确的 multipart/form-data 格式。
5. 调用 Workflow 执行生成
const generate = async () => {
status.value = '图片上传中...'
const file_id = await uploadFile()
if (!file_id) return
status.value = '图片上传成功,正在生成...'
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
}
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
}
const data = JSON.parse(ret.data) // Workflow 返回的是字符串
imgUrl.value = data.data // 直接是图片 URL
status.value = ''
}
关键点:
-
picture 参数必须是 JSON 字符串格式 { "file_id": "xxx" }
-
Workflow 执行是异步的,但 Coze 的 Run API 是同步阻塞式(最长等待约 60-90 秒),适合这种单次生成场景
-
返回的 ret.data 是工作流自定义输出的 JSON 字符串,需要 JSON.parse
6.本地-Coze-workflow梳理
6.1) 先把本地图片上传到 Coze 云端,获取一个 file_id
用户在页面选择一张宠物照片后,点击“生成”按钮:
- 前端使用 new FormData() 把用户选择的图片文件(File 对象)打包。
- 通过 fetch 发送 POST 请求到 Coze 的文件上传接口: api.coze.cn/v1/files/up…
- 请求头只带授权 token(Authorization: Bearer xxx),不需要设置 Content-Type。
- Coze 服务器收到图片后,会把它保存在云端,并返回一个唯一的 file_id(类似云盘文件 ID)。
这一步的目的是:Coze 工作流只能处理已经上传到它自己云端的文件,不能直接接收本地文件。
6.2) 把 file_id 和表单选项一起传给 Coze 工作流执行
拿到 file_id 后,前端立刻发起第二步请求:
-
构造一个参数对象:
{ picture: JSON.stringify({ file_id: "刚才拿到的id" }), style: "写实", // 用户选择的风格 uniform_color: "红", // 队服颜色 uniform_number: 10, // 队服号码 position: 0, // 位置(0守门员、1前锋、2后卫) shooting_hand: 0 // 持杆手(0左手、1右手) } -
通过 fetch POST 到 Coze 工作流运行接口: api.coze.cn/v1/workflow…
-
请求体包含 workflow_id 和上面的 parameters。
Coze 收到请求后:
- 根据 file_id 找到云端那张图片
- 结合你填写的风格、颜色、号码等参数,在工作流内部进行图像理解、提示词拼接、AI 绘图
- 最终生成新图片并返回图片 URL,前端显示出来。
四、Coze 工作流设计思路
工作流结构:
实际工作流节点设计建议(推荐结构):
-
开始节点:接收参数
- picture(文件类型)
- style、uniform_color、uniform_number、position、shooting_hand(文本类型)
-
图像理解节点(可选但强烈推荐)
- 使用“豆包·图像理解”或“GPT-4o 图像输入”节点
- 输入:用户上传的 picture
- Prompt:要求模型详细描述宠物主体(种类、颜色、姿势、表情等),避免背景干扰
- 输出:主体描述文本 pet_desc
-
提示词工程节点(LLM 节点)
- 使用豆包或 Claude 构建最终 Prompt
- Prompt 模板示例:
text
请根据以下信息生成一张冰球运动员形象图,主体必须是这只宠物,不要改变宠物的品种和外观特征。 宠物描述:{{pet_desc}} 要求: - 穿着{{uniform_color}}色冰球服,胸前号码{{uniform_number}} - 位置:{{position == 0 ? '守门员(戴面具)' : position == 1 ? '前锋' : '后卫'}} - 持杆手:{{shooting_hand == 0 ? '左手' : '右手'}} - 艺术风格:{{style}} - 背景为冰球场,动态姿势,专业运动员感觉 - 高清,细节丰富 -
图像生成节点
- 使用“豆包·绘图”或“Flux”节点(图生图模式)
- 输入图片:原始 picture(作为参考图)
- 输入 Prompt:上一步生成的最终提示词
- 强度(Strength)建议 0.7~0.85,保留宠物原貌同时融入装备
-
结束节点
- 输出:生成的图像(类型:图片)
这样设计的好处:
-
通过图像理解先提取宠物主体,避免模型“脑补”错误动物
-
结构化参数精准控制装备细节
-
图生图模式比纯文生图更稳定,宠物相似度更高
五、效果展示
本项目主要是为了体验Coze工作流和前端的交互生成,所以UI界面写的十分简陋,有兴趣的朋友可以自己重新编写一下或者让ai美化一下
<template>
<div class="container">
<div class="input">
<div class="file-input">
<input type="file" ref="uploadImage" accept="image/*" @change="updateImageData" required />
</div>
<img :src="imgPreview" alt="" v-if="imgPreview" />
<div class="settings">
<div class="selection">
<label>队服编号:</label>
<input type="number" v-model="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>
<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">生成</button>
</div>
</div>
<div class="output">
<div class="generated">
<img :src="imgUrl" alt="" v-if="imgUrl">
<div v-if="status">{{ status }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// script + setup 是vue3 最好的代码组织方式
// composition api 组合
// 直接在script setup 中定义函数
// 用于标记一个DOM 对象, 如果要做就用ref
// 未挂载前null, uploadImage tempalte 中的ref 绑定的对象
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 = '7584046163344097334'
const uniform_number = ref(10);
const uniform_color = ref('红');
const position = ref(0);
const shooting_hand = ref(0);
const style = ref('写实');
// 聚焦于数据状态
const status = ref('')// 空 -> 上传中 -> 生成中 -> 生成成功
const imgUrl = ref('')// 生成的图片url
// 生成图片模块
const generate = async () => {
status.value = ''
status.value = '图片上传中...'
const file_id = await uploadFile()
if (!file_id) return
status.value = '图片上传成功,正在生成...'
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
}
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
}
const data = JSON.parse(ret.data)
console.log(data);
status.value = ''
imgUrl.value = data.data
}
// 先上传到coze 服务器
const uploadFile = async () => {
// post 请求体 http 协议
const formData = new FormData() // 请求体的表单提交对象 收集表单提交数据
const input = uploadImage.value
if (!input.files || input.files.length <= 0) return
formData.append('file', input.files[0]) // 请求体里加上了文件
// 向coze发送http请求 上传图片
const res = await fetch(uploadUrl, {
method: 'POST',
headers: {
// 请求头 令牌
'Authorization': `Bearer ${patToken}` // 授权字段
},
body: formData
})
const ret = await res.json()
console.log(ret);
if (ret.code !== 0) { // 如果出错了 code=0表示0问题
status.value = ret.msg // msg 错误消息
return
}
return ret.data.id // 在云端通过id找到上传的图片
}
// 图片预览模块
const uploadImage = ref(null);
const imgPreview = ref(''); // 申明了响应式对象
// null -> dom 对象 变化
// 挂载了
onMounted(() => {
console.log(uploadImage.value);
})
console.log(uploadImage);
const updateImageData = () => {
// html5 文件对象
// console.log(uploadImage.value.files);
const input = uploadImage.value;
if (!input.files || input.files.length === 0) {
return;
}
const file = input.files[0]; // 文件对象 html5 新特性
// FileReader 文件阅读对象
const reader = new FileReader();
reader.readAsDataURL(file); // url 异步的
reader.onload = (e) => { // 读完了
// console.log(e.target.result);
imgPreview.value = e.target.result;
}
}
</script>
<style scoped>
.container {
display: flex;
flex-direction: row;
align-items: start;
justify-content: start;
height: 100vh;
font-size: .85rem;
}
.preview {
max-width: 300px;
margin-bottom: 20px;
}
.settings {
display: flex;
flex-direction: row;
align-items: start;
justify-content: start;
margin-top: 1rem;
}
.selection {
width: 100%;
text-align: left;
}
.selection input {
width: 50px;
}
.input {
display: flex;
flex-direction: column;
min-width: 330px;
}
.file-input {
display: flex;
margin-bottom: 16px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 10px;
min-width: 200px;
margin-left: 6px;
border: solid 1px black;
}
.generate {
width: 100%;
margin-top: 16px;
}
.generated {
width: 400px;
height: 400px;
border: solid 1px black;
position: relative;
display: flex;
justify-content: center;
/* 水平居中 */
align-items: center;
/* 垂直居中 */
}
.output img {
width: 100%;
}
</style>
六、用户体验优化细节
- 状态提示:上传中 → 生成中 → 成功/失败,减少用户焦虑
- 本地预览:即时反馈
- 默认参数:号码 10、红队服、写实风格,降低决策成本
- 生成的图片区域固定大小:避免布局跳动
- 错误处理:统一通过 status 显示 Coze 返回的错误信息
七、总结与扩展思路
这个项目展示了前端 + 无服务器 AI 平台的极简开发模式:
- 前端只负责交互与参数收集
- 复杂提示词工程、图像理解、生成全部交给 Coze 工作流
- 开发周期极短
未来可以扩展的方向:
- 增加更多运动项目(篮球、足球、滑雪等)
- 支持多人宠物合影
- 加入分享海报生成(加上二维码)
- 接入微信小程序版本