style: 优化按钮禁用态与主题入口并将设置移至侧栏底部

This commit is contained in:
GouJ
2026-09-01 21:58:32 +08:00
parent 03bf87ca9a
commit a24326b85d
11 changed files with 278 additions and 71 deletions
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, h, ref } from 'vue'
import { NButton, NDropdown, NTooltip } from 'naive-ui'
import { Monitor, Moon, Sun } from '@lucide/vue'
import type { ThemePreference } from '../../composables/useTheme'
/** 图标表示用户偏好;保留系统跟随选项,不以简单二态切换覆盖偏好。 */
const preference = defineModel<ThemePreference>({ required: true })
const open = ref(false)
const modes = [
{ key: 'light', label: '浅色模式', icon: Sun },
{ key: 'dark', label: '暗黑模式', icon: Moon },
{ key: 'system', label: '跟随系统', icon: Monitor }
] as const
const current = computed(() => modes.find(mode => mode.key === preference.value) ?? modes[2])
const options = modes.map(mode => ({ ...mode, icon: () => h(mode.icon, { size: 16 }) }))
/** Dropdown 的键类型较宽,只接收受支持的主题值。 */
function selectTheme(key: string | number) {
if (key === 'light' || key === 'dark' || key === 'system') preference.value = key
}
</script>
<template>
<NDropdown v-model:show="open" trigger="click" :value="preference" :options="options" @select="selectTheme">
<NTooltip :disabled="open">
<template #trigger>
<NButton
quaternary
circle
class="theme-toggle"
:aria-label="`主题模式:${current.label}`"
aria-haspopup="menu"
:aria-expanded="open"
>
<component :is="current.icon" :size="18" aria-hidden="true" />
</NButton>
</template>
{{ current.label }} · 切换主题
</NTooltip>
</NDropdown>
</template>
+42
View File
@@ -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<HTMLButtonElement>(`.${type}-button`).element
const disabled = wrapper.get<HTMLButtonElement>(`.${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<HTMLButtonElement>('.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() {} }))
+49 -12
View File
@@ -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<HTMLElement>('.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<HTMLElement>('.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('<span>剧集</span')
expect(source).toMatch(/aria-label="选择(?:分镜|生产)剧集"/)
}
})
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {