fix: 使用 NScrollbar 统一内容区滚动条

This commit is contained in:
GouJ
2026-09-01 16:44:30 +08:00
parent db8c176b55
commit 3fe1b3f5c3
17 changed files with 967 additions and 737 deletions
+1
View File
@@ -87,6 +87,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
+134 -28
View File
@@ -123,10 +123,13 @@ body {
.project-tabs .n-tabs-nav {
border: 0;
}
.project-notices {
.project-notices.n-scrollbar {
flex-shrink: 0;
height: auto;
max-height: 100px;
overflow: auto;
overflow: hidden;
}
.project-notices-content {
padding: 8px 24px;
display: grid;
gap: 5px;
@@ -147,12 +150,14 @@ body {
min-height: 0;
overflow: hidden;
}
.workspace-heading {
.workspace-heading-scroll.n-scrollbar {
flex-shrink: 0;
height: auto;
max-height: min(35dvh, 260px);
overflow: hidden;
}
.workspace-heading {
padding: 16px 24px 12px;
max-height: 35%;
overflow: auto;
overscroll-behavior: contain;
}
.workspace-heading h2 {
font-size: 16px;
@@ -189,28 +194,27 @@ body {
margin: 0;
flex-shrink: 0;
}
.workspace-overview {
.workspace-overview.n-scrollbar {
flex-shrink: 0;
max-height: 42%;
overflow: auto;
padding: 10px 14px;
height: auto;
max-height: min(36dvh, 420px);
overflow: hidden;
background: var(--app-surface);
border: 1px solid var(--app-border);
border-radius: 6px;
}
.workspace-feedback {
.overview-scroll-content {
padding: 10px 14px;
}
.workspace-feedback.n-scrollbar {
flex-shrink: 0;
max-height: 24%;
overflow: auto;
height: auto;
max-height: min(24dvh, 160px);
overflow: hidden;
}
.workspace-feedback .n-alert + .n-alert {
margin-top: 8px;
}
.workspace-split > .n-alert {
flex-shrink: 0;
max-height: 24%;
overflow: auto;
}
.script-section {
flex: 1;
display: flex;
@@ -259,11 +263,11 @@ body {
}
.production-shot-list {
max-height: none;
overflow: auto;
overflow: hidden;
min-height: 0;
}
.production-workspace > article {
overflow: auto;
overflow: hidden;
min-height: 0;
overscroll-behavior: contain;
}
@@ -278,7 +282,7 @@ body {
}
.identity-workspace > div {
min-height: 0;
overflow: auto;
overflow: hidden;
overscroll-behavior: contain;
}
.identity-subject-list {
@@ -292,7 +296,7 @@ body {
}
.storyboard-workspace > article,
.storyboard-workspace .shot-list {
overflow: auto;
overflow: hidden;
min-height: 0;
height: 100%;
max-height: none;
@@ -306,7 +310,7 @@ body {
.content-with-history > aside,
.script-page {
min-height: 0;
overflow: auto;
overflow: hidden;
overscroll-behavior: contain;
}
.script-workspace {
@@ -315,6 +319,84 @@ body {
.episode-list {
max-height: none;
min-height: 0;
overflow: hidden;
}
/* 面板只负责尺寸与裁切;滚动和深浅主题滚动条统一交给 Naive UI。 */
.panel-scroll.n-scrollbar {
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.n-scrollbar > .n-scrollbar-container {
overscroll-behavior: contain;
}
.production-shot-list,
.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 {
padding: 0;
}
.production-shot-list-content {
padding-block: 8px;
}
.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%;
flex-shrink: 0;
}
.image-history.n-scrollbar,
.table-scroll.n-scrollbar {
height: auto;
overflow: hidden;
}
.image-history-content {
display: flex;
gap: 12px;
padding: 4px 4px 8px;
width: max-content;
}
.code-scroll.n-scrollbar {
height: auto;
max-height: 420px;
overflow: hidden;
}
.code-scroll .json-view,
.code-scroll .storyboard-json {
max-height: none;
overflow: visible;
}
.project-table-area {
flex: 1;
@@ -522,18 +604,39 @@ body {
grid-template-rows: 110px minmax(0, 1fr);
}
.production-shot-list {
display: flex;
overflow-x: auto;
display: block;
overflow: hidden;
height: 110px;
}
.production-shot-link {
flex-shrink: 0;
.production-shot-list-content {
flex-direction: row;
width: max-content;
}
.n-button.production-shot-link {
width: 180px;
min-height: 98px;
}
.identity-workspace {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 180px minmax(0, 1fr);
gap: 10px;
}
.identity-workspace > aside {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 6px 8px;
padding: 10px;
}
.identity-workspace > aside > * {
margin-top: 0;
min-width: 0;
}
.identity-workspace > aside > h3,
.identity-workspace > aside > .identity-subject-list,
.identity-workspace > aside > .n-button {
grid-column: 1 / -1;
}
.storyboard-workspace {
grid-template-columns: 120px minmax(0, 1fr);
}
@@ -545,7 +648,7 @@ body {
grid-template-rows: minmax(0, 1fr) 130px;
}
.history-panel {
overflow: auto;
overflow: hidden;
min-height: 0;
}
.script-workspace {
@@ -560,6 +663,9 @@ body {
width: 100%;
}
.script-page {
padding: 0;
}
.script-page-content {
padding: 18px;
}
.project-search {
+3 -1
View File
@@ -5,7 +5,9 @@ defineProps<{ split?: boolean }>()
</script>
<template>
<section class="workspace-page">
<header v-if="$slots.header" class="workspace-heading"><slot name="header" /></header>
<NScrollbar v-if="$slots.header" class="workspace-heading-scroll"
><header class="workspace-heading"><slot name="header" /></header
></NScrollbar>
<div v-if="split" class="workspace-split"><slot /></div>
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
</section>
+10 -3
View File
@@ -23,13 +23,20 @@ describe('管理后台组件边界', () => {
expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/)
expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/)
})
it('固定标题在滚动容器之外,普通工作区只有内容使用滚动组件', () => {
it('业务样式不再直接开启浏览器原生滚动条', () => {
const css = readFileSync('src/admin.css', 'utf8') + readFileSync('src/styles.css', 'utf8')
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
})
it('标题与内容使用各自滚动容器,不随正文一起滚动', () => {
wrapper = mount(WorkspacePage, {
slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' }
})
expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区')
expect(wrapper.getComponent(NScrollbar).text()).toBe('正文')
expect(wrapper.getComponent(NScrollbar).find('h2').exists()).toBe(false)
const content = wrapper.get('.workspace-scroll')
expect(content.classes()).toContain('n-scrollbar')
expect(content.text()).toBe('正文')
expect(content.find('h2').exists()).toBe(false)
expect(wrapper.get('.workspace-heading-scroll .n-scrollbar-content').text()).toBe('固定操作区')
})
it('分栏工作区不再包一层整页滚动', () => {
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
import { computed, onScopeDispose, ref, watch } from 'vue'
import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui'
@@ -343,7 +343,7 @@ function exportResult() {
<StoryboardList :plans="plans" :episodes="shots" />
</div>
<div v-if="tab === 'tasks'" class="p-5">
<div v-if="snapshot?.tasks?.length" class="table-scroll">
<NScrollbar x-scrollable v-if="snapshot?.tasks?.length" class="table-scroll">
<NTable :single-line="false" class="project-table"
><thead>
<tr>
@@ -383,7 +383,7 @@ function exportResult() {
>
</tbody></NTable
>
</div>
</NScrollbar>
<EmptyState
v-else
title="尚无抽取任务记录"
+21 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { 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 { ArrowRight, Download, Check, Circle } from '@lucide/vue'
import { EmptyState } from '../../components/ui'
@@ -138,7 +138,10 @@ function exportScript() {
<div v-if="tab === 'episodes'" class="h-full">
<div v-if="episode" class="script-workspace">
<aside class="episode-list">
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p>
<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"
@@ -149,8 +152,10 @@ function exportScript() {
><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>
@@ -164,6 +169,7 @@ function exportScript() {
<dd>{{ episode.hook }}</dd></template
>
</dl>
</NScrollbar>
</article>
</div>
<EmptyState
@@ -172,7 +178,8 @@ function exportScript() {
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
/>
</div>
<div v-if="tab === 'characters'" class="p-6 lg:p-8">
<NScrollbar v-if="tab !== 'episodes'" class="panel-scroll"
><div v-if="tab === 'characters'" class="p-6 lg:p-8">
<div v-if="project?.characters.length" class="divide-y divide-line">
<article
v-for="character in project.characters"
@@ -221,7 +228,11 @@ function exportScript() {
<NCollapse v-if="project.world.rules" class="mt-6 text-sm"
><NCollapseItem name="details"
><template #header>世界规则</template>
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
<NScrollbar class="code-scroll mt-3" x-scrollable>
<pre class="json-view">{{
JSON.stringify(project.world.rules, null, 2)
}}</pre>
</NScrollbar>
</NCollapseItem></NCollapse
>
</div>
@@ -253,10 +264,15 @@ function exportScript() {
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
><NCollapseItem name="details"
><template #header>查看改写建议原文</template>
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
<NScrollbar class="code-scroll mt-3" x-scrollable>
<pre class="json-view">{{
JSON.stringify(state.rewriteSuggestion, null, 2)
}}</pre>
</NScrollbar>
</NCollapseItem></NCollapse
>
</div>
</NScrollbar>
</main>
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
</div></div
+30 -8
View File
@@ -1,6 +1,16 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NCollapse, NCollapseItem, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NSelect } from 'naive-ui'
import {
NScrollbar,
NCollapse,
NCollapseItem,
NAlert,
NButton,
NCheckbox,
NInputNumber,
NProgress,
NSelect
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui'
@@ -166,11 +176,14 @@ watch(
</p>
</section></template
>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
<NScrollbar v-if="query.error.value" class="workspace-feedback"
><NAlert role="alert" type="error" :show-icon="false"
>{{ query.error.value
}}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span></NAlert
></NScrollbar
>
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapse v-model:expanded-names="overview"
><NCollapseItem name="overview" title="项目生产总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
@@ -244,7 +257,10 @@ watch(
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<p v-if="stage.key === 'keyframes' && staleKeyframes" class="mt-3 text-[11px] leading-5">
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<p v-if="stage.key === 'videos' && staleVideos" class="mt-3 text-[11px] leading-5">
@@ -318,6 +334,7 @@ watch(
</div>
</div></NCollapseItem
></NCollapse
></NScrollbar
>
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
@@ -328,6 +345,7 @@ watch(
</div>
<div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list" aria-label="本集生产镜头">
<NScrollbar class="panel-scroll" content-class="production-shot-list-content" x-scrollable>
<NButton
v-for="item in shots"
:key="item.shotId"
@@ -347,7 +365,8 @@ watch(
<CircleAlert :size="12" />主首帧已过期 </span
><span
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
?.primaryVideoStale
"
class="mt-2 flex items-center gap-2 text-[10px]"
>
@@ -369,14 +388,16 @@ watch(
/>
{{
productionStatusLabel(
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)?.status ||
'not_started'
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)
?.status || 'not_started'
)
}}
</span></NButton
>
</NScrollbar>
</nav>
<article class="min-w-0 p-5 lg:p-7">
<article class="production-detail min-w-0">
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<header class="mb-5 border-b border-line pb-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@@ -425,6 +446,7 @@ watch(
:disabled="blocked"
@changed="refreshProject"
/>
</NScrollbar>
</article>
</div>
<EmptyState
+4 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
import { RefreshCw } from '@lucide/vue'
import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context'
@@ -59,9 +59,10 @@ function switchWorkflow(path: string) {
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
</NTabs>
</header>
<div
<NScrollbar
v-if="context.error.value || operation.pending || operation.error || operation.notice"
class="project-notices"
content-class="project-notices-content"
>
<NAlert v-if="context.error.value" type="error" :show-icon="false"
>{{ context.error.value
@@ -72,7 +73,7 @@ function switchWorkflow(path: string) {
>
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
</div>
</NScrollbar>
<div class="project-view">
<RouterView v-if="context.project.value" :key="id" />
<NSpin
+18 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import {
NScrollbar,
NAlert,
NButton,
NCheckbox,
@@ -155,7 +156,8 @@ watch(
</div>
</section></template
>
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapse v-model:expanded-names="overview"
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
><div class="overview-content">
<div class="generation-row">
@@ -241,9 +243,12 @@ watch(
"
@confirm="run('prompts')" /></NCollapseItem
></NCollapse>
<GenerationReceipt v-if="session.receipt" :receipt="session.receipt" /></div></NCollapseItem
></NCollapse>
<div class="workspace-feedback">
<GenerationReceipt
v-if="session.receipt"
:receipt="session.receipt"
/></div></NCollapseItem></NCollapse
></NScrollbar>
<NScrollbar class="workspace-feedback">
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }} 当前保留上次成功读取的数据暂停生成操作<NButton
@click="query.refresh"
@@ -269,7 +274,7 @@ watch(
>
本集导演设计尚未补齐请先完成第 01 再生成本集即时状态
</NAlert>
</div>
</NScrollbar>
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 镜头检查</h3>
@@ -306,6 +311,7 @@ watch(
</div>
<div v-else-if="shot" class="panel storyboard-workspace">
<nav class="shot-list" aria-label="本集镜头">
<NScrollbar class="panel-scroll" content-class="shot-list-content">
<p class="px-4 pb-3 pt-5 text-[10px] text-muted">{{ shots.length }} 个镜头 · Beat 排列</p>
<template v-for="(item, index) in shots" :key="item.shotId"
><h4
@@ -319,7 +325,9 @@ watch(
@click="selectedShot = item.shotId"
class="shot-link"
:class="{ selected: shot.shotId === item.shotId }"
><span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
><span class="font-mono text-[10px] text-muted">{{
String(item.shotNo).padStart(2, '0')
}}</span
><span class="min-w-0"
><span class="block truncate text-xs font-medium">{{ item.title }}</span
><span class="mt-1 block text-[10px] text-muted"
@@ -329,8 +337,10 @@ watch(
></NButton
></template
>
</NScrollbar>
</nav>
<article class="min-w-0 p-5 lg:p-7">
<article class="storyboard-detail min-w-0">
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<header class="mb-6">
<p class="eyebrow">
BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}
@@ -357,6 +367,7 @@ watch(
<ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" />
</div>
</div>
</NScrollbar>
</article>
</div>
<EmptyState
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NTable } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NTable } from 'naive-ui'
import { computed } from 'vue'
import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format'
@@ -42,7 +42,7 @@ function exportReceipt() {
{{ receipt.result.episodeCount }} · 完成 {{ receipt.result.completedEpisodes }} · 跳过
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
</p>
<div class="table-scroll">
<NScrollbar x-scrollable class="table-scroll">
<NTable :single-line="false" class="project-table"
><thead>
<tr>
@@ -74,7 +74,7 @@ function exportReceipt() {
</tr>
</tbody></NTable
>
</div>
</NScrollbar>
</template>
<template v-else-if="receipt.kind === 'prompts'">
<p class="mt-4 text-sm">
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NCheckbox } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NCheckbox } from 'naive-ui'
import { computed, onScopeDispose, ref, watch } from 'vue'
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
import { storyboardApi } from '../api'
@@ -183,7 +183,9 @@ function exportResult(kind: 'spec' | 'prompt') {
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} </span
><NButton @click="exportResult('spec')" text size="small"><Download :size="13" />导出规格</NButton>
</div>
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre>
<NScrollbar class="code-scroll mt-3" x-scrollable>
<pre class="storyboard-json">{{ JSON.stringify(spec, null, 2) }}</pre>
</NScrollbar>
</template>
</section>
<section class="border-t border-line pt-6">
@@ -1,6 +1,17 @@
<script setup lang="ts">
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
import {
NScrollbar,
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInput,
NInputNumber,
NSelect,
NTag
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { UserCheck } from '@lucide/vue'
@@ -141,16 +152,16 @@ watch(
>
</div></template
>
<NAlert
<NScrollbar
class="workspace-feedback"
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
role="alert"
type="error"
:show-icon="false"
class="mt-4"
><NAlert role="alert" type="error" :show-icon="false"
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
当前相关操作已暂停请刷新核对后端
</NAlert>
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
</NAlert></NScrollbar
>
<NScrollbar class="workspace-overview" content-class="overview-scroll-content"
><NCollapse v-model:expanded-names="overview"
><NCollapseItem name="casting" title="角色选角总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
@@ -299,8 +310,8 @@ watch(
<p v-else class="mt-3 text-xs text-muted">
{{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成 {{ session.receipt.result.generated }} ·
失败 {{ session.receipt.result.failed }}
{{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
</p>
<NAlert
v-if="session.receipt.result.failed"
@@ -322,6 +333,7 @@ watch(
</section>
</div></NCollapseItem
></NCollapse
></NScrollbar
>
<div v-if="subjects.length" class="identity-workspace">
<aside class="panel min-w-0 p-4">
@@ -346,7 +358,10 @@ watch(
]"
class="mt-3"
></NSelect>
<div class="identity-subject-list mt-4">
<NScrollbar
class="identity-subject-list panel-scroll mt-4"
content-class="identity-subject-list-content"
>
<NButton
v-for="item in filtered"
:key="item.id"
@@ -367,7 +382,7 @@ watch(
></NButton
>
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p>
</div>
</NScrollbar>
<NButton
:disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory"
@@ -378,7 +393,7 @@ watch(
刷新主体目录
</NButton>
</aside>
<div class="min-w-0">
<NScrollbar class="identity-detail panel-scroll min-w-0">
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
><p class="text-xs">当前主体有未保存修改切换会丢弃草稿</p>
<div class="mt-3 flex gap-3">
@@ -495,7 +510,7 @@ watch(
>
</div>
</section>
</div>
</NScrollbar>
</div>
<p v-else-if="catalog.loading.value" class="py-8 text-sm text-muted" role="status">正在读取正式主体目录</p>
<EmptyState
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets'
@@ -132,7 +132,12 @@ function chooseAnchor() {
本次锚定图片 ID{{ readImageProvenance(selected.rawJson).referenceImageId }}
</p>
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
<div class="image-history mt-4" aria-label="身份图片历史">
<NScrollbar
x-scrollable
content-class="image-history-content"
class="image-history mt-4"
aria-label="身份图片历史"
>
<NButton
v-for="image in images"
:key="image.id"
@@ -154,7 +159,7 @@ function chooseAnchor() {
: identityViewLabels[image.viewType]
}}</span></NButton
>
</div>
</NScrollbar>
</template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
</section>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { NScrollbar, NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
@@ -159,7 +159,12 @@ async function choosePrimary() {
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
}}
</p>
<div class="image-history mt-5" aria-label="图片历史">
<NScrollbar
x-scrollable
content-class="image-history-content"
class="image-history mt-5"
aria-label="图片历史"
>
<NButton
v-for="image in images"
:key="image.id"
@@ -185,7 +190,7 @@ async function choosePrimary() {
]
}}</span></NButton
>
</div>
</NScrollbar>
</div>
<p v-else class="py-10 text-center text-sm text-muted">
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
+6 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
import { NScrollbar, NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
import { Clock3 } from '@lucide/vue'
import { formatDate, nodeLabel } from '../../lib/format'
import { workflowCheckpoints } from './selectors'
@@ -13,6 +13,7 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
<template>
<aside class="history-panel">
<NScrollbar class="panel-scroll" content-class="history-content">
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
<p class="mb-6 text-xs leading-5 text-muted">最近 18 checkpoint · 自动刷新</p>
<NEmpty v-if="!history.length" size="small" description="工作流尚未保存执行记录。" />
@@ -29,7 +30,9 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
· {{ (item.metadata.nodeDurationMs / 1000).toFixed(1) }}s</span
>
</p>
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
<span
v-if="item.state.workflowExecution?.status === 'failed'"
class="mt-1 block text-xs text-danger"
>运行失败</span
>
</NTimelineItem>
@@ -37,5 +40,6 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
记录在节点或批次结束后更新长时间无新记录不一定意味着任务失败
</p>
</NScrollbar>
</aside>
</template>
+40
View File
@@ -1,4 +1,5 @@
import { computed, ref } from 'vue'
import { NScrollbar } from 'naive-ui'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -142,6 +143,45 @@ afterEach(() => {
})
describe('内容级滚动下的生产反馈', () => {
it('生产目录和资产详情由两个独立 NScrollbar 接管,切镜头不会新增生产请求', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/storyboard-directions')) return jsonResponse(directionsResult(fixture.id))
if (path.includes('/readiness') || path.endsWith('/videos/status'))
return jsonResponse({
total: 2,
ready: 0,
skipped: 0,
blocked: 0,
completed: 0,
queued: 0,
pending: 0,
running: 0,
failed: 0,
cancelled: 0,
notStarted: 2,
items: []
})
return jsonResponse([])
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ProductionPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
await flushPromises()
const directory = wrapper.get('.production-shot-list')
const detail = wrapper.get('.production-detail')
expect(directory.getComponent(NScrollbar).props('xScrollable')).toBe(true)
expect(detail.get('.panel-scroll').classes()).toContain('n-scrollbar')
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
const items = directory.findAll('.production-shot-link')
expect(items).toHaveLength(2)
await items[1]!.trigger('click')
await flushPromises()
expect(items[1]!.attributes('aria-current')).toBe('true')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('默认折叠总览时查询错误仍显示在内容区外层', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('生产连接中断')))
wrapper = mount(ProductionPage, {
+8 -15
View File
@@ -70,7 +70,7 @@
}
.identity-subject-list {
max-height: 560px;
overflow-y: auto;
overflow: hidden;
}
.identity-subject-item {
display: block;
@@ -248,7 +248,7 @@
border-color: var(--color-accent);
}
.table-scroll {
overflow-x: auto;
overflow: hidden;
}
.project-table {
width: 100%;
@@ -336,7 +336,7 @@
}
.production-shot-list {
max-height: 760px;
overflow-y: auto;
overflow: hidden;
border-right: 1px solid var(--color-line);
background: var(--app-surface);
padding-block: 8px;
@@ -413,7 +413,7 @@
.production-shot-list {
display: flex;
max-height: none;
overflow-x: auto;
overflow: hidden;
border-right: none;
border-bottom: 1px solid var(--color-line);
}
@@ -565,10 +565,7 @@
height: min(48vh, 480px);
}
.image-history {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 4px;
overflow: hidden;
}
.image-history-item {
width: 110px;
@@ -615,7 +612,7 @@
}
.shot-list {
max-height: 1000px;
overflow-y: auto;
overflow: hidden;
border-right: 1px solid var(--color-line);
background: var(--app-surface);
padding: 0 8px 20px;
@@ -661,8 +658,6 @@
background: var(--app-subtle);
}
.storyboard-json {
overflow: auto;
max-height: 440px;
padding: 16px;
border-radius: 4px;
background: var(--app-subtle);
@@ -680,7 +675,7 @@
padding: 0 7px 20px;
background: var(--app-surface);
max-height: 850px;
overflow-y: auto;
overflow: hidden;
}
.episode-link {
display: flex;
@@ -757,8 +752,6 @@
overflow-wrap: anywhere;
}
.json-view {
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 16px;
@@ -930,7 +923,7 @@
}
.episode-list {
display: flex;
overflow-x: auto;
overflow: hidden;
border-right: 0;
border-bottom: 1px solid var(--color-line);
padding: 8px;