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
+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: '' })
})