style: 优化按钮禁用态与主题入口并将设置移至侧栏底部
This commit is contained in:
@@ -99,7 +99,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
- 分镜设计与镜头生产共用两级镜头目录:分组头展示 `BEAT 01` 和镜头数,组内仅显示“镜头 01”、标题及状态。分组头在本组内吸顶;窄屏横向模式才在条目上补回 Beat 归属。点击仍使用正式 Shot ID,不会混淆不同 Beat 的同号镜头。
|
- 分镜设计与镜头生产共用两级镜头目录:分组头展示 `BEAT 01` 和镜头数,组内仅显示“镜头 01”、标题及状态。分组头在本组内吸顶;窄屏横向模式才在条目上补回 Beat 归属。点击仍使用正式 Shot ID,不会混淆不同 Beat 的同号镜头。
|
||||||
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
|
- 身份、分镜、生产与图库的批量配置、说明和诊断统一放入 `WorkspaceTools`(Naive UI Drawer),按需覆盖当前工作区,不挤压列表。面板支持 Esc 或关闭按钮;关闭不会取消任务或清除配置。历史回执可从入口查看,新回执主动打开诊断;查询错误始终在外层可见。
|
||||||
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
- 生产步骤的操作按钮保留上下间距;`ConfirmAction` 用稳定根元素承接外部间距,避免多根弹窗组件吞掉 `class`。
|
||||||
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
- 右上角主题入口以太阳/月亮/显示器图标显示当前偏好,点击选择浅色、暗黑或跟随系统。默认跟随系统,选择保存在本机浏览器;存储被禁用时仍可在当前会话切换。后端连接设置固定在左侧导航底部,折叠时仅显示图标,导航菜单独立滚动。
|
||||||
|
- 实心绿色按钮采用白色文字,正常/悬停/按下底色分别调整以保证可读性;禁用按钮为灰底灰字,保留原生 disabled 行为。分镜与生产页的剧集选择器不重复显示“剧集”前缀,保留可访问名称。
|
||||||
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
- 采用微信风格配色:浅色为浅灰背景与白色内容,暗色为近黑背景与深灰内容;绿色用于主操作、选中态和进度。按钮文字、目录文字与主绿色分别配置以保证对比度,错误/警告仍使用独立语义色。Naive UI 和业务内容共用主题,浏览器主题色随之切换;加载前应用偏好,减少主题闪烁。
|
||||||
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
- 全站采用低边框、直角样式:面板、提示框、标签、图片卡片和表单用背景层次代替静态描边;表格与长记录列表使用交替底色,抽屉用灰底承托内容。保留输入框聚焦/校验反馈、复选框、选中指示和必要的浮层阴影;开关、加载等功能图形不强制改形状。
|
||||||
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
||||||
|
|||||||
+31
-29
@@ -9,7 +9,7 @@ import {
|
|||||||
NLayoutHeader,
|
NLayoutHeader,
|
||||||
NLayoutSider,
|
NLayoutSider,
|
||||||
NMenu,
|
NMenu,
|
||||||
NSelect,
|
NScrollbar,
|
||||||
NTooltip,
|
NTooltip,
|
||||||
zhCN,
|
zhCN,
|
||||||
dateZhCN,
|
dateZhCN,
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { AppDialog } from './components/ui'
|
import { AppDialog } from './components/ui'
|
||||||
import { useTheme } from './composables/useTheme'
|
import { useTheme } from './composables/useTheme'
|
||||||
|
import ThemeToggle from './components/ui/ThemeToggle.vue'
|
||||||
|
|
||||||
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -38,11 +39,6 @@ const settingsOpen = ref(false)
|
|||||||
const { preference, theme, overrides } = useTheme()
|
const { preference, theme, overrides } = useTheme()
|
||||||
const projectId = computed(() => String(route.params.projectId || ''))
|
const projectId = computed(() => String(route.params.projectId || ''))
|
||||||
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
const themeOptions = [
|
|
||||||
{ label: '浅色模式', value: 'light' },
|
|
||||||
{ label: '暗黑模式', value: 'dark' },
|
|
||||||
{ label: '跟随系统', value: 'system' }
|
|
||||||
]
|
|
||||||
const workflowItems = [
|
const workflowItems = [
|
||||||
['create-drama', '剧本创作', FileText],
|
['create-drama', '剧本创作', FileText],
|
||||||
['breakdown', '剧本拆解', Layers],
|
['breakdown', '剧本拆解', Layers],
|
||||||
@@ -90,19 +86,40 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:native-scrollbar="false"
|
:native-scrollbar="false"
|
||||||
class="admin-sider"
|
class="admin-sider"
|
||||||
|
content-class="admin-sider-content"
|
||||||
>
|
>
|
||||||
<RouterLink to="/projects" class="admin-brand" aria-label="短剧工作台首页">
|
<RouterLink to="/projects" class="admin-brand" aria-label="短剧工作台首页">
|
||||||
<Clapperboard :size="24" /><span v-if="!collapsed"
|
<Clapperboard :size="24" /><span v-if="!collapsed"
|
||||||
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
|
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
|
||||||
>
|
>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<NMenu
|
<NScrollbar class="admin-nav-scroll">
|
||||||
:value="route.path"
|
<NMenu
|
||||||
:options="menuOptions"
|
:value="route.path"
|
||||||
:collapsed="collapsed"
|
:options="menuOptions"
|
||||||
:collapsed-width="64"
|
:collapsed="collapsed"
|
||||||
:collapsed-icon-size="19"
|
:collapsed-width="64"
|
||||||
/>
|
:collapsed-icon-size="19"
|
||||||
|
/>
|
||||||
|
</NScrollbar>
|
||||||
|
<footer class="admin-sider-footer">
|
||||||
|
<NTooltip placement="right" :disabled="!collapsed">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton
|
||||||
|
quaternary
|
||||||
|
block
|
||||||
|
class="admin-settings-button"
|
||||||
|
:class="{ 'is-collapsed': collapsed }"
|
||||||
|
aria-label="后端连接"
|
||||||
|
@click="settingsOpen = true"
|
||||||
|
>
|
||||||
|
<template #icon><Settings2 :size="18" /></template>
|
||||||
|
<span v-if="!collapsed">后端连接</span>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
后端连接
|
||||||
|
</NTooltip>
|
||||||
|
</footer>
|
||||||
</NLayoutSider>
|
</NLayoutSider>
|
||||||
<NLayout class="admin-main">
|
<NLayout class="admin-main">
|
||||||
<NLayoutHeader class="admin-topbar">
|
<NLayoutHeader class="admin-topbar">
|
||||||
@@ -117,22 +134,7 @@ const menuOptions = computed<MenuOption[]>(() => [
|
|||||||
</NButton>
|
</NButton>
|
||||||
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
|
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<ThemeToggle v-model="preference" />
|
||||||
<NSelect
|
|
||||||
v-model:value="preference"
|
|
||||||
:options="themeOptions"
|
|
||||||
:virtual-scroll="false"
|
|
||||||
size="small"
|
|
||||||
class="theme-select"
|
|
||||||
aria-label="主题模式"
|
|
||||||
/>
|
|
||||||
<NTooltip
|
|
||||||
><template #trigger
|
|
||||||
><NButton quaternary circle aria-label="后端连接" @click="settingsOpen = true"
|
|
||||||
><Settings2 :size="18" /></NButton></template
|
|
||||||
>后端连接</NTooltip
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</NLayoutHeader>
|
</NLayoutHeader>
|
||||||
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
|
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
|
||||||
</NLayout>
|
</NLayout>
|
||||||
|
|||||||
+46
-10
@@ -12,7 +12,12 @@
|
|||||||
--app-accent: #07c160;
|
--app-accent: #07c160;
|
||||||
--app-accent-hover: #2dcb79;
|
--app-accent-hover: #2dcb79;
|
||||||
--app-accent-text: #087c42;
|
--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-selected: #e4f4e9;
|
||||||
--app-shadow: #00000018;
|
--app-shadow: #00000018;
|
||||||
--app-danger: #a93232;
|
--app-danger: #a93232;
|
||||||
@@ -26,6 +31,8 @@
|
|||||||
--app-subtle: #232323;
|
--app-subtle: #232323;
|
||||||
--app-control: #2b2b2b;
|
--app-control: #2b2b2b;
|
||||||
--app-control-hover: #353535;
|
--app-control-hover: #353535;
|
||||||
|
--app-disabled-bg: #303030;
|
||||||
|
--app-disabled-text: #808080;
|
||||||
--app-border: #333333;
|
--app-border: #333333;
|
||||||
--app-inverse: #111111;
|
--app-inverse: #111111;
|
||||||
--app-accent-text: #5cd693;
|
--app-accent-text: #5cd693;
|
||||||
@@ -65,6 +72,34 @@ body {
|
|||||||
.admin-sider {
|
.admin-sider {
|
||||||
height: 100%;
|
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;只铺满导航行,不改图标与文字缩进。 */
|
/* Naive 菜单背景默认左右内缩 8px;只铺满导航行,不改图标与文字缩进。 */
|
||||||
.admin-sider .n-menu .n-menu-item-content::before {
|
.admin-sider .n-menu .n-menu-item-content::before {
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -73,6 +108,7 @@ body {
|
|||||||
}
|
}
|
||||||
.admin-brand {
|
.admin-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: 74px;
|
height: 74px;
|
||||||
@@ -108,6 +144,10 @@ body {
|
|||||||
.n-button {
|
.n-button {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
.n-button:focus-visible {
|
||||||
|
outline: 2px solid var(--app-accent-text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
.admin-sider .n-menu-divider {
|
.admin-sider .n-menu-divider {
|
||||||
height: 12px;
|
height: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -119,8 +159,10 @@ body {
|
|||||||
.workspace-tools-drawer .n-drawer-body {
|
.workspace-tools-drawer .n-drawer-body {
|
||||||
background: var(--app-body);
|
background: var(--app-body);
|
||||||
}
|
}
|
||||||
.theme-select {
|
.n-button.theme-toggle {
|
||||||
width: 120px;
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
}
|
}
|
||||||
.admin-content {
|
.admin-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -213,10 +255,6 @@ body {
|
|||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.toolbar-episode > span {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--app-muted);
|
|
||||||
}
|
|
||||||
.toolbar-episode .n-select {
|
.toolbar-episode .n-select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -1160,9 +1198,6 @@ body {
|
|||||||
.production-video {
|
.production-video {
|
||||||
background: #080808;
|
background: #080808;
|
||||||
}
|
}
|
||||||
.button-primary {
|
|
||||||
color: var(--app-on-accent);
|
|
||||||
}
|
|
||||||
.skip-link:focus {
|
.skip-link:focus {
|
||||||
z-index: 3000;
|
z-index: 3000;
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1220,7 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 44px;
|
top: 44px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
height: auto;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
box-shadow: 6px 0 24px var(--app-shadow);
|
box-shadow: 6px 0 24px var(--app-shadow);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,6 +16,48 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('全站平面主题', () => {
|
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 => {
|
it.each([false, true])('模式 %s 的真实控件无静态描边、使用直角,并保留聚焦与错误反馈', dark => {
|
||||||
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
|
||||||
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('管理后台组件边界', () => {
|
|||||||
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
|
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('真实主题选择器切换暗色,侧栏保持全部七个工作流入口', async () => {
|
it('图标主题菜单保留三种模式,设置入口位于侧栏底部并支持折叠', async () => {
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -108,19 +108,56 @@ describe('管理后台组件边界', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.findAll('.n-menu a')).toHaveLength(8)
|
expect(wrapper.findAll('.n-menu a')).toHaveLength(8)
|
||||||
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||||
await wrapper.get('.theme-select .n-base-selection').trigger('click')
|
expect(wrapper.get('.theme-toggle').text()).toBe('')
|
||||||
await flushPromises()
|
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
|
||||||
const darkOption = [...document.querySelectorAll<HTMLElement>('.n-base-select-option')].find(
|
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
|
||||||
item => item.textContent?.trim() === '暗黑模式'
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
)
|
for (const [key, label, icon] of [
|
||||||
expect(darkOption).toBeDefined()
|
['dark', '暗黑模式', 'moon'],
|
||||||
darkOption!.click()
|
['light', '浅色模式', 'sun'],
|
||||||
await flushPromises()
|
['system', '跟随系统', 'monitor']
|
||||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
] as const) {
|
||||||
expect(wrapper.getComponent(NConfigProvider).props('theme')?.name).toBe('dark')
|
await wrapper.get('.theme-toggle').trigger('click')
|
||||||
expect(localStorage.getItem('drama-studio-theme')).toBe('dark')
|
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')
|
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
|
||||||
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
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 () => {
|
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe('微信风格明暗主题', () => {
|
|||||||
expect(state.theme.value?.name).toBe('dark')
|
expect(state.theme.value?.name).toBe('dark')
|
||||||
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
||||||
expect(state.overrides.value.common?.bodyColor).toBe('#111111')
|
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')
|
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,8 +127,13 @@ describe('微信风格明暗主题', () => {
|
|||||||
expect(Button?.colorHover).toBe(tokens['control-hover'])
|
expect(Button?.colorHover).toBe(tokens['control-hover'])
|
||||||
expect(Select?.peers?.InternalSelection?.color).toBe(tokens.control)
|
expect(Select?.peers?.InternalSelection?.color).toBe(tokens.control)
|
||||||
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
|
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
|
||||||
for (const background of [common!.primaryColor!, common!.primaryColorHover!, common!.primaryColorPressed!]) {
|
expect(Button?.colorPrimary).toBe(tokens['button-primary'])
|
||||||
expect(contrast(String(Button!.textColorPrimary), background)).toBeGreaterThanOrEqual(4.5)
|
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.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5)
|
||||||
expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5)
|
expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5)
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ export function useTheme() {
|
|||||||
const primary = '#07c160'
|
const primary = '#07c160'
|
||||||
const hover = '#2dcb79'
|
const hover = '#2dcb79'
|
||||||
const pressed = '#06ad56'
|
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 accentText = dark ? '#5cd693' : '#087c42'
|
||||||
const body = dark ? '#111111' : '#ededed'
|
const body = dark ? '#111111' : '#ededed'
|
||||||
const surface = dark ? '#191919' : '#ffffff'
|
const surface = dark ? '#191919' : '#ffffff'
|
||||||
@@ -102,13 +108,43 @@ export function useTheme() {
|
|||||||
borderPressed: 'none',
|
borderPressed: 'none',
|
||||||
borderDisabled: 'none',
|
borderDisabled: 'none',
|
||||||
borderFocus: focusBorder,
|
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,
|
textColorPrimary: onPrimary,
|
||||||
textColorHoverPrimary: onPrimary,
|
textColorHoverPrimary: onPrimary,
|
||||||
textColorPressedPrimary: onPrimary,
|
textColorPressedPrimary: onPrimary,
|
||||||
textColorFocusPrimary: onPrimary,
|
textColorFocusPrimary: onPrimary,
|
||||||
textColorDisabledPrimary: onPrimary,
|
textColorDisabledPrimary: disabledText,
|
||||||
textColorTextPrimary: accentText,
|
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: {
|
Input: {
|
||||||
color: control,
|
color: control,
|
||||||
@@ -190,7 +226,7 @@ export function useTheme() {
|
|||||||
borderWarning: 'none',
|
borderWarning: 'none',
|
||||||
borderError: 'none'
|
borderError: 'none'
|
||||||
},
|
},
|
||||||
Checkbox: { checkMarkColor: onPrimary },
|
Checkbox: { checkMarkColor: '#082b17' },
|
||||||
Switch: { railColorActive: primary },
|
Switch: { railColorActive: primary },
|
||||||
Progress: { fillColor: primary }
|
Progress: { fillColor: primary }
|
||||||
} satisfies GlobalThemeOverrides
|
} satisfies GlobalThemeOverrides
|
||||||
|
|||||||
@@ -123,9 +123,8 @@ watch(
|
|||||||
<WorkspacePage split compact
|
<WorkspacePage split compact
|
||||||
><template #header
|
><template #header
|
||||||
><div class="workspace-toolbar">
|
><div class="workspace-toolbar">
|
||||||
<label class="toolbar-episode"
|
<div class="toolbar-episode">
|
||||||
><span>剧集</span
|
<NSelect
|
||||||
><NSelect
|
|
||||||
:value="episodeNo"
|
:value="episodeNo"
|
||||||
aria-label="选择生产剧集"
|
aria-label="选择生产剧集"
|
||||||
@update:value="selectedEpisode = Number($event)"
|
@update:value="selectedEpisode = Number($event)"
|
||||||
@@ -137,8 +136,8 @@ watch(
|
|||||||
}))
|
}))
|
||||||
]"
|
]"
|
||||||
class="select-control"
|
class="select-control"
|
||||||
></NSelect
|
></NSelect>
|
||||||
></label>
|
</div>
|
||||||
<span class="toolbar-summary"
|
<span class="toolbar-summary"
|
||||||
>本集 {{ shots.length }} 镜<span v-if="videoRunning">
|
>本集 {{ shots.length }} 镜<span v-if="videoRunning">
|
||||||
· {{ videoRunning }} 个视频任务进行中</span
|
· {{ videoRunning }} 个视频任务进行中</span
|
||||||
|
|||||||
@@ -100,9 +100,8 @@ watch(
|
|||||||
<WorkspacePage split compact class="storyboard-workspace-page"
|
<WorkspacePage split compact class="storyboard-workspace-page"
|
||||||
><template #header
|
><template #header
|
||||||
><div class="workspace-toolbar">
|
><div class="workspace-toolbar">
|
||||||
<label class="toolbar-episode"
|
<div class="toolbar-episode">
|
||||||
><span>剧集</span
|
<NSelect
|
||||||
><NSelect
|
|
||||||
:value="episodeNo"
|
:value="episodeNo"
|
||||||
aria-label="选择分镜剧集"
|
aria-label="选择分镜剧集"
|
||||||
@update:value="selectedEpisode = Number($event)"
|
@update:value="selectedEpisode = Number($event)"
|
||||||
@@ -114,8 +113,8 @@ watch(
|
|||||||
}))
|
}))
|
||||||
]"
|
]"
|
||||||
class="select-control"
|
class="select-control"
|
||||||
></NSelect
|
></NSelect>
|
||||||
></label>
|
</div>
|
||||||
<div class="toolbar-metrics">
|
<div class="toolbar-metrics">
|
||||||
<span v-for="item in coverage" :key="item.title"
|
<span v-for="item in coverage" :key="item.title"
|
||||||
>{{ item.title }} {{ item.count }} / {{ item.total }}</span
|
>{{ item.title }} {{ item.count }} / {{ item.total }}</span
|
||||||
|
|||||||
+10
-2
@@ -152,11 +152,19 @@
|
|||||||
border-color 0.15s;
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.button-primary {
|
.button-primary {
|
||||||
background: var(--app-accent);
|
background: var(--app-button-primary);
|
||||||
color: var(--app-on-accent);
|
color: var(--app-on-accent);
|
||||||
}
|
}
|
||||||
.button-primary:hover:not(:disabled) {
|
.button-primary:hover:not(:disabled) {
|
||||||
background: var(--app-accent-hover);
|
background: var(--app-button-hover);
|
||||||
|
}
|
||||||
|
.button-primary:active:not(:disabled) {
|
||||||
|
background: var(--app-button-pressed);
|
||||||
|
}
|
||||||
|
.button:disabled {
|
||||||
|
background: var(--app-disabled-bg);
|
||||||
|
color: var(--app-disabled-text);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.button-secondary {
|
.button-secondary {
|
||||||
background: var(--app-control);
|
background: var(--app-control);
|
||||||
|
|||||||
Reference in New Issue
Block a user