fix: 使用 NScrollbar 统一内容区滚动条
This commit is contained in:
@@ -87,6 +87,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
||||
|
||||
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
||||
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||
- 目录、详情、执行记录、折叠操作区、图片历史和 JSON 查看区统一使用 Naive UI `NScrollbar`;外层只负责尺寸与裁切,不再设置原生 `overflow: auto`。内边距与横向排列放在 `content-class` 内容层,避免双滚动条。
|
||||
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
||||
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
||||
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
||||
|
||||
+134
-28
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('分栏工作区不再包一层整页滚动', () => {
|
||||
|
||||
@@ -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="尚无抽取任务记录"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user