diff --git a/src/admin.css b/src/admin.css index 79154a9..7db73fb 100644 --- a/src/admin.css +++ b/src/admin.css @@ -875,6 +875,18 @@ body { .form-image-toolbar.has-impact-picker { grid-template-columns: minmax(220px, 340px) minmax(0, 1fr); } +.form-image-toolbar.has-impact-picker.has-impact-actions { + grid-template-columns: minmax(260px, 420px) minmax(0, 1fr); +} +.asset-impact-picker { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.asset-impact-picker .asset-impact-select { + flex: 1 1 0; +} .form-image-toolbar > * { min-width: 0; } @@ -904,7 +916,8 @@ body { white-space: nowrap; } @container (max-width: 1200px) { - .form-image-toolbar.has-impact-picker { + .form-image-toolbar.has-impact-picker, + .form-image-toolbar.has-impact-picker.has-impact-actions { grid-template-columns: minmax(0, 1fr); } } @@ -1482,8 +1495,12 @@ body { } /* 镜头影响和素材定位使用固定底色;高亮只表示当前定位,不代表素材失效。 */ .asset-impact-context { + display: flex; + flex-direction: column; + gap: 10px; padding: 14px 16px; background: var(--app-subtle); + margin-bottom: 12px; } .asset-impact-select { min-width: 0; diff --git a/src/components/ui/flatTheme.test.ts b/src/components/ui/flatTheme.test.ts index 0ef00c0..0858b47 100644 --- a/src/components/ui/flatTheme.test.ts +++ b/src/components/ui/flatTheme.test.ts @@ -152,8 +152,10 @@ describe('全站平面主题', () => { /\.form-image-toolbar\.has-impact-picker\s*\{[^}]*grid-template-columns:\s*minmax\(220px, 340px\) minmax\(0, 1fr\);/ ) expect(css).toMatch( - /@container \(max-width: 1200px\)\s*\{\s*\.form-image-toolbar\.has-impact-picker\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/ + /@container \(max-width: 1200px\)\s*\{\s*\.form-image-toolbar\.has-impact-picker,\s*\.form-image-toolbar\.has-impact-picker\.has-impact-actions\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/ ) + expect(css).toMatch(/\.asset-impact-picker\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*8px;/) + expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*gap:\s*10px;[^}]*margin-bottom:\s*12px;/) }) it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => { diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue index 70a3d8e..d81cb17 100644 --- a/src/features/subject-images/SubjectImagesPage.vue +++ b/src/features/subject-images/SubjectImagesPage.vue @@ -6,7 +6,7 @@ import WorkspaceTools from '../../components/ui/WorkspaceTools.vue' import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui' import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { Search, RefreshCw, Download, ImagePlus, ArrowRight, AlertTriangle } from '@lucide/vue' +import { Search, RefreshCw, Download, ImagePlus, ArrowLeft, ArrowRight, LayoutGrid, AlertTriangle } from '@lucide/vue' import { AssetImage, EmptyState, StatusBadge } from '../../components/ui' import { downloadText } from '../../lib/format' import ConfirmAction from '../workflows/ConfirmAction.vue' @@ -51,6 +51,35 @@ const targetForm = computed(() => forms.value.find(form => form.id === targetFor const impact = useKeyframeImpact(id, sourceShotId) const sourceShot = impact.source const linkedReferences = computed(() => referenceTargets(impact.references.data.value, [])) +/** 定位说明仅在有实际状态、引用或错误时出现,避免两项导航操作单独撑起空白横条。 */ +const sourceMissing = computed( + () => sourceShotId.value && !sourceShot.value && !impact.query.loading.value && !impact.query.error.value +) +const sourceCurrent = computed( + () => + sourceShot.value && + !sourceShot.value.primaryKeyframeStale && + !impact.query.loading.value && + !impact.query.error.value +) +const targetUnlinked = computed( + () => + targetForm.value && + impact.references.data.value && + !impact.references.error.value && + !impact.relatedForms.value.has(targetForm.value.id) +) +const showImpactDetails = computed( + () => + sourceShotId.value && + (sourceMissing.value || + sourceCurrent.value || + sourceShot.value?.inconsistent || + targetUnlinked.value || + linkedReferences.value.length || + impact.references.loading.value || + impact.references.error.value) +) const sourceLocation = computed(() => sourceShot.value ? { @@ -380,17 +409,53 @@ async function showStaleForms() { 重试检查
-
- +
+ class="asset-impact-picker" + role="group" + aria-label="镜头定位与导航" + > + + + + + 返回第 {{ sourceShot?.episodeNo }} 集 · 镜头 {{ sourceShot?.shotNo }} + + + + + +
-
-
- 返回第 {{ sourceShot?.episodeNo }} 集 · 镜头 {{ sourceShot?.shotNo }} → - 查看全部素材 -
-

- 来源镜头不存在或不属于当前项目,未读取其素材。 -

-

+

+

来源镜头不存在或不属于当前项目,未读取其素材。

+

当前检查:{{ - sourceShot.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧' + sourceShot?.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧' }}。请以后端最新就绪状态为准。

-

+

当前镜头:首帧检查未标记过期,但视频检查报告过期。请核对后端检查逻辑与身份母版,暂不建议重复生图。

-

+

指定形态不在该镜头当前的有效引用列表中,关联可能已调整或主图缺失,请从下方关联素材重新核对。

本镜头当前关联素材:{{ target.label }} →
-

正在读取镜头关联素材…

-

+

正在读取镜头关联素材…

+

{{ impact.references.error.value }} 重试读取素材

diff --git a/src/features/subject-images/asset-impact.test.ts b/src/features/subject-images/asset-impact.test.ts index d051134..b7b9eac 100644 --- a/src/features/subject-images/asset-impact.test.ts +++ b/src/features/subject-images/asset-impact.test.ts @@ -231,7 +231,7 @@ describe('过期首帧到具体素材定位', () => { expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/references'))).toBe(false) // 镜头选择和图库筛选共享上方工具栏;未选择镜头时不留下空的关联说明面板。 const toolbar = wrapper!.get('.form-image-toolbar') - const picker = toolbar.get('.asset-impact-select').element + const picker = toolbar.get('.asset-impact-picker').element expect(toolbar.classes()).toContain('has-impact-picker') expect(picker.nextElementSibling).toBe(toolbar.get('.form-image-filters').element) expect(toolbar.find('input[aria-label="搜索形态图片"]').exists()).toBe(true) @@ -248,6 +248,27 @@ describe('过期首帧到具体素材定位', () => { expect(wrapper!.text()).toContain('待处理首帧关联此素材') expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/references'))).toHaveLength(1) }) + it('镜头导航与筛选共行,无关联说明时不留横条,查看全部素材清除定位和筛选', async () => { + const { references, router, fetcher } = await gallery() + references.references = [] + await router.push({ query: { sourceShotId: 'shot-target' } }) + await flushPromises() + const picker = wrapper!.get('.asset-impact-picker') + const back = picker.get('a[aria-label="返回第 2 集 · 镜头 1"]') + expect(back.classes()).toContain('icon-button') + expect(new URL(back.attributes('href')!, 'https://local.test').searchParams.get('shotId')).toBe('shot-target') + expect(picker.get('[aria-label="查看全部素材"]').classes()).toContain('icon-button') + expect(wrapper!.find('.asset-impact-context').exists()).toBe(false) + await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('不存在的素材') + expect(wrapper!.findAll('.form-image-card')).toHaveLength(0) + await picker.get('[aria-label="查看全部素材"]').trigger('click') + await flushPromises() + expect(router.currentRoute.value.query.sourceShotId).toBeUndefined() + expect(wrapper!.findAll('.form-image-card')).toHaveLength(3) + expect(wrapper!.find('.asset-impact-picker a').exists()).toBe(false) + expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true) + }) + it('精准定位卡片、清空旧类型筛选、提供可恢复原镜头的返回链接', async () => { const { router } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special&subjectRef=%40CH0001') expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)