普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月20日首页

从零开始:用Vue 3和Coze工作流打造AI冰球运动员生成器

2026年2月19日 20:03

# 从零开始:用Vue 3和Coze工作流打造AI冰球运动员生成器

之前我使用coze生成了工作流内容是:为冰球协会开发一个AI应用,让会员上传自己的照片,一键生成酷炫的冰球运动员形象。用户觉得有趣还可以分享到朋友圈,达到传播效果。 但我每次想使用它时都得打开coze使用,这非常麻烦,现在我想把它完善一些,让我们在浏览器就能直接调用它,现在我就使用vue让我们再浏览器端就能直接使用了,接下来我将与大家分析我的代码历程

关于Coze工作流的具体配置,可以参考我之前发表的文章:《保姆级教程:用 Coze 打造宠物→冰球运动员拟人化工作流》。链接我放在了文章末尾

第一章:认识我们的项目

1.1 项目故事

想象一下,你是冰球协会的网站开发者。协会想做一个有趣的活动:会员上传自己的照片,AI就能生成一张冰球运动员的形象照。用户觉得好玩就会分享到朋友圈,为协会带来更多关注。

1.2 项目功能

我们的应用需要实现:

  1. 上传照片:用户选择自己的照片
  2. 预览照片:立即看到选中的照片
  3. 选择参数:队服颜色、号码、位置等
  4. AI生成:调用Coze平台的工作流
  5. 显示结果:展示生成的冰球运动员形象

1.3 最终效果预览

image.png

第二章: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" />        <!-- 这是选项框,可以动态选择 -->

技术原理解释

  1. 浏览器解析HTML时,src="xxx"期望得到一个字符串
  2. 如果写src="{{url}}",浏览器看到的是字符串"{{url}}"
  3. :是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>
  • 效果图

屏幕截图 2026-02-19 195155.png

第八章:常见错误与解决方法

错误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 打造宠物→冰球运动员拟人化工作流》

❌
❌