在这个 AI 爆发的时代,如何快速将一个创意转化为可落地的应用?本文将带你通过字节跳动 旗下的 AI 开发平台 Coze (扣子) 和前端框架 Vue3,一步步实现一个有趣的"冰球宠物拟人化"项目。即使你是零基础的小白,跟着这篇文章也能独立完成你的第一个 AI 应用。
一、 项目背景:让宠物变身冰球明星
本项目源于一个节日活动场景:用户只需上传一张宠物的照片,选择心仪的队服编号、颜色、比赛位置和艺术风格,系统就会通过 Coze AI 工作流 生成一张该宠物"拟人化"后的冰球运动员海报。
核心技术栈
- Coze (扣子) :负责构建 AI 图片编辑工作流,处理逻辑判断与图像生成。
- Vue3 (Composition API) :负责前端界面搭建,收集用户输入并展示结果。
- Coze API:连接前端与 AI 后端,实现数据传输。
二、 后端核心:在 Coze 上搭建 AI 工作流
Coze 的工作流(Workflow)就像一个"加工厂",你只需要把节点(Node)像积木一样连接起来,就能实现复杂的逻辑。
1. 工作流架构解析
根据你提供的截图,我们的工作流由以下节点组成:
- 开始节点 (Start) :定义输入参数,包括
picture(用户上传的图片)、style(风格)、uniform_number(编号)等。 - 代码节点 (Code) :这是提升严谨性的关键。 由于用户输入的可能是数字(如位置 0 代表守门员),代码节点负责将这些原始数据转化为 AI 能听懂的自然语言描述。
- imgUnderstand (图片理解) :分析用户上传的宠物图片,提取出宠物的品种、毛色、特征。
- 特征提取 (LLM) :结合图片描述和用户选定的参数,生成一段详细的绘图提示词(Prompt)。
- 图像生成 (Image Generation) :调用模型,根据提示词生成最终的冰球运动员照片。
- 结束节点 (End) :将生成的图片 URL 返回给前端。
2. 代码节点的逻辑 (1.ts)
在工作流中,我们写了一段简单的 TypeScript 代码来处理数据映射:
TypeScript
const position = params.position == 0 ? '守门员': (params.position == 1 ? '前锋': '后卫');
const shooting_hand = params.shooting_hand == 0 ? '左手': '右手';
这段代码的作用是:如果用户在前端选了"0",它会自动变成"守门员"字样传给 AI,大大提高了生成图片的准确度。
三、 前端实战:Vue3 业务逻辑详解
前端部分是我们与用户交互的窗口。我们将深入讲解 App.vue 中的每一行核心逻辑。
1. 响应式状态管理
在 Vue3 中,我们使用 ref 来定义需要随用户操作而改变的数据:
JavaScript
const uniform_number = ref(10); // 队服编号,默认10
const status = ref(''); // 状态提示:上传中 -> 生成中 -> 成功
const imgUrl = ref(''); // AI 生成后的图片地址
const imgPreview = ref(''); // 用户本地上传后的预览图
2. 图片预览:本地加载而非上传
为了提升用户体验,用户选完图片应立即看到预览,而不是等上传成功。
JavaScript
const updateImageData = () => {
const input = uploadImage.value; // 获取 DOM 元素
const file = input.files[0]; // 获取选中的文件对象
const reader = new FileReader(); // HTML5 文件读取器
reader.readAsDataURL(file); // 将图片转为 Base64 编码字符串
reader.onload = (e) => {
imgPreview.value = e.target.result; // 将 Base64 赋给预览图标签
}
}
- 核心逻辑 :利用
FileReader在浏览器本地完成读取,不消耗网络流量,响应极快。
3. 调用 Coze API:两步走战略
由于 Coze 的工作流不能直接接收原始文件流,我们需要先将文件上传到 Coze 服务器换取 file_id。
第一步:上传文件
JavaScript
const uploadFile = async () => {
const formData = new FormData(); // 准备表单数据
formData.append('file', uploadImage.value.files[0]);
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${patToken}` }, // 使用个人访问令牌鉴权
body: formData
});
const ret = await res.json();
return ret.data.id; // 返回 file_id
};
第二步:运行工作流
拿到 file_id 后,连同用户选择的样式、位置等参数一起发给工作流:
JavaScript
const generate = async () => {
status.value = '图片上传中...';
const file_id = await uploadFile(); // 先拿 ID
status.value = "正在生成...";
const parameters = {
picture: JSON.stringify({ file_id: file_id }), // 传入图片 ID
style: style.value,
position: position.value,
// ...其他参数
}
const res = await fetch(workflowUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ workflow_id, parameters }) // 指定工作流 ID 运行
});
const ret = await res.json();
const data = JSON.parse(ret.data); // 解析返回的 JSON 结果
imgUrl.value = data.data; // 这里的 data.data 是最终生成的图片 URL
};
四、 关键点与避坑指南
- 权限与 Token :必须在 Coze 个人设置中申请
PAT_TOKEN(个人访问令牌),并确保该 Token 有权限调用你创建的工作流。 - 安全建议 :代码中的
patToken不应直接写在前端,正式项目中应通过后端转发,防止 Token 泄露。 - Vite 环境变量 :在 Vue 项目中,敏感信息(如 Token)建议放在
.env.local文件中,通过import.meta.env.VITE_xxx调用。
五、拓展:Vue3小细节
1. Vue3 数据处理:v-model VS ref
在 App.vue 中,处理用户输入主要有两种手段:
-
v-model(双向绑定)
- 适用场景 :队服号码(
uniform_number)、颜色选择(uniform_color)等普通表单。 - 原理:它建立了 HTML 表单元素与 JavaScript 变量之间的"实时同步"。当你在页面输入数字,JS 里的变量会自动更新;如果你在 JS 里修改了变量,页面上的显示也会随之改变。
- 适用场景 :队服号码(
-
ref(DOM 引用)
- 适用场景 :文件上传框
<input type="file">。 - 原理 :浏览器出于安全考虑,禁止 JavaScript 脚本直接设置或修改文件上传框的
value。因此,v-model在这里无法实现"写入",失去了意义。我们必须通过ref直接拿到这个 HTML 元素(DOM)本身,才能读取到用户选中的原始文件对象。
- 适用场景 :文件上传框
2. 属性与事件绑定::src, @change, @click
这是实现界面交互的"三剑客":
-
:src(属性绑定)- 在 Vue 中,普通的
src="xxx"只能指向静态路径。 - 加上冒号的
:src="imgUrl"表示这是一个动态变量 。当 AI 生成图片后,JS 将新的地址赋值给imgUrl.value,Vue 会自动侦测到变化并更新页面图片。
- 在 Vue 中,普通的
-
@change(改变监听)- 用法 :
<input type="file" @change="updateImageData">。 - 作用 :当用户在文件选择器中选中了一张图片,这个事件就会被触发。在项目中,我们利用它来立即读取图片内容并实现本地预览,让用户在点击"生成"前就能看到自己上传的照片。
- 用法 :
-
@click(点击监听)- 用法 :
<button @click="generate">生成</button>。 - 作用:这是用户发起 AI 生成请求的开关。点击后,程序会依次执行"上传图片到服务器"和"调用 Coze 工作流"这两大步骤。
- 用法 :
3. FormData:文件传输的集装箱
在 uploadFile 函数中,你会看到 new FormData() 的操作。
- 作用:在进行网络请求时,普通的 JSON 格式(文本流)无法承载二进制的文件数据(图片)。
- 形象理解:FormData 就像一个特殊的快递盒,专门用来封装文件,以便通过 fetch 安全地发送到服务器。
六、 总结
通过"Coze 工作流 + 代码节点处理 + Vue3 异步请求",我们成功把复杂的 AI 绘图逻辑封装成了一个简单的网页应用。这种"低代码 AI 后端 + 灵活前端"的模式,是目前快速开发 AI 应用的主流选择。
七、App.vue 源码
js
<template>
<div class="container">
<!-- 左侧输入区域 -->
<div class="input">
<!-- 文件上传区域 -->
<div class="file-input">
<!--
ref="uploadImage":给这个 input 元素起个名字,方便 JS 操作它
accept="image/*":只允许选择图片文件
required:表单提交时必须选文件(但这里我们手动处理,所以主要是语义)
@change="updateImageData":当用户选择了文件,就调用 updateImageData 函数
-->
<input type="file" ref="uploadImage" accept="image/*" required @change="updateImageData" />
</div>
<!-- 图片预览:只有 imgPreview 有值时才显示 -->
<img :src="imgPreview" alt="预览图片" v-if="imgPreview" />
<!-- 表单设置区域 -->
<div class="settings">
<!-- 队服编号 -->
<div class="selection">
<label>队服编号: </label>
<!-- v-model 双向绑定:输入框内容 ↔ uniform_number 变量 -->
<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 class="selection">
<label>位置: </label>
<!-- 注意:value 是数字,但 HTML 中写成字符串也没问题,Vue 会自动转 -->
<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">
<!-- 显示 AI 生成的图片 -->
<img :src="imgUrl" alt="AI生成图片" v-if="imgUrl" />
<!-- 显示状态信息(如"上传中...") -->
<div v-if="status">{{ status }}</div>
</div>
</div>
</div>
</template>
<script setup>
// 引入 Vue 3 的组合式 API
import { ref, onMounted } from 'vue'
// ======================
// 🔑 响应式数据定义(相当于 Vue 2 的 data)
// ======================
// 表单数据(用 ref 包裹,变成响应式)
const uniform_number = ref(10) // 默认编号 10
const uniform_color = ref('红') // 默认红色
const position = ref(0) // 0=前锋, 1=后卫, 2=守门员
const shooting_hand = ref('0') // '0'=左手, '1'=右手(注意:这里保持字符串,和 option 一致)
const style = ref('写实') // 默认风格
// 状态管理
const status = ref('') // 当前操作状态提示(空 / 上传中 / 生成中...)
const imgUrl = ref('') // AI 生成的图片 URL
// DOM 引用(用于操作文件输入框)
const uploadImage = ref(null) // 初始为 null,挂载后指向 input 元素
const imgPreview = ref('') // 本地预览图的 Base64 URL
// ======================
// ⚙️ 环境变量 & 接口配置
// ======================
const patToken = import.meta.env.VITE_PAT_TOKEN; // 从 .env 文件读取令牌
const uploadUrl = 'https://api.coze.cn/v1/files/upload';
const workflowUrl = 'https://api.coze.cn/v1/workflow/run';
const workflow_id = '7584046122328555530'; // 你的 Workflow ID
// ======================
// 📤 上传文件到 Coze 服务器
// ======================
const uploadFile = async () => {
const input = uploadImage.value;
// 检查是否选择了文件
if (!input?.files || input.files.length === 0) {
alert('请先选择一张图片!');
return null;
}
// 创建 FormData 对象(用于上传文件)
const formData = new FormData();
formData.append('file', input.files[0]); // 把第一个文件加进去
try {
// 发送 POST 请求上传文件
const res = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${patToken}` // 携带认证令牌
},
body: formData
});
const ret = await res.json();
console.log('上传响应:', ret);
// 检查是否上传成功(Coze 返回 code=0 表示成功)
if (ret.code !== 0) {
status.value = `上传失败: ${ret.msg}`;
return null;
}
// 返回文件 ID(用于后续 Workflow 调用)
return ret.data.id;
} catch (error) {
status.value = '网络错误,请检查网络或 PAT 令牌';
console.error('上传出错:', error);
return null;
}
};
// ======================
// 🧠 调用 AI Workflow 生成图片
// ======================
const generate = async () => {
// 重置状态
imgUrl.value = '';
status.value = '图片上传中...';
// 第一步:上传图片,获取 file_id
const file_id = await uploadFile();
if (!file_id) return;
status.value = '图片上传成功,正在生成...';
// 第二步:准备参数,调用 Workflow
const parameters = {
// ⚠️ 注意:Coze 的 picture 参数要求是 JSON 字符串!
picture: JSON.stringify({ file_id: 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' // 发送 JSON 数据
},
body: JSON.stringify({
workflow_id,
parameters
})
});
const ret = await res.json();
console.log('Workflow 响应:', ret);
if (ret.code !== 0) {
status.value = `生成失败: ${ret.msg}`;
return;
}
// Coze 返回的是字符串化的 JSON,需要再解析一次
const data = JSON.parse(ret.data);
imgUrl.value = data.data; // 获取生成的图片 URL
status.value = ''; // 清空状态
} catch (error) {
status.value = '生成请求失败,请重试';
console.error('生成出错:', error);
}
};
// ======================
// 👁️ 图片预览功能
// ======================
const updateImageData = () => {
const input = uploadImage.value;
if (!input?.files || input.files.length === 0) return;
const file = input.files[0];
console.log('选中的文件:', file);
// 使用 FileReader 读取文件为 Base64
const reader = new FileReader();
reader.readAsDataURL(file); // 异步读取
// 读取完成后触发
reader.onload = (e) => {
imgPreview.value = e.target.result; // 赋值给预览变量
};
};
</script>
<style scoped>
.container {
display: flex;
flex-direction: row;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
display: flex;
flex-direction: column;
min-width: 330px;
padding: 20px;
}
.file-input {
margin-bottom: 16px;
}
.settings {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 1rem;
}
.selection {
display: flex;
align-items: center;
gap: 8px;
}
.selection input,
.selection select {
padding: 4px;
}
.generate {
margin-top: 20px;
}
button {
padding: 10px 20px;
border: 1px solid #333;
background: #f5f5f5;
cursor: pointer;
}
.output {
padding: 20px;
width: 100%;
}
.generated {
width: 400px;
height: 400px;
border: 1px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
background: #fafafa;
}
.generated img {
max-width: 100%;
max-height: 100%;
}
</style>