feat: 优化剧集连续阅读布局与目录滚动联动
This commit is contained in:
@@ -88,6 +88,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
||||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||||
|
- 剧集正文按编号连续展示全部已写入剧集;点击目录定位正文,滚动正文同步高亮并保持当前目录项可见。目录分行显示集数和完整标题,执行记录可按需展开。切换角色/世界观等标签不会销毁阅读器或重置位置。
|
||||||
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
||||||
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
||||||
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
||||||
|
|||||||
+146
-38
@@ -307,19 +307,158 @@ body {
|
|||||||
grid-template-columns: minmax(0, 1fr) 207px;
|
grid-template-columns: minmax(0, 1fr) 207px;
|
||||||
}
|
}
|
||||||
.content-with-history > main,
|
.content-with-history > main,
|
||||||
.content-with-history > aside,
|
.content-with-history > aside {
|
||||||
.script-page {
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
.script-workspace {
|
/* 连续阅读区保留双栏,执行记录按需展开,不挤占默认阅读宽度。 */
|
||||||
min-height: 0;
|
.script-section .n-tabs-tab__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.episode-list {
|
.script-section .content-with-history.history-hidden {
|
||||||
max-height: none;
|
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;
|
min-height: 0;
|
||||||
overflow: hidden;
|
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。 */
|
/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
|
||||||
.panel-scroll.n-scrollbar {
|
.panel-scroll.n-scrollbar {
|
||||||
@@ -335,16 +474,13 @@ body {
|
|||||||
.production-detail,
|
.production-detail,
|
||||||
.storyboard-detail,
|
.storyboard-detail,
|
||||||
.shot-list,
|
.shot-list,
|
||||||
.episode-list,
|
|
||||||
.content-with-history > main {
|
.content-with-history > main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.script-page,
|
|
||||||
.history-panel,
|
.history-panel,
|
||||||
.production-shot-list,
|
.production-shot-list,
|
||||||
.shot-list,
|
.shot-list {
|
||||||
.episode-list {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.production-shot-list-content {
|
.production-shot-list-content {
|
||||||
@@ -353,25 +489,17 @@ body {
|
|||||||
.shot-list-content {
|
.shot-list-content {
|
||||||
padding: 0 8px 20px;
|
padding: 0 8px 20px;
|
||||||
}
|
}
|
||||||
.episode-list-content {
|
|
||||||
padding: 0 7px 20px;
|
|
||||||
}
|
|
||||||
.script-page-content {
|
|
||||||
padding: 36px 40px;
|
|
||||||
}
|
|
||||||
.history-content {
|
.history-content {
|
||||||
padding: 23px 21px;
|
padding: 23px 21px;
|
||||||
}
|
}
|
||||||
.production-shot-list-content,
|
.production-shot-list-content,
|
||||||
.shot-list-content,
|
.shot-list-content,
|
||||||
.episode-list-content,
|
|
||||||
.identity-subject-list-content {
|
.identity-subject-list-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.n-button.production-shot-link,
|
.n-button.production-shot-link,
|
||||||
.n-button.shot-link,
|
.n-button.shot-link,
|
||||||
.n-button.episode-link,
|
|
||||||
.n-button.identity-subject-item {
|
.n-button.identity-subject-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -480,7 +608,6 @@ body {
|
|||||||
.n-button:disabled {
|
.n-button:disabled {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.n-button.episode-link,
|
|
||||||
.n-button.shot-link,
|
.n-button.shot-link,
|
||||||
.n-button.production-shot-link,
|
.n-button.production-shot-link,
|
||||||
.n-button.identity-subject-item,
|
.n-button.identity-subject-item,
|
||||||
@@ -489,7 +616,6 @@ body {
|
|||||||
height: auto;
|
height: auto;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.n-button.episode-link .n-button__content,
|
|
||||||
.n-button.shot-link .n-button__content {
|
.n-button.shot-link .n-button__content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -507,7 +633,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.n-button.episode-link .n-button__border,
|
|
||||||
.n-button.shot-link .n-button__border,
|
.n-button.shot-link .n-button__border,
|
||||||
.n-button.production-shot-link .n-button__border,
|
.n-button.production-shot-link .n-button__border,
|
||||||
.n-button.identity-subject-item .n-button__border {
|
.n-button.identity-subject-item .n-button__border {
|
||||||
@@ -651,23 +776,6 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
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 {
|
.project-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
import { NScrollbar, NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
import { NScrollbar, NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
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 { EmptyState } from '../../components/ui'
|
||||||
import { useProjectContext } from '../projects/context'
|
import { useProjectContext } from '../projects/context'
|
||||||
import { projectsApi } from '../projects/api'
|
import { projectsApi } from '../projects/api'
|
||||||
@@ -11,15 +11,15 @@ import { getOperation, runOperation } from '../workflows/operations'
|
|||||||
import HistoryPanel from '../workflows/HistoryPanel.vue'
|
import HistoryPanel from '../workflows/HistoryPanel.vue'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
import { downloadText, formatDate } from '../../lib/format'
|
import { downloadText, formatDate } from '../../lib/format'
|
||||||
|
import EpisodeReader from './EpisodeReader.vue'
|
||||||
|
|
||||||
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
|
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
|
||||||
const { project, checkpoints, refresh, error } = useProjectContext()
|
const { project, checkpoints, refresh, error } = useProjectContext()
|
||||||
const selected = ref<number>()
|
|
||||||
const tab = ref('episodes')
|
const tab = ref('episodes')
|
||||||
|
const showHistory = ref(false)
|
||||||
const dramaRecords = computed(() => workflowCheckpoints(checkpoints.value, 'create-drama'))
|
const dramaRecords = computed(() => workflowCheckpoints(checkpoints.value, 'create-drama'))
|
||||||
const state = computed(() => dramaRecords.value.at(-1)?.state)
|
const state = computed(() => dramaRecords.value.at(-1)?.state)
|
||||||
const episodes = computed(() => project.value?.episodes ?? [])
|
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 total = computed(() => state.value?.episodeCount)
|
||||||
const operation = computed(() => getOperation(project.value!.id))
|
const operation = computed(() => getOperation(project.value!.id))
|
||||||
const complete = computed(() => project.value?.status === 'completed')
|
const complete = computed(() => project.value?.status === 'completed')
|
||||||
@@ -123,6 +123,9 @@ function exportScript() {
|
|||||||
><NTab name="review">审核记录</NTab></NTabs
|
><NTab name="review">审核记录</NTab></NTabs
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<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"
|
<NButton :disabled="!episodes.length" @click="exportScript" text size="small"
|
||||||
><Download :size="14" />导出剧本</NButton
|
><Download :size="14" />导出剧本</NButton
|
||||||
><RouterLink
|
><RouterLink
|
||||||
@@ -133,49 +136,14 @@ function exportScript() {
|
|||||||
/></RouterLink>
|
/></RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<main class="min-w-0">
|
||||||
<div v-if="tab === 'episodes'" class="h-full">
|
<div v-show="tab === 'episodes'" class="h-full">
|
||||||
<div v-if="episode" class="script-workspace">
|
<EpisodeReader v-if="episodes.length" :episodes="episodes" />
|
||||||
<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>
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else
|
v-else
|
||||||
title="剧本正在酝酿"
|
title="剧本正在酝酿"
|
||||||
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
|
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可展开执行记录查看进度。"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NScrollbar v-if="tab !== 'episodes'" class="panel-scroll"
|
<NScrollbar v-if="tab !== 'episodes'" class="panel-scroll"
|
||||||
@@ -274,7 +242,7 @@ function exportScript() {
|
|||||||
</div>
|
</div>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
</main>
|
</main>
|
||||||
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
|
<HistoryPanel v-if="showHistory" :checkpoints="checkpoints" workflow="create-drama" />
|
||||||
</div></div
|
</div></div
|
||||||
></WorkspacePage>
|
></WorkspacePage>
|
||||||
</template>
|
</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 { NScrollbar } from 'naive-ui'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
@@ -1134,6 +1134,35 @@ describe('工作台页面交互', () => {
|
|||||||
expect(provided.refresh).toHaveBeenCalledOnce()
|
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', () => {
|
it('剧本文本按纯文本显示,不执行模型输出的 HTML', () => {
|
||||||
wrapper = mount(CreateDramaPage, {
|
wrapper = mount(CreateDramaPage, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
|
|||||||
@@ -665,47 +665,6 @@
|
|||||||
11px/1.9 ui-monospace,
|
11px/1.9 ui-monospace,
|
||||||
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 {
|
.script-summary {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
@@ -918,29 +877,6 @@
|
|||||||
font-size: 23px;
|
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 {
|
.breakdown-config {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user