用 Vue3 + Coze 工作流轻松打造趣味 AI 应用:让宠物变身冰球运动员

70 阅读13分钟

前言

最近,我基于 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

核心流程只有两步:

  1. 前端先把用户上传的图片上传到 Coze 云端,获取 file_id
  2. 把 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,才能在工作流中使用。

image.png

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

image.png

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 工作流设计思路

工作流结构:

Ice_hockey_player.png

实际工作流节点设计建议(推荐结构):

  1. 开始节点:接收参数

    • picture(文件类型)
    • style、uniform_color、uniform_number、position、shooting_hand(文本类型)
  2. 图像理解节点(可选但强烈推荐)

    • 使用“豆包·图像理解”或“GPT-4o 图像输入”节点
    • 输入:用户上传的 picture
    • Prompt:要求模型详细描述宠物主体(种类、颜色、姿势、表情等),避免背景干扰
    • 输出:主体描述文本 pet_desc
  3. 提示词工程节点(LLM 节点)

    • 使用豆包或 Claude 构建最终 Prompt
    • Prompt 模板示例:

    text

    请根据以下信息生成一张冰球运动员形象图,主体必须是这只宠物,不要改变宠物的品种和外观特征。
    
    宠物描述:{{pet_desc}}
    
    要求:
    - 穿着{{uniform_color}}色冰球服,胸前号码{{uniform_number}}
    - 位置:{{position == 0 ? '守门员(戴面具)' : position == 1 ? '前锋' : '后卫'}}
    - 持杆手:{{shooting_hand == 0 ? '左手' : '右手'}}
    - 艺术风格:{{style}}
    - 背景为冰球场,动态姿势,专业运动员感觉
    - 高清,细节丰富
    
  4. 图像生成节点

    • 使用“豆包·绘图”或“Flux”节点(图生图模式)
    • 输入图片:原始 picture(作为参考图)
    • 输入 Prompt:上一步生成的最终提示词
    • 强度(Strength)建议 0.7~0.85,保留宠物原貌同时融入装备
  5. 结束节点

    • 输出:生成的图像(类型:图片)

这样设计的好处:

  • 通过图像理解先提取宠物主体,避免模型“脑补”错误动物

  • 结构化参数精准控制装备细节

  • 图生图模式比纯文生图更稳定,宠物相似度更高

五、效果展示

image.png

本项目主要是为了体验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>

六、用户体验优化细节

  1. 状态提示:上传中 → 生成中 → 成功/失败,减少用户焦虑
  2. 本地预览:即时反馈
  3. 默认参数:号码 10、红队服、写实风格,降低决策成本
  4. 生成的图片区域固定大小:避免布局跳动
  5. 错误处理:统一通过 status 显示 Coze 返回的错误信息

七、总结与扩展思路

这个项目展示了前端 + 无服务器 AI 平台的极简开发模式:

  • 前端只负责交互与参数收集
  • 复杂提示词工程、图像理解、生成全部交给 Coze 工作流
  • 开发周期极短

未来可以扩展的方向:

  • 增加更多运动项目(篮球、足球、滑雪等)
  • 支持多人宠物合影
  • 加入分享海报生成(加上二维码)
  • 接入微信小程序版本