refactor: 统一主体与镜头目录并优化筛选工具栏布局
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
正在读取形态和已有图片…
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user