从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效实战解析

作者:袖梨 2026-07-30

本文围绕从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效实战解析展开,先梳理核心概念,再结合实践场景说明步骤、代码思路和容易忽略的细节,方便后续直接参考。

从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效很多 AIGC 项目的第一版,通常只有一个输入框:用户输入 Prompt;前端调用模型接口;页面显示一张图片。这个流程可以验证模型能力,

从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效

很多 AIGC 项目的第一版,通常只有一个输入框:

img_6a6aeee57097830.webp

  1. 用户输入 Prompt;
  2. 前端调用模型接口;
  3. 页面显示一张图片。

这个流程可以验证模型能力,但它还不能算一个完整的应用。真实项目还要面对异步任务、失败处理、接口密钥安全、图片链接过期、历史记录、文件下载,以及生成后的二次编辑。

我最近从一个能正常运行的 Vite 空项目开始,逐步完成了一个名为 AIGC Creative Studio 的图片创作工作台。它目前已经打通:

React 表单→ Express 创建本地任务→ 调用阿里云百炼万相→ 查询外部任务状态→ 下载并持久化图片→ 前端自动轮询→ 生成库管理→ Canvas 图片编辑→ PNG / WebM 导出

本文不只展示最终效果,也会完整复盘我为什么按这个顺序开发、其中遇到了哪些问题,以及这个项目对前端和全栈能力有什么帮助。

一、为什么要做这个项目

我本身有前端、全栈和 HarmonyOS 开发经历,也做过 Web 容器、JavaScript Bridge 和异步通信相关项目。

在完成跨端运行时项目之后,我希望再做一个更贴近当前应用方向的作品。这个项目需要满足几个条件:

  1. 不是只展示静态页面;
  2. 有真实的第三方 AI 服务;
  3. 有前后端通信和异步任务;
  4. 有文件处理与本地持久化;
  5. 有能体现前端深度的交互;
  6. 可以部署、演示,也可以继续扩展。

因此,我选择了“AIGC 图片创作工作台”。

我没有一开始就创建复杂脚手架,也没有直接生成一个庞大的项目。第一步只是:

npm create vite@latest

选择:

ReactTypeScriptESLint

先让空项目正常启动,再一点点增加功能。这样做的好处是,每一步都知道自己加入了什么,也更容易定位问题。

二、技术栈

当前项目的主要技术栈如下:

前端

  1. Vite
  2. React
  3. TypeScript
  4. React Router
  5. Canvas 2D API
  6. MediaRecorder
  7. 原生 Fetch API

后端

  1. Node.js
  2. Express
  3. TypeScript
  4. 原生 Fetch API
  5. 本地 JSON 元数据存储
  6. 本地文件存储

AIGC 服务

  1. 阿里云百炼
  2. 万相文生图模型
  3. 异步任务接口

我没有在第一版引入 UI 组件库、Redux、数据库、Redis 和消息队列。不是因为这些技术没有价值,而是因为 MVP 阶段最重要的是先验证核心链路。

三、第一阶段:先做一个纯前端创作台

第一版页面只有三个区域。

顶部导航

展示项目名称、页面入口和后端服务状态。

左侧参数面板

包括:

  1. Prompt;
  2. Negative Prompt;
  3. 图片比例;
  4. 生成数量;
  5. Seed;
  6. 风格预设;
  7. 开始生成按钮。

右侧结果区域

根据任务状态展示:

  1. 空状态;
  2. 提交中;
  3. 等待处理;
  4. 生成中;
  5. 生成成功;
  6. 生成失败。

表单状态最初直接使用 React useState 管理。这个阶段不接接口,只验证页面结构、响应式布局和交互状态。

这里有一个很实际的经验:结果区域要保持稳定高度。

如果空状态、加载状态、失败状态和图片状态的高度完全不同,轮询期间整个页面会不断跳动。按钮文字从“刷新状态”变成“查询中”时,如果没有设置稳定宽度,也会造成明显抖动。

因此,我后来做了两项调整:

  1. 结果区域设置稳定的最小高度;
  2. 状态按钮设置固定宽度,并区分自动轮询状态与手动查询状态。

四、第二阶段:建立最小后端

