优化构架

This commit is contained in:
2026-08-03 18:49:18 +08:00
parent 1241d11c01
commit 44c33a6280
133 changed files with 651 additions and 603 deletions
+59
View File
@@ -0,0 +1,59 @@
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import AppLayout from './AppLayout.vue'
vi.mock('element-plus', async (importOriginal) => {
const actual = await importOriginal<typeof import('element-plus')>()
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
})
async function mountLayout(path = '/') {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
auth.permissions = ['platform:warehouse:list']
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<p>page</p>' } },
{ path: '/customers', component: { template: '<p>customers</p>' }, meta: { title: '客户管理', showPageHeading: false } },
{ path: '/login', component: { template: '<p>login</p>' } },
],
})
await router.push(path)
await router.isReady()
const wrapper = mount(AppLayout, {
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
})
return { wrapper, router }
}
describe('AppLayout', () => {
beforeEach(() => vi.mocked(ElMessageBox.confirm).mockReset())
it('shows the authenticated identity without a role preview switcher', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.text()).not.toContain('权限预览')
})
it('hides the shell heading when the route opts out', async () => {
const { wrapper } = await mountLayout('/customers')
expect(wrapper.find('.page-heading').exists()).toBe(false)
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
})
it('logs out after confirmation', async () => {
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
const { wrapper, router } = await mountLayout()
await wrapper.get('[aria-label="退出登录"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
})
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { LogOut, Menu } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
access.initializeAfterLogin()
async function handleLogout() {
try {
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
})
} catch {
return
}
access.reset()
unsaved.clear()
try {
await auth.logout()
} catch (error) {
const details = getApiErrorDetails(error, '服务器会话撤销失败')
const trackingId = details.requestId || details.traceId
ElMessage.warning(`本地已退出,但服务器会话撤销失败${trackingId ? `(追踪标识:${trackingId})` : ''}`)
}
try {
await router.replace('/login')
} catch {
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
}
}
function handleMenuToggle() {
if (window.matchMedia('(max-width: 720px)').matches) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
</script>
<template>
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
<aside class="app-sidebar">
<div class="brand-block">
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
</div>
<nav class="side-nav" aria-label="主导航">
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
</nav>
</aside>
<div class="app-main">
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
<button
class="shell-menu-button"
type="button"
:aria-label="mobileMenuOpen ? '关闭菜单' : collapsed ? '展开导航' : '收起导航'"
:aria-expanded="mobileMenuOpen || !collapsed"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div class="header-user">
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
</div>
</header>
<main class="page-container"><RouterView /></main>
</div>
</div>
</template>
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
</style>