fix: 补齐过期首帧素材定位与图库影响提示

This commit is contained in:
GouJ
2026-09-02 18:49:20 +08:00
parent f71d2bf0a4
commit 771b66cafc
11 changed files with 826 additions and 23 deletions
+1
View File
@@ -223,6 +223,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。 9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。 10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。
11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。 11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。
12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId``subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。
### 视觉风格、身份与形态的衔接 ### 视觉风格、身份与形态的衔接
+9 -1
View File
@@ -42,6 +42,14 @@
时间线分组是节点阶段,不是独立 executionId。未识别的节点被后端归为“其它”;前端用共享 checkpoint 的 workflowName 进一步筛选。两个观测接口独立报错,关闭弹窗不继续发起 API 查询,旧项目迟到响应不会覆盖新项目。 时间线分组是节点阶段,不是独立 executionId。未识别的节点被后端归为“其它”;前端用共享 checkpoint 的 workflowName 进一步筛选。两个观测接口独立报错,关闭弹窗不继续发起 API 查询,旧项目迟到响应不会覆盖新项目。
## 过期首帧与素材定位
- 生产页将结构化 `stale_keyframe.missingSubjects` 映射到当前镜头 `/references` 返回的正式 `subjectFormId`,点击可定位图库中实际使用的形态;不按主体名称猜默认形态。缺少关联时退回主体引用检索并明确提示待核对。
- 图库始终读取 keyframes 与 videos 的 readiness,显示下游过期镜头数量;选择来源镜头后才查询其参考图,避免遍历全部镜头的 N+1 请求。只允许定位当前项目列表中的镜头。
- 图库深链接支持 `sourceShotId``subjectFormId``subjectRef`;精确形态优先,切换定位清除旧筛选,丢失的形态不自动回退其他形态。返回生产页携带 `episodeNo` 与正式 `shotId`
- “身份主图过期”与“下游首帧过期”独立展示。素材有效而首帧过期时,只需核对后重建首帧;定位、刷新、筛选不触发生成或切换主资产。
- 当前后端基线存在检查口径不一致风险:`video-readiness.service.ts``video-generation-compiler.service.ts` 调用 `shotKeyframeCurrentnessService.check` 时仅传形态引用,遗漏第三个当前身份母版参数。默认空列表会将快照里的身份母版视为已移除;而 `shot-keyframe-readiness.service.ts` 会正确传入当前母版。前端因此合并两种检查,并将“首帧未标过期、视频报告过期”单列为“状态待核对”,不据此判定素材失效或建议直接重复生图。本次仅修改前端,不绕过视频阻塞;后端调用链需另行修复与联调。
## 不添加无效或重复入口 ## 不添加无效或重复入口
| 后端路由/能力 | 处理方式与理由 | | 后端路由/能力 | 处理方式与理由 |
@@ -57,5 +65,5 @@
## 回归验证 ## 回归验证
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。 - 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
- 已通过 lint、格式、TypeScript、Vitest22 个文件、220 项测试)和生产构建。不触发真实文本/图片/视频模型,不提交真实生产任务。 - 已通过 lint、格式、TypeScript、Vitest23 个文件、233 项测试)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。 - 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
+12
View File
@@ -1460,6 +1460,18 @@ body {
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
} }
/* 镜头影响和素材定位使用固定底色;高亮只表示当前定位,不代表素材失效。 */
.asset-impact-context {
padding: 14px 16px;
background: var(--app-subtle);
}
.asset-impact-select {
flex: 0 1 340px;
min-width: 0;
}
.form-image-card-focused {
box-shadow: inset 3px 0 0 var(--app-accent-text);
}
.select-control { .select-control {
min-width: 130px; min-width: 130px;
} }
+44 -13
View File
@@ -12,7 +12,7 @@ import {
NProgress, NProgress,
NSelect NSelect
} from 'naive-ui' } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, inject, ref, watch } from 'vue'
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue' import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui' import { EmptyState, StatusBadge } from '../../components/ui'
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue' import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
@@ -22,6 +22,9 @@ import { useProduction } from './useProduction'
import ProductionReceipt from './components/ProductionReceipt.vue' import ProductionReceipt from './components/ProductionReceipt.vue'
import ShotProductionAssets from './components/ShotProductionAssets.vue' import ShotProductionAssets from './components/ShotProductionAssets.vue'
import AdvancedProduction from './components/AdvancedProduction.vue' import AdvancedProduction from './components/AdvancedProduction.vue'
import StaleKeyframeNotice from './components/StaleKeyframeNotice.vue'
import { routeLocationKey } from 'vue-router'
import { queryText } from './asset-links'
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */ /** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
const { const {
@@ -49,6 +52,23 @@ const {
blocked, blocked,
run run
} = useProduction() } = useProduction()
const route = inject(routeLocationKey, undefined)
/** 从素材页返回时恢复到指定剧集与正式镜头,不按重复的 Shot 编号定位。 */
watch(
() => [route?.query.episodeNo, route?.query.shotId],
() => {
const episode = Number(queryText(route?.query.episodeNo))
selectedEpisode.value = Number.isSafeInteger(episode) && episode > 0 ? episode : undefined
selectedShot.value = queryText(route?.query.shotId)
},
{ immediate: true }
)
const linkedShotMissing = computed(
() =>
queryText(route?.query.shotId) &&
query.data.value &&
!shots.value.some(item => item.shotId === selectedShot.value)
)
const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0) const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
const stages = computed(() => { const stages = computed(() => {
const data = query.data.value const data = query.data.value
@@ -92,6 +112,12 @@ const stages = computed(() => {
const selectedIssues = computed(() => const selectedIssues = computed(() =>
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? []) [promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
) )
const remainingIssues = computed(() => selectedIssues.value.filter(issue => issue.code !== 'stale_keyframe'))
const selectedKeyframeStale = computed(
() =>
!!keyframeItem.value?.primaryKeyframeStale ||
selectedIssues.value.some(issue => issue.code === 'stale_keyframe')
)
const videoRunning = computed( const videoRunning = computed(
() => () =>
(query.data.value?.videoStatus.pending ?? 0) + (query.data.value?.videoStatus.pending ?? 0) +
@@ -514,6 +540,9 @@ watch(
<article class="production-detail min-w-0"> <article class="production-detail min-w-0">
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7"> <NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<header class="mb-5 pb-5"> <header class="mb-5 pb-5">
<NAlert v-if="linkedShotMissing" type="warning" :show-icon="false" class="mb-4"
>链接中的镜头已不存在或不属于该剧集,请在左侧重新选择。</NAlert
>
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p> <p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p>
@@ -527,15 +556,17 @@ watch(
</div> </div>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p> <p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p> <p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<NAlert <StaleKeyframeNotice
v-if="keyframeItem?.primaryKeyframeStale" v-if="selectedKeyframeStale"
role="status" :key="shot.shotId"
type="info" :project-id="id"
:show-icon="false" :shot-id="shot.shotId"
class="mt-4 text-xs" :issues="selectedIssues"
> :inconsistent="
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。 keyframeItem?.primaryKeyframeStale === false &&
</NAlert> selectedIssues.some(issue => issue.code === 'stale_keyframe')
"
/>
<NAlert <NAlert
v-if="videoItem?.primaryVideoStale" v-if="videoItem?.primaryVideoStale"
role="status" role="status"
@@ -545,8 +576,8 @@ watch(
> >
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。 主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
</NAlert> </NAlert>
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert"> <ul v-if="remainingIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`"> <li v-for="(issue, index) in remainingIssues" :key="`${issue.code}-${index}`">
{{ issueLabel(issue.code) }}{{ issue.reason }} {{ issueLabel(issue.code) }}{{ issue.reason }}
</li> </li>
</ul> </ul>
@@ -558,7 +589,7 @@ watch(
:prompt-readiness="promptItem" :prompt-readiness="promptItem"
:keyframe-readiness="keyframeItem" :keyframe-readiness="keyframeItem"
:video-readiness="videoItem" :video-readiness="videoItem"
:disabled="blocked" :disabled="blocked || !!linkedShotMissing"
@changed="refreshProject" @changed="refreshProject"
/> />
</NScrollbar> </NScrollbar>
+63
View File
@@ -0,0 +1,63 @@
import type { ProductionIssue } from './types'
import type { ShotReferences } from '../storyboard/types'
/** 素材定位使用正式 Form ID;无当前关联时只保留主体引用,不猜测形态。 */
export interface ReferenceTarget {
subjectRef: string
subjectId?: string
formId?: string
label: string
}
/** 优先使用后端结构化变更引用;仅有过期标记时展示全部当前关联素材,不能称其全部已变更。 */
export function referenceTargets(references: ShotReferences | null, issues: ProductionIssue[]): ReferenceTarget[] {
const changed = [
...new Set(
issues.filter(issue => issue.code === 'stale_keyframe').flatMap(issue => issue.missingSubjects ?? [])
)
]
const available = references?.references ?? []
const refs = changed.length
? changed
: [
...new Set([
...available.map(item => item.subjectRef),
...(references?.missing ?? []).map(item => item.subjectRef)
])
]
return refs.flatMap(ref => {
const matches = available.filter(item => item.subjectRef === ref || item.subjectId === ref)
if (matches.length)
return matches.map(item => ({
subjectRef: item.subjectRef,
subjectId: item.subjectId,
formId: item.subjectFormId,
label: `${item.subjectName} · ${item.subjectFormName}${item.subjectRef}`
}))
const missing = references?.missing.find(item => item.subjectRef === ref || item.subjectId === ref)
return [
{
subjectRef: missing?.subjectRef ?? ref,
subjectId: missing?.subjectId,
label: `${missing?.subjectName ? `${missing.subjectName} · ` : ''}${ref}(当前形态待核对)`
}
]
})
}
/** 深链接保留来源镜头,图库据此读取实时状态而不是相信 URL 的过期标记。 */
export function referenceLocation(projectId: string, shotId: string, target: ReferenceTarget) {
return {
path: `/projects/${encodeURIComponent(projectId)}/subject-images`,
query: {
sourceShotId: shotId,
subjectRef: target.subjectRef,
...(target.formId ? { subjectFormId: target.formId } : {})
}
}
}
/** 从 URL 取单值查询参数,不接受多值数组或空白值。 */
export function queryText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton } from 'naive-ui'
import { referenceLocation, referenceTargets } from '../asset-links'
import { useShotReferences } from '../useShotReferences'
import type { ProductionIssue } from '../types'
/** 主首帧过期提示直接定位当前引用的形态,缺失关联时明确降级到主体检索。 */
const props = defineProps<{ projectId: string; shotId: string; issues: ProductionIssue[]; inconsistent?: boolean }>()
const references = useShotReferences(
computed(() => props.projectId),
computed(() => props.shotId)
)
const targets = computed(() => referenceTargets(references.data.value, props.issues))
const reasons = computed(() => [
...new Set(props.issues.filter(issue => issue.code === 'stale_keyframe').map(issue => issue.reason))
])
const precise = computed(() =>
props.issues.some(issue => issue.code === 'stale_keyframe' && issue.missingSubjects?.length)
)
</script>
<template>
<NAlert
role="alert"
type="warning"
:show-icon="false"
class="mt-4 text-xs"
:title="inconsistent ? '主首帧状态待核对' : '主首帧已过期'"
>
<p v-if="inconsistent">
首帧检查未标记过期但视频检查报告参考资产已变化两项后端检查结果不一致请先核对下列素材与身份母版不建议直接重复生图视频仍会按后端检查阻止提交
</p>
<p v-else>
当前参考资产与生成该首帧时不一致旧首帧需重建素材本身不一定过期请先核对关联素材确认有效后返回本镜头重新生成主首帧
</p>
<p v-for="reason in reasons" :key="reason" class="mt-2">{{ reason }}</p>
<p class="mt-3 font-medium">
{{ precise ? '定位后端标记的变更主体' : '核对本镜头关联素材(后端未返回具体变更项)' }}
</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<RouterLink
v-for="target in targets"
:key="target.formId || target.subjectRef"
:to="referenceLocation(projectId, shotId, target)"
class="text-button"
>{{ target.label }} </RouterLink
>
<RouterLink
v-if="!targets.length"
:to="{
path: `/projects/${encodeURIComponent(projectId)}/subject-images`,
query: { sourceShotId: shotId }
}"
class="text-button"
>前往图库核对 </RouterLink
>
</div>
<p v-if="references.loading.value" class="mt-2 text-muted">正在定位当前引用的形态</p>
<div v-if="references.error.value" class="mt-2 text-danger">
{{ references.error.value }} <NButton text size="small" @click="references.refresh">重试定位</NButton>
</div>
</NAlert>
</template>
@@ -0,0 +1,16 @@
import { computed, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { storyboardApi } from '../storyboard/api'
/** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */
export function useShotReferences(projectId: Ref<string>, shotId: Ref<string>) {
const key = computed(() => JSON.stringify([projectId.value, shotId.value]))
return usePolling(key, async (value, signal) => {
const [project, shot] = JSON.parse(value) as [string, string]
if (!project || !shot) return null
const result = await storyboardApi.references(shot, signal)
if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
return result
})
}
@@ -3,7 +3,7 @@ import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue' import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui' import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Search, RefreshCw, Download, ImagePlus, ArrowRight, AlertTriangle } from '@lucide/vue' import { Search, RefreshCw, Download, ImagePlus, ArrowRight, AlertTriangle } from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui' import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format' import { downloadText } from '../../lib/format'
@@ -15,9 +15,12 @@ import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue' import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
import FormPromptDialog from './components/FormPromptDialog.vue' import FormPromptDialog from './components/FormPromptDialog.vue'
import FormPromptBatch from './components/FormPromptBatch.vue' import FormPromptBatch from './components/FormPromptBatch.vue'
import { useKeyframeImpact } from './useKeyframeImpact'
import { queryText, referenceLocation, referenceTargets } from '../production/asset-links'
/** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */ /** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */
const route = useRoute() const route = useRoute()
const router = useRouter()
const { const {
id, id,
forms, forms,
@@ -40,6 +43,28 @@ const {
generateProject, generateProject,
generateStale generateStale
} = useSubjectImages() } = useSubjectImages()
const sourceShotId = computed(() => queryText(route.query.sourceShotId))
const targetFormId = computed(() => queryText(route.query.subjectFormId))
const targetForm = computed(() => forms.value.find(form => form.id === targetFormId.value))
const impact = useKeyframeImpact(id, sourceShotId)
const sourceShot = impact.source
const linkedReferences = computed(() => referenceTargets(impact.references.data.value, []))
const sourceLocation = computed(() =>
sourceShot.value
? {
path: `/projects/${encodeURIComponent(id.value)}/production`,
query: { episodeNo: String(sourceShot.value.episodeNo), shotId: sourceShot.value.shotId }
}
: null
)
const impactOptions = computed(() => {
const items = [...impact.stale.value]
if (sourceShot.value && !items.some(item => item.shotId === sourceShot.value?.shotId)) items.push(sourceShot.value)
return items.map(item => ({
value: item.shotId,
label: `${item.episodeNo} 集 · BEAT ${item.beatNo} / 镜头 ${item.shotNo}${item.inconsistent ? ' · 状态待核对' : item.primaryKeyframeStale ? ' · 首帧过期' : ' · 当前镜头'}`
}))
})
const toolsOpen = ref(false) const toolsOpen = ref(false)
/** 新批量回执到达时显示诊断,进入页面不自动遮挡图库。 */ /** 新批量回执到达时显示诊断,进入页面不自动遮挡图库。 */
watch( watch(
@@ -71,6 +96,7 @@ const completeCount = computed(() => forms.value.filter(form => primaryImage(for
const filtered = computed(() => const filtered = computed(() =>
forms.value.filter( forms.value.filter(
form => form =>
(!targetFormId.value || form.id === targetFormId.value) &&
(module.value === 'all' || form.subject.module === module.value) && (module.value === 'all' || form.subject.module === module.value) &&
(!onlyMissing.value || !primaryImage(form.images)) && (!onlyMissing.value || !primaryImage(form.images)) &&
(!onlyStale.value || isPrimaryIdentityStale(form)) && (!onlyStale.value || isPrimaryIdentityStale(form)) &&
@@ -80,9 +106,15 @@ const filtered = computed(() =>
) )
) )
watch( watch(
() => route.query.subjectRef, () => [route.query.subjectRef, route.query.subjectFormId, route.query.sourceShotId],
value => { () => {
search.value = typeof value === 'string' ? value : '' search.value = targetFormId.value ? '' : queryText(route.query.subjectRef)
module.value = 'all'
onlyMissing.value = false
onlyStale.value = false
galleryOpen.value = false
generateOpen.value = false
promptOpen.value = false
}, },
{ immediate: true } { immediate: true }
) )
@@ -117,6 +149,32 @@ function resetFilters() {
module.value = 'all' module.value = 'all'
onlyMissing.value = false onlyMissing.value = false
onlyStale.value = false onlyStale.value = false
void router.replace({
query: { ...route.query, subjectFormId: undefined, subjectRef: undefined, sourceShotId: undefined }
})
}
/** 切换受影响镜头时清空之前的素材过滤,避免相关素材被旧筛选隐藏。 */
function selectImpactShot(shotId: string | null) {
void router.replace({
query: { ...route.query, sourceShotId: shotId || undefined, subjectFormId: undefined, subjectRef: undefined }
})
}
/** 同时刷新素材和镜头影响状态,查询仍不启动生成。 */
async function refreshAssets() {
await Promise.all([query.refresh(), impact.query.refresh(), impact.references.refresh()])
}
/** 全局过期筛选不沿用深链接的单个素材限制。 */
async function showStaleForms() {
await router.replace({
query: { ...route.query, subjectFormId: undefined, subjectRef: undefined, sourceShotId: undefined }
})
search.value = ''
module.value = 'all'
onlyMissing.value = false
onlyStale.value = true
} }
</script> </script>
@@ -129,7 +187,11 @@ function resetFilters() {
>{{ staleForms.length }} 个形态主图过期</NButton >{{ staleForms.length }} 个形态主图过期</NButton
> >
<div class="toolbar-actions"> <div class="toolbar-actions">
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small" <NButton
:disabled="query.loading.value || impact.query.loading.value"
@click="refreshAssets"
text
size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库 </NButton ><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库 </NButton
><WorkspaceTools ><WorkspaceTools
v-model:open="toolsOpen" v-model:open="toolsOpen"
@@ -289,6 +351,122 @@ function resetFilters() {
</WorkspaceTools> </WorkspaceTools>
</div> </div>
</div> </div>
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
{{ staleForms.length }}
个形态主图与当前已锁定身份母版不一致请生成候选验图并切换主图再重建相关首帧
<NButton text size="small" @click="showStaleForms">筛选身份过期形态</NButton>
</NAlert>
<NAlert
v-if="impact.stale.value.length"
type="warning"
:show-icon="false"
class="mt-3"
title="下游主首帧需要处理"
data-keyframe-impact
>
{{ impact.stale.value.length }}
个镜头被后端报告主首帧过期此警告不表示形态图片本身失效请选择镜头核对当前素材确认过期后再重建首帧
<p v-if="impact.stale.value.some(item => item.inconsistent)" class="mt-2">
其中
{{ impact.stale.value.filter(item => item.inconsistent).length }}
个镜头的首帧与视频检查结果不一致不建议直接重复生图
</p>
</NAlert>
<NAlert v-if="impact.query.error.value" type="warning" :show-icon="false" class="mt-3"
>镜头影响检查失败暂时无法确认有无过期首帧{{ impact.query.error.value }}
<NButton text size="small" @click="impact.query.refresh">重试检查</NButton></NAlert
>
<div
v-if="impactOptions.length || sourceShotId"
class="asset-impact-context mt-3"
aria-label="镜头与素材定位"
>
<div class="flex flex-wrap items-center gap-3">
<NSelect
:value="sourceShot?.shotId ?? null"
:options="impactOptions"
placeholder="选择过期镜头,查看关联素材"
clearable
aria-label="选择关联镜头"
class="asset-impact-select"
@update:value="selectImpactShot"
/>
<RouterLink v-if="sourceLocation" :to="sourceLocation" class="text-button"
>返回第 {{ sourceShot?.episodeNo }} · 镜头 {{ sourceShot?.shotNo }} </RouterLink
>
<NButton v-if="targetFormId || sourceShotId" text size="small" @click="resetFilters"
>查看全部素材</NButton
>
</div>
<p
v-if="sourceShotId && !sourceShot && !impact.query.loading.value && !impact.query.error.value"
class="mt-3 text-danger"
>
来源镜头不存在或不属于当前项目未读取其素材
</p>
<p
v-if="
sourceShot &&
!sourceShot.primaryKeyframeStale &&
!impact.query.error.value &&
!impact.query.loading.value
"
class="mt-3 text-xs"
>
当前检查:{{
sourceShot.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧'
}}。请以后端最新就绪状态为准。
</p>
<p v-if="sourceShot?.inconsistent" class="mt-3 text-xs text-danger">
当前镜头:首帧检查未标记过期,但视频检查报告过期。请核对后端检查逻辑与身份母版,暂不建议重复生图。
</p>
<p
v-if="
targetForm &&
impact.references.data.value &&
!impact.references.error.value &&
!impact.relatedForms.value.has(targetForm.id)
"
class="mt-3 text-xs text-danger"
>
指定形态不在该镜头当前的有效引用列表中,关联可能已调整或主图缺失,请从下方关联素材重新核对。
</p>
<div
v-if="linkedReferences.length && !impact.references.error.value"
class="mt-3 flex flex-wrap items-center gap-3"
>
<span class="text-xs text-muted">本镜头当前关联素材:</span
><RouterLink
v-for="target in linkedReferences"
:key="target.formId || target.subjectRef"
:to="referenceLocation(id, sourceShotId, target)"
class="text-button"
>{{ target.label }} →</RouterLink
>
</div>
<p v-if="impact.references.loading.value" class="mt-2 text-xs text-muted">正在读取镜头关联素材…</p>
<p v-if="impact.references.error.value" class="mt-2 text-xs text-danger">
{{ impact.references.error.value }}
<NButton text size="small" @click="impact.references.refresh">重试读取素材</NButton>
</p>
</div>
<NAlert
v-if="targetFormId"
:type="targetForm ? 'info' : 'warning'"
:show-icon="false"
class="mt-3"
data-focused-material
>
<template v-if="targetForm"
>已定位:{{ targetForm.subject.name }} · {{ targetForm.name }}{{
targetForm.subject.ref
}})。可直接查看下方图片与记录、主体身份与母版。</template
>
<template v-else>{{
query.loading.value ? '正在定位素材…' : '指定形态不存在或已被移除,请从本镜头当前关联素材重新选择。'
}}</template>
<NButton text size="small" @click="resetFilters">清除定位</NButton>
</NAlert>
<div class="form-image-filter-region"> <div class="form-image-filter-region">
<div class="form-image-filters" role="group" aria-label="形态图片筛选"> <div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput <NInput
@@ -329,7 +507,11 @@ function resetFilters() {
v-for="form in filtered" v-for="form in filtered"
:key="form.id" :key="form.id"
class="panel form-image-card" class="panel form-image-card"
:class="{ 'ring-1 ring-danger': isPrimaryIdentityStale(form) }" :class="{
'ring-1 ring-danger': isPrimaryIdentityStale(form),
'form-image-card-focused': targetFormId === form.id
}"
:data-form-id="form.id"
> >
<AssetImage <AssetImage
:src="coverImage(form)?.imageUrl" :src="coverImage(form)?.imageUrl"
@@ -373,6 +555,29 @@ function resetFilters() {
当前主图未能匹配最新已锁定母版。请基于当前 Identity Anchor 当前主图未能匹配最新已锁定母版。请基于当前 Identity Anchor
重新生成候选图,并在确认后设置为新的主参考图。 重新生成候选图,并在确认后设置为新的主参考图。
</NAlert> </NAlert>
<NAlert
v-if="
sourceShot?.primaryKeyframeStale &&
impact.relatedForms.value.has(form.id) &&
!impact.references.error.value
"
type="warning"
:show-icon="false"
class="mt-3 text-xs leading-6"
>
第 {{ sourceShot.episodeNo }} 集 · BEAT {{ sourceShot.beatNo }} / 镜头
{{ sourceShot.shotNo }} 的待处理首帧关联此素材。
{{
sourceShot.inconsistent
? '后端首帧与视频检查结果不一致,请先核对,不要据此重复生图。'
: isPrimaryIdentityStale(form)
? '请先更新该形态主图,再重建首帧。'
: '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。'
}}
<RouterLink v-if="sourceLocation" :to="sourceLocation" class="text-button mt-2"
>返回该镜头处理 →</RouterLink
>
</NAlert>
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger"> <p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
{{ form.images[0].error }} {{ form.images[0].error }}
</p> </p>
@@ -419,6 +624,6 @@ function resetFilters() {
:project-id="id" :project-id="id"
:form="galleryForm" :form="galleryForm"
:disabled="blocked || !galleryForm" :disabled="blocked || !galleryForm"
@changed="query.refresh" @changed="refreshAssets"
/></WorkspacePage> /></WorkspacePage>
</template> </template>
@@ -0,0 +1,360 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NSelect } from 'naive-ui'
import { testProjectContext } from '../../testing/project-context'
import { projectContextKey } from '../projects/context'
import { formFixture, imageFixture } from './testing/fixtures'
import { referenceLocation, referenceTargets } from '../production/asset-links'
import StaleKeyframeNotice from '../production/components/StaleKeyframeNotice.vue'
import SubjectImagesPage from './SubjectImagesPage.vue'
import ProductionPage from '../production/ProductionPage.vue'
import { directionsResult, storyboardCheckpoint } from '../storyboard/testing/fixtures'
import type { ProductionIssue } from '../production/types'
import type { ShotReferences } from '../storyboard/types'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 镜头引用非默认形态,用于防止退化成仅按主体名搜索。 */
function fixture() {
const first = formFixture('capability-test')
const second = {
...first,
id: 'form-special',
name: '雨夜形态',
isDefault: false,
images: [imageFixture({ subjectFormId: 'form-special' })]
}
const other = {
...formFixture('capability-test'),
id: 'form-other',
subjectId: 'subject-other',
subject: { ...first.subject, id: 'subject-other', name: '路人', ref: '@CH0005' },
images: [imageFixture({ subjectFormId: 'form-other' })]
}
const references: ShotReferences = {
shotId: 'shot-target',
references: [
{
shotSubjectId: 'binding-1',
subjectId: first.subjectId,
subjectRef: first.subject.ref,
subjectName: first.subject.name,
module: 'character',
subjectFormId: second.id,
subjectFormName: second.name,
imageId: 'image-db-1',
imageUrl: '/storage/current.png'
},
{
shotSubjectId: 'binding-2',
subjectId: other.subjectId,
subjectRef: other.subject.ref,
subjectName: other.subject.name,
module: 'character',
subjectFormId: other.id,
subjectFormName: other.name,
imageId: 'other-image',
imageUrl: '/storage/other.png'
}
],
missing: []
}
const issues: ProductionIssue[] = [
{ code: 'stale_keyframe', reason: '参考资产已变化: @CH0001, @CH0005', missingSubjects: ['@CH0001', '@CH0005'] }
]
const keyframes = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 1,
items: [
{
shotId: 'shot-target',
shotNo: 1,
episodeNo: 2,
beatNo: 2,
status: 'ready',
primaryKeyframeStale: true,
primaryKeyframeId: 'keyframe-old',
issues: []
}
]
}
const videos = {
total: 1,
ready: 0,
skipped: 0,
blocked: 1,
items: [{ shotId: 'shot-target', shotNo: 1, status: 'blocked', issues }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
const data = path.endsWith('/subject-forms')
? [first, second, other]
: path.includes('/keyframes/readiness')
? keyframes
: path.includes('/videos/readiness')
? videos
: path.endsWith('/references')
? references
: []
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return { first, second, other, references, issues, keyframes, videos, fetcher }
}
/** 在真实内存路由中验证跨页 URL,不替换 RouterLink 行为。 */
async function gallery(query = '') {
const data = fixture()
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects/:projectId/subject-images', component: SubjectImagesPage },
{ path: '/projects/:projectId/production', component: { template: '<div />' } }
]
})
await router.push('/projects/capability-test/subject-images' + query)
wrapper = mount(SubjectImagesPage, {
attachTo: document.body,
global: { plugins: [router], provide: { [projectContextKey as symbol]: testProjectContext() } }
})
await flushPromises()
return { ...data, router }
}
describe('过期首帧到具体素材定位', () => {
it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => {
const { references } = fixture()
let finish!: (response: Response) => void
const nextReferences = {
...references,
shotId: 'shot-new',
references: [{ ...references.references[0]!, subjectFormId: 'form-new', subjectFormName: '新镜头形态' }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
String(url).includes('shot-target')
? new Promise(resolve => {
finish = resolve
})
: Promise.resolve(new Response(JSON.stringify({ data: nextReferences })))
)
vi.stubGlobal('fetch', fetcher)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
await router.push('/')
wrapper = mount(StaleKeyframeNotice, {
global: { plugins: [router] },
props: { projectId: 'capability-test', shotId: 'shot-target', issues: [] }
})
await flushPromises()
const signal = fetcher.mock.calls[0]?.[1]?.signal
await wrapper.setProps({ shotId: 'shot-new' })
await flushPromises()
expect(signal?.aborted).toBe(true)
finish(new Response(JSON.stringify({ data: references })))
await flushPromises()
expect(wrapper.text()).toContain('新镜头形态')
expect(wrapper.findAll('a').some(link => link.attributes('href')?.includes('form-special'))).toBe(false)
})
it('参考素材查询失败时保留引用级定位和重试,不静默选择默认形态', async () => {
const { issues } = fixture()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 500 })))
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
await router.push('/')
wrapper = mount(StaleKeyframeNotice, {
global: { plugins: [router] },
props: { projectId: 'capability-test', shotId: 'shot-target', issues, inconsistent: true }
})
await flushPromises()
expect(wrapper.text()).toContain('状态待核对')
expect(wrapper.text()).toContain('重试定位')
expect(wrapper.findAll('a').every(link => !link.attributes('href')?.includes('subjectFormId'))).toBe(true)
})
it('结构化变更引用映射到实际使用的非默认形态,并保留来源镜头', () => {
const { references, issues } = fixture()
const targets = referenceTargets(references, issues)
expect(targets.map(item => item.formId)).toEqual(['form-special', 'form-other'])
expect(referenceLocation('project/1', 'shot-target', targets[0]!)).toEqual({
path: '/projects/project%2F1/subject-images',
query: { sourceShotId: 'shot-target', subjectRef: '@CH0001', subjectFormId: 'form-special' }
})
})
it('没有当前关联时只回退到引用,不猜默认形态;没有变更列表时展示关联素材', () => {
const { references } = fixture()
const targets = referenceTargets(references, [
{ code: 'stale_keyframe', reason: '历史主体被移除', missingSubjects: ['@CH0999'] }
])
expect(targets[0]?.formId).toBeUndefined()
expect(targets[0]?.label).toContain('当前形态待核对')
expect(referenceTargets(references, [])).toHaveLength(2)
})
it('生产警告的引用可点击定位,点击不提交生图', async () => {
const { issues, fetcher } = fixture()
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
await router.push('/projects/capability-test/production')
wrapper = mount(StaleKeyframeNotice, {
global: { plugins: [router] },
props: { projectId: 'capability-test', shotId: 'shot-target', issues }
})
await flushPromises()
const links = wrapper.findAll('a')
expect(links).toHaveLength(2)
expect(links[0]?.text()).toContain('雨夜形态')
await links[0]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.subjectFormId).toBe('form-special')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('图库直接访问即显示下游警告,但不把有效素材误标成身份过期', async () => {
const { fetcher } = await gallery()
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('1 个镜头')
expect(wrapper!.text()).toContain('不表示形态图片本身失效')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
expect(wrapper!.text()).not.toContain('个形态主图与当前已锁定身份母版不一致')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/references'))).toBe(false)
const select = wrapper!
.findAllComponents(NSelect)
.find(item => item.attributes('aria-label') === '选择关联镜头')!
select.vm.$emit('update:value', 'shot-target')
await flushPromises()
expect(wrapper!.text()).toContain('待处理首帧关联此素材')
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/references'))).toHaveLength(1)
})
it('精准定位卡片、清空旧类型筛选、提供可恢复原镜头的返回链接', async () => {
const { router } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special&subjectRef=%40CH0001')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-special')
expect(wrapper!.get('.form-image-card').classes()).toContain('form-image-card-focused')
expect(wrapper!.get('[data-focused-material]').text()).toContain('雨夜形态')
const back = wrapper!.findAll('a').find(link => link.text().includes('返回第 2 集'))!
const url = new URL(back.attributes('href')!, 'https://local.test')
expect(url.searchParams.get('episodeNo')).toBe('2')
expect(url.searchParams.get('shotId')).toBe('shot-target')
const type = wrapper!.findAllComponents(NSelect).find(item => item.attributes('aria-label') === '筛选主体类型')!
type.vm.$emit('update:value', 'scene')
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
await router.push({ query: { sourceShotId: 'shot-target', subjectFormId: 'form-other' } })
await flushPromises()
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-other')
const clear = wrapper!.findAll('button').find(button => button.text() === '清除定位')!
await clear.trigger('click')
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
})
it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => {
const { first, router } = await gallery('?subjectFormId=form-other')
first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] }
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新图库')!
await refresh.trigger('click')
await flushPromises()
expect(wrapper!.text()).toContain('形态主图需要更新')
await wrapper!
.findAll('button')
.find(button => button.text() === '筛选身份过期形态')!
.trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.subjectFormId).toBeUndefined()
expect(wrapper!.findAll('.form-image-card').map(item => item.attributes('data-form-id'))).toEqual([
'form-db-1',
'form-special'
])
})
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('检查结果不一致')
expect(wrapper!.get('.form-image-card').text()).toContain('不要据此重复生图')
})
it('镜头修复后清除过期警告,保留明确的当前状态和返回入口', async () => {
const { keyframes, videos } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
videos.items[0]!.issues = []
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
expect(wrapper!.find('[data-keyframe-impact]').exists()).toBe(false)
expect(wrapper!.text()).toContain('未被标记为过期')
})
it('无效镜头和已移除形态不发跨项目请求,也不误定位其他素材', async () => {
const { fetcher } = await gallery('?sourceShotId=foreign-shot&subjectFormId=deleted-form')
expect(wrapper!.text()).toContain('来源镜头不存在或不属于当前项目')
expect(wrapper!.get('[data-focused-material]').text()).toContain('指定形态不存在')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
expect(fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot/references'))).toBe(false)
})
it('影响查询失败不能当成没有过期首帧,仍然显示已有图库', async () => {
const { fetcher } = await gallery()
const original = fetcher.getMockImplementation()!
fetcher.mockImplementation((url, init) =>
String(url).includes('/readiness')
? Promise.resolve(new Response('{}', { status: 500 }))
: original(url, init)
)
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
expect(wrapper!.text()).toContain('暂时无法确认有无过期首帧')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
})
it('素材返回生产页时按正式 Shot ID 选择,不误选同编号的另一个 Beat', async () => {
const context = testProjectContext()
context.data.value!.checkpoints = [storyboardCheckpoint()]
const data = directionsResult('capability-test', 2)
const target = data.beats[1]!.shots[0]!
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
return new Response(
JSON.stringify({
data: path.includes('/storyboard-directions')
? data
: path.includes('/readiness') || path.endsWith('/videos/status')
? { total: 0, items: [] }
: []
})
)
})
vi.stubGlobal('fetch', fetcher)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/projects/:projectId/production', component: ProductionPage }]
})
await router.push({
path: '/projects/capability-test/production',
query: { episodeNo: '2', shotId: target.shotId }
})
wrapper = mount(ProductionPage, {
global: { plugins: [router], provide: { [projectContextKey as symbol]: context } }
})
await flushPromises()
expect(wrapper!.get('.production-detail').text()).toContain(target.shotId)
expect(fetcher.mock.calls.some(([url]) => String(url).includes('episodeNo=2'))).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
})
@@ -0,0 +1,42 @@
import { computed, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { productionApi } from '../production/api'
import { useShotReferences } from '../production/useShotReferences'
/** 镜头影响检查与素材查询独立;检查失败不把素材误判为正常或阻断独立的素材操作。 */
export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<string>) {
const query = usePolling(projectId, async (id, signal) => {
if (!id) return null
const [keyframes, videos] = await Promise.all([
productionApi.keyframeReadiness(id, false, signal),
productionApi.videoReadiness(id, false, signal)
])
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
throw new Error('主首帧影响检查未返回镜头列表。')
const byShot = new Map(videos.items.map(item => [item.shotId, item]))
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
return {
...keyframes,
items: keyframes.items.map(item => {
const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
return {
...item,
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
inconsistent: item.primaryKeyframeStale === false && issues.length > 0
}
})
}
})
const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? [])
// 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。
const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value))
const references = useShotReferences(
projectId,
computed(() => source.value?.shotId ?? '')
)
const relatedForms = computed(
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
)
return { query, stale, source, references, relatedForms }
}
+4 -2
View File
@@ -856,7 +856,9 @@ describe('工作台页面交互', () => {
expect(wrapper!.text()).toContain('林知夏') expect(wrapper!.text()).toContain('林知夏')
expect(wrapper!.text()).toContain('主参考图') expect(wrapper!.text()).toContain('主参考图')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([ expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/projects/page-test-project/subject-forms', 'GET'] ['/api/projects/page-test-project/subject-forms', 'GET'],
['/api/projects/page-test-project/keyframes/readiness?force=false', 'GET'],
['/api/projects/page-test-project/videos/readiness?force=false', 'GET']
]) ])
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态') await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态')
expect(wrapper!.text()).toContain('没有匹配的形态') expect(wrapper!.text()).toContain('没有匹配的形态')
@@ -883,7 +885,7 @@ describe('工作台页面交互', () => {
'/storage/subjects/project/form/image.png' '/storage/subjects/project/form/image.png'
) )
expect(document.querySelector('.image-history')).toBeNull() expect(document.querySelector('.image-history')).toBeNull()
expect(fetcher).toHaveBeenCalledOnce() expect(fetcher).toHaveBeenCalledTimes(3)
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click() document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
await flushPromises() await flushPromises()
button('查看图片与记录').click() button('查看图片与记录').click()