feat: 优化剧集连续阅读布局与目录滚动联动
This commit is contained in:
@@ -88,6 +88,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
||||
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
||||
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
||||
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
||||
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
||||
|
||||
+146
-38
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user