diff --git a/README.md b/README.md index 36d7709..da432fd 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S - 分镜设计与镜头生产共用两级镜头目录:分组头展示 `BEAT 01` 和镜头数,组内仅显示“镜头 01”、标题及状态。分组头在本组内吸顶;窄屏横向模式才在条目上补回 Beat 归属。点击仍使用正式 Shot ID,不会混淆不同 Beat 的同号镜头。 - 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。 - 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。 -- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。 +- 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。 +- 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。 - 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。 - 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。 - 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。 diff --git a/src/App.vue b/src/App.vue index b274e21..8b0a6e1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,7 +9,7 @@ import { NLayoutHeader, NLayoutSider, NMenu, - NSelect, + NScrollbar, NTooltip, zhCN, dateZhCN, @@ -30,6 +30,7 @@ import { } from '@lucide/vue' import { AppDialog } from './components/ui' import { useTheme } from './composables/useTheme' +import ThemeToggle from './components/ui/ThemeToggle.vue' /** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */ const route = useRoute() @@ -38,11 +39,6 @@ const settingsOpen = ref(false) const { preference, theme, overrides } = useTheme() const projectId = computed(() => String(route.params.projectId || '')) const apiBase = import.meta.env.VITE_API_BASE_URL || '/api' -const themeOptions = [ - { label: '浅色模式', value: 'light' }, - { label: '暗黑模式', value: 'dark' }, - { label: '跟随系统', value: 'system' } -] const workflowItems = [ ['create-drama', '剧本创作', FileText], ['breakdown', '剧本拆解', Layers], @@ -90,19 +86,40 @@ const menuOptions = computed(() => [ :collapsed-width="64" :native-scrollbar="false" class="admin-sider" + content-class="admin-sider-content" > 短剧工作台SHORT DRAMA STUDIO - + + + +
+ + + 后端连接 + +
@@ -117,22 +134,7 @@ const menuOptions = computed(() => [ {{ route.meta.title || '工作空间' }} -
- - 后端连接 -
+
diff --git a/src/admin.css b/src/admin.css index e483c24..c04dc07 100644 --- a/src/admin.css +++ b/src/admin.css @@ -12,7 +12,12 @@ --app-accent: #07c160; --app-accent-hover: #2dcb79; --app-accent-text: #087c42; - --app-on-accent: #082b17; + --app-on-accent: #ffffff; + --app-button-primary: #078640; + --app-button-hover: #087c42; + --app-button-pressed: #066b36; + --app-disabled-bg: #e4e4e4; + --app-disabled-text: #8c8c8c; --app-selected: #e4f4e9; --app-shadow: #00000018; --app-danger: #a93232; @@ -26,6 +31,8 @@ --app-subtle: #232323; --app-control: #2b2b2b; --app-control-hover: #353535; + --app-disabled-bg: #303030; + --app-disabled-text: #808080; --app-border: #333333; --app-inverse: #111111; --app-accent-text: #5cd693; @@ -65,6 +72,34 @@ body { .admin-sider { height: 100%; } +/* 外壳固定品牌与底部设置;只有菜单自身使用 NScrollbar 滚动。 */ +.admin-sider-content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +} +.admin-nav-scroll.n-scrollbar { + flex: 1; + min-height: 0; +} +.admin-sider-footer { + flex-shrink: 0; + padding-block: 8px max(8px, env(safe-area-inset-bottom)); +} +.n-button.admin-settings-button { + justify-content: flex-start; + height: 44px; + padding-inline: 20px; +} +.n-button.admin-settings-button.is-collapsed { + justify-content: center; + padding-inline: 0; +} +.admin-settings-button.is-collapsed .n-button__icon { + margin: 0; +} /* Naive 菜单背景默认左右内缩 8px;只铺满导航行,不改图标与文字缩进。 */ .admin-sider .n-menu .n-menu-item-content::before { left: 0; @@ -73,6 +108,7 @@ body { } .admin-brand { display: flex; + flex-shrink: 0; align-items: center; gap: 12px; height: 74px; @@ -108,6 +144,10 @@ body { .n-button { border-radius: 0; } +.n-button:focus-visible { + outline: 2px solid var(--app-accent-text); + outline-offset: 2px; +} .admin-sider .n-menu-divider { height: 12px; background: transparent; @@ -119,8 +159,10 @@ body { .workspace-tools-drawer .n-drawer-body { background: var(--app-body); } -.theme-select { - width: 120px; +.n-button.theme-toggle { + flex-shrink: 0; + width: 34px; + height: 34px; } .admin-content { flex: 1; @@ -213,10 +255,6 @@ body { min-width: 180px; font-size: 12px; } -.toolbar-episode > span { - flex-shrink: 0; - color: var(--app-muted); -} .toolbar-episode .n-select { min-width: 0; } @@ -1160,9 +1198,6 @@ body { .production-video { background: #080808; } -.button-primary { - color: var(--app-on-accent); -} .skip-link:focus { z-index: 3000; } @@ -1185,6 +1220,7 @@ body { position: absolute; top: 44px; bottom: 0; + height: auto; z-index: 20; box-shadow: 6px 0 24px var(--app-shadow); } diff --git a/src/components/ui/ThemeToggle.vue b/src/components/ui/ThemeToggle.vue new file mode 100644 index 0000000..a536ca9 --- /dev/null +++ b/src/components/ui/ThemeToggle.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/ui/flatTheme.test.ts b/src/components/ui/flatTheme.test.ts index 0b94089..6603f97 100644 --- a/src/components/ui/flatTheme.test.ts +++ b/src/components/ui/flatTheme.test.ts @@ -16,6 +16,48 @@ afterEach(() => { }) describe('全站平面主题', () => { + it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => { + localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light') + vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} })) + const click = vi.fn<() => void>() + wrapper = mount({ + setup() { + const { theme, overrides } = useTheme() + return () => + h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () => + (['primary', 'success'] as const).flatMap(type => [ + h(NButton, { type, class: `${type}-button`, onClick: click }, () => '提交'), + h( + NButton, + { type, disabled: true, class: `${type}-disabled`, onClick: click }, + () => '提交' + ) + ]) + ) + } + }) + for (const type of ['primary', 'success']) { + const active = wrapper.get(`.${type}-button`).element + const disabled = wrapper.get(`.${type}-disabled`).element + for (const property of [ + '--n-text-color', + '--n-text-color-hover', + '--n-text-color-pressed', + '--n-text-color-focus' + ]) { + expect(active.style.getPropertyValue(property)).toBe('#ffffff') + } + expect(active.style.getPropertyValue('--n-color')).toBe('#078640') + expect(disabled.style.getPropertyValue('--n-color-disabled')).toBe(dark ? '#303030' : '#e4e4e4') + expect(disabled.style.getPropertyValue('--n-text-color-disabled')).toBe(dark ? '#808080' : '#8c8c8c') + expect(disabled.disabled).toBe(true) + disabled.click() + expect(click).not.toHaveBeenCalled() + } + wrapper.get('.primary-button').element.click() + expect(click).toHaveBeenCalledOnce() + }) + it.each([false, true])('模式 %s 的真实控件无静态描边、使用直角,并保留聚焦与错误反馈', dark => { localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light') vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} })) diff --git a/src/components/ui/layout.test.ts b/src/components/ui/layout.test.ts index 18e6d00..536408b 100644 --- a/src/components/ui/layout.test.ts +++ b/src/components/ui/layout.test.ts @@ -92,7 +92,7 @@ describe('管理后台组件边界', () => { expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2) }) - it('真实主题选择器切换暗色,侧栏保持全部七个工作流入口', async () => { + it('图标主题菜单保留三种模式,设置入口位于侧栏底部并支持折叠', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [ @@ -108,19 +108,56 @@ describe('管理后台组件边界', () => { await flushPromises() expect(wrapper.findAll('.n-menu a')).toHaveLength(8) expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情') - await wrapper.get('.theme-select .n-base-selection').trigger('click') - await flushPromises() - const darkOption = [...document.querySelectorAll('.n-base-select-option')].find( - item => item.textContent?.trim() === '暗黑模式' - ) - expect(darkOption).toBeDefined() - darkOption!.click() - await flushPromises() - expect(document.documentElement.dataset.theme).toBe('dark') - expect(wrapper.getComponent(NConfigProvider).props('theme')?.name).toBe('dark') - expect(localStorage.getItem('drama-studio-theme')).toBe('dark') + expect(wrapper.get('.theme-toggle').text()).toBe('') + expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu') + expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor') + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + for (const [key, label, icon] of [ + ['dark', '暗黑模式', 'moon'], + ['light', '浅色模式', 'sun'], + ['system', '跟随系统', 'monitor'] + ] as const) { + await wrapper.get('.theme-toggle').trigger('click') + await flushPromises() + const option = [...document.querySelectorAll('.n-dropdown-option-body')].find( + item => item.textContent?.trim() === label + ) + expect(option).toBeDefined() + option!.click() + await flushPromises() + expect(localStorage.getItem('drama-studio-theme')).toBe(key) + expect(wrapper.get('.theme-toggle svg').classes()).toContain(`lucide-${icon}`) + expect(wrapper.get('.theme-toggle').attributes('aria-label')).toBe(`主题模式:${label}`) + const expectedTheme = key === 'system' ? systemTheme : key + expect(document.documentElement.dataset.theme).toBe(expectedTheme) + expect(wrapper.getComponent(NConfigProvider).props('theme')?.name ?? 'light').toBe(expectedTheme) + } + expect(wrapper.find('.admin-topbar [aria-label="后端连接"]').exists()).toBe(false) + expect(wrapper.find('.admin-nav-scroll [aria-label="后端连接"]').exists()).toBe(false) + expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接') await wrapper.get('[aria-label="折叠侧栏"]').trigger('click') expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true) + const settings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]') + expect(settings.text()).toBe('') + await settings.trigger('click') + await flushPromises() + expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull() + expect(wrapper.findAll('.admin-nav-scroll .n-menu a')).toHaveLength(8) + }) + + it('侧栏菜单独立滚动,页脚不参与滚动且窄屏不超出视口底部', () => { + const css = readFileSync('src/admin.css', 'utf8') + expect(css).toMatch( + /\.admin-sider-content\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/ + ) + expect(css).toMatch(/\.admin-nav-scroll\.n-scrollbar\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/) + expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/) + expect(css).toMatch(/\.admin-sider:not\([^)]*\)\s*\{[^}]*bottom:\s*0;[^}]*height:\s*auto/) + for (const path of ['storyboard/StoryboardPage.vue', 'production/ProductionPage.vue']) { + const source = readFileSync(`src/features/${path}`, 'utf8') + expect(source).not.toContain('剧集 { diff --git a/src/composables/useTheme.test.ts b/src/composables/useTheme.test.ts index 1eefe08..5338fdf 100644 --- a/src/composables/useTheme.test.ts +++ b/src/composables/useTheme.test.ts @@ -83,7 +83,7 @@ describe('微信风格明暗主题', () => { expect(state.theme.value?.name).toBe('dark') expect(state.overrides.value.common?.primaryColor).toBe('#07c160') expect(state.overrides.value.common?.bodyColor).toBe('#111111') - expect(state.overrides.value.Button?.textColorPrimary).toBe('#082b17') + expect(state.overrides.value.Button?.textColorPrimary).toBe('#ffffff') expect(document.documentElement.dataset.theme).toBe('dark') }) @@ -127,8 +127,13 @@ describe('微信风格明暗主题', () => { expect(Button?.colorHover).toBe(tokens['control-hover']) expect(Select?.peers?.InternalSelection?.color).toBe(tokens.control) expect(Button?.textColorPrimary).toBe(tokens['on-accent']) - for (const background of [common!.primaryColor!, common!.primaryColorHover!, common!.primaryColorPressed!]) { - expect(contrast(String(Button!.textColorPrimary), background)).toBeGreaterThanOrEqual(4.5) + expect(Button?.colorPrimary).toBe(tokens['button-primary']) + expect(Button?.colorHoverPrimary).toBe(tokens['button-hover']) + expect(Button?.colorPressedPrimary).toBe(tokens['button-pressed']) + expect(Button?.colorDisabledPrimary).toBe(tokens['disabled-bg']) + expect(Button?.textColorDisabledPrimary).toBe(tokens['disabled-text']) + for (const background of [Button!.colorPrimary!, Button!.colorHoverPrimary!, Button!.colorPressedPrimary!]) { + expect(contrast(String(Button!.textColorPrimary), String(background))).toBeGreaterThanOrEqual(4.5) } expect(contrast(tokens.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5) expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5) diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index 2d86a77..d17c055 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -28,7 +28,13 @@ export function useTheme() { const primary = '#07c160' const hover = '#2dcb79' const pressed = '#06ad56' - const onPrimary = '#082b17' + // 实心绿按钮使用白字;按钮底色独立于品牌绿,兼顾正常、悬停和按下态的可读性。 + const onPrimary = '#ffffff' + const buttonPrimary = '#078640' + const buttonHover = '#087c42' + const buttonPressed = '#066b36' + const disabledColor = dark ? '#303030' : '#e4e4e4' + const disabledText = dark ? '#808080' : '#8c8c8c' const accentText = dark ? '#5cd693' : '#087c42' const body = dark ? '#111111' : '#ededed' const surface = dark ? '#191919' : '#ffffff' @@ -102,13 +108,43 @@ export function useTheme() { borderPressed: 'none', borderDisabled: 'none', borderFocus: focusBorder, + opacityDisabled: '1', + colorDisabled: disabledColor, + textColorDisabled: disabledText, + colorPrimary: buttonPrimary, + colorHoverPrimary: buttonHover, + colorPressedPrimary: buttonPressed, + colorFocusPrimary: buttonHover, + colorDisabledPrimary: disabledColor, + borderPrimary: 'none', + borderHoverPrimary: 'none', + borderPressedPrimary: 'none', + borderFocusPrimary: 'none', + borderDisabledPrimary: 'none', textColorPrimary: onPrimary, textColorHoverPrimary: onPrimary, textColorPressedPrimary: onPrimary, textColorFocusPrimary: onPrimary, - textColorDisabledPrimary: onPrimary, + textColorDisabledPrimary: disabledText, textColorTextPrimary: accentText, - textColorGhostPrimary: accentText + textColorGhostPrimary: accentText, + colorSuccess: buttonPrimary, + colorHoverSuccess: buttonHover, + colorPressedSuccess: buttonPressed, + colorFocusSuccess: buttonHover, + colorDisabledSuccess: disabledColor, + borderSuccess: 'none', + borderHoverSuccess: 'none', + borderPressedSuccess: 'none', + borderFocusSuccess: 'none', + borderDisabledSuccess: 'none', + textColorSuccess: onPrimary, + textColorHoverSuccess: onPrimary, + textColorPressedSuccess: onPrimary, + textColorFocusSuccess: onPrimary, + textColorDisabledSuccess: disabledText, + textColorTextSuccess: accentText, + textColorGhostSuccess: accentText }, Input: { color: control, @@ -190,7 +226,7 @@ export function useTheme() { borderWarning: 'none', borderError: 'none' }, - Checkbox: { checkMarkColor: onPrimary }, + Checkbox: { checkMarkColor: '#082b17' }, Switch: { railColorActive: primary }, Progress: { fillColor: primary } } satisfies GlobalThemeOverrides diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue index 7f643fa..ab33e05 100644 --- a/src/features/production/ProductionPage.vue +++ b/src/features/production/ProductionPage.vue @@ -123,9 +123,8 @@ watch(