前端页面稳定后,我在项目中增加 server 目录,使用 Express 和 TypeScript 搭建后端。

第一个接口不是生图,而是健康检查:

GET /api/health

响应:

{"success": true,"message": "AIGC Creative Studio API is running"}

然后让前端导航栏显示:

服务检测中服务正常服务未连接

这个接口看起来很简单,但它验证了第一条真正的全栈链路:

React → Fetch → Express → JSON → React 状态

后来增加路由时,我还遇到过一个问题:

  1. /create 显示“服务未连接”;
  2. /library 显示“服务正常”。

原因不是后端不稳定,而是两个页面分别维护了一份健康检查状态。最终我把 Header 放到公共布局中,只保留一份应用级状态。

推荐结构如下:

BrowserRouter└── AppLayout├── Header└── Routes├── CreatePage├── LibraryPage└── EditorPage

服务状态属于整个应用,不应该分别散落在页面组件里。

五、生成任务为什么不能只返回一张图片

真实生图通常不是立即完成的。

请求提交到模型平台后,平台先返回一个任务 ID,任务状态随后经历:

PENDING → RUNNING → SUCCEEDED / FAILED

因此,后端不能把它设计成普通同步接口。

项目中的本地任务状态为:

type GenerationStatus =| 'pending'| 'processing'| 'succeeded'| 'failed'

创建接口:

POST /api/generations

查询接口:

GET /api/generations/:taskId

前端提交参数后,后端立即创建本地任务:

{"success": true,"data": {"taskId": "本地任务 ID","status": "pending"}}

随后由后端调用模型服务,并更新本地任务状态。

这样做的好处是,前端只需要理解自己系统的任务协议,不需要直接依赖某一家模型平台的字段。

六、Provider 抽象:不要把业务代码绑死在模型平台上

在接入真实万相接口之前,我先定义了 Provider 接口:

interface ImageGenerationProvider {readonly name: stringgenerate(input: GenerateImageInput): Promise<GenerateImageResult>}

业务层只关心:

输入生成参数→ 等待生成结果→ 得到图片数组

至于底层使用万相、OpenAI Images、本地 Stable Diffusion,还是其他服务,由 Provider 负责。

这层抽象解决了两个问题:

  1. 第三方接口字段不会污染业务路由;
  2. 以后替换模型时,不需要重写任务和生成库。

当前使用的是 WanxImageProvider,主要负责:

  1. 读取环境变量;
  2. 创建万相异步任务;
  3. 查询任务状态;
  4. 映射图片比例;
  5. 处理超时和错误;
  6. 提取图片 URL;
  7. 转换为项目内部统一结果。

为了避免开发期间意外消耗额度,我还增加了安全开关:

ENABLE_REAL_GENERATION=false

需要真实生成时才设置:

ENABLE_REAL_GENERATION=true

这不是模型平台要求的字段,而是项目自己的成本保护机制。

七、接入阿里云百炼万相

测试阶段我选择了成本较低、带有新人免费额度的万相文生图模型。

后端环境变量示例:

PORT=3001DASHSCOPE_API_KEY=DASHSCOPE_MODEL=wanx2.0-t2i-turboDASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1ENABLE_REAL_GENERATION=false

真实 .env 不应该提交到 Git。

万相旧版文生图使用异步流程:

创建任务→ 获得外部 task_id→ 定时查询→ 获取图片 URL

Provider 内部将外部状态映射为项目状态:

PENDING → pendingRUNNING → processingSUCCEEDED → succeededFAILED→ failed

前端则根据本地 taskId 自动轮询:

等待处理→ 生成中→ 生成完成

任务进入 succeededfailed 后立即停止轮询。组件卸载、任务变化或重新生成时,也会清理旧定时器。

这里需要注意:轮询不是越快越好。频率过高会增加服务器和第三方接口压力,甚至触发限流。

八、失败信息必须真正展示出来

最初失败时,页面只显示:

生成失败,请稍后重试

这对用户和开发者都不够友好。

后来任务增加了结构化错误:

{"code": "REAL_GENERATION_DISABLED","message": "Real image generation is disabled","retryable": false}

