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

第 {{ String(episode.episode).padStart(2, '0') }} 集

-

{{ episode.title }}

-

{{ episode.summary }}

-
{{ episode.content }}
-
- -
-
-
-
+
+
- +
diff --git a/src/features/create-drama/EpisodeReader.test.ts b/src/features/create-drama/EpisodeReader.test.ts new file mode 100644 index 0000000..cb1ac26 --- /dev/null +++ b/src/features/create-drama/EpisodeReader.test.ts @@ -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: '', 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('.reader-scroll .n-scrollbar-container').element + const directory = wrapper.get('.reader-directory-scroll .n-scrollbar-container').element + const tops: Record = { 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('') + 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() + 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() + }) +}) diff --git a/src/features/create-drama/EpisodeReader.vue b/src/features/create-drama/EpisodeReader.vue new file mode 100644 index 0000000..d3163bb --- /dev/null +++ b/src/features/create-drama/EpisodeReader.vue @@ -0,0 +1,168 @@ + + + diff --git a/src/features/workflows/pages.test.ts b/src/features/workflows/pages.test.ts index f9c400b..00e7bff 100644 --- a/src/features/workflows/pages.test.ts +++ b/src/features/workflows/pages.test.ts @@ -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('.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, diff --git a/src/styles.css b/src/styles.css index 50c6dd8..38498e2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); }