style: 精简主题提示并优化分镜工具栏与分隔线

This commit is contained in:
GouJ
2026-09-02 10:03:10 +08:00
parent 4229426339
commit a125164103
10 changed files with 205 additions and 98 deletions
@@ -1,6 +1,8 @@
import { computed, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NSelect } from 'naive-ui'
import { readFileSync } from 'node:fs'
import BreakdownPage from './BreakdownPage.vue'
import { projectContextKey, type useProjectData } from '../projects/context'
import type { ProjectDetail } from '../projects/types'
@@ -138,6 +140,63 @@ async function selectTab(label: string) {
}
describe('拆解页内容滚动', () => {
it('分镜选择与计数、入口同排,默认显示实际剧集,切换后内容和统计同步', async () => {
const record = checkpoint()
const result = record.state.breakdownResult!
const first = result.storyboardPlans![0]!
const second = { ...first, episodeNo: 2, episodeTitle: '第二集', beats: first.beats.slice(0, 2) }
result.storyboardPlans!.push(second)
result.storyboardEpisodeShots!.push({
episodeNo: 2,
episodePlan: second,
beatShots: result.storyboardEpisodeShots![0]!.beatShots.slice(0, 2)
})
const provided = mountPage([record])
await selectTab('分镜')
const toolbar = wrapper!.get('.breakdown-storyboard-toolbar')
expect(toolbar.get('.breakdown-episode-picker > span').text()).toBe('选择剧集')
const select = toolbar.getComponent(NSelect)
expect(select.props('value')).toBe(1)
expect(toolbar.text()).toContain('长剧集')
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('25 个节拍 · 25 个镜头')
expect(toolbar.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
'/projects/breakdown-scroll-test/storyboard'
)
expect(wrapper!.find('.breakdown-storyboard > .surface-inset').exists()).toBe(false)
select.vm.$emit('update:value', 2)
await flushPromises()
expect(select.props('value')).toBe(2)
expect(toolbar.text()).toContain('第二集')
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头')
expect(wrapper!.findAll('.beat-section')).toHaveLength(2)
// 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。
provided.data.value!.checkpoints = [checkpoint()]
await flushPromises()
expect(select.props('value')).toBe(1)
expect(wrapper!.findAll('.beat-section')).toHaveLength(25)
})
it('分镜为空时保留空提示与进入设计入口,不显示空选择器', async () => {
const record = checkpoint()
record.state.breakdownResult!.storyboardPlans = []
record.state.breakdownResult!.storyboardEpisodeShots = []
mountPage([record])
await selectTab('分镜')
expect(wrapper!.get('.breakdown-storyboard').text()).toContain('分镜规划尚未生成')
expect(wrapper!.find('.breakdown-episode-picker').exists()).toBe(false)
expect(wrapper!.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
'/projects/breakdown-scroll-test/storyboard'
)
})
it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => {
// 仅保护样式契约,DOM 环境不提供真实布局坐标。
const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(/\.breakdown-storyboard\s*\{[^}]*padding:\s*16px 20px 20px;/)
expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/)
})
it.each([
['人物', 'character 主体 30'],
['场景', 'scene 主体 30'],
+7 -6
View File
@@ -410,12 +410,13 @@ function exportResult() {
/></div
></template>
<div v-if="tab === 'storyboard'">
<div class="mb-4 flex justify-end">
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
>进入分镜设计<ArrowRight :size="14"
/></RouterLink>
</div>
<StoryboardList :plans="plans" :episodes="shots" />
<StoryboardList :plans="plans" :episodes="shots">
<template #actions>
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
>进入分镜设计<ArrowRight :size="14"
/></RouterLink>
</template>
</StoryboardList>
</div>
<div v-if="tab === 'tasks'" class="p-5">
<NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll">
@@ -9,6 +9,7 @@ import type { EpisodePlan, EpisodeShots } from '../types'
const props = defineProps<{ plans: EpisodePlan[]; episodes: EpisodeShots[] }>()
const selected = ref<number>()
const plans = computed(() => (props.plans.length ? props.plans : props.episodes.map(item => item.episodePlan)))
/** 选择器与正文统一使用最终规划编号;初次读取或当前剧集被移除时回退到第一集。 */
const plan = computed(() => plans.value.find(item => item.episodeNo === selected.value) ?? plans.value[0])
const episode = computed(() => props.episodes.find(item => item.episodeNo === plan.value?.episodeNo))
const purposeLabels: Record<string, string> = {
@@ -26,81 +27,82 @@ const purposeLabels: Record<string, string> = {
</script>
<template>
<div v-if="plan" class="p-5 lg:p-7">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-3 text-sm"
>选择剧集<NSelect
<div class="breakdown-storyboard">
<div v-if="plan || $slots.actions" class="breakdown-storyboard-toolbar">
<label v-if="plan" class="breakdown-episode-picker"
><span>选择剧集</span
><NSelect
aria-label="选择分镜剧集"
v-model:value="selected"
:options="[
...(selected === undefined
? [
{
label: String(' 第 ' + plan.episodeNo + ' 集 · ' + plan.episodeTitle),
value: undefined
}
]
: []),
...plans.map(item => ({
:value="plan.episodeNo"
@update:value="selected = $event"
:options="
plans.map(item => ({
label: String(' 第 ' + item.episodeNo + ' 集 · ' + item.episodeTitle),
value: item.episodeNo
}))
]"
class="select-control"
></NSelect></label
><span class="text-xs text-muted"
>{{ plan.beats.length }} 个节拍 ·
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
>
</div>
<div class="mb-7 surface-inset p-4">
<p class="font-medium">{{ plan.storyGoal }}</p>
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
</div>
<NAlert v-if="!episode" type="info" :show-icon="false" class="mb-5">
本集已完成节拍规划镜头尚未生成请等待进度更新或在确认中断后使用补齐缺失镜头
</NAlert>
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
<div class="beat-heading">
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
<NTag size="small" :bordered="false">{{ purposeLabels[beat.purpose] || beat.purpose }}</NTag>
</div>
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
<div class="space-y-3">
<article
v-for="shot in episode?.beatShots.find(item => item.beatNo === beat.beatNo)?.shots ?? []"
:key="shot.shotNo"
class="shot-row"
"
></NSelect
></label>
<div class="breakdown-storyboard-summary">
<span v-if="plan" class="breakdown-storyboard-count"
>{{ plan.beats.length }} 个节拍 ·
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
>
<div class="shot-label">
镜头 {{ String(shot.shotNo).padStart(2, '0')
}}<span class="mt-2 flex items-center gap-1 text-[11px]"
><Clock3 :size="11" />{{ shot.durationSeconds }}s</span
>
</div>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-medium">{{ shot.title }}</h4>
<p class="mt-2 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-2 text-xs leading-6 text-muted">视觉重点 · {{ shot.visualFocus }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<code v-for="ref in shot.subjectRefs" :key="ref" class="subject-ref">{{ ref }}</code>
</div>
<p v-if="shot.subjectBindings?.length" class="mt-2 text-xs leading-6 text-muted">
{{
shot.subjectBindings
.map(binding => binding.subjectRef + ' · ' + binding.formName)
.join(' / ')
}}
</p>
</div>
</article>
<slot name="actions" />
</div>
</section>
</div>
<template v-if="plan">
<div v-if="plan.storyGoal || plan.emotionalArc" class="mb-7 surface-inset p-4">
<p v-if="plan.storyGoal" class="font-medium">{{ plan.storyGoal }}</p>
<p v-if="plan.emotionalArc" class="text-sm leading-6 text-muted" :class="{ 'mt-2': !!plan.storyGoal }">
{{ plan.emotionalArc }}
</p>
</div>
<NAlert v-if="!episode" type="info" :show-icon="false" class="mb-5">
本集已完成节拍规划镜头尚未生成请等待进度更新或在确认中断后使用补齐缺失镜头
</NAlert>
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
<div class="beat-heading">
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
<NTag size="small" :bordered="false">{{ purposeLabels[beat.purpose] || beat.purpose }}</NTag>
</div>
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
<div class="space-y-3">
<article
v-for="shot in episode?.beatShots.find(item => item.beatNo === beat.beatNo)?.shots ?? []"
:key="shot.shotNo"
class="shot-row"
>
<div class="shot-label">
镜头 {{ String(shot.shotNo).padStart(2, '0')
}}<span class="mt-2 flex items-center gap-1 text-[11px]"
><Clock3 :size="11" />{{ shot.durationSeconds }}s</span
>
</div>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-medium">{{ shot.title }}</h4>
<p class="mt-2 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-2 text-xs leading-6 text-muted">视觉重点 · {{ shot.visualFocus }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<code v-for="ref in shot.subjectRefs" :key="ref" class="subject-ref">{{ ref }}</code>
</div>
<p v-if="shot.subjectBindings?.length" class="mt-2 text-xs leading-6 text-muted">
{{
shot.subjectBindings
.map(binding => binding.subjectRef + ' · ' + binding.formName)
.join(' / ')
}}
</p>
</div>
</article>
</div>
</section>
</template>
<EmptyState
v-else
title="分镜规划尚未生成"
description="完成主体整理后,工作流会先规划每集的剧情节拍,再逐集生成镜头。"
/>
</div>
<EmptyState
v-else
title="分镜规划尚未生成"
description="完成主体整理后,工作流会先规划每集的剧情节拍,再逐集生成镜头。"
/>
</template>