前端失败卡片展示:

  1. 生成失败;
  2. 错误原因;
  3. 可选错误码;
  4. 重新生成按钮。

但不能把以下内容返回给用户:

  1. API Key;
  2. Authorization;
  3. 服务器绝对路径;
  4. 完整异常堆栈;
  5. 第三方敏感响应。

好的错误处理,不只是 catch 一下,而是要在安全与可诊断性之间取得平衡。

九、为什么必须把图片下载到本地

模型服务返回的图片通常是临时 URL。

如果生成库直接保存这个 URL,过一段时间后,历史图片就会全部失效。

因此,Provider成功后,后端立即执行:

读取临时 URL→ 下载图片二进制→ 保存到 storage/images→ 将任务中的 URL 替换为本地地址

本地地址示例:

/api/images/{taskId}-0.png

任务元数据保存到:

server/data/generations.json

结构类似:

[{"taskId": "fced3f18-6914-48de-b5b9-23fc86d26cec","status": "succeeded","request": {"prompt": "一只猫被一个少女抱着逛街","negativePrompt": "","aspectRatio": "1:1","count": 1,"style": "anime"},"result": {"images": [{"url": "/api/images/fced3f18-6914-48de-b5b9-23fc86d26cec-0.png"}]}}]

服务启动时重新读取 JSON,恢复历史任务。

当前阶段使用 JSON 是为了快速完成 MVP。它不适合多实例和高并发写入,后续计划迁移 PostgreSQL;图片则可以从本地目录迁移到 OSS。

十、生成库:让“生成历史”真正有意义

最初导航栏同时出现了“生成库”和“生成历史”,实际功能完全重合。后来我删除重复入口,只保留:

图片创作 | 生成库 | 服务状态

生成库接口:

GET /api/generations?status=succeeded&limit=20&offset=0

生成库展示:

  1. 图片;
  2. Prompt;
  3. 风格;
  4. 图片比例;
  5. 生成时间;
  6. 下载;
  7. 新窗口查看;
  8. 编辑;
  9. 删除。

图片下载没有直接依赖第三方 URL,而是通过后端下载接口:

GET /api/generations/:taskId/images/:imageIndex/download

后端只能读取任务中已有的图片地址,不接受用户提交任意 URL,从而降低 SSRF 风险。

删除功能同样需要同时处理两部分:

删除本地图片文件+更新任务元数据

如果一个任务的最后一张图片被删除,则一并清理任务记录。

十一、Canvas 编辑器:项目真正有辨识度的部分

单纯调用生图 API,更多体现接口集成能力。为了增加前端技术深度,我又加入 Canvas 编辑器。

路由:

/editor/:taskId/:imageIndex

编辑器会:

  1. 根据任务 ID 查询任务;
  2. 找到指定图片;
  3. 加载本地图片;
  4. 绘制到原始分辨率的 Canvas;
  5. 在页面中按容器尺寸缩放显示。

页面看到的是缩放后的 Canvas,但导出时仍使用原始分辨率,避免把页面显示尺寸误当成图片输出尺寸。

十二、黑白滤镜与灰度渐变

黑白滤镜不是简单使用 CSS:

filter: grayscale(1);

而是真正处理 Canvas 像素。

灰度值计算:

gray = 0.299 × red + 0.587 × green + 0.114 × blue

强度混合:

result = original × (1 - intensity) + gray × intensity

每次调整都从原始 ImageData 重新计算,不能基于上一次处理结果继续计算,否则来回拖动后会累计失真。

在此基础上,我又实现了“灰度到彩色”的横向渐变:

  1. 左侧黑白;
  2. 右侧彩色;
  3. 中间平滑过渡;
  4. 可以拖动分界位置;
  5. 可以调节过渡宽度。

过渡使用 smoothstep

t = clamp((x - start) / (end - start), 0, 1)smooth = t × t × (3 - 2 × t)

最后使用 smooth 混合彩色与灰度值。

十三、动态雨滴与“雨滴唤醒色彩”

普通静态雨丝可以通过 Canvas 绘制半透明线段完成,但项目中更有辨识度的效果是:

整张图片变灰→ 用户设置雨滴落点→ 雨滴从顶部落下→ 落点出现涟漪→ 涟漪覆盖区域恢复原图色彩

