style: 将工作区操作移入标签页后缀并增强浅色分隔线

This commit is contained in:
GouJ
2026-09-02 09:49:22 +08:00
parent 2ddd9b8009
commit 4229426339
10 changed files with 112 additions and 44 deletions
+1
View File
@@ -108,6 +108,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。 - 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。 - 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
- Tabs 例外保留底部分隔线,浅色和暗黑模式均区分导航与正文;选中下划线仍使用绿色。视觉风格工具栏将“图标+状态说明”与等高操作按钮分组,读取失败不再显示为“尚未创建”,锁定与未保存草稿的生成限制不变。 - Tabs 例外保留底部分隔线,浅色和暗黑模式均区分导航与正文;选中下划线仍使用绿色。视觉风格工具栏将“图标+状态说明”与等高操作按钮分组,读取失败不再显示为“尚未创建”,锁定与未保存草稿的生成限制不变。
- 剧本正文的执行记录/导出/进入拆解,以及拆解结果的 JSON 导出统一放在 `NTabs``suffix` 插槽,与标签垂直居中并共用底线。浅色 Tabs 分隔线单独加深,不影响其它组件的低边框风格;窄内容区由容器断点把操作放到下一行,标签仍使用内置横向滚动。
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。 - 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body``window.scrollTo` 管理滚动。 布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`(内容优先页开启 `compact`),低频操作使用 `WorkspaceTools`;避免通过 `body``window.scrollTo` 管理滚动。
+51 -15
View File
@@ -431,11 +431,6 @@ body {
.script-section > :first-child { .script-section > :first-child {
flex-shrink: 0; flex-shrink: 0;
} }
.script-section > :first-child .n-tabs {
flex: 1;
min-width: 220px;
width: auto;
}
.script-section .content-with-history { .script-section .content-with-history {
flex: 1; flex: 1;
height: auto; height: auto;
@@ -724,20 +719,61 @@ body {
outline: 2px solid var(--app-accent); outline: 2px solid var(--app-accent);
outline-offset: -2px; outline-offset: -2px;
} }
/* Tabs 的默认整行宽度限制在左侧网格内,导出按钮始终处于同一行右端。 */ /* 标签与操作由同一 NTabs 导航行管理,suffix 共享底线及垂直中心。 */
.result-toolbar { .tabs-toolbar {
display: grid; container: tabs-toolbar / inline-size;
grid-template-columns: minmax(0, 1fr) auto; width: 100%;
align-items: center;
gap: 16px;
}
.result-toolbar > .n-tabs {
min-width: 0; min-width: 0;
} }
.result-toolbar > .n-button { .workspace-tabs.n-tabs {
justify-self: end; width: 100%;
min-width: 0;
}
.workspace-tabs > .n-tabs-nav {
align-items: stretch;
}
.workspace-tabs .n-tabs-tab {
min-height: 40px;
box-sizing: border-box;
}
.workspace-tabs .n-tabs-nav-scroll-wrapper {
min-width: 0;
}
.workspace-tabs .n-tabs-nav__suffix {
align-items: center;
flex-shrink: 0;
padding-left: 20px;
}
.tabs-toolbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 4px 14px;
min-height: 40px;
}
.tabs-toolbar-actions .n-button,
.tabs-toolbar-actions .text-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
white-space: nowrap; white-space: nowrap;
} }
/* 窄内容区允许 suffix 独占一行;Tabs 本身继续使用组件内置横向滚动。 */
@container tabs-toolbar (max-width: 600px) {
.workspace-tabs > .n-tabs-nav {
flex-wrap: wrap;
}
.workspace-tabs .n-tabs-nav-scroll-wrapper {
flex-basis: 100%;
}
.workspace-tabs .n-tabs-nav__suffix {
width: 100%;
justify-content: flex-end;
padding-left: 0;
}
}
.result-toolbar .n-tabs-tab__label { .result-toolbar .n-tabs-tab__label {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 6px;
+3 -2
View File
@@ -86,9 +86,10 @@ describe('统一目录条目', () => {
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/) expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
}) })
it('导出工具栏与筛选器使用受约束的网格,不让 Tabs 和 Select 默认宽度强制换行', () => { it('Tabs 工具栏与筛选网格限制最小宽度,不让组件默认宽度挤压内容', () => {
const css = readFileSync('src/admin.css', 'utf8') const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(/\.result-toolbar\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto/) expect(css).toMatch(/\.workspace-tabs\.n-tabs\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0/)
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav-scroll-wrapper\s*\{[^}]*min-width:\s*0/)
expect(css).toMatch( expect(css).toMatch(
/\.form-image-filters\s*\{[^}]*grid-template-columns:\s*minmax\(200px, 360px\) 152px minmax\(260px, 1fr\) auto/ /\.form-image-filters\s*\{[^}]*grid-template-columns:\s*minmax\(200px, 360px\) 152px minmax\(260px, 1fr\) auto/
) )
+1 -1
View File
@@ -119,7 +119,7 @@ describe('全站平面主题', () => {
expect(theme.common?.borderColor).not.toBe('transparent') expect(theme.common?.borderColor).not.toBe('transparent')
expect(theme.Checkbox).not.toHaveProperty('border') expect(theme.Checkbox).not.toHaveProperty('border')
expect(theme.Tabs?.barColor).toBe('#07c160') expect(theme.Tabs?.barColor).toBe('#07c160')
expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#e5e5e5') expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#bfbfbf')
}) })
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => { it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
+10
View File
@@ -22,6 +22,16 @@ afterEach(() => {
}) })
describe('管理后台组件边界', () => { describe('管理后台组件边界', () => {
it('Tabs suffix 与标签共用导航行,窄屏换行但不产生外层滚动', () => {
// DOM 测试不计算坐标;保护对齐尺寸与容器断点,视觉验收仍需浏览器。
const css = readFileSync('src/admin.css', 'utf8')
expect(css).toMatch(/\.workspace-tabs > \.n-tabs-nav\s*\{[^}]*align-items:\s*stretch;/)
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav__suffix\s*\{[^}]*align-items:\s*center;/)
expect(css).toMatch(/\.tabs-toolbar-actions\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*40px;/)
expect(css).toContain('@container tabs-toolbar (max-width: 600px)')
expect(css).not.toContain('.result-toolbar > .n-button')
})
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => { it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。 // happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
const adminCss = readFileSync('src/admin.css', 'utf8') const adminCss = readFileSync('src/admin.css', 'utf8')
+9
View File
@@ -47,6 +47,15 @@ function contrast(foreground: string, background: string) {
} }
describe('微信风格明暗主题', () => { describe('微信风格明暗主题', () => {
it('浅色 Tabs 分隔线与页面、内容表面保持可辨识的灰阶差异', () => {
const { overrides } = setup()
const color = overrides.value.Tabs!.tabBorderColor!
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
expect(color).toBe('#bfbfbf')
expect(contrast(color, '#ededed')).toBeGreaterThan(1.5)
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.5)
})
it('默认跟随系统并实时响应,销毁时移除监听', async () => { it('默认跟随系统并实时响应,销毁时移除监听', async () => {
const state = setup() const state = setup()
expect(state.preference.value).toBe('system') expect(state.preference.value).toBe('system')
+1 -1
View File
@@ -216,7 +216,7 @@ export function useTheme() {
tabTextColorHoverLine: accentText, tabTextColorHoverLine: accentText,
barColor: primary, barColor: primary,
// Tabs 保留横向分隔线,明确导航与内容边界;选中下划线仍使用主绿色。 // Tabs 保留横向分隔线,明确导航与内容边界;选中下划线仍使用主绿色。
tabBorderColor: dark ? '#333333' : '#e5e5e5' tabBorderColor: dark ? '#333333' : '#bfbfbf'
}, },
Tag: { Tag: {
textColorPrimary: accentText, textColorPrimary: accentText,
+11 -6
View File
@@ -382,15 +382,20 @@ function exportResult() {
</NAlert> </NAlert>
</NScrollbar> </NScrollbar>
<section class="breakdown-results-section"> <section class="breakdown-results-section">
<div class="result-toolbar"> <div class="result-toolbar tabs-toolbar">
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果" <NTabs v-model:value="tab" class="workspace-tabs" type="line" size="small" aria-label="拆解结果"
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value" ><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
>{{ option.label >{{ option.label
}}<span>{{ subjects.filter(item => item.module === option.value).length }}</span></NTab }}<span>{{ subjects.filter(item => item.module === option.value).length }}</span></NTab
><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab></NTabs ><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab>
><NButton :disabled="!snapshot" @click="exportResult" text size="small" <template #suffix
><Download :size="14" />导出 JSON ><div class="tabs-toolbar-actions">
</NButton> <NButton :disabled="!snapshot" @click="exportResult" text size="small"
><Download :size="14" />导出 JSON
</NButton>
</div></template
>
</NTabs>
</div> </div>
<div class="content-with-history panel"> <div class="content-with-history panel">
<main class="min-w-0"> <main class="min-w-0">
+19 -17
View File
@@ -115,26 +115,28 @@ function exportScript() {
class="mt-4" class="mt-4"
></NProgress> ></NProgress>
<div class="script-section"> <div class="script-section">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="tabs-toolbar">
<NTabs v-model:value="tab" type="line" size="small" aria-label="剧本内容" <NTabs v-model:value="tab" class="workspace-tabs" type="line" size="small" aria-label="剧本内容"
><NTab name="episodes" ><NTab name="episodes"
>剧集正文 <span>{{ episodes.length }}</span></NTab >剧集正文 <span>{{ episodes.length }}</span></NTab
><NTab name="characters">角色设定</NTab><NTab name="world">世界观</NTab ><NTab name="characters">角色设定</NTab><NTab name="world">世界观</NTab
><NTab name="review">审核记录</NTab></NTabs ><NTab name="review">审核记录</NTab>
> <template #suffix>
<div class="flex items-center gap-3"> <div class="tabs-toolbar-actions">
<NButton text size="small" :aria-expanded="showHistory" @click="showHistory = !showHistory" <NButton text size="small" :aria-expanded="showHistory" @click="showHistory = !showHistory"
><History :size="14" />{{ showHistory ? '收起执行记录' : '执行记录' }}</NButton ><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
v-if="complete" v-if="complete"
class="text-button text-accent" class="text-button text-accent"
:to="`/projects/${project?.id}/breakdown`" :to="`/projects/${project?.id}/breakdown`"
>进入拆解<ArrowRight :size="14" >进入拆解<ArrowRight :size="14"
/></RouterLink> /></RouterLink>
</div> </div>
</template>
</NTabs>
</div> </div>
<div class="content-with-history panel mt-4" :class="{ 'history-hidden': !showHistory }"> <div class="content-with-history panel mt-4" :class="{ 'history-hidden': !showHistory }">
<main class="min-w-0"> <main class="min-w-0">
+6 -2
View File
@@ -829,14 +829,15 @@ describe('工作台页面交互', () => {
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true) expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
}) })
it('拆解导出按钮紧邻标签栏且保持空快照禁用状态', () => { it('拆解导出按钮位于 Tabs suffix且保持空快照禁用状态', () => {
wrapper = mount(BreakdownPage, { wrapper = mount(BreakdownPage, {
attachTo: document.body, attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } } global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
}) })
const toolbar = wrapper.get('.result-toolbar') const toolbar = wrapper.get('.result-toolbar')
expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true) expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true)
const exportButton = toolbar.get<HTMLButtonElement>(':scope > button') expect(toolbar.find(':scope > button').exists()).toBe(false)
const exportButton = toolbar.get<HTMLButtonElement>('.n-tabs-nav__suffix button')
expect(exportButton.text()).toBe('导出 JSON') expect(exportButton.text()).toBe('导出 JSON')
expect(exportButton.element.disabled).toBe(true) expect(exportButton.element.disabled).toBe(true)
}) })
@@ -1422,6 +1423,9 @@ describe('工作台页面交互', () => {
attachTo: document.body, attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } } global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
}) })
const suffix = wrapper.get('.script-section .n-tabs-nav__suffix')
expect(suffix.findAll('button').map(item => item.text())).toEqual(['执行记录', '导出剧本'])
expect(suffix.get('router-link-stub').attributes('to')).toBe(`/projects/${fixture.id}/breakdown`)
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
reader.scrollTop = 120 reader.scrollTop = 120
expect(wrapper.find('.history-panel').exists()).toBe(false) expect(wrapper.find('.history-panel').exists()).toBe(false)