style: 按 Beat 分组整理镜头目录并去除重复编号
This commit is contained in:
@@ -16,12 +16,13 @@ import {
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from '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 ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
import GenerationReceipt from './components/GenerationReceipt.vue'
|
||||
import ShotDesign from './components/ShotDesign.vue'
|
||||
import ShotTools from './components/ShotTools.vue'
|
||||
import BeatShotDirectory from './components/BeatShotDirectory.vue'
|
||||
import { useStoryboard } from './useStoryboard'
|
||||
|
||||
const detailTab = ref('design')
|
||||
@@ -353,25 +354,17 @@ watch(
|
||||
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="directory-group-heading"
|
||||
>
|
||||
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
||||
</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.visualState ? '状态已保存' : '待状态' }}</span>
|
||||
</template></DirectoryItem
|
||||
></template
|
||||
<BeatShotDirectory
|
||||
:shots="shots"
|
||||
:active-id="shot.shotId"
|
||||
item-class="shot-link"
|
||||
@select="selectedShot = $event"
|
||||
>
|
||||
<template #meta="{ shot: item }">
|
||||
<span class="directory-status">{{ item.direction ? '设计已保存' : '待设计' }}</span>
|
||||
<span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span>
|
||||
</template>
|
||||
</BeatShotDirectory>
|
||||
</NScrollbar>
|
||||
</nav>
|
||||
<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>
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user