refactor: 使用 Naive UI 重构后台布局与黑白双主题

This commit is contained in:
GouJ
2026-09-01 16:31:21 +08:00
parent 55516537bf
commit db8c176b55
51 changed files with 3277 additions and 2442 deletions
+20 -38
View File
@@ -1,44 +1,26 @@
<script setup lang="ts">
import {
DialogRoot,
DialogPortal,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose
} from 'reka-ui'
import { X } from '@lucide/vue'
/** 统一弹窗容器,Reka UI 负责焦点锁定、Escape 和无障碍语义。 */
import { NModal, NScrollbar } from 'naive-ui'
/** Naive UI 管理焦点锁定、Esc、遮罩与主题;长弹窗只滚动内部正文。 */
defineProps<{ title: string; description: string; busy?: boolean; wide?: boolean }>()
const open = defineModel<boolean>('open', { default: false })
</script>
<template>
<DialogRoot v-model:open="open">
<slot name="trigger"></slot>
<DialogPortal>
<DialogOverlay class="dialog-overlay" />
<DialogContent
class="dialog-content"
:class="{ 'dialog-wide': wide }"
@interact-outside="busy && $event.preventDefault()"
@escape-key-down="busy && $event.preventDefault()"
>
<div class="flex items-start justify-between gap-4">
<div>
<DialogTitle class="text-xl font-semibold">{{ title }}</DialogTitle
><DialogDescription class="mt-2 text-sm leading-6 text-muted">{{
description
}}</DialogDescription>
</div>
<DialogClose class="icon-button shrink-0" aria-label="关闭弹窗" :disabled="busy"
><X :size="18"
/></DialogClose>
</div>
<slot></slot>
</DialogContent>
</DialogPortal>
</DialogRoot>
<slot name="trigger" />
<NModal
v-model:show="open"
preset="card"
:title="title"
:closable="!busy"
:mask-closable="!busy"
:close-on-esc="!busy"
:style="{ width: wide ? '1040px' : '600px' }"
class="app-dialog"
role="dialog"
:aria-label="title"
>
<NScrollbar class="dialog-body-scroll">
<p class="text-sm leading-6 text-muted">{{ description }}</p>
<slot />
</NScrollbar>
</NModal>
</template>
+8 -5
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { NButton, NImage } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { referenceImageUrl } from '../../lib/assets'
@@ -20,21 +21,23 @@ watch(
<template>
<div class="asset-image">
<img
<NImage
v-if="url && !failed"
:src="url"
:alt="alt"
loading="lazy"
referrerpolicy="no-referrer"
lazy
preview-disabled
object-fit="contain"
:img-props="{ referrerpolicy: 'no-referrer' }"
@error="failed = true"
/>
<span v-else class="asset-image-empty"
><ImageOff :size="22" :stroke-width="1.3" /><span>{{
src ? '图片无法加载' : emptyText || '尚未生成图片'
}}</span
><button v-if="failed && retryable" type="button" class="text-button" @click.stop="failed = false">
><NButton v-if="failed && retryable" @click.stop="failed = false" text size="small">
重试加载
</button></span
</NButton></span
>
</div>
</template>
+9 -9
View File
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { NEmpty } from 'naive-ui'
import { FileText } from '@lucide/vue'
/** 空数据与等待生成共用的轻量占位,不放入演示数据。 */
/** 没有数据时展示真实空态,不创建虚构项目或结果。 */
defineProps<{ title: string; description: string }>()
</script>
<template>
<div class="empty-state">
<FileText :size="28" :stroke-width="1.4" class="mb-4 text-muted" />
<h3 class="font-medium">{{ title }}</h3>
<p class="mt-2 max-w-md text-sm leading-6 text-muted">{{ description }}</p>
<div class="mt-5"><slot></slot></div>
</div>
<NEmpty class="empty-state" :description="title">
<template #icon><FileText :size="30" :stroke-width="1.4" /></template>
<template #extra
><p class="max-w-md text-sm leading-6 text-muted">{{ description }}</p>
<div class="mt-5"><slot /></div
></template>
</NEmpty>
</template>
+16 -8
View File
@@ -1,14 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NTag } from 'naive-ui'
import { statusLabels } from '../../lib/format'
import type { ProjectStatus } from '../../features/projects/types'
/** 状态同时提供文字和颜色,避免只依赖色觉。 */
defineProps<{ status: string; label?: string }>()
/** 状态使用 Naive 标签,同时保留文本,避免仅依赖颜色区分。 */
const props = defineProps<{ status: string; label?: string }>()
const type = computed(() =>
['failed', 'blocked', 'cancelled'].includes(props.status)
? 'error'
: ['completed', 'ready'].includes(props.status)
? 'success'
: ['running', 'generating', 'pending', 'unlocked', 'candidate_pending'].includes(props.status)
? 'warning'
: 'default'
)
</script>
<template>
<span class="status-badge" :data-status="status">
<span class="status-dot" aria-hidden="true"></span
>{{ label || statusLabels[status as ProjectStatus] || status }}
</span>
<NTag :type="type" size="small" :bordered="false" :data-status="status">{{
label || statusLabels[status as ProjectStatus] || status
}}</NTag>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { NScrollbar } from 'naive-ui'
/** 工作区固定标题和操作区;仅内容容器滚动,不向 document 传播滚动。 */
defineProps<{ split?: boolean }>()
</script>
<template>
<section class="workspace-page">
<header v-if="$slots.header" class="workspace-heading"><slot name="header" /></header>
<div v-if="split" class="workspace-split"><slot /></div>
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
</section>
</template>
+89
View File
@@ -0,0 +1,89 @@
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { NConfigProvider, NModal, NScrollbar } from 'naive-ui'
import App from '../../App.vue'
import WorkspacePage from './WorkspacePage.vue'
import AppDialog from './AppDialog.vue'
import { readFileSync } from 'node:fs'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
localStorage.clear()
document.body.innerHTML = ''
})
describe('管理后台组件边界', () => {
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
const adminCss = readFileSync('src/admin.css', 'utf8')
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('固定标题在滚动容器之外,普通工作区只有内容使用滚动组件', () => {
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)
})
it('分栏工作区不再包一层整页滚动', () => {
wrapper = mount(WorkspacePage, {
props: { split: true },
slots: { default: '<aside>目录</aside><article>正文</article>' }
})
expect(wrapper.findComponent(NScrollbar).exists()).toBe(false)
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
})
it('真实主题选择器切换暗色,侧栏保持全部七个工作流入口', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects', component: { render: () => h('p', '项目列表') } },
{
path: '/projects/:projectId/:workspace',
component: { render: () => h(WorkspacePage, {}, () => '镜头详情') }
}
]
})
await router.push('/projects/test/production')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
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')
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
})
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
wrapper = mount(AppDialog, {
attachTo: document.body,
props: { open: true, title: '确认生图', description: '费用确认', busy: true },
slots: { default: '<p>内容</p>' }
})
await flushPromises()
expect(wrapper.getComponent(NModal).props()).toMatchObject({
closable: false,
maskClosable: false,
closeOnEsc: false
})
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
})
})