diff --git a/README.md b/README.md
index 495548d..0d0d25b 100644
--- a/README.md
+++ b/README.md
@@ -131,7 +131,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
布局样式集中在 `src/styles/admin.css`,业务排版保留在 `src/styles/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body` 或 `window.scrollTo` 管理滚动。
-本次 UI 重构保留前端 `dev@5551653` 的业务更新,包括过期形态图、主首帧和主视频的提示与更新入口。首帧默认只补当前剧集;存在过期主首帧时优先更新全项目过期项。非覆盖模式下的过期主视频重建完成后,会接替旧主视频;普通新增候选不自动替换主资产。
+本次 UI 重构保留前端 `dev@5551653` 的业务更新,包括主首帧和主视频的过期提示与更新入口。2026-09-10 已适配精简公开 DTO,见 [接口数据同步说明](docs/api-contract-sync.md)。首帧默认只补当前剧集;存在过期主首帧时优先更新全项目过期项。非覆盖模式下的过期主视频重建完成后,会接替旧主视频;普通新增候选不自动替换主资产。
本地验收建议使用 1920×1080、1280×720 和窄屏视口:打开长剧本、长项目标题和多镜头项目,确认顶栏不移动、目录与正文能分别滚到底;切换暗黑模式后检查弹窗、下拉菜单、图片失败占位,刷新后主题应保持。
@@ -223,7 +223,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
### 形态图片约定
1. 入口为 `/projects/:projectId/subject-images`;项目导航、拆解主体列表和分镜参考图缺失提示均可进入。图库自动读取后端已生成图片,不需要重新生图才能显示。
-2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。项目列表包含 Identity Anchor 及图片的 `rawJson` 追溯字段,用于识别母版过期;完整实际 Prompt 在历史弹窗读取。
+2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。图库通过 `identity.anchorImageId` 返回当前母版,不再返回图片 `rawJson` 或实际生成 Prompt;不能据此推断形态图历史母版是否过期。正式可编辑提示词保留在形态上。
3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。自定义 Prompt 优先,其次正式 `generationPrompt`;缺少时后端先调用文本模型生成并保存。`appearancePrompt` 仅为原始素材,不能直接充当最终 Prompt。可在形态卡片“提示词与生成”先生成、检查与导出正式提示词;后端尚无手工编辑保存接口。
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 后端图片模型 Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
@@ -231,7 +231,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
7. 图库、镜头影响检查、关联素材和图片历史均不定时刷新;首次进入、切换查询目标、手动刷新及本次写操作完成后按需读取。“刷新图库”同时更新项目状态。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。当前形态列表接口返回完整数组,无分页参数或游标,因此不显示分页或触底加载入口。筛选栏与有值的关联素材行共用一个不透明背景的吸顶容器,内部无 margin 断层;关联素材保持单行横向滚动、上下居中,图片区域沿正文向下滚动。
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
-10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
+10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。母版变更后需人工核对形态,按需生成候选、验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。
12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId`/`subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。
@@ -253,7 +253,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
2. 身份 AI 生成需要已创建 VisualStyle;身份图生成还需要已保存的非空 `generationPrompt`。不存在身份时不调用会报错的图片 GET。404 或网络失败不会被当作“尚未创建”。
3. 前端以正式 `subject-forms` 目录为基础,并合并 Character Casting readiness,因此尚无形态的角色也可先完成 Identity 和选角。Scene / Prop 若还没有 Form,仍不会凭 checkpoint 伪造正式 Subject ID。
4. 身份文本批量分为 Character 专用入口和全部主体高级入口。选角候选批量只处理 `missing_anchor`,受本批上限控制;`candidate_pending` 必须人工确认,不会由批量任务静默决定演员。
-5. 身份图片的 `isAnchor` 来自专用 GET。Character primary 通过 Casting Candidate 接口生成且默认停用;点击确认演员后才成为 Anchor 并锁定 Identity。辅助视图不能直接成为母版。Scene / Prop 保留普通母版切换接口。
+5. 身份图片的 `isAnchor` 来自专用 GET。Character primary 通过 Casting Candidate 接口生成,候选的 `isAnchor` 为 false;点击确认演员后才成为 Anchor 并锁定 Identity。辅助视图不能直接成为母版。Scene / Prop 保留普通母版切换接口。
6. 普通身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。Character Casting Candidate 是独立选角入口,即使已有 Anchor 也不会复用旧母版,避免旧演员身份污染新候选。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。
7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。
8. 修改风格或身份文本不会自动重生成旧图。更换已锁定母版后,形态图库会根据 `identityAnchorImageId` 标记旧主图过期;主动生成候选、验图并切换主图,再更新下游首帧。Keyframe 就绪检查也会比对母版与形态主图引用,不应绕过过期提示继续生产。旧记录无追溯字段时不推断它已匹配当前母版。
diff --git a/docs/api-contract-sync.md b/docs/api-contract-sync.md
new file mode 100644
index 0000000..18472be
--- /dev/null
+++ b/docs/api-contract-sync.md
@@ -0,0 +1,32 @@
+# 接口数据同步说明
+
+核对日期:2026-09-10。依据同级后端仓库 `short-drama-agent` 的 `HEAD=f0321c2`、`src/api/routes` 及实际资产 DTO/service 实现。本轮仅修改前端,沿用已完成页面和正式路由。
+
+| 接口范围 | 精简后的公开数据 | 前端处理 |
+| --- | --- | --- |
+| 项目列表/详情 | 项目业务字段、characters、world、episodes;不含 reviews、tasks | 删除旧类型依赖;审核读取共享 checkpoint 的明确 reviewPassed,不伪造审核记录;生产预检从每条工作流最新 checkpoint 检查运行状态 |
+| 视觉风格及参考图 | 风格保留可编辑 Prompt、hardConstraints 和 images;图片去除生成 Prompt | 保留编辑、锁定、图片登记和启停;移除图片内部提示词展示 |
+| 主体身份查询/保存/生成 | id、subjectId、description、generationPrompt、isLocked、时间 | 图片继续通过独立 GET 查询,不从身份对象读取 images |
+| 身份图片查询/生成/切母版 | 明确 isAnchor,不含 enabled、prompt、rawJson | 母版识别和切换结果校验统一使用 isAnchor;辅助视角仍不可设母版 |
+| 角色选角确认 | identityId、subjectId、isLocked、anchor | 验证主体、身份、图片归属和母版标记,不再访问嵌套 identity |
+| 形态图库/图片/主图 | identity.anchorImageId;图片仅含资产字段 | 正常预览、生成和切主图;移除依赖历史 rawJson 的身份过期推断及批量重生,保留下游 readiness 过期定位 |
+| 形态正式 Prompt | 正式形态业务字段和时间,不含 subject、images 关联 | 单独声明回执类型;生成后重读图库,避免将单体回执当作图库记录 |
+| 视频 Prompt | shotId、status、videoPrompt、negativePrompt、updatedAt | 两个页面均按 shotId 校验回执;保留正文展示与导出 |
+| 首帧资产 | source、状态、图片、主图标记、模型与时间;无内部 Prompt/任务信息 | 保留候选和主图;source=provider_variant 的模型输入变体不可设业务主首帧 |
+| 视频资产/单任务刷新 | 状态、地址、时长、isPrimary、模型和时间;无 rawJson/Provider Task/Prompt | 播放与状态使用公开字段;未知质量不显示通过或自动校验中 |
+| 视频就绪/项目状态 | stale_prompt、provider_input_recovery;公开失败分类、retryable 和输入恢复状态 | 补齐类型和中文原因,显示不可普通重试提示;不解析内部失败快照 |
+| 视频生成规格 | 业务 keyframe 和实际 providerInputKeyframe | 类型区分两种用途,规格面板展示实际输入 ID;尺寸允许后端 null |
+| 失败视频重试 | totalFailed、retryable、skipped、retried、failed、failures | 显示可重试和跳过数,保留部分失败,不将 HTTP 成功当作全部重试成功 |
+| 拆解、导演设计、即时状态、质量 POST、串联生产、诊断 | 已核对现有页面使用的正式路由与返回业务切片 | 保留原入口、参数、费用确认与失败处理;不增加测试/探针生成入口 |
+
+视频校验的新增运动、空间交互、道具结构及连接检查回执也已同步;专项未执行不显示通过,完整证据随结果导出。视频校验费用提示按一次请求可能调用多个模型说明。
+
+## 质量数据边界
+
+公开视频 DTO 不再带质量候选类型、历史校验和修复次数。后端自动质量链仍负责通过后晋升,前端以刷新后的 `isPrimary` 为准。普通手动设主入口要求本会话对此资产收到 PASS;修复要求本会话收到 FAIL。回执按项目、镜头、资产隔离,切页仍可查看,刷新浏览器后不伪造历史。重新校验会清除旧通过资格;失败的修复请求保留其依据的失败校验。次数上限由后端检查,错误原样呈现,不自动重试。
+
+项目详情不再提供内部任务列表。公开 checkpoint 只能证明已记录工作流的状态,不能证明所有外部生成任务均已停止;保留现有的资产状态检查和操作确认。
+
+## 回归验证范围
+
+测试使用不包含旧内部字段的公开 DTO,覆盖页面加载、身份保存/选角、图片主图、Prompt 正式 ID、质量会话隔离、输入变体禁用、失败重试和缺失历史不误报过期。全部 API 使用模拟响应,不触发真实模型。此次不以自动化 DOM 测试代替浏览器视觉验收或真实后端联调。
diff --git a/docs/backend-coverage.md b/docs/backend-coverage.md
index 7578498..94ea608 100644
--- a/docs/backend-coverage.md
+++ b/docs/backend-coverage.md
@@ -1,6 +1,6 @@
# 后端功能覆盖说明
-核对日期:2026-09-07。后端:`qianlanse/short-drama-agent`,`dev@697cf864e1e8d6f66e3ad7beeebdfd0ae0d6c16c`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。
+核对日期:2026-09-10。后端:`qianlanse/short-drama-agent`,本地 `HEAD=f0321c2`。精简返回结构的适配详情见 [接口数据同步说明](api-contract-sync.md)。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。
## 页面与接口能力
@@ -17,7 +17,7 @@
| 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 |
| 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 |
| 正式形态提示词 | 形态卡片 → 提示词与生成;批量工具 | POST `subject-forms/:id/generation-prompt`、项目 `subject-forms/generation-prompts` | 新增单个生成/重生成、原始素材与正式文本对照、导出、批量补齐/覆盖及失败回执 |
-| 形态图片 | 形态图片及图库 | 项目 `subject-forms`、形态 `images`、主图 PUT、项目 `subject-images/generate` | 增加本批数量上限;支持后端自动补正式 Prompt;人物/场景/道具已锁定母版继承;过期刷新只生成候选 |
+| 形态图片 | 形态图片及图库 | 项目 `subject-forms`、形态 `images`、主图 PUT、项目 `subject-images/generate` | 增加本批数量上限;支持后端自动补正式 Prompt;人物/场景/道具已锁定母版继承;按需生成候选,历史母版信息不公开时不推断过期 |
| 导演设计与即时状态 | 分镜设计 | directions/visual-states 查询、项目 `generate` | 只保留正式项目批量、覆盖、校验修复次数和逐集回执;不暴露 `generate-test` |
| 参考图与规格 | 分镜参考图/生产单镜头 | `references`、`generation-spec`、`keyframe-spec`、`video-generation-spec` | 保留按需只读检查与缺失原因,查询不调用生成模型 |
| 视频提示词 | 分镜设计/镜头生产 | 单镜头 `video-prompt`、项目 `video-prompts/readiness` 与 `generate` | 保留单镜头/项目生成,重写需确认 |
@@ -27,7 +27,7 @@
## 新增质量检查与修复
-已接入:首帧 `validate`、`repair`,项目 `keyframes/generate-quality`,视频 `validate`/`repair`,以及单镜头和项目级 `videos/generate-quality`。严格视频候选由后端 Poller 自动执行 Validator,失败时按上限创建 Repair Candidate,只有 PASS 才晋升 Primary;前端识别 `rawJson.qualityCandidate`,禁止未通过候选绕过校验手动设为主视频。生成请求不再传固定 Provider。质量接口的详细参数、默认一镜范围、费用确认、主图晋升、三点视频抽样和会话回执限制见 [前端简化与质量功能同步](frontend-simplification.md)。首帧校验历史尚无 GET,不虚构读取接口;视频已有结果从 `rawJson.videoValidation` 读取。
+已接入:首帧 `validate`、`repair`,项目 `keyframes/generate-quality`,视频 `validate`/`repair`,以及单镜头和项目级 `videos/generate-quality`。严格视频候选由后端 Poller 自动执行 Validator,失败时按上限创建 Repair Candidate,只有 PASS 才晋升 Primary;公开视频列表不再提供质量标记;前端只有在本会话收到 PASS 校验回执后才显示候选的手动设主入口。生成请求不再传固定 Provider。质量接口的详细参数、默认一镜范围、费用确认、主图晋升、视频抽样与专项检查和会话回执限制见 [前端简化与质量功能同步](frontend-simplification.md)。首帧和视频均不虚构质量历史 GET;校验回执按项目、镜头和资产 ID 保存在当前会话。
## 高级串联生产边界
@@ -51,7 +51,7 @@
- 生产页将结构化 `stale_keyframe.missingSubjects` 映射到当前镜头 `/references` 返回的正式 `subjectFormId`,点击可定位图库中实际使用的形态;不按主体名称猜默认形态。缺少关联时退回主体引用检索并明确提示待核对。
- 图库始终读取 keyframes 与 videos 的 readiness,显示下游过期镜头数量;选择来源镜头后才查询其参考图,避免遍历全部镜头的 N+1 请求。只允许定位当前项目列表中的镜头。
- 图库深链接支持 `sourceShotId`、`subjectFormId` 和 `subjectRef`;精确形态优先,切换定位清除旧筛选,丢失的形态不自动回退其他形态。返回生产页携带 `episodeNo` 与正式 `shotId`。
-- “身份主图过期”与“下游首帧过期”独立展示。素材有效而首帧过期时,只需核对后重建首帧;定位、刷新、筛选不触发生成或切换主资产。
+- 形态图库不再有历史母版引用,已移除前端推导的“身份主图过期”筛选和批量重生入口。“下游首帧过期”仍读取后端 readiness;核对素材无误后可重建首帧;定位、刷新、筛选不触发生成或切换主资产。
- 本次后端基线已修复视频 readiness/compiler 缺少当前身份母版参数引起的过期误报。前端保留异常时的两种检查结果对照和“状态待核对”提示,不绕过后端阻塞或自动重复生图。
## 不添加无效或重复入口
diff --git a/docs/frontend-simplification.md b/docs/frontend-simplification.md
index 127c87d..dece738 100644
--- a/docs/frontend-simplification.md
+++ b/docs/frontend-simplification.md
@@ -1,6 +1,6 @@
# 前端简化与质量功能同步
-核对日期:2026-09-03。后端 dev 基线:`05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`。本次只改前端,不更改数据库、后端配置或生成资产。
+核对日期:2026-09-10。后端本地基线:`f0321c2`。精简返回结构见 [接口数据同步说明](api-contract-sync.md)。本次只改前端,不更改数据库、后端配置或生成资产。
## 页面保留什么、收起什么
@@ -37,7 +37,7 @@
| 生产工具栏 → 首帧质量生成 | POST `/projects/:projectId/keyframes/generate-quality` | 当前集、默认最多一镜、并发 1、最多修复 1 次;每镜候选通过后才设主图 |
| 单镜头 → 提交视频质量任务 | POST `/storyboard-shots/:shotId/videos/generate-quality` | 创建严格质量候选;生成完成后由后端自动校验,失败时最多自动修复 2 次,通过后才晋升主视频 |
| 批量生产 → 视频成片 | POST `/projects/:projectId/videos/generate-quality` | 显式限制本批镜头数和每镜修复次数;只提交 ready 镜头,后续 Validator/Repair 由 Poller 推进 |
-| 已完成视频 → 质量检查 | POST `/storyboard-shots/:shotId/videos/:videoId/validate` | 三点抽帧视觉检查,不检测音频/口型/完整连续运动;普通候选不切换主视频,修复候选通过后自动晋升 |
+| 已完成视频 → 质量检查 | POST `/storyboard-shots/:shotId/videos/:videoId/validate` | 抽样视觉与按需运动/道具专项检查,不检测音频/口型;普通候选不切换主视频,修复候选通过后自动晋升 |
批量质量配置与普通批量面板独立。后端非 force 先根据全项目是否有过期项决定筛选,再筛当前集;因此其他集存在过期项而当前集无过期项时,本集不会先补缺失首帧。前端明确提示切换剧集,不悄悄扩大范围。
@@ -45,19 +45,19 @@
### 视频自动质量链与手工复检
-生产页的单镜和批量视频主入口使用 `generate-quality`。HTTP 回执只表示初始 Candidate 已创建;后台 Poller 会在 Provider 完成后自动执行 Validator,失败时按 `maxRepairAttempts` 创建下一代 Repair Candidate,只有通过校验的版本才会晋升 Primary。前端读取 `rawJson.qualityCandidate` 和 `qualityPipeline` 标记质量候选,并禁止未通过候选使用普通“设为主视频”绕过校验。
+生产页的单镜和批量视频主入口使用 `generate-quality`。HTTP 回执只表示初始 Candidate 已创建;后台 Poller 会在 Provider 完成后自动执行 Validator,失败时按 `maxRepairAttempts` 创建下一代 Repair Candidate,只有通过校验的版本才会晋升 Primary。公开视频列表不再提供 `rawJson`。前端以资产 `isPrimary` 展示晋升结果;只有本会话收到 PASS 校验的候选才可使用手动“设为主视频”。不会将缺少历史质量数据标为自动校验中或通过。
视频质量面板新增“生成修复候选”,使用 POST `/storyboard-shots/:shotId/videos/:videoId/repair`,只接受 `maxRepairAttempts`(默认 2,正整数)。该参数是修复链的上限,不是本次自动生成次数。本次只提交一个视频候选,不自动等待、复检或循环重试;修复约束和允许文字沿用已保存的失败校验。
-确认后重新读取视频,未校验、校验已通过、链路到达上限或镜头有活动视频时不提交。候选完成后仍不是主视频,需要对新候选显式执行视觉校验;后端通过后自动晋升,界面在付费确认前说明后果,并显示实际晋升结果。未通过复检的修复候选不提供普通“设为主视频”按钮,不能绕过验收。
+确认后重新读取视频;本会话没有失败校验或镜头有活动视频时不提交。修复链次数不再由内部 JSON 推断,交由后端校验并显示其失败原因。候选完成后仍不是主视频,需要对新候选显式执行视觉校验;后端通过后自动晋升,界面在付费确认前说明后果,并显示实际晋升结果。未通过复检的修复候选不提供普通“设为主视频”按钮,不能绕过验收。
### 状态与风险控制
- 打开页面、菜单、结果和模型能力不会自动调用生成/视觉模型。质量 POST 只在确认后发生,不自动超时重试。
-- 确认后再次 GET 项目完成状态、活动任务、项目镜头列表与目标资产归属。视频必须有完成状态、地址和有效时长;首帧必须完成且有图片。修复和小批量质量生成先检查模型参考图能力,批次检查当前镜头是否仍在生图。
+- 确认后再次 GET 项目完成状态、工作流 checkpoint、项目镜头列表与目标资产归属。项目详情已无内部任务数组,只检查公开工作流执行状态和资产活动状态。视频必须有完成状态、地址和有效时长;首帧必须完成且有图片。修复和小批量质量生成先检查模型参考图能力,批次检查当前镜头是否仍在生图。
- 预检期间关闭面板或换目标不发送 POST。已提交请求继续运行,回执按原项目/镜头/资产保存,不能写入新项目。当前浏览器项目互斥不能代替后端跨客户端锁;仍需确认外部任务停止。
- 结果分别显示视觉通过、修复未通过、调用失败、未执行;不会把 HTTP 200 当质量通过。批量回执提供定位镜头,完整主体检查/逐轮耗时/原始回执可展开和导出。
-- 视频历史结果直接从 `rawJson.videoValidation` 读取,不为“查看结果”重新调用视觉模型。它是历史抽样结论,不保证当前引用仍一致。
+- 视频列表不再返回质量历史;查看本会话校验回执不会重新调用模型。浏览器刷新后不保留通过结论;修复请求失败保留其所依据的失败校验。
- 后端持久化首帧校验,但尚无历史读取 GET。前端只显示当前会话确实收到的首帧回执并支持导出,不虚构历史接口。
- 后端已修复视频校验漏传身份母版导致的过期误报。前端保留两种就绪结果冲突时的防御性提示,不绕过后端阻塞、不自动重复生成。
diff --git a/src/components/ui/AppForm.vue b/src/components/ui/AppForm.vue
new file mode 100644
index 0000000..9959962
--- /dev/null
+++ b/src/components/ui/AppForm.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/DetailDisclosure.vue b/src/components/ui/DetailDisclosure.vue
index 7395495..bfeac00 100644
--- a/src/components/ui/DetailDisclosure.vue
+++ b/src/components/ui/DetailDisclosure.vue
@@ -9,6 +9,11 @@ const expanded = ref([])
function toggle() {
expanded.value = expanded.value.length ? [] : ['details']
}
+defineExpose({
+ expand: () => {
+ expanded.value = ['details']
+ }
+})
+import AppForm from '../../components/ui/AppForm.vue'
+import { NFormItem } from 'naive-ui'
+import { integerRule, fieldRule } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import ActionMenu from '../../components/ui/ActionMenu.vue'
import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
@@ -132,6 +135,11 @@ function exportResult() {
'application/json'
)
}
+const formModel = computed(() => ({ groupSize: groupSize.value, modules: modules.value }))
+const rules = {
+ groupSize: integerRule('每组集数'),
+ modules: fieldRule(value => Array.isArray(value) && value.length > 0, '请至少选择一个抽取模块', true)
+}
@@ -170,64 +178,73 @@ function exportResult() {
来源:正式剧集
-
-
-
-
-
-
- {{ review.passed ? '审核通过' : '需要修改' }}
-
-
- {{ review.message || '本次审核未附加说明。' }}
-
-
-
+
+ {{ state.reviewPassed ? '最近工作流审核通过' : '最近工作流审核未通过' }}
+
查看改写建议原文
diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue
index 89de22e..f4e797d 100644
--- a/src/features/production/ProductionPage.vue
+++ b/src/features/production/ProductionPage.vue
@@ -1,4 +1,7 @@
@@ -219,25 +237,31 @@ watch(
-
-
+
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
@@ -393,63 +417,69 @@ watch(
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
-
-
+ />
-
+ />
-
- 上限须为正整数;宽高须同时留空或填写正整数。
-
-
-
-
-
-
- 本批上限和自动修复次数都必须是正整数。
-
-
+ />
+
@@ -466,7 +496,7 @@ watch(
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
- description="为当前最近一次状态为失败的镜头重新创建视频模型任务。成功镜头和进行中的镜头不会处理。"
+ description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
@@ -591,6 +621,18 @@ watch(
{{ shot.description }}
Shot ID · {{ shot.shotId }}
+
+ {{ videoStatusItem.error || '最近一次视频任务失败' }}
+
+ 此失败不支持普通重试,请先处理失败原因。
+
+ 后端允许重试此任务。
+
+import AppForm from '../../../components/ui/AppForm.vue'
+import { NFormItem } from 'naive-ui'
+import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -42,6 +45,8 @@ function submit() {
open.value = false
reset()
}
+const formModel = computed(() => ({ width: width.value, height: height.value }))
+const rules = sizeRules(() => formModel.value)
@@ -50,43 +55,51 @@ function submit() {
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 图片模型。"
>
-
- 当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
-
-
-
-
-
- 宽高需要同时留空,或同时填写正整数。
- 生成成功后设为当前主首帧
-
-
- 当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
-
- 我已确认调用图片模型可能产生费用,且当前没有相同镜头的生图任务。
-
-
+
+
+ 当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
+
+
+
+
+
+ 生成成功后设为当前主首帧
+
+
+ 当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
+
+ 我已确认调用图片模型可能产生费用,且当前没有相同镜头的生图任务。
+
+
+
diff --git a/src/features/production/components/ProductionReceipt.vue b/src/features/production/components/ProductionReceipt.vue
index 3049bc3..038b727 100644
--- a/src/features/production/components/ProductionReceipt.vue
+++ b/src/features/production/components/ProductionReceipt.vue
@@ -40,7 +40,8 @@ function exportReceipt() {
{{ receipt.result.failed }}
- 检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
+ 检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 可重试 {{ receipt.result.retryable }} · 跳过
+ {{ receipt.result.skipped }} · 已重新提交 {{ receipt.result.retried }} · 提交失败
{{ receipt.result.failed }}
+import AppForm from '../../../components/ui/AppForm.vue'
+import { NFormItem } from 'naive-ui'
+import { integerRule, sizeRules } from '../../../lib/form-rules'
import { computed, ref, watch } from 'vue'
import {
NAlert,
@@ -12,7 +15,7 @@ import {
NRadioGroup
} from 'naive-ui'
import { AppDialog } from '../../../components/ui'
-import { allowedTextLines, savedVideoValidation, validQualityInput } from '../quality'
+import { allowedTextLines, validQualityInput } from '../quality'
import type { QualityAction, QualityInput, QualityTarget } from '../quality.types'
import { useQuality } from '../useQuality'
import QualityResult from './QualityResult.vue'
@@ -23,7 +26,6 @@ const props = defineProps<{
projectId: string
target: QualityTarget | null
disabled: boolean
- savedRawJson?: string | null
}>()
const open = defineModel('open', { default: false })
const emit = defineEmits<{ changed: []; locate: [shotId: string] }>()
@@ -75,7 +77,7 @@ const maxImages = computed(() =>
const maxValidations = computed(() =>
action.value === 'batch' ? maxImages.value : action.value === 'repair' ? input.value.maxRepairAttempts + 1 : 1
)
-const saved = computed(() => (props.target?.kind === 'video' ? savedVideoValidation(props.savedRawJson) : null))
+const saved = computed(() => (props.target?.kind === 'video' ? (session.value.videoValidation ?? null) : null))
const repairAvailable = computed(
() =>
!videoRepair.value ||
@@ -109,6 +111,21 @@ async function submit() {
acknowledged.value = false
await run(action.value, input.value)
}
+const formModel = computed(() => ({
+ attempts: attempts.value,
+ concurrency: concurrency.value,
+ limit: limit.value,
+ width: width.value,
+ height: height.value,
+ texts: texts.value
+}))
+const rules = computed(() => ({
+ attempts: integerRule('修复次数', videoRepair.value ? 1 : 0),
+ concurrency: integerRule('批量并发'),
+ limit: integerRule('本批镜头上限'),
+ ...sizeRules(() => formModel.value)
+}))
+const optionsExpanded = ref([])
@@ -118,126 +135,141 @@ async function submit() {
description="查看质量结果,或按需发起新的视觉检查。"
wide
>
-
- 视觉校验{{ target?.kind === 'video' ? '生成修复候选' : '校验并修复' }}
-
- 视频校验只检查三个抽样帧,不含音频、口型和完整运动。普通候选只校验;修复候选复检通过后会自动设为主视频。
-
-
-
-
- 重做本集已有有效首帧(通过后替换主图)
-
- 仅第 {{ target.episodeNo }} 集,最多
- {{ limit ?? '—' }} 镜;非重做模式优先处理过期项,若全项目无过期项则补缺失首帧。
-
-
- 本次仅创建一个修复视频,不自动等待或复检。完成后请打开新候选的质量检查;修复约束沿用上次失败校验,不使用额外参数。
-
- 请先执行视觉校验,只有未通过的视频可以创建修复候选。
-
-
-
-
-
-
-
-
- 我已确认费用、处理范围,且同项目没有其他生成任务。
-
+
+
{{ operation.label }}进行中,请勿重复提交。
@@ -246,7 +278,7 @@ async function submit() {
}}
{{ saved.summary }}
{{ issue.description }}
@@ -254,8 +286,8 @@ async function submit() {
此为历史抽样结论,不保证当前上游素材仍然一致。
-
- 回执保留在当前会话,可导出;后端暂未提供首帧校验历史读取接口。
+
+ 校验回执保留在当前会话,可导出;素材列表不提供历史质量结果,刷新浏览器后需重新校验。
diff --git a/src/features/production/components/QualityResult.vue b/src/features/production/components/QualityResult.vue
index bd7baf6..e03dc02 100644
--- a/src/features/production/components/QualityResult.vue
+++ b/src/features/production/components/QualityResult.vue
@@ -22,6 +22,16 @@ const issues = computed(() => {
? (value.result.attempts.at(-1)?.issues ?? [])
: value.result.issues
})
+const videoChecks = computed(() => {
+ if (props.receipt.kind !== 'video') return []
+ const result = props.receipt.result
+ return [
+ { label: '运动真实性', result: result.motionRealism },
+ { label: '空间交互', result: result.spatialInteraction },
+ { label: '物理结构', result: result.physicalStructure },
+ { label: '物理连接', result: result.physicalJoint }
+ ].filter(item => item.result)
+})
/** 导出只使用后端实际回执,便于关闭页面前留存。 */
function exportResult() {
downloadText(
@@ -119,6 +129,14 @@ function exportResult() {
: '未发现'
}}
+
+ {{ check.label }}:{{
+ check.result.applicable === false ? '不适用' : check.result.passed ? '通过' : '未通过'
+ }}
+ · {{ check.result.summary }}未执行
+
props.disabled || operation.value.pending ||
const keyframeOpen = ref(false)
const qualityOpen = ref(false)
const qualityTarget = ref
(null)
-const qualityRawJson = computed(() => {
- const target = qualityTarget.value
- return target?.kind === 'video' ? (videos.value.find(item => item.id === target.assetId)?.rawJson ?? null) : null
-})
const specsOpen = ref(false)
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */
@@ -132,7 +128,7 @@ async function generatePrompt() {
const overwrite = props.promptReadiness?.status === 'skipped'
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 视频提示词`, async () => {
const result = await storyboardApi.generatePrompt(shotId, overwrite)
- if (!result || result.id !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
+ if (!result || result.shotId !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
})
await refreshAll()
}
@@ -151,7 +147,12 @@ async function generateKeyframe(input: GenerateKeyframeInput) {
/** 切换主首帧不调用模型,但会改变后续视频生成输入。 */
async function setPrimaryKeyframe() {
const keyframeId = confirmingKeyframe.value
- if (assetBlocked.value || !keyframeId) return
+ if (
+ assetBlocked.value ||
+ !keyframeId ||
+ keyframes.value.find(item => item.id === keyframeId)?.source === 'provider_variant'
+ )
+ return
const shotId = props.shot.shotId
confirmingKeyframe.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主首帧`, async () => {
@@ -195,8 +196,9 @@ async function setPrimaryVideo() {
const video = videos.value.find(item => item.id === videoId)
if (
!video ||
- ((videoRepairInfo(video.rawJson) || videoQualityInfo(video.rawJson)) &&
- savedVideoValidation(video.rawJson)?.passed !== true)
+ video.status !== 'completed' ||
+ !video.videoUrl ||
+ sessionVideoValidation(props.projectId, video.shotId, video.id)?.passed !== true
)
return
const shotId = props.shot.shotId
@@ -322,6 +324,9 @@ async function inspectSpecs() {
主首帧
+
模型输入变体
已过期
{{ item.width || '—' }} × {{ item.height || '—' }}质量检查
主视频
- 修复候选 · 待复检
- 质量候选 ·
- {{
- savedVideoValidation(item.rawJson)
- ? savedVideoValidation(item.rawJson)?.passed
- ? '已通过'
- : '未通过'
- : '自动校验中'
- }}
+ 候选视频
已过期
{{ item.durationSeconds || shot.durationSeconds || '—' }}s质量检查
- · {{ savedVideoValidation(item.rawJson)?.passed ? '曾通过' : '未通过' }}质量检查
+ ·
+ {{
+ sessionVideoValidation(projectId, item.shotId, item.id)?.passed
+ ? '曾通过'
+ : '未通过'
+ }}
尚无视频任务记录。
+
+ 候选视频的历史质量结果未公开;手动设为主视频前需在本会话完成视觉校验。自动质量任务的晋升结果以刷新后的主视频标记为准。
+
- Keyframe ID · {{ videoSpec.keyframe.id }}
+ 业务主首帧 ID · {{ videoSpec.keyframe.id }}
+
+
+ 模型输入首帧 ID · {{ videoSpec.providerInputKeyframe.id }}
+ {{
+ videoSpec.providerInputKeyframe.source === 'provider_variant'
+ ? '(专用变体)'
+ : '(业务主首帧)'
+ }}
item.isPrimary && item.status === 'completed' && item.imageUrl)
+ return items.find(
+ item => item.isPrimary && item.source !== 'provider_variant' && item.status === 'completed' && item.imageUrl
+ )
}
/** 返回当前成功主视频。 */
@@ -47,6 +49,8 @@ export function issueLabel(code: ProductionIssueCode): string {
missing_identity_anchor: '缺少演员母版',
identity_unlocked: '演员身份未锁定',
missing_prompt: '缺少视频提示词',
+ stale_prompt: '视频提示词需要更新',
+ provider_input_recovery: '视频模型输入需要处理',
missing_keyframe: '缺少主首帧',
stale_keyframe: '主首帧已过期'
}
diff --git a/src/features/production/quality.ts b/src/features/production/quality.ts
index 9b860ac..bd840d5 100644
--- a/src/features/production/quality.ts
+++ b/src/features/production/quality.ts
@@ -1,10 +1,12 @@
import { reactive } from 'vue'
import { validOptionalSize } from './model'
import type { KeyframeReadiness } from './types'
-import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation } from './quality.types'
+import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation, VideoValidation } from './quality.types'
/** 未返回的操作不显示上次成功结论;网络失败不会自动重发。 */
-const sessions = reactive>({})
+const sessions = reactive<
+ Record
+>({})
/** 正式 ID 与目标类型共同隔离会话结果,批量回执按剧集隔离。 */
export function qualityKey(projectId: string, target: QualityTarget | null) {
@@ -83,50 +85,7 @@ export function isVisualValidation(value: unknown): value is VisualValidation {
)
}
-/** 读取视频已保存的抽样校验,不为查看历史再次调用视觉模型。 */
-export function savedVideoValidation(rawJson?: string | null): VisualValidation | null {
- try {
- const value: unknown = JSON.parse(rawJson || '{}').videoValidation
- return isVisualValidation(value) ? value : null
- } catch {
- return null
- }
-}
-
-/** 识别后端修复候选标记,避免未复检候选通过普通主视频按钮绕过校验。 */
-export function videoRepairInfo(rawJson?: string | null): { attempt: number } | null {
- try {
- const repair: unknown = JSON.parse(rawJson || '{}').repair
- if (!repair || typeof repair !== 'object' || Array.isArray(repair)) return null
- const attempt = (repair as { attempt?: unknown }).attempt
- return { attempt: typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt >= 0 ? attempt : 0 }
- } catch {
- return null
- }
-}
-
-/** 识别严格质量候选及其自动修复配置,防止未通过候选被手动绕过校验设为主视频。 */
-export function videoQualityInfo(
- rawJson?: string | null
-): { allowedTexts: string[]; maxRepairAttempts: number } | null {
- try {
- const snapshot: unknown = JSON.parse(rawJson || '{}')
- if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
- const record = snapshot as Record
- if (record.qualityCandidate !== true) return null
- const pipeline = record.qualityPipeline
- if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) return null
- const config = pipeline as Record
- const maxRepairAttempts = config.maxRepairAttempts
- if (typeof maxRepairAttempts !== 'number' || !Number.isSafeInteger(maxRepairAttempts) || maxRepairAttempts <= 0)
- return null
- return {
- allowedTexts: Array.isArray(config.allowedTexts)
- ? config.allowedTexts.filter((item): item is string => typeof item === 'string')
- : [],
- maxRepairAttempts
- }
- } catch {
- return null
- }
+/** 公开视频 DTO 不含质量历史,只使用本会话真实收到的校验回执。 */
+export function sessionVideoValidation(projectId: string, shotId: string, assetId: string): VideoValidation | null {
+ return qualitySession(qualityKey(projectId, { kind: 'video', shotId, assetId, title: '' })).videoValidation ?? null
}
diff --git a/src/features/production/quality.types.ts b/src/features/production/quality.types.ts
index 27e4f40..615dca8 100644
--- a/src/features/production/quality.types.ts
+++ b/src/features/production/quality.types.ts
@@ -40,7 +40,7 @@ export interface KeyframeValidation extends VisualValidation {
isPrimary: boolean
}
-/** 视频校验基于三点抽样帧;修复候选通过后后端会自动晋升为主视频。 */
+/** 视频校验包括抽样帧及按需专项检查;修复候选通过后后端会自动晋升为主视频。 */
export interface VideoValidation extends VisualValidation {
shotId: string
videoId: string
@@ -49,6 +49,13 @@ export interface VideoValidation extends VisualValidation {
validatedAt: string
allowedTexts: string[]
sampleFrames: { label: string; timeSeconds: number }[]
+ motionSampleFrames?: { label: string; timeSeconds: number }[]
+ physicalDetailFrames?: { label: string; timeSeconds: number; imageLabel: string }[]
+ propLocator?: { enabled: boolean; [key: string]: unknown }
+ motionRealism?: VideoValidationCheck
+ spatialInteraction?: VideoValidationCheck
+ physicalStructure?: VideoValidationCheck
+ physicalJoint?: VideoValidationCheck
}
/** 视频修复只创建一个异步候选任务,不自动等待视频或再次执行视觉校验。 */
@@ -148,3 +155,8 @@ export type QualityReceipt =
| { kind: 'video-repair'; result: VideoRepair }
| { kind: 'repair'; result: KeyframeRepair }
| { kind: 'batch'; result: QualityBatchResult }
+
+/** 视频专项检查保留完整回执;缺少前置材料时后端会明确停用,不视为通过。 */
+export type VideoValidationCheck =
+ | { enabled: false; reason: string }
+ | { enabled: true; passed: boolean; summary: string; applicable?: boolean; [key: string]: unknown }
diff --git a/src/features/production/testing/fixtures.ts b/src/features/production/testing/fixtures.ts
index ff03238..a47857b 100644
--- a/src/features/production/testing/fixtures.ts
+++ b/src/features/production/testing/fixtures.ts
@@ -8,13 +8,11 @@ export function keyframeFixture(patch: Partial = {}): ShotKeyframe
source: 'generated',
provider: 'seedream',
model: 'seedream-test',
- prompt: '首帧提示词',
imageUrl: '/storage/keyframes/keyframe-1.png',
width: 1920,
height: 1080,
status: 'completed',
isPrimary: true,
- providerTaskId: null,
error: null,
createdAt: '2026-08-31T00:00:00.000Z',
updatedAt: '2026-08-31T00:00:00.000Z',
@@ -29,9 +27,6 @@ export function videoFixture(patch: Partial = {}): ShotVideo {
shotId: 'shot-1',
provider: 'seedance',
model: 'seedance-test',
- providerTaskId: 'provider-task-1',
- prompt: '视频提示词',
- negativePrompt: null,
status: 'completed',
videoUrl: '/storage/videos/video-1.mp4',
durationSeconds: 5,
diff --git a/src/features/production/types.ts b/src/features/production/types.ts
index 11875e8..c7aea51 100644
--- a/src/features/production/types.ts
+++ b/src/features/production/types.ts
@@ -10,6 +10,8 @@ export type ProductionIssueCode =
| 'missing_identity_anchor'
| 'identity_unlocked'
| 'missing_prompt'
+ | 'stale_prompt'
+ | 'provider_input_recovery'
| 'missing_keyframe'
| 'stale_keyframe'
@@ -25,6 +27,7 @@ export interface PromptReadinessItem {
shotId: string
shotNo: number
status: Exclude
+ stalePrompt?: boolean
issues: ProductionIssue[]
}
@@ -68,6 +71,7 @@ export interface VideoReadinessItem {
activeVideoId?: string | null
/** 当前主视频已基于旧主首帧或旧主体参考图生成。 */
primaryVideoStale?: boolean
+ providerInputRecovery?: ProviderInputRecoveryStatus | null
}
/** 视频任务项目级就绪汇总。 */
@@ -80,6 +84,8 @@ export interface VideoReadiness {
missingPrompt: number
missingKeyframe: number
staleKeyframe: number
+ stalePrompt?: number
+ providerInputRecovery?: number
stalePrimaryVideo: number
missingReference: number
items: VideoReadinessItem[]
@@ -89,16 +95,14 @@ export interface VideoReadiness {
export interface ShotKeyframe {
id: string
shotId: string
- source: string
+ source: string | null
provider: string | null
model: string | null
- prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: 'pending' | 'generating' | 'completed' | 'failed'
isPrimary: boolean
- providerTaskId: string | null
error: string | null
createdAt: string
updatedAt: string
@@ -110,16 +114,11 @@ export interface ShotVideo {
shotId: string
provider: string
model: string
- providerTaskId: string | null
- prompt: string
- negativePrompt: string | null
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
videoUrl: string | null
durationSeconds: number | null
isPrimary: boolean
error: string | null
- /** 后端保存的抽样视觉校验与生成快照,按需读取,不直接展示整段原始数据。 */
- rawJson?: string | null
createdAt: string
updatedAt: string
}
@@ -170,7 +169,12 @@ export interface VideoGenerationSpec {
durationSeconds: number
videoPrompt: string
negativePrompt?: string
- keyframe: { id: string; imageUrl: string; width?: number; height?: number }
+ keyframe: VideoInputKeyframe
+ providerInputKeyframe: VideoInputKeyframe & {
+ source: 'business_primary' | 'provider_variant'
+ targetProvider?: string | null
+ variantReason?: string | null
+ }
references: VideoReference[]
}
@@ -255,6 +259,9 @@ export interface ProjectVideoStatusItem {
shotNo: number
status: ShotVideo['status'] | 'not_started'
videoId: string | null
+ failureCategory?: 'content_safety' | 'invalid_request' | 'rate_limit' | 'transient' | 'unknown' | null
+ retryable?: boolean | null
+ providerInputRecovery?: ProviderInputRecoveryStatus | null
videoUrl: string | null
error: string | null
}
@@ -275,6 +282,8 @@ export interface ProjectVideoStatus {
/** 失败视频批量重试回执。 */
export interface RetryVideosResult {
totalFailed: number
+ retryable: number
+ skipped: number
retried: number
failed: number
failures: { shotId: string; error: string }[]
@@ -315,3 +324,14 @@ export interface PipelineBatchResult {
skipped: number
failed: number
}
+
+/** 视频模型最终输入和业务主首帧共用的公开图片规格。 */
+export interface VideoInputKeyframe {
+ id: string
+ imageUrl: string
+ width?: number | null
+ height?: number | null
+}
+
+/** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */
+export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted'
diff --git a/src/features/production/useAdvancedProduction.ts b/src/features/production/useAdvancedProduction.ts
index 927e479..553520d 100644
--- a/src/features/production/useAdvancedProduction.ts
+++ b/src/features/production/useAdvancedProduction.ts
@@ -2,9 +2,9 @@ import { computed, onScopeDispose, ref, watch } from 'vue'
import { projectsApi } from '../projects/api'
import { useProjectContext } from '../projects/context'
import { subjectImagesApi } from '../subject-images/api'
-import { hasRunningImages, isPrimaryIdentityStale, primaryImage } from '../subject-images/model'
+import { hasRunningImages, primaryImage } from '../subject-images/model'
import { getOperation, runOperation } from '../workflows/operations'
-import { workflowCheckpoints } from '../workflows/selectors'
+import { hasRunningWorkflow } from '../workflows/selectors'
import { errorMessage } from '../../lib/http'
import { productionApi } from './api'
import { getProductionSession } from './model'
@@ -33,16 +33,10 @@ export async function checkPipeline(projectId: string) {
)
issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。')
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
- if (
- project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)) ||
- [...new Set(checkpoints.map(item => item.workflowName))].some(
- name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running'
- )
- )
- issues.push('项目仍有后台任务或工作流进行中。')
+ if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。')
if (!forms.length) issues.push('尚无正式形态,请先完成拆解。')
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
- if (forms.some(form => !primaryImage(form.images)?.imageUrl || isPrimaryIdentityStale(form)))
+ if (forms.some(form => !primaryImage(form.images)?.imageUrl))
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
if (
!keyframes.total ||
diff --git a/src/features/production/useQuality.ts b/src/features/production/useQuality.ts
index ac7c7ed..ee6ee08 100644
--- a/src/features/production/useQuality.ts
+++ b/src/features/production/useQuality.ts
@@ -1,5 +1,6 @@
import { computed, onScopeDispose } from 'vue'
import { projectsApi } from '../projects/api'
+import { hasRunningWorkflow } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import { productionApi } from './api'
import { qualityApi } from './quality-api'
@@ -9,8 +10,7 @@ import {
qualitySession,
qualityTargets,
validQualityInput,
- savedVideoValidation,
- videoRepairInfo
+ sessionVideoValidation
} from './quality'
import type { QualityAction, QualityInput, QualityReceipt, QualityTarget, RepairAttempt } from './quality.types'
@@ -58,6 +58,7 @@ export function useQuality(
const receiptTarget = qualitySession(originalKey)
receiptTarget.receipt = null
receiptTarget.error = ''
+ if (target.kind === 'video' && action === 'validate') delete receiptTarget.videoValidation
const stillCurrent = () => !disposed && key.value === originalKey && visible()
const ok = await runOperation(
projectId,
@@ -69,14 +70,14 @@ export function useQuality(
: '首帧自动修复'
: '视觉质量校验',
async () => {
- const [project, readiness] = await Promise.all([
+ const [project, readiness, checkpoints] = await Promise.all([
projectsApi.detail(projectId),
- productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true)
+ productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true),
+ projectsApi.checkpoints(projectId)
])
if (project.id !== projectId || project.status !== 'completed')
throw new Error('剧本未完成或项目已变化,未提交质量任务。')
- if (project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)))
- throw new Error('项目还有后台任务,请结束后再执行质量检查。')
+ if (hasRunningWorkflow(checkpoints)) throw new Error('项目还有后台任务,请结束后再执行质量检查。')
if (
!Array.isArray(readiness.items) ||
readiness.items.length !== readiness.total ||
@@ -142,12 +143,10 @@ export function useQuality(
)
throw new Error('视频未完成、缺少视频地址或有效时长,不能执行抽样校验。')
if (action === 'repair') {
- if (savedVideoValidation(video.rawJson)?.passed !== false)
+ if (sessionVideoValidation(projectId, target.shotId, target.assetId)?.passed !== false)
throw new Error('请先完成视频质量校验;只有未通过的视频才能创建修复候选。')
if (videos.some(item => ['pending', 'queued', 'running'].includes(item.status)))
throw new Error('此镜头仍有活动视频任务,请结束后再修复。')
- if ((videoRepairInfo(video.rawJson)?.attempt ?? 0) + 1 > input.maxRepairAttempts)
- throw new Error('当前视频修复链已达到次数上限,请核对后再调整。')
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
const result = await qualityApi.repairVideo(
target.shotId,
@@ -179,13 +178,15 @@ export function useQuality(
!isVisualValidation(result) ||
result.shotId !== target.shotId ||
result.videoId !== target.assetId ||
- !Array.isArray(result.sampleFrames)
+ !Array.isArray(result.sampleFrames) ||
+ typeof result.isPrimary !== 'boolean'
)
throw new Error('视频校验结果与目标不匹配或不完整,请核对后端记录。')
+ receiptTarget.videoValidation = result
receipt = {
kind: 'video',
result,
- promoted: !!videoRepairInfo(video.rawJson) && result.passed && result.isPrimary
+ promoted: !video.isPrimary && result.passed && result.isPrimary
}
}
} else {
diff --git a/src/features/projects/components/CreateProjectDialog.vue b/src/features/projects/components/CreateProjectDialog.vue
index 61e78f6..2ee5e3a 100644
--- a/src/features/projects/components/CreateProjectDialog.vue
+++ b/src/features/projects/components/CreateProjectDialog.vue
@@ -1,4 +1,7 @@
@@ -49,40 +49,44 @@ async function submit() {
新建剧本
-
+
diff --git a/src/features/projects/types.ts b/src/features/projects/types.ts
index 8106dfa..649afe0 100644
--- a/src/features/projects/types.ts
+++ b/src/features/projects/types.ts
@@ -52,21 +52,11 @@ export interface World {
rules?: unknown
}
-/** 后端审核记录,列表按最新在前返回。 */
-export interface Review {
- id: string
- passed: boolean
- message?: string | null
- createdAt: string
-}
-
/** 项目详情接口包含的关联数据。 */
export interface ProjectDetail extends Project {
episodes: Episode[]
characters: Character[]
world: World | null
- reviews: Review[]
- tasks: { id: string; type: string; status: string; error?: string | null }[]
}
/** Create Drama checkpoint 中页面使用的状态切片。 */
diff --git a/src/features/storyboard/StoryboardPage.vue b/src/features/storyboard/StoryboardPage.vue
index aa10e6b..c9df4f3 100644
--- a/src/features/storyboard/StoryboardPage.vue
+++ b/src/features/storyboard/StoryboardPage.vue
@@ -1,4 +1,7 @@
@@ -156,42 +159,42 @@ watch(
>
-
-
+
diff --git a/src/features/storyboard/components/ShotTools.vue b/src/features/storyboard/components/ShotTools.vue
index 39602c7..a24b062 100644
--- a/src/features/storyboard/components/ShotTools.vue
+++ b/src/features/storyboard/components/ShotTools.vue
@@ -77,7 +77,7 @@ async function generatePrompt() {
// 请求结果不确定时不要保留可能已被覆盖的旧正文。
delete session.prompts[shotId]
const result = await storyboardApi.generatePrompt(shotId, overwrite)
- if (!result || result.id !== shotId || !result.videoPrompt)
+ if (!result || result.shotId !== shotId || !result.videoPrompt)
throw new Error('接口未返回此镜头的有效提示词,请检查后端记录后重试。')
session.prompts[shotId] = result
})
diff --git a/src/features/storyboard/types.ts b/src/features/storyboard/types.ts
index 89e61ae..11b76ef 100644
--- a/src/features/storyboard/types.ts
+++ b/src/features/storyboard/types.ts
@@ -145,7 +145,8 @@ export interface ShotReferences {
/** 视频提示词接口返回的 Shot 数据切片,不包含实际视频生成。 */
export interface ShotPromptResult {
- id: string
+ shotId: string
+ updatedAt: string
videoPrompt: string | null
negativePrompt: string | null
status: string
diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue
index 456cc3a..537c109 100644
--- a/src/features/subject-identity/SubjectIdentityPage.vue
+++ b/src/features/subject-identity/SubjectIdentityPage.vue
@@ -1,4 +1,7 @@
@@ -251,64 +256,73 @@ watch(
{{ casting.ready }}
-
-
- 并发数
-
-
-
- 本批上限
-
-
- 覆盖未锁定身份文本
-
-
-
-
-
- 并发数与本批上限都必须是正整数。
-
+
+
+
+
+
+
+
+ 覆盖未锁定身份文本
+
+
+
+
+
+import AppForm from '../../../components/ui/AppForm.vue'
+import { NFormItem } from 'naive-ui'
+import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInput, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -33,17 +36,31 @@ function submit() {
})
open.value = false
}
+const formModel = computed(() => ({ width: width.value, height: height.value, prompt: prompt.value }))
+const rules = { ...sizeRules(() => formModel.value) }
-
-
+
diff --git a/src/features/subject-identity/components/IdentityEditor.vue b/src/features/subject-identity/components/IdentityEditor.vue
index c529177..f083926 100644
--- a/src/features/subject-identity/components/IdentityEditor.vue
+++ b/src/features/subject-identity/components/IdentityEditor.vue
@@ -1,4 +1,6 @@
-
-
+
diff --git a/src/features/subject-identity/components/IdentityGallery.vue b/src/features/subject-identity/components/IdentityGallery.vue
index b90d562..77df48c 100644
--- a/src/features/subject-identity/components/IdentityGallery.vue
+++ b/src/features/subject-identity/components/IdentityGallery.vue
@@ -1,10 +1,10 @@
-
+
diff --git a/src/features/subject-identity/index.ts b/src/features/subject-identity/index.ts
index c5e39b4..ba763aa 100644
--- a/src/features/subject-identity/index.ts
+++ b/src/features/subject-identity/index.ts
@@ -1,4 +1,4 @@
/** 主体身份模块公共入口。 */
export { subjectIdentityApi } from './api'
-export { currentAnchor, readImageProvenance } from './model'
+export { currentAnchor } from './model'
export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
diff --git a/src/features/subject-identity/model.ts b/src/features/subject-identity/model.ts
index 47a05b3..615e32a 100644
--- a/src/features/subject-identity/model.ts
+++ b/src/features/subject-identity/model.ts
@@ -67,28 +67,9 @@ export function canBeAnchor(image: IdentityImage): boolean {
return image.viewType === 'primary' && image.status === 'completed' && !!image.imageUrl
}
-/** 使用后端 isAnchor,不把 front/full-body 的 enabled 错当成母版。 */
+/** 使用后端明确的 isAnchor 标记,并校验图片已完成且为 primary 视角。 */
export function currentAnchor(images: IdentityImage[]): IdentityImage | undefined {
- return images.find(image => image.isAnchor && image.enabled && canBeAnchor(image))
-}
-
-/** 仅读取追溯所需 ID,不把原始 Provider 数据渲染为 HTML。 */
-export function readImageProvenance(rawJson?: string | null): {
- referenceImageId?: string
- identityAnchorImageId?: string
-} {
- try {
- const parsed: unknown = JSON.parse(rawJson || '{}')
- if (!parsed || typeof parsed !== 'object') return {}
- const value = parsed as Record
- return {
- referenceImageId: typeof value.referenceImageId === 'string' ? value.referenceImageId : undefined,
- identityAnchorImageId:
- typeof value.identityAnchorImageId === 'string' ? value.identityAnchorImageId : undefined
- }
- } catch {
- return {}
- }
+ return images.find(image => image.isAnchor && canBeAnchor(image))
}
/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
diff --git a/src/features/subject-identity/testing/fixtures.ts b/src/features/subject-identity/testing/fixtures.ts
index 0821ab1..57d5eef 100644
--- a/src/features/subject-identity/testing/fixtures.ts
+++ b/src/features/subject-identity/testing/fixtures.ts
@@ -8,7 +8,6 @@ export function identityFixture(overrides: Partial = {}): Subje
description: '稳定面部特征',
generationPrompt: '保持相同五官与骨相',
isLocked: false,
- images: [],
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
@@ -24,12 +23,10 @@ export function identityImageFixture(overrides: Partial = {}): Id
viewType: 'primary',
provider: 'seedream',
model: 'configured-model',
- prompt: '',
imageUrl: '/storage/identity.png',
width: 2048,
height: 2048,
status: 'completed',
- enabled: true,
isAnchor: true,
error: null,
createdAt: '2026-08-28T00:00:00Z',
diff --git a/src/features/subject-identity/types.ts b/src/features/subject-identity/types.ts
index 01a66c6..54fb93a 100644
--- a/src/features/subject-identity/types.ts
+++ b/src/features/subject-identity/types.ts
@@ -3,7 +3,7 @@ import type { SubjectImageStatus, SubjectFormAsset } from '../subject-images/typ
/** 只有 primary 视图可以选为身份母版,其余视图是辅助参考。 */
export type IdentityViewType = 'primary' | 'front' | 'three-quarter' | 'full-body'
-/** 身份参考图;isAnchor 仅由专用图片查询接口返回,不能用 enabled 代替。 */
+/** 身份参考图公开 DTO;查询与变更均返回明确的 isAnchor 标记。 */
export interface IdentityImage {
id: string
identityId: string
@@ -11,15 +11,12 @@ export interface IdentityImage {
viewType: IdentityViewType
provider: string | null
model: string | null
- prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: SubjectImageStatus
- enabled: boolean
- isAnchor?: boolean
+ isAnchor: boolean
error: string | null
- rawJson?: string | null
createdAt: string
updatedAt: string
}
@@ -31,7 +28,6 @@ export interface SubjectIdentity {
description: string | null
generationPrompt: string | null
isLocked: boolean
- images: IdentityImage[]
createdAt: string
updatedAt: string
}
@@ -138,7 +134,9 @@ export interface CastingBatchResult {
/** 确认选角会在同一事务中切换 Anchor 并锁定 Identity。 */
export interface ConfirmCastingResult {
- identity: Omit
+ identityId: string
+ subjectId: string
+ isLocked: boolean
anchor: IdentityImage
}
diff --git a/src/features/subject-identity/useSubjectIdentity.ts b/src/features/subject-identity/useSubjectIdentity.ts
index ee97526..13bcda8 100644
--- a/src/features/subject-identity/useSubjectIdentity.ts
+++ b/src/features/subject-identity/useSubjectIdentity.ts
@@ -17,8 +17,7 @@ import type {
/** 单主体保存响应必须与提交时固定的正式 ID 一致。 */
function assertIdentity(value: SubjectIdentity, id: string) {
- if (!value || value.subjectId !== id || value.images.some(image => image.identityId !== value.id))
- throw new Error('后端未返回匹配的主体身份,请刷新核对。')
+ if (!value || value.subjectId !== id) throw new Error('后端未返回匹配的主体身份,请刷新核对。')
}
/** 主体目录、项目风格和当前身份分开查询,未创建 Identity 不误报为空图库错误。 */
@@ -256,7 +255,7 @@ export function useSubjectIdentity() {
!result ||
result.id !== imageId ||
result.identityId !== identityId ||
- !result.enabled ||
+ !result.isAnchor ||
!canBeAnchor(result)
)
throw new Error('接口未确认身份母版切换,请刷新核对。')
@@ -282,10 +281,13 @@ export function useSubjectIdentity() {
const result = await subjectIdentityApi.confirmCasting(subjectId, imageId)
if (
!result ||
- result.identity.id !== identityId ||
- !result.identity.isLocked ||
+ result.identityId !== identityId ||
+ result.subjectId !== subjectId ||
+ !result.isLocked ||
result.anchor.id !== imageId ||
- !result.anchor.enabled
+ !result.anchor.isAnchor ||
+ result.anchor.identityId !== identityId ||
+ !canBeAnchor(result.anchor)
)
throw new Error('接口未确认演员母版与身份锁定,请刷新核对。')
})
diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue
index f117827..1c0128a 100644
--- a/src/features/subject-images/SubjectImagesPage.vue
+++ b/src/features/subject-images/SubjectImagesPage.vue
@@ -1,4 +1,7 @@
@@ -340,7 +329,7 @@ async function showStaleForms() {
人物、场景、道具形态图均可继承已锁定 Identity
- 的母版,并记录本次引用来源。母版变化后可筛选过期图,重新生成候选并人工确认新的主参考图;不要直接沿用旧资产进入下游。
+ 的母版。母版变化后请核对形态图,按需生成候选并确认主图。当前图库不提供历史母版引用,无法自动判断形态图是否过期;下游状态以镜头就绪检查为准。
管理主体身份 →
{{ completeCount }} / {{ forms.length }} 个形态已有主图
-
- {{ staleForms.length }} 个形态主图已过期
-
图片模型 · 后端配置
图库不自动刷新,可点击“刷新图库”读取最新记录。主图供分镜引用,候选图和历史失败记录均保留;过期主图不会自动删除或替换。
-
-
- 检测到 {{ staleForms.length }} 个形态 仍在使用旧 Identity
- Anchor。
-
-
项目批量生图
-
-
- 批量并发
-
+
+
+
+
+
+
-
- 本批上限(可选)
- 已有主图也新增候选图
-
-
-
-
- 并发和填写的数量上限必须是正整数。
-
+ class="control-row-checkbox text-xs"
+ >已有主图也新增候选图
+
+
+
+
作用于整个项目,不受下方筛选影响。上限只限制本次真正生图数量,其余显示为“待后续处理”,不计入跳过。默认使用后端尺寸与正式提示词,不会启动视频生成。
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
-
- 新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
-
-
{{ failure.subjectFormId }}
-
- {{ staleForms.length }}
- 个形态主图与当前已锁定身份母版不一致。请生成候选、验图并切换主图,再重建相关首帧。
- 筛选身份过期形态
-
- 身份母版已变更
主参考图
候选图 · 尚未设主图
{{ form.images.length }} 条记录
-
- 当前主图未能匹配最新已锁定母版。请基于当前 Identity Anchor
- 重新生成候选图,并在确认后设置为新的主参考图。
-
返回该镜头处理 →
- {{
- isPrimaryIdentityStale(form)
- ? '基于当前母版重新生成'
- : coverImage(form)
- ? '再生成一张'
- : '生成图片'
- }} {{ coverImage(form) ? '再生成一张' : '生成图片' }}
diff --git a/src/features/subject-images/components/FormPromptBatch.vue b/src/features/subject-images/components/FormPromptBatch.vue
index 29b482a..66df9ff 100644
--- a/src/features/subject-images/components/FormPromptBatch.vue
+++ b/src/features/subject-images/components/FormPromptBatch.vue
@@ -1,4 +1,7 @@
@@ -30,34 +35,34 @@ function exportReceipt() {
正式提示词 {{ complete }} /
{{ forms.length }}。结合项目视觉风格生成,只保存文本,不自动生图。批量面向全项目,不受搜索筛选影响。
-
-
提示词并发
-
+
+
+
+
+ 覆盖已有正式提示词
+ emit('generate'))"
/>
-
- 覆盖已有正式提示词
-
-
- 提示词并发必须是正整数。
+
+
{{ receipt.title }}
diff --git a/src/features/subject-images/components/GenerateImageDialog.vue b/src/features/subject-images/components/GenerateImageDialog.vue
index 75aaa6f..1e49c84 100644
--- a/src/features/subject-images/components/GenerateImageDialog.vue
+++ b/src/features/subject-images/components/GenerateImageDialog.vue
@@ -1,4 +1,7 @@
@@ -47,7 +52,15 @@ function submit() {
title="生成形态图片"
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
>
-
-
+
diff --git a/src/features/subject-images/components/ImageGalleryDialog.vue b/src/features/subject-images/components/ImageGalleryDialog.vue
index dbe6ea6..63dd3a1 100644
--- a/src/features/subject-images/components/ImageGalleryDialog.vue
+++ b/src/features/subject-images/components/ImageGalleryDialog.vue
@@ -1,15 +1,14 @@
',
...overrides
}
}
diff --git a/src/features/subject-images/types.ts b/src/features/subject-images/types.ts
index 8d11293..55d6652 100644
--- a/src/features/subject-images/types.ts
+++ b/src/features/subject-images/types.ts
@@ -15,18 +15,13 @@ export interface SubjectImage {
error: string | null
createdAt: string
updatedAt: string
- /** 详情查询才返回实际使用的 Prompt;列表不包含长文本。 */
- prompt?: string
- negativePrompt?: string | null
- /** 形态图生成追溯信息,包含当时使用的 Identity Anchor ID。 */
- rawJson?: string | null
}
-/** 当前主体 Identity 只暴露形态图库判断新旧母版所需的轻量信息。 */
+/** 当前主体 Identity 的公开摘要;只说明当前母版,不包含图片的历史引用。 */
export interface SubjectFormIdentitySummary {
id: string
isLocked: boolean
- images: { id: string }[]
+ anchorImageId: string | null
}
/** 正式 SubjectForm 及其所属主体、图片,不使用 checkpoint 的领域 formId。 */
@@ -79,7 +74,10 @@ export interface ImageBatchResult {
}
/** 正式提示词生成只返回形态字段,不包含图库列表的 images 关联。 */
-export type FormPromptResult = Pick
+export type FormPromptResult = Omit & {
+ createdAt: string
+ updatedAt: string
+}
/** 项目提示词增强的批量参数,与图片生成的 force、limit 独立。 */
export interface GenerateFormPromptsInput {
diff --git a/src/features/subject-images/useSubjectImages.ts b/src/features/subject-images/useSubjectImages.ts
index fc1ee96..d3c7245 100644
--- a/src/features/subject-images/useSubjectImages.ts
+++ b/src/features/subject-images/useSubjectImages.ts
@@ -5,8 +5,8 @@ import { useProjectContext } from '../projects/context'
import { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors'
import { subjectImagesApi } from './api'
-import { getImageSession, hasRunningImages, isPrimaryIdentityStale } from './model'
-import type { GenerateFormImageInput, SubjectFormAsset } from './types'
+import { getImageSession, hasRunningImages } from './model'
+import type { GenerateFormImageInput } from './types'
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
export function useSubjectImages() {
@@ -44,7 +44,6 @@ export function useSubjectImages() {
false
)
const forms = computed(() => query.data.value ?? [])
- const staleForms = computed(() => forms.value.filter(form => isPrimaryIdentityStale(form)))
const operation = computed(() => getOperation(id.value))
const session = computed(() => getImageSession(id.value))
const running = computed(() => forms.value.some(form => hasRunningImages(form.images)))
@@ -138,59 +137,9 @@ export function useSubjectImages() {
await query.refresh()
}
- /**
- * 只刷新 Identity Anchor 已变化的形态,覆盖人物、场景与道具。
- * 与确认文案一致:仅生成候选,用户验图后再显式切换主图。
- */
- async function generateStale() {
- if (blocked.value || !concurrencyValid.value || !staleForms.value.length) return
- const projectId = id.value
- const targets = [...staleForms.value]
- const total = forms.value.length
- const receipt = getImageSession(projectId)
- receipt.receipt = null
-
- await runOperation(projectId, '刷新过期形态图', async () => {
- const results = await runWithConcurrency(targets, concurrency.value, async form => {
- try {
- const image = await subjectImagesApi.generate(form.id, {
- setPrimary: false
- })
- if (!image || image.subjectFormId !== form.id || image.status !== 'completed' || !image.imageUrl) {
- throw new Error(image?.error || '接口未返回已完成的候选图')
- }
- return { subjectFormId: form.id, success: true as const }
- } catch (error) {
- return {
- subjectFormId: form.id,
- success: false as const,
- error: error instanceof Error ? error.message : String(error)
- }
- }
- })
- const failures = results
- .filter(item => !item.success)
- .map(item => ({ subjectFormId: item.subjectFormId, error: item.error }))
-
- receipt.receipt = {
- title: '刷新过期形态图',
- result: {
- total,
- targetCount: targets.length,
- generated: results.filter(item => item.success).length,
- skipped: total - targets.length,
- failed: failures.length,
- failures
- }
- }
- })
- await query.refresh()
- }
-
return {
id,
forms,
- staleForms,
query,
refreshProject: context.refresh,
operation,
@@ -208,31 +157,6 @@ export function useSubjectImages() {
running,
batchValid,
generate,
- generateProject,
- generateStale
+ generateProject
}
}
-
-/** 使用固定并发数执行需要付费的单形态生图请求。 */
-async function runWithConcurrency(
- items: SubjectFormAsset[],
- concurrency: number,
- handler: (item: SubjectFormAsset) => Promise
-): Promise {
- const results: TResult[] = []
- let currentIndex = 0
-
- async function worker() {
- while (currentIndex < items.length) {
- const index = currentIndex
- currentIndex += 1
- const item = items[index]
- if (!item) continue
- results[index] = await handler(item)
- }
- }
-
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
- await Promise.all(workers)
- return results
-}
diff --git a/src/features/visual-style/components/StyleEditor.vue b/src/features/visual-style/components/StyleEditor.vue
index f99b831..63e7fff 100644
--- a/src/features/visual-style/components/StyleEditor.vue
+++ b/src/features/visual-style/components/StyleEditor.vue
@@ -1,4 +1,7 @@
-
+
diff --git a/src/features/visual-style/components/StyleImages.vue b/src/features/visual-style/components/StyleImages.vue
index 01fee93..407d950 100644
--- a/src/features/visual-style/components/StyleImages.vue
+++ b/src/features/visual-style/components/StyleImages.vue
@@ -1,6 +1,9 @@
@@ -40,19 +43,18 @@ function remove(image: VisualStyleImage) {
登记已有 HTTP(S) 或 /storage/
图片地址。后端尚无文件上传接口;这些图片目前仅管理记录,现有身份/形态生图不会自动将风格图片传给模型。
-
-
- 请填写有效图片地址和整数排序。
-
+
{{ image.provider }} {{ image.model }}
- 实际提示词
- {{ image.prompt }}
= {}): Vi
sortOrder: 0,
provider: null,
model: null,
- prompt: null,
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
diff --git a/src/features/visual-style/types.ts b/src/features/visual-style/types.ts
index 387e1cc..a93668f 100644
--- a/src/features/visual-style/types.ts
+++ b/src/features/visual-style/types.ts
@@ -12,7 +12,6 @@ export interface VisualStyleImage {
sortOrder: number
provider: string | null
model: string | null
- prompt: string | null
createdAt: string
updatedAt: string
}
diff --git a/src/features/workflows/selectors.ts b/src/features/workflows/selectors.ts
index 88b7c33..395a1f5 100644
--- a/src/features/workflows/selectors.ts
+++ b/src/features/workflows/selectors.ts
@@ -43,3 +43,10 @@ export function recoveryOptions(checkpoints: Checkpoint[]) {
)
return { retry, shots, storyboard }
}
+
+/** 项目公开详情不含内部任务;按每条工作流的最新 checkpoint 检查活动执行。 */
+export function hasRunningWorkflow(checkpoints: Checkpoint[]): boolean {
+ return [...new Set(checkpoints.map(item => item.workflowName))].some(
+ name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running'
+ )
+}
diff --git a/src/lib/form-rules.ts b/src/lib/form-rules.ts
new file mode 100644
index 0000000..09dc8bb
--- /dev/null
+++ b/src/lib/form-rules.ts
@@ -0,0 +1,52 @@
+import type { FormItemRule } from 'naive-ui'
+import { referenceImageUrl } from './assets'
+
+const trigger = ['input', 'change', 'blur']
+
+/** 字段错误交给 Naive FormItem 呈现;业务层仍可独立检查接口参数。 */
+export function fieldRule(check: (value: unknown) => boolean, message: string, required = false): FormItemRule {
+ return { trigger, required, validator: (_rule, value) => check(value) || new Error(message) }
+}
+
+export const requiredTextRule = (label: string) =>
+ fieldRule(value => typeof value === 'string' && !!value.trim(), `请填写${label}`, true)
+
+/** 可选数字仅将真正的空值视为空,不把 0 或小数悄悄转换成默认值。 */
+export function integerRule(label: string, min = 1, optional = false): FormItemRule {
+ return fieldRule(
+ value => (optional && isEmpty(value)) || (Number.isSafeInteger(value) && Number(value) >= min),
+ `${label}须为${min === 0 ? '非负' : '正'}整数${optional ? ',或留空' : ''}`,
+ !optional
+ )
+}
+
+export function isEmpty(value: unknown): boolean {
+ return value === '' || value === null || value === undefined
+}
+
+/** 两个尺寸字段共同校验,修改任何一侧都能更新另一侧的错误。 */
+export function sizeRules(values: () => { width: unknown; height: unknown }) {
+ const rule = fieldRule(() => {
+ const { width, height } = values()
+ return (
+ (isEmpty(width) && isEmpty(height)) ||
+ (Number.isSafeInteger(width) && Number(width) > 0 && Number.isSafeInteger(height) && Number(height) > 0)
+ )
+ }, '宽高须同时填写正整数,或同时留空')
+ return { width: rule, height: rule }
+}
+
+export const imageUrlRule = fieldRule(
+ value => typeof value === 'string' && !!referenceImageUrl(value),
+ '请填写有效的 HTTP(S) 或 /storage/ 图片地址',
+ true
+)
+
+export const stringArrayJsonRule = fieldRule(value => {
+ try {
+ const parsed: unknown = JSON.parse(String(value))
+ return Array.isArray(parsed) && parsed.every(item => typeof item === 'string')
+ } catch {
+ return false
+ }
+}, '请输入 JSON 字符串数组,例如 ["真人写实", "禁止二次元"]')
diff --git a/src/testing/project-context.ts b/src/testing/project-context.ts
index dab3bef..96cc4af 100644
--- a/src/testing/project-context.ts
+++ b/src/testing/project-context.ts
@@ -16,9 +16,7 @@ export function testProjectContext(id = 'capability-test'): ReturnType {
await flushPromises()
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await wrapper.get('form').trigger('submit')
+ await flushPromises()
expect(wrapper.emitted('save')).toMatchObject([
[{ characterPrompt: '人物风格', hardConstraints: ['保留硬约束'] }]
])
diff --git a/tests/components/ui/form.test.ts b/tests/components/ui/form.test.ts
new file mode 100644
index 0000000..6053a09
--- /dev/null
+++ b/tests/components/ui/form.test.ts
@@ -0,0 +1,165 @@
+import { defineComponent, h, reactive } from 'vue'
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { NFormItem, NInput } from 'naive-ui'
+import AppForm from '@/components/ui/AppForm.vue'
+import CreateProjectDialog from '@/features/projects/components/CreateProjectDialog.vue'
+import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
+import StyleEditor from '@/features/visual-style/components/StyleEditor.vue'
+import StyleImages from '@/features/visual-style/components/StyleImages.vue'
+import { formFixture } from '@/features/subject-images/testing/fixtures'
+import { requiredTextRule } from '@/lib/form-rules'
+
+let wrapper: VueWrapper | undefined
+afterEach(() => {
+ wrapper?.unmount()
+ wrapper = undefined
+ document.body.innerHTML = ''
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+})
+function button(text: string) {
+ const result = [...document.querySelectorAll('button')].find(
+ el => el.textContent?.trim() === text
+ )
+ if (!result) throw new Error(`找不到按钮:${text}`)
+ return result
+}
+async function input(selector: string, value: string) {
+ const el = document.querySelector(selector)!
+ el.value = value
+ el.dispatchEvent(new Event('input', { bubbles: true }))
+ await flushPromises()
+}
+function feedback() {
+ return [...document.querySelectorAll('.n-form-item-feedback--error')].map(el => el.textContent).join(' ')
+}
+async function submit() {
+ document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ await flushPromises()
+}
+
+describe('统一的 Naive UI 表单校验', () => {
+ it('新建剧本使用字段反馈拦截空白主题和小数集数,修正后只发送一次请求', async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
+ vi.stubGlobal('fetch', fetcher)
+ wrapper = mount(CreateProjectDialog, { attachTo: document.body })
+ button('新建剧本').click()
+ await flushPromises()
+ expect(document.querySelector('form')!.noValidate).toBe(true)
+ expect(document.querySelector('[required]')).toBeNull()
+ await input('#topic', ' ')
+ const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
+ await submit()
+ expect(feedback()).toContain('请填写故事主题')
+ // 无布局的 DOM 环境会被模态框焦点圈带回哨兵,检查实际聚焦目标。
+ expect(focus.mock.contexts).toContain(document.querySelector('#topic'))
+ expect(fetcher).not.toHaveBeenCalled()
+ await input('#topic', ' 雨夜来信 ')
+ await input('#episode-count', '1.5')
+ await submit()
+ expect(feedback()).toContain('计划集数须为正整数')
+ expect(fetcher).not.toHaveBeenCalled()
+ await input('#episode-count', '3')
+ expect(feedback()).toBe('')
+ button('开始生成').click()
+ button('开始生成').click()
+ await flushPromises()
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted('created')).toEqual([['created']])
+ })
+
+ it('图片宽高错误显示在两侧字段,补全后同时消失,切换目标清除校验状态', async () => {
+ wrapper = mount(GenerateImageDialog, {
+ attachTo: document.body,
+ props: { open: true, form: formFixture(), disabled: false }
+ })
+ await flushPromises()
+ await input('#image-width', '1024')
+ expect(document.querySelectorAll('.n-form-item-feedback--error')).toHaveLength(2)
+ expect(feedback()).toContain('宽高须同时填写正整数')
+ await input('#image-height', '768')
+ expect(feedback()).toBe('')
+ await input('#image-height', '')
+ expect(feedback()).not.toBe('')
+ await wrapper.setProps({ form: formFixture('other') })
+ await flushPromises()
+ expect(feedback()).toBe('')
+ expect(wrapper.emitted('generate')).toBeUndefined()
+ })
+
+ it('折叠后的非法 JSON 仍拦截保存并展开错误,修正后保留草稿正常提交', async () => {
+ wrapper = mount(StyleEditor, { attachTo: document.body, props: { visualStyle: null, disabled: false } })
+ await flushPromises()
+ button('分类风格与硬约束').click()
+ await flushPromises()
+ await input('#style-name', '手工风格')
+ await input('#style-constraints', '{"invalid":true}')
+ button('分类风格与硬约束').click()
+ await flushPromises()
+ await submit()
+ expect(wrapper.emitted('save')).toBeUndefined()
+ expect(feedback()).toContain('请输入 JSON 字符串数组')
+ expect(button('分类风格与硬约束').getAttribute('aria-expanded')).toBe('true')
+ await input('#style-constraints', '["真人写实"]')
+ await submit()
+ expect(wrapper.emitted('save')?.[0]?.[0]).toMatchObject({ name: '手工风格', hardConstraints: ['真人写实'] })
+ })
+
+ it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => {
+ wrapper = mount(StyleImages, { attachTo: document.body, props: { images: [], disabled: false } })
+ await input('[aria-label="风格图片地址"]', 'javascript:alert(1)')
+ await input('[aria-label="风格图片排序"]', '1.5')
+ await submit()
+ expect(feedback()).toContain('请填写有效的 HTTP(S) 或 /storage/ 图片地址')
+ expect(feedback()).toContain('排序须为整数')
+ expect(wrapper.emitted('add')).toBeUndefined()
+ await input('[aria-label="风格图片地址"]', '/storage/style.png')
+ await input('[aria-label="风格图片排序"]', '-1')
+ await submit()
+ expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 })
+ })
+
+ it('异步验证期间切换对象或重复提交不会执行旧动作', async () => {
+ let finish: (() => void) | undefined
+ const model = reactive({ text: '有效输入' })
+ const onSubmit = vi.fn<() => void>()
+ wrapper = mount(
+ defineComponent({
+ props: { target: { type: String, default: 'first' } },
+ setup(props) {
+ return () =>
+ h(
+ AppForm,
+ {
+ model,
+ resetKey: props.target,
+ onSubmit,
+ rules: {
+ text: {
+ ...requiredTextRule('文本'),
+ asyncValidator: () =>
+ new Promise(resolve => {
+ finish = resolve
+ })
+ }
+ }
+ },
+ () => h(NFormItem, { path: 'text', label: '文本' }, () => h(NInput, { value: model.text }))
+ )
+ }
+ }),
+ { attachTo: document.body }
+ )
+ const form = document.querySelector('form')!
+ form.dispatchEvent(new Event('submit', { cancelable: true }))
+ form.dispatchEvent(new Event('submit', { cancelable: true }))
+ await flushPromises()
+ await wrapper.setProps({ target: 'second' })
+ finish!()
+ await flushPromises()
+ expect(onSubmit).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/components/ui/layout.test.ts b/tests/components/ui/layout.test.ts
index c0ca3c1..36c5449 100644
--- a/tests/components/ui/layout.test.ts
+++ b/tests/components/ui/layout.test.ts
@@ -386,9 +386,7 @@ describe('管理后台组件边界', () => {
updatedAt: '',
episodes: [],
characters: [],
- world: null,
- reviews: [],
- tasks: []
+ world: null
})
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const paths = [
diff --git a/tests/features/breakdown/BreakdownPage.test.ts b/tests/features/breakdown/BreakdownPage.test.ts
index b20a335..8a0e077 100644
--- a/tests/features/breakdown/BreakdownPage.test.ts
+++ b/tests/features/breakdown/BreakdownPage.test.ts
@@ -111,8 +111,7 @@ function mountPage(records = [checkpoint()], episodes = 1): ReturnType {
await wrapper!.get('[data-workspace-tools]').trigger('click')
await flushPromises()
const config = drawerPanel().get('.breakdown-config')
- expect(config.get('label[for="group-size"]').text()).toBe('每组集数')
+ expect(config.get('label[for="group-size"]').text()).toContain('每组集数')
expect(config.get('#group-size').element.value).toBe('3')
expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组')
- expect(config.get('fieldset > legend').text()).toBe('抽取模块')
+ expect(config.get('.breakdown-modules-field .n-form-item-label').text()).toContain('抽取模块')
const options = config.findAll('.breakdown-module-option')
expect(options).toHaveLength(3)
const preview = config.get('.breakdown-preview-button')
@@ -165,8 +164,10 @@ describe('拆解页内容滚动', () => {
expect(description.text()).not.toBe('')
await checkbox.trigger('click')
}
- expect(preview.element.disabled).toBe(true)
- expect(drawerPanel().text()).toContain('至少选择一个抽取模块')
+ await preview.trigger('click')
+ await flushPromises()
+ expect(config.get('.n-form-item-feedback--error').text()).toContain('至少选择一个抽取模块')
+ expect(fetcher).not.toHaveBeenCalled()
await options[1]!.get('[role="checkbox"]').trigger('click')
expect(preview.element.disabled).toBe(false)
provided.data.value!.project.status = 'generating'
@@ -179,9 +180,7 @@ describe('拆解页内容滚动', () => {
// DOM 环境不计算坐标,检查统一标题偏移、34px 控件行和容器断点契约。
const css = readAllStyles()
expect(css).toMatch(/\.breakdown-settings-panel\s*\{\s*container:\s*breakdown-settings \/ inline-size;/)
- expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*28px;/)
- expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*margin(?:-bottom)?:\s*(?:0 0 )?8px/)
- expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*line-height:\s*20px/)
+ expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*29px;/)
expect(css).toMatch(/\.breakdown-group-unit\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;/
diff --git a/tests/features/production/production.test.ts b/tests/features/production/production.test.ts
index 4304299..4f7c5f7 100644
--- a/tests/features/production/production.test.ts
+++ b/tests/features/production/production.test.ts
@@ -50,6 +50,8 @@ describe('镜头生产数据契约', () => {
it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
+ expect(issueLabel('stale_prompt')).toBe('视频提示词需要更新')
+ expect(issueLabel('provider_input_recovery')).toBe('视频模型输入需要处理')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
@@ -135,6 +137,7 @@ describe('镜头生产数据契约', () => {
document.querySelector('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
+ await flushPromises()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
})
diff --git a/tests/features/production/public-assets.test.ts b/tests/features/production/public-assets.test.ts
new file mode 100644
index 0000000..9594a69
--- /dev/null
+++ b/tests/features/production/public-assets.test.ts
@@ -0,0 +1,168 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import QualityResult from '@/features/production/components/QualityResult.vue'
+import { expandSections } from '@/testing/naive'
+import ShotProductionAssets from '@/features/production/components/ShotProductionAssets.vue'
+import { primaryKeyframe } from '@/features/production/model'
+import { qualityKey, qualitySession, sessionVideoValidation } from '@/features/production/quality'
+import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
+import { storyboardApi } from '@/features/storyboard/api'
+import { hasRunningWorkflow } from '@/features/workflows/selectors'
+import type { DesignedShot } from '@/features/storyboard/types'
+import type { VideoValidation } from '@/features/production/quality.types'
+import type { Checkpoint } from '@/features/workflows/types'
+
+const shot: DesignedShot = {
+ shotId: 'shot-1',
+ shotNo: 1,
+ beatNo: 1,
+ title: '测试镜头',
+ description: '',
+ visualFocus: '',
+ direction: null,
+ visualState: null
+}
+const validation: VideoValidation = {
+ shotId: 'shot-1',
+ videoId: 'video-1',
+ videoUrl: '/storage/videos/video-1.mp4',
+ isPrimary: false,
+ passed: true,
+ summary: '通过',
+ subjects: [],
+ subjectCountConsistent: true,
+ unauthorizedText: { detected: false, texts: [] },
+ issues: [],
+ sampleFrames: [],
+ allowedTexts: [],
+ validatedAt: '2026-09-10T00:00:00Z'
+}
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+/** 构造带完整执行字段的后端 checkpoint。 */
+const record = (workflowName: string, day: number, status: 'running' | 'completed'): Checkpoint => ({
+ checkpointId: `${workflowName}-${day}`,
+ workflowName,
+ createdAt: `2026-09-0${day}T00:00:00Z`,
+ state: {
+ workflowExecution: {
+ status,
+ executionId: `${workflowName}-${day}`,
+ startedAt: `2026-09-0${day}T00:00:00Z`
+ }
+ }
+})
+
+describe('精简公开资产回归', () => {
+ it('无内部字段的视频可播放,未知质量不得直接晋升;真实校验结果按项目隔离', async () => {
+ const projectId = 'public-video-contract'
+ const video = videoFixture({ isPrimary: false })
+ expect(video).not.toHaveProperty('rawJson')
+ const fetcher = vi.fn().mockImplementation(
+ async url =>
+ new Response(
+ JSON.stringify({
+ data: String(url).endsWith('/videos') ? [video] : []
+ })
+ )
+ )
+ vi.stubGlobal('fetch', fetcher)
+ const wrapper = mount(ShotProductionAssets, { props: { projectId, shot, disabled: false } })
+ try {
+ await flushPromises()
+ expect(wrapper.find('video').exists()).toBe(true)
+ expect(wrapper.text()).not.toContain('自动校验中')
+ expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(false)
+ const session = qualitySession(
+ qualityKey(projectId, { kind: 'video', shotId: shot.shotId, assetId: video.id, title: '' })
+ )
+ session.videoValidation = validation
+ await flushPromises()
+ expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(true)
+ expect(sessionVideoValidation('other-project', shot.shotId, video.id)).toBeNull()
+ session.videoValidation = { ...validation, passed: false }
+ await flushPromises()
+ expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(false)
+ expect(fetcher.mock.calls.every(([, options]) => options?.method === 'GET')).toBe(true)
+ } finally {
+ wrapper.unmount()
+ }
+ })
+
+ it('模型输入变体可以预览,但不能设为业务主首帧', async () => {
+ const variant = keyframeFixture({ source: 'provider_variant', isPrimary: false })
+ expect(primaryKeyframe([{ ...variant, isPrimary: true }])).toBeUndefined()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation(
+ async url =>
+ new Response(
+ JSON.stringify({
+ data: String(url).endsWith('/keyframes') ? [variant] : []
+ })
+ )
+ )
+ )
+ const wrapper = mount(ShotProductionAssets, {
+ props: { projectId: 'public-variant-contract', shot, disabled: false }
+ })
+ try {
+ await flushPromises()
+ expect(wrapper.text()).toContain('模型输入变体')
+ expect(wrapper.findAll('button').some(button => button.text() === '设为主首帧')).toBe(false)
+ } finally {
+ wrapper.unmount()
+ }
+ })
+
+ it('提示词返回使用 shotId,保留正文、状态和更新时间', async () => {
+ const result = {
+ shotId: 'shot/db',
+ videoPrompt: '镜头正文',
+ negativePrompt: null,
+ status: 'prompt_ready',
+ updatedAt: '2026-09-10T00:00:00Z'
+ }
+ const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: result })))
+ vi.stubGlobal('fetch', fetcher)
+ expect(await storyboardApi.generatePrompt('shot/db', false)).toEqual(result)
+ expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot%2Fdb/video-prompt')
+ expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ force: false })
+ })
+
+ it('视频专项检查区分通过、失败和未执行,并保留后端摘要', async () => {
+ const wrapper = mount(QualityResult, {
+ attachTo: document.body,
+ props: {
+ receipt: {
+ kind: 'video',
+ promoted: false,
+ result: {
+ ...validation,
+ passed: false,
+ motionRealism: { enabled: true, passed: false, summary: '运动发生跳变' },
+ spatialInteraction: { enabled: true, passed: true, summary: '空间关系一致' },
+ physicalStructure: { enabled: false, reason: 'missing_prop_subject' }
+ }
+ }
+ }
+ })
+ try {
+ await expandSections()
+ expect(wrapper.text()).toContain('运动真实性:未通过')
+ expect(wrapper.text()).toContain('运动发生跳变')
+ expect(wrapper.text()).toContain('空间交互:通过')
+ expect(wrapper.text()).toContain('物理结构:未执行')
+ } finally {
+ wrapper.unmount()
+ }
+ })
+
+ it('项目不再提供任务数组时,从每条工作流最新 checkpoint 判断运行状态', () => {
+ expect(hasRunningWorkflow([])).toBe(false)
+ expect(hasRunningWorkflow([record('breakdown', 1, 'running'), record('breakdown', 2, 'completed')])).toBe(false)
+ expect(hasRunningWorkflow([record('breakdown', 1, 'running'), record('other', 2, 'completed')])).toBe(true)
+ })
+})
diff --git a/tests/features/production/quality.test.ts b/tests/features/production/quality.test.ts
index a24b6c0..e6a7b39 100644
--- a/tests/features/production/quality.test.ts
+++ b/tests/features/production/quality.test.ts
@@ -10,8 +10,8 @@ import {
qualityKey,
qualitySession,
qualityTargets,
- savedVideoValidation,
- videoRepairInfo,
+ sessionVideoValidation,
+ isVisualValidation,
validQualityInput
} from '@/features/production/quality'
import type { KeyframeReadiness } from '@/features/production/types'
@@ -125,8 +125,7 @@ function server() {
id: 'video-repair-1',
status: 'queued',
isPrimary: false,
- videoUrl: null,
- rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
+ videoUrl: null
})
}
else if (path.endsWith('/repair')) data = repair
@@ -136,13 +135,14 @@ function server() {
shotId: 'shot-1',
videoId: 'video-1',
validatedAt: '2026-09-03',
- isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
+ isPrimary: validation.passed,
videoUrl: video.videoUrl,
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
allowedTexts: []
}
else data = keyframeResult
} else if (path.endsWith(`/projects/${projectId}`)) data = project
+ else if (path.endsWith('/checkpoints')) data = []
else if (path.includes('/readiness')) data = readiness
else if (path.endsWith('/keyframes')) data = [keyframe]
else if (path.endsWith('/videos')) data = [video]
@@ -437,9 +437,10 @@ describe('质量面板渐进展示', () => {
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
const data = server()
- expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
- expect(savedVideoValidation('{broken')).toBeNull()
- expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
+ expect(sessionVideoValidation(data.projectId, 'shot-1', 'video-1')).toBeNull()
+ expect(isVisualValidation(data.validation)).toBe(true)
+ expect(isVisualValidation('{broken')).toBe(false)
+ expect(isVisualValidation({ passed: true })).toBe(false)
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
@@ -454,7 +455,9 @@ describe('视频修复候选与复检晋升', () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
- data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
+ data.validation.passed = false
+ await data.service.run('validate', input())
+ data.fetcher.mockClear()
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
@@ -465,21 +468,23 @@ describe('视频修复候选与复检晋升', () => {
})
})
- it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
+ it('修复不接受零次上限,后端次数限制错误原样保留且不重试', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
- data.video.rawJson = JSON.stringify({
- videoValidation: { ...data.validation, passed: false },
- repair: { attempt: 2 }
- })
+ data.validation.passed = false
+ await data.service.run('validate', input())
+ data.fetcher.mockClear()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
- await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
+ vi.spyOn(qualityApi, 'repairVideo').mockRejectedValueOnce(new Error('修复次数上限已达到'))
+ await data.service.run('repair', input({ maxRepairAttempts: 2 }))
+ expect(qualityApi.repairVideo).toHaveBeenCalledTimes(1)
expect(data.service.session.value.error).toContain('次数上限')
+ expect(sessionVideoValidation(data.projectId, 'shot-1', 'video-1')?.passed).toBe(false)
})
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
- data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
+
data.video.isPrimary = false
await data.service.run('validate', input())
const receipt = data.service.session.value.receipt!
@@ -499,8 +504,7 @@ describe('视频修复候选与复检晋升', () => {
projectId: data.projectId,
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
open: true,
- disabled: false,
- savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
+ disabled: false
}
})
await flushPromises()
diff --git a/tests/features/projects/access.test.ts b/tests/features/projects/access.test.ts
index 7f49116..04ce8b3 100644
--- a/tests/features/projects/access.test.ts
+++ b/tests/features/projects/access.test.ts
@@ -33,9 +33,7 @@ function project(id: string, status: ProjectStatus): ProjectDetail {
updatedAt: '',
episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }],
characters: [],
- world: null,
- reviews: [],
- tasks: []
+ world: null
}
}
diff --git a/tests/features/subject-identity/identity.test.ts b/tests/features/subject-identity/identity.test.ts
index b7524a2..0974218 100644
--- a/tests/features/subject-identity/identity.test.ts
+++ b/tests/features/subject-identity/identity.test.ts
@@ -11,8 +11,7 @@ import {
castingStatusLabel,
currentAnchor,
groupIdentitySubjects,
- mergeCastingSubjects,
- readImageProvenance
+ mergeCastingSubjects
} from '@/features/subject-identity/model'
import { identityImageFixture } from '@/features/subject-identity/testing/fixtures'
import { formFixture } from '@/features/subject-images/testing/fixtures'
@@ -83,21 +82,19 @@ describe('身份图与母版契约', () => {
expect(wrapper.emitted('anchor')).toBeUndefined()
})
- it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
- const image = identityImageFixture({ prompt: '本次实际生成提示词' })
+ it('身份图库使用精简资产,不展示已移除的提示词和启停状态', () => {
wrapper = mount(IdentityGallery, {
- props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
+ props: {
+ subjectName: '林夏',
+ module: 'character',
+ identityLocked: true,
+ disabled: false,
+ images: [identityImageFixture()]
+ }
})
- const panel = wrapper.get('.n-collapse-item')
- expect(panel.classes()).toContain('n-collapse-item--active')
- expect(panel.text()).toContain('本次实际生成提示词')
- await panel.get('.n-collapse-item__header-main').trigger('click')
- expect(panel.classes()).not.toContain('n-collapse-item--active')
- await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] })
- expect(panel.classes()).not.toContain('n-collapse-item--active')
- await panel.get('.n-collapse-item__header-main').trigger('click')
- expect(panel.classes()).toContain('n-collapse-item--active')
- expect(panel.text()).toContain('更新后的实际提示词')
+ expect(wrapper.text()).toContain('当前身份母版')
+ expect(wrapper.text()).not.toContain('本次实际提示词')
+ expect(wrapper.text()).not.toContain('停用')
})
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
@@ -105,26 +102,24 @@ describe('身份图与母版契约', () => {
identityImageFixture({
id: 'candidate',
imageUrl: '/storage/candidate.png',
- isAnchor: false,
- enabled: false
+ isAnchor: false
}),
identityImageFixture({
id: 'failed',
status: 'failed',
imageUrl: null,
isAnchor: false,
- enabled: false,
+
error: '图片模型超时'
}),
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }),
identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }),
- identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }),
+ identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false }),
identityImageFixture({
id: 'generating',
status: 'generating',
imageUrl: null,
- isAnchor: false,
- enabled: false
+ isAnchor: false
})
]
wrapper = mount(IdentityGallery, {
@@ -196,9 +191,7 @@ describe('身份图与母版契约', () => {
module: 'character',
identityLocked: false,
disabled: false,
- images: ['candidate-a', 'candidate-b'].map(id =>
- identityImageFixture({ id, isAnchor: false, enabled: false })
- )
+ images: ['candidate-a', 'candidate-b'].map(id => identityImageFixture({ id, isAnchor: false }))
}
})
button('确认选角并锁定').click()
@@ -225,7 +218,7 @@ describe('身份图与母版契约', () => {
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
- const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
+ const candidate = identityImageFixture({ id: 'candidate', isAnchor: false })
expect(canBeAnchor(front)).toBe(false)
expect(canBeAnchor(candidate)).toBe(true)
expect(currentAnchor([front, candidate])).toBeUndefined()
@@ -233,14 +226,8 @@ describe('身份图与母版契约', () => {
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
})
- it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
+ it('正式主体关联校验不接受不同主体的 form', () => {
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
- expect(readImageProvenance('{bad')).toEqual({})
- expect(readImageProvenance(null)).toEqual({})
- expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
- identityAnchorImageId: 'anchor',
- referenceImageId: undefined
- })
})
it('选角就绪结果可以补入尚无形态的角色目录', () => {
@@ -342,7 +329,7 @@ describe('身份图与母版契约', () => {
expect(select.props('value')).toBe('front')
})
- it('辅助图不能切换母版,提示词按纯文本展示', async () => {
+ it('辅助图不能切换母版,精简资产不展示内部提示词', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
@@ -355,7 +342,7 @@ describe('身份图与母版契约', () => {
})
expect(button('确认选角并锁定').disabled).toBe(true)
await expandSections()
- expect(wrapper.text()).toContain('')
+ expect(wrapper.text()).not.toContain('本次实际提示词')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
@@ -388,6 +375,7 @@ describe('身份图与母版契约', () => {
document.querySelector('#casting-candidate-cost')!.click()
await flushPromises()
button('确认生成候选').click()
+ await flushPromises()
expect(wrapper.emitted('generate')).toEqual([[{}]])
})
diff --git a/tests/features/subject-identity/thumbnail.test.ts b/tests/features/subject-identity/thumbnail.test.ts
index 188646d..cdf9d56 100644
--- a/tests/features/subject-identity/thumbnail.test.ts
+++ b/tests/features/subject-identity/thumbnail.test.ts
@@ -93,7 +93,7 @@ describe('主体目录母版缩略图', () => {
identityImageFixture({
id: 'candidate',
isAnchor: false,
- enabled: false,
+
imageUrl: '/storage/candidate.png'
}),
identityImageFixture({
diff --git a/tests/features/subject-images/asset-impact.test.ts b/tests/features/subject-images/asset-impact.test.ts
index 231d261..736b3c8 100644
--- a/tests/features/subject-images/asset-impact.test.ts
+++ b/tests/features/subject-images/asset-impact.test.ts
@@ -414,23 +414,14 @@ describe('过期首帧到具体素材定位', () => {
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
})
- it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => {
+ it('精简图库没有历史母版字段时,不误报形态过期或破坏定位', async () => {
const { first, router } = await gallery('?subjectFormId=form-other')
- first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] }
- const refresh = wrapper!.get('[aria-label="刷新图库"]')
- await refresh.trigger('click')
+ first.subject.identity = { id: 'identity-1', isLocked: true, anchorImageId: 'anchor-new' }
+ await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
- expect(wrapper!.text()).toContain('形态主图需要更新')
- await wrapper!
- .findAll('button')
- .find(button => button.text() === '筛选身份过期形态')!
- .trigger('click')
- await flushPromises()
- expect(router.currentRoute.value.query.subjectFormId).toBeUndefined()
- expect(wrapper!.findAll('.form-image-card').map(item => item.attributes('data-form-id'))).toEqual([
- 'form-db-1',
- 'form-special'
- ])
+ expect(wrapper!.text()).not.toContain('形态主图需要更新')
+ expect(router.currentRoute.value.query.subjectFormId).toBe('form-other')
+ expect(wrapper!.findAll('button').some(item => item.text() === '重新生成过期形态')).toBe(false)
})
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
diff --git a/tests/features/subject-images/images.test.ts b/tests/features/subject-images/images.test.ts
index c1115b2..8407ad7 100644
--- a/tests/features/subject-images/images.test.ts
+++ b/tests/features/subject-images/images.test.ts
@@ -167,7 +167,7 @@ describe('形态图片选择与操作', () => {
await vi.advanceTimersByTimeAsync(30_000)
expect(fetcher.mock.calls).toHaveLength(1)
await expandSections()
- expect(document.body.textContent).toContain('')
+ expect(document.body.textContent).not.toContain('本次实际提示词')
expect(document.querySelector('[role="dialog"] script')).toBeNull()
button('设为主参考图').click()
await flushPromises()
diff --git a/tests/features/subject-images/prompts.test.ts b/tests/features/subject-images/prompts.test.ts
index 56d3377..1c60c9d 100644
--- a/tests/features/subject-images/prompts.test.ts
+++ b/tests/features/subject-images/prompts.test.ts
@@ -5,7 +5,7 @@ import { projectContextKey } from '@/features/projects/context'
import { testProjectContext } from '@/testing/project-context'
import { getOperation } from '@/features/workflows/operations'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
-import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from '@/features/subject-images/model'
+import { currentIdentityAnchorId, getImageSession } from '@/features/subject-images/model'
import { useSubjectImages } from '@/features/subject-images/useSubjectImages'
import FormPromptDialog from '@/features/subject-images/components/FormPromptDialog.vue'
import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
@@ -142,17 +142,15 @@ describe('形态正式提示词与批量配置', () => {
await service.generateProject()
expect(posts()).toHaveLength(0)
})
- it.each(['character', 'scene', 'prop'])('%s 已锁定母版才参与过期判断,刷新只新增候选', async module => {
+ it.each(['character', 'scene', 'prop'])('%s 使用明确母版 ID,不以缺少追溯信息触发生图', async module => {
const { service, form, posts } = await setup()
form.subject.module = module
- form.subject.identity = { id: 'identity', isLocked: false, images: [{ id: 'anchor-new' }] }
+ form.subject.identity = { id: 'identity', isLocked: false, anchorImageId: 'anchor-new' }
expect(currentIdentityAnchorId(form)).toBeUndefined()
- expect(isPrimaryIdentityStale(form)).toBe(false)
form.subject.identity.isLocked = true
- expect(isPrimaryIdentityStale(form)).toBe(true)
+ expect(currentIdentityAnchorId(form)).toBe('anchor-new')
await service.query.refresh()
- await service.generateStale()
- expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ setPrimary: false })
+ expect(posts()).toHaveLength(0)
})
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
const form = formFixture()
diff --git a/tests/features/workflows/pages.test.ts b/tests/features/workflows/pages.test.ts
index 827735c..ee145fc 100644
--- a/tests/features/workflows/pages.test.ts
+++ b/tests/features/workflows/pages.test.ts
@@ -65,9 +65,7 @@ const fixture: ProjectDetail = {
updatedAt: '2026-08-27T00:00:00Z',
episodes: [{ episode: 1, title: '来信', content: '\n第一场:旧书店。' }],
characters: [{ id: 'character', name: '林知夏' }],
- world: { era: '当代' },
- reviews: [],
- tasks: []
+ world: { era: '当代' }
}
let wrapper: VueWrapper | undefined
@@ -219,7 +217,14 @@ describe('内容级滚动下的生产反馈', () => {
getProductionSession(fixture.id).receipt = {
kind: 'retry',
title: '重试失败视频任务',
- result: { totalFailed: 1, retried: 0, failed: 1, failures: [{ shotId: 'shot-1', error: '供应商不可用' }] }
+ result: {
+ totalFailed: 1,
+ retryable: 1,
+ skipped: 0,
+ retried: 0,
+ failed: 1,
+ failures: [{ shotId: 'shot-1', error: '供应商不可用' }]
+ }
}
await flushPromises()
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('true')
@@ -392,7 +397,10 @@ describe('视觉风格与主体身份工作区', () => {
vi.stubGlobal('fetch', fetcher)
await mountAssets('style')
await wrapper!.get('[aria-label="风格图片地址"]').setValue('javascript:alert(1)')
- expect(button('登记参考图').disabled).toBe(true)
+ button('登记参考图').click()
+ await flushPromises()
+ expect(document.querySelector('.n-form-item-feedback--error')?.textContent).toContain('请填写有效的')
+ expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
await wrapper!.get('[aria-label="风格图片地址"]').setValue('/storage/style.png')
button('登记参考图').click()
await flushPromises()
@@ -437,7 +445,10 @@ describe('视觉风格与主体身份工作区', () => {
expect(wrapper!.text()).toContain('默认采用中国人物选角基线')
expect(wrapper!.text()).toContain('人物风格与选角背景')
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
- expect(button('保存视觉风格').disabled).toBe(true)
+ button('保存视觉风格').click()
+ await flushPromises()
+ expect(document.querySelector('.n-form-item-feedback--error')?.textContent).toContain('JSON 字符串数组')
+ expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
await wrapper!.get('#style-lock').trigger('click')
await wrapper!.get('#style-name').setValue('雨夜风格')
@@ -611,7 +622,7 @@ describe('视觉风格与主体身份工作区', () => {
return jsonResponse(identityImageFixture({ id: 'candidate' }))
}
if (String(url).endsWith('/identity/images'))
- return jsonResponse([identityImageFixture({ id: 'candidate', enabled: selected, isAnchor: selected })])
+ return jsonResponse([identityImageFixture({ id: 'candidate', isAnchor: selected })])
return jsonResponse(identityFixture())
})
vi.stubGlobal('fetch', fetcher)
@@ -697,7 +708,7 @@ describe('视觉风格与主体身份工作区', () => {
it('Character 确认选角原子切换 Anchor 并锁定 Identity,不调用普通母版接口', async () => {
let locked = false
let selected = false
- const candidate = identityImageFixture({ id: 'casting-1', enabled: false, isAnchor: false })
+ const candidate = identityImageFixture({ id: 'casting-1', isAnchor: false })
const fetcher = vi.fn().mockImplementation(async (url, init) => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse([formFixture()])
@@ -725,14 +736,15 @@ describe('视觉风格与主体身份工作区', () => {
}
]
})
- if (path.endsWith('/identity/images'))
- return jsonResponse([{ ...candidate, enabled: selected, isAnchor: selected }])
+ if (path.endsWith('/identity/images')) return jsonResponse([{ ...candidate, isAnchor: selected }])
if (init?.method === 'PUT' && path.endsWith('/casting')) {
locked = true
selected = true
return jsonResponse({
- identity: { ...identityFixture({ isLocked: true }), images: undefined },
- anchor: { ...candidate, enabled: true }
+ identityId: candidate.identityId,
+ subjectId: 'subject-db-1',
+ isLocked: true,
+ anchor: { ...candidate, isAnchor: true }
})
}
return jsonResponse(identityFixture({ isLocked: locked }))
@@ -834,9 +846,11 @@ describe('工作台页面交互', () => {
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'all')
await filters.get('input[aria-label="搜索形态图片"]').setValue('旧书店')
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
- selectControl(wrapper!, 'aria-label', '筛选图片状态').vm.$emit('update:value', 'stale')
- await flushPromises()
- expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
+ expect(selectControl(wrapper!, 'aria-label', '筛选图片状态').props('options')).not.toContainEqual({
+ label: '身份过期',
+ value: 'stale'
+ })
+ expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
@@ -924,7 +938,9 @@ describe('工作台页面交互', () => {
await flushPromises()
document.querySelector('#confirm-image-cost')!.click()
await flushPromises()
- document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ document
+ .querySelector('.app-dialog form')!
+ .dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
@@ -1382,7 +1398,8 @@ describe('工作台页面交互', () => {
new Response(
JSON.stringify({
data: {
- id: 'shot-db-1-1',
+ shotId: 'shot-db-1-1',
+ updatedAt: '2026-09-10T00:00:00Z',
videoPrompt: '',
negativePrompt: '模糊',
status: 'prompt_ready'