fix: 同步制作接口并修复浅色主题层次

This commit is contained in:
GouJ
2026-09-14 10:08:54 +08:00
parent 2eea0ea72e
commit 7ea3f2a0b4
19 changed files with 641 additions and 93 deletions
+5 -1
View File
@@ -193,7 +193,10 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET | `/storyboard-shots/:shotId/videos` | 单镜头视频历史 | | GET | `/storyboard-shots/:shotId/videos` | 单镜头视频历史 |
| POST | `/storyboard-shots/:shotId/videos/generate-quality` | 单镜头严格质量候选;后台自动校验与有限修复 | | POST | `/storyboard-shots/:shotId/videos/generate-quality` | 单镜头严格质量候选;后台自动校验与有限修复 |
| PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 | | PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 |
| POST | `/projects/:id/production/start` | 高级串联入口;重复预检、费用确认,仅传两种 Provider,不生成首帧、不等待成片 | | GET | `/projects/:id/production/plan` | 无消耗执行计划、分阶段数量与阻塞原因 |
| GET | `/projects/:id/production/status` | 真实主资产有效性与制作完成状态 |
| GET | `/storyboard-shots/:id/provider-input-spec?provider=...` | 指定模型的最终首帧与授权素材,只读检查 |
| POST | `/projects/:id/production/start` | 高级串联入口;按执行计划再次预检、费用确认,body 为 {},包含首帧质量流程,不等待异步视频完成 |
API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。 API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
@@ -327,3 +330,4 @@ server {
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。 本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。 自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
+14
View File
@@ -30,3 +30,17 @@
## 回归验证范围 ## 回归验证范围
测试使用不包含旧内部字段的公开 DTO,覆盖页面加载、身份保存/选角、图片主图、Prompt 正式 ID、质量会话隔离、输入变体禁用、失败重试和缺失历史不误报过期。全部 API 使用模拟响应,不触发真实模型。此次不以自动化 DOM 测试代替浏览器视觉验收或真实后端联调。 测试使用不包含旧内部字段的公开 DTO,覆盖页面加载、身份保存/选角、图片主图、Prompt 正式 ID、质量会话隔离、输入变体禁用、失败重试和缺失历史不误报过期。全部 API 使用模拟响应,不触发真实模型。此次不以自动化 DOM 测试代替浏览器视觉验收或真实后端联调。
## 2026-09-14 增量同步
核对后端 dev `c2142681b16798ae5ff865a2eb648a9f45221af2` 的路由、Production PlanStatusState、主体生图及视频输入编译实现。历史文档中的 `f0321c2` 已无法通过 GitHub compare 解析,本轮以当前源码为准。
- 接入制作执行计划和真实制作状态,删除“串联不会生成首帧”和必须先有全部主图的旧限制。
- 补齐首帧质量回执、视频阻塞/进行中、dispatchCompletedProvider 启动参数保持空对象。
- 人工阻塞与请求错误分开,允许其余 ready 主体部分成功;提交前重新预检,项目归属/活动任务校验保留。
- 批量生图默认包含过期主图,但新候选不会自动替换已有主图,页面明确提醒确认主图。
- 指定模型最终输入支持只读检查授权素材与实际首帧,通用规格改为明确未指定模型的展示。授权资产绑定管理和实验探针不在本轮新增范围。
- 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。
验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。
+13 -7
View File
@@ -31,14 +31,19 @@
## 高级串联生产边界 ## 高级串联生产边界
实际后端顺序:形态正式提示词 → 形态图片 → 视频提示词 → 视频任务提交。**没有视觉风格、身份、导演设计即时状态或首帧生成节点,也不会等待成片。** 实际后端顺序:形态正式提示词 → 形态图片 → 首帧质量生成/校验/有限修复 → 视频提示词 → 视频任务提交。视觉风格、身份母版、导演设计即时状态需提前准备。
- 仅剧本 `completed` 可启动;先检查项目/工作流任务、形态活动图片、当前母版引用、镜头规格、主首帧有效性和活动视频 - 通过 GET `/projects/:id/production/plan` 展示可执行、跳过、前置完成后可执行和人工阻塞数量,并保留逐项后端原因。缺少可自动生成的主图或首帧不再被前端直接拦截
- 为避免中途生成主图使既有首帧失效,前端要求先确认全部形态主图并补齐有效主首帧;因此该入口通常跳过形态生图,只串联仍缺少的文本与视频任务 - GET `/projects/:id/production/status` 展示真实资产有效数量与完成状态。计划是当前快照,前置阶段执行后后端还会重新检查
- 点击预检只有 GET;付费确认后再次 GET 校验。查询失败或条件变更不提交 POST。单次项目锁仅覆盖本浏览器,不能代替服务器事务和跨客户端互斥 - 剧本未完成、活动任务、资产归属或列表不一致、没有可执行工作时不提交。人工阻塞独立提示,允许其余 ready 形态部分成功;不能保证后续阶段继续
- 图片与视频模型由后端统一配置,前端不传请求级 Provider。旧串联路由仍解析默认字段,但实际执行节点已改用统一配置;不据此显示虚假模型选择。后端内部文字并发 3、图片视频并发 2,不接受普通批量面板的覆盖、上限和尺寸配置 - 点击检查只有 GET;费用确认后再次读取计划与状态,条件变化时不提交 POST。POST body 为 `{}`图片视频模型与并发读取后端配置,不消费普通批量面板参数
- 图工作流 `completed` 仅说明经过 finalize,可能仍有错误;`needsManualReview``stopReason` 和所有 `errors` 原样显示。视频统计用“已提交”,不标“成片已完成 - `dispatchCompleted` 表示同步分发结束;`completed` 由后端核对所有有效主体主图、首帧、主视频后给出。回执展示首帧 passedrepairFailedfailedblocked 及视频 inProgressblocked,不把提交成功显示为制作完成。
- 此工作流没有持久 checkpoint/恢复/取消接口。网络断开不能推断后端停止,禁止自动重发。已提交的长请求回执按原项目隔离;页面刷新后需从资产状态核对 - 批量主体生图会处理缺失及过期主图,已有过期主图仍须用户确认新候选为主图。前端不读取 rawJson 猜测时效
- 单次项目锁仅覆盖本浏览器;长请求不自动重发,回执按原项目隔离。刷新后通过只读制作状态重新核对。
## 视频实际输入
生成规格弹窗提供 GET `/storyboard-shots/:id/provider-input-spec?provider=...`,显式填写目标模型,查看实际首帧、变体原因和授权素材。查询失败保留后端错误;切换镜头或模型清除旧结果。通用生成规格不宣称是指定模型最终输入。本轮未增加授权资产绑定管理或探针生成入口。
## 观测指标边界 ## 观测指标边界
@@ -71,3 +76,4 @@
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。 - 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。 - 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。 - 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
+2 -1
View File
@@ -24,7 +24,7 @@
### 模型配置 ### 模型配置
图片与视频实际使用后端 `IMAGE_*``VIDEO_*` 配置。身份/选角/形态/首帧/视频请求不再发送固定 `provider`;串联生产也不再传固定 `imageProvider``videoProvider`串联路由仍保留默认字段,但新执行节点不再消费这些请求级模型值,因此界面不能把它们当实际模型展示 图片与视频实际使用后端 `IMAGE_*``VIDEO_*` 配置。身份/选角/形态/首帧/视频请求不再发送固定 `provider`;串联生产也不再传固定 `imageProvider``videoProvider`。串联启动路由已移除请求级模型字段;指定模型的只读输入检查独立传入 provider,不影响生产配置
质量面板或资产规格弹窗可按需读取 `/image-providers/capabilities` 和当前镜头的 `keyframe-provider-capability`。此入口只读,不提供会被后端忽略的“前端切换模型”。未声明参考图上限显示“上限未声明”,不声称无限制。 质量面板或资产规格弹窗可按需读取 `/image-providers/capabilities` 和当前镜头的 `keyframe-provider-capability`。此入口只读,不提供会被后端忽略的“前端切换模型”。未声明参考图上限显示“上限未声明”,不声称无限制。
@@ -64,3 +64,4 @@
## 验证边界 ## 验证边界
单元与组件测试全部使用模拟 API,验证参数、正式 ID、禁用、费用确认、范围、归属、回执隔离和菜单访问,不触发真实模型任务。浏览器视觉与实际模型联调需要在可访问的开发环境进行;不将 DOM 测试视为视觉验收。 单元与组件测试全部使用模拟 API,验证参数、正式 ID、禁用、费用确认、范围、归属、回执隔离和菜单访问,不触发真实模型任务。浏览器视觉与实际模型联调需要在可访问的开发环境进行;不将 DOM 测试视为视觉验收。
+3 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ededed" /> <meta name="theme-color" content="#ffffff" />
<script> <script>
// 在应用挂载前恢复主题,避免深色模式出现白色闪屏。 // 在应用挂载前恢复主题,避免深色模式出现白色闪屏。
try { try {
@@ -13,7 +13,7 @@
((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches) ((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.dataset.theme = dark ? 'dark' : 'light' document.documentElement.dataset.theme = dark ? 'dark' : 'light'
document.documentElement.style.colorScheme = dark ? 'dark' : 'light' document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
document.querySelector('meta[name="theme-color"]').setAttribute('content', dark ? '#111111' : '#ededed') document.querySelector('meta[name="theme-color"]').setAttribute('content', dark ? '#111111' : '#ffffff')
} catch { } catch {
/* 存储不可用时由应用主题配置接管。 */ /* 存储不可用时由应用主题配置接管。 */
} }
@@ -26,3 +26,4 @@
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+3 -2
View File
@@ -36,7 +36,7 @@ export function useTheme() {
const disabledColor = dark ? '#303030' : '#e4e4e4' const disabledColor = dark ? '#303030' : '#e4e4e4'
const disabledText = dark ? '#808080' : '#8c8c8c' const disabledText = dark ? '#808080' : '#8c8c8c'
const accentText = dark ? '#5cd693' : '#087c42' const accentText = dark ? '#5cd693' : '#087c42'
const body = dark ? '#111111' : '#ededed' const body = dark ? '#111111' : '#ffffff'
const surface = dark ? '#191919' : '#ffffff' const surface = dark ? '#191919' : '#ffffff'
const subtle = dark ? '#232323' : '#f7f7f7' const subtle = dark ? '#232323' : '#f7f7f7'
const text = dark ? '#d9d9d9' : '#191919' const text = dark ? '#d9d9d9' : '#191919'
@@ -254,7 +254,7 @@ export function useTheme() {
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light' document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
document document
.querySelector('meta[name="theme-color"]') .querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDark.value ? '#111111' : '#ededed') ?.setAttribute('content', isDark.value ? '#111111' : '#ffffff')
try { try {
localStorage.setItem(storageKey, preference.value) localStorage.setItem(storageKey, preference.value)
} catch { } catch {
@@ -263,3 +263,4 @@ export function useTheme() {
}) })
return { preference, isDark, theme, overrides } return { preference, isDark, theme, overrides }
} }
+14
View File
@@ -10,6 +10,9 @@ import type {
ProjectVideoStatus, ProjectVideoStatus,
PromptReadiness, PromptReadiness,
ProductionPipelineResult, ProductionPipelineResult,
ProductionPlan,
ProviderInputSpec,
ProjectProductionStatus,
RetryVideosResult, RetryVideosResult,
ShotKeyframe, ShotKeyframe,
ShotVideo, ShotVideo,
@@ -32,6 +35,17 @@ function shotPath(id: string) {
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */ /** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
export const productionApi = { export const productionApi = {
/** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */
providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) =>
request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, {
signal
}),
/** 查询当前计划,不生成图片、视频或提示词。 */
plan: (projectId: string, signal?: AbortSignal) =>
request<ProductionPlan>(`${projectPath(projectId)}/production/plan`, { signal }),
/** 读取真实主资产完成状态,不能用任务提交回执代替。 */
status: (projectId: string, signal?: AbortSignal) =>
request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }),
startPipeline: (projectId: string) => startPipeline: (projectId: string) =>
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, { request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
method: 'POST', method: 'POST',
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import ConfirmAction from '../../workflows/ConfirmAction.vue' import ConfirmAction from '../../workflows/ConfirmAction.vue'
import { downloadText, formatDate } from '../../../lib/format' import { downloadText, formatDate } from '../../../lib/format'
import { useAdvancedProduction } from '../useAdvancedProduction' import { useAdvancedProduction } from '../useAdvancedProduction'
import ProductionPlanPanel from './ProductionPlanPanel.vue'
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */ /** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction() const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
@@ -31,12 +32,10 @@ function exportReceipt() {
<NCollapse v-model:expanded-names="expanded" class="mt-5" <NCollapse v-model:expanded-names="expanded" class="mt-5"
><NCollapseItem name="advanced" title="高级:串联生产(全项目)"> ><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
<NAlert type="warning" :show-icon="false" <NAlert type="warning" :show-icon="false"
>依次补齐形态提示词形态参考图视频提示词并提交视频任务不会生成视觉风格身份母版导演设计即时状态或首帧请先在各阶段完成验收</NAlert >依次补齐形态提示词参考图首帧质量校验与修复视频提示词提交视频任务视觉风格身份母版导演设计和即时状态仍需提前准备</NAlert
> >
<p class="mt-3 text-xs leading-6 text-muted"> <p class="mt-3 text-xs leading-6 text-muted">
范围固定为全项目跳过已有结果不使用上方并发覆盖数量上限和尺寸配置后端固定文字并发 范围固定为全项目跳过已有结果不使用上方并发覆盖数量上限和尺寸配置可能产生文字图片首帧校验与修复视频模型费用模型与并发由服务端配置决定请确认其他页面没有任务断网后先刷新制作状态勿直接重试
3图片和视频并发 2可能产生模型费用此流程没有持久运行锁或恢复
checkpoint请确认其他页面终端没有任务断网后先查资产勿直接重试
</p> </p>
<div class="mt-4 flex flex-wrap items-center gap-3"> <div class="mt-4 flex flex-wrap items-center gap-3">
<NButton :disabled="blocked || checking" :loading="checking" @click="preflight" <NButton :disabled="blocked || checking" :loading="checking" @click="preflight"
@@ -46,7 +45,7 @@ function exportReceipt() {
label="启动串联生产" label="启动串联生产"
:disabled="blocked || checking || !check || !!check.issues.length" :disabled="blocked || checking || !check || !!check.issues.length"
acknowledgement acknowledgement
:description="`项目 ${id}:全项目串联生产,不生成首帧,也不等待视频完成。确认后会重新检查前置条件;可能产生文字、图片和视频模型费用。`" :description="`项目 ${id}:全项目串联生产,包含首帧生成、校验与有限修复,不等待异步视频完成。确认后会重新读取计划;有阻塞时先处理可执行部分,成功资产会保留。可能产生文字、图片、校验和视频模型费用。`"
@confirm="start" @confirm="start"
/> />
</div> </div>
@@ -63,8 +62,9 @@ function exportReceipt() {
<ul v-if="check.issues.length" class="mt-2 space-y-2 text-xs text-danger"> <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> <li v-for="issue in check.issues" :key="issue">{{ issue }}</li>
</ul> </ul>
<p v-else class="mt-2 text-sm">前置检查通过提交前仍会再次校验</p> <p v-else class="mt-2 text-sm">可以执行计划中的任务提交前仍会再次校验</p>
</div> </div>
<ProductionPlanPanel v-if="check" :plan="check.plan" :status="check.status" />
<p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm"> <p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm">
请求进行中请勿重复提交 请求进行中请勿重复提交
</p> </p>
@@ -73,7 +73,13 @@ function exportReceipt() {
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
串联生产回执 · 串联生产回执 ·
{{ {{
receipt.needsManualReview ? '需要人工处理' : receipt.completed ? '流程已返回' : '流程未完成' receipt.needsManualReview
? '需要人工处理'
: receipt.completed
? '制作已完成'
: receipt.dispatchCompleted
? '任务已分发,等待视频完成'
: '本轮未完成'
}} }}
</h3> </h3>
<NButton text size="small" @click="exportReceipt">导出回执</NButton> <NButton text size="small" @click="exportReceipt">导出回执</NButton>
@@ -87,11 +93,23 @@ function exportReceipt() {
失败 {{ row.result.failed }}</template 失败 {{ row.result.failed }}</template
><template v-else>未执行或未返回</template> ><template v-else>未执行或未返回</template>
</p> </p>
<p class="mt-2 text-xs">
首帧质量<template v-if="receipt.keyframeQualityResult">
{{ receipt.keyframeQualityResult.total }} · 校验通过
{{ receipt.keyframeQualityResult.passed }} · 跳过 {{ receipt.keyframeQualityResult.skipped }} ·
阻塞 {{ receipt.keyframeQualityResult.blocked }} · 修复未通过
{{ receipt.keyframeQualityResult.repairFailed }} · 执行失败
{{ receipt.keyframeQualityResult.failed }} </template
><template v-else>未执行或未返回</template>
</p>
<p class="mt-2 text-xs"> <p class="mt-2 text-xs">
视频任务<template v-if="receipt.videoGenerationResult" 视频任务<template v-if="receipt.videoGenerationResult"
> {{ receipt.videoGenerationResult.total }} · 已提交 > {{ receipt.videoGenerationResult.total }} · 已提交
{{ receipt.videoGenerationResult.created }} · 跳过 {{ receipt.videoGenerationResult.skipped }} · {{ receipt.videoGenerationResult.created }} · 跳过
提交失败 {{ receipt.videoGenerationResult.failed }}</template {{ receipt.videoGenerationResult.skipped }} 含进行中
{{ receipt.videoGenerationResult.inProgress }}阻塞
{{ receipt.videoGenerationResult.blocked }} · 提交失败
{{ receipt.videoGenerationResult.failed }}</template
><template v-else>未执行或未返回</template> ><template v-else>未执行或未返回</template>
</p> </p>
<NAlert <NAlert
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NCollapse, NCollapseItem, NScrollbar } from 'naive-ui'
import type { ProductionPlan, ProjectProductionStatus } from '../types'
/** 展示后端只读执行计划与真实资产状态,不自行推断阻塞是否可自动解除。 */
const props = defineProps<{ plan: ProductionPlan; status: ProjectProductionStatus }>()
const rows = computed(() => [
{ label: '主体参考图', stage: props.plan.subjectImages },
{ label: '首帧质量', stage: props.plan.keyframes },
{ label: '视频提示词', stage: props.plan.videoPrompts },
{ label: '视频任务', stage: props.plan.videos }
])
const stageNames = { subject_images: '主体参考图', keyframes: '首帧', videos: '视频', completed: '全部完成' }
/** 保留全部后端问题,包括预计可由前置阶段解决的问题,便于核对计划。 */
const problems = computed(() => [
...props.plan.details.subjectImages
.filter(item => item.issues.length)
.map(item => ({
key: `form:${item.subjectFormId}`,
label: `主体参考图 · ${item.subjectName} / ${item.formName}${item.subjectRef}`,
issues: item.issues
})),
...[
{ label: '首帧', key: 'keyframe', items: props.plan.details.keyframes },
{ label: '视频提示词', key: 'prompt', items: props.plan.details.videoPrompts },
{ label: '视频', key: 'video', items: props.plan.details.videos }
].flatMap(group =>
group.items
.filter(item => item.issues.length)
.map(item => ({
key: `${group.key}:${item.shotId}`,
label: `${group.label} · 镜头 ${item.shotNo}${item.shotId}`,
issues: item.issues
}))
)
])
</script>
<template>
<section class="surface-inset mt-3 p-4" aria-label="制作执行计划">
<h3 class="text-sm font-semibold">执行计划 · 仅检查不产生生成费用</h3>
<p class="mt-2 text-xs" role="status">
当前制作状态{{ status.completed ? '制作已完成' : `待完成${stageNames[status.stage]}` }} · 有效主体图
{{ status.subjectImages.current }}/{{ status.subjectImages.total }} · 有效首帧
{{ status.keyframes.skipped }}/{{ status.keyframes.total }} · 有效主视频 {{ status.videos.skipped }}/{{
status.videos.total
}}
· 视频进行中 {{ status.videos.inProgress }}
</p>
<NScrollbar x-scrollable class="mt-3">
<table class="production-plan-table">
<caption class="sr-only">
各阶段当前执行数量
</caption>
<thead>
<tr>
<th scope="col">阶段</th>
<th scope="col">总数</th>
<th scope="col">可执行</th>
<th scope="col">跳过</th>
<th scope="col">前置完成后可执行</th>
<th scope="col">需人工处理</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.stage.total }}</td>
<td>{{ row.stage.planned }}</td>
<td>{{ row.stage.skipped }}</td>
<td>{{ row.stage.upstreamResolvable }}</td>
<td>{{ row.stage.manualBlocked }}</td>
</tr>
</tbody>
</table>
</NScrollbar>
<p class="mt-2 text-xs text-muted">
计划按当前资产估算前置生成后会重新检查视频跳过包含已有有效视频和进行中的任务已有过期主体主图生成新候选后仍需在形态图库确认新主图
</p>
<NAlert v-if="plan.hasManualBlockers" type="warning" :show-icon="false" class="mt-3">
各阶段合计
{{ plan.manualBlocked }}
项需人工处理同一镜头可能重复计数可先生成就绪的主体参考图成功结果会保留遇到阻塞时后续阶段可能暂停
</NAlert>
<NCollapse v-if="problems.length" class="mt-3">
<NCollapseItem name="issues" title="查看前置条件与阻塞原因">
<ul class="space-y-3 text-xs">
<li v-for="item in problems" :key="item.key">
<strong class="break-all">{{ item.label }}</strong>
<p v-for="(issue, index) in item.issues" :key="index" class="mt-1 break-words">
{{ issue.reason
}}<span v-if="issue.missingSubjects?.length">
· 关联主体{{ issue.missingSubjects.join('') }}</span
>
</p>
</li>
</ul>
</NCollapseItem>
</NCollapse>
</section>
</template>
<style scoped>
.production-plan-table {
width: 100%;
min-width: 540px;
border-collapse: collapse;
font-size: 12px;
}
.production-plan-table th,
.production-plan-table td {
padding: 9px 12px;
text-align: left;
}
.production-plan-table thead {
background: var(--app-control);
}
.production-plan-table tbody {
background: var(--app-surface);
}
.production-plan-table tbody tr:nth-child(even) {
background: var(--app-subtle);
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton, NInput } from 'naive-ui'
import { productionApi } from '../api'
import { errorMessage } from '../../../lib/http'
import type { ProviderInputSpec } from '../types'
/** 最终输入检查仅发 GET,切换模型或镜头后清除旧回执,不修改授权绑定。 */
const props = defineProps<{ shotId: string }>()
const provider = ref('')
const targetProvider = computed(() => provider.value.trim().toLowerCase())
const result = ref<ProviderInputSpec | null>(null)
const error = ref('')
const loading = ref(false)
let version = 0
let controller: AbortController | undefined
/** 使晚到响应失效,避免把另一镜头或模型的授权信息展示到当前页面。 */
function clear() {
version++
controller?.abort()
result.value = null
error.value = ''
loading.value = false
}
watch([() => props.shotId, targetProvider], clear, { flush: 'sync' })
onScopeDispose(clear)
/** 查询实际输入并核对镜头及模型归属。 */
async function inspect() {
if (!props.shotId || !targetProvider.value || loading.value) return
clear()
const current = version
const shotId = props.shotId
const providerName = targetProvider.value
controller = new AbortController()
loading.value = true
try {
const data = await productionApi.providerInputSpec(shotId, providerName, controller.signal)
if (current !== version) return
if (data.shotId !== shotId || data.targetProvider !== providerName)
throw new Error('最终输入与当前镜头或模型不匹配,请重新查询。')
result.value = data
} catch (cause) {
if (current === version) error.value = errorMessage(cause)
} finally {
if (current === version) loading.value = false
}
}
</script>
<template>
<section class="surface-inset mt-4 p-4" aria-label="视频模型最终输入检查">
<h4 class="text-sm font-medium">按模型检查实际输入</h4>
<p class="mt-2 text-xs text-muted">
填写后端使用的视频模型标识检查实际首帧与授权素材只读查询不产生生成费用
</p>
<div class="mt-3 flex flex-wrap gap-2">
<NInput
v-model:value="provider"
placeholder="例如 seedance"
class="min-w-0 flex-1"
:input-props="{ 'aria-label': '视频模型标识' }"
/>
<NButton :disabled="!targetProvider || loading" :loading="loading" @click="inspect">检查实际输入</NButton>
</div>
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
<div v-if="result" class="mt-3 space-y-2 break-all text-xs">
<p>目标模型{{ result.targetProvider }}</p>
<p>业务主首帧{{ result.businessKeyframe.id }}</p>
<p>
实际输入首帧{{ result.providerInputKeyframe.id }} ·
{{ result.providerInputKeyframe.source === 'provider_variant' ? '模型专用变体' : '业务主首帧' }}
</p>
<p v-if="result.providerInputKeyframe.variantReason">
变体原因{{ result.providerInputKeyframe.variantReason }}
</p>
<ul class="space-y-2">
<li v-for="reference in result.references" :key="`${reference.subjectId}:${reference.subjectFormId}`">
{{ reference.subjectName }} / {{ reference.subjectFormName }}{{ reference.subjectRef }}
<template v-if="reference.providerAsset"
>授权素材 {{ reference.providerAsset.assetId }} ·
{{ reference.providerAsset.provider }}</template
>
<template v-else>未返回该模型的有效授权素材</template>
</li>
</ul>
</div>
</section>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ProviderInputInspector from './ProviderInputInspector.vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui' import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue' import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
@@ -502,6 +503,7 @@ async function inspectSpecs() {
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert> <NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
<p v-if="inspecting" role="status" class="mt-4 text-xs text-muted">正在读取生成规格…</p> <p v-if="inspecting" role="status" class="mt-4 text-xs text-muted">正在读取生成规格…</p>
<ModelCapabilities :shot-id="shot.shotId" /> <ModelCapabilities :shot-id="shot.shotId" />
<ProviderInputInspector :shot-id="shot.shotId" />
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs" :default-expanded-names="['details']" <NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs" :default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>本镜确定性生成规格</template> ><template #header>本镜确定性生成规格</template>
@@ -530,7 +532,7 @@ async function inspectSpecs() {
业务主首帧 ID · {{ videoSpec.keyframe.id }} 业务主首帧 ID · {{ videoSpec.keyframe.id }}
</p> </p>
<p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted"> <p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted">
模型输入首帧 ID · {{ videoSpec.providerInputKeyframe.id }} 通用规格首帧 ID(未指定模型) · {{ videoSpec.providerInputKeyframe.id }}
{{ {{
videoSpec.providerInputKeyframe.source === 'provider_variant' videoSpec.providerInputKeyframe.source === 'provider_variant'
? '(专用变体)' ? '(专用变体)'
+124 -4
View File
@@ -148,6 +148,8 @@ export interface KeyframeSpec {
/** 视频生成规格中的主体参考图。 */ /** 视频生成规格中的主体参考图。 */
export interface VideoReference { export interface VideoReference {
/** 当前目标视频模型的授权素材,不拼接模型私有地址。 */
providerAsset?: { provider: string; assetId: string }
subjectId: string subjectId: string
subjectRef: string subjectRef: string
subjectName: string subjectName: string
@@ -302,15 +304,20 @@ export interface ProductionSession {
pipelineReceipt?: ProductionPipelineResult | null pipelineReceipt?: ProductionPipelineResult | null
} }
/** 串联生产只汇总节点结果completed 不代表视频已完成。 */ /** 串联生产回执completed 由后端核对全部当前有效主资产后给出。 */
export interface ProductionPipelineResult { export interface ProductionPipelineResult {
projectId: string projectId: string
imageProvider: string
videoProvider: string
visualPromptResult?: PipelineBatchResult visualPromptResult?: PipelineBatchResult
subjectImageResult?: PipelineBatchResult subjectImageResult?: PipelineBatchResult
videoPromptResult?: PipelineBatchResult videoPromptResult?: PipelineBatchResult
videoGenerationResult?: Omit<PipelineBatchResult, 'generated'> & { created: number } keyframeQualityResult?: PipelineKeyframeQualityResult
videoGenerationResult?: Omit<PipelineBatchResult, 'generated'> & {
created: number
inProgress: number
blocked: number
}
/** 同步任务分发结束;异步视频仍可能正在生成和校验。 */
dispatchCompleted: boolean
completed: boolean completed: boolean
needsManualReview: boolean needsManualReview: boolean
stopReason: string stopReason: string
@@ -335,3 +342,116 @@ export interface VideoInputKeyframe {
/** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */ /** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */
export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted' export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted'
/** 项目当前制作阶段,与剧本创作状态分别展示。 */
export type ProductionStage = 'subject_images' | 'keyframes' | 'videos' | 'completed'
/** 主体参考图的公开就绪明细,阻塞原因由后端提供。 */
export interface SubjectImageReadinessItem {
subjectFormId: string
subjectRef: string
subjectName: string
module: string
formName: string
status: 'ready' | 'skipped' | 'blocked'
primaryImageId: string | null
primaryImageStale: boolean
issues: ProductionIssue[]
}
/** 无消耗计划中每个阶段的数量;不同阶段的阻塞数不可当作去重镜头数。 */
export interface ProductionPlanStage {
total: number
planned: number
skipped: number
upstreamResolvable: number
manualBlocked: number
}
/** 后端执行计划只读取资产,不触发模型生成。 */
export interface ProductionPlan {
projectId: string
completed: boolean
stage: ProductionStage
hasManualBlockers: boolean
manualBlocked: number
subjectImages: ProductionPlanStage
keyframes: ProductionPlanStage
videoPrompts: ProductionPlanStage
videos: ProductionPlanStage
details: {
subjectImages: SubjectImageReadinessItem[]
keyframes: KeyframeReadinessItem[]
videoPrompts: PromptReadinessItem[]
videos: VideoReadinessItem[]
}
}
/** 真实制作状态;只有主体主图、主首帧和主视频均有效才完成。 */
export interface ProjectProductionStatus {
projectId: string
completed: boolean
stage: ProductionStage
subjectImages: {
total: number
current: number
missing: number
stale: number
complete: boolean
items: {
subjectFormId: string
subjectRef: string
subjectName: string
module: string
formName: string
primaryImageId: string | null
status: 'missing' | 'current' | 'stale'
reason: string
}[]
}
keyframes: Pick<KeyframeReadiness, 'total' | 'ready' | 'skipped' | 'blocked' | 'stalePrimaryKeyframe' | 'items'> & {
complete: boolean
}
videos: Pick<
VideoReadiness,
| 'total'
| 'ready'
| 'skipped'
| 'inProgress'
| 'blocked'
| 'stalePrimaryVideo'
| 'providerInputRecovery'
| 'items'
> & {
complete: boolean
tasks: ProjectVideoStatus
}
}
/** 首帧质量流程保留未通过和异常失败计数,不将已有主图视作本轮校验通过。 */
export interface PipelineKeyframeQualityResult {
total: number
passed: number
repairFailed: number
failed: number
skipped: number
blocked: number
stalePrimaryKeyframe: number
}
/** 指定模型的最终输入查询回执;此接口不会生成图片或视频。 */
export interface ProviderInputSpec {
shotId: string
targetProvider: string
businessKeyframe: Pick<VideoInputKeyframe, 'id' | 'imageUrl'>
providerInputKeyframe: Pick<
VideoGenerationSpec['providerInputKeyframe'],
'id' | 'imageUrl' | 'source' | 'targetProvider' | 'variantReason'
>
references: (Pick<
VideoReference,
'subjectId' | 'subjectRef' | 'subjectName' | 'module' | 'subjectFormId' | 'subjectFormName'
> & {
providerAsset: { provider: string; assetId: string } | null
})[]
}
@@ -2,66 +2,59 @@ import { computed, onScopeDispose, ref, watch } from 'vue'
import { projectsApi } from '../projects/api' import { projectsApi } from '../projects/api'
import { useProjectContext } from '../projects/context' import { useProjectContext } from '../projects/context'
import { subjectImagesApi } from '../subject-images/api' import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages, primaryImage } from '../subject-images/model' import { hasRunningImages } from '../subject-images/model'
import { getOperation, runOperation } from '../workflows/operations' import { getOperation, runOperation } from '../workflows/operations'
import { hasRunningWorkflow } from '../workflows/selectors' import { hasRunningWorkflow } from '../workflows/selectors'
import { errorMessage } from '../../lib/http' import { errorMessage } from '../../lib/http'
import { productionApi } from './api' import { productionApi } from './api'
import { getProductionSession } from './model' import { getProductionSession } from './model'
/** 高级串联入口只能消费已验图的主资产,不替用户跳过选角、设计和首帧环节。 */ /** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */
export async function checkPipeline(projectId: string) { export async function checkPipeline(projectId: string) {
const [project, checkpoints, forms, keyframes, prompts, videos] = await Promise.all([ const [project, checkpoints, forms, plan, status] = await Promise.all([
projectsApi.detail(projectId), projectsApi.detail(projectId),
projectsApi.checkpoints(projectId), projectsApi.checkpoints(projectId),
subjectImagesApi.listForms(projectId), subjectImagesApi.listForms(projectId),
productionApi.keyframeReadiness(projectId, true), productionApi.plan(projectId),
productionApi.promptReadiness(projectId, true), productionApi.status(projectId)
productionApi.videoReadiness(projectId, false)
]) ])
if (project.id !== projectId || forms.some(form => form.subject.projectId !== projectId)) if (
project.id !== projectId ||
plan.projectId !== projectId ||
status.projectId !== projectId ||
forms.some(form => form.subject.projectId !== projectId)
)
throw new Error('预检返回了其他项目的数据,请重新读取。') throw new Error('预检返回了其他项目的数据,请重新读取。')
const issues: string[] = [] const issues: string[] = []
const shotIds = new Set(keyframes.items.map(item => item.shotId)) const shotIds = new Set(plan.details.keyframes.map(item => item.shotId))
if ( if (
shotIds.size !== keyframes.total || shotIds.size !== plan.keyframes.total ||
[prompts, videos].some( plan.details.keyframes.length !== plan.keyframes.total ||
data => plan.subjectImages.total !== forms.length ||
new Set(data.items.map(item => item.shotId)).size !== shotIds.size || plan.details.subjectImages.length !== forms.length ||
data.items.some(item => !shotIds.has(item.shotId)) plan.details.subjectImages.some(item => !forms.some(form => form.id === item.subjectFormId)) ||
[plan.details.videoPrompts, plan.details.videos, status.keyframes.items, status.videos.items].some(
items =>
items.length !== shotIds.size ||
new Set(items.map(item => item.shotId)).size !== shotIds.size ||
items.some(item => !shotIds.has(item.shotId))
) )
) )
issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。') issues.push('各阶段资产列表不一致,可能正在重新拆解,请刷新后重试。')
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。') if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。') if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。')
if (!forms.length) issues.push('尚无正式形态,请先完成拆解。') if (!forms.length || !plan.keyframes.total) issues.push('尚无正式形态或镜头,请先完成拆解和分镜。')
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。') if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
if (forms.some(form => !primaryImage(form.images)?.imageUrl))
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
if ( if (
!keyframes.total || status.videos.inProgress ||
keyframes.items.length !== keyframes.total || status.videos.items.some(item => item.status === 'in_progress' || item.activeVideoId)
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('存在活动视频任务,请等待结束后再操作。') issues.push('存在活动视频任务,请等待结束后再操作。')
return { projectId, issues, shotCount: keyframes.total, checkedAt: new Date().toISOString() } if (status.completed || plan.completed) issues.push('当前项目制作已完成,无需再次启动。')
else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0))
issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。')
// 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。
return { projectId, issues, plan, status, shotCount: plan.keyframes.total, checkedAt: new Date().toISOString() }
} }
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */ /** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
@@ -396,7 +396,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
:description=" :description="
force force
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。' ? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。' : '对缺少主图或主图已过期的形态调用图片模型,仍有效的主图跳过。已有过期主图不会被自动替换,生成后请在图片记录中确认新主图。操作面向整个项目,不受列表筛选影响。'
" "
@confirm="generateProject" @confirm="generateProject"
/> />
@@ -846,7 +846,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
/* 外层使用浅灰分组,输入与下拉恢复为表面色,避免同色后失去控件边界。 */ /* 外层使用浅灰分组,输入与下拉恢复为表面色,避免同色后失去控件边界。 */
--app-field: var(--app-surface); --app-field: var(--app-surface);
--app-field-hover: var(--app-surface); --app-field-hover: var(--app-surface);
background: color-mix(in srgb, var(--app-subtle) 96%, white); background: var(--app-subtle);
container-type: inline-size; container-type: inline-size;
} }
.form-image-toolbar { .form-image-toolbar {
+12 -3
View File
@@ -2,13 +2,13 @@
:root { :root {
--app-ink: #191919; --app-ink: #191919;
--app-muted: #707070; --app-muted: #707070;
--app-body: #ededed; --app-body: #ffffff;
--app-surface: #ffffff; --app-surface: #ffffff;
--app-subtle: #f7f7f7; --app-subtle: #f7f7f7;
--app-control: #f0f0f0; --app-control: #f0f0f0;
--app-control-hover: #e5e5e5; --app-control-hover: #e5e5e5;
--app-field: #ffffff; --app-field: #f0f0f0;
--app-field-hover: #ffffff; --app-field-hover: #e8e8e8;
--app-border: #e5e5e5; --app-border: #e5e5e5;
--app-inverse: #ffffff; --app-inverse: #ffffff;
--app-accent: #07c160; --app-accent: #07c160;
@@ -208,6 +208,7 @@ body {
min-width: 0; min-width: 0;
} }
.workspace-tabs > .n-tabs-nav { .workspace-tabs > .n-tabs-nav {
background: var(--app-surface);
align-items: stretch; align-items: stretch;
} }
.workspace-tabs .n-tabs-tab { .workspace-tabs .n-tabs-tab {
@@ -437,3 +438,11 @@ body {
grid-template-rows: minmax(0, 1fr) 130px; grid-template-rows: minmax(0, 1fr) 130px;
} }
} }
/* 导航保留明确选中态与轻量悬停反馈,明暗模式共用主题变量。 */
.workspace-tabs .n-tabs-tab--active {
font-weight: 600;
}
.workspace-tabs .n-tabs-tab:hover {
background: var(--app-subtle);
}
+2 -1
View File
@@ -194,7 +194,7 @@ describe('全站平面主题', () => {
expect(source).not.toContain('title="网格布局"') expect(source).not.toContain('title="网格布局"')
expect(source).not.toContain('title="瀑布流布局"') expect(source).not.toContain('title="瀑布流布局"')
expect(css).toMatch( expect(css).toMatch(
/\.form-image-filter-region\s*\{[^}]*padding:\s*12px;[^}]*--app-field:\s*var\(--app-surface\);[^}]*--app-field-hover:\s*var\(--app-surface\);[^}]*background:\s*color-mix\(in srgb, var\(--app-subtle\) 96%, white\);/ /\.form-image-filter-region\s*\{[^}]*padding:\s*12px;[^}]*--app-field:\s*var\(--app-surface\);[^}]*--app-field-hover:\s*var\(--app-surface\);[^}]*background:\s*var\(--app-subtle\);/
) )
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/) expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
expect(css).toMatch( expect(css).toMatch(
@@ -298,3 +298,4 @@ describe('全站平面主题', () => {
} }
}) })
}) })
+5 -4
View File
@@ -52,7 +52,7 @@ describe('微信风格明暗主题', () => {
const color = overrides.value.Tabs!.tabBorderColor! const color = overrides.value.Tabs!.tabBorderColor!
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串') if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
expect(color).toBe('#dadada') expect(color).toBe('#dadada')
expect(contrast(color, '#ededed')).toBeGreaterThan(1.15) expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15) expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
}) })
@@ -86,7 +86,7 @@ describe('微信风格明暗主题', () => {
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => { it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
const state = setup() const state = setup()
expect(state.overrides.value.common?.primaryColor).toBe('#07c160') expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
expect(state.overrides.value.common?.bodyColor).toBe('#ededed') expect(state.overrides.value.common?.bodyColor).toBe('#ffffff')
state.preference.value = 'dark' state.preference.value = 'dark'
await nextTick() await nextTick()
expect(state.theme.value?.name).toBe('dark') expect(state.theme.value?.name).toBe('dark')
@@ -158,13 +158,13 @@ describe('微信风格明暗主题', () => {
document.head.appendChild(meta) document.head.appendChild(meta)
try { try {
const state = setup() const state = setup()
expect(meta.content).toBe('#ededed') expect(meta.content).toBe('#ffffff')
state.change(true) state.change(true)
await nextTick() await nextTick()
expect(meta.content).toBe('#111111') expect(meta.content).toBe('#111111')
state.preference.value = 'light' state.preference.value = 'light'
await nextTick() await nextTick()
expect(meta.content).toBe('#ededed') expect(meta.content).toBe('#ffffff')
} finally { } finally {
meta.remove() meta.remove()
} }
@@ -190,3 +190,4 @@ describe('微信风格明暗主题', () => {
expect(document.documentElement.dataset.theme).toBe('dark') expect(document.documentElement.dataset.theme).toBe('dark')
}) })
}) })
+92 -18
View File
@@ -54,17 +54,67 @@ function server() {
} }
const receipt = { const receipt = {
projectId: 'capability-test', projectId: 'capability-test',
completed: true, completed: false,
dispatchCompleted: true,
needsManualReview: false, needsManualReview: false,
stopReason: '', stopReason: '',
errors: ['一个提示词未生成'], errors: ['一个提示词未生成'],
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 } keyframeQualityResult: {
total: 1,
passed: 1,
skipped: 0,
blocked: 0,
repairFailed: 0,
failed: 0,
stalePrimaryKeyframe: 0
},
videoGenerationResult: { total: 1, created: 1, skipped: 0, inProgress: 0, blocked: 0, failed: 0 }
}
const stage = () => ({ total: 1, planned: 1, skipped: 0, upstreamResolvable: 0, manualBlocked: 0 })
const plan = {
projectId: 'capability-test',
completed: false,
stage: 'subject_images',
hasManualBlockers: false,
manualBlocked: 0,
subjectImages: stage(),
keyframes: stage(),
videoPrompts: stage(),
videos: stage(),
details: {
subjectImages: [
{
subjectFormId: form.id,
subjectRef: 'C1',
subjectName: '角色',
formName: '基础形态',
module: 'character',
status: 'ready',
primaryImageId: null,
primaryImageStale: false,
issues: [] as { code: string; reason: string }[]
}
],
keyframes: readiness.items,
videoPrompts: readiness.items,
videos: readiness.items
}
}
const status = {
projectId: 'capability-test',
completed: false,
stage: 'subject_images',
subjectImages: { total: 1, current: 0, missing: 1, stale: 0, complete: false, items: [] },
keyframes: readiness,
videos: readiness
} }
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url) const path = String(url)
let data: unknown = context.project.value let data: unknown = context.project.value
if (path.endsWith('/checkpoints')) data = context.checkpoints.value if (path.endsWith('/checkpoints')) data = context.checkpoints.value
else if (path.endsWith('/subject-forms')) data = [form] else if (path.endsWith('/subject-forms')) data = [form]
else if (path.endsWith('/production/plan')) data = plan
else if (path.endsWith('/production/status')) data = status
else if (path.includes('/readiness')) data = readiness else if (path.includes('/readiness')) data = readiness
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test') else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
else if (path.endsWith('/videos/status')) else if (path.endsWith('/videos/status'))
@@ -91,6 +141,8 @@ function server() {
form, form,
readiness, readiness,
receipt, receipt,
plan,
status,
fetcher, fetcher,
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST') posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
} }
@@ -113,7 +165,7 @@ async function advanced() {
} }
describe('高级串联生产的安全边界', () => { describe('高级串联生产的安全边界', () => {
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => { it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交空参数', async () => {
const { service, fetcher, posts } = await advanced() const { service, fetcher, posts } = await advanced()
expect(fetcher).not.toHaveBeenCalled() expect(fetcher).not.toHaveBeenCalled()
await service.start() await service.start()
@@ -125,37 +177,56 @@ describe('高级串联生产的安全边界', () => {
expect(posts()).toHaveLength(1) expect(posts()).toHaveLength(1)
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start') expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({}) expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
expect( expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/production/plan'))).toHaveLength(2)
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
).toHaveLength(2)
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成']) expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
expect(service.check.value).toBeNull() expect(service.check.value).toBeNull()
}) })
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => { it('预检后新增活动任务,确认时重新检查并阻止重复提交', async () => {
const { service, readiness, posts } = await advanced() const { service, readiness, posts } = await advanced()
await service.preflight() await service.preflight()
readiness.items[0]!.primaryKeyframeStale = true readiness.inProgress = 1
await service.start() await service.start()
expect(posts()).toHaveLength(0) expect(posts()).toHaveLength(0)
expect(service.check.value?.issues.join()).toContain('有效主首帧') expect(service.check.value?.issues.join()).toContain('活动视频任务')
expect(getOperation('capability-test').error).toContain('条件变化') expect(getOperation('capability-test').error).toContain('条件变化')
}) })
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => { it('缺失主图和过期首帧允许后端计划补齐,但未完成剧本和活动任务仍阻止启动', async () => {
const { context, form, readiness } = server() const { context, form, readiness } = server()
context.data.value!.project.status = 'need_review'
form.images = [] form.images = []
readiness.items[0]!.primaryKeyframeStale = true
readiness.items[0]!.issues = [{ code: 'missing_reference', reason: '先补参考图' }]
expect((await checkPipeline('capability-test')).issues).toEqual([])
context.data.value!.project.status = 'need_review'
readiness.inProgress = 1 readiness.inProgress = 1
const result = await checkPipeline('capability-test') const result = await checkPipeline('capability-test')
expect(result.issues.join()).toContain('剧本尚未完成') expect(result.issues.join()).toContain('剧本尚未完成')
expect(result.issues.join()).toContain('所有形态主图')
expect(result.issues.join()).toContain('活动视频任务') expect(result.issues.join()).toContain('活动视频任务')
}) })
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => { it('人工阻塞保留后端原因,同时允许其他就绪形态先生成', async () => {
const { readiness } = server() const { service, plan, posts } = await advanced()
readiness.items[0]!.status = 'skipped' plan.hasManualBlockers = true
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }] plan.manualBlocked = 1
const result = await checkPipeline('capability-test') plan.keyframes.manualBlocked = 1
expect(result.issues.join()).toContain('视频前置检查未通过') plan.details.keyframes[0]!.issues = [{ code: 'missing_identity_anchor', reason: '缺少身份母版' }]
await service.preflight()
expect(service.check.value?.issues).toEqual([])
expect(service.check.value?.plan.details.keyframes[0]?.issues[0]?.reason).toBe('缺少身份母版')
await service.start()
expect(posts()).toHaveLength(1)
})
it('没有可执行任务或真实资产已全部完成时不再启动', async () => {
const { plan, status } = server()
for (const stage of [plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos]) stage.planned = 0
expect((await checkPipeline('capability-test')).issues.join()).toContain('没有可执行')
status.completed = true
expect((await checkPipeline('capability-test')).issues.join()).toContain('制作已完成')
})
it('计划项目不匹配或镜头集合改变时拒绝采用旧计划', async () => {
const { plan } = server()
plan.details.videoPrompts = []
expect((await checkPipeline('capability-test')).issues.join()).toContain('资产列表不一致')
plan.projectId = 'other'
await expect(checkPipeline('capability-test')).rejects.toThrow('其他项目')
}) })
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => { it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
const { service, context, posts } = await advanced() const { service, context, posts } = await advanced()
@@ -223,6 +294,9 @@ describe('高级串联生产的安全边界', () => {
expect(document.body.textContent).toContain('流程返回不等于成片完成') expect(document.body.textContent).toContain('流程返回不等于成片完成')
expect(document.body.textContent).toContain(receipt.errors[0]) expect(document.body.textContent).toContain(receipt.errors[0])
expect(document.body.textContent).toContain('已提交 1') expect(document.body.textContent).toContain('已提交 1')
expect(document.body.textContent).toContain('任务已分发,等待视频完成')
expect(document.body.textContent).toContain('校验通过 1')
expect(document.body.textContent).not.toContain('不会生成首帧')
}) })
}) })
@@ -0,0 +1,75 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import ProviderInputInspector from '@/features/production/components/ProviderInputInspector.vue'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
vi.unstubAllGlobals()
})
/** 仅提供公开输入规格,不连接生成服务。 */
function inputSpec(shotId = 'shot-1') {
return {
shotId,
targetProvider: 'seedance',
businessKeyframe: { id: 'keyframe-1', imageUrl: '/key.png' },
providerInputKeyframe: {
id: 'variant-1',
imageUrl: '/variant.png',
source: 'provider_variant',
variantReason: '模型输入适配'
},
references: [
{
subjectId: 'subject-1',
subjectFormId: 'form-1',
subjectRef: 'C1',
subjectName: '角色',
subjectFormName: '基础形态',
module: 'character',
providerAsset: { provider: 'seedance', assetId: 'asset-1' }
}
]
}
}
it('按显式模型发出只读查询并展示授权素材,切换镜头清除旧结果', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: inputSpec() })))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ProviderInputInspector, { props: { shotId: 'shot-1' } })
expect(fetcher).not.toHaveBeenCalled()
await wrapper.get('input').setValue(' Seedance ')
await wrapper.get('button').trigger('click')
await flushPromises()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-1/provider-input-spec?provider=seedance')
expect(fetcher.mock.calls[0]?.[1]?.method).toBe('GET')
expect(wrapper.text()).toContain('asset-1')
expect(wrapper.text()).toContain('variant-1')
await wrapper.setProps({ shotId: 'shot-2' })
expect(wrapper.text()).not.toContain('asset-1')
})
it('模型切换使晚到的旧响应失效,错误归属不发布结果', async () => {
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ProviderInputInspector, { props: { shotId: 'shot-1' } })
await wrapper.get('input').setValue('seedance')
await wrapper.get('button').trigger('click')
await wrapper.get('input').setValue('other')
finish(new Response(JSON.stringify({ data: inputSpec() })))
await flushPromises()
expect(wrapper.text()).not.toContain('asset-1')
await wrapper.get('input').setValue('seedance')
fetcher.mockResolvedValue(new Response(JSON.stringify({ data: inputSpec('wrong-shot') })))
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('不匹配')
expect(wrapper.text()).not.toContain('asset-1')
})