feat: 优化剧集连续阅读布局与目录滚动联动

This commit is contained in:
GouJ
2026-09-01 17:02:39 +08:00
parent 3fe1b3f5c3
commit a35f5fe82d
7 changed files with 540 additions and 146 deletions
+146 -38
View File
@@ -307,19 +307,158 @@ body {
grid-template-columns: minmax(0, 1fr) 207px;
}
.content-with-history > main,
.content-with-history > aside,
.script-page {
.content-with-history > aside {
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
}
.script-workspace {
min-height: 0;
/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
.script-section .n-tabs-tab__label {
display: inline-flex;
align-items: center;
gap: 8px;
}
.episode-list {
max-height: none;
.script-section .content-with-history.history-hidden {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.episode-reader {
display: grid;
grid-template-columns: clamp(190px, 22%, 240px) minmax(0, 1fr);
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
container-type: inline-size;
}
.reader-directory {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
background: var(--app-subtle);
border-right: 1px solid var(--app-border);
}
.reader-directory-heading {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
gap: 8px;
padding: 22px 20px 14px;
font-size: 13px;
}
.reader-directory-heading h3 {
font-weight: 600;
}
.reader-directory-heading span {
font-size: 11px;
color: var(--app-muted);
white-space: nowrap;
}
.reader-directory-scroll {
flex: 1;
}
.reader-directory-content {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 10px 18px;
}
.n-button.reader-episode-link {
flex-shrink: 0;
height: auto;
min-height: 64px;
padding: 12px 14px;
border-radius: 5px;
color: var(--app-muted);
white-space: normal;
text-align: left;
}
.n-button.reader-episode-link .n-button__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
width: 100%;
min-width: 0;
}
.reader-episode-label {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
}
.reader-episode-title {
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.n-button.reader-episode-link.active {
background: var(--app-surface);
color: var(--app-ink);
box-shadow: inset 3px 0 var(--app-ink);
}
.reader-page {
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--app-surface);
}
.reader-scroll .n-scrollbar-container:focus-visible {
outline: 2px solid var(--app-ink);
outline-offset: -3px;
}
.reader-content {
max-width: 900px;
margin-inline: auto;
padding: 32px clamp(24px, 5cqw, 64px);
}
.reader-chapter + .reader-chapter {
margin-top: 48px;
border-top: 1px solid var(--app-border);
padding-top: 36px;
}
.reader-chapter:last-child {
/* 末集较短时仍可定位到阅读线,不必把倒数第二集误认为当前集。 */
min-height: max(160px, calc(var(--reader-height) - 64px));
}
.reader-chapter-heading {
margin-bottom: 24px;
}
.reader-chapter-heading p {
color: var(--app-muted);
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
}
.reader-chapter-heading h2 {
font-size: clamp(18px, 2cqw, 24px);
font-weight: 600;
line-height: 1.6;
overflow-wrap: anywhere;
}
@media (max-width: 1000px) {
.episode-reader {
grid-template-columns: 180px minmax(0, 1fr);
}
}
@media (max-width: 600px) {
.episode-reader {
grid-template-columns: 112px minmax(0, 1fr);
}
.reader-directory-heading {
padding: 16px 12px 12px;
flex-wrap: wrap;
}
.reader-directory-content {
padding-inline: 6px;
}
.n-button.reader-episode-link {
padding: 12px 10px;
}
.reader-content {
padding-inline: 18px;
}
}
/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
.panel-scroll.n-scrollbar {
@@ -335,16 +474,13 @@ body {
.production-detail,
.storyboard-detail,
.shot-list,
.episode-list,
.content-with-history > main {
min-width: 0;
min-height: 0;
}
.script-page,
.history-panel,
.production-shot-list,
.shot-list,
.episode-list {
.shot-list {
padding: 0;
}
.production-shot-list-content {
@@ -353,25 +489,17 @@ body {
.shot-list-content {
padding: 0 8px 20px;
}
.episode-list-content {
padding: 0 7px 20px;
}
.script-page-content {
padding: 36px 40px;
}
.history-content {
padding: 23px 21px;
}
.production-shot-list-content,
.shot-list-content,
.episode-list-content,
.identity-subject-list-content {
display: flex;
flex-direction: column;
}
.n-button.production-shot-link,
.n-button.shot-link,
.n-button.episode-link,
.n-button.identity-subject-item {
display: flex;
width: 100%;
@@ -480,7 +608,6 @@ body {
.n-button:disabled {
opacity: 1;
}
.n-button.episode-link,
.n-button.shot-link,
.n-button.production-shot-link,
.n-button.identity-subject-item,
@@ -489,7 +616,6 @@ body {
height: auto;
white-space: normal;
}
.n-button.episode-link .n-button__content,
.n-button.shot-link .n-button__content {
width: 100%;
justify-content: flex-start;
@@ -507,7 +633,6 @@ body {
width: 100%;
justify-content: space-between;
}
.n-button.episode-link .n-button__border,
.n-button.shot-link .n-button__border,
.n-button.production-shot-link .n-button__border,
.n-button.identity-subject-item .n-button__border {
@@ -651,23 +776,6 @@ body {
overflow: hidden;
min-height: 0;
}
.script-workspace {
grid-template-columns: 105px minmax(0, 1fr);
}
.episode-list {
display: block;
padding: 0;
border-right: 1px solid var(--app-border);
}
.episode-link {
width: 100%;
}
.script-page {
padding: 0;
}
.script-page-content {
padding: 18px;
}
.project-search {
width: 100%;
}
+11 -43
View File
@@ -2,7 +2,7 @@
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NScrollbar, NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
import { computed, ref } from 'vue'
import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
import { ArrowRight, Download, Check, Circle, History } from '@lucide/vue'
import { EmptyState } from '../../components/ui'
import { useProjectContext } from '../projects/context'
import { projectsApi } from '../projects/api'
@@ -11,15 +11,15 @@ import { getOperation, runOperation } from '../workflows/operations'
import HistoryPanel from '../workflows/HistoryPanel.vue'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { downloadText, formatDate } from '../../lib/format'
import EpisodeReader from './EpisodeReader.vue'
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
const { project, checkpoints, refresh, error } = useProjectContext()
const selected = ref<number>()
const tab = ref('episodes')
const showHistory = ref(false)
const dramaRecords = computed(() => workflowCheckpoints(checkpoints.value, 'create-drama'))
const state = computed(() => dramaRecords.value.at(-1)?.state)
const episodes = computed(() => project.value?.episodes ?? [])
const episode = computed(() => episodes.value.find(item => item.episode === selected.value) ?? episodes.value[0])
const total = computed(() => state.value?.episodeCount)
const operation = computed(() => getOperation(project.value!.id))
const complete = computed(() => project.value?.status === 'completed')
@@ -123,6 +123,9 @@ function exportScript() {
><NTab name="review">审核记录</NTab></NTabs
>
<div class="flex items-center gap-3">
<NButton text size="small" :aria-expanded="showHistory" @click="showHistory = !showHistory"
><History :size="14" />{{ showHistory ? '收起执行记录' : '执行记录' }}</NButton
>
<NButton :disabled="!episodes.length" @click="exportScript" text size="small"
><Download :size="14" />导出剧本</NButton
><RouterLink
@@ -133,49 +136,14 @@ function exportScript() {
/></RouterLink>
</div>
</div>
<div class="content-with-history panel mt-4">
<div class="content-with-history panel mt-4" :class="{ 'history-hidden': !showHistory }">
<main class="min-w-0">
<div v-if="tab === 'episodes'" class="h-full">
<div v-if="episode" class="script-workspace">
<aside class="episode-list">
<NScrollbar class="panel-scroll" content-class="episode-list-content">
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">
剧集目录
</p>
<NButton
v-for="item in episodes"
:key="item.episode"
:aria-pressed="episode.episode === item.episode"
@click="selected = item.episode"
class="episode-link"
:class="{ active: episode.episode === item.episode }"
><span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
><span class="truncate">{{ item.title }}</span></NButton
>
</NScrollbar>
</aside>
<article class="script-page">
<NScrollbar class="panel-scroll" content-class="script-page-content">
<p class="eyebrow"> {{ String(episode.episode).padStart(2, '0') }} </p>
<h2 class="mt-3 text-2xl font-semibold">{{ episode.title }}</h2>
<p v-if="episode.summary" class="script-summary">{{ episode.summary }}</p>
<div class="script-body">{{ episode.content }}</div>
<dl v-if="episode.conflict || episode.hook" class="script-notes">
<template v-if="episode.conflict"
><dt>核心冲突</dt>
<dd>{{ episode.conflict }}</dd></template
><template v-if="episode.hook"
><dt>结尾钩子</dt>
<dd>{{ episode.hook }}</dd></template
>
</dl>
</NScrollbar>
</article>
</div>
<div v-show="tab === 'episodes'" class="h-full">
<EpisodeReader v-if="episodes.length" :episodes="episodes" />
<EmptyState
v-else
title="剧本正在酝酿"
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可展开执行记录查看进度。"
/>
</div>
<NScrollbar v-if="tab !== 'episodes'" class="panel-scroll"
@@ -274,7 +242,7 @@ function exportScript() {
</div>
</NScrollbar>
</main>
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
<HistoryPanel v-if="showHistory" :checkpoints="checkpoints" workflow="create-drama" />
</div></div
></WorkspacePage>
</template>
@@ -0,0 +1,184 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EpisodeReader from './EpisodeReader.vue'
import type { Episode } from '../projects/types'
const episodes: Episode[] = [
{ episode: 1, title: '来信', summary: '第一集摘要', content: '第一场:旧书店。', conflict: '信件失踪' },
{ episode: 2, title: '雨夜', content: '<script>不要执行模型内容</script>', hook: '门外有人' },
{ episode: 3, title: '重逢', content: '' }
]
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** happy-dom 不计算排版;注入明确的容器几何,但保持真实 NScrollbar 和原生 scroll 事件路径。 */
async function mountReader() {
wrapper = mount(EpisodeReader, { props: { episodes } })
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
const directory = wrapper.get<HTMLElement>('.reader-directory-scroll .n-scrollbar-container').element
const tops: Record<number, number> = { 1: 32, 2: 700, 3: 1480 }
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(directory, 'clientHeight', { configurable: true, value: 164 })
const originalRect = HTMLElement.prototype.getBoundingClientRect
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this === reader) return new DOMRect(0, 100, 600, reader.clientHeight)
if (this === directory) return new DOMRect(0, 50, 200, directory.clientHeight)
if (this.matches('.reader-chapter')) {
return new DOMRect(0, 100 + tops[Number(this.dataset.episode)]! - reader.scrollTop, 600, 600)
}
if (this.matches('.reader-episode-link')) {
return new DOMRect(0, 58 + (Number(this.dataset.episode) - 1) * 72 - directory.scrollTop, 180, 64)
}
return originalRect.call(this)
})
const readerScroll = vi.spyOn(reader, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
if (typeof options !== 'object') return
// 模拟浏览器边界:末集最小高度及上下留白让其标题能抵达阅读线。
reader.scrollTop = Math.max(0, Math.min(options.top ?? 0, Math.max(...Object.values(tops)) - 32))
reader.dispatchEvent(new Event('scroll'))
})
const directoryScroll = vi.spyOn(directory, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
if (typeof options !== 'object') return
directory.scrollTop = Math.max(0, options.top ?? 0)
directory.dispatchEvent(new Event('scroll'))
})
await wrapper.setProps({ episodes: [...episodes] })
return { reader, directory, tops, readerScroll, directoryScroll }
}
/** 获取目录中的唯一当前集,避免只验证按钮类名而漏掉无障碍状态。 */
function currentEpisode() {
const current = wrapper!.findAll('.reader-episode-link[aria-current="location"]')
expect(current).toHaveLength(1)
return Number(current[0]!.attributes('data-episode'))
}
describe('连续剧本阅读器', () => {
it('按编号连续渲染全部剧集、元数据和空正文,标题不截断,模型内容只按文本显示', () => {
const reversed = episodes.toReversed()
wrapper = mount(EpisodeReader, { props: { episodes: reversed } })
expect(wrapper.findAll('.reader-chapter').map(item => Number(item.attributes('data-episode')))).toEqual([
1, 2, 3
])
expect(reversed[0]?.episode).toBe(3)
expect(wrapper.findAll('.reader-chapter .script-body')).toHaveLength(2)
expect(wrapper.get('.script-summary').text()).toBe('第一集摘要')
expect(wrapper.text()).toContain('信件失踪')
expect(wrapper.text()).toContain('门外有人')
expect(wrapper.text()).toContain('本集正文尚未写入')
expect(wrapper.text()).toContain('<script>不要执行模型内容</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.findAll('.n-scrollbar-container')).toHaveLength(2)
expect(wrapper.get('.reader-scroll [role="region"]').attributes('tabindex')).toBe('0')
expect(wrapper.get('.reader-episode-link').attributes('aria-controls')).toBe(
wrapper.get('.reader-chapter').attributes('id')
)
})
it('点击目录通过 NScrollbar 定位首集、中间集和短末集,不滚动 window', async () => {
const { reader, readerScroll } = await mountReader()
const pageScroll = vi.spyOn(window, 'scrollTo')
for (const [episode, top] of [
[2, 668],
[3, 1448],
[1, 0]
] as const) {
await wrapper!.get(`.reader-episode-link[data-episode="${episode}"]`).trigger('click')
expect(readerScroll).toHaveBeenLastCalledWith(expect.objectContaining({ top, behavior: 'auto' }))
expect(reader.scrollTop).toBe(top)
expect(currentEpisode()).toBe(episode)
}
expect(pageScroll).not.toHaveBeenCalled()
})
it('正文双向滚动同步当前集,目录项离开可见区时自动露出', async () => {
const { reader, directory, directoryScroll } = await mountReader()
reader.scrollTop = 1450
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(3)
expect(directoryScroll).toHaveBeenCalled()
expect(directory.scrollTop).toBeGreaterThan(0)
reader.scrollTop = 0
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(1)
expect(directory.scrollTop).toBe(0)
})
it('同集内阅读不反复挪动目录,也不因目录滚动而改变正文', async () => {
const { reader, directory, directoryScroll, readerScroll } = await mountReader()
directory.scrollTop = 60
directory.dispatchEvent(new Event('scroll'))
reader.scrollTop = 120
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(1)
expect(directory.scrollTop).toBe(60)
expect(directoryScroll).not.toHaveBeenCalled()
expect(readerScroll).not.toHaveBeenCalled()
})
it('后台刷新与追加剧集不销毁正文或跳回首集,新增集可以定位', async () => {
const { reader, tops } = await mountReader()
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
const chapter = wrapper!.get('.reader-chapter[data-episode="2"]').element
tops[4] = 2200
await wrapper!.setProps({
episodes: [...episodes.map(item => ({ ...item })), { episode: 4, title: '回家', content: '结局' }]
})
expect(reader.scrollTop).toBe(668)
expect(currentEpisode()).toBe(2)
expect(wrapper!.get('.reader-chapter[data-episode="2"]').element).toBe(chapter)
await wrapper!.get('.reader-episode-link[data-episode="4"]').trigger('click')
expect(reader.scrollTop).toBe(2168)
expect(currentEpisode()).toBe(4)
})
it('内容尺寸和可见性变化重新测量,卸载释放尺寸监听', async () => {
const observers: TestObserver[] = []
/** 仅模拟尺寸通知;断言仍走真实组件挂载与卸载生命周期。 */
class TestObserver {
targets = new Set<Element>()
constructor(readonly callback: ResizeObserverCallback) {
observers.push(this)
}
observe(target: Element) {
this.targets.add(target)
}
unobserve(target: Element) {
this.targets.delete(target)
}
disconnect = vi.fn<() => void>(() => this.targets.clear())
notify() {
this.callback([], this as unknown as ResizeObserver)
}
}
vi.stubGlobal('ResizeObserver', TestObserver)
const { reader, tops } = await mountReader()
const observer = observers.find(item => item.targets.has(wrapper!.get('.reader-content').element))!
expect(observer).toBeDefined()
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 0 })
observer.notify()
await nextTick()
expect(currentEpisode()).toBe(2)
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 500 })
tops[2] = 500
observer.notify()
await nextTick()
expect(wrapper!.get('.reader-content').attributes('style')).toContain('--reader-height: 500px')
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
expect(reader.scrollTop).toBe(468)
wrapper!.unmount()
wrapper = undefined
expect(observer.disconnect).toHaveBeenCalledOnce()
})
})
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { NButton, NScrollbar, type ScrollbarInst } from 'naive-ui'
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import type { Episode } from '../projects/types'
/** 连续剧本阅读器:目录与正文独立滚动,导航仅影响本阅读器。 */
const props = defineProps<{ episodes: Episode[] }>()
const orderedEpisodes = computed(() => props.episodes.toSorted((a, b) => a.episode - b.episode))
const active = ref(orderedEpisodes.value[0]?.episode)
const directory = ref<ScrollbarInst>()
const reader = ref<ScrollbarInst>()
const directoryContent = ref<HTMLElement>()
const readerContent = ref<HTMLElement>()
const viewportHeight = ref(400)
const readerId = useId()
const readingInset = 32
let viewport: HTMLElement | null = null
let directoryViewport: HTMLElement | null = null
let observer: ResizeObserver | undefined
/** 缓存章节在正文滚动容器中的位置,滚动事件不逐集触发布局测量。 */
let positions: { episode: number; top: number }[] = []
/** Naive 的公开实例仅提供滚动方法;从自身内容定位容器,不访问私有实例或全局 DOM。 */
function scrollContainer(content: HTMLElement | undefined) {
return content?.closest<HTMLElement>('.n-scrollbar-container') ?? null
}
/** 只在当前目录项离开可见区时调整目录,避免带动页面或打断手动浏览目录。 */
function revealActive() {
const item = directoryContent.value?.querySelector<HTMLElement>(`[data-episode="${active.value}"]`)
if (!item || !directoryViewport?.clientHeight) return
const bounds = directoryViewport.getBoundingClientRect()
const rect = item.getBoundingClientRect()
if (rect.top < bounds.top + 8) {
directory.value?.scrollTo({ top: directoryViewport.scrollTop + rect.top - bounds.top - 8 })
} else if (rect.bottom > bounds.bottom - 8) {
directory.value?.scrollTo({ top: directoryViewport.scrollTop + rect.bottom - bounds.bottom + 8 })
}
}
/** 以正文顶部的阅读线确定当前集;末集保留足够高度,也能定位到顶部。 */
function syncActive() {
if (!viewport?.clientHeight || !positions.length) return
const readingLine = Math.max(0, viewport.scrollTop) + readingInset + 2
let current = positions[0]!.episode
for (const item of positions) {
if (item.top > readingLine) break
current = item.episode
}
active.value = current
}
/** 内容或面板尺寸变化时重测;隐藏标签不覆盖缓存和已有阅读位置。 */
function measure() {
if (!viewport?.clientHeight || !readerContent.value) return
viewportHeight.value = viewport.clientHeight
const origin = viewport.getBoundingClientRect().top - viewport.scrollTop
positions = [...readerContent.value.querySelectorAll<HTMLElement>('[data-episode]')].map(item => ({
episode: Number(item.dataset.episode),
top: item.getBoundingClientRect().top - origin
}))
syncActive()
}
/** 点击目录使用组件公开方法即时定位,避免长距离平滑滚动经过多集时高亮闪烁。 */
function selectEpisode(episode: number) {
measure()
const position = positions.find(item => item.episode === episode)
if (!position) return
reader.value?.scrollTo({ top: Math.max(0, position.top - readingInset), behavior: 'auto' })
active.value = episode
revealActive()
}
watch(active, revealActive, { flush: 'post' })
watch(
orderedEpisodes,
items => {
if (!items.some(item => item.episode === active.value)) active.value = items[0]?.episode
measure()
},
{ flush: 'post', deep: true }
)
onMounted(() => {
viewport = scrollContainer(readerContent.value)
directoryViewport = scrollContainer(directoryContent.value)
// 让键盘直接聚焦真正的滚动区域,方向键、PageDown 和 Home/End 均由浏览器处理。
if (viewport) {
viewport.tabIndex = 0
viewport.setAttribute('role', 'region')
viewport.setAttribute('aria-label', '全部剧集正文')
}
measure()
observer = new ResizeObserver(() => {
measure()
revealActive()
})
if (viewport) observer.observe(viewport)
if (readerContent.value) observer.observe(readerContent.value)
if (directoryViewport) observer.observe(directoryViewport)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<template>
<div class="episode-reader">
<nav class="reader-directory" aria-label="剧集目录">
<div class="reader-directory-heading">
<h3>分集剧本</h3>
<span>{{ orderedEpisodes.length }} </span>
</div>
<NScrollbar ref="directory" class="panel-scroll reader-directory-scroll">
<div ref="directoryContent" class="reader-directory-content">
<NButton
v-for="item in orderedEpisodes"
:key="item.episode"
quaternary
block
class="reader-episode-link"
:class="{ active: active === item.episode }"
:data-episode="item.episode"
:aria-current="active === item.episode ? 'location' : undefined"
:aria-controls="`${readerId}-episode-${item.episode}`"
@click="selectEpisode(item.episode)"
>
<span class="reader-episode-label"> {{ item.episode }} </span>
<span class="reader-episode-title">{{ item.title || '未命名剧集' }}</span>
</NButton>
</div>
</NScrollbar>
</nav>
<div class="reader-page">
<NScrollbar ref="reader" class="panel-scroll reader-scroll" @scroll="syncActive">
<div ref="readerContent" class="reader-content" :style="{ '--reader-height': `${viewportHeight}px` }">
<section
v-for="item in orderedEpisodes"
:id="`${readerId}-episode-${item.episode}`"
:key="item.episode"
:data-episode="item.episode"
:aria-labelledby="`${readerId}-heading-${item.episode}`"
class="reader-chapter"
>
<header class="reader-chapter-heading">
<p> {{ item.episode }} </p>
<h2 :id="`${readerId}-heading-${item.episode}`">{{ item.title || '未命名剧集' }}</h2>
</header>
<p v-if="item.summary" class="script-summary">{{ item.summary }}</p>
<div v-if="item.content" class="script-body">{{ item.content }}</div>
<p v-else class="text-sm text-muted">本集正文尚未写入</p>
<dl v-if="item.conflict || item.hook" class="script-notes">
<template v-if="item.conflict"
><dt>核心冲突</dt>
<dd>{{ item.conflict }}</dd></template
>
<template v-if="item.hook"
><dt>结尾钩子</dt>
<dd>{{ item.hook }}</dd></template
>
</dl>
</section>
</div>
</NScrollbar>
</div>
</div>
</template>
+30 -1
View File
@@ -1,4 +1,4 @@
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { NScrollbar } from 'naive-ui'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
@@ -1134,6 +1134,35 @@ describe('工作台页面交互', () => {
expect(provided.refresh).toHaveBeenCalledOnce()
})
it('切换剧本标签保留阅读容器和位置,执行记录按需展开', async () => {
wrapper = mount(CreateDramaPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
reader.scrollTop = 120
expect(wrapper.find('.history-panel').exists()).toBe(false)
await wrapper
.findAll('.n-tabs-tab')
.find(item => item.text() === '角色设定')!
.trigger('click')
expect(wrapper.get('.episode-reader').isVisible()).toBe(false)
await wrapper
.findAll('.n-tabs-tab')
.find(item => item.text().includes('剧集正文'))!
.trigger('click')
expect(wrapper.get('.episode-reader').isVisible()).toBe(true)
expect(wrapper.get('.reader-scroll .n-scrollbar-container').element).toBe(reader)
expect(reader.scrollTop).toBe(120)
button('执行记录').click()
await nextTick()
expect(wrapper.find('.history-panel').exists()).toBe(true)
expect(wrapper.get('.content-with-history').classes()).not.toContain('history-hidden')
button('收起执行记录').click()
await nextTick()
expect(wrapper.find('.history-panel').exists()).toBe(false)
})
it('剧本文本按纯文本显示,不执行模型输出的 HTML', () => {
wrapper = mount(CreateDramaPage, {
attachTo: document.body,
-64
View File
@@ -665,47 +665,6 @@
11px/1.9 ui-monospace,
monospace;
}
.script-workspace {
display: grid;
grid-template-columns: 166px minmax(0, 1fr);
height: 100%;
}
.episode-list {
border-right: 1px solid var(--color-line);
padding: 0 7px 20px;
background: var(--app-surface);
max-height: 850px;
overflow: hidden;
}
.episode-link {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
text-align: left;
padding: 11px 9px;
border-radius: 4px;
font-size: 11px;
color: var(--color-muted);
}
.episode-link:hover {
background: var(--app-subtle);
}
.episode-link.active {
color: var(--app-ink);
background: var(--app-subtle);
font-weight: 500;
}
.episode-number {
font:
10px ui-monospace,
monospace;
opacity: 0.7;
}
.script-page {
padding: 34px 34px 50px;
min-width: 0;
}
.script-summary {
font-size: 12px;
color: var(--color-muted);
@@ -918,29 +877,6 @@
font-size: 23px;
}
.script-workspace {
grid-template-columns: minmax(0, 1fr);
}
.episode-list {
display: flex;
overflow: hidden;
border-right: 0;
border-bottom: 1px solid var(--color-line);
padding: 8px;
gap: 4px;
}
.episode-list > p {
display: none;
}
.episode-link {
width: auto;
max-width: 170px;
flex-shrink: 0;
}
.script-page {
padding: 25px 20px 35px;
}
.breakdown-config {
grid-template-columns: minmax(0, 1fr);
}