feat: 优化剧集连续阅读布局与目录滚动联动
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user