style: 按 Beat 分组整理镜头目录并去除重复编号

This commit is contained in:
GouJ
2026-09-01 19:40:40 +08:00
parent 9e3bff7997
commit 799f16d336
8 changed files with 182 additions and 35 deletions
+1
View File
@@ -96,6 +96,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。 - 主体身份、分镜设计和镜头生产共用 `DirectoryItem`:编号、完整标题和状态分层展示,桌面目录为 240–280px,窄屏改为可横向滚动的条目。目录标题与筛选固定,只有列表滚动。拆解结果的导出按钮与标签同排右对齐;形态图片筛选按内容区宽度自动排成一行或两列网格。
- 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动,不影响表单与弹窗的圆角。 - 侧栏导航与主体/剧集/镜头目录采用直角满行背景,移除列表两侧的内缩,悬停和选中态都铺满所在列。条目内部保留文字间距;窄屏横向目录仍保持独立滚动,不影响表单与弹窗的圆角。
- 主体目录以母版缩略图搭配名称、编号与状态,默认保留行背景和细分隔线。人物优先复用选角接口的母版;缺少地址的条目进入可视区域后才读取正式身份图库,不为整份目录轮询。无母版或加载失败显示人物/场景/道具图标,候选与形态图不会替代母版;点击缩略图只切换主体,刷新目录可重试读取。 - 主体目录以母版缩略图搭配名称、编号与状态,默认保留行背景和细分隔线。人物优先复用选角接口的母版;缺少地址的条目进入可视区域后才读取正式身份图库,不为整份目录轮询。无母版或加载失败显示人物/场景/道具图标,候选与形态图不会替代母版;点击缩略图只切换主体,刷新目录可重试读取。
- 分镜设计与镜头生产共用两级镜头目录:分组头展示 `BEAT 01` 和镜头数,组内仅显示“镜头 01”、标题及状态。分组头在本组内吸顶;窄屏横向模式才在条目上补回 Beat 归属。点击仍使用正式 Shot ID,不会混淆不同 Beat 的同号镜头。
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。 - 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class` - 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 - 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
+36 -4
View File
@@ -461,15 +461,40 @@ body {
padding: 8px 0 16px; padding: 8px 0 16px;
} }
.directory-group-heading { .directory-group-heading {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0; flex-shrink: 0;
padding: 14px 14px 2px; padding: 10px 14px;
border-block: 1px solid var(--app-border);
background: var(--app-subtle);
font: font:
11px/1.6 ui-monospace, 600 11px/1.6 ui-monospace,
monospace; monospace;
color: var(--app-ink);
}
.directory-group-count {
font-weight: 400;
color: var(--app-muted); color: var(--app-muted);
} }
.directory-group-heading:first-child { .beat-directory-group {
padding-top: 2px; min-width: 0;
}
.beat-directory-items {
display: flex;
flex-direction: column;
}
.directory-mobile-beat {
display: none;
}
.shot-list-content,
.production-shot-list-content {
gap: 12px;
padding: 0 0 12px;
} }
.n-button.directory-item { .n-button.directory-item {
display: flex; display: flex;
@@ -1216,6 +1241,13 @@ body {
.directory-group-heading { .directory-group-heading {
display: none; display: none;
} }
.beat-directory-group,
.beat-directory-items {
display: contents;
}
.directory-mobile-beat {
display: inline;
}
.identity-workspace { .identity-workspace {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: 250px minmax(0, 1fr); grid-template-rows: 250px minmax(0, 1fr);
+1 -1
View File
@@ -81,7 +81,7 @@ describe('统一目录条目', () => {
) )
) )
} }
expect(css).toMatch(/\.directory-group-heading\s*\{[^}]*padding:\s*14px 14px 2px/) expect(css).toMatch(/\.directory-group-heading\s*\{[^}]*padding:\s*10px 14px/)
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/) expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
}) })
+10 -11
View File
@@ -14,7 +14,8 @@ import {
} from 'naive-ui' } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, 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 { DirectoryItem, EmptyState, StatusBadge } from '../../components/ui' import { EmptyState, StatusBadge } from '../../components/ui'
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
import ConfirmAction from '../workflows/ConfirmAction.vue' import ConfirmAction from '../workflows/ConfirmAction.vue'
import { issueLabel, productionStatusLabel } from './model' import { issueLabel, productionStatusLabel } from './model'
import { useProduction } from './useProduction' import { useProduction } from './useProduction'
@@ -412,15 +413,13 @@ watch(
content-class="directory-list-content production-shot-list-content" content-class="directory-list-content production-shot-list-content"
x-scrollable x-scrollable
> >
<DirectoryItem <BeatShotDirectory
v-for="item in shots" :shots="shots"
:key="item.shotId" :active-id="shot.shotId"
:active="item.shotId === shot.shotId" @select="selectedShot = $event"
@click="selectedShot = item.shotId" item-class="production-shot-link"
class="production-shot-link" >
>{{ item.title }} <template #meta="{ shot: item }"
<template #eyebrow>BEAT {{ item.beatNo }} / SHOT {{ item.shotNo }}</template>
<template #meta
><span ><span
v-if=" v-if="
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId) query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
@@ -459,7 +458,7 @@ watch(
) )
}} }}
</span></template </span></template
></DirectoryItem ></BeatShotDirectory
> >
</NScrollbar> </NScrollbar>
</nav> </nav>
+10 -17
View File
@@ -16,12 +16,13 @@ import {
} from 'naive-ui' } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue' import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
import { DirectoryItem, EmptyState } from '../../components/ui' import { EmptyState } from '../../components/ui'
import { downloadText } from '../../lib/format' import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue' import ConfirmAction from '../workflows/ConfirmAction.vue'
import GenerationReceipt from './components/GenerationReceipt.vue' import GenerationReceipt from './components/GenerationReceipt.vue'
import ShotDesign from './components/ShotDesign.vue' import ShotDesign from './components/ShotDesign.vue'
import ShotTools from './components/ShotTools.vue' import ShotTools from './components/ShotTools.vue'
import BeatShotDirectory from './components/BeatShotDirectory.vue'
import { useStoryboard } from './useStoryboard' import { useStoryboard } from './useStoryboard'
const detailTab = ref('design') const detailTab = ref('design')
@@ -353,25 +354,17 @@ watch(
content-class="directory-list-content shot-list-content" content-class="directory-list-content shot-list-content"
x-scrollable x-scrollable
> >
<template v-for="(item, index) in shots" :key="item.shotId" <BeatShotDirectory
><h4 :shots="shots"
v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo" :active-id="shot.shotId"
class="directory-group-heading" item-class="shot-link"
@select="selectedShot = $event"
> >
BEAT {{ String(item.beatNo).padStart(2, '0') }} <template #meta="{ shot: item }">
</h4>
<DirectoryItem
:active="shot.shotId === item.shotId"
@click="selectedShot = item.shotId"
class="shot-link"
>{{ 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.direction ? '设计已保存' : '待设计' }}</span>
<span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span> <span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span>
</template></DirectoryItem </template>
></template </BeatShotDirectory>
>
</NScrollbar> </NScrollbar>
</nav> </nav>
<article class="storyboard-detail min-w-0"> <article class="storyboard-detail min-w-0">
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, useId } from 'vue'
import { DirectoryItem } from '../../../components/ui'
import type { DesignedShot } from '../types'
/** 分镜设计与镜头生产共用的两级目录;选择始终使用正式 Shot ID。 */
const props = defineProps<{ shots: DesignedShot[]; activeId: string; itemClass: string }>()
const emit = defineEmits<{ select: [shotId: string] }>()
const directoryId = useId()
const groups = computed(() => {
const entries = new Map<number, DesignedShot[]>()
for (const shot of props.shots) {
const items = entries.get(shot.beatNo) ?? []
items.push(shot)
entries.set(shot.beatNo, items)
}
return [...entries]
.toSorted(([a], [b]) => a - b)
.map(([beatNo, shots]) => ({
beatNo,
shots: shots.toSorted((a, b) => a.shotNo - b.shotNo)
}))
})
</script>
<template>
<div
v-for="group in groups"
:key="group.beatNo"
class="beat-directory-group"
role="group"
:aria-labelledby="`${directoryId}-beat-${group.beatNo}`"
>
<h4 :id="`${directoryId}-beat-${group.beatNo}`" class="directory-group-heading">
<span>BEAT {{ String(group.beatNo).padStart(2, '0') }}</span>
<span class="directory-group-count">{{ group.shots.length }} </span>
</h4>
<div class="beat-directory-items">
<DirectoryItem
v-for="shot in group.shots"
:key="shot.shotId"
:active="activeId === shot.shotId"
:class="itemClass"
:aria-label="`BEAT ${group.beatNo} · 镜头 ${shot.shotNo} · ${shot.title}`"
@click="emit('select', shot.shotId)"
>
{{ shot.title }}
<template #eyebrow>
<!-- 窄屏目录变为横向条目时补回归属桌面不重复显示分组编号 -->
<span class="directory-mobile-beat">BEAT {{ String(group.beatNo).padStart(2, '0') }} / </span>
<span class="directory-shot-number">镜头 {{ String(shot.shotNo).padStart(2, '0') }}</span>
</template>
<template #meta><slot name="meta" :shot="shot" /></template>
</DirectoryItem>
</div>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import BeatShotDirectory from './components/BeatShotDirectory.vue'
import { designedShot } from './testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => wrapper?.unmount())
describe('Beat 两级镜头目录', () => {
it('按 Beat 和镜号排序,分组只出现一次并显示镜数,不改变原数组', () => {
const shots = [
{ ...designedShot('b2-s1'), beatNo: 2 },
{ ...designedShot('b1-s2'), shotNo: 2 },
designedShot('b1-s1')
]
wrapper = mount(BeatShotDirectory, {
props: { shots, activeId: 'b1-s1', itemClass: 'shot-link' },
slots: { meta: '<span>设计已保存</span>' }
})
const groups = wrapper.findAll('.beat-directory-group')
expect(groups).toHaveLength(2)
expect(groups[0]!.get('h4').text()).toContain('BEAT 01')
expect(groups[0]!.get('.directory-group-count').text()).toBe('2 镜')
expect(groups[1]!.get('h4').text()).toContain('BEAT 02')
expect(groups[1]!.get('.directory-group-count').text()).toBe('1 镜')
expect(wrapper.findAll('.directory-shot-number').map(item => item.text())).toEqual([
'镜头 01',
'镜头 02',
'镜头 01'
])
expect(wrapper.findAll('.directory-item-meta').every(item => item.text() === '设计已保存')).toBe(true)
expect(groups[0]!.attributes('aria-labelledby')).toBe(groups[0]!.get('h4').attributes('id'))
expect(shots.map(shot => shot.shotId)).toEqual(['b2-s1', 'b1-s2', 'b1-s1'])
})
it('不同 Beat 的同号镜头按正式 ID 选择,数据刷新后选中项保持不变', async () => {
const shots = [designedShot('b1-s1'), { ...designedShot('b2-s1'), beatNo: 2 }]
wrapper = mount(BeatShotDirectory, { props: { shots, activeId: 'b1-s1', itemClass: 'production-shot-link' } })
await wrapper.findAll('.production-shot-link')[1]!.trigger('click')
expect(wrapper.emitted('select')).toEqual([['b2-s1']])
await wrapper.setProps({
activeId: 'b2-s1',
shots: [...shots, { ...designedShot('b2-s2'), beatNo: 2, shotNo: 2 }]
})
expect(wrapper.get('.selected').attributes('aria-label')).toBe('BEAT 2 · 镜头 1 · 来信')
expect(wrapper.findAll('.selected')).toHaveLength(1)
expect(wrapper.findAll('.directory-group-count')[1]!.text()).toBe('2 镜')
})
it('组内吸顶与移动端归属提示分别保护,不在桌面重复展示 Beat', () => {
// happy-dom 不计算吸顶位置,保护 CSS 边界,实际滚动仍需浏览器验收。
const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(
/\.directory-group-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*background:\s*var\(--app-subtle\)/
)
expect(css).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*none/)
const mobile = css.slice(css.lastIndexOf('@media (max-width: 760px)'))
expect(mobile).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*inline/)
expect(mobile).toMatch(/\.beat-directory-group,\s*\.beat-directory-items\s*\{\s*display:\s*contents/)
})
})
+3
View File
@@ -177,6 +177,9 @@ describe('内容级滚动下的生产反馈', () => {
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element) expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
const items = directory.findAll('.production-shot-link') const items = directory.findAll('.production-shot-link')
expect(items).toHaveLength(2) expect(items).toHaveLength(2)
expect(directory.findAll('.beat-directory-group')).toHaveLength(2)
expect(directory.findAll('.directory-group-count').map(item => item.text())).toEqual(['1 镜', '1 镜'])
expect(items[0]!.get('.directory-shot-number').text()).toBe('镜头 01')
expect(directory.get('.directory-heading').text()).toContain('生产镜头') expect(directory.get('.directory-heading').text()).toContain('生产镜头')
expect(directory.get('.directory-heading').element.closest('.n-scrollbar-container')).toBeNull() 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-title').exists()).toBe(true)