diff --git a/src/components.d.ts b/src/components.d.ts index 34c807c..16c6945 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -26,6 +26,9 @@ declare module 'vue' { ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] @@ -52,6 +55,7 @@ declare module 'vue' { QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + WorkspaceTabs: typeof import('./components/WorkspaceTabs.vue')['default'] } export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/src/components/WorkspaceTabs.vue b/src/components/WorkspaceTabs.vue new file mode 100644 index 0000000..e77d95a --- /dev/null +++ b/src/components/WorkspaceTabs.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/env.d.ts b/src/env.d.ts index 8d37ef6..e2f4247 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -10,5 +10,7 @@ declare module 'vue-router' { description?: string requiresAuth?: boolean permission?: string + workspaceTab?: boolean + keepAlive?: boolean } } diff --git a/src/layouts/AppLayout.test.mjs b/src/layouts/AppLayout.test.mjs index 55e73af..4ad0ffd 100644 --- a/src/layouts/AppLayout.test.mjs +++ b/src/layouts/AppLayout.test.mjs @@ -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\)/) diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index 2b7f5b5..a0a3985 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -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() const sidebarCloseButtonRef = ref() let previousBodyOverflow: string | undefined let desktopMediaQuery: MediaQueryList | undefined +const scrollPositions = new Map() + +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() { + +
- + + + + + +
diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts index 5456266..bf02e5a 100644 --- a/src/modules/first-leg/routes.ts +++ b/src/modules/first-leg/routes.ts @@ -9,6 +9,7 @@ export const firstLegModule = { path: 'orders/first-leg', name: 'first-leg-orders', component: () => import('./views/FirstLegOrdersView.vue'), + props: { createPage: false }, release: 'active', meta: { title: '头程订单', description: '创建、查询并跟进本企业头程订单', permission: 'order:first-leg:list' }, navigation: { key: 'first-leg-orders', title: '头程下单', icon: FileText }, @@ -17,6 +18,7 @@ export const firstLegModule = { path: 'orders/first-leg/create', name: 'first-leg-order-create', component: () => import('./views/FirstLegOrdersView.vue'), + props: { createPage: true }, release: 'active', meta: { title: '创建头程订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' }, }, diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index e66496c..f1d6803 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -12,8 +12,10 @@ test('routes the confirmed first-leg path to the implemented order view', () => test('opens order creation as a dedicated protected page while keeping draft editing in a drawer', () => { assert.match(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'/) + assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?props: \{ createPage: true \}/) assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/) - assert.match(source, /const isCreatePage = computed\(\(\) => route\.name === 'first-leg-order-create'\)/) + assert.match(source, /defineProps<\{ createPage\?: boolean \}>/) + assert.match(source, /const isCreatePage = computed\(\(\) => props\.createPage\)/) assert.match(source, /]*:page="isCreatePage"/) assert.match(editorShellSource, /
/) assert.match(editorShellSource, / { - assert.match(source, /onBeforeRouteLeave/) - assert.match(source, /beforeunload/) + assert.match(source, /useUnsavedWorkRegistration\(/) + assert.match(source, /routeKey: unsavedRouteKey/) + assert.doesNotMatch(source, /onBeforeRouteLeave|beforeunload/) assert.match(source, /confirmDiscardChanges/) assert.match(source, /:before-close="requestCloseEditor"/) assert.match(source, /store\.loadError/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 30878e9..fccf6c9 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -1,6 +1,6 @@