天国拯救2最强马匹和马具推荐 马具怎么搭配最厉害
2026-07-30 3435292
2026-07-30 0
本文围绕从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效实战解析展开,先梳理核心概念,再结合实践场景说明步骤、代码思路和容易忽略的细节,方便后续直接参考。
从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效很多 AIGC 项目的第一版,通常只有一个输入框:用户输入 Prompt;前端调用模型接口;页面显示一张图片。这个流程可以验证模型能力,
很多 AIGC 项目的第一版,通常只有一个输入框:

这个流程可以验证模型能力,但它还不能算一个完整的应用。真实项目还要面对异步任务、失败处理、接口密钥安全、图片链接过期、历史记录、文件下载,以及生成后的二次编辑。
我最近从一个能正常运行的 Vite 空项目开始,逐步完成了一个名为 AIGC Creative Studio 的图片创作工作台。它目前已经打通:
React 表单→ Express 创建本地任务→ 调用阿里云百炼万相→ 查询外部任务状态→ 下载并持久化图片→ 前端自动轮询→ 生成库管理→ Canvas 图片编辑→ PNG / WebM 导出
本文不只展示最终效果,也会完整复盘我为什么按这个顺序开发、其中遇到了哪些问题,以及这个项目对前端和全栈能力有什么帮助。
我本身有前端、全栈和 HarmonyOS 开发经历,也做过 Web 容器、JavaScript Bridge 和异步通信相关项目。
在完成跨端运行时项目之后,我希望再做一个更贴近当前应用方向的作品。这个项目需要满足几个条件:
因此,我选择了“AIGC 图片创作工作台”。
我没有一开始就创建复杂脚手架,也没有直接生成一个庞大的项目。第一步只是:
npm create vite@latest
选择:
ReactTypeScriptESLint
先让空项目正常启动,再一点点增加功能。这样做的好处是,每一步都知道自己加入了什么,也更容易定位问题。
当前项目的主要技术栈如下:
我没有在第一版引入 UI 组件库、Redux、数据库、Redis 和消息队列。不是因为这些技术没有价值,而是因为 MVP 阶段最重要的是先验证核心链路。
第一版页面只有三个区域。
展示项目名称、页面入口和后端服务状态。
包括:
根据任务状态展示:
表单状态最初直接使用 React useState 管理。这个阶段不接接口,只验证页面结构、响应式布局和交互状态。
这里有一个很实际的经验:结果区域要保持稳定高度。
如果空状态、加载状态、失败状态和图片状态的高度完全不同,轮询期间整个页面会不断跳动。按钮文字从“刷新状态”变成“查询中”时,如果没有设置稳定宽度,也会造成明显抖动。
因此,我后来做了两项调整:
前端页面稳定后,我在项目中增加 server 目录,使用 Express 和 TypeScript 搭建后端。
第一个接口不是生图,而是健康检查:
GET /api/health
响应:
{"success": true,"message": "AIGC Creative Studio API is running"}
然后让前端导航栏显示:
服务检测中服务正常服务未连接
这个接口看起来很简单,但它验证了第一条真正的全栈链路:
React → Fetch → Express → JSON → React 状态
后来增加路由时,我还遇到过一个问题:
/create 显示“服务未连接”;/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 接口:
interface ImageGenerationProvider {readonly name: stringgenerate(input: GenerateImageInput): Promise<GenerateImageResult>}
业务层只关心:
输入生成参数→ 等待生成结果→ 得到图片数组
至于底层使用万相、OpenAI Images、本地 Stable Diffusion,还是其他服务,由 Provider 负责。
这层抽象解决了两个问题:
当前使用的是 WanxImageProvider,主要负责:
为了避免开发期间意外消耗额度,我还增加了安全开关:
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 自动轮询:
等待处理→ 生成中→ 生成完成
任务进入 succeeded 或 failed 后立即停止轮询。组件卸载、任务变化或重新生成时,也会清理旧定时器。
这里需要注意:轮询不是越快越好。频率过高会增加服务器和第三方接口压力,甚至触发限流。
最初失败时,页面只显示:
生成失败,请稍后重试
这对用户和开发者都不够友好。
后来任务增加了结构化错误:
{"code": "REAL_GENERATION_DISABLED","message": "Real image generation is disabled","retryable": false}
前端失败卡片展示:
但不能把以下内容返回给用户:
好的错误处理,不只是 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
生成库展示:
图片下载没有直接依赖第三方 URL,而是通过后端下载接口:
GET /api/generations/:taskId/images/:imageIndex/download
后端只能读取任务中已有的图片地址,不接受用户提交任意 URL,从而降低 SSRF 风险。
删除功能同样需要同时处理两部分:
删除本地图片文件+更新任务元数据
如果一个任务的最后一张图片被删除,则一并清理任务记录。
单纯调用生图 API,更多体现接口集成能力。为了增加前端技术深度,我又加入 Canvas 编辑器。
路由:
/editor/:taskId/:imageIndex
编辑器会:
页面看到的是缩放后的 Canvas,但导出时仍使用原始分辨率,避免把页面显示尺寸误当成图片输出尺寸。
黑白滤镜不是简单使用 CSS:
filter: grayscale(1);
而是真正处理 Canvas 像素。
灰度值计算:
gray = 0.299 × red + 0.587 × green + 0.114 × blue
强度混合:
result = original × (1 - intensity) + gray × intensity
每次调整都从原始 ImageData 重新计算,不能基于上一次处理结果继续计算,否则来回拖动后会累计失真。
在此基础上,我又实现了“灰度到彩色”的横向渐变:
过渡使用 smoothstep:
t = clamp((x - start) / (end - start), 0, 1)smooth = t × t × (3 - 2 × t)
最后使用 smooth 混合彩色与灰度值。
普通静态雨丝可以通过 Canvas 绘制半透明线段完成,但项目中更有辨识度的效果是:
整张图片变灰→ 用户设置雨滴落点→ 雨滴从顶部落下→ 落点出现涟漪→ 涟漪覆盖区域恢复原图色彩
这个效果使用两份离屏画布:
colorCanvas:彩色原图grayscaleCanvas:灰度图片
每一帧:
伪代码如下:
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 更新位置,避免不同刷新率下动画速度不一致。
此外还需要处理:
useRef,不在每一帧触发 React 渲染。Canvas 当前画面可以通过:
canvas.toBlob()
导出 PNG。
但 PNG 只能保存静态瞬间。为了保存完整的雨滴与涟漪动画,项目又使用:
canvas.captureStream(30)
配合浏览器原生 MediaRecorder 导出 WebM。
大致流程:
重置动画→ captureStream 获取 Canvas 视频流→ MediaRecorder 开始录制→ 播放雨滴与涟漪动画→ 动画完成后保留最终画面→ 停止录制→ 合并 Blob→ 下载 WebM
格式选择需要逐级检测:
video/webm;codecs=vp9video/webm;codecs=vp8video/webm
不能默认所有浏览器都支持 VP9。
录制结束后,还必须清理:
Canvas编辑完成后,除了本地下载,还可以保存回生成库。
前端通过 canvas.toBlob() 得到 PNG,再发送:
POST /api/generations/:taskId/images/:imageIndex/editsContent-Type: image/png
后端使用路由级 express.raw() 接收二进制,校验:
编辑后的图片不会覆盖原图,而是作为新资产追加:
AI 生成图└── 编辑作品
生成库中可以继续查看、下载和再次编辑。
至此,项目形成完整闭环:
生成→ 保存→ 浏览→ 编辑→ 导出→ 再保存→ 删除
.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)
增加 React Router 后,如果请求写成:
fetch('api/generations')
在 /create 页面可能被解析为:
/create/api/generations
因此我统一封装了 API Base URL:
VITE_API_BASE_URL=http://localhost:3001
所有健康检查、任务创建、轮询、图片地址和下载地址都通过同一个方法拼接。
因此增加后端下载袋里,只下载任务中已经保存的本地图片,再设置:
Content-Disposition: attachment
如果图片跨域加载且响应头不正确,调用 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”三个字。
项目核心功能已经打通,后续重点不会继续无限堆滤镜,而是提高工程完整度:
这个项目最重要的收获,不是成功调用了一次生图 API。
真正有价值的是把一个模型调用,逐步变成一个完整的软件流程:
输入→ 校验→ 创建任务→ 异步处理→ 状态同步→ 文件持久化→ 历史管理→ 图片编辑→ 动态导出
如果只看最终页面,很多功能似乎理所当然。但把它们逐个实现后,会发现 AIGC 应用本质上仍然离不开传统的软件工程能力。
模型负责生成内容,应用负责让这项能力真正可用。
如果你也准备做一个 AIGC 项目,我的建议是:不要第一天就设计数据库、队列、登录和复杂架构。先从一个可运行的页面开始,完成最小链路,然后每次只解决一个真实问题。
当每一步都能运行、验证和提交时,最后得到的不只是一个 Demo,而是一个自己真正理解的项目。
实际使用时,建议结合项目规模、依赖环境和团队习惯做取舍;先保证流程清晰和结果可验证,再逐步优化细节。