refactor: 统一主体与镜头目录并优化筛选工具栏布局

This commit is contained in:
GouJ
2026-09-01 17:21:13 +08:00
parent a35f5fe82d
commit 70ab4ba48f
12 changed files with 496 additions and 251 deletions
+1 -1
View File
@@ -314,7 +314,7 @@ function exportResult() {
</div>
</section>
<div class="mt-7">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="result-toolbar">
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果"
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
>{{ option.label
+52 -42
View File
@@ -13,7 +13,7 @@ import {
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui'
import { DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { issueLabel, productionStatusLabel } from './model'
import { useProduction } from './useProduction'
@@ -344,55 +344,65 @@ watch(
<span class="text-xs text-muted">{{ shots.length }} 个正式镜头</span>
</div>
<div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list" aria-label="本集生产镜头">
<NScrollbar class="panel-scroll" content-class="production-shot-list-content" x-scrollable>
<NButton
<nav class="production-shot-list directory-panel" aria-label="本集生产镜头">
<div class="directory-heading">
<h3>
生产镜头 <span>{{ shots.length }} </span>
</h3>
</div>
<NScrollbar
class="panel-scroll directory-scroll"
content-class="directory-list-content production-shot-list-content"
x-scrollable
>
<DirectoryItem
v-for="item in shots"
:key="item.shotId"
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
:active="item.shotId === shot.shotId"
@click="selectedShot = item.shotId"
class="production-shot-link"
:class="{ selected: item.shotId === shot.shotId }"
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
><span
v-if="
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
?.primaryKeyframeStale
"
class="mt-2 flex items-center gap-2 text-[10px]"
>
<CircleAlert :size="12" />主首帧已过期 </span
><span
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
?.primaryVideoStale
"
class="mt-2 flex items-center gap-2 text-[10px]"
>
<CircleAlert :size="12" />主视频已过期 </span
><span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
<CheckCircle2
>{{ item.title }}
<template #eyebrow>BEAT {{ item.beatNo }} / SHOT {{ item.shotNo }}</template>
<template #meta
><span
v-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'skipped'
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
?.primaryKeyframeStale
"
:size="12"
/>
<CircleAlert
class="directory-status"
>
<CircleAlert :size="12" />主首帧已过期 </span
><span
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'blocked'
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
?.primaryVideoStale
"
:size="12"
/>
{{
productionStatusLabel(
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)
?.status || 'not_started'
)
}}
</span></NButton
class="directory-status"
>
<CircleAlert :size="12" />主视频已过期 </span
><span v-else class="directory-status">
<CheckCircle2
v-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
?.status === 'skipped'
"
:size="12"
/>
<CircleAlert
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
?.status === 'blocked'
"
:size="12"
/>
{{
productionStatusLabel(
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)
?.status || 'not_started'
)
}}
</span></template
></DirectoryItem
>
</NScrollbar>
</nav>
+22 -18
View File
@@ -15,7 +15,7 @@ import {
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
import { EmptyState } from '../../components/ui'
import { DirectoryItem, EmptyState } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import GenerationReceipt from './components/GenerationReceipt.vue'
@@ -310,31 +310,35 @@ watch(
正在读取正式分镜数据
</div>
<div v-else-if="shot" class="panel storyboard-workspace">
<nav class="shot-list" aria-label="本集镜头">
<NScrollbar class="panel-scroll" content-class="shot-list-content">
<p class="px-4 pb-3 pt-5 text-[10px] text-muted">{{ shots.length }} 个镜头 · Beat 排列</p>
<nav class="shot-list directory-panel" aria-label="本集镜头">
<div class="directory-heading">
<h3>
镜头目录 <span>{{ shots.length }} </span>
</h3>
<span> Beat 排列</span>
</div>
<NScrollbar
class="panel-scroll directory-scroll"
content-class="directory-list-content shot-list-content"
x-scrollable
>
<template v-for="(item, index) in shots" :key="item.shotId"
><h4
v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo"
class="px-4 pb-2 pt-4 font-mono text-[10px] text-muted"
class="directory-group-heading"
>
BEAT {{ String(item.beatNo).padStart(2, '0') }}
</h4>
<NButton
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
<DirectoryItem
:active="shot.shotId === item.shotId"
@click="selectedShot = item.shotId"
class="shot-link"
:class="{ selected: shot.shotId === item.shotId }"
><span class="font-mono text-[10px] text-muted">{{
String(item.shotNo).padStart(2, '0')
}}</span
><span class="min-w-0"
><span class="block truncate text-xs font-medium">{{ item.title }}</span
><span class="mt-1 block text-[10px] text-muted"
>{{ item.direction ? '设计已保存' : '待设计' }} ·
{{ item.visualState ? '状态已保存' : '待状态' }}</span
></span
></NButton
>{{ item.title }}
<template #eyebrow>BEAT {{ item.beatNo }} / SHOT {{ item.shotNo }}</template>
<template #meta>
<span class="directory-status">{{ item.direction ? '设计已保存' : '待设计' }}</span>
<span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span>
</template></DirectoryItem
></template
>
</NScrollbar>
@@ -14,8 +14,8 @@ import {
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { UserCheck } from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { RefreshCw, UserCheck } from '@lucide/vue'
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { castingStatusLabel, currentAnchor } from './model'
@@ -336,62 +336,69 @@ watch(
></NScrollbar
>
<div v-if="subjects.length" class="identity-workspace">
<aside class="panel min-w-0 p-4">
<h3 class="text-sm font-medium">
主体目录 <span class="ml-2 text-xs text-muted">{{ subjects.length }}</span>
</h3>
<NInput
placeholder="搜索主体或引用"
aria-label="搜索主体身份"
:input-props="{ 'aria-label': '搜索主体身份' }"
class="mt-4"
v-model:value="search"
></NInput>
<NSelect
aria-label="身份主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
class="mt-3"
></NSelect>
<NScrollbar
class="identity-subject-list panel-scroll mt-4"
content-class="identity-subject-list-content"
>
<aside class="panel directory-panel identity-directory">
<div class="directory-heading">
<h3>
主体目录 <span>{{ filtered.length }} / {{ subjects.length }}</span>
</h3>
<NButton
:disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory"
quaternary
circle
size="small"
aria-label="刷新主体目录"
title="刷新主体目录"
><RefreshCw :size="14"
/></NButton>
</div>
<div class="directory-filters">
<NInput
placeholder="搜索主体或引用"
aria-label="搜索主体身份"
:input-props="{ 'aria-label': '搜索主体身份' }"
v-model:value="search"
clearable
></NInput>
<NSelect
aria-label="身份主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
></NSelect>
</div>
<NScrollbar
class="identity-subject-list panel-scroll directory-scroll"
content-class="directory-list-content identity-subject-list-content"
x-scrollable
>
<DirectoryItem
v-for="item in filtered"
:key="item.id"
:aria-pressed="selectedId === item.id"
:active="selectedId === item.id"
@click="select(item.id)"
class="identity-subject-item"
:class="{ selected: selectedId === item.id }"
><span class="block font-medium">{{ item.name }}</span
><span class="mt-1 block text-[11px] text-muted"
>{{ item.ref }} · {{ item.forms.length }} 个形态<span
>{{ item.name }}
<template #eyebrow>{{ item.ref }}</template>
<template #meta>
<span>{{ item.forms.length }} 个形态</span
><span
v-if="casting?.items.find(row => row.subjectId === item.id)"
class="directory-status"
>
·
{{
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
}}</span
></span
></NButton
>
</template></DirectoryItem
>
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p>
</NScrollbar>
<NButton
:disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory"
text
size="small"
class="mt-4"
>
刷新主体目录
</NButton>
</aside>
<NScrollbar class="identity-detail panel-scroll min-w-0">
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
@@ -211,29 +211,31 @@ function resetFilters() {
</ul>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准</p>
</section>
<div class="my-6 flex flex-wrap items-center gap-4">
<label class="search-field">
<Search :size="14" />
<div class="form-image-filter-region">
<div class="form-image-filters" role="group" aria-label="形态图片筛选">
<NInput
placeholder="搜索主体、引用或形态"
:input-props="{ 'aria-label': '搜索形态图片' }"
v-model:value="search"
clearable
><template #prefix><Search :size="14" /></template
></NInput>
</label>
<NSelect
aria-label="筛选主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
class="select-control"
></NSelect>
<NCheckbox v-model:checked="onlyMissing" class="flex items-center gap-2 text-xs">仅看缺少主图 </NCheckbox>
<NCheckbox v-model:checked="onlyStale" class="flex items-center gap-2 text-xs">仅看身份过期 </NCheckbox>
<span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
<NSelect
aria-label="筛选主体类型"
v-model:value="module"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('人物'), value: 'character' },
{ label: String('场景'), value: 'scene' },
{ label: String('道具'), value: 'prop' }
]"
></NSelect>
<div class="filter-toggles">
<NCheckbox v-model:checked="onlyMissing">仅看缺少主图</NCheckbox>
<NCheckbox v-model:checked="onlyStale">仅看身份过期</NCheckbox>
</div>
<span class="filter-result-count">{{ filtered.length }} 个形态</span>
</div>
</div>
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片
+49 -1
View File
@@ -177,6 +177,10 @@ describe('内容级滚动下的生产反馈', () => {
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
const items = directory.findAll('.production-shot-link')
expect(items).toHaveLength(2)
expect(directory.get('.directory-heading').text()).toContain('生产镜头')
expect(directory.get('.directory-heading').element.closest('.n-scrollbar-container')).toBeNull()
expect(items[0]!.find('.directory-item-title').exists()).toBe(true)
expect(items[0]!.find('.directory-item-meta .directory-status').exists()).toBe(true)
await items[1]!.trigger('click')
await flushPromises()
expect(items[1]!.attributes('aria-current')).toBe('true')
@@ -232,9 +236,12 @@ describe('视觉风格与主体身份工作区', () => {
})
)
await mountAssets('identity', '?subjectId=subject-db-1')
const sidebar = wrapper!.get('.identity-directory')
expect(sidebar.get('.directory-filters').element.closest('.n-scrollbar-container')).toBeNull()
expect(sidebar.get('.identity-subject-list').findAll('.directory-item')).toHaveLength(2)
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
await flushPromises()
button('刷新主体目录').click()
await wrapper!.get('[aria-label="刷新主体目录"]').trigger('click')
await flushPromises()
expect(wrapper!.findAll('.identity-subject-item')[1]!.attributes('aria-pressed')).toBe('true')
})
@@ -631,6 +638,47 @@ describe('视觉风格与主体身份工作区', () => {
})
describe('工作台页面交互', () => {
it('形态筛选搜索、类型和勾选组合仍只过滤本地显示,控件与结果数处于同一工具栏', async () => {
const person = formFixture()
const scene = {
...formFixture(),
id: 'scene-form',
images: [],
subject: { ...person.subject, id: 'scene-subject', ref: '@SC0001', name: '旧书店', module: 'scene' }
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse([person, scene]))
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
const filters = wrapper!.get('.form-image-filters')
expect(filters.get('.filter-result-count').text()).toBe('2 个形态')
expect(filters.find('input[aria-label="搜索形态图片"]').exists()).toBe(true)
expect(filters.findAll('.filter-toggles [role="checkbox"]')).toHaveLength(2)
await filters.findAll('.filter-toggles [role="checkbox"]')[0]!.trigger('click')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').text()).toContain('旧书店')
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'character')
await flushPromises()
expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'all')
await filters.get('input[aria-label="搜索形态图片"]').setValue('旧书店')
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
await filters.findAll('.filter-toggles [role="checkbox"]')[1]!.trigger('click')
expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('拆解导出按钮紧邻标签栏且保持空快照禁用状态', () => {
wrapper = mount(BreakdownPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
const toolbar = wrapper.get('.result-toolbar')
expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true)
const exportButton = toolbar.get<HTMLButtonElement>(':scope > button')
expect(exportButton.text()).toBe('导出 JSON')
expect(exportButton.element.disabled).toBe(true)
})
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
const fetcher = vi
.fn<typeof fetch>()