单图生成页面可以用几个 useState 很快搭出来:文件、提示词、模型和 loading。当产品开始支持两到五张参考图,并允许用户为图片指定主体、材质或环境角色后,这套状态很容易失控。
最常见的问题不是页面报错,而是状态彼此矛盾:界面显示多图模式,队列里却只有一张图;切换模型后仍保留不支持的分辨率;任务已经提交,用户还能修改本次请求;结果返回后,原始提示词和参考图已经丢失。
这类界面更适合用 useReducer 管理一个明确的状态模型。
先区分"草稿"和"任务状态"
草稿仍可编辑,任务快照不可变:
ts
type SourceRole =
| "subject"
| "material"
| "environment"
| "composition"
| "lighting";
type SourceItem = {
localId: string;
file: File;
role: SourceRole;
assetId?: string;
status: "local" | "uploading" | "ready" | "failed";
};
type Draft = {
mode: "single" | "fusion";
sources: SourceItem[];
change: string;
preserve: string[];
model: string;
aspectRatio: string;
resolution: string;
};
用户点击生成时,将 Draft 转换成只读 RequestSnapshot。后续即使界面允许准备下一次尝试,也不能改变正在处理的任务记录。
用联合类型代替多个布尔值
isUploading、isGenerating、hasResult、hasError 互相独立时,会组合出大量不合法状态。可区分联合类型更容易限制分支:
ts
type EditorState =
| { type: "editing"; draft: Draft }
| { type: "uploading"; draft: Draft }
| { type: "ready"; draft: Draft }
| { type: "submitting"; request: RequestSnapshot }
| { type: "processing"; request: RequestSnapshot; jobId: string }
| {
type: "reviewing";
request: RequestSnapshot;
jobId: string;
resultUrl: string;
}
| {
type: "failed";
draft: Draft;
stage: "upload" | "validation" | "submission" | "provider";
message: string;
};
在 reviewing 分支中,组件一定能同时拿到结果和原始请求;在 processing 分支中,一定有可查询的 jobId。TypeScript 也能通过穷尽检查提醒遗漏状态。
reducer 负责维护不变量
事件不应直接修改任意字段,而应维护模式和图片数量之间的不变量:
ts
type Action =
| { type: "SET_MODE"; mode: Draft["mode"] }
| { type: "ADD_SOURCE"; source: SourceItem }
| { type: "REMOVE_SOURCE"; localId: string }
| { type: "SET_ROLE"; localId: string; role: SourceRole }
| { type: "APPLY_CAPABILITY"; capability: ModeCapability }
| { type: "SUBMIT"; request: RequestSnapshot }
| { type: "JOB_ACCEPTED"; jobId: string }
| { type: "RESULT_READY"; resultUrl: string }
| {
type: "FAIL";
stage: "upload" | "validation" | "submission" | "provider";
message: string;
};
例如切回单图模式时,不应该静默保留五张图。产品可以阻止切换并要求用户选择主图,也可以保留第一张并给出明确提示。关键是这个决策只能存在于 reducer 的一个分支里,而不是散落在上传区、模型选择器和提交按钮中。
能力变化时只修正失效字段
模型切换会影响输入数量、宽高比、分辨率、登录要求和积分成本。收到新的能力分支后,可以执行一次归一化:
ts
function normalizeDraft(draft: Draft, cap: ModeCapability): Draft {
return {
...draft,
sources: draft.sources.slice(0, cap.maxSources),
aspectRatio: cap.aspectRatios.includes(draft.aspectRatio)
? draft.aspectRatio
: cap.aspectRatios[0],
resolution: cap.resolutions.includes(draft.resolution)
? draft.resolution
: cap.resolutions[0],
};
}
仍合法的选择继续保留,失效字段才被替换。界面还应展示一条变更说明,例如"当前模型不支持原分辨率,已切换为 HD",避免用户误以为配置被随机清空。
图片角色也是表单状态
多张参考图不能只显示缩略图和删除按钮。每张图需要一个明确角色,发生冲突时还需要优先级。
角色选择应该参与提交前校验。例如融合模式下:
- 至少有一个
subject; - 同一强约束角色是否允许重复由产品规则决定;
- 图片数量符合当前能力;
- 所有图片状态都是
ready; - 修改与保留条件不为空。
这些规则用于提前反馈,但服务端仍需最终校验。前端合法不等于服务端一定接受,因为能力和账户状态可能在页面打开后变化。
reviewing 不能省略
生成结果返回后,不要直接把状态改成 completed。在 reviewing 中同时展示:
- 原参考图及其角色;
- 修改条件和保留条件;
- 模型、模式、比例和分辨率;
- 生成结果;
- 接受、下载或基于本次请求创建新尝试的操作。
"模型返回成功"只能证明输出存在,不能证明人脸、文字、商品边缘、反射、重复纹理和透视都正确。
失败后恢复草稿
上传失败应保留其他已经完成的资源;能力冲突应刷新选项并标出失效字段;登录中断后应恢复草稿;超时后应继续查询原任务,而不是马上重复提交;结果不满意时应从请求快照派生新草稿。
恢复策略的原则很简单:保留用户输入,刷新外部状态。
实际边界
当前审阅的产品界面中,单图模式接收一张图片,多图融合接收两到五张;支持 JPEG、PNG 和 WebP,单文件公开限制为 24 MB。模型、模式、分辨率、账号要求、积分成本和可用性会变化,高分辨率输出只适用于符合条件的组合。
生成结果也不能保证完美转换、精确身份保持、无瑕疵、唯一性、固定耗时、完全遵循提示词或商业权利无风险。
我参与的 Image to Image Generator 是本文的实现背景。可复用的重点不是某个具体页面,而是把草稿、能力归一化、异步任务和人工复核放进同一套状态模型。