This commit is contained in:
2026-08-05 15:55:02 +08:00
parent a6828df5b9
commit 356bec03fb
16 changed files with 640 additions and 27 deletions
+2 -2
View File
@@ -60,14 +60,14 @@ test('source contract: aligns the light top bar beside the prototype-inspired da
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/)
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 250px;[^}]*background: linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-bottom: 1px solid rgba\(255, 255, 255, \.09\)/)
assert.match(shellStyles, /\.client-main \{[^}]*padding: 88px 20px 40px;/)
assert.match(shellStyles, /\.client-main \{[^}]*padding: 134px 20px 40px;/)
assert.match(globalStyles, /@media \(max-width: 720px\) \{[\s\S]*\.top-header \{ inset: 0;[^}]*padding: 0 14px; \}/)
assert.match(globalStyles, /\.client-header-menu-button \{ display: grid; \}/)
assert.match(globalStyles, /\.client-header-heading p, \.client-user-copy \{ display: none; \}/)
})
test('source contract: closes the mobile menu on route changes and Escape', () => {
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, closeMobileMenu\)/)
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, \(\) => \{\s*closeMobileMenu\(\)\s*registerCurrentRoute\(\)\s*\}\)/)
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\)/)
+108 -3
View File
@@ -5,11 +5,22 @@ import { ElMessageBox } from 'element-plus'
import { LogOut, Menu, X } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
import WorkspaceTabs from '@/components/WorkspaceTabs.vue'
import { useAuthStore } from '@/modules/auth/public'
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
import { useUnsavedWorkStore } from '@/navigation/workspace-tabs/unsavedWork'
import {
MAX_CACHED_WORKSPACE_VIEWS,
isWorkspaceRoute,
useWorkspaceTabsStore,
type WorkspaceTab,
} from '@/navigation/workspace-tabs/workspaceTabs'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const workspace = useWorkspaceTabsStore()
const unsaved = useUnsavedWorkStore()
const pageTitle = computed(() => route.meta.title || '业务总览')
const pageDescription = computed(() => route.meta.description || '')
const mobileMenuOpen = ref(false)
@@ -19,6 +30,25 @@ const sidebarRef = ref<HTMLElement>()
const sidebarCloseButtonRef = ref<HTMLButtonElement>()
let previousBodyOverflow: string | undefined
let desktopMediaQuery: MediaQueryList | undefined
const scrollPositions = new Map<string, number>()
function isAllowedTab(tab: WorkspaceTab) {
const resolved = router.resolve(tab.fullPath)
if (String(resolved.name ?? '') !== tab.routeName || !isWorkspaceRoute(resolved)) return false
const permission = typeof resolved.meta.permission === 'string' ? resolved.meta.permission : undefined
return auth.hasPermission(permission)
}
function registerCurrentRoute() {
workspace.openRoute(route, unsaved.dirtyRouteKeys)
}
const restoredActivePath = workspace.initialize(auth.user?.id ?? '', isAllowedTab)
if (route.fullPath === '/' && restoredActivePath && restoredActivePath !== '/') {
void router.replace(restoredActivePath)
} else {
registerCurrentRoute()
}
function openMobileMenu() {
mobileMenuOpen.value = true
@@ -44,7 +74,51 @@ function handleDesktopBreakpoint(event: MediaQueryList | MediaQueryListEvent) {
if (event.matches) closeMobileMenu()
}
watch(() => route.fullPath, closeMobileMenu)
watch(() => route.fullPath, () => {
closeMobileMenu()
registerCurrentRoute()
})
watch(
() => String(route.name ?? ''),
async (routeName, previousRouteName) => {
if (previousRouteName && workspace.tabs.some((tab) => tab.key === previousRouteName)) {
scrollPositions.set(previousRouteName, window.scrollY)
}
await nextTick()
window.requestAnimationFrame(() => {
window.scrollTo(0, scrollPositions.get(routeName) ?? 0)
})
},
)
watch(
() => workspace.tabs.map((tab) => tab.key),
(tabKeys) => {
const retainedKeys = new Set(tabKeys)
for (const routeKey of scrollPositions.keys()) {
if (!retainedKeys.has(routeKey)) scrollPositions.delete(routeKey)
}
},
)
watch(
() => auth.permissionCodes.join('|'),
() => {
const result = workspace.reconcile(isAllowedTab)
if (result.closedActive) void router.replace(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
},
)
watch(
() => auth.isAuthenticated,
(authenticated) => {
if (authenticated) return
workspace.clear()
unsaved.clear()
},
)
watch(mobileMenuOpen, async (open, wasOpen) => {
if (open) {
@@ -66,6 +140,7 @@ watch(mobileMenuOpen, async (open, wasOpen) => {
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
window.addEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
handleDesktopBreakpoint(desktopMediaQuery)
@@ -73,21 +148,33 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('beforeunload', handleBeforeUnload)
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
restoreBodyOverflow()
})
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!unsaved.hasDirty) return
event.preventDefault()
event.returnValue = ''
}
async function handleLogout() {
try {
await ElMessageBox.confirm('确定退出客户工作台吗?', '退出登录', {
await ElMessageBox.confirm(unsaved.hasDirty
? '当前还有未保存的修改,退出后这些内容将丢失。确定继续吗?'
: '确定退出客户工作台吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: unsaved.hasDirty ? 'warning' : undefined,
})
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
workspace.clear()
unsaved.clear()
await auth.logout()
await router.replace('/login')
}
@@ -158,12 +245,30 @@ async function handleLogout() {
</nav>
</aside>
<WorkspaceTabs
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
/>
<main
class="client-main"
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
>
<RouterView />
<RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
<component
:is="Component"
v-if="viewRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(viewRoute.name)"
/>
</KeepAlive>
<component
:is="Component"
v-if="viewRoute.meta.keepAlive === false"
:key="String(viewRoute.name ?? viewRoute.fullPath)"
/>
</RouterView>
</main>
</div>
</template>