refactor: 使用 Naive UI 重构后台布局与黑白双主题
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user