feat: 同步精简接口并统一表单校验
This commit is contained in:
@@ -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 就绪检查也会比对母版与形态主图引用,不应绕过过期提示继续生产。旧记录无追溯字段时不推断它已匹配当前母版。
|
||||
|
||||
@@ -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 测试代替浏览器视觉验收或真实后端联调。
|
||||
@@ -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 缺少当前身份母版参数引起的过期误报。前端保留异常时的两种检查结果对照和“状态待核对”提示,不绕过后端阻塞或自动重复生图。
|
||||
|
||||
## 不添加无效或重复入口
|
||||
|
||||
@@ -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。前端只显示当前会话确实收到的首帧回执并支持导出,不虚构历史接口。
|
||||
- 后端已修复视频校验漏传身份母版导致的过期误报。前端保留两种就绪结果冲突时的防御性提示,不绕过后端阻塞、不自动重复生成。
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { NForm, type FormInst, type FormRules, type FormValidationError } from 'naive-ui'
|
||||
|
||||
/** 统一使用 Naive UI 校验,屏蔽浏览器原生气泡;不改变请求和费用确认职责。 */
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: Record<string, unknown>
|
||||
rules?: FormRules
|
||||
disabled?: boolean
|
||||
validateOnChange?: boolean
|
||||
resetKey?: unknown
|
||||
}>(),
|
||||
{ validateOnChange: false }
|
||||
)
|
||||
const emit = defineEmits<{ submit: []; invalid: [errors: FormValidationError[]] }>()
|
||||
const formRef = ref<(FormInst & { $el: HTMLFormElement }) | null>(null)
|
||||
let revision = 0
|
||||
let validating = false
|
||||
let submitted = false
|
||||
onBeforeUnmount(() => {
|
||||
revision++
|
||||
})
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
revision++
|
||||
submitted = false
|
||||
formRef.value?.restoreValidation()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
/** 验证等待期间切换目标或修改输入不继续执行旧的提交。 */
|
||||
async function validate(action?: () => unknown) {
|
||||
if (props.disabled || validating || !formRef.value) return false
|
||||
const version = revision
|
||||
const snapshot = JSON.stringify(props.model)
|
||||
submitted = true
|
||||
validating = true
|
||||
try {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch (errors) {
|
||||
if (version === revision && Array.isArray(errors)) {
|
||||
emit('invalid', errors)
|
||||
// 等待调用方展开高级参数,再将焦点交给第一个错误字段。
|
||||
await nextTick()
|
||||
if (version === revision) {
|
||||
formRef.value?.$el
|
||||
.querySelector<HTMLElement>(
|
||||
'.n-form-item-blank--error input, .n-form-item-blank--error textarea'
|
||||
)
|
||||
?.focus()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (version !== revision || props.disabled || snapshot !== JSON.stringify(props.model)) return false
|
||||
if (action) await action()
|
||||
return true
|
||||
} finally {
|
||||
validating = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.model,
|
||||
async () => {
|
||||
if (!submitted && !props.validateOnChange) return
|
||||
await nextTick()
|
||||
// 这里只更新字段反馈,不提交业务操作。
|
||||
void formRef.value?.validate().catch(() => {})
|
||||
},
|
||||
{ deep: true, flush: 'post' }
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
await validate(() => emit('submit'))
|
||||
}
|
||||
defineExpose({ validate, restoreValidation: () => formRef.value?.restoreValidation() })
|
||||
</script>
|
||||
<template>
|
||||
<NForm
|
||||
ref="formRef"
|
||||
class="app-form"
|
||||
:model="model"
|
||||
:rules="rules"
|
||||
:disabled="disabled"
|
||||
label-placement="top"
|
||||
require-mark-placement="right"
|
||||
novalidate
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<slot :validate="validate" />
|
||||
</NForm>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 标签与错误由 Naive UI 排版;窄列中的反馈换行不挤压相邻字段。 */
|
||||
.app-form .n-form-item {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-form .n-form-item-blank > .n-input-number,
|
||||
.app-form .n-form-item-blank > .n-select {
|
||||
width: 100%;
|
||||
}
|
||||
.app-form .n-form-item-feedback {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.app-form .form-controls {
|
||||
align-items: start;
|
||||
}
|
||||
.app-form .form-controls > .n-checkbox,
|
||||
.app-form .form-controls > .n-button,
|
||||
.app-form .form-controls > .confirm-action {
|
||||
align-self: start;
|
||||
margin-top: 29px;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,11 @@ const expanded = ref<string[]>([])
|
||||
function toggle() {
|
||||
expanded.value = expanded.value.length ? [] : ['details']
|
||||
}
|
||||
defineExpose({
|
||||
expand: () => {
|
||||
expanded.value = ['details']
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<NCollapse v-model:expanded-names="expanded" class="detail-disclosure"
|
||||
|
||||
@@ -148,6 +148,15 @@ export function useTheme() {
|
||||
textColorTextSuccess: accentText,
|
||||
textColorGhostSuccess: accentText
|
||||
},
|
||||
Form: {
|
||||
labelFontSizeTopMedium: '12px',
|
||||
labelFontWeight: '500',
|
||||
labelTextColor: text,
|
||||
labelPaddingVertical: '0 0 9px 0',
|
||||
feedbackFontSizeMedium: '12px',
|
||||
feedbackHeightMedium: '0px',
|
||||
feedbackPadding: '4px 0 0 0'
|
||||
},
|
||||
Input: {
|
||||
color: 'var(--app-field)',
|
||||
colorHover: 'var(--app-field-hover)',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -170,64 +178,73 @@ function exportResult() {
|
||||
</div>
|
||||
<NTag size="small" :bordered="false">来源:正式剧集</NTag>
|
||||
</div>
|
||||
<div class="breakdown-config">
|
||||
<div class="breakdown-group-field">
|
||||
<label class="field-label" for="group-size">每组集数</label>
|
||||
<div class="breakdown-group-input">
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'group-size' }"
|
||||
class="breakdown-group-number"
|
||||
:value="typeof groupSize === 'number' ? groupSize : null"
|
||||
@update:value="groupSize = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span class="breakdown-group-unit">集 / 组</span>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="breakdown-modules-field">
|
||||
<legend class="field-label">抽取模块</legend>
|
||||
<div class="breakdown-module-options">
|
||||
<div
|
||||
v-for="option in moduleOptions"
|
||||
:key="option.value"
|
||||
class="breakdown-module-option"
|
||||
>
|
||||
<NCheckbox
|
||||
:checked="modules.includes(option.value)"
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
@submit="loadPreview"
|
||||
>
|
||||
<div class="breakdown-config">
|
||||
<NFormItem
|
||||
class="breakdown-group-field"
|
||||
path="groupSize"
|
||||
label="每组集数"
|
||||
:label-props="{ for: 'group-size' }"
|
||||
>
|
||||
<div class="breakdown-group-input">
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:aria-label="option.label"
|
||||
:aria-describedby="`breakdown-module-${option.value}-help`"
|
||||
@update:checked="toggleModule(option.value, $event)"
|
||||
class="control-row-checkbox"
|
||||
><span class="text-sm font-medium">{{ option.label }}</span></NCheckbox
|
||||
>
|
||||
<p
|
||||
:id="`breakdown-module-${option.value}-help`"
|
||||
class="breakdown-module-description"
|
||||
>
|
||||
{{ option.description }}
|
||||
</p>
|
||||
:input-props="{ id: 'group-size' }"
|
||||
class="breakdown-group-number"
|
||||
:value="typeof groupSize === 'number' ? groupSize : null"
|
||||
@update:value="groupSize = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span class="breakdown-group-unit">集 / 组</span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<NButton
|
||||
:disabled="
|
||||
project?.status !== 'completed' ||
|
||||
!configValid ||
|
||||
previewBusy ||
|
||||
operation.pending ||
|
||||
!!error
|
||||
"
|
||||
@click="loadPreview"
|
||||
class="breakdown-preview-button"
|
||||
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||
</NButton>
|
||||
</div>
|
||||
<p v-if="!configValid" class="mt-4 text-xs text-danger">
|
||||
每组集数必须是正整数,并至少选择一个抽取模块。
|
||||
</p>
|
||||
</NFormItem>
|
||||
<NFormItem path="modules" label="抽取模块" class="breakdown-modules-field">
|
||||
<div class="breakdown-module-options">
|
||||
<div
|
||||
v-for="option in moduleOptions"
|
||||
:key="option.value"
|
||||
class="breakdown-module-option"
|
||||
>
|
||||
<NCheckbox
|
||||
:checked="modules.includes(option.value)"
|
||||
:disabled="operation.pending"
|
||||
:aria-label="option.label"
|
||||
:aria-describedby="`breakdown-module-${option.value}-help`"
|
||||
@update:checked="toggleModule(option.value, $event)"
|
||||
class="control-row-checkbox"
|
||||
><span class="text-sm font-medium">{{
|
||||
option.label
|
||||
}}</span></NCheckbox
|
||||
>
|
||||
<p
|
||||
:id="`breakdown-module-${option.value}-help`"
|
||||
class="breakdown-module-description"
|
||||
>
|
||||
{{ option.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NButton
|
||||
:disabled="
|
||||
project?.status !== 'completed' ||
|
||||
previewBusy ||
|
||||
operation.pending ||
|
||||
!!error
|
||||
"
|
||||
attr-type="submit"
|
||||
class="breakdown-preview-button"
|
||||
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||
</NButton>
|
||||
</div>
|
||||
</AppForm>
|
||||
<NAlert v-if="previewError" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||||
previewError
|
||||
}}</NAlert>
|
||||
@@ -517,10 +534,7 @@ function exportResult() {
|
||||
}
|
||||
.breakdown-config {
|
||||
@apply grid grid-cols-[180px_minmax(0,_1fr)_auto] items-start gap-y-4 gap-x-6;
|
||||
--breakdown-label-offset: 28px;
|
||||
}
|
||||
.breakdown-config .field-label {
|
||||
@apply mt-0 mx-0 mb-2 p-0 leading-[20px];
|
||||
--breakdown-label-offset: 29px;
|
||||
}
|
||||
.breakdown-group-field,
|
||||
.breakdown-modules-field,
|
||||
@@ -528,7 +542,7 @@ function exportResult() {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.breakdown-group-input {
|
||||
@apply flex items-center gap-3;
|
||||
@apply w-full flex items-center gap-3;
|
||||
}
|
||||
.breakdown-group-number.n-input-number {
|
||||
@apply flex-[0_0_120px] w-[120px];
|
||||
@@ -540,7 +554,7 @@ function exportResult() {
|
||||
@apply m-0 p-0 border-0;
|
||||
}
|
||||
.breakdown-module-options {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
|
||||
@apply w-full grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
|
||||
}
|
||||
.breakdown-module-description {
|
||||
@apply mt-1 mx-0 mb-0 pl-6 text-muted text-[11px] leading-[18px] wrap-anywhere;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { workflowCheckpoints } from '../workflows/selectors'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
import HistoryPanel from '../workflows/HistoryPanel.vue'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
import { downloadText, formatDate } from '../../lib/format'
|
||||
import { downloadText } from '../../lib/format'
|
||||
import EpisodeReader from './EpisodeReader.vue'
|
||||
|
||||
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
|
||||
@@ -44,7 +44,7 @@ const stages = computed(() => [
|
||||
{ label: '角色设定', done: !!project.value?.characters.length },
|
||||
{ label: '世界观', done: !!project.value?.world },
|
||||
{ label: '编写剧集', done: !!total.value && episodes.value.length >= total.value },
|
||||
{ label: '审核与改写', done: project.value?.reviews[0]?.passed === true },
|
||||
{ label: '审核与改写', done: state.value?.reviewPassed === true },
|
||||
{ label: '完成', done: complete.value }
|
||||
])
|
||||
|
||||
@@ -212,24 +212,13 @@ function exportScript() {
|
||||
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" />
|
||||
</div>
|
||||
<div v-if="tab === 'review'" class="p-6 lg:p-8">
|
||||
<div v-if="project?.reviews.length" class="space-y-5">
|
||||
<article v-for="review in project.reviews" :key="review.id" class="pb-5">
|
||||
<div class="mb-3 flex justify-between gap-3">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="review.passed ? 'text-success' : 'text-danger'"
|
||||
>{{ review.passed ? '审核通过' : '需要修改' }}</span
|
||||
><time class="text-xs text-muted">{{ formatDate(review.createdAt) }}</time>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-7">
|
||||
{{ review.message || '本次审核未附加说明。' }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="typeof state?.reviewPassed === 'boolean'" class="text-sm">
|
||||
{{ state.reviewPassed ? '最近工作流审核通过' : '最近工作流审核未通过' }}
|
||||
</p>
|
||||
<EmptyState
|
||||
v-else
|
||||
title="还没有审核记录"
|
||||
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
|
||||
title="暂无可用审核结果"
|
||||
description="项目详情不再提供审核记录;可在执行记录中查看工作流状态。"
|
||||
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>查看改写建议原文</template>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { integerRule, sizeRules } from '../../lib/form-rules'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
import {
|
||||
@@ -164,6 +167,21 @@ watch(
|
||||
if (receipt) toolsOpen.value = true
|
||||
}
|
||||
)
|
||||
const batchModel = computed(() => ({ concurrency: concurrency.value }))
|
||||
const batchRules = { concurrency: integerRule('批量并发') }
|
||||
const keyframeModel = computed(() => ({
|
||||
keyframeLimit: keyframeLimit.value,
|
||||
keyframeWidth: keyframeWidth.value,
|
||||
keyframeHeight: keyframeHeight.value
|
||||
}))
|
||||
const dimensionRules = sizeRules(() => ({ width: keyframeWidth.value, height: keyframeHeight.value }))
|
||||
const keyframeRules = {
|
||||
keyframeLimit: integerRule('本批上限', 1, true),
|
||||
keyframeWidth: dimensionRules.width,
|
||||
keyframeHeight: dimensionRules.height
|
||||
}
|
||||
const videoModel = computed(() => ({ videoLimit: videoLimit.value, videoRepairAttempts: videoRepairAttempts.value }))
|
||||
const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairAttempts: integerRule('每镜最多自动修复') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -219,25 +237,31 @@ watch(
|
||||
</RouterLink>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="项目生产配置">
|
||||
<div class="production-controls">
|
||||
<label>
|
||||
<span class="field-label">批量并发</span>
|
||||
<NInputNumber
|
||||
<AppForm
|
||||
:model="batchModel"
|
||||
:rules="batchRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
>
|
||||
<div class="form-controls production-controls">
|
||||
<NFormItem path="concurrency" label="批量并发">
|
||||
<NInputNumber
|
||||
:input-props="{ 'aria-label': '批量并发' }"
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</NFormItem>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="control-row-checkbox text-xs"
|
||||
>覆盖模式:重写提示词/新增图视频候选
|
||||
</NCheckbox>
|
||||
</div>
|
||||
class="control-row-checkbox text-xs"
|
||||
>覆盖模式:重写提示词/新增图视频候选
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</AppForm>
|
||||
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
|
||||
><p class="text-xs leading-6 text-muted">
|
||||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||||
@@ -393,63 +417,69 @@ watch(
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
<div v-if="stage.key === 'keyframes'" class="mt-4 grid gap-3">
|
||||
<label
|
||||
><span class="field-label">本批上限(可选)</span
|
||||
<AppForm
|
||||
v-if="stage.key === 'keyframes'"
|
||||
:model="keyframeModel"
|
||||
:rules="keyframeRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
class="mt-4 grid gap-3"
|
||||
>
|
||||
<NFormItem path="keyframeLimit" label="本批上限(可选)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '本批上限(可选)' }"
|
||||
:value="keyframeLimit === '' ? null : keyframeLimit"
|
||||
@update:value="keyframeLimit = $event ?? ''"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
placeholder="留空不限制"
|
||||
/></label>
|
||||
/></NFormItem>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label
|
||||
><span class="field-label">宽度(像素)</span
|
||||
<NFormItem path="keyframeWidth" label="宽度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '宽度(像素)' }"
|
||||
:value="keyframeWidth === '' ? null : keyframeWidth"
|
||||
@update:value="keyframeWidth = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认" /></label
|
||||
><label
|
||||
><span class="field-label">高度(像素)</span
|
||||
placeholder="默认" /></NFormItem
|
||||
><NFormItem path="keyframeHeight" label="高度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '高度(像素)' }"
|
||||
:value="keyframeHeight === '' ? null : keyframeHeight"
|
||||
@update:value="keyframeHeight = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认"
|
||||
/></label>
|
||||
/></NFormItem>
|
||||
</div>
|
||||
<p v-if="!keyframeValid" class="text-xs text-danger">
|
||||
上限须为正整数;宽高须同时留空或填写正整数。
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="stage.key === 'videos'" class="mt-4 grid gap-3">
|
||||
<label
|
||||
><span class="field-label">本批镜头上限</span
|
||||
</AppForm>
|
||||
<AppForm
|
||||
v-if="stage.key === 'videos'"
|
||||
:model="videoModel"
|
||||
:rules="videoRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
class="mt-4 grid gap-3"
|
||||
>
|
||||
<NFormItem path="videoLimit" label="本批镜头上限"
|
||||
><NInputNumber
|
||||
v-model:value="videoLimit"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
|
||||
/></label>
|
||||
<label
|
||||
><span class="field-label">每镜最多自动修复</span
|
||||
/></NFormItem>
|
||||
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
|
||||
><NInputNumber
|
||||
v-model:value="videoRepairAttempts"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ 'aria-label': '视频自动修复次数' }"
|
||||
/></label>
|
||||
<p v-if="!videoQualityValid" class="text-xs text-danger">
|
||||
本批上限和自动修复次数都必须是正整数。
|
||||
</p>
|
||||
</div>
|
||||
/></NFormItem>
|
||||
</AppForm>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="query.data.value" class="panel mt-4 p-5">
|
||||
@@ -466,7 +496,7 @@ watch(
|
||||
blocked || !batchValid || !query.data.value.videoStatus.failed
|
||||
"
|
||||
acknowledgement
|
||||
description="为当前最近一次状态为失败的镜头重新创建视频模型任务。成功镜头和进行中的镜头不会处理。"
|
||||
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
||||
@confirm="run('retry-videos')"
|
||||
/>
|
||||
</div>
|
||||
@@ -591,6 +621,18 @@ watch(
|
||||
</div>
|
||||
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
|
||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
|
||||
<NAlert
|
||||
v-if="videoStatusItem?.status === 'failed'"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
>
|
||||
{{ videoStatusItem.error || '最近一次视频任务失败' }}
|
||||
<p v-if="videoStatusItem.retryable === false" class="mt-2">
|
||||
此失败不支持普通重试,请先处理失败原因。
|
||||
</p>
|
||||
<p v-else-if="videoStatusItem.retryable === true" class="mt-2">后端允许重试此任务。</p>
|
||||
</NAlert>
|
||||
<StaleKeyframeNotice
|
||||
v-if="selectedKeyframeStale"
|
||||
:key="shot.shotId"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,43 +55,51 @@ function submit() {
|
||||
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
|
||||
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 图片模型。"
|
||||
>
|
||||
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
||||
</NAlert>
|
||||
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(可选)</span
|
||||
><NInputNumber
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">高度(可选)</span
|
||||
><NInputNumber
|
||||
:value="typeof height === 'number' ? height : null"
|
||||
@update:value="height = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
</div>
|
||||
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
||||
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>生成成功后设为当前主首帧
|
||||
</NCheckbox>
|
||||
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||
当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
|
||||
</NAlert>
|
||||
<NCheckbox
|
||||
id="confirm-keyframe-cost"
|
||||
v-model:checked="confirmed"
|
||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>我已确认调用图片模型可能产生费用,且当前没有相同镜头的生图任务。
|
||||
</NCheckbox>
|
||||
<div class="dialog-footer mt-6">
|
||||
<NButton @click="open = false">取消</NButton>
|
||||
<NButton :disabled="!canSubmit" @click="submit" type="primary">确认生成首帧</NButton>
|
||||
</div>
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="disabled"
|
||||
:reset-key="`${open}:${shotId}`"
|
||||
validate-on-change
|
||||
@submit="submit"
|
||||
>
|
||||
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
||||
</NAlert>
|
||||
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||
<NFormItem path="width" label="宽度(可选)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '宽度(可选)' }"
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></NFormItem>
|
||||
<NFormItem path="height" label="高度(可选)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '高度(可选)' }"
|
||||
:value="typeof height === 'number' ? height : null"
|
||||
@update:value="height = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></NFormItem>
|
||||
</div>
|
||||
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>生成成功后设为当前主首帧
|
||||
</NCheckbox>
|
||||
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||
当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
|
||||
</NAlert>
|
||||
<NCheckbox
|
||||
id="confirm-keyframe-cost"
|
||||
v-model:checked="confirmed"
|
||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||
>我已确认调用图片模型可能产生费用,且当前没有相同镜头的生图任务。
|
||||
</NCheckbox>
|
||||
<div class="dialog-footer mt-6">
|
||||
<NButton @click="open = false">取消</NButton>
|
||||
<NButton :disabled="disabled || !confirmed" attr-type="submit" type="primary">确认生成首帧</NButton>
|
||||
</div>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -40,7 +40,8 @@ function exportReceipt() {
|
||||
{{ receipt.result.failed }}
|
||||
</p>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
|
||||
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 可重试 {{ receipt.result.retryable }} · 跳过
|
||||
{{ receipt.result.skipped }} · 已重新提交 {{ receipt.result.retried }} · 提交失败
|
||||
{{ receipt.result.failed }}
|
||||
</p>
|
||||
<NAlert
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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<boolean>('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<string[]>([])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -118,126 +135,141 @@ async function submit() {
|
||||
description="查看质量结果,或按需发起新的视觉检查。"
|
||||
wide
|
||||
>
|
||||
<NRadioGroup
|
||||
v-if="target && target.kind !== 'batch'"
|
||||
v-model:value="action"
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="operation.pending"
|
||||
class="mt-4"
|
||||
aria-label="素材质量操作"
|
||||
:reset-key="`${open}:${key}:${action}`"
|
||||
validate-on-change
|
||||
@invalid="optionsExpanded = ['options']"
|
||||
@submit="submit"
|
||||
>
|
||||
<NRadioButton value="validate">视觉校验</NRadioButton
|
||||
><NRadioButton value="repair">{{ target?.kind === 'video' ? '生成修复候选' : '校验并修复' }}</NRadioButton>
|
||||
</NRadioGroup>
|
||||
<NAlert v-if="target?.kind === 'video'" type="info" :show-icon="false" class="mt-4"
|
||||
>视频校验只检查三个抽样帧,不含音频、口型和完整运动。普通候选只校验;修复候选复检通过后会自动设为主视频。</NAlert
|
||||
>
|
||||
<div v-if="generating" class="quality-fields mt-4">
|
||||
<label
|
||||
><span class="field-label">{{ videoRepair ? '修复链次数上限' : '每镜最多修复' }}</span
|
||||
><NInputNumber
|
||||
v-model:value="attempts"
|
||||
:disabled="operation.pending"
|
||||
:min="videoRepair ? 1 : 0"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '每镜最多修复次数' }"
|
||||
/></label>
|
||||
<label v-if="target?.kind === 'batch'"
|
||||
><span class="field-label">本批镜头上限</span
|
||||
><NInputNumber
|
||||
v-model:value="limit"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '质量生成镜头上限' }"
|
||||
/></label>
|
||||
</div>
|
||||
<NCheckbox v-if="target?.kind === 'batch'" v-model:checked="force" :disabled="operation.pending" class="mt-4"
|
||||
>重做本集已有有效首帧(通过后替换主图)</NCheckbox
|
||||
>
|
||||
<p v-if="target?.kind === 'batch'" class="mt-3 text-xs text-muted">
|
||||
仅第 {{ target.episodeNo }} 集,最多
|
||||
{{ limit ?? '—' }} 镜;非重做模式优先处理过期项,若全项目无过期项则补缺失首帧。
|
||||
</p>
|
||||
<p v-if="videoRepair" class="mt-3 text-xs text-muted">
|
||||
本次仅创建一个修复视频,不自动等待或复检。完成后请打开新候选的质量检查;修复约束沿用上次失败校验,不使用额外参数。
|
||||
</p>
|
||||
<NAlert v-if="!repairAvailable" type="warning" :show-icon="false" class="mt-3"
|
||||
>请先执行视觉校验,只有未通过的视频可以创建修复候选。</NAlert
|
||||
>
|
||||
<NCollapse v-if="!videoRepair" class="mt-4">
|
||||
<NCollapseItem name="options" title="更多参数与模型限制">
|
||||
<div class="quality-fields">
|
||||
<label v-if="target?.kind === 'batch'"
|
||||
><span class="field-label">批量并发</span
|
||||
><NInputNumber
|
||||
v-model:value="concurrency"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '质量生成并发' }"
|
||||
/></label>
|
||||
<label v-if="generating"
|
||||
><span class="field-label">宽度(可选)</span
|
||||
><NInputNumber
|
||||
v-model:value="width"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:input-props="{ 'aria-label': '质量生成宽度' }"
|
||||
/></label>
|
||||
<label v-if="generating"
|
||||
><span class="field-label">高度(可选)</span
|
||||
><NInputNumber
|
||||
v-model:value="height"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:input-props="{ 'aria-label': '质量生成高度' }"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="mt-4 block"
|
||||
><span class="field-label">额外允许的画面文字(一行一项,可留空)</span
|
||||
><NInput
|
||||
v-model:value="texts"
|
||||
<NRadioGroup
|
||||
v-if="target && target.kind !== 'batch'"
|
||||
v-model:value="action"
|
||||
:disabled="operation.pending"
|
||||
class="mt-4"
|
||||
aria-label="素材质量操作"
|
||||
>
|
||||
<NRadioButton value="validate">视觉校验</NRadioButton
|
||||
><NRadioButton value="repair">{{
|
||||
target?.kind === 'video' ? '生成修复候选' : '校验并修复'
|
||||
}}</NRadioButton>
|
||||
</NRadioGroup>
|
||||
<NAlert v-if="target?.kind === 'video'" type="info" :show-icon="false" class="mt-4"
|
||||
>视频校验会按镜头启用抽样帧、运动及道具专项检查,不含音频和口型。普通候选只校验;修复候选复检通过后会自动设为主视频。</NAlert
|
||||
>
|
||||
<div v-if="generating" class="quality-fields mt-4">
|
||||
<NFormItem path="attempts" :label="videoRepair ? '修复链次数上限' : '每镜最多修复'"
|
||||
><NInputNumber
|
||||
v-model:value="attempts"
|
||||
:disabled="operation.pending"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:input-props="{ 'aria-label': '额外允许的画面文字' }"
|
||||
/></label>
|
||||
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
<NAlert v-if="!valid" type="error" :show-icon="false" class="mt-4">{{
|
||||
videoRepair
|
||||
? '修复链次数上限需为正整数。'
|
||||
: '并发、数量和集数需为正整数,修复次数需为非负整数;宽高同时留空或同时填写正整数。'
|
||||
}}</NAlert>
|
||||
<NAlert type="warning" :show-icon="false" class="mt-4">
|
||||
<template v-if="videoRepair"
|
||||
>本次最多提交 1
|
||||
个视频生成任务,可能产生费用;生成完成不会切换主视频,需再次付费复检通过后才会晋升。</template
|
||||
:min="videoRepair ? 1 : 0"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '每镜最多修复次数' }"
|
||||
/></NFormItem>
|
||||
<NFormItem v-if="target?.kind === 'batch'" path="limit" label="本批镜头上限"
|
||||
><NInputNumber
|
||||
v-model:value="limit"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '质量生成镜头上限' }"
|
||||
/></NFormItem>
|
||||
</div>
|
||||
<NCheckbox
|
||||
v-if="target?.kind === 'batch'"
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="mt-4"
|
||||
>重做本集已有有效首帧(通过后替换主图)</NCheckbox
|
||||
>
|
||||
<template v-else-if="valid"
|
||||
>本次最多调用 {{ maxImages }} 次生图、{{ maxValidations }} 次视觉校验,均可能产生费用。</template
|
||||
<p v-if="target?.kind === 'batch'" class="mt-3 text-xs text-muted">
|
||||
仅第 {{ target.episodeNo }} 集,最多
|
||||
{{ limit ?? '—' }} 镜;非重做模式优先处理过期项,若全项目无过期项则补缺失首帧。
|
||||
</p>
|
||||
<p v-if="videoRepair" class="mt-3 text-xs text-muted">
|
||||
本次仅创建一个修复视频,不自动等待或复检。完成后请打开新候选的质量检查;修复约束沿用上次失败校验,不使用额外参数。
|
||||
</p>
|
||||
<NAlert v-if="!repairAvailable" type="warning" :show-icon="false" class="mt-3"
|
||||
>请先执行视觉校验,只有未通过的视频可以创建修复候选。</NAlert
|
||||
>
|
||||
<template v-if="generating && !videoRepair"
|
||||
>修复/质量生成通过后可能替换主首帧;失败候选仍会保留,已有视频不会自动更新。</template
|
||||
<NCollapse
|
||||
v-if="!videoRepair"
|
||||
v-model:expanded-names="optionsExpanded"
|
||||
display-directive="show"
|
||||
class="mt-4"
|
||||
>
|
||||
<template v-else-if="!generating">{{
|
||||
target?.kind === 'video' ? '如果当前是修复候选,校验通过将替换当前主视频。' : '仅校验,不修改主资产。'
|
||||
}}</template>
|
||||
</NAlert>
|
||||
<NCheckbox v-model:checked="acknowledged" :disabled="operation.pending" class="mt-4"
|
||||
>我已确认费用、处理范围,且同项目没有其他生成任务。</NCheckbox
|
||||
>
|
||||
<div class="dialog-footer mt-4">
|
||||
<NButton @click="open = false">关闭</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="operation.pending"
|
||||
:disabled="blocked || !valid || !acknowledged || !repairAvailable"
|
||||
@click="submit"
|
||||
>{{ label }}</NButton
|
||||
<NCollapseItem name="options" title="更多参数与模型限制">
|
||||
<div class="quality-fields">
|
||||
<NFormItem v-if="target?.kind === 'batch'" path="concurrency" label="批量并发"
|
||||
><NInputNumber
|
||||
v-model:value="concurrency"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '质量生成并发' }"
|
||||
/></NFormItem>
|
||||
<NFormItem v-if="generating" path="width" label="宽度(可选)"
|
||||
><NInputNumber
|
||||
v-model:value="width"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:input-props="{ 'aria-label': '质量生成宽度' }"
|
||||
/></NFormItem>
|
||||
<NFormItem v-if="generating" path="height" label="高度(可选)"
|
||||
><NInputNumber
|
||||
v-model:value="height"
|
||||
:disabled="operation.pending"
|
||||
:min="1"
|
||||
:input-props="{ 'aria-label': '质量生成高度' }"
|
||||
/></NFormItem>
|
||||
</div>
|
||||
<NFormItem class="mt-4 block" path="texts" label="额外允许的画面文字(一行一项,可留空)"
|
||||
><NInput
|
||||
v-model:value="texts"
|
||||
:disabled="operation.pending"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:input-props="{ 'aria-label': '额外允许的画面文字' }"
|
||||
/></NFormItem>
|
||||
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
<NAlert type="warning" :show-icon="false" class="mt-4">
|
||||
<template v-if="videoRepair"
|
||||
>本次最多提交 1
|
||||
个视频生成任务,可能产生费用;生成完成不会切换主视频,需再次付费复检通过后才会晋升。</template
|
||||
>
|
||||
<template v-else-if="target?.kind === 'video'"
|
||||
>本次提交一次视频校验请求,后端可能调用多个视觉模型并产生费用。</template
|
||||
>
|
||||
<template v-else-if="valid"
|
||||
>本次最多调用 {{ maxImages }} 次生图、{{ maxValidations }} 次视觉校验,均可能产生费用。</template
|
||||
>
|
||||
<template v-if="generating && !videoRepair"
|
||||
>修复/质量生成通过后可能替换主首帧;失败候选仍会保留,已有视频不会自动更新。</template
|
||||
>
|
||||
<template v-else-if="!generating">{{
|
||||
target?.kind === 'video'
|
||||
? '如果当前是修复候选,校验通过将替换当前主视频。'
|
||||
: '仅校验,不修改主资产。'
|
||||
}}</template>
|
||||
</NAlert>
|
||||
<NCheckbox v-model:checked="acknowledged" :disabled="operation.pending" class="mt-4"
|
||||
>我已确认费用、处理范围,且同项目没有其他生成任务。</NCheckbox
|
||||
>
|
||||
</div>
|
||||
<div class="dialog-footer mt-4">
|
||||
<NButton @click="open = false">关闭</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="operation.pending"
|
||||
:disabled="blocked || !acknowledged || !repairAvailable"
|
||||
attr-type="submit"
|
||||
>{{ label }}</NButton
|
||||
>
|
||||
</div>
|
||||
</AppForm>
|
||||
<p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted">
|
||||
{{ operation.label }}进行中,请勿重复提交。
|
||||
</p>
|
||||
@@ -246,7 +278,7 @@ async function submit() {
|
||||
}}</NAlert>
|
||||
<QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" />
|
||||
<NCollapse v-else-if="saved" class="mt-4"
|
||||
><NCollapseItem name="saved" :title="`最近已保存校验 · ${saved.passed ? '通过' : '未通过'}`"
|
||||
><NCollapseItem name="saved" :title="`本会话最近校验 · ${saved.passed ? '通过' : '未通过'}`"
|
||||
><p>{{ saved.summary }}</p>
|
||||
<p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger">
|
||||
{{ issue.description }}
|
||||
@@ -254,8 +286,8 @@ async function submit() {
|
||||
<p class="mt-3 text-xs text-muted">此为历史抽样结论,不保证当前上游素材仍然一致。</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<p v-if="session.receipt && target?.kind !== 'video'" class="mt-3 text-xs text-muted">
|
||||
回执保留在当前会话,可导出;后端暂未提供首帧校验历史读取接口。
|
||||
<p v-if="target?.kind !== 'batch'" class="mt-3 text-xs text-muted">
|
||||
校验回执保留在当前会话,可导出;素材列表不提供历史质量结果,刷新浏览器后需重新校验。
|
||||
</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -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() {
|
||||
: '未发现'
|
||||
}}
|
||||
</p>
|
||||
<div v-for="check in videoChecks" :key="check.label" class="mt-2 text-xs">
|
||||
{{ check.label }}:<template v-if="check.result?.enabled"
|
||||
>{{
|
||||
check.result.applicable === false ? '不适用' : check.result.passed ? '通过' : '未通过'
|
||||
}}
|
||||
· {{ check.result.summary }}</template
|
||||
><template v-else>未执行</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="subject in receipt.result.subjects"
|
||||
:key="subject.subjectRef"
|
||||
|
||||
@@ -27,7 +27,7 @@ import ActionMenu from '../../../components/ui/ActionMenu.vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
import ModelCapabilities from './ModelCapabilities.vue'
|
||||
import type { QualityTarget } from '../quality.types'
|
||||
import { savedVideoValidation, videoQualityInfo, videoRepairInfo } from '../quality'
|
||||
import { sessionVideoValidation } from '../quality'
|
||||
|
||||
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
|
||||
const props = defineProps<{
|
||||
@@ -64,10 +64,6 @@ const assetBlocked = computed(() => props.disabled || operation.value.pending ||
|
||||
const keyframeOpen = ref(false)
|
||||
const qualityOpen = ref(false)
|
||||
const qualityTarget = ref<QualityTarget | null>(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() {
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||
<NTag v-if="item.isPrimary" size="small" :bordered="false">主首帧</NTag>
|
||||
<NTag v-if="item.source === 'provider_variant'" size="small" :bordered="false"
|
||||
>模型输入变体</NTag
|
||||
>
|
||||
<NTag v-if="item.isPrimary && keyframeStale" size="small" :bordered="false">已过期</NTag>
|
||||
<span class="text-[10px] text-muted"
|
||||
>{{ item.width || '—' }} × {{ item.height || '—' }}</span
|
||||
@@ -338,7 +343,12 @@ async function inspectSpecs() {
|
||||
>质量检查</NButton
|
||||
>
|
||||
<NButton
|
||||
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
||||
v-if="
|
||||
item.status === 'completed' &&
|
||||
item.imageUrl &&
|
||||
item.source !== 'provider_variant' &&
|
||||
!item.isPrimary
|
||||
"
|
||||
:disabled="assetBlocked"
|
||||
@click="confirmingKeyframe = item.id"
|
||||
text
|
||||
@@ -401,22 +411,7 @@ async function inspectSpecs() {
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
|
||||
<NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false"
|
||||
>修复候选 · 待复检</NTag
|
||||
>
|
||||
<NTag
|
||||
v-else-if="videoQualityInfo(item.rawJson) && !item.isPrimary"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>质量候选 ·
|
||||
{{
|
||||
savedVideoValidation(item.rawJson)
|
||||
? savedVideoValidation(item.rawJson)?.passed
|
||||
? '已通过'
|
||||
: '未通过'
|
||||
: '自动校验中'
|
||||
}}</NTag
|
||||
>
|
||||
<NTag v-if="!item.isPrimary" size="small" :bordered="false">候选视频</NTag>
|
||||
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
|
||||
<span class="text-[10px] text-muted"
|
||||
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
|
||||
@@ -434,8 +429,13 @@ async function inspectSpecs() {
|
||||
text
|
||||
size="small"
|
||||
@click="openQuality('video', item.id)"
|
||||
>质量检查<template v-if="savedVideoValidation(item.rawJson)">
|
||||
· {{ savedVideoValidation(item.rawJson)?.passed ? '曾通过' : '未通过' }}</template
|
||||
>质量检查<template v-if="sessionVideoValidation(projectId, item.shotId, item.id)">
|
||||
·
|
||||
{{
|
||||
sessionVideoValidation(projectId, item.shotId, item.id)?.passed
|
||||
? '曾通过'
|
||||
: '未通过'
|
||||
}}</template
|
||||
></NButton
|
||||
>
|
||||
<a
|
||||
@@ -451,8 +451,7 @@ async function inspectSpecs() {
|
||||
item.status === 'completed' &&
|
||||
item.videoUrl &&
|
||||
!item.isPrimary &&
|
||||
(!(videoRepairInfo(item.rawJson) || videoQualityInfo(item.rawJson)) ||
|
||||
savedVideoValidation(item.rawJson)?.passed === true)
|
||||
sessionVideoValidation(projectId, item.shotId, item.id)?.passed === true
|
||||
"
|
||||
:disabled="assetBlocked"
|
||||
@click="confirmingVideo = item.id"
|
||||
@@ -473,6 +472,9 @@ async function inspectSpecs() {
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p>
|
||||
<p v-if="videos.some(item => !item.isPrimary)" class="mt-3 text-xs text-muted">
|
||||
候选视频的历史质量结果未公开;手动设为主视频前需在本会话完成视觉校验。自动质量任务的晋升结果以刷新后的主视频标记为准。
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<ConfirmAction
|
||||
:label="
|
||||
@@ -525,7 +527,15 @@ async function inspectSpecs() {
|
||||
Negative prompt: {{ videoSpec.negativePrompt }}
|
||||
</p>
|
||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">
|
||||
Keyframe ID · {{ videoSpec.keyframe.id }}
|
||||
业务主首帧 ID · {{ videoSpec.keyframe.id }}
|
||||
</p>
|
||||
<p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted">
|
||||
模型输入首帧 ID · {{ videoSpec.providerInputKeyframe.id }}
|
||||
{{
|
||||
videoSpec.providerInputKeyframe.source === 'provider_variant'
|
||||
? '(专用变体)'
|
||||
: '(业务主首帧)'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div></NCollapseItem
|
||||
@@ -537,7 +547,6 @@ async function inspectSpecs() {
|
||||
:project-id="projectId"
|
||||
:target="qualityTarget"
|
||||
:disabled="assetBlocked"
|
||||
:saved-raw-json="qualityRawJson"
|
||||
@changed="refreshAll"
|
||||
/>
|
||||
<KeyframeDialog
|
||||
|
||||
@@ -24,7 +24,9 @@ export function validOptionalSize(width: number | '', height: number | ''): bool
|
||||
|
||||
/** 返回当前成功主首帧;异常的重复主图只取最新列表中的第一条。 */
|
||||
export function primaryKeyframe(items: ShotKeyframe[]): ShotKeyframe | undefined {
|
||||
return items.find(item => 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: '主首帧已过期'
|
||||
}
|
||||
|
||||
@@ -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<Record<string, { receipt: QualityReceipt | null; error: string }>>({})
|
||||
const sessions = reactive<
|
||||
Record<string, { receipt: QualityReceipt | null; error: string; videoValidation?: VideoValidation }>
|
||||
>({})
|
||||
|
||||
/** 正式 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -8,13 +8,11 @@ export function keyframeFixture(patch: Partial<ShotKeyframe> = {}): 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> = {}): 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,
|
||||
|
||||
@@ -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<ProductionReadinessStatus, 'in_progress'>
|
||||
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'
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { requiredTextRule, integerRule } from '../../../lib/form-rules'
|
||||
import { NAlert, NButton, NInput, NInputNumber } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
|
||||
@@ -17,10 +20,6 @@ const form = reactive({ topic: '', style: '爽文反转', episodeCount: 3 })
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
error.value = ''
|
||||
if (!form.topic.trim() || !Number.isSafeInteger(form.episodeCount) || form.episodeCount <= 0) {
|
||||
error.value = '请填写故事主题,并输入大于 0 的整数集数。'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await projectsApi.create({
|
||||
@@ -37,6 +36,7 @@ async function submit() {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRule('计划集数') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,40 +49,44 @@ async function submit() {
|
||||
<template #trigger
|
||||
><NButton @click="open = true" type="primary"><Plus :size="16" />新建剧本</NButton></template
|
||||
>
|
||||
<form class="mt-7 space-y-5" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
|
||||
<AppForm
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:disabled="busy"
|
||||
:reset-key="open"
|
||||
class="mt-7 space-y-4"
|
||||
@submit="submit"
|
||||
>
|
||||
<NFormItem path="topic" label="故事主题" :label-props="{ for: 'topic' }"
|
||||
><NInput
|
||||
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
||||
:disabled="busy"
|
||||
:input-props="{ id: 'topic', required: true }"
|
||||
:input-props="{ id: 'topic' }"
|
||||
class="min-h-32 resize-y"
|
||||
v-model:value="form.topic"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</div>
|
||||
></NInput
|
||||
></NFormItem>
|
||||
<div class="grid grid-cols-[1fr_110px] gap-4">
|
||||
<div>
|
||||
<label class="field-label" for="style">剧本风格</label
|
||||
<NFormItem path="style" label="剧本风格" :label-props="{ for: 'style' }"
|
||||
><NInput
|
||||
placeholder="如:都市悬疑、爽文反转"
|
||||
:disabled="busy"
|
||||
:input-props="{ id: 'style' }"
|
||||
v-model:value="form.style"
|
||||
></NInput>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="episode-count">计划集数</label
|
||||
></NInput
|
||||
></NFormItem>
|
||||
<NFormItem path="episodeCount" label="计划集数" :label-props="{ for: 'episode-count' }"
|
||||
><NInputNumber
|
||||
:disabled="busy"
|
||||
:input-props="{ id: 'episode-count', required: true }"
|
||||
:input-props="{ id: 'episode-count' }"
|
||||
:value="typeof form.episodeCount === 'number' ? form.episodeCount : null"
|
||||
@update:value="form.episodeCount = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</div>
|
||||
></NInputNumber
|
||||
></NFormItem>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
建议先用 3 集验证生成效果。生成会实际调用后端模型,产生相应费用。
|
||||
@@ -96,6 +100,6 @@ async function submit() {
|
||||
:size="16"
|
||||
/></NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -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 中页面使用的状态切片。 */
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { integerRule } from '../../lib/form-rules'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
@@ -46,8 +49,6 @@ const {
|
||||
maxRepairAttempts,
|
||||
force,
|
||||
allowed,
|
||||
batchValid,
|
||||
repairsValid,
|
||||
blocked,
|
||||
directionComplete,
|
||||
hasCurrentEpisodeShots,
|
||||
@@ -96,6 +97,8 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
const formModel = computed(() => ({ concurrency: concurrency.value, maxRepairAttempts: maxRepairAttempts.value }))
|
||||
const rules = { concurrency: integerRule('批量并发'), maxRepairAttempts: integerRule('状态自动修复次数', 0) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -156,42 +159,42 @@ watch(
|
||||
>
|
||||
</div>
|
||||
<section class="mt-3" aria-label="分镜生成配置">
|
||||
<div class="storyboard-controls">
|
||||
<label
|
||||
><span class="field-label">批量并发</span
|
||||
><NInputNumber
|
||||
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
|
||||
<div class="form-controls storyboard-controls">
|
||||
<NFormItem
|
||||
path="concurrency"
|
||||
label="批量并发"
|
||||
:label-props="{ for: 'storyboard-concurrency' }"
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></NFormItem>
|
||||
<NFormItem
|
||||
path="maxRepairAttempts"
|
||||
label="状态自动修复次数"
|
||||
:label-props="{ for: 'storyboard-repairs' }"
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-repairs' }"
|
||||
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
|
||||
@update:value="maxRepairAttempts = $event ?? 0"
|
||||
:min="0"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></NFormItem>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span v-if="!batchValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入正整数</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><span class="field-label">状态自动修复次数</span
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'storyboard-repairs' }"
|
||||
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
|
||||
@update:value="maxRepairAttempts = $event ?? 0"
|
||||
:min="0"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入非负整数,0 表示不修复</span
|
||||
></label
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="control-row-checkbox text-xs"
|
||||
>批量覆盖已有结果</NCheckbox
|
||||
>
|
||||
</div>
|
||||
class="control-row-checkbox text-xs"
|
||||
>批量覆盖已有结果</NCheckbox
|
||||
>
|
||||
</div>
|
||||
</AppForm>
|
||||
</section>
|
||||
<div class="storyboard-coverage grid grid-cols-2 gap-3">
|
||||
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { integerRule } from '../../lib/form-rules'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||
import {
|
||||
@@ -155,6 +158,8 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
|
||||
const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -251,64 +256,73 @@ watch(
|
||||
<dd>{{ casting.ready }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-5 flex flex-wrap items-end gap-4 pt-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">并发数</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<label class="w-28">
|
||||
<span class="field-label">本批上限</span>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="
|
||||
typeof candidateLimit === 'number' ? candidateLimit : null
|
||||
"
|
||||
@update:value="candidateLimit = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="control-row-checkbox text-xs"
|
||||
>覆盖未锁定身份文本
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
|
||||
:disabled="
|
||||
blocked || !hasStyle || !batchValid || dirty || !casting.total
|
||||
"
|
||||
acknowledgement
|
||||
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
|
||||
@confirm="generateProject(true)"
|
||||
/>
|
||||
<ConfirmAction
|
||||
label="生成选角候选"
|
||||
:disabled="
|
||||
castingBlocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
!candidateLimitValid ||
|
||||
!casting.missingAnchor
|
||||
"
|
||||
acknowledgement
|
||||
description="最多按本批上限为缺少 Anchor 的 Character 调用 图片模型,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||
@confirm="generateCastingCandidates"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="!batchValid || !candidateLimitValid"
|
||||
class="mt-3 text-xs text-danger"
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
>
|
||||
并发数与本批上限都必须是正整数。
|
||||
</p>
|
||||
<div class="form-controls mt-5 flex flex-wrap gap-4 pt-4">
|
||||
<NFormItem class="w-28" path="concurrency" label="并发数">
|
||||
<NInputNumber
|
||||
:input-props="{ 'aria-label': '并发数' }"
|
||||
:disabled="operation.pending"
|
||||
:value="
|
||||
typeof concurrency === 'number' ? concurrency : null
|
||||
"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</NFormItem>
|
||||
<NFormItem class="w-28" path="candidateLimit" label="本批上限">
|
||||
<NInputNumber
|
||||
:input-props="{ 'aria-label': '本批上限' }"
|
||||
:disabled="operation.pending"
|
||||
:value="
|
||||
typeof candidateLimit === 'number'
|
||||
? candidateLimit
|
||||
: null
|
||||
"
|
||||
@update:value="candidateLimit = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</NFormItem>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="control-row-checkbox text-xs"
|
||||
>覆盖未锁定身份文本
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
dirty ||
|
||||
!casting.total
|
||||
"
|
||||
acknowledgement
|
||||
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
|
||||
@confirm="generateProject(true)"
|
||||
/>
|
||||
<ConfirmAction
|
||||
label="生成选角候选"
|
||||
:disabled="
|
||||
castingBlocked ||
|
||||
!hasStyle ||
|
||||
!batchValid ||
|
||||
!candidateLimitValid ||
|
||||
!casting.missingAnchor
|
||||
"
|
||||
acknowledgement
|
||||
description="最多按本批上限为缺少 Anchor 的 Character 调用 图片模型,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||
@confirm="generateCastingCandidates"
|
||||
/>
|
||||
</div>
|
||||
</AppForm>
|
||||
<div class="casting-list" role="group" aria-label="角色选角进度">
|
||||
<NButton
|
||||
v-for="item in casting.items"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="disabled"
|
||||
:reset-key="`${open}:${subjectId}`"
|
||||
validate-on-change
|
||||
class="mt-5 space-y-4"
|
||||
@submit="submit"
|
||||
>
|
||||
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||
后端会依据角色 Identity、项目视觉风格和默认选角约束独立生成一张 primary
|
||||
候选,不会复用当前身份母版。生成完成后仍需人工点击“确认选角”。
|
||||
</NAlert>
|
||||
<label class="block">
|
||||
<span class="field-label">覆盖本次完整提示词(可选)</span>
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="prompt"
|
||||
label="覆盖本次完整提示词(可选)"
|
||||
:label-props="{ for: 'casting-candidate-prompt' }"
|
||||
>
|
||||
<NInput
|
||||
placeholder="建议留空,使用后端编译的稳定身份和选角约束;未明确人物背景时默认中国人物"
|
||||
:input-props="{ id: 'casting-candidate-prompt' }"
|
||||
@@ -52,31 +69,30 @@ function submit() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</NFormItem>
|
||||
<p v-if="prompt.trim()" class="text-xs text-danger">
|
||||
自定义文本会替换后端完整提示词,请自行包含人物身份、基础服装和中性参考图要求。
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(px)</span
|
||||
<NFormItem path="width" label="宽度(px)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '宽度(px)' }"
|
||||
placeholder="后端默认"
|
||||
:value="typeof width === 'number' ? width : null"
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">高度(px)</span
|
||||
></NFormItem>
|
||||
<NFormItem path="height" label="高度(px)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '高度(px)' }"
|
||||
placeholder="后端默认"
|
||||
:value="typeof height === 'number' ? height : null"
|
||||
@update:value="height = $event ?? ''"
|
||||
:min="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
></NFormItem>
|
||||
</div>
|
||||
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
||||
<NCheckbox
|
||||
id="casting-candidate-cost"
|
||||
v-model:checked="acknowledged"
|
||||
@@ -89,6 +105,6 @@ function submit() {
|
||||
确认生成候选
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
||||
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
@@ -32,10 +34,13 @@ function save() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="mt-5" @submit.prevent="save">
|
||||
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
|
||||
<fieldset :disabled="disabled" class="space-y-4">
|
||||
<label class="block"
|
||||
><span class="field-label">稳定身份描述</span
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="description"
|
||||
label="稳定身份描述"
|
||||
:label-props="{ for: 'identity-description' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
||||
@@ -45,9 +50,12 @@ function save() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<label class="block"
|
||||
><span class="field-label">身份核心提示词(稳定事实)</span
|
||||
</NFormItem>
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="generationPrompt"
|
||||
label="身份核心提示词(稳定事实)"
|
||||
:label-props="{ for: 'identity-prompt' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="不固定某一形态的服装、伤势或临时状态"
|
||||
@@ -57,7 +65,7 @@ function save() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</NFormItem>
|
||||
<DetailDisclosure title="身份填写与锁定规则"
|
||||
><p class="text-xs leading-6 text-muted">
|
||||
{{
|
||||
@@ -87,5 +95,5 @@ function save() {
|
||||
><NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</AppForm>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||
import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { formatDate } from '../../../lib/format'
|
||||
import { canBeAnchor, currentAnchor, identityViewLabels, readImageProvenance } from '../model'
|
||||
import { canBeAnchor, currentAnchor, identityViewLabels } from '../model'
|
||||
import type { IdentityImage } from '../types'
|
||||
|
||||
/** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */
|
||||
@@ -104,9 +104,7 @@ function chooseAnchor() {
|
||||
? '母版候选'
|
||||
: identityViewLabels[image.viewType]
|
||||
}}</span>
|
||||
<span class="image-history-meta"
|
||||
><StatusBadge :status="image.status" /><span>{{ image.enabled ? '启用' : '停用' }}</span></span
|
||||
>
|
||||
<span class="image-history-meta"><StatusBadge :status="image.status" /></span>
|
||||
</NButton>
|
||||
</NScrollbar>
|
||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -118,10 +116,7 @@ function chooseAnchor() {
|
||||
? '母版候选'
|
||||
: identityViewLabels[selected.viewType]
|
||||
}}</NTag
|
||||
><span class="text-xs text-muted"
|
||||
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
|
||||
{{ selected.height || '—' }}</span
|
||||
>
|
||||
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="imageUrl && selected.status === 'completed'"
|
||||
@@ -163,19 +158,6 @@ function chooseAnchor() {
|
||||
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
<!-- 默认展示实际提示词;用户手动收起后,轮询不会强制重新展开。 -->
|
||||
<NCollapse v-if="selected.prompt" :default-expanded-names="['details']" class="mt-4 text-xs"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>本次实际提示词</template>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<p
|
||||
v-if="readImageProvenance(selected.rawJson).referenceImageId"
|
||||
class="mt-3 break-all text-[11px] text-muted"
|
||||
>
|
||||
本次锚定图片 ID:{{ readImageProvenance(selected.rawJson).referenceImageId }}
|
||||
</p>
|
||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
||||
</template>
|
||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { sizeRules, fieldRule } from '../../../lib/form-rules'
|
||||
import { NAlert, NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
@@ -56,26 +59,51 @@ function submit() {
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
const formModel = computed(() => ({
|
||||
width: width.value,
|
||||
height: height.value,
|
||||
prompt: prompt.value,
|
||||
viewType: viewType.value,
|
||||
referenceImageId: referenceImageId.value,
|
||||
availableReferenceIds: references.value.map(image => image.id)
|
||||
}))
|
||||
const rules = {
|
||||
...sizeRules(() => formModel.value),
|
||||
referenceImageId: fieldRule(
|
||||
value => !value || references.value.some(image => image.id === value),
|
||||
'请选择此主体当前可用的参考图'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="disabled"
|
||||
:reset-key="`${open}:${subjectId}`"
|
||||
validate-on-change
|
||||
class="mt-5 space-y-4"
|
||||
@submit="submit"
|
||||
>
|
||||
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||
调用 图片模型,每次新增一张图片,可能产生费用。第一张成功的 primary 图自动成为母版;已有母版时,新
|
||||
primary 图仅作为停用候选保留。
|
||||
</NAlert>
|
||||
<label class="block"
|
||||
><span class="field-label">参考视角</span
|
||||
<NFormItem class="block" path="viewType" label="参考视角" :label-props="{ for: 'identity-view' }"
|
||||
><NSelect
|
||||
id="identity-view"
|
||||
v-model:value="viewType"
|
||||
:options="allowedViews.map(([value, label]) => ({ label: label + '(' + value + ')', value }))"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<label class="block"
|
||||
><span class="field-label">身份锚定来源</span
|
||||
></NFormItem>
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="referenceImageId"
|
||||
label="身份锚定来源"
|
||||
:label-props="{ for: 'identity-reference' }"
|
||||
><NSelect
|
||||
id="identity-reference"
|
||||
v-model:value="referenceImageId"
|
||||
@@ -91,12 +119,15 @@ function submit() {
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
></NFormItem>
|
||||
<p class="text-xs leading-6 text-muted">
|
||||
只能选择此主体已完成的图片。后端需取得 Provider 可访问的远程地址;本地预览可见不保证远程地址仍有效。
|
||||
</p>
|
||||
<label class="block"
|
||||
><span class="field-label">覆盖本次完整提示词(可选)</span
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="prompt"
|
||||
label="覆盖本次完整提示词(可选)"
|
||||
:label-props="{ for: 'identity-image-prompt' }"
|
||||
><NInput
|
||||
placeholder="建议留空,使用后端编译的身份约束和视角要求"
|
||||
:input-props="{ id: 'identity-image-prompt' }"
|
||||
@@ -105,13 +136,12 @@ function submit() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</NFormItem>
|
||||
<p v-if="prompt.trim()" class="text-xs text-danger">
|
||||
自定义文本会替换后端编译的完整提示词,请自行包含身份约束和视角要求。
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(px)</span
|
||||
<NFormItem path="width" label="宽度(px)" :label-props="{ for: 'identity-width' }"
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:input-props="{ id: 'identity-width' }"
|
||||
@@ -119,9 +149,8 @@ function submit() {
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber></label
|
||||
><label
|
||||
><span class="field-label">高度(px)</span
|
||||
></NInputNumber></NFormItem
|
||||
><NFormItem path="height" label="高度(px)" :label-props="{ for: 'identity-height' }"
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:input-props="{ id: 'identity-height' }"
|
||||
@@ -130,9 +159,8 @@ function submit() {
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
></NFormItem>
|
||||
</div>
|
||||
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数;指定的参考图必须仍可用。</p>
|
||||
<NCheckbox
|
||||
id="identity-image-cost"
|
||||
v-model:checked="acknowledged"
|
||||
@@ -145,6 +173,6 @@ function submit() {
|
||||
确认生成身份图
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 主体身份模块公共入口。 */
|
||||
export { subjectIdentityApi } from './api'
|
||||
export { currentAnchor, readImageProvenance } from './model'
|
||||
export { currentAnchor } from './model'
|
||||
export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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))
|
||||
}
|
||||
|
||||
/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
|
||||
|
||||
@@ -8,7 +8,6 @@ export function identityFixture(overrides: Partial<SubjectIdentity> = {}): 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<IdentityImage> = {}): Id
|
||||
viewType: 'primary',
|
||||
provider: 'seedream',
|
||||
model: 'configured-model',
|
||||
prompt: '<script>不执行</script>',
|
||||
imageUrl: '/storage/identity.png',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
status: 'completed',
|
||||
enabled: true,
|
||||
isAnchor: true,
|
||||
error: null,
|
||||
createdAt: '2026-08-28T00:00:00Z',
|
||||
|
||||
@@ -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<SubjectIdentity, 'images'>
|
||||
identityId: string
|
||||
subjectId: string
|
||||
isLocked: boolean
|
||||
anchor: IdentityImage
|
||||
}
|
||||
|
||||
|
||||
@@ -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('接口未确认演员母版与身份锁定,请刷新核对。')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { integerRule } from '../../lib/form-rules'
|
||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||||
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||||
@@ -27,14 +30,13 @@ import {
|
||||
ArrowRight,
|
||||
LayoutGrid,
|
||||
Columns3,
|
||||
Settings2,
|
||||
AlertTriangle
|
||||
Settings2
|
||||
} from '@lucide/vue'
|
||||
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
|
||||
import { downloadText } from '../../lib/format'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
import { useSubjectImages } from './useSubjectImages'
|
||||
import { coverImage, hasRunningImages, isPrimaryIdentityStale, primaryImage } from './model'
|
||||
import { coverImage, hasRunningImages, primaryImage } from './model'
|
||||
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
|
||||
import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
|
||||
@@ -50,7 +52,6 @@ const router = useRouter()
|
||||
const {
|
||||
id,
|
||||
forms,
|
||||
staleForms,
|
||||
query,
|
||||
refreshProject,
|
||||
operation,
|
||||
@@ -60,15 +61,13 @@ const {
|
||||
limit,
|
||||
promptConcurrency,
|
||||
promptForce,
|
||||
concurrencyValid,
|
||||
generatePrompt,
|
||||
generatePrompts,
|
||||
blocked,
|
||||
running,
|
||||
batchValid,
|
||||
generate,
|
||||
generateProject,
|
||||
generateStale
|
||||
generateProject
|
||||
} = useSubjectImages()
|
||||
const sourceShotId = computed(() => queryText(route.query.sourceShotId))
|
||||
const targetFormId = computed(() => queryText(route.query.subjectFormId))
|
||||
@@ -149,12 +148,11 @@ const search = ref('')
|
||||
const layout = ref<GalleryLayout>('grid')
|
||||
const module = ref('all')
|
||||
/** 形态图片状态筛选;单选下拉避免移动端复选项占据两行。 */
|
||||
type ImageStatusFilter = 'all' | 'missing' | 'stale'
|
||||
type ImageStatusFilter = 'all' | 'missing'
|
||||
const statusFilter = ref<ImageStatusFilter>('all')
|
||||
const statusFilterOptions: Array<{ label: string; value: ImageStatusFilter }> = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '缺少主图', value: 'missing' },
|
||||
{ label: '身份过期', value: 'stale' }
|
||||
{ label: '缺少主图', value: 'missing' }
|
||||
]
|
||||
/** 保存每张卡片的尺寸观察器,切换回网格时及时释放。 */
|
||||
const masonryCardObservers = new WeakMap<HTMLElement, ResizeObserver>()
|
||||
@@ -241,7 +239,6 @@ const filtered = computed(() =>
|
||||
(!targetFormId.value || form.id === targetFormId.value) &&
|
||||
(module.value === 'all' || form.subject.module === module.value) &&
|
||||
(statusFilter.value !== 'missing' || !primaryImage(form.images)) &&
|
||||
(statusFilter.value !== 'stale' || isPrimaryIdentityStale(form)) &&
|
||||
`${form.subject.name} ${form.subject.ref} ${form.name}`
|
||||
.toLowerCase()
|
||||
.includes(search.value.trim().toLowerCase())
|
||||
@@ -305,16 +302,8 @@ function selectImpactShot(shotId: string | null) {
|
||||
async function refreshAssets() {
|
||||
await Promise.all([refreshProject(), query.refresh(), impact.query.refresh(), impact.references.refresh()])
|
||||
}
|
||||
|
||||
/** 全局过期筛选不沿用深链接的单个素材限制。 */
|
||||
async function showStaleForms() {
|
||||
await router.replace({
|
||||
query: { ...route.query, subjectFormId: undefined, subjectRef: undefined, sourceShotId: undefined }
|
||||
})
|
||||
search.value = ''
|
||||
module.value = 'all'
|
||||
statusFilter.value = 'stale'
|
||||
}
|
||||
const formModel = computed(() => ({ concurrency: concurrency.value, limit: limit.value }))
|
||||
const rules = { concurrency: integerRule('批量并发'), limit: integerRule('本批上限', 1, true) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,7 +329,7 @@ async function showStaleForms() {
|
||||
</div>
|
||||
<DetailDisclosure title="母版继承与过期处理" class="mt-4">
|
||||
人物、场景、道具形态图均可继承已锁定 Identity
|
||||
的母版,并记录本次引用来源。母版变化后可筛选过期图,重新生成候选并人工确认新的主参考图;不要直接沿用旧资产进入下游。
|
||||
的母版。母版变化后请核对形态图,按需生成候选并确认主图。当前图库不提供历史母版引用,无法自动判断形态图是否过期;下游状态以镜头就绪检查为准。
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||
>管理主体身份 →</RouterLink
|
||||
></DetailDisclosure
|
||||
@@ -358,76 +347,61 @@ async function showStaleForms() {
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-sm">
|
||||
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图
|
||||
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
|
||||
{{ staleForms.length }} 个形态主图已过期
|
||||
</span>
|
||||
<span class="ml-3 text-xs text-muted">图片模型 · 后端配置</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||
图库不自动刷新,可点击“刷新图库”读取最新记录。主图供分镜引用,候选图和历史失败记录均保留;过期主图不会自动删除或替换。
|
||||
</p>
|
||||
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
|
||||
><div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个形态 仍在使用旧 Identity
|
||||
Anchor。
|
||||
</span>
|
||||
<ConfirmAction
|
||||
label="重新生成过期形态"
|
||||
:disabled="blocked || !concurrencyValid || !staleForms.length"
|
||||
acknowledgement
|
||||
description="只为身份母版已经变化的形态调用 图片模型,各新增一张基于当前已锁定母版的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
||||
@confirm="generateStale"
|
||||
/></div
|
||||
></NAlert>
|
||||
<NCollapse class="mt-4 pt-4"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>项目批量生图</template>
|
||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||
<label class="w-28">
|
||||
<span class="field-label">批量并发</span>
|
||||
<NInputNumber
|
||||
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
|
||||
<div class="form-controls mt-4 flex flex-wrap gap-4">
|
||||
<NFormItem
|
||||
class="w-28"
|
||||
path="concurrency"
|
||||
label="批量并发"
|
||||
:label-props="{ for: 'image-concurrency' }"
|
||||
>
|
||||
<NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'image-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</NFormItem>
|
||||
<NFormItem class="w-36" path="limit" label="本批上限(可选)"
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof limit === 'number' ? limit : null"
|
||||
@update:value="limit = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
placeholder="不限制"
|
||||
:input-props="{ 'aria-label': '生图本批上限' }"
|
||||
/></NFormItem>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ id: 'image-concurrency' }"
|
||||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber>
|
||||
</label>
|
||||
<label class="w-36"
|
||||
><span class="field-label">本批上限(可选)</span
|
||||
><NInputNumber
|
||||
:disabled="operation.pending"
|
||||
:value="typeof limit === 'number' ? limit : null"
|
||||
@update:value="limit = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
placeholder="不限制"
|
||||
:input-props="{ 'aria-label': '生图本批上限' }"
|
||||
/></label>
|
||||
<NCheckbox
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="control-row-checkbox text-xs"
|
||||
>已有主图也新增候选图
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||
:disabled="blocked || !batchValid || !forms.length"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||
"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">
|
||||
并发和填写的数量上限必须是正整数。
|
||||
</p>
|
||||
class="control-row-checkbox text-xs"
|
||||
>已有主图也新增候选图
|
||||
</NCheckbox>
|
||||
<ConfirmAction
|
||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||
:disabled="blocked || !batchValid || !forms.length"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||
"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
</div>
|
||||
</AppForm>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
作用于整个项目,不受下方筛选影响。上限只限制本次真正生图数量,其余显示为“待后续处理”,不计入跳过。默认使用后端尺寸与正式提示词,不会启动视频生成。
|
||||
</p></NCollapseItem
|
||||
@@ -457,14 +431,6 @@ async function showStaleForms() {
|
||||
>
|
||||
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-else-if="session.receipt.title === '刷新过期形态图'"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-3 text-xs"
|
||||
>
|
||||
新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
|
||||
</NAlert>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
|
||||
<code>{{ failure.subjectFormId }}</code
|
||||
@@ -477,11 +443,6 @@ async function showStaleForms() {
|
||||
</section>
|
||||
</WorkspaceTools>
|
||||
<!-- 关联说明、筛选和图片共用正文滚动区,避免镜头切换时两块滚动区域高度不同步。 -->
|
||||
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
|
||||
{{ staleForms.length }}
|
||||
个形态主图与当前已锁定身份母版不一致。请生成候选、验图并切换主图,再重建相关首帧。
|
||||
<NButton text size="small" @click="showStaleForms">筛选身份过期形态</NButton>
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="impact.stale.value.length"
|
||||
type="warning"
|
||||
@@ -730,7 +691,6 @@ async function showStaleForms() {
|
||||
:key="form.id"
|
||||
class="panel form-image-card"
|
||||
:class="{
|
||||
'ring-1 ring-danger': isPrimaryIdentityStale(form),
|
||||
'form-image-card-focused': targetFormId === form.id
|
||||
}"
|
||||
:data-form-id="form.id"
|
||||
@@ -755,9 +715,6 @@ async function showStaleForms() {
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
|
||||
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false"
|
||||
>身份母版已变更</NTag
|
||||
>
|
||||
<NTag v-else-if="primaryImage(form.images)" size="small" :bordered="false">主参考图</NTag>
|
||||
<NTag v-else-if="coverImage(form)" size="small" :bordered="false">候选图 · 尚未设主图</NTag>
|
||||
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger"
|
||||
@@ -765,15 +722,6 @@ async function showStaleForms() {
|
||||
>
|
||||
<span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
|
||||
</div>
|
||||
<NAlert
|
||||
v-if="isPrimaryIdentityStale(form)"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
class="mt-3 text-xs leading-6"
|
||||
>
|
||||
当前主图未能匹配最新已锁定母版。请基于当前 Identity Anchor
|
||||
重新生成候选图,并在确认后设置为新的主参考图。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="
|
||||
sourceShot?.primaryKeyframeStale &&
|
||||
@@ -789,9 +737,7 @@ async function showStaleForms() {
|
||||
{{
|
||||
sourceShot.inconsistent
|
||||
? '后端首帧与视频检查结果不一致,请先核对,不要据此重复生图。'
|
||||
: isPrimaryIdentityStale(form)
|
||||
? '请先更新该形态主图,再重建首帧。'
|
||||
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
|
||||
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
|
||||
}}
|
||||
<RouterLink v-if="sourceLocation" :to="sourceLocation" class="text-button mt-2"
|
||||
>返回该镜头处理 →</RouterLink
|
||||
@@ -818,14 +764,7 @@ async function showStaleForms() {
|
||||
"
|
||||
/>
|
||||
<NButton :disabled="blocked" @click="openGenerate(form)"
|
||||
><ImagePlus :size="14" />
|
||||
{{
|
||||
isPrimaryIdentityStale(form)
|
||||
? '基于当前母版重新生成'
|
||||
: coverImage(form)
|
||||
? '再生成一张'
|
||||
: '生成图片'
|
||||
}}</NButton
|
||||
><ImagePlus :size="14" /> {{ coverImage(form) ? '再生成一张' : '生成图片' }}</NButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { integerRule } from '../../../lib/form-rules'
|
||||
import { computed } from 'vue'
|
||||
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
@@ -22,6 +25,8 @@ const valid = computed(() => Number.isSafeInteger(concurrency.value) && concurre
|
||||
function exportReceipt() {
|
||||
downloadText('form-prompts-receipt.json', JSON.stringify(props.receipt, null, 2), 'application/json')
|
||||
}
|
||||
const formModel = computed(() => ({ concurrency: concurrency.value }))
|
||||
const rules = { concurrency: integerRule('提示词并发') }
|
||||
</script>
|
||||
<template>
|
||||
<section class="panel mt-5 p-5" aria-label="形态提示词批量管理">
|
||||
@@ -30,34 +35,34 @@ function exportReceipt() {
|
||||
正式提示词 {{ complete }} /
|
||||
{{ forms.length }}。结合项目视觉风格生成,只保存文本,不自动生图。批量面向全项目,不受搜索筛选影响。
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||
<label class="w-28"
|
||||
><span class="field-label">提示词并发</span>
|
||||
<NInputNumber
|
||||
:disabled="pending"
|
||||
:value="concurrency"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '提示词并发' }"
|
||||
<AppForm :model="formModel" :rules="rules" :disabled="pending" validate-on-change v-slot="{ validate }">
|
||||
<div class="form-controls mt-4 flex flex-wrap gap-4">
|
||||
<NFormItem class="w-28" path="concurrency" label="提示词并发">
|
||||
<NInputNumber
|
||||
:disabled="pending"
|
||||
:value="concurrency"
|
||||
@update:value="concurrency = $event ?? 0"
|
||||
:min="1"
|
||||
:step="1"
|
||||
:input-props="{ 'aria-label': '提示词并发' }"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NCheckbox v-model:checked="force" :disabled="pending" class="control-row-checkbox text-xs"
|
||||
>覆盖已有正式提示词</NCheckbox
|
||||
>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成全部形态提示词' : '补齐形态提示词'"
|
||||
:disabled="disabled || !valid || !forms.length || (!force && complete === forms.length)"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '调用文本模型重新生成全项目形态的正式提示词,覆盖原有文本;已有图片和主图不变。'
|
||||
: '调用文本模型补齐缺少正式提示词的形态,已有正式提示词跳过;不会自动生成图片。'
|
||||
"
|
||||
@confirm="validate(() => emit('generate'))"
|
||||
/>
|
||||
</label>
|
||||
<NCheckbox v-model:checked="force" :disabled="pending" class="control-row-checkbox text-xs"
|
||||
>覆盖已有正式提示词</NCheckbox
|
||||
>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成全部形态提示词' : '补齐形态提示词'"
|
||||
:disabled="disabled || !valid || !forms.length || (!force && complete === forms.length)"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '调用文本模型重新生成全项目形态的正式提示词,覆盖原有文本;已有图片和主图不变。'
|
||||
: '调用文本模型补齐缺少正式提示词的形态,已有正式提示词跳过;不会自动生成图片。'
|
||||
"
|
||||
@confirm="emit('generate')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!valid" class="mt-2 text-xs text-danger">提示词并发必须是正整数。</p>
|
||||
</div>
|
||||
</AppForm>
|
||||
<section v-if="receipt" class="surface-inset mt-4 p-4" aria-label="提示词批量回执">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h4>{{ receipt.title }}</h4>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { sizeRules } from '../../../lib/form-rules'
|
||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { AppDialog } from '../../../components/ui'
|
||||
@@ -39,6 +42,8 @@ function submit() {
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
const formModel = computed(() => ({ width: width.value, height: height.value, prompt: prompt.value }))
|
||||
const rules = { ...sizeRules(() => formModel.value) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +52,15 @@ function submit() {
|
||||
title="生成形态图片"
|
||||
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
|
||||
>
|
||||
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
||||
<AppForm
|
||||
:model="formModel"
|
||||
:rules="rules"
|
||||
:disabled="disabled"
|
||||
:reset-key="`${open}:${form?.id}`"
|
||||
validate-on-change
|
||||
class="mt-6 space-y-5"
|
||||
@submit="submit"
|
||||
>
|
||||
<NAlert type="info" :show-icon="false" class="text-xs"
|
||||
>使用后端配置的图片模型,可能产生费用。每次新增一张图片,不删除历史结果。</NAlert
|
||||
>
|
||||
@@ -55,8 +68,11 @@ function submit() {
|
||||
人物、场景、道具均会引用已锁定身份的当前母版,保持身份、空间骨架或物件结构;身份未锁定或没有母版时不会继承该图片。更换母版不会自动更新已有形态图。
|
||||
身份母版与此处的“形态主参考图”是两种不同用途的图片。
|
||||
</p>
|
||||
<label class="block"
|
||||
><span class="field-label">自定义提示词(可选,仅用于本次)</span
|
||||
<NFormItem
|
||||
class="block"
|
||||
path="prompt"
|
||||
label="自定义提示词(可选,仅用于本次)"
|
||||
:label-props="{ for: 'image-prompt' }"
|
||||
><NInput
|
||||
placeholder="留空使用正式 generationPrompt;缺失时后端先自动生成正式提示词"
|
||||
:input-props="{ id: 'image-prompt' }"
|
||||
@@ -65,7 +81,7 @@ function submit() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</NFormItem>
|
||||
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>{{
|
||||
@@ -77,8 +93,7 @@ function submit() {
|
||||
></NCollapse
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
><span class="field-label">宽度(px)</span
|
||||
<NFormItem path="width" label="宽度(px)" :label-props="{ for: 'image-width' }"
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:input-props="{ id: 'image-width' }"
|
||||
@@ -86,9 +101,8 @@ function submit() {
|
||||
@update:value="width = $event ?? ''"
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber></label
|
||||
><label
|
||||
><span class="field-label">高度(px)</span
|
||||
></NInputNumber></NFormItem
|
||||
><NFormItem path="height" label="高度(px)" :label-props="{ for: 'image-height' }"
|
||||
><NInputNumber
|
||||
placeholder="后端默认"
|
||||
:input-props="{ id: 'image-height' }"
|
||||
@@ -97,10 +111,9 @@ function submit() {
|
||||
:min="1"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
></NFormItem>
|
||||
</div>
|
||||
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K;自定义尺寸须满足模型限制。</p>
|
||||
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数,或同时留空。</p>
|
||||
<p v-if="!form?.generationPrompt" class="text-xs leading-6 text-muted">
|
||||
未填写自定义提示词时,后端先调用文本模型补齐正式提示词,再生图;可能产生两类模型费用。原始外观素材不会直接作为最终生图提示词。
|
||||
</p>
|
||||
@@ -124,6 +137,6 @@ function submit() {
|
||||
确认生成图片
|
||||
</NButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppForm>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||
import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, RefreshCw } from '@lucide/vue'
|
||||
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { usePolling } from '../../../composables/usePolling'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { formatDate } from '../../../lib/format'
|
||||
import { readImageProvenance } from '../../subject-identity'
|
||||
import { getOperation, runOperation } from '../../workflows/operations'
|
||||
import { subjectImagesApi } from '../api'
|
||||
import { hasRunningImages, primaryImage } from '../model'
|
||||
import { hasRunningImages, primaryImage, currentIdentityAnchorId } from '../model'
|
||||
import type { SubjectFormAsset } from '../types'
|
||||
|
||||
/** 形态图片历史与主图选择;历史查询只在弹窗打开时进行。 */
|
||||
@@ -186,22 +185,9 @@ async function choosePrimary() {
|
||||
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||
</div></NAlert
|
||||
>
|
||||
<NCollapse v-if="selected.prompt" class="mt-4 text-xs"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>查看本次实际提示词</template>
|
||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
|
||||
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
|
||||
Negative prompt: {{ selected.negativePrompt }}
|
||||
</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
|
||||
<p class="mt-2 break-all text-[11px] text-muted">
|
||||
{{
|
||||
readImageProvenance(selected.rawJson).identityAnchorImageId
|
||||
? `本次引用身份母版 ID:${readImageProvenance(selected.rawJson).identityAnchorImageId}`
|
||||
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
|
||||
}}
|
||||
<p v-if="form && currentIdentityAnchorId(form)" class="mt-2 break-all text-[11px] text-muted">
|
||||
当前已锁定身份母版 ID:{{ currentIdentityAnchorId(form) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="py-10 text-center text-sm text-muted">
|
||||
|
||||
@@ -21,29 +21,7 @@ export function coverImage(form: SubjectFormAsset): SubjectImage | undefined {
|
||||
|
||||
/** 当前主体正在使用的 Identity Anchor ID。 */
|
||||
export function currentIdentityAnchorId(form: SubjectFormAsset): string | undefined {
|
||||
return form.subject.identity?.isLocked ? form.subject.identity.images[0]?.id : undefined
|
||||
}
|
||||
|
||||
/** 读取形态图生成时实际使用的 Identity Anchor ID。 */
|
||||
export function imageIdentityAnchorId(image: SubjectImage | undefined): string | undefined {
|
||||
if (!image?.rawJson) return undefined
|
||||
try {
|
||||
const raw = JSON.parse(image.rawJson) as { identityAnchorImageId?: unknown }
|
||||
return typeof raw.identityAnchorImageId === 'string' ? raw.identityAnchorImageId : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前主形态图是否由旧 Identity Anchor 生成。
|
||||
* 有当前 Anchor 但旧图没有追溯 ID,也按过期处理,避免历史图片继续进入后续 Keyframe。
|
||||
*/
|
||||
export function isPrimaryIdentityStale(form: SubjectFormAsset): boolean {
|
||||
const anchorId = currentIdentityAnchorId(form)
|
||||
const primary = primaryImage(form.images)
|
||||
if (!anchorId || !primary) return false
|
||||
return imageIdentityAnchorId(primary) !== anchorId
|
||||
return form.subject.identity?.isLocked ? (form.subject.identity.anchorImageId ?? undefined) : undefined
|
||||
}
|
||||
|
||||
/** 查询发现后台仍在运行时也阻止重复提交,不只依赖本地 loading。 */
|
||||
|
||||
@@ -15,7 +15,6 @@ export function imageFixture(overrides: Partial<SubjectImage> = {}): SubjectImag
|
||||
error: null,
|
||||
createdAt: '2026-08-28T08:00:00Z',
|
||||
updatedAt: '2026-08-28T08:00:00Z',
|
||||
prompt: '<script>模型提示词</script>',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SubjectFormAsset, 'id' | 'subjectId' | 'generationPrompt'>
|
||||
export type FormPromptResult = Omit<SubjectFormAsset, 'subject' | 'images'> & {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 项目提示词增强的批量参数,与图片生成的 force、limit 独立。 */
|
||||
export interface GenerateFormPromptsInput {
|
||||
|
||||
@@ -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<TResult>(
|
||||
items: SubjectFormAsset[],
|
||||
concurrency: number,
|
||||
handler: (item: SubjectFormAsset) => Promise<TResult>
|
||||
): Promise<TResult[]> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { stringArrayJsonRule } from '../../../lib/form-rules'
|
||||
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
||||
import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
@@ -18,16 +21,6 @@ const form = reactive({
|
||||
})
|
||||
const baseline = ref('')
|
||||
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
||||
const constraintsError = computed(() => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(form.constraints)
|
||||
return Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||
? ''
|
||||
: '硬约束须为字符串数组,例如 ["真人写实", "禁止二次元"]。'
|
||||
} catch {
|
||||
return '硬约束 JSON 格式不正确,请修正后保存。'
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => props.visualStyle,
|
||||
value => {
|
||||
@@ -49,7 +42,7 @@ watch(dirty, value => emit('dirty', value), { immediate: true })
|
||||
|
||||
/** 显式校验 JSON 后保存结构化约束;不对错误结构做静默转换。 */
|
||||
function save() {
|
||||
if (props.disabled || constraintsError.value) return
|
||||
if (props.disabled) return
|
||||
emit('save', {
|
||||
name: form.name.trim(),
|
||||
prompt: form.prompt,
|
||||
@@ -60,21 +53,28 @@ function save() {
|
||||
isLocked: form.isLocked
|
||||
})
|
||||
}
|
||||
const rules = { constraints: stringArrayJsonRule }
|
||||
const constraintsDetails = ref<InstanceType<typeof DetailDisclosure> | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="panel p-5 sm:p-6" @submit.prevent="save">
|
||||
<AppForm
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:disabled="disabled"
|
||||
class="panel p-5 sm:p-6"
|
||||
@invalid="constraintsDetails?.expand()"
|
||||
@submit="save"
|
||||
>
|
||||
<fieldset :disabled="disabled" class="space-y-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="font-medium">风格说明</h3>
|
||||
<NTag v-if="dirty" size="small" :bordered="false">有未保存修改</NTag>
|
||||
</div>
|
||||
<label class="block"
|
||||
><span class="field-label">风格名称</span
|
||||
<NFormItem class="block" path="name" label="风格名称" :label-props="{ for: 'style-name' }"
|
||||
><NInput :disabled="disabled" :input-props="{ id: 'style-name' }" v-model:value="form.name"></NInput
|
||||
></label>
|
||||
<label class="block"
|
||||
><span class="field-label">整体视觉语言</span
|
||||
></NFormItem>
|
||||
<NFormItem class="block" path="prompt" label="整体视觉语言" :label-props="{ for: 'style-prompt' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
placeholder="媒介、真实感、色彩和整体美术方向"
|
||||
@@ -84,12 +84,12 @@ function save() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<DetailDisclosure title="分类风格与硬约束"
|
||||
</NFormItem>
|
||||
<DetailDisclosure ref="constraintsDetails" display-directive="show" title="分类风格与硬约束"
|
||||
><div class="grid gap-5 lg:grid-cols-3">
|
||||
<label
|
||||
><span class="field-label">人物风格与选角背景</span
|
||||
<NFormItem path="characterPrompt" label="人物风格与选角背景"
|
||||
><NInput
|
||||
:input-props="{ 'aria-label': '人物风格与选角背景' }"
|
||||
:disabled="disabled"
|
||||
placeholder="人物质感、妆造与默认选角背景;留空时 AI 默认采用中国人物基线"
|
||||
class="min-h-32"
|
||||
@@ -97,30 +97,33 @@ function save() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<label
|
||||
><span class="field-label">场景风格补充</span
|
||||
</NFormItem>
|
||||
<NFormItem path="scenePrompt" label="场景风格补充"
|
||||
><NInput
|
||||
:input-props="{ 'aria-label': '场景风格补充' }"
|
||||
:disabled="disabled"
|
||||
class="min-h-32"
|
||||
v-model:value="form.scenePrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
<label
|
||||
><span class="field-label">道具风格补充</span
|
||||
</NFormItem>
|
||||
<NFormItem path="propPrompt" label="道具风格补充"
|
||||
><NInput
|
||||
:input-props="{ 'aria-label': '道具风格补充' }"
|
||||
:disabled="disabled"
|
||||
class="min-h-32"
|
||||
v-model:value="form.propPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</NFormItem>
|
||||
</div>
|
||||
<label class="mt-4 block"
|
||||
><span class="field-label">硬约束 · JSON 字符串数组</span
|
||||
<NFormItem
|
||||
class="mt-4 block"
|
||||
path="constraints"
|
||||
label="硬约束 · JSON 字符串数组"
|
||||
:label-props="{ for: 'style-constraints' }"
|
||||
><NInput
|
||||
:disabled="disabled"
|
||||
spellcheck="false"
|
||||
@@ -130,9 +133,8 @@ function save() {
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||
></NInput>
|
||||
</label>
|
||||
</NFormItem>
|
||||
</DetailDisclosure>
|
||||
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
|
||||
<NCheckbox
|
||||
:disabled="disabled"
|
||||
id="style-lock"
|
||||
@@ -144,10 +146,8 @@ function save() {
|
||||
锁定后仍可人工编辑并保存。取消勾选并保存后,才能再次使用 AI 重生成。
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<NButton :disabled="disabled || !!constraintsError" type="primary" attr-type="submit">
|
||||
保存视觉风格
|
||||
</NButton>
|
||||
<NButton :disabled="disabled" type="primary" attr-type="submit"> 保存视觉风格 </NButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</AppForm>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import AppForm from '../../../components/ui/AppForm.vue'
|
||||
import { NFormItem } from 'naive-ui'
|
||||
import { fieldRule, imageUrlRule } from '../../../lib/form-rules'
|
||||
import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { AssetImage } from '../../../components/ui'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types'
|
||||
@@ -15,11 +18,10 @@ const emit = defineEmits<{
|
||||
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
|
||||
const confirmingId = ref('')
|
||||
const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
|
||||
const valid = computed(() => !!referenceImageUrl(form.imageUrl) && Number.isSafeInteger(form.sortOrder))
|
||||
|
||||
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
|
||||
function add() {
|
||||
if (props.disabled || !valid.value) return
|
||||
if (props.disabled) return
|
||||
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' })
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ function remove(image: VisualStyleImage) {
|
||||
confirmingId.value = ''
|
||||
emit('remove', image)
|
||||
}
|
||||
const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeInteger, '排序须为整数') }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,19 +43,18 @@ function remove(image: VisualStyleImage) {
|
||||
登记已有 HTTP(S) 或 /storage/
|
||||
图片地址。后端尚无文件上传接口;这些图片目前仅管理记录,现有身份/形态生图不会自动将风格图片传给模型。
|
||||
</p>
|
||||
<form class="mt-4" @submit.prevent="add">
|
||||
<fieldset :disabled="disabled" class="flex flex-wrap items-end gap-3">
|
||||
<label class="min-w-48 flex-1"
|
||||
><span class="field-label">图片地址</span
|
||||
<AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add">
|
||||
<fieldset :disabled="disabled" class="form-controls flex flex-wrap gap-3">
|
||||
<NFormItem class="min-w-48 flex-1" path="imageUrl" label="图片地址"
|
||||
><NInput
|
||||
placeholder="https://… 或 /storage/…"
|
||||
:input-props="{ 'aria-label': '风格图片地址' }"
|
||||
v-model:value="form.imageUrl"
|
||||
></NInput
|
||||
></label>
|
||||
<label
|
||||
><span class="field-label">分类</span
|
||||
></NFormItem>
|
||||
<NFormItem path="category" label="分类" :label-props="{ for: 'style-image-category' }"
|
||||
><NSelect
|
||||
id="style-image-category"
|
||||
v-model:value="form.category"
|
||||
:options="[
|
||||
...Object.entries(categories).map(([value, label]) => ({
|
||||
@@ -62,23 +64,19 @@ function remove(image: VisualStyleImage) {
|
||||
]"
|
||||
class="select-control"
|
||||
></NSelect
|
||||
></label>
|
||||
<label class="w-24"
|
||||
><span class="field-label">排序</span
|
||||
></NFormItem>
|
||||
<NFormItem class="w-24" path="sortOrder" label="排序"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '风格图片排序' }"
|
||||
:value="typeof form.sortOrder === 'number' ? form.sortOrder : null"
|
||||
@update:value="form.sortOrder = $event ?? 0"
|
||||
:step="1"
|
||||
></NInputNumber
|
||||
></label>
|
||||
<NCheckbox v-model:checked="form.enabled" class="flex gap-2 pb-2 text-xs">启用</NCheckbox>
|
||||
<NButton :disabled="disabled || !valid" attr-type="submit">登记参考图</NButton>
|
||||
></NFormItem>
|
||||
<NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox>
|
||||
<NButton :disabled="disabled" attr-type="submit">登记参考图</NButton>
|
||||
</fieldset>
|
||||
</form>
|
||||
<p v-if="form.imageUrl && !valid" class="mt-2 text-xs text-danger" role="alert">
|
||||
请填写有效图片地址和整数排序。
|
||||
</p>
|
||||
</AppForm>
|
||||
<div v-if="images.length" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<article v-for="image in images" :key="image.id" class="surface-inset min-w-0 p-3">
|
||||
<AssetImage
|
||||
@@ -94,12 +92,6 @@ function remove(image: VisualStyleImage) {
|
||||
<p v-if="image.provider || image.model" class="mt-2 break-words text-[11px] text-muted">
|
||||
{{ image.provider }} {{ image.model }}
|
||||
</p>
|
||||
<NCollapse v-if="image.prompt" class="mt-2 text-xs"
|
||||
><NCollapseItem name="details"
|
||||
><template #header>实际提示词</template>
|
||||
<p class="mt-2 whitespace-pre-wrap">{{ image.prompt }}</p></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<a
|
||||
v-if="referenceImageUrl(image.imageUrl)"
|
||||
|
||||
@@ -31,7 +31,6 @@ export function styleImageFixture(overrides: Partial<VisualStyleImage> = {}): Vi
|
||||
sortOrder: 0,
|
||||
provider: null,
|
||||
model: null,
|
||||
prompt: null,
|
||||
createdAt: '2026-08-28T00:00:00Z',
|
||||
updatedAt: '2026-08-28T00:00:00Z',
|
||||
...overrides
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface VisualStyleImage {
|
||||
sortOrder: number
|
||||
provider: string | null
|
||||
model: string | null
|
||||
prompt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 字符串数组,例如 ["真人写实", "禁止二次元"]')
|
||||
@@ -16,9 +16,7 @@ export function testProjectContext(id = 'capability-test'): ReturnType<typeof us
|
||||
updatedAt: '2026-09-01T00:00:00Z',
|
||||
episodes: [{ episode: 1, title: '第一集', content: '内容' }],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
world: null
|
||||
}
|
||||
const data = ref({ project, checkpoints: [] as Checkpoint[] })
|
||||
return {
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('精简入口保留功能', () => {
|
||||
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: ['保留硬约束'] }]
|
||||
])
|
||||
|
||||
@@ -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<HTMLButtonElement>('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<HTMLInputElement | HTMLTextAreaElement>(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<typeof fetch>()
|
||||
.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<void>(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()
|
||||
})
|
||||
})
|
||||
@@ -386,9 +386,7 @@ describe('管理后台组件边界', () => {
|
||||
updatedAt: '',
|
||||
episodes: [],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
world: null
|
||||
})
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const paths = [
|
||||
|
||||
@@ -111,8 +111,7 @@ function mountPage(records = [checkpoint()], episodes = 1): ReturnType<typeof us
|
||||
updatedAt: '',
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: [],
|
||||
|
||||
episodes: episodes ? [{ episode: 1, title: '第一集', content: '正文' }] : []
|
||||
}
|
||||
const data = ref({ project, checkpoints: records })
|
||||
@@ -149,10 +148,10 @@ describe('拆解页内容滚动', () => {
|
||||
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<HTMLInputElement>('#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<HTMLButtonElement>('.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;/
|
||||
|
||||
@@ -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<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成首帧').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -33,9 +33,7 @@ function project(id: string, status: ProjectStatus): ProjectDetail {
|
||||
updatedAt: '',
|
||||
episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
world: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('<script>不执行</script>')
|
||||
expect(wrapper.text()).not.toContain('本次实际提示词')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
@@ -388,6 +375,7 @@ describe('身份图与母版契约', () => {
|
||||
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成候选').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
||||
})
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('主体目录母版缩略图', () => {
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
|
||||
imageUrl: '/storage/candidate.png'
|
||||
}),
|
||||
identityImageFixture({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('形态图片选择与操作', () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await expandSections()
|
||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||
expect(document.body.textContent).not.toContain('本次实际提示词')
|
||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
||||
button('设为主参考图').click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -65,9 +65,7 @@ const fixture: ProjectDetail = {
|
||||
updatedAt: '2026-08-27T00:00:00Z',
|
||||
episodes: [{ episode: 1, title: '来信', content: '<script>不要执行模型内容</script>\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<typeof fetch>().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<HTMLInputElement>('#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: '<script>模型内容</script>',
|
||||
negativePrompt: '模糊',
|
||||
status: 'prompt_ready'
|
||||
|
||||
Reference in New Issue
Block a user