feat: 对齐后端能力并补齐形态提示词与生产诊断

This commit is contained in:
GouJ
2026-09-02 18:21:20 +08:00
parent 7d8074f90e
commit f71d2bf0a4
30 changed files with 1707 additions and 66 deletions
+12 -6
View File
@@ -126,13 +126,14 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 接口基线
对齐后端 `qianlanse/short-drama-agent``dev`
`40c700f47d578888105484cfb317f52568855e0e`2026-09-01),包含 Character Casting、身份/形态双参考首帧、首帧与视频生产就绪检查,以及独立候选生成和中国人物默认选角基线。本轮只修改前端,请确保后端依照其开发流程同步 Prisma migration 并重启服务。
`537dba89a18d571a6df24dae17494b09ce635c15`2026-09-02 核对),包含正式形态提示词、三类主体母版继承、数量上限、身份/形态双参考首帧与视频就绪检查。本轮只修改前端,请确保后端依照其开发流程同步 Prisma migration 并重启服务。完整映射、已补能力和后端限制见 [后端功能覆盖说明](docs/backend-coverage.md)。
| HTTP | 路径(以 /api 为前缀) | 用途 |
| --- | --- | --- |
| GET / POST | `/projects` | 项目列表 / 创建剧本 |
| GET | `/projects/:id` | 正式数据库详情、剧集、角色、世界观与审核 |
| GET | `/projects/:id/checkpoints` | 两条 graph 的 checkpoint;前端按 workflowName 过滤 |
| GET | `/projects/:id/metrics``/projects/:id/timeline/grouped` | 全量运行诊断;按需查询,支持工作流/节点筛选与 JSON 导出 |
| POST | `/projects/:id/resume-generation` | 继续生成未完成剧集 |
| POST | `/projects/:id/resume-rewrite` | 恢复审核与改写 |
| GET | `/projects/:id/breakdown-preview?groupSize=3&modules=character,scene,prop` | 真实分组与任务预览 |
@@ -154,7 +155,9 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET | `/subject-forms/:formId/images` | 此形态的全部图片历史及实际 Prompt |
| POST | `/subject-forms/:formId/images` | 单图生成;显式 `provider: seedream`,可选 Prompt 和成对尺寸,`setPrimary` |
| PUT | `/subject-forms/:formId/images/:imageId/primary` | 选择已完成图片作为主参考图,不调用模型 |
| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider``concurrency``force` |
| POST | `/subject-forms/:formId/generation-prompt` | 单个正式提示词生成/重生成;`force` |
| POST | `/projects/:id/subject-forms/generation-prompts` | 全项目提示词补齐/覆盖;`concurrency``force` |
| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider``concurrency``force`、可选正整数 `limit` |
| GET / PUT | `/projects/:id/visual-style` | 查询/人工保存视觉风格,包含分类 Prompt、硬约束和锁定 |
| POST | `/projects/:id/visual-style/generate` | AI 生成风格;已有风格重生成显式 `force: true`,锁定时禁止 |
| POST | `/projects/:id/visual-style/images` | 登记已有风格图片地址,不上传文件 |
@@ -179,6 +182,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| POST | `/projects/:id/videos/retry-failed` | 重试最近失败的视频任务 |
| GET / POST | `/storyboard-shots/:shotId/videos` | 单镜头视频历史与任务创建 |
| PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 |
| POST | `/projects/:id/production/start` | 高级串联入口;重复预检、费用确认,仅传两种 Provider,不生成首帧、不等待成片 |
API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
@@ -204,19 +208,21 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。
8. 每 6 秒查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。
9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。
10. Video Prompt、Keyframe 与 Video 都有项目级 readiness。页面只向 `ready` 镜头批量提交;`force` 会重写 Prompt 或新增首帧/视频候选,但不会静默替换主首帧和主视频。视频任务创建成功不代表成片完成,最终状态以后端轮询和持久化资产为准。
10. Video Prompt、Keyframe 与 Video 都有项目级 readiness。`force` 会重写 Prompt,普通首帧/视频新增候选;**过期首帧即使 force 也会更新主图**,非 force 的过期视频完成后会接替旧主视频。确认文案明确此差异。首帧支持可选 `limit` 和成对 `width``height`,只影响首帧,不污染提示词和视频参数。视频任务创建成功不代表成片完成,最终状态以后端轮询和持久化资产为准。
### 形态图片约定
1. 入口为 `/projects/:projectId/subject-images`;项目导航、拆解主体列表和分镜参考图缺失提示均可进入。图库自动读取后端已生成图片,不需要重新生图才能显示。
2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。新增查询从项目关联读取正式形态,返回轻量图片元数据,不包含图片的 `rawJson` 或长 Prompt;打开历史弹窗后再读取详细记录
3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。默认使用后端的 generationPrompt,其次 appearancePrompt;可为本次输入自定义 Prompt,详情显示后端最终实际使用的文本
2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。项目列表包含 Identity Anchor 及图片的 `rawJson` 追溯字段,用于识别母版过期;完整实际 Prompt历史弹窗读取
3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。自定义 Prompt 优先,其次正式 `generationPrompt`;缺少时后端先调用文本模型生成并保存。`appearancePrompt` 仅为原始素材,不能直接充当最终 Prompt。可在形态卡片“提示词与生成”先生成、检查与导出正式提示词;后端尚无手工编辑保存接口
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 Seedream Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。
7. 图库每 6 秒读取一次真实记录,弹窗只在打开时查询该形态图片。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。
### 视觉风格、身份与形态的衔接
@@ -237,7 +243,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
5. 身份图片的 `isAnchor` 来自专用 GET。Character primary 通过 Casting Candidate 接口生成且默认停用;点击确认演员后才成为 Anchor 并锁定 Identity。辅助视图不能直接成为母版。Scene / Prop 保留普通母版切换接口。
6. 普通身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。Character Casting Candidate 是独立选角入口,即使已有 Anchor 也不会复用旧母版,避免旧演员身份污染新候选。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。
7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。
8. 修改风格身份文本或母版,不会自动使旧图失效或重生成。已有形态主图仍会被分镜使用;需要保持新身份时,主动生成候选图并设为形态主图,再刷新分镜参考图,按需覆盖视频提示词。图库详情展示当次 `identityAnchorImageId``referenceImageId`;旧记录没有该字段时明确显示未知,不推断它使用了当前母版。
8. 修改风格身份文本不会自动重生成旧图。更换已锁定母版后,形态图库会根据 `identityAnchorImageId` 标记旧主图过期;主动生成候选、验图并切换主图,再更新下游首帧。Keyframe 就绪检查也会比对母版与形态主图引用,不应绕过过期提示继续生产。旧记录无追溯字段时不推断它已匹配当前母版。
9. 风格参考图支持登记已有 HTTP(S)/`/storage/` 地址、分类、排序、启停和删除记录;**没有文件上传接口,也没有风格图 AI 生成接口**。当前身份图/形态图 Provider 调用不会自动消费风格参考图片,页面不暗示登记后已生效。
10. 编辑草稿与轮询分离。保存成功才清空草稿,失败保留;主体切换会提示放弃未保存修改。离开页面不会自动保存。所有新写操作沿用项目级会话互斥,生成前需要费用与后台任务确认,无法替代后端跨浏览器锁。
11. AI 生成 VisualStyle 与 Character Identity 时,若项目、世界观和角色事实都未指定人物背景,默认采用自然真实的中国人物选角基线。项目明确配置和角色事实优先于系统默认值,不限制后续使用其它国家、地区、族裔或肤色人物。
+61
View File
@@ -0,0 +1,61 @@
# 后端功能覆盖说明
核对日期:2026-09-02。后端:`qianlanse/short-drama-agent``dev@537dba89a18d571a6df24dae17494b09ce635c15`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。
## 页面与接口能力
所有路径以 `/api` 为前缀;`id``formId``shotId` 必须取正式数据库 ID。
| 能力 | 前端入口 | 对应后端 | 本轮结果 |
| --- | --- | --- | --- |
| 项目列表、创建与详情 | 我的剧本/剧本创作 | `/projects``/projects/:id` | 保留状态筛选、创建、正文、角色、世界观、审核与导出 |
| 剧本恢复 | 剧本创作操作面板 | `resume-generation``resume-rewrite` | 保留费用确认、项目互斥与最近可用 checkpoint |
| 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview``breakdown/start``retry``resume-shots``resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 |
| 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics``timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 |
| 视觉风格 | 视觉风格 | GETPUTPOST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 |
| 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 |
| 角色选角 | 主体身份角色模块 | `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;人物/场景/道具已锁定母版继承;过期刷新只生成候选 |
| 导演设计与即时状态 | 分镜设计 | directionsvisual-states 查询、单集 generate-test、项目 generate | 保留单集持久化(`persist: true`)、项目批量、覆盖、校验修复次数、逐集回执 |
| 参考图与规格 | 分镜参考图/生产单镜头 | `references``generation-spec``keyframe-spec``video-generation-spec` | 保留按需只读检查与缺失原因,查询不调用生成模型 |
| 视频提示词 | 分镜设计/镜头生产 | 单镜头 `video-prompt`、项目 `video-prompts/readiness``generate` | 保留单镜头/项目生成,重写需确认 |
| 镜头首帧 | 镜头生产 | 项目 `keyframes/readiness``generate`、单镜头 keyframekeyframes、主图 PUT | 新增批量 `limit` 与成对尺寸;保留当前集/过期项/覆盖范围;显示因上限未执行数 |
| 视频任务与主视频 | 镜头生产 | 项目 videos readinessgeneratestatusretry-failed,逐镜 videos,单任务 status,主视频 PUT | 保留任务提交、轮询、播放、失败重试与主视频切换;不把 HTTP 完成当成片完成 |
| 高级串联生产 | 镜头生产批量面板底部(默认折叠) | POST `/projects/:id/production/start` | 新增预检、再次校验、费用确认、分阶段回执与诊断导出;明确不是全链自动制片 |
## 高级串联生产边界
实际后端顺序:形态正式提示词 → 形态图片 → 视频提示词 → 视频任务提交。**没有视觉风格、身份、导演设计、即时状态或首帧生成节点,也不会等待成片。**
- 仅剧本 `completed` 可启动;先检查项目/工作流任务、形态活动图片、当前母版引用、镜头规格、主首帧有效性和活动视频。
- 为避免中途生成主图使既有首帧失效,前端要求先确认全部形态主图并补齐有效主首帧;因此该入口通常跳过形态生图,只串联仍缺少的文本与视频任务。
- 点击预检只有 GET;付费确认后再次 GET 校验。查询失败或条件变更不提交 POST。单次项目锁仅覆盖本浏览器,不能代替服务器事务和跨客户端互斥。
- 参数固定为 `imageProvider: seedream``videoProvider: seedance`。后端内部文字并发 3、图片与视频并发 2;不接受批量面板的覆盖、数量上限或尺寸配置,不应让用户误以为可小批量执行。
- 图工作流 `completed` 仅说明经过 finalize,可能仍有错误;`needsManualReview``stopReason` 和所有 `errors` 原样显示。视频统计用“已提交”,不标“成片已完成”。
- 此工作流没有持久 checkpoint/恢复/取消接口。网络断开不能推断后端停止,禁止自动重发。已提交的长请求回执按原项目隔离;页面刷新后需从资产状态核对。
## 观测指标边界
后端 metrics 将 checkpoint 数当 completedNodeCount,并给非空记录返回 successRate 100;并非模型任务成功率。前端只展示保存记录数、累计节点耗时、最近 checkpoint 重试数;累计耗时不等于墙钟耗时。明确的失败取 checkpoint 中的执行字段,其他记录仅标“记录已保存”。原始统计保留在导出 JSON 中。
时间线分组是节点阶段,不是独立 executionId。未识别的节点被后端归为“其它”;前端用共享 checkpoint 的 workflowName 进一步筛选。两个观测接口独立报错,关闭弹窗不继续发起 API 查询,旧项目迟到响应不会覆盖新项目。
## 不添加无效或重复入口
| 后端路由/能力 | 处理方式与理由 |
| --- | --- |
| `episode-groups` | 正式 `breakdown-preview` 已包含分组与任务,不重复增加一个分组页 |
| `checkpoints/latest``state``breakdown/latest` | 页面已按 workflowName 从共享 checkpoint 取状态及最近可用快照;保留现有 API 方法,避免重复查询 |
| `timeline` | 新诊断从 `timeline/grouped` 展平并按 index 排序,保持同一份数据 |
| 图片/首帧/视频的 GET primary | 从后端完整列表的 primary 标志选取,无需再次发相同内容请求 |
| POST `/projects/:id/resume` | 实际只返回恢复信息,并不启动恢复工作流;不显示虚假的“继续执行”按钮 |
| `projects/stream-test``breakdown/start-test` | 测试专用,不暴露生产入口;Storyboard 的 generate-test 是现有正式单集生成入口,仍传 `persist: true` |
| 手工保存形态 generationPrompt、取消运行任务、删除项目、合并最终视频 | 后端没有对应正式路由,本轮不虚构按钮、成功提示或客户端模拟实现 |
## 回归验证
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
- 已通过 lint、格式、TypeScript、Vitest22 个文件、220 项测试)和生产构建。不触发真实文本/图片/视频模型,不提交真实生产任务。
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
+20
View File
@@ -1440,6 +1440,26 @@ body {
font-size: 12px;
white-space: nowrap;
}
/* 诊断筛选按实际空间换行,长 checkpoint ID 不撑开弹窗。 */
.diagnostics-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.diagnostics-filters > .n-input {
flex: 1 1 240px;
min-width: 0;
}
.diagnostics-filters > .n-select {
flex: 0 1 180px;
min-width: 0;
}
.diagnostics-records {
display: grid;
gap: 12px;
min-width: 0;
}
.select-control {
min-width: 130px;
}
+1 -1
View File
@@ -480,7 +480,7 @@ function exportResult() {
</div>
</NScrollbar>
</main>
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
<HistoryPanel :project-id="project?.id" :checkpoints="checkpoints" workflow="breakdown" />
</div>
</section>
</template>
@@ -236,7 +236,12 @@ function exportScript() {
</div>
</NScrollbar>
</main>
<HistoryPanel v-if="showHistory" :checkpoints="checkpoints" workflow="create-drama" />
<HistoryPanel
v-if="showHistory"
:project-id="project?.id"
:checkpoints="checkpoints"
workflow="create-drama"
/>
</div></div
></WorkspacePage>
</template>
+59 -9
View File
@@ -21,6 +21,7 @@ import { issueLabel, productionStatusLabel } from './model'
import { useProduction } from './useProduction'
import ProductionReceipt from './components/ProductionReceipt.vue'
import ShotProductionAssets from './components/ShotProductionAssets.vue'
import AdvancedProduction from './components/AdvancedProduction.vue'
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
const {
@@ -40,6 +41,10 @@ const {
session,
concurrency,
force,
keyframeLimit,
keyframeWidth,
keyframeHeight,
keyframeValid,
batchValid,
blocked,
run
@@ -117,6 +122,12 @@ watch(
}
}
)
watch(
() => session.value.pipelineReceipt,
receipt => {
if (receipt) toolsOpen.value = true
}
)
</script>
<template>
@@ -155,7 +166,7 @@ watch(
<WorkspaceTools
v-model:open="toolsOpen"
title="项目生产总览与批量操作"
:has-receipt="!!session.receipt"
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
@@ -185,11 +196,11 @@ watch(
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>覆盖模式为已有结果新增候选
>覆盖模式重写提示词新增图视频候选
</NCheckbox>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式新增全项目候选Seedream
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选Seedream
Seedance 均可能产生费用
</p>
</section>
@@ -198,7 +209,7 @@ watch(
><NCollapseItem name="overview" title="项目生产总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
每一步都按后端就绪检查执行覆盖只会新增首帧视频候选不会替换当前主资产上游参考资产变化后下游过期结果会被标记并阻止继续使用需要重新生成后才能恢复就绪
每一步都按后端就绪检查执行覆盖会重写提示词图片和视频通常新增候选但过期首帧即使在覆盖模式也会更新为主图上游资产变化后请先更新过期结果再继续生产
</NAlert>
<NAlert
v-if="videoRunning"
@@ -325,7 +336,12 @@ watch(
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
:disabled="
blocked ||
!batchValid ||
(stage.key === 'keyframes' && !keyframeValid) ||
!(stage.data?.ready ?? 0)
"
acknowledgement
:description="
stage.key === 'videos'
@@ -333,12 +349,46 @@ watch(
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
: stage.key === 'keyframes'
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
: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
><NInputNumber
:value="keyframeLimit === '' ? null : keyframeLimit"
@update:value="keyframeLimit = $event ?? ''"
:min="1"
:precision="0"
:disabled="operation.pending"
placeholder="留空不限制"
/></label>
<div class="grid grid-cols-2 gap-3">
<label
><span class="field-label">宽度像素</span
><NInputNumber
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认" /></label
><label
><span class="field-label">高度像素</span
><NInputNumber
:value="keyframeHeight === '' ? null : keyframeHeight"
@update:value="keyframeHeight = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认"
/></label>
</div>
<p v-if="!keyframeValid" class="text-xs text-danger">
上限须为正整数宽高须同时留空或填写正整数
</p>
</div>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
@@ -386,10 +436,10 @@ watch(
></NCollapse
>
</div>
<AdvancedProduction />
</WorkspaceTools>
</div>
</div></template
>
</div></div
></template>
<NScrollbar v-if="query.error.value" class="workspace-feedback"
><NAlert role="alert" type="error" :show-icon="false"
>{{ query.error.value
+7
View File
@@ -9,6 +9,7 @@ import type {
KeyframeSpec,
ProjectVideoStatus,
PromptReadiness,
ProductionPipelineResult,
RetryVideosResult,
ShotKeyframe,
ShotVideo,
@@ -29,6 +30,12 @@ function shotPath(id: string) {
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
export const productionApi = {
startPipeline: (projectId: string) =>
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
method: 'POST',
body: { imageProvider: 'seedream', videoProvider: 'seedance' },
timeoutMs: 0
}),
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
request<PromptReadiness>(`${projectPath(projectId)}/video-prompts/readiness?force=${force}`, { signal }),
generatePrompts: (projectId: string, input: StoryboardBatchInput) =>
@@ -0,0 +1,274 @@
import { defineComponent } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { projectContextKey } from '../projects/context'
import { testProjectContext } from '../../testing/project-context'
import { getOperation } from '../workflows/operations'
import { formFixture } from '../subject-images/testing/fixtures'
import { directionsResult } from '../storyboard/testing/fixtures'
import { checkPipeline, useAdvancedProduction } from './useAdvancedProduction'
import { useProduction } from './useProduction'
import { getProductionSession } from './model'
import AdvancedProduction from './components/AdvancedProduction.vue'
import { expandSections } from '../../testing/naive'
let wrapper: VueWrapper | undefined
/** 从实际挂载的确认弹窗查找操作按钮。 */
function button(label: string) {
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
for (const id of ['capability-test', 'new-project']) {
Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' })
Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null })
}
})
/** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */
function server() {
const context = testProjectContext()
const form = formFixture('capability-test')
const readiness = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
inProgress: 0,
stalePrimaryKeyframe: 0,
items: [
{
shotId: 'shot-1',
shotNo: 1,
episodeNo: 1,
beatNo: 1,
status: 'ready',
issues: [] as { code: string; reason: string }[],
primaryKeyframeId: 'keyframe-1',
primaryKeyframeStale: false
}
]
}
const receipt = {
projectId: 'capability-test',
completed: true,
needsManualReview: false,
stopReason: '',
errors: ['一个提示词未生成'],
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 }
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
let data: unknown = context.project.value
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
else if (path.endsWith('/subject-forms')) data = [form]
else if (path.includes('/readiness')) data = readiness
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
else if (path.endsWith('/videos/status'))
data = {
total: 1,
completed: 0,
queued: 0,
running: 0,
pending: 0,
failed: 0,
cancelled: 0,
notStarted: 1,
items: []
}
else if (init?.method === 'POST')
data = path.endsWith('/production/start')
? receipt
: { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] }
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return {
context,
form,
readiness,
receipt,
fetcher,
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
}
}
async function advanced() {
const data = server()
let service!: ReturnType<typeof useAdvancedProduction>
wrapper = mount(
defineComponent({
setup() {
service = useAdvancedProduction()
return () => null
}
}),
{ global: { provide: { [projectContextKey as symbol]: data.context } } }
)
await flushPromises()
return { ...data, service }
}
describe('高级串联生产的安全边界', () => {
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => {
const { service, fetcher, posts } = await advanced()
expect(fetcher).not.toHaveBeenCalled()
await service.start()
expect(posts()).toHaveLength(0)
await service.preflight()
expect(service.check.value?.issues).toEqual([])
expect(posts()).toHaveLength(0)
await service.start()
expect(posts()).toHaveLength(1)
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
imageProvider: 'seedream',
videoProvider: 'seedance'
})
expect(
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
).toHaveLength(2)
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
expect(service.check.value).toBeNull()
})
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => {
const { service, readiness, posts } = await advanced()
await service.preflight()
readiness.items[0]!.primaryKeyframeStale = true
await service.start()
expect(posts()).toHaveLength(0)
expect(service.check.value?.issues.join()).toContain('有效主首帧')
expect(getOperation('capability-test').error).toContain('条件变化')
})
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => {
const { context, form, readiness } = server()
context.data.value!.project.status = 'need_review'
form.images = []
readiness.inProgress = 1
const result = await checkPipeline('capability-test')
expect(result.issues.join()).toContain('剧本尚未完成')
expect(result.issues.join()).toContain('所有形态主图')
expect(result.issues.join()).toContain('活动视频任务')
})
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => {
const { readiness } = server()
readiness.items[0]!.status = 'skipped'
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }]
const result = await checkPipeline('capability-test')
expect(result.issues.join()).toContain('视频前置检查未通过')
})
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
const { service, context, posts } = await advanced()
await service.preflight()
getOperation('capability-test').pending = true
await service.start()
expect(posts()).toHaveLength(0)
getOperation('capability-test').pending = false
context.data.value!.project.status = 'failed'
await service.start()
expect(posts()).toHaveLength(0)
context.data.value!.project.id = 'new-project'
await flushPromises()
expect(service.check.value).toBeNull()
})
it('已提交的长请求只写回原项目的回执,不污染新项目', async () => {
const { service, context, fetcher, receipt } = await advanced()
await service.preflight()
const original = fetcher.getMockImplementation()!
let finish!: (value: Response) => void
fetcher.mockImplementation((url, init) =>
String(url).endsWith('/production/start')
? new Promise(resolve => {
finish = resolve
})
: original(url, init)
)
const pending = service.start()
await flushPromises()
context.data.value!.project.id = 'new-project'
await flushPromises()
finish(new Response(JSON.stringify({ data: receipt })))
await pending
expect(service.session.value.pipelineReceipt).toBeNull()
expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test')
})
it('回执项目不匹配时不发布成功回执', async () => {
const { service, receipt } = await advanced()
await service.preflight()
receipt.projectId = 'wrong'
await service.start()
expect(service.session.value.pipelineReceipt).toBeNull()
expect(getOperation('capability-test').error).toContain('回执项目不匹配')
})
it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => {
const { context, receipt, posts } = server()
wrapper = mount(AdvancedProduction, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context } }
})
await flushPromises()
await expandSections()
expect(button('启动串联生产').disabled).toBe(true)
button('检查串联生产条件').click()
await flushPromises()
button('启动串联生产').click()
await flushPromises()
expect(button('确认启动串联生产').disabled).toBe(true)
expect(posts()).toHaveLength(0)
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
await flushPromises()
button('确认启动串联生产').click()
await flushPromises()
expect(posts()).toHaveLength(1)
expect(document.body.textContent).toContain('流程返回不等于成片完成')
expect(document.body.textContent).toContain(receipt.errors[0])
expect(document.body.textContent).toContain('已提交 1')
})
})
describe('首帧批量参数', () => {
it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => {
const { context, posts } = server()
let service!: ReturnType<typeof useProduction>
wrapper = mount(
defineComponent({
setup() {
service = useProduction()
return () => null
}
}),
{ global: { provide: { [projectContextKey as symbol]: context } } }
)
await flushPromises()
service.keyframeLimit.value = 2
service.keyframeWidth.value = 2048
await service.run('keyframes')
expect(posts()).toHaveLength(0)
service.keyframeHeight.value = 2048
await service.run('keyframes')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
provider: 'seedream',
concurrency: 2,
force: false,
episodeNo: 1,
limit: 2,
width: 2048,
height: 2048
})
service.keyframeLimit.value = -1
await service.run('keyframes')
expect(posts()).toHaveLength(1)
await service.run('prompts')
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false })
service.keyframeLimit.value = ''
service.keyframeWidth.value = ''
service.keyframeHeight.value = ''
service.force.value = true
await flushPromises()
await service.run('keyframes')
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ provider: 'seedream', concurrency: 2, force: true })
})
})
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import ConfirmAction from '../../workflows/ConfirmAction.vue'
import { downloadText, formatDate } from '../../../lib/format'
import { useAdvancedProduction } from '../useAdvancedProduction'
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
const receipt = computed(() => session.value.pipelineReceipt)
const expanded = ref<string[]>([])
watch(
receipt,
value => {
if (value) expanded.value = ['advanced']
},
{ immediate: true }
)
const rows = computed(() => [
{ name: '形态正式提示词', result: receipt.value?.visualPromptResult },
{ name: '形态参考图', result: receipt.value?.subjectImageResult },
{ name: '视频提示词', result: receipt.value?.videoPromptResult }
])
/** 导出实际后端回执,不把流程完成转换为视频完成。 */
function exportReceipt() {
if (receipt.value)
downloadText(`project-${id.value}-pipeline.json`, JSON.stringify(receipt.value, null, 2), 'application/json')
}
</script>
<template>
<NCollapse v-model:expanded-names="expanded" class="mt-5"
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
<NAlert type="warning" :show-icon="false"
>依次补齐形态提示词形态参考图视频提示词并提交视频任务不会生成视觉风格身份母版导演设计即时状态或首帧请先在各阶段完成验收</NAlert
>
<p class="mt-3 text-xs leading-6 text-muted">
范围固定为全项目跳过已有结果不使用上方并发覆盖数量上限和尺寸配置后端固定文字并发
3图片和视频并发 2可能产生模型费用此流程没有持久运行锁或恢复
checkpoint请确认其他页面终端没有任务断网后先查资产勿直接重试
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<NButton :disabled="blocked || checking" :loading="checking" @click="preflight"
>检查串联生产条件</NButton
>
<ConfirmAction
label="启动串联生产"
:disabled="blocked || checking || !check || !!check.issues.length"
acknowledgement
:description="`项目 ${id}:全项目串联生产,不生成首帧,也不等待视频完成。确认后会重新检查前置条件;可能产生文字、图片和视频模型费用。`"
@confirm="start"
/>
</div>
<NAlert v-if="error" type="error" class="mt-3" :show-icon="false">{{ error }}</NAlert>
<NAlert
v-if="operation.label === '高级串联生产' && operation.error"
type="error"
class="mt-3"
:show-icon="false"
>{{ operation.error }}</NAlert
>
<div v-if="check" class="surface-inset mt-3 p-4">
<p class="text-xs text-muted">{{ formatDate(check.checkedAt) }} · 检查 {{ check.shotCount }} </p>
<ul v-if="check.issues.length" class="mt-2 space-y-2 text-xs text-danger">
<li v-for="issue in check.issues" :key="issue">{{ issue }}</li>
</ul>
<p v-else class="mt-2 text-sm">前置检查通过提交前仍会再次校验</p>
</div>
<p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm">
请求进行中关闭面板不会取消后端任务请勿重复提交
</p>
<section v-if="receipt" aria-label="串联生产回执" class="surface-inset mt-4 p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">
串联生产回执 ·
{{
receipt.needsManualReview ? '需要人工处理' : receipt.completed ? '流程已返回' : '流程未完成'
}}
</h3>
<NButton text size="small" @click="exportReceipt">导出回执</NButton>
</div>
<p class="mt-3 text-xs text-muted">
流程返回不等于成片完成已保存结果不会回滚最终结果以各资产页为准
</p>
<p v-for="row in rows" :key="row.name" class="mt-2 text-xs">
{{ row.name }}<template v-if="row.result"
> {{ row.result.total }} · 生成 {{ row.result.generated }} · 跳过 {{ row.result.skipped }} ·
失败 {{ row.result.failed }}</template
><template v-else>未执行或未返回</template>
</p>
<p class="mt-2 text-xs">
视频任务<template v-if="receipt.videoGenerationResult"
> {{ receipt.videoGenerationResult.total }} · 已提交
{{ receipt.videoGenerationResult.created }} · 跳过 {{ receipt.videoGenerationResult.skipped }} ·
提交失败 {{ receipt.videoGenerationResult.failed }}</template
><template v-else>未执行或未返回</template>
</p>
<NAlert
v-if="receipt.stopReason || receipt.errors.length"
type="warning"
:show-icon="false"
class="mt-3"
><p>{{ receipt.stopReason }}</p>
<p v-for="(item, index) in receipt.errors" :key="index" class="mt-2">{{ item }}</p></NAlert
>
</section>
</NCollapseItem></NCollapse
>
</template>
@@ -28,6 +28,9 @@ function exportReceipt() {
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 阻塞 {{ receipt.result.blocked }} ·
失败
{{ receipt.result.failed }}
<template v-if="receipt.result.selected !== undefined">
· 因本批上限未执行 {{ Math.max(0, receipt.result.selected - receipt.result.targetCount) }}</template
>
</p>
<p v-else-if="receipt.kind === 'videos'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已创建任务
+26
View File
@@ -196,6 +196,8 @@ export interface GenerateKeyframeInput {
/** 首帧批量生成回执;详细阻塞原因应配合 readiness 查看。 */
export interface KeyframeBatchResult {
total: number
/** 应用剧集范围后、数量上限前的目标数;老版本可能不返回。 */
selected?: number
targetCount: number
generated: number
skipped: number
@@ -265,4 +267,28 @@ export type ProductionReceipt =
/** 项目生产页的浏览器会话数据。 */
export interface ProductionSession {
receipt: ProductionReceipt | null
pipelineReceipt?: ProductionPipelineResult | null
}
/** 串联生产只汇总节点结果;completed 不代表视频已完成。 */
export interface ProductionPipelineResult {
projectId: string
imageProvider: string
videoProvider: string
visualPromptResult?: PipelineBatchResult
subjectImageResult?: PipelineBatchResult
videoPromptResult?: PipelineBatchResult
videoGenerationResult?: Omit<PipelineBatchResult, 'generated'> & { created: number }
completed: boolean
needsManualReview: boolean
stopReason: string
errors: string[]
}
/** 后端串联节点只有汇总数量,逐项失败信息保留在 errors。 */
export interface PipelineBatchResult {
total: number
generated: number
skipped: number
failed: number
}
@@ -0,0 +1,155 @@
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 { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors'
import { errorMessage } from '../../lib/http'
import { productionApi } from './api'
import { getProductionSession } from './model'
/** 高级串联入口只能消费已验图的主资产,不替用户跳过选角、设计和首帧环节。 */
export async function checkPipeline(projectId: string) {
const [project, checkpoints, forms, keyframes, prompts, videos] = await Promise.all([
projectsApi.detail(projectId),
projectsApi.checkpoints(projectId),
subjectImagesApi.listForms(projectId),
productionApi.keyframeReadiness(projectId, true),
productionApi.promptReadiness(projectId, true),
productionApi.videoReadiness(projectId, false)
])
if (project.id !== projectId || forms.some(form => form.subject.projectId !== projectId))
throw new Error('预检返回了其他项目的数据,请重新读取。')
const issues: string[] = []
const shotIds = new Set(keyframes.items.map(item => item.shotId))
if (
shotIds.size !== keyframes.total ||
[prompts, videos].some(
data =>
new Set(data.items.map(item => item.shotId)).size !== shotIds.size ||
data.items.some(item => !shotIds.has(item.shotId))
)
)
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 (!forms.length) issues.push('尚无正式形态,请先完成拆解。')
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
if (forms.some(form => !primaryImage(form.images)?.imageUrl || isPrimaryIdentityStale(form)))
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
if (
!keyframes.total ||
keyframes.items.length !== keyframes.total ||
keyframes.items.some(
item =>
!item.primaryKeyframeId || item.primaryKeyframeStale || item.issues.length || item.status === 'blocked'
)
)
issues.push('请先为全部镜头完成设计、身份检查和有效主首帧;串联流程不会生成首帧。')
if (
prompts.total !== keyframes.total ||
prompts.items.length !== prompts.total ||
prompts.items.some(item => item.issues.length || item.status === 'blocked')
)
issues.push('视频提示词的生成规格或参考图不完整,请到分镜页修复。')
if (
videos.total !== keyframes.total ||
videos.items.length !== videos.total ||
videos.items.some(item => item.issues.some(issue => issue.code !== 'missing_prompt'))
)
issues.push('视频前置检查未通过,请检查参考图和主首帧。')
if (videos.inProgress || videos.items.some(item => item.status === 'in_progress' || item.activeVideoId))
issues.push('存在活动视频任务,请等待结束后再操作。')
return { projectId, issues, shotCount: keyframes.total, checkedAt: new Date().toISOString() }
}
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
export function useAdvancedProduction() {
const context = useProjectContext()
const id = computed(() => context.project.value?.id ?? '')
const operation = computed(() => getOperation(id.value))
const session = computed(() => getProductionSession(id.value))
const checking = ref(false)
const error = ref('')
const check = ref<Awaited<ReturnType<typeof checkPipeline>> | null>(null)
const blocked = computed(
() =>
!id.value ||
operation.value.pending ||
!!context.error.value ||
context.project.value?.status !== 'completed'
)
let version = 0
let disposed = false
onScopeDispose(() => {
disposed = true
version++
})
watch(id, () => {
version++
check.value = null
error.value = ''
checking.value = false
})
async function preflight() {
if (blocked.value || checking.value) return
const current = ++version
const projectId = id.value
checking.value = true
check.value = null
error.value = ''
try {
const result = await checkPipeline(projectId)
if (current === version && id.value === projectId) check.value = result
} catch (cause) {
if (current === version) error.value = errorMessage(cause)
} finally {
if (current === version) checking.value = false
}
}
async function start() {
if (
blocked.value ||
checking.value ||
!check.value ||
check.value.projectId !== id.value ||
check.value.issues.length
)
return
const projectId = id.value
const target = getProductionSession(projectId)
let submitted = false
await runOperation(projectId, '高级串联生产', async () => {
const result = await checkPipeline(projectId)
if (
disposed ||
id.value !== projectId ||
context.project.value?.status !== 'completed' ||
context.error.value
)
return
check.value = result
if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。')
target.pipelineReceipt = null
submitted = true
const receipt = await productionApi.startPipeline(projectId)
if (!receipt || receipt.projectId !== projectId)
throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。')
target.pipelineReceipt = receipt
})
if (!disposed && id.value === projectId) {
if (submitted) check.value = null
await context.refresh()
}
}
return { id, operation, session, checking, error, check, blocked, preflight, start }
}
+19 -3
View File
@@ -6,7 +6,7 @@ import { storyboardApi } from '../storyboard/api'
import { breakdownSnapshot } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import { productionApi } from './api'
import { getProductionSession } from './model'
import { getProductionSession, validOptionalSize } from './model'
/** 项目级生产动作;每次请求都由统一项目锁防止重复提交。 */
export type ProductionCommand = 'prompts' | 'keyframes' | 'videos' | 'retry-videos'
@@ -19,6 +19,9 @@ export function useProduction() {
const selectedShot = ref('')
const concurrency = ref(2)
const force = ref(false)
const keyframeLimit = ref<number | ''>('')
const keyframeWidth = ref<number | ''>('')
const keyframeHeight = ref<number | ''>('')
const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value))
const sourceEpisodes = computed(
() => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? []
@@ -67,6 +70,11 @@ export function useProduction() {
const operation = computed(() => getOperation(id.value))
const session = computed(() => getProductionSession(id.value))
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
const keyframeValid = computed(
() =>
(keyframeLimit.value === '' || (Number.isSafeInteger(keyframeLimit.value) && keyframeLimit.value > 0)) &&
validOptionalSize(keyframeWidth.value, keyframeHeight.value)
)
const blocked = computed(
() =>
operation.value.pending ||
@@ -78,7 +86,7 @@ export function useProduction() {
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
async function run(command: ProductionCommand) {
if (blocked.value || !batchValid.value) return
if (blocked.value || !batchValid.value || (command === 'keyframes' && !keyframeValid.value)) return
const projectId = id.value
const input = { concurrency: concurrency.value, force: force.value }
const target = getProductionSession(projectId)
@@ -114,7 +122,11 @@ export function useProduction() {
provider: 'seedream',
...input,
// 有 stale 时由后端优先处理全部 stale;否则只补当前剧集,避免误跑整个项目。
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value })
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }),
...(keyframeLimit.value === '' ? {} : { limit: keyframeLimit.value }),
...(keyframeWidth.value === '' || keyframeHeight.value === ''
? {}
: { width: keyframeWidth.value, height: keyframeHeight.value })
})
}
} else if (command === 'videos') {
@@ -151,6 +163,10 @@ export function useProduction() {
session,
concurrency,
force,
keyframeLimit,
keyframeWidth,
keyframeHeight,
keyframeValid,
batchValid,
blocked,
run
@@ -153,7 +153,7 @@ function chooseAnchor() {
{{
character
? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。'
: '确认切换身份母版?旧 primary 母版将停用并保留。后续场景形态生图会引用新母版。此操作不调用模型。'
: '确认切换身份母版?旧 primary 母版将停用并保留。身份锁定后,后续场景/道具形态生图会引用新母版。此操作不调用模型。'
}}
</p>
<div class="mt-3 flex gap-3">
@@ -13,6 +13,8 @@ import { coverImage, hasRunningImages, isPrimaryIdentityStale, primaryImage } fr
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
import FormPromptDialog from './components/FormPromptDialog.vue'
import FormPromptBatch from './components/FormPromptBatch.vue'
/** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */
const route = useRoute()
@@ -25,6 +27,12 @@ const {
session,
concurrency,
force,
limit,
promptConcurrency,
promptForce,
concurrencyValid,
generatePrompt,
generatePrompts,
blocked,
running,
batchValid,
@@ -40,6 +48,12 @@ watch(
if (receipt) toolsOpen.value = true
}
)
watch(
() => session.value.promptReceipt,
receipt => {
if (receipt) toolsOpen.value = true
}
)
const search = ref('')
const module = ref('all')
const onlyMissing = ref(false)
@@ -48,6 +62,9 @@ const generateOpen = ref(false)
const galleryOpen = ref(false)
const generateId = ref('')
const galleryId = ref('')
const promptOpen = ref(false)
const promptId = ref('')
const promptForm = computed(() => forms.value.find(form => form.id === promptId.value) ?? null)
const generateForm = computed(() => forms.value.find(form => form.id === generateId.value) ?? null)
const galleryForm = computed(() => forms.value.find(form => form.id === galleryId.value) ?? null)
const completeCount = computed(() => forms.value.filter(form => primaryImage(form.images)).length)
@@ -70,6 +87,12 @@ watch(
{ immediate: true }
)
/** 打开指定正式形态的提示词管理。 */
function openPrompt(form: SubjectFormAsset) {
promptId.value = form.id
promptOpen.value = true
}
/** 打开指定正式形态的生图表单。 */
function openGenerate(form: SubjectFormAsset) {
generateId.value = form.id
@@ -111,7 +134,7 @@ function resetFilters() {
><WorkspaceTools
v-model:open="toolsOpen"
title="形态生图配置与回执"
:has-receipt="!!session.receipt"
:has-receipt="!!session.receipt || !!session.promptReceipt"
><div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">形态图片</h2>
@@ -124,18 +147,27 @@ function resetFilters() {
/></RouterLink>
</div>
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
Character 形态图会记录生成时使用的 Identity
Anchor更换演员母版旧主图会标记为身份母版已变更且后端会阻止它继续进入分镜首帧请基于当前母版重新生成候选并确认新的主参考图
人物场景道具形态图均可继承已锁定 Identity
母版并记录本次引用来源母版变化后可筛选过期图重新生成候选并人工确认新的主参考图不要直接沿用旧资产进入下游
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
>管理主体身份 </RouterLink
></NAlert
>
<FormPromptBatch
:forms="forms"
:disabled="blocked"
:pending="operation.pending"
:receipt="session.promptReceipt"
v-model:concurrency="promptConcurrency"
v-model:force="promptForce"
@generate="generatePrompts"
/>
<div class="panel mt-5 p-5">
<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 }} 人物形态主图已过期
{{ staleForms.length }} 个形态主图已过期
</span>
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
</p>
@@ -147,14 +179,14 @@ function resetFilters() {
<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 }} Character Form
仍在使用旧 Identity Anchor
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 形态 仍在使用旧
Identity Anchor
</span>
<ConfirmAction
label="重新生成过期形态"
:disabled="blocked || !batchValid || !staleForms.length"
:disabled="blocked || !concurrencyValid || !staleForms.length"
acknowledgement
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
description="只为身份母版已经变化的形态调用 Seedream,各新增一张基于当前已锁定母版的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
@confirm="generateStale"
/></div
></NAlert>
@@ -173,6 +205,17 @@ function resetFilters() {
: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"
@@ -191,9 +234,11 @@ function resetFilters() {
@confirm="generateProject"
/>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数</p>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">
并发和填写的数量上限必须是正整数
</p>
<p class="mt-3 text-xs leading-6 text-muted">
作用于整个项目不受下方筛选影响批量使用后端默认尺寸与形态提示词不会启动视频生成
作用于整个项目不受下方筛选影响上限只限制本次真正生图数量其余显示为待后续处理不计入跳过默认使用后端尺寸与正式提示词不会启动视频生成
</p></NCollapseItem
></NCollapse
>
@@ -210,6 +255,9 @@ function resetFilters() {
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
跳过 {{ session.receipt.result.skipped }} · 失败
{{ session.receipt.result.failed }}
<span v-if="session.receipt.result.remaining !== undefined">
· 待后续处理 {{ session.receipt.result.remaining }}</span
>
</p>
<NAlert
v-if="session.receipt.result.failed"
@@ -221,7 +269,7 @@ function resetFilters() {
部分形态生图失败已生成的图片保留先查看失败原因再按形态重新生图
</NAlert>
<NAlert
v-else-if="session.receipt.title === '刷新过期人物形态图'"
v-else-if="session.receipt.title === '刷新过期形态图'"
type="info"
:show-icon="false"
class="mt-3 text-xs"
@@ -322,13 +370,14 @@ function resetFilters() {
:show-icon="false"
class="mt-3 text-xs leading-6"
>
当前主图由旧演员母版生成后续分镜已禁止继续引用请基于当前 Identity Anchor
当前主图未能匹配最新已锁定母版请基于当前 Identity Anchor
重新生成候选图并在确认后设置为新的主参考图
</NAlert>
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
{{ form.images[0].error }}
</p>
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<NButton text size="small" @click="openPrompt(form)">提示词与生成</NButton>
<NButton @click="openGallery(form)" text size="small">查看图片与记录</NButton>
<NButton :disabled="blocked" @click="openGenerate(form)"
><ImagePlus :size="14" />
@@ -357,7 +406,11 @@ function resetFilters() {
<RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary"
>前往剧本拆解</RouterLink
> </EmptyState
><GenerateImageDialog
><FormPromptDialog
v-model:open="promptOpen"
:form="promptForm"
:disabled="blocked || !promptForm"
@generate="generatePrompt" /><GenerateImageDialog
v-model:open="generateOpen"
:form="generateForm"
:disabled="blocked || !generateForm"
+16
View File
@@ -2,6 +2,8 @@ import { request } from '../../lib/http'
import type {
GenerateFormImageInput,
GenerateProjectImagesInput,
GenerateFormPromptsInput,
FormPromptResult,
ImageBatchResult,
SubjectFormAsset,
SubjectImage
@@ -14,6 +16,20 @@ function formPath(id: string) {
/** 形态图库 API:读取与生成分离,生成不自动重试,不设置客户端短超时。 */
export const subjectImagesApi = {
/** 生成并保存正式生图提示词;force 仅覆盖提示词,不生成或替换图片。 */
generatePrompt: (formId: string, force: boolean) =>
request<FormPromptResult | null>(`/subject-forms/${encodeURIComponent(formId)}/generation-prompt`, {
method: 'POST',
body: { force },
timeoutMs: 0
}),
/** 批量增强全项目形态,不受当前列表筛选影响。 */
generatePrompts: (projectId: string, input: GenerateFormPromptsInput) =>
request<ImageBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-forms/generation-prompts`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
listForms: (projectId: string, signal?: AbortSignal) =>
request<SubjectFormAsset[]>(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }),
listImages: (formId: string, signal?: AbortSignal) => request<SubjectImage[]>(formPath(formId), { signal }),
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { downloadText } from '../../../lib/format'
import ConfirmAction from '../../workflows/ConfirmAction.vue'
import type { ImageSession, SubjectFormAsset } from '../types'
/** 提示词批处理独立于图片批处理,避免覆盖开关和结果回执混用。 */
const props = defineProps<{
forms: SubjectFormAsset[]
disabled: boolean
pending: boolean
receipt?: ImageSession['promptReceipt']
}>()
const concurrency = defineModel<number>('concurrency', { required: true })
const force = defineModel<boolean>('force', { required: true })
const emit = defineEmits<{ generate: [] }>()
const complete = computed(() => props.forms.filter(form => form.generationPrompt?.trim()).length)
const valid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
/** 保存真实后端回执,包含失败的正式形态 ID。 */
function exportReceipt() {
downloadText('form-prompts-receipt.json', JSON.stringify(props.receipt, null, 2), 'application/json')
}
</script>
<template>
<section class="panel mt-5 p-5" aria-label="形态提示词批量管理">
<h3 class="font-semibold">形态生图提示词</h3>
<p class="mt-2 text-xs leading-6 text-muted">
正式提示词 {{ 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': '提示词并发' }"
/>
</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>
<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>
<NButton text @click="exportReceipt">导出提示词回执</NButton>
</div>
<p class="mt-3 text-xs">
总数 {{ receipt.result.total }} · 目标 {{ receipt.result.targetCount }} · 生成
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 失败 {{ receipt.result.failed }}
</p>
<NAlert v-if="receipt.result.failed" type="error" :show-icon="false" class="mt-3"
>部分提示词生成失败已保存结果保留可按下方形态 ID 定位并重试</NAlert
>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in receipt.result.failures" :key="failure.subjectFormId">
<code>{{ failure.subjectFormId }}</code
>{{ failure.error }}
</li>
</ul>
</section>
</section>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton } from 'naive-ui'
import { AppDialog } from '../../../components/ui'
import { downloadText } from '../../../lib/format'
import ConfirmAction from '../../workflows/ConfirmAction.vue'
import type { SubjectFormAsset } from '../types'
/** 只管理后端正式提示词;原始外观只读,不提供后端尚不支持的手工保存接口。 */
const props = defineProps<{ form: SubjectFormAsset | null; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [formId: string, force: boolean] }>()
const hasPrompt = computed(() => !!props.form?.generationPrompt?.trim())
/** 固定本次正式 Form ID,重生成只覆盖提示词,不替换历史图片。 */
function generate() {
if (!props.disabled && props.form) emit('generate', props.form.id, hasPrompt.value)
}
/** 导出数据库已保存的提示词,不把原始外观伪装成最终 Prompt。 */
function exportPrompt() {
if (props.form?.generationPrompt) downloadText(`form-${props.form.id}-prompt.txt`, props.form.generationPrompt)
}
</script>
<template>
<AppDialog
v-model:open="open"
title="形态正式提示词"
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
>
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
结合形态描述与项目视觉风格生成并保存
generationPrompt只调用文本模型不生成图片覆盖不会修改已有图片的实际提示词或主图
</NAlert>
<section class="surface-inset mt-5 p-4">
<h3 class="text-sm font-medium">原始外观素材</h3>
<p class="mt-3 whitespace-pre-wrap break-words text-xs leading-6 text-muted">
{{ form?.appearancePrompt || form?.description || '暂无原始外观素材' }}
</p>
</section>
<section class="surface-inset mt-4 p-4">
<h3 class="text-sm font-medium">正式生图提示词</h3>
<p class="mt-3 whitespace-pre-wrap break-words text-sm leading-6">
{{ form?.generationPrompt || '尚未生成。可先在这里生成检查,也可由生图接口自动补齐。' }}
</p>
</section>
<div class="mt-5 flex flex-wrap items-center gap-3">
<ConfirmAction
:key="form?.id"
:label="hasPrompt ? '重新生成正式提示词' : '生成正式提示词'"
:disabled="disabled || !form"
acknowledgement
:description="`为 ${form?.subject.name || ''} · ${form?.name || ''} ${hasPrompt ? '覆盖已有' : '补齐'}正式提示词。会调用文本模型,已有图片保持不变。`"
@confirm="generate"
/>
<NButton :disabled="!hasPrompt" @click="exportPrompt">导出提示词</NButton>
</div>
</AppDialog>
</template>
@@ -15,9 +15,6 @@ const height = ref<number | ''>('')
const setPrimary = ref(false)
const acknowledged = ref(false)
const dimensionsValid = computed(() => validImageSize(width.value, height.value))
const hasPrompt = computed(
() => !!(prompt.value.trim() || (props.form?.generationPrompt ?? props.form?.appearancePrompt ?? '').trim())
)
/** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */
watch(
@@ -34,7 +31,7 @@ watch(
/** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */
function submit() {
if (!props.form || props.disabled || !dimensionsValid.value || !hasPrompt.value || !acknowledged.value) return
if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return
emit('generate', props.form.id, {
provider: 'seedream',
setPrimary: setPrimary.value,
@@ -56,17 +53,13 @@ function submit() {
>使用后端配置的 Seedream 模型可能产生费用每次新增一张图片不删除历史结果</NAlert
>
<p class="text-xs leading-6 text-muted">
{{
form?.subject.module === 'character' || form?.subject.module === 'scene'
? '后端会自动引用此人物/场景当前的身份母版,保持身份或空间结构;没有母版时仍可按文本生成。更换母版不会自动更新已有形态图。'
: '当前道具形态生图暂不自动引用身份母版。'
}}
人物场景道具均会引用已锁定身份的当前母版保持身份空间骨架或物件结构身份未锁定或没有母版时不会继承该图片更换母版不会自动更新已有形态图
身份母版与此处的形态主参考图是两种不同用途的图片
</p>
<label class="block"
><span class="field-label">自定义提示词可选仅用于本次</span
><NInput
placeholder="留空使用已保存的 generationPrompt,其次使用 appearancePrompt"
placeholder="留空使用正式 generationPrompt;缺失时后端先自动生成正式提示词"
:input-props="{ id: 'image-prompt' }"
class="min-h-28"
v-model:value="prompt"
@@ -76,7 +69,9 @@ function submit() {
</label>
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs"
><NCollapseItem name="details"
><template #header>查看后端默认提示词</template>
><template #header>{{
form?.generationPrompt ? '查看正式生图提示词' : '查看原始外观素材(不是最终提示词)'
}}</template>
<p class="mt-3 whitespace-pre-wrap leading-6">
{{ form.generationPrompt ?? form.appearancePrompt }}
</p></NCollapseItem
@@ -107,7 +102,9 @@ function submit() {
</div>
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K自定义尺寸须满足模型限制</p>
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数或同时留空</p>
<p v-if="!hasPrompt" class="text-xs text-danger" role="alert">当前形态没有可用提示词请填写本次提示词</p>
<p v-if="!form?.generationPrompt" class="text-xs leading-6 text-muted">
未填写自定义提示词时后端先调用文本模型补齐正式提示词再生图可能产生两类模型费用原始外观素材不会直接作为最终生图提示词
</p>
<NCheckbox v-model:checked="setPrimary" class="flex gap-2 text-sm">生成成功后设为此形态主参考图</NCheckbox>
<p v-if="setPrimary && form && primaryImage(form.images)" class="text-xs text-muted">
会替换当前主图标记旧图片保留已有视频提示词不会自动更新
@@ -121,7 +118,7 @@ function submit() {
<div class="dialog-footer">
<NButton @click="open = false">取消</NButton
><NButton
:disabled="disabled || !acknowledged || !dimensionsValid || !hasPrompt"
:disabled="disabled || !form || !acknowledged || !dimensionsValid"
type="primary"
attr-type="submit"
>
+2 -3
View File
@@ -66,9 +66,8 @@ describe('形态图片选择与操作', () => {
form.subject.module = module
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
await flushPromises()
expect(document.body.textContent).toContain(
module === 'prop' ? '道具形态生图暂不自动引用' : '后端会自动引用此人物/场景当前的身份母版'
)
expect(document.body.textContent).toContain('母版')
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
})
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
const form = formFixture()
+1 -1
View File
@@ -21,7 +21,7 @@ export function coverImage(form: SubjectFormAsset): SubjectImage | undefined {
/** 当前主体正在使用的 Identity Anchor ID。 */
export function currentIdentityAnchorId(form: SubjectFormAsset): string | undefined {
return form.subject.identity?.images[0]?.id
return form.subject.identity?.isLocked ? form.subject.identity.images[0]?.id : undefined
}
/** 读取形态图生成时实际使用的 Identity Anchor ID。 */
+188
View File
@@ -0,0 +1,188 @@
import { defineComponent } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { projectContextKey } from '../projects/context'
import { testProjectContext } from '../../testing/project-context'
import { getOperation } from '../workflows/operations'
import { formFixture, imageFixture } from './testing/fixtures'
import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from './model'
import { useSubjectImages } from './useSubjectImages'
import FormPromptDialog from './components/FormPromptDialog.vue'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
let wrapper: VueWrapper | undefined
const projectId = 'capability-test'
/** 从实际挂载的确认弹窗查找操作按钮。 */
function find(label: string) {
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
Object.assign(getOperation(projectId), { pending: false, error: '', notice: '', label: '' })
Object.assign(getImageSession(projectId), { receipt: null, promptReceipt: null })
})
async function setup() {
let service!: ReturnType<typeof useSubjectImages>
const context = testProjectContext()
const form = formFixture(projectId)
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
let data: unknown = [form]
if (init?.method === 'POST') {
if (path.endsWith('/generation-prompts'))
data = {
total: 2,
targetCount: 2,
generated: 1,
skipped: 0,
failed: 1,
failures: [{ subjectFormId: 'form-failed', error: '模型拒绝' }]
}
else if (path.endsWith('/generation-prompt')) {
form.generationPrompt = '正式提示词'
data = { id: form.id, subjectId: form.subjectId, generationPrompt: form.generationPrompt }
} else if (path.includes('/subject-forms/')) data = imageFixture()
else
data = {
total: 3,
targetCount: 1,
generated: 1,
skipped: 0,
failed: 0,
failures: [],
eligibleCount: 3,
remaining: 2
}
}
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(
defineComponent({
setup() {
service = useSubjectImages()
return () => null
}
}),
{ global: { provide: { [projectContextKey as symbol]: context } } }
)
await flushPromises()
return {
service,
context,
form,
fetcher,
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
}
}
describe('形态正式提示词与批量配置', () => {
it('单个使用正式形态 ID 和 force,生成提示词不会调用图片接口', async () => {
const { service, posts } = await setup()
await service.generatePrompt('unknown-form', false)
expect(posts()).toHaveLength(0)
await service.generatePrompt('form-db-1', false)
expect(posts()).toHaveLength(1)
expect(posts()[0]?.[0]).toBe('/api/subject-forms/form-db-1/generation-prompt')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ force: false })
expect(service.forms.value[0]?.generationPrompt).toBe('正式提示词')
await service.generatePrompt('form-db-1', true)
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ force: true })
})
it('提示词接口返回空正文时显示失败,不误报保存成功', async () => {
const { service, fetcher } = await setup()
fetcher.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { id: 'form-db-1', subjectId: 'subject-db-1', generationPrompt: '' } }))
)
await service.generatePrompt('form-db-1', false)
expect(getOperation(projectId).error).toContain('未确认正式提示词已保存')
})
it('批量提示词保留部分失败回执,不覆盖图片回执且不传图片上限', async () => {
const { service, posts } = await setup()
service.limit.value = 1
service.promptConcurrency.value = 4
service.promptForce.value = true
await service.generatePrompts()
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/subject-forms/generation-prompts')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 4, force: true })
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
expect(service.session.value.receipt).toBeNull()
})
it('图片数量上限可选,非法上限和并发阻止提交', async () => {
const { service, posts } = await setup()
for (const limit of [0, -1, 1.5]) {
service.limit.value = limit
await service.generateProject()
}
expect(posts()).toHaveLength(0)
service.limit.value = 1
await service.generateProject()
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
provider: 'seedream',
concurrency: 2,
force: false,
limit: 1
})
expect(service.session.value.receipt?.result.remaining).toBe(2)
service.limit.value = ''
await service.generateProject()
expect(JSON.parse(String(posts()[1]?.[1]?.body))).not.toHaveProperty('limit')
service.promptConcurrency.value = 0
await service.generatePrompts()
expect(posts()).toHaveLength(2)
})
it.each(['draft', 'generating', 'need_review', 'failed'] as const)('%s 剧本不能生成提示词和图片', async status => {
const { service, context, posts } = await setup()
context.data.value!.project.status = status
await service.generatePrompt('form-db-1', true)
await service.generatePrompts()
await service.generateProject()
expect(posts()).toHaveLength(0)
})
it.each(['character', 'scene', 'prop'])('%s 已锁定母版才参与过期判断,刷新只新增候选', async module => {
const { service, form, posts } = await setup()
form.subject.module = module
form.subject.identity = { id: 'identity', isLocked: false, images: [{ id: 'anchor-new' }] }
expect(currentIdentityAnchorId(form)).toBeUndefined()
expect(isPrimaryIdentityStale(form)).toBe(false)
form.subject.identity.isLocked = true
expect(isPrimaryIdentityStale(form)).toBe(true)
await service.query.refresh()
await service.generateStale()
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ provider: 'seedream', setPrimary: false })
})
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
const form = formFixture()
wrapper = mount(FormPromptDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
await flushPromises()
find('生成正式提示词').click()
await flushPromises()
expect(find('确认生成正式提示词').disabled).toBe(true)
expect(wrapper.emitted('generate')).toBeUndefined()
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
await flushPromises()
find('确认生成正式提示词').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-1', false]])
})
it('缺少正式和原始提示词仍可确认生图,由后端补齐而非前端编造', async () => {
const form = formFixture()
form.appearancePrompt = null
form.generationPrompt = null
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
await flushPromises()
document.querySelector<HTMLElement>('#confirm-image-cost')!.click()
await flushPromises()
const submit = [...document.querySelectorAll('button')].find(
item => item.textContent?.trim() === '确认生成图片'
)!
expect(submit.disabled).toBe(false)
submit.click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { provider: 'seedream', setPrimary: false }]])
})
})
+16
View File
@@ -63,6 +63,8 @@ export interface GenerateProjectImagesInput {
provider: 'seedream'
concurrency: number
force: boolean
/** 仅限制本次真正生图的数量,留空表示不限制。 */
limit?: number
}
/** HTTP 200 也可能包含部分失败;此响应不返回成功图片列表。 */
@@ -73,9 +75,23 @@ export interface ImageBatchResult {
skipped: number
failed: number
failures: { subjectFormId: string; error: string }[]
/** 新后端返回符合条件及因数量限制尚未执行的形态数。 */
eligibleCount?: number
remaining?: number
}
/** 正式提示词生成只返回形态字段,不包含图库列表的 images 关联。 */
export type FormPromptResult = Pick<SubjectFormAsset, 'id' | 'subjectId' | 'generationPrompt'>
/** 项目提示词增强的批量参数,与图片生成的 force、limit 独立。 */
export interface GenerateFormPromptsInput {
concurrency: number
force: boolean
}
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
export interface ImageSession {
receipt: { title: string; result: ImageBatchResult } | null
/** 提示词回执独立保存,避免覆盖最近一次生图回执。 */
promptReceipt?: { title: string; result: ImageBatchResult } | null
}
+67 -12
View File
@@ -14,6 +14,9 @@ export function useSubjectImages() {
const id = computed(() => context.project.value?.id ?? '')
const concurrency = ref(2)
const force = ref(false)
const limit = ref<number | ''>('')
const promptConcurrency = ref(3)
const promptForce = ref(false)
const query = usePolling(id, async (projectId, signal) => {
if (!projectId) return []
try {
@@ -54,9 +57,48 @@ export function useSubjectImages() {
query.data.value === null ||
running.value ||
breakdownRunning.value ||
context.project.value?.status === 'generating'
context.project.value?.status !== 'completed'
)
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
const concurrencyValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
const batchValid = computed(
() => concurrencyValid.value && (limit.value === '' || (Number.isSafeInteger(limit.value) && limit.value > 0))
)
const promptValid = computed(() => Number.isSafeInteger(promptConcurrency.value) && promptConcurrency.value > 0)
/** 单个提示词保存后重读正式形态,不把详情响应误用作图库列表。 */
async function generatePrompt(formId: string, overwrite: boolean) {
if (blocked.value) return
const form = forms.value.find(item => item.id === formId)
if (!form) return
const projectId = id.value
await runOperation(projectId, `生成 ${form.subject.name} · ${form.name} 正式提示词`, async () => {
const result = await subjectImagesApi.generatePrompt(formId, overwrite)
if (
!result ||
result.id !== formId ||
result.subjectId !== form.subjectId ||
!result.generationPrompt?.trim()
)
throw new Error('接口未确认正式提示词已保存,请先刷新形态记录核对。')
})
await query.refresh()
}
/** 批量只生成提示词,完整保留部分失败诊断;不自动进入生图。 */
async function generatePrompts() {
if (blocked.value || !promptValid.value || !forms.value.length) return
const projectId = id.value
const input = { concurrency: promptConcurrency.value, force: promptForce.value }
const target = getImageSession(projectId)
target.promptReceipt = null
await runOperation(projectId, '批量生成形态正式提示词', async () => {
target.promptReceipt = {
title: input.force ? '重生成全部形态提示词' : '补齐形态提示词',
result: await subjectImagesApi.generatePrompts(projectId, input)
}
})
await query.refresh()
}
/** 捕获正式 Form ID,生成后重新读取图片;刷新失败也不自动重发有费用的请求。 */
async function generate(formId: string, input: GenerateFormImageInput) {
@@ -76,7 +118,12 @@ export function useSubjectImages() {
async function generateProject() {
if (blocked.value || !batchValid.value || !forms.value.length) return
const projectId = id.value
const input = { provider: 'seedream' as const, concurrency: concurrency.value, force: force.value }
const input = {
provider: 'seedream' as const,
concurrency: concurrency.value,
force: force.value,
...(limit.value === '' ? {} : { limit: limit.value })
}
const target = getImageSession(projectId)
target.receipt = null
await runOperation(projectId, '批量生成项目形态图片', async () => {
@@ -89,25 +136,26 @@ export function useSubjectImages() {
}
/**
* 只刷新 Identity Anchor 已变化的 Character Form
* 用户已经明确确认“刷新过期形态”,因此成功的新图直接切换为当前 Primary;旧图仍保留为历史记录
* 只刷新 Identity Anchor 已变化的形态,覆盖人物、场景与道具
* 与确认文案一致:仅生成候选,用户验图后再显式切换主图
*/
async function generateStale() {
if (blocked.value || !batchValid.value || !staleForms.value.length) return
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 () => {
await runOperation(projectId, '刷新过期形态图', async () => {
const results = await runWithConcurrency(targets, concurrency.value, async form => {
try {
const image = await subjectImagesApi.generate(form.id, {
provider: 'seedream',
setPrimary: true
setPrimary: false
})
if (!image || image.subjectFormId !== form.id || image.status !== 'completed' || !image.imageUrl) {
throw new Error(image?.error || '接口未返回已完成的主参考图')
throw new Error(image?.error || '接口未返回已完成的候选图')
}
return { subjectFormId: form.id, success: true as const }
} catch (error) {
@@ -123,12 +171,12 @@ export function useSubjectImages() {
.map(item => ({ subjectFormId: item.subjectFormId, error: item.error }))
receipt.receipt = {
title: '刷新过期人物形态图',
title: '刷新过期形态图',
result: {
total: forms.value.length,
total,
targetCount: targets.length,
generated: results.filter(item => item.success).length,
skipped: forms.value.length - targets.length,
skipped: total - targets.length,
failed: failures.length,
failures
}
@@ -146,6 +194,13 @@ export function useSubjectImages() {
session,
concurrency,
force,
limit,
promptConcurrency,
promptForce,
promptValid,
concurrencyValid,
generatePrompt,
generatePrompts,
blocked,
running,
batchValid,
+14 -3
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NScrollbar, NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
import { computed, ref } from 'vue'
import { NButton, NScrollbar, NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
import { Clock3 } from '@lucide/vue'
import { formatDate, nodeLabel } from '../../lib/format'
import { workflowCheckpoints } from './selectors'
import type { Checkpoint } from './types'
import WorkflowDiagnosticsDialog from './WorkflowDiagnosticsDialog.vue'
/** 时间线只说明 checkpoint 已保存,不把每条记录误标为工作流成功。 */
const props = defineProps<{ checkpoints: Checkpoint[]; workflow: string }>()
const props = defineProps<{ checkpoints: Checkpoint[]; workflow: string; projectId?: string }>()
const diagnosticsOpen = ref(false)
const history = computed(() => workflowCheckpoints(props.checkpoints, props.workflow).slice(-18).toReversed())
</script>
@@ -16,6 +18,9 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
<NScrollbar class="panel-scroll" content-class="history-content">
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
<p class="mb-6 text-xs leading-5 text-muted">最近 18 checkpoint · 自动刷新</p>
<NButton v-if="projectId" text size="small" class="mb-4" @click="diagnosticsOpen = true"
>查看完整运行诊断</NButton
>
<NEmpty v-if="!history.length" size="small" description="工作流尚未保存执行记录。" />
<NTimeline v-else>
<NTimelineItem
@@ -41,5 +46,11 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
记录在节点或批次结束后更新长时间无新记录不一定意味着任务失败
</p>
</NScrollbar>
<WorkflowDiagnosticsDialog
v-if="projectId"
v-model:open="diagnosticsOpen"
:project-id="projectId"
:checkpoints="checkpoints"
/>
</aside>
</template>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NInput, NSelect, NTab, NTabs } from 'naive-ui'
import { Download, RefreshCw, Search } from '@lucide/vue'
import { AppDialog, EmptyState } from '../../components/ui'
import { usePolling } from '../../composables/usePolling'
import { downloadText, formatDate, nodeLabel } from '../../lib/format'
import { loadWorkflowDiagnostics } from './diagnostics'
import type { Checkpoint } from './types'
/** 完整诊断按需读取,关闭或切项目取消旧查询;不把保存记录误标为执行成功。 */
const props = defineProps<{ projectId: string; checkpoints: Checkpoint[] }>()
const open = defineModel<boolean>('open', { default: false })
const key = computed(() => (open.value ? props.projectId : ''))
const query = usePolling(key, (id, signal) => (id ? loadWorkflowDiagnostics(id, signal) : Promise.resolve(null)), 12000)
const tab = ref('timeline')
const workflow = ref('all')
const search = ref('')
const checkpointMap = computed(() => new Map(props.checkpoints.map(item => [item.checkpointId, item])))
const options = computed(() => [
{ label: '全部工作流', value: 'all' },
...[...new Set(props.checkpoints.map(item => item.workflowName))].map(value => ({ label: value, value }))
])
const groups = computed(() =>
(query.data.value?.groups ?? [])
.map(group => ({
...group,
nodes: group.nodes.filter(
node =>
(workflow.value === 'all' ||
checkpointMap.value.get(node.checkpointId)?.workflowName === workflow.value) &&
`${node.nodeName} ${nodeLabel(node.nodeName)} ${node.checkpointId}`
.toLowerCase()
.includes(search.value.trim().toLowerCase())
)
}))
.filter(group => group.nodes.length)
)
const nodes = computed(() => groups.value.flatMap(group => group.nodes).toSorted((a, b) => a.index - b.index))
watch(
() => props.projectId,
() => {
workflow.value = 'all'
search.value = ''
tab.value = 'timeline'
}
)
/** 只根据 checkpoint 中明确的执行错误显示失败,未知结果仍标记为已保存。 */
function recordLabel(id: string) {
return checkpointMap.value.get(id)?.state.workflowExecution?.status === 'failed' ? '包含失败记录' : '记录已保存'
}
/** 导出原始指标和完整分组,不被视图中的筛选裁剪。 */
function exportReport() {
if (query.data.value)
downloadText(
`project-${props.projectId}-diagnostics.json`,
JSON.stringify({ projectId: props.projectId, ...query.data.value }, null, 2),
'application/json'
)
}
</script>
<template>
<AppDialog
v-model:open="open"
title="工作流运行诊断"
description="展示全项目已保存的 checkpoint 指标与完整时间线,不启动或恢复任务。"
wide
>
<NAlert type="info" :show-icon="false" class="mt-4 text-xs"
>后端指标按保存记录累计耗时之和不等于实际墙钟耗时completed也不代表图片或视频成功生产任务状态请以资产页面为准</NAlert
>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-3"
>{{ query.error.value }}当前保留上次数据</NAlert
>
<NAlert
v-for="error in query.data.value?.errors"
:key="error"
role="alert"
type="error"
:show-icon="false"
class="mt-3"
>{{ error }}</NAlert
>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-4 text-sm">
<template v-if="query.data.value?.metrics">
<span>全项目记录 {{ query.data.value.metrics.nodeCount }}</span>
<span>累计节点耗时 {{ query.data.value.metrics.totalDurationText }}</span>
<span>最近记录重试数 {{ query.data.value.metrics.retryCount }}</span>
</template>
<span v-else class="text-muted">{{ query.loading.value ? '读取诊断中…' : '运行指标暂不可用' }}</span>
</div>
<div class="flex items-center gap-2">
<NButton :disabled="!query.data.value" @click="exportReport"
><template #icon><Download :size="14" /></template>导出诊断</NButton
><NButton
class="icon-button"
:loading="query.loading.value"
aria-label="刷新运行诊断"
@click="query.refresh"
><template #icon><RefreshCw :size="16" /></template
></NButton>
</div>
</div>
<div class="diagnostics-filters mt-5">
<NInput
v-model:value="search"
clearable
placeholder="搜索节点或 checkpoint"
:input-props="{ 'aria-label': '搜索运行记录' }"
><template #prefix><Search :size="14" /></template
></NInput>
<NSelect v-model:value="workflow" :options="options" aria-label="筛选工作流" />
<span class="text-xs text-muted">{{ nodes.length }} 条记录</span>
</div>
<NTabs v-model:value="tab" type="line" class="mt-3"
><NTab name="timeline">完整时间线</NTab><NTab name="grouped">按阶段分组</NTab></NTabs
>
<div v-if="tab === 'timeline'" class="diagnostics-records mt-4">
<article v-for="node in nodes" :key="node.checkpointId" class="surface-inset p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ node.index }} · {{ nodeLabel(node.nodeName) }}</h3>
<span
class="text-xs"
:class="recordLabel(node.checkpointId) === '包含失败记录' ? 'text-danger' : 'text-muted'"
>{{ recordLabel(node.checkpointId) }}</span
>
</div>
<p class="mt-2 text-xs text-muted">
{{ checkpointMap.get(node.checkpointId)?.workflowName || '未标注工作流' }} · {{ node.phase }} ·
{{ node.durationText }} · {{ formatDate(node.createdAt) }}
</p>
<p class="mt-2 break-all font-mono text-[11px] text-muted">{{ node.checkpointId }}</p>
</article>
</div>
<NCollapse v-else class="mt-4"
><NCollapseItem
v-for="group in groups"
:key="group.phase"
:name="group.phase"
:title="`${group.phase} · ${group.nodes.length} 条`"
>
<div class="diagnostics-records">
<article v-for="node in group.nodes" :key="node.checkpointId" class="surface-inset p-4">
<p class="text-sm">{{ nodeLabel(node.nodeName) }} · {{ node.durationText }}</p>
<p class="mt-2 text-xs text-muted">
{{ checkpointMap.get(node.checkpointId)?.workflowName || '未标注工作流' }} ·
{{ recordLabel(node.checkpointId) }}
</p>
<p class="mt-2 break-all font-mono text-[11px] text-muted">{{ node.checkpointId }}</p>
</article>
</div>
</NCollapseItem></NCollapse
>
<EmptyState
v-if="!query.loading.value && !nodes.length"
title="暂无匹配的运行记录"
description="可清空筛选后重试;尚未保存 checkpoint 的任务不会出现在时间线中。"
/>
</AppDialog>
</template>
+166
View File
@@ -0,0 +1,166 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WorkflowDiagnosticsDialog from './WorkflowDiagnosticsDialog.vue'
import { loadWorkflowDiagnostics, type WorkflowTimelineGroup } from './diagnostics'
import type { Checkpoint } from './types'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 后端将 checkpoint 固定标为 completed,测试确保界面不把它当执行成功。 */
const groups: WorkflowTimelineGroup[] = [
{
phase: '其它',
nodeCount: 22,
durationMs: 22000,
durationText: '22s',
nodes: Array.from({ length: 22 }, (_, index) => ({
index: index + 1,
checkpointId: `checkpoint-${index}`,
nodeName: `node-${index}`,
phase: '其它',
status: 'completed',
durationMs: 1000,
durationText: '1s',
retryCount: 0,
createdAt: '2026-09-01T00:00:00Z'
}))
}
]
const metrics = {
projectId: 'diagnostics-test',
nodeCount: 22,
totalDurationText: '22s',
retryCount: 1,
successRate: 100,
failedNodeCount: 0
}
describe('运行观测入口', () => {
it('关闭时不请求,打开只读两个接口,完整展示超过 18 条记录且不伪造成功率', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : groups }))
)
vi.stubGlobal('fetch', fetcher)
const checkpoints: Checkpoint[] = [
{
checkpointId: 'checkpoint-0',
workflowName: 'breakdown',
createdAt: '2026-09-01T00:00:00Z',
state: {
workflowExecution: {
status: 'failed',
executionId: 'execution-1',
startedAt: '2026-09-01T00:00:00Z'
}
}
}
]
wrapper = mount(WorkflowDiagnosticsDialog, {
attachTo: document.body,
props: { projectId: 'diagnostics-test', open: false, checkpoints }
})
await flushPromises()
expect(fetcher).not.toHaveBeenCalled()
await wrapper.setProps({ open: true })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(22)
expect(document.body.textContent).toContain('包含失败记录')
expect(document.body.textContent).not.toContain('100%')
const input = document.querySelector<HTMLInputElement>('[aria-label="搜索运行记录"]')!
input.value = 'checkpoint-21'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(1)
})
it('一个观测接口失败仍显示另一项,不启动恢复或生产', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async url =>
String(url).endsWith('/metrics')
? new Response(JSON.stringify({ error: '指标不可用' }), { status: 500 })
: new Response(JSON.stringify({ data: groups }))
)
vi.stubGlobal('fetch', fetcher)
const result = await loadWorkflowDiagnostics('project/1')
expect(result.metrics).toBeNull()
expect(result.groups?.[0]?.nodes).toHaveLength(22)
expect(result.errors[0]).toContain('指标')
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/metrics')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/timeline/grouped')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('指标项目不匹配时拒绝展示', async () => {
vi.stubGlobal(
'fetch',
vi
.fn<typeof fetch>()
.mockImplementation(
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : [] }))
)
)
const result = await loadWorkflowDiagnostics('other-project')
expect(result.metrics).toBeNull()
expect(result.errors.join()).toContain('不匹配的项目')
})
it('切项目会取消旧查询,晚到响应不会写进新项目', async () => {
const pending: ((response: Response) => void)[] = []
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
String(url).includes('/old/')
? new Promise(resolve => pending.push(resolve))
: Promise.resolve(
new Response(
JSON.stringify({
data: String(url).endsWith('/metrics')
? { ...metrics, projectId: 'new', nodeCount: 0 }
: []
})
)
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(WorkflowDiagnosticsDialog, {
attachTo: document.body,
props: { projectId: 'old', open: true, checkpoints: [] }
})
await flushPromises()
const signal = fetcher.mock.calls[0]?.[1]?.signal
await wrapper.setProps({ projectId: 'new' })
await flushPromises()
expect(signal?.aborted).toBe(true)
pending[0]!(new Response(JSON.stringify({ data: { ...metrics, projectId: 'old' } })))
pending[1]!(new Response(JSON.stringify({ data: groups })))
await flushPromises()
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(0)
expect(document.body.textContent).toContain('全项目记录 0')
await wrapper.setProps({ open: false })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(4)
})
it('关闭弹窗时取消仍未返回的查询', async () => {
const finishes: ((response: Response) => void)[] = []
const fetcher = vi.fn<typeof fetch>().mockImplementation(() => new Promise(resolve => finishes.push(resolve)))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(WorkflowDiagnosticsDialog, {
attachTo: document.body,
props: { projectId: 'diagnostics-test', open: true, checkpoints: [] }
})
await flushPromises()
await wrapper.setProps({ open: false })
await flushPromises()
expect(fetcher.mock.calls.every(([, init]) => init?.signal?.aborted)).toBe(true)
finishes[0]!(new Response(JSON.stringify({ data: metrics })))
finishes[1]!(new Response(JSON.stringify({ data: groups })))
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
})
})
+70
View File
@@ -0,0 +1,70 @@
import { request, errorMessage } from '../../lib/http'
/** 后端指标按已保存 checkpoint 统计,不代表当前运行或失败任务数量。 */
export interface WorkflowMetrics {
projectId: string
nodeCount: number
totalDurationMs: number
totalDurationText: string
retryCount: number
/** 原始服务会把所有 checkpoint 视为完成,因此仅在 JSON 中保留这些字段。 */
completedNodeCount: number
failedNodeCount: number
runningNodeCount: number
pendingNodeCount: number
successRate: number
reviewPassed: boolean
projectStatus?: string
nodes: unknown[]
}
/** 单条时间线记录;status 是保存状态,不能用作模型结果判断。 */
export interface WorkflowTimelineNode {
index: number
checkpointId: string
nodeName: string
phase: string
status: string
durationMs: number
durationText: string
retryCount: number
reviewPassed?: boolean
projectStatus?: string
createdAt: string
}
/** 后端按节点阶段分组,阶段不是独立的工作流执行批次。 */
export interface WorkflowTimelineGroup {
phase: string
nodeCount: number
durationMs: number
durationText: string
nodes: WorkflowTimelineNode[]
}
/** 两项观测查询分别报错,单个接口失败不隐藏另一项成功结果。 */
export interface WorkflowDiagnostics {
metrics: WorkflowMetrics | null
groups: WorkflowTimelineGroup[] | null
errors: string[]
}
/** 仅读取已存在的观测接口,不调用 POST /resume(它只返回恢复信息)。 */
export async function loadWorkflowDiagnostics(projectId: string, signal?: AbortSignal): Promise<WorkflowDiagnostics> {
const path = `/projects/${encodeURIComponent(projectId)}`
const [metrics, groups] = await Promise.allSettled([
request<WorkflowMetrics>(`${path}/metrics`, { signal }).then(value => {
if (value.projectId !== projectId) throw new Error('运行指标返回了不匹配的项目。')
return value
}),
request<WorkflowTimelineGroup[]>(`${path}/timeline/grouped`, { signal })
])
return {
metrics: metrics.status === 'fulfilled' ? metrics.value : null,
groups: groups.status === 'fulfilled' ? groups.value : null,
errors: [
metrics.status === 'rejected' ? `指标:${errorMessage(metrics.reason)}` : '',
groups.status === 'rejected' ? `时间线:${errorMessage(groups.reason)}` : ''
].filter(Boolean)
}
}
+2
View File
@@ -132,6 +132,7 @@ async function mountSubjectImages() {
afterEach(() => {
getProductionSession(fixture.id).receipt = null
getProductionSession(fixture.id).pipelineReceipt = null
getIdentitySession(fixture.id).receipt = null
wrapper?.unmount()
wrapper = undefined
@@ -139,6 +140,7 @@ afterEach(() => {
vi.unstubAllGlobals()
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
getImageSession(fixture.id).receipt = null
getImageSession(fixture.id).promptReceipt = null
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
})
+33
View File
@@ -0,0 +1,33 @@
import { computed, ref } from 'vue'
import { vi } from 'vitest'
import type { ProjectDetail } from '../features/projects/types'
import type { Checkpoint } from '../features/workflows/types'
import type { useProjectData } from '../features/projects/context'
/** 模拟正式项目上下文,测试不连接真实模型或数据库。 */
export function testProjectContext(id = 'capability-test'): ReturnType<typeof useProjectData> {
const project: ProjectDetail = {
id,
title: '测试剧本',
topic: '测试',
style: '写实',
status: 'completed',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
episodes: [{ episode: 1, title: '第一集', content: '内容' }],
characters: [],
world: null,
reviews: [],
tasks: []
}
const data = ref({ project, checkpoints: [] as Checkpoint[] })
return {
data,
project: computed(() => data.value.project),
checkpoints: computed(() => data.value.checkpoints),
loading: ref(false),
error: ref(''),
updatedAt: ref(''),
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
}
}