chore: initial commit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
|
||||
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||
const sidebarMenuSource = readFileSync(new URL('../components/ClientSidebarMenu.vue', import.meta.url), 'utf8')
|
||||
const sidebarMenuNodeSource = readFileSync(new URL('../components/ClientSidebarMenuNode.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('source contract: renders the grouped sidebar menu and mobile drawer controls', () => {
|
||||
assert.match(layoutSource, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/)
|
||||
assert.match(layoutSource, /import \{[\s\S]*?\bX,[\s\S]*?\} from '@lucide\/vue'/)
|
||||
assert.match(layoutSource, /import ClientSidebarMenu from '@\/components\/ClientSidebarMenu\.vue'/)
|
||||
assert.match(layoutSource, /:class="\{ 'mobile-menu-open': mobileMenuOpen \}"/)
|
||||
assert.match(layoutSource, /<button\s+v-if="mobileMenuOpen"[^>]*class="client-sidebar-scrim"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"/s)
|
||||
assert.match(layoutSource, /<aside\s+ref="sidebarRef"\s+id="client-sidebar"\s+class="client-sidebar"\s+:inert="!desktopViewport && !mobileMenuOpen"\s+:aria-hidden="!desktopViewport && !mobileMenuOpen \? 'true' : undefined"\s*>/)
|
||||
assert.doesNotMatch(layoutSource, /<aside[^>]*aria-label=/)
|
||||
assert.match(layoutSource, /<nav aria-label="客户工作台导航">/)
|
||||
assert.match(layoutSource, /<div class="client-sidebar-header">\s*<strong>功能导航<\/strong>/s)
|
||||
assert.match(layoutSource, /<button[^>]*ref="sidebarCloseButtonRef"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"[^>]*>\s*<X :size="20" \/>/s)
|
||||
assert.match(layoutSource, /<ClientSidebarMenu\s+:active-path="route\.path"\s+@navigate="closeMobileMenu"\s*\/>/s)
|
||||
assert.doesNotMatch(layoutSource, /v-for="item in clientNavigation"/)
|
||||
assert.doesNotMatch(layoutSource, /from '@\/router'/)
|
||||
})
|
||||
|
||||
test('source contract: exposes and opens the controlled mobile sidebar', () => {
|
||||
assert.match(layoutSource, /const mobileMenuOpen = ref\(false\)/)
|
||||
assert.match(layoutSource, /const menuButtonRef = ref<HTMLButtonElement>\(\)/)
|
||||
assert.match(layoutSource, /const sidebarRef = ref<HTMLElement>\(\)/)
|
||||
assert.match(layoutSource, /const sidebarCloseButtonRef = ref<HTMLButtonElement>\(\)/)
|
||||
assert.match(layoutSource, /ref="menuButtonRef"[^>]*aria-label="打开导航"[^>]*aria-controls="client-sidebar"[^>]*:aria-expanded="mobileMenuOpen"[^>]*@click="openMobileMenu"/s)
|
||||
})
|
||||
|
||||
test('source contract: moves focus into the drawer and restores it only after an open-to-closed transition', () => {
|
||||
assert.match(layoutSource, /watch\(mobileMenuOpen, async \(open, wasOpen\) =>/)
|
||||
assert.match(layoutSource, /await nextTick\(\)\s*if \(mobileMenuOpen\.value\) sidebarCloseButtonRef\.value\?\.focus\(\)/)
|
||||
assert.match(layoutSource, /if \(wasOpen\) \{\s*await nextTick\(\)\s*if \(!mobileMenuOpen\.value && !desktopViewport\.value\) menuButtonRef\.value\?\.focus\(\)/s)
|
||||
})
|
||||
|
||||
test('source contract: removes the header and main content from the accessibility tree while the drawer is open', () => {
|
||||
assert.match(layoutSource, /<header\s+class="top-header"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
|
||||
assert.match(layoutSource, /<main\s+class="client-main"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
|
||||
})
|
||||
|
||||
test('source contract: closes the mobile menu on route changes and Escape', () => {
|
||||
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, closeMobileMenu\)/)
|
||||
assert.match(layoutSource, /event\.key === 'Escape'/)
|
||||
assert.match(layoutSource, /onMounted\(\(\) => \{[\s\S]*document\.addEventListener\('keydown', handleKeydown\)[\s\S]*\}\)/)
|
||||
assert.match(layoutSource, /document\.removeEventListener\('keydown', handleKeydown\)/)
|
||||
})
|
||||
|
||||
test('source contract: closes and unlocks the mobile drawer when the viewport enters desktop', () => {
|
||||
assert.match(layoutSource, /const desktopViewport = ref\(true\)/)
|
||||
assert.match(layoutSource, /let desktopMediaQuery: MediaQueryList \| undefined/)
|
||||
assert.match(layoutSource, /function handleDesktopBreakpoint\(event: MediaQueryList \| MediaQueryListEvent\) \{\s*desktopViewport\.value = event\.matches\s*if \(event\.matches\) closeMobileMenu\(\)\s*\}/)
|
||||
assert.match(layoutSource, /desktopMediaQuery = window\.matchMedia\('\(min-width: 721px\)'\)/)
|
||||
assert.match(layoutSource, /desktopMediaQuery\.addEventListener\('change', handleDesktopBreakpoint\)/)
|
||||
assert.match(layoutSource, /handleDesktopBreakpoint\(desktopMediaQuery\)/)
|
||||
assert.match(layoutSource, /desktopMediaQuery\?\.removeEventListener\('change', handleDesktopBreakpoint\)/)
|
||||
})
|
||||
|
||||
test('source contract: locks body scrolling while open and restores it on close or unmount', () => {
|
||||
assert.match(layoutSource, /watch\(mobileMenuOpen, async \(open, wasOpen\) =>/)
|
||||
assert.match(layoutSource, /previousBodyOverflow = document\.body\.style\.overflow/)
|
||||
assert.match(layoutSource, /document\.body\.style\.overflow = 'hidden'/)
|
||||
assert.match(layoutSource, /document\.body\.style\.overflow = previousBodyOverflow/)
|
||||
assert.match(layoutSource, /onBeforeUnmount\(\(\) => \{[\s\S]*restoreBodyOverflow\(\)[\s\S]*\}\)/)
|
||||
})
|
||||
|
||||
test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => {
|
||||
assert.match(layoutSource, /async function handleLogout\(\) \{\s*try \{/)
|
||||
assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/)
|
||||
})
|
||||
|
||||
test('source contract: uses a native recursive disclosure list without Element menu roles', () => {
|
||||
assert.doesNotMatch(sidebarMenuSource, /MenuInstance|<el-menu|openNavigationParentKeys/)
|
||||
assert.doesNotMatch(sidebarMenuNodeSource, /<el-(?:menu-item|sub-menu)|role="(?:menubar|menu|menuitem)"/)
|
||||
assert.match(sidebarMenuSource, /filterNavigationByPermissions\(clientNavigation, auth\.permissionCodes\)/)
|
||||
assert.match(sidebarMenuSource, /const activeParentKeys = computed\(\(\) => new Set\(getNavigationParentKeys\(visibleNavigation\.value, props\.activePath\)\)\)/)
|
||||
assert.match(sidebarMenuSource, /const openedKeys = ref<ReadonlySet<string>>\(new Set\(activeParentKeys\.value\)\)/)
|
||||
assert.match(sidebarMenuSource, /openedKeys\.value = new Set\(\[\.\.\.openedKeys\.value, \.\.\.activeParentKeys\.value\]\)/)
|
||||
assert.match(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
|
||||
assert.match(sidebarMenuSource, /<ul class="client-nav-menu">/)
|
||||
assert.match(sidebarMenuSource, /:active-path="activePath"/)
|
||||
assert.match(sidebarMenuSource, /:opened-keys="openedKeys"/)
|
||||
assert.match(sidebarMenuSource, /:active-parent-keys="activeParentKeys"/)
|
||||
assert.match(sidebarMenuSource, /@toggle="handleToggle"/)
|
||||
assert.match(sidebarMenuNodeSource, /<li\s+class="client-nav-node"[\s\S]*?v-if="node\.children"/)
|
||||
assert.match(sidebarMenuNodeSource, /<button[\s\S]*?:aria-expanded="openedKeys\.has\(node\.key\)"[\s\S]*?:aria-controls="groupId"[\s\S]*?@click="emit\('toggle', node\.key\)"/)
|
||||
assert.match(sidebarMenuNodeSource, /<ul\s+v-show="openedKeys\.has\(node\.key\)"\s+:id="groupId"\s+class="client-nav-submenu"\s*>/)
|
||||
assert.match(sidebarMenuNodeSource, /<RouterLink[\s\S]*?:aria-current="node\.path === activePath \? 'page' : undefined"[\s\S]*?@click="emit\('navigate', node\.path\)"/)
|
||||
assert.match(sidebarMenuNodeSource, /@navigate="emit\('navigate', \$event\)"/)
|
||||
assert.match(sidebarMenuNodeSource, /@toggle="emit\('toggle', \$event\)"/)
|
||||
})
|
||||
|
||||
test('source contract: keeps navigation data out of the router module', () => {
|
||||
assert.doesNotMatch(routerSource, /clientNavigationTree/)
|
||||
assert.doesNotMatch(routerSource, /getNavigationLeaves/)
|
||||
assert.doesNotMatch(routerSource, /export const clientNavigation/)
|
||||
})
|
||||
|
||||
test('source contract: routes denied pages to an explicit 403 state', () => {
|
||||
assert.match(routerSource, /name: 'forbidden'/)
|
||||
assert.match(routerSource, /!auth\.hasPermission\(permission\)/)
|
||||
assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/)
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Building2,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
import { PRODUCT_NAME } from '@chongchong/shared'
|
||||
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const pageTitle = computed(() => route.meta.title || '业务总览')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const mobileMenuOpen = ref(false)
|
||||
const desktopViewport = ref(true)
|
||||
const menuButtonRef = ref<HTMLButtonElement>()
|
||||
const sidebarRef = ref<HTMLElement>()
|
||||
const sidebarCloseButtonRef = ref<HTMLButtonElement>()
|
||||
let previousBodyOverflow: string | undefined
|
||||
let desktopMediaQuery: MediaQueryList | undefined
|
||||
|
||||
function openMobileMenu() {
|
||||
mobileMenuOpen.value = true
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
function restoreBodyOverflow() {
|
||||
if (previousBodyOverflow === undefined) return
|
||||
|
||||
document.body.style.overflow = previousBodyOverflow
|
||||
previousBodyOverflow = undefined
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') closeMobileMenu()
|
||||
}
|
||||
|
||||
function handleDesktopBreakpoint(event: MediaQueryList | MediaQueryListEvent) {
|
||||
desktopViewport.value = event.matches
|
||||
if (event.matches) closeMobileMenu()
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, closeMobileMenu)
|
||||
|
||||
watch(mobileMenuOpen, async (open, wasOpen) => {
|
||||
if (open) {
|
||||
previousBodyOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
await nextTick()
|
||||
if (mobileMenuOpen.value) sidebarCloseButtonRef.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
restoreBodyOverflow()
|
||||
|
||||
if (wasOpen) {
|
||||
await nextTick()
|
||||
if (!mobileMenuOpen.value && !desktopViewport.value) menuButtonRef.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
|
||||
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
|
||||
handleDesktopBreakpoint(desktopMediaQuery)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
|
||||
restoreBodyOverflow()
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出客户工作台吗?', '退出登录', {
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen }">
|
||||
<header
|
||||
class="top-header"
|
||||
:inert="mobileMenuOpen"
|
||||
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||
>
|
||||
<RouterLink class="client-brand" to="/">
|
||||
<span class="client-brand-mark">CC</span>
|
||||
<span><strong>{{ PRODUCT_NAME }}</strong><small>客户工作台</small></span>
|
||||
</RouterLink>
|
||||
<div class="tenant-name"><Building2 :size="17" /> {{ auth.user?.tenantName }}</div>
|
||||
<div class="client-user">
|
||||
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
|
||||
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
|
||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
v-if="mobileMenuOpen"
|
||||
type="button"
|
||||
class="client-sidebar-scrim"
|
||||
aria-label="关闭导航"
|
||||
@click="closeMobileMenu"
|
||||
/>
|
||||
|
||||
<aside
|
||||
ref="sidebarRef"
|
||||
id="client-sidebar"
|
||||
class="client-sidebar"
|
||||
:inert="!desktopViewport && !mobileMenuOpen"
|
||||
:aria-hidden="!desktopViewport && !mobileMenuOpen ? 'true' : undefined"
|
||||
>
|
||||
<div class="client-sidebar-header">
|
||||
<strong>功能导航</strong>
|
||||
<button
|
||||
ref="sidebarCloseButtonRef"
|
||||
type="button"
|
||||
aria-label="关闭导航"
|
||||
@click="closeMobileMenu"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label="客户工作台导航">
|
||||
<ClientSidebarMenu
|
||||
:active-path="route.path"
|
||||
@navigate="closeMobileMenu"
|
||||
/>
|
||||
</nav>
|
||||
<div class="sidebar-help"><strong>需要帮助?</strong><span>联系您的专属客户经理</span></div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="client-main"
|
||||
:inert="mobileMenuOpen"
|
||||
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||
>
|
||||
<div class="client-page-heading">
|
||||
<button
|
||||
ref="menuButtonRef"
|
||||
type="button"
|
||||
aria-label="打开导航"
|
||||
aria-controls="client-sidebar"
|
||||
:aria-expanded="mobileMenuOpen"
|
||||
@click="openMobileMenu"
|
||||
>
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<div><h1>{{ pageTitle }}</h1><p>{{ pageDescription }}</p></div>
|
||||
</div>
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user