feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
@@ -619,3 +619,99 @@ watch(
/>
</WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.production-controls {
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
}
.production-pipeline {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3.5;
}
.production-pipeline-card {
@apply p-5;
}
.production-stats {
@apply grid grid-cols-[repeat(3,_1fr)] gap-2;
}
.production-stats div,
.production-status-grid div {
@apply p-2.5 rounded-none bg-(--app-subtle);
}
.production-stats dt,
.production-status-grid dt {
@apply text-muted text-[10px];
}
.production-stats dd,
.production-status-grid dd {
@apply mt-[5px] text-[13px] font-mono;
}
.production-status-grid {
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
}
.production-workspace {
@apply grid grid-cols-[220px_minmax(0,_1fr)] items-start overflow-hidden;
}
.production-shot-list {
@apply max-h-[760px] overflow-hidden bg-(--app-surface) py-2;
}
@media (max-width: 1100px) {
.production-pipeline {
@apply grid-cols-[1fr];
}
.production-controls {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
}
@media (max-width: 760px) {
.production-controls,
.production-workspace {
@apply grid-cols-[minmax(0,_1fr)];
}
.production-shot-list {
@apply flex max-h-none overflow-hidden;
border-right: none;
}
.production-status-grid {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
}
.workspace-inline-status {
@apply flex items-center flex-wrap justify-between gap-y-2 gap-x-4 py-[7px] px-3 shrink-0 rounded-none bg-(--app-subtle) text-xs;
}
.production-pipeline-card > .confirm-action {
@apply mt-4 mb-1;
}
.production-workspace {
@apply flex-1 min-h-0 items-stretch overflow-hidden;
}
.production-workspace {
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
}
.production-shot-list {
@apply max-h-none overflow-hidden min-h-0;
}
.production-workspace > article {
@apply overflow-hidden min-h-0 overscroll-contain;
}
.production-shot-list-content {
@apply gap-3 pt-0 px-0 pb-3;
}
.production-shot-list {
@apply p-0;
}
@media (max-width: 1000px) {
.production-controls {
@apply grid-cols-[minmax(130px,_1fr)_90px];
}
}
@media (max-width: 760px) {
.production-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
}
.production-pipeline {
@apply grid-cols-[1fr];
}
}
</style>
@@ -1,270 +0,0 @@
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({})
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({
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({ concurrency: 2, force: true })
})
})
@@ -259,3 +259,11 @@ async function submit() {
</p>
</AppDialog>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.quality-fields {
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_180px),_1fr))] items-start gap-4;
}
</style>
@@ -140,3 +140,17 @@ function exportResult() {
</NCollapse>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.quality-result {
@apply mt-6 p-4 bg-(--app-subtle);
}
.quality-result-row {
@apply flex flex-wrap items-center gap-y-2 gap-x-3 mt-2.5 p-3 bg-(--app-control) text-xs wrap-anywhere;
}
.quality-json {
@apply mt-4 whitespace-pre-wrap wrap-anywhere text-[11px] leading-[1.7];
}
</style>
@@ -523,3 +523,46 @@ async function inspectSpecs() {
/>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.production-stage {
@apply py-5 px-0;
}
.production-stage:first-of-type {
@apply border-t-0;
}
.production-stage-heading {
@apply flex items-start justify-between gap-4;
}
.keyframe-grid {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3;
}
.keyframe-card {
@apply overflow-hidden rounded-none bg-(--app-subtle);
}
.keyframe-card .asset-image {
@apply aspect-video rounded-none;
}
.video-record {
@apply flex overflow-hidden rounded-none bg-(--app-subtle);
}
.production-video {
@apply w-[min(44%,390px)] min-h-[170px] bg-ink object-contain;
}
@media (max-width: 760px) {
.keyframe-grid {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
.video-record {
@apply block;
}
.production-video {
@apply w-full;
}
}
.production-video {
@apply bg-[#080808];
}
</style>
-133
View File
@@ -1,133 +0,0 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mediaAssetUrl } from '../../lib/assets'
import KeyframeDialog from './components/KeyframeDialog.vue'
import { productionApi } from './api'
import {
isActiveVideo,
issueLabel,
primaryKeyframe,
primaryVideo,
productionStatusLabel,
validOptionalSize
} from './model'
import { keyframeFixture, videoFixture } from './testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从 Naive 弹窗中查找精确按钮。 */
function button(label: string): HTMLButtonElement {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
describe('镜头生产数据契约', () => {
it('可选尺寸必须成对留空或填写正整数', () => {
expect(validOptionalSize('', '')).toBe(true)
expect(validOptionalSize(1920, 1080)).toBe(true)
expect(validOptionalSize(1920, '')).toBe(false)
expect(validOptionalSize('', 1080)).toBe(false)
expect(validOptionalSize(0, 1080)).toBe(false)
expect(validOptionalSize(10.5, 1080)).toBe(false)
})
it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => {
expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1')
expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined()
expect(primaryVideo([videoFixture()])?.id).toBe('video-1')
expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined()
for (const status of ['pending', 'queued', 'running'] as const)
expect(isActiveVideo(videoFixture({ status }))).toBe(true)
expect(isActiveVideo(videoFixture())).toBe(false)
})
it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
expect(productionStatusLabel('unknown')).toBe('unknown')
})
it('视频地址与图片使用相同的安全协议限制', () => {
expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4')
expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4')
expect(mediaAssetUrl('javascript:alert(1)')).toBeNull()
expect(mediaAssetUrl('/storage/../admin')).toBeNull()
})
it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/readiness'))
return new Response(
JSON.stringify({
data: {
total: 1,
ready: 1,
skipped: 0,
inProgress: 0,
blocked: 0,
missingPrompt: 0,
missingKeyframe: 0,
missingReference: 0,
items: []
}
})
)
return new Response(
JSON.stringify({
data: {
total: 1,
targetCount: 1,
created: 1,
skipped: 0,
readiness: {},
failed: 0,
failures: []
}
})
)
})
vi.stubGlobal('fetch', fetcher)
await productionApi.videoReadiness('project/1', true)
await productionApi.generateVideos('project/1', { concurrency: 3, force: true })
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
concurrency: 3,
force: true
})
})
it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => {
wrapper = mount(KeyframeDialog, {
attachTo: document.body,
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
})
await flushPromises()
expect(button('确认生成首帧').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([[{ setPrimary: true }]])
await wrapper.setProps({ open: false })
await flushPromises()
await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] })
await flushPromises()
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
})
-515
View File
@@ -1,515 +0,0 @@
import { defineComponent, reactive, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
import { testProjectContext } from '../../testing/project-context'
import { productionApi } from './api'
import { qualityApi } from './quality-api'
import {
allowedTextLines,
qualityKey,
qualitySession,
qualityTargets,
savedVideoValidation,
videoRepairInfo,
validQualityInput
} from './quality'
import type { KeyframeReadiness } from './types'
import type { QualityInput, QualityTarget } from './quality.types'
import { useQuality } from './useQuality'
import { keyframeFixture, videoFixture } from './testing/fixtures'
import QualityDialog from './components/QualityDialog.vue'
import QualityResult from './components/QualityResult.vue'
import ModelCapabilities from './components/ModelCapabilities.vue'
let wrapper: VueWrapper | undefined
let sequence = 0
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
function server() {
const projectId = `quality-test-${sequence++}`
const project = testProjectContext(projectId).project.value!
const validation = {
passed: true,
summary: '身份与造型一致',
subjects: [],
subjectCountConsistent: true,
unauthorizedText: { detected: false, texts: [] },
issues: []
}
const keyframe = keyframeFixture()
const video = videoFixture()
const readiness = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingVisualStyle: 0,
missingIdentity: 0,
missingIdentityAnchor: 0,
identityUnlocked: 0,
invalidGenerationSpec: 0,
missingReference: 0,
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
} as KeyframeReadiness
const capability = {
provider: 'qwen-image',
referenceCount: 3,
maxReferenceImages: 3,
valid: true,
message: '参考图超限'
}
const keyframeResult = {
...validation,
shotId: 'shot-1',
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
isPrimary: true,
imageUrl: keyframe.imageUrl!
}
const attempt = {
attempt: 0,
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
passed: true,
issues: [],
validationDurationMs: 10
}
const repair = {
shotId: 'shot-1',
initialKeyframeId: 'keyframe-1',
finalKeyframeId: 'keyframe-1',
passed: true,
repaired: false,
primaryChanged: false,
repairAttempts: 0,
maxRepairAttempts: 1,
attempts: [attempt]
}
const batch = {
total: 1,
ready: 1,
selected: 1,
targetCount: 1,
passed: 1,
repairFailed: 0,
failed: 0,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingReference: 0,
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
const path = String(url)
let data: unknown
if (options?.method === 'POST') {
if (path.endsWith('/generate-quality')) data = batch
else if (path.includes('/videos/') && path.endsWith('/repair'))
data = {
shotId: 'shot-1',
sourceVideoId: 'video-1',
repairAttempt: 1,
maxRepairAttempts: 2,
repairInstructions: ['修复人物漂移'],
allowedTexts: [],
candidate: videoFixture({
id: 'video-repair-1',
status: 'queued',
isPrimary: false,
videoUrl: null,
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
})
}
else if (path.endsWith('/repair')) data = repair
else if (path.includes('/videos/'))
data = {
...validation,
shotId: 'shot-1',
videoId: 'video-1',
validatedAt: '2026-09-03',
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
videoUrl: video.videoUrl,
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
allowedTexts: []
}
else data = keyframeResult
} else if (path.endsWith(`/projects/${projectId}`)) data = project
else if (path.includes('/readiness')) data = readiness
else if (path.endsWith('/keyframes')) data = [keyframe]
else if (path.endsWith('/videos')) data = [video]
else if (path.endsWith('/keyframe-provider-capability')) data = capability
else if (path.endsWith('/image-providers/capabilities'))
data = [
{
provider: 'qwen-image',
active: true,
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
}
]
else throw new Error('意外接口:' + path)
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return {
projectId,
project,
validation,
keyframe,
video,
readiness,
capability,
keyframeResult,
repair,
batch,
fetcher,
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
}
}
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
function input(patch: Partial<QualityInput> = {}): QualityInput {
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
}
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
function validationButton() {
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始视觉校验'
)!
}
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
const data = server()
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
const visible = ref(true)
const changed = vi.fn<() => void>()
let service!: ReturnType<typeof useQuality>
wrapper = mount(
defineComponent({
setup() {
service = useQuality(props, () => visible.value, changed)
return () => null
}
})
)
return { ...data, props, visible, changed, service }
}
describe('视觉质量契约与付费边界', () => {
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
const data = setup()
expect(data.fetcher).not.toHaveBeenCalled()
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
})
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
const data = setup()
data.project.status = status
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('剧本未完成')
})
it('目标不属于项目时不查询目标素材,不生成', async () => {
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
})
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
const data = setup()
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
...data.keyframeResult,
passed: undefined
} as never)
await data.service.run('validate', input())
expect(data.service.session.value.receipt).toBeNull()
expect(data.service.session.value.error).toContain('不完整')
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
})
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
const data = setup()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toContain('/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
})
it('模型参考图能力不足时阻止修复与质量批次', async () => {
const data = setup()
data.capability.valid = false
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('参考图超限')
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
})
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.durationSeconds = null
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
data.video.durationSeconds = 5
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video',
result: { sampleFrames: [{ timeSeconds: 2 }] }
})
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
})
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
Object.assign(data.batch, {
passed: 0,
repairFailed: 1,
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
})
await data.service.run('batch', input({ width: 1536, height: 1024 }))
expect(data.posts()).toHaveLength(1)
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
const receipt = data.service.session.value.receipt!
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('仍需处理')
expect(wrapper.text()).toContain('人物不一致')
await wrapper
.findAll('button')
.find(button => button.text() === '定位镜头')!
.trigger('click')
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
})
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
data.readiness.stalePrimaryKeyframe = 1
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('切换剧集')
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
})
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
const data = setup()
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
await data.service.run('repair', input(values))
expect(data.fetcher).not.toHaveBeenCalled()
data.keyframe.status = 'generating'
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
data.props.disabled = true
const count = data.fetcher.mock.calls.length
await data.service.run('validate', input())
expect(data.fetcher).toHaveBeenCalledTimes(count)
})
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
const data = setup()
let finishCheck!: (value: KeyframeReadiness) => void
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
() =>
new Promise(resolve => {
finishCheck = resolve
})
)
const pendingCheck = data.service.run('validate', input())
await flushPromises()
data.visible.value = false
finishCheck(data.readiness)
await pendingCheck
expect(data.posts()).toHaveLength(0)
data.visible.value = true
let finish!: (value: typeof data.keyframeResult) => void
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
() =>
new Promise(resolve => {
finish = resolve
})
)
const originalKey = data.service.key.value
const pending = data.service.run('validate', input())
await flushPromises()
await data.service.run('validate', input())
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
data.props.projectId = 'different-project'
finish(data.keyframeResult)
await pending
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
expect(data.service.session.value.receipt).toBeNull()
})
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
const data = server()
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
await qualityApi.validateVideo('shot/a', 'video/b', [])
expect(data.posts().map(([url]) => url)).toEqual([
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
])
})
})
describe('质量面板渐进展示', () => {
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
open: true,
disabled: false
}
})
await flushPromises()
expect(validationButton().disabled).toBe(true)
expect(document.body.textContent).toContain('更多参数与模型限制')
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
expect(data.fetcher).not.toHaveBeenCalled()
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
expect(validationButton().disabled).toBe(false)
validationButton().click()
await flushPromises()
expect(data.posts()).toHaveLength(1)
expect(document.body.textContent).toContain('视觉校验通过')
})
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
open: true,
disabled: false
}
})
await flushPromises()
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
await flushPromises()
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始质量生成'
)!
expect(submit.disabled).toBe(true)
expect(data.posts()).toHaveLength(0)
})
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
const data = server()
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
expect(data.fetcher).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('qwen-image · 当前启用')
expect(wrapper.text()).toContain('最多 3 张参考图')
expect(data.posts()).toHaveLength(0)
})
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
const data = server()
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
expect(savedVideoValidation('{broken')).toBeNull()
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
)
expect(data.fetcher).not.toHaveBeenCalled()
})
})
describe('视频修复候选与复检晋升', () => {
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video-repair',
result: { candidate: { status: 'queued', isPrimary: false } }
})
})
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.rawJson = JSON.stringify({
videoValidation: { ...data.validation, passed: false },
repair: { attempt: 2 }
})
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('次数上限')
})
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
data.video.isPrimary = false
await data.service.run('validate', input())
const receipt = data.service.session.value.receipt!
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
expect(data.posts()).toHaveLength(1)
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('后端已将其设为主视频')
})
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
open: true,
disabled: false,
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
}
})
await flushPromises()
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
await flushPromises()
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
expect(document.body.textContent).not.toContain('次生图')
expect(document.body.textContent).toContain('修复链次数上限')
expect(data.posts()).toHaveLength(0)
})
})