这个效果使用两份离屏画布:

colorCanvas:彩色原图grayscaleCanvas:灰度图片

每一帧:

  1. 主画布绘制灰度底图;
  2. 绘制下落雨滴;
  3. 雨滴到达目标点后进入涟漪阶段;
  4. 使用圆形裁剪区域绘制彩色图层;
  5. 绘制逐渐扩散并淡出的涟漪圆环。

伪代码如下:

ctx.drawImage(grayscaleCanvas, 0, 0)ctx.save()ctx.beginPath()ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)ctx.clip()ctx.drawImage(colorCanvas, 0, 0)ctx.restore()

动画使用 requestAnimationFrame,并通过 deltaTime 更新位置,避免不同刷新率下动画速度不一致。

此外还需要处理:

  1. 切换工具时取消动画;
  2. 组件卸载时取消动画;
  3. 页面不可见时暂停;
  4. React StrictMode 下避免双动画循环;
  5. 动画数据放在 useRef,不在每一帧触发 React 渲染。

十四、PNG 与 WebM 导出

Canvas 当前画面可以通过:

canvas.toBlob()

导出 PNG。

但 PNG 只能保存静态瞬间。为了保存完整的雨滴与涟漪动画,项目又使用:

canvas.captureStream(30)

配合浏览器原生 MediaRecorder 导出 WebM。

大致流程:

重置动画→ captureStream 获取 Canvas 视频流→ MediaRecorder 开始录制→ 播放雨滴与涟漪动画→ 动画完成后保留最终画面→ 停止录制→ 合并 Blob→ 下载 WebM

格式选择需要逐级检测:

video/webm;codecs=vp9video/webm;codecs=vp8video/webm

不能默认所有浏览器都支持 VP9。

录制结束后,还必须清理:

  1. MediaStream Track;
  2. MediaRecorder;
  3. Blob URL;
  4. 超时定时器;
  5. requestAnimationFrame。

十五、编辑后的图片如何回到生成库

Canvas编辑完成后,除了本地下载,还可以保存回生成库。

前端通过 canvas.toBlob() 得到 PNG,再发送:

POST /api/generations/:taskId/images/:imageIndex/editsContent-Type: image/png

后端使用路由级 express.raw() 接收二进制,校验:

  1. Content-Type;
  2. 文件大小;
  3. PNG 文件头;
  4. 原任务;
  5. 原图片索引;
  6. 存储路径。

编辑后的图片不会覆盖原图,而是作为新资产追加:

AI 生成图└── 编辑作品

生成库中可以继续查看、下载和再次编辑。

至此,项目形成完整闭环:

生成→ 保存→ 浏览→ 编辑→ 导出→ 再保存→ 删除

十六、开发中遇到的几个典型问题

1. .env 明明存在,却读取不到

我曾经执行:

node -e "require('dotenv').config(); console.log(process.env.ENABLE_REAL_GENERATION)"

结果是:

undefined

最终发现配置没有正确写入真实的 server/.env,或者文件名实际是 .env.txt

排查环境变量时,不要直接打印 API Key,可以只判断是否存在:

Boolean(process.env.DASHSCOPE_API_KEY)

2. 健康检查正常,生图却提示无法连接

增加 React Router 后,如果请求写成:

fetch('api/generations')

/create 页面可能被解析为:

/create/api/generations

因此我统一封装了 API Base URL:

VITE_API_BASE_URL=http://localhost:3001

所有健康检查、任务创建、轮询、图片地址和下载地址都通过同一个方法拼接。

3. 图片能在 Network 看到,却不能在页面下载

跨域图片使用 时,浏览器不一定直接下载。

因此增加后端下载袋里,只下载任务中已经保存的本地图片,再设置:

Content-Disposition: attachment

4. Canvas 跨域污染

如果图片跨域加载且响应头不正确,调用 toBlob()getImageData() 时可能出现:

SecurityError: canvas has been tainted

因此图片加载需要正确的 CORS 配置,并在设置 src 之前配置:

image.crossOrigin = 'anonymous'

十七、如何运行项目

克隆:

git clone https://github.com/lichenyang5/AIGC-Creative-Studio.gitcd AIGC-Creative-Studio

安装前端依赖:

npm install

安装后端依赖:

cd servernpm install

根据 .env.example 创建:

server/.env

示例:

PORT=3001DASHSCOPE_API_KEY=你的APIKeyDASHSCOPE_MODEL=wanx2.0-t2i-turboDASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1ENABLE_REAL_GENERATION=true

不要将真实 .env 提交到 GitHub。

启动后端:

cd servernpm run dev

启动前端:

npm run dev

打开:

http://localhost:5173

如果只想调试前后端链路、不调用真实模型:

ENABLE_REAL_GENERATION=false

十八、为什么暂时没有数据库

项目目前使用:

任务数据:generations.json图片文件:storage/images

我原本计划使用:

PostgreSQL + Prisma + Docker

但在受限网络环境中,Docker无法正常拉取 PostgreSQL 镜像。因此我没有为了“看起来技术栈更多”而强行改造,而是先保留可运行的 JSON Repository。

后续网络条件允许时,计划迁移:

JSON → PostgreSQL本地图片 → OSS

数据库只保存任务和图片元数据,不直接存储大图片二进制。

这个取舍也让我更加明确:项目开发不是技术名词收集,而是根据当前目标、环境和成本做选择。

十九、这个项目体现了哪些能力

从求职角度看,这个项目的价值不只在“AIGC”三个字。

前端能力

  1. React组件拆分;
  2. TypeScript类型设计;
  3. React Router公共布局;
  4. 表单和异步状态管理;
  5. 自动轮询和生命周期清理;
  6. 响应式工作台;
  7. Canvas像素处理;
  8. Pointer Events;
  9. requestAnimationFrame;
  10. MediaRecorder;
  11. 文件导出和下载;
  12. 错误、空状态和加载状态设计。

后端能力

  1. Express接口设计;
  2. 参数校验;
  3. 异步任务建模;
  4. Provider抽象;
  5. 第三方 API 集成;
  6. 错误码转换;
  7. 文件下载和持久化;
  8. 二进制上传;
  9. 静态资源服务;
  10. 路径安全和 SSRF 防护;
  11. JSON Repository。

工程能力

  1. 环境变量管理;
  2. API Key安全;
  3. 成本开关;
  4. 前后端统一协议;
  5. 本地任务与第三方任务解耦;
  6. 渐进式开发;
  7. Git阶段提交;
  8. 为数据库和对象存储预留迁移空间。

二十、下一步计划

项目核心功能已经打通,后续重点不会继续无限堆滤镜,而是提高工程完整度:

  1. 使用 Vitest + Supertest 增加后端接口测试;
  2. 增加前端核心组件测试;
  3. 完善 README、架构图和截图;
  4. 增加演示视频;
  5. 在合适环境下迁移 PostgreSQL;
  6. 将图片存储迁移到 OSS;
  7. 增加用户和权限体系;
  8. 部署前端和后端;
  9. 对生成频率和成本增加限流。

结语

这个项目最重要的收获,不是成功调用了一次生图 API。

真正有价值的是把一个模型调用,逐步变成一个完整的软件流程:

输入→ 校验→ 创建任务→ 异步处理→ 状态同步→ 文件持久化→ 历史管理→ 图片编辑→ 动态导出

如果只看最终页面,很多功能似乎理所当然。但把它们逐个实现后,会发现 AIGC 应用本质上仍然离不开传统的软件工程能力。

模型负责生成内容,应用负责让这项能力真正可用。

如果你也准备做一个 AIGC 项目,我的建议是:不要第一天就设计数据库、队列、登录和复杂架构。先从一个可运行的页面开始,完成最小链路,然后每次只解决一个真实问题。

当每一步都能运行、验证和提交时,最后得到的不只是一个 Demo,而是一个自己真正理解的项目。

参考资料

  1. 项目源码:AIGC-Creative-Studio
  2. 阿里云百炼万相文生图 API:万相文生图 V2 API 参考
  3. 阿里云百炼模型价格:模型调用价格

实际使用时,建议结合项目规模、依赖环境和团队习惯做取舍;先保证流程清晰和结果可验证,再逐步优化细节。

相关文章

精彩推荐