import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { ElMessage } from 'element-plus' import { useAuthStore } from '@/stores/auth' const routes: RouteRecordRaw[] = [ { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { layout: 'blank', public: true, title: '登录' } }, { path: '/status', name: 'status', component: () => import('@/views/ServiceStatusView.vue'), meta: { layout: 'blank', public: true, title: '服务状态' } }, { path: '/admin', component: () => import('@/layouts/AppShell.vue'), meta: { scope: 'Platform' }, redirect: '/admin/dashboard', children: [ { path: 'dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/DashboardView.vue'), meta: { title: '总览' } }, { path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } }, { path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } }, { path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } }, { path: 'cars', name: 'admin-cars', component: () => import('@/views/admin/CarPanelView.vue'), meta: { title: '车辆管理' } }, { path: 'processes', name: 'admin-processes', component: () => import('@/views/admin/ProcessPanelView.vue'), meta: { title: '进程管理' } }, { path: 'scripts', name: 'admin-scripts', component: () => import('@/views/admin/ScriptPanelView.vue'), meta: { title: '脚本管理' } }, { path: 'missions', name: 'admin-missions', component: () => import('@/views/admin/MissionEditorView.vue'), meta: { title: '任务编排' } }, { path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } }, { path: 'playback', name: 'admin-playback', component: () => import('@/views/admin/PlaybackView.vue'), meta: { title: '调度回放' } }, { path: 'config/system', name: 'admin-config-system', component: () => import('@/views/admin/config/SystemConfigView.vue'), meta: { title: '系统级配置' } }, { path: 'config/integrations', name: 'admin-config-integrations', component: () => import('@/views/admin/config/ExternalIntegrationView.vue'), meta: { title: '外部系统对接' } }, { path: 'config/routing', name: 'admin-config-routing', component: () => import('@/views/admin/config/RoutingPolicyView.vue'), meta: { title: '路径规划策略' } }, { path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } }, { path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' }, // 旧独立配置页已合并进车辆运维 Tab,保留深链接兼容。 { path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'maintenance' } } }, { path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'fleet' } } }, { path: 'config/charge', name: 'admin-config-charge', component: () => import('@/views/admin/config/ChargePolicyView.vue'), meta: { title: '充电逻辑' } }, { path: 'config/task', name: 'admin-config-task', component: () => import('@/views/admin/config/TaskAllocationView.vue'), meta: { title: '任务分配' } }, { path: 'config/traffic', name: 'admin-config-traffic', component: () => import('@/views/admin/config/TrafficRuleView.vue'), meta: { title: '交通管制' } }, { path: 'config/auth', name: 'admin-config-auth', component: () => import('@/views/admin/config/AuthRoleView.vue'), meta: { title: '权限角色' } }, { path: 'config/device', name: 'admin-config-device', component: () => import('@/views/admin/config/DeviceHubView.vue'), meta: { title: '设备接入' } }, { path: 'config/scenario', name: 'admin-config-scenario', component: () => import('@/views/admin/config/ScenarioTemplateView.vue'), meta: { title: '场景模板' } }, { path: 'config/location', name: 'admin-config-location', component: () => import('@/views/admin/config/LocationView.vue'), meta: { title: '库位管理' } }, { path: 'config/ops', name: 'admin-config-ops', component: () => import('@/views/admin/config/OpsConfigView.vue'), meta: { title: '运营维护' } }, { path: 'config/widget', name: 'admin-config-widget', component: () => import('@/views/admin/config/CustomWidgetView.vue'), meta: { title: '自定义控件' } }, { path: 'config/map-monitor', name: 'admin-config-map-monitor', component: () => import('@/views/admin/config/MapMonitorConfigView.vue'), meta: { title: '地图监控配置' } } ] }, { path: '/monitor', component: () => import('@/layouts/AppShell.vue'), meta: { scope: 'RCSMonitor' }, redirect: '/monitor/map', children: [ { path: 'dashboard', name: 'monitor-dashboard', component: () => import('@/views/monitor/MonitorDashboardView.vue'), meta: { title: '运营总览' } }, { path: 'vehicle-hub', name: 'monitor-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } }, { path: 'map', name: 'monitor-map', component: () => import('@/views/monitor/MonitorMapView.vue'), meta: { title: '地图监控' } }, { path: 'ops', name: 'monitor-ops', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维操作' } }, { path: 'notes', name: 'monitor-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运营备注' } } ] }, { path: '/', redirect: '/login' }, { path: '/:pathMatch(.*)*', redirect: '/login' } ] /** * RBAC 页面权限:route.name 即「权限页面 Key」(与后端 PageCatalog / EffectivePermissions.allowedPages 对齐)。 * 这里从路由表派生出各 scope 的有序页面列表,供守卫做权限校验与「跳到首个可访问页面」的回退。 */ function childrenPages(parentPath: string): Array<{ name: string; path: string }> { const parent = routes.find((r) => r.path === parentPath) return ((parent?.children ?? []) as RouteRecordRaw[]) .filter((c) => typeof c.name === 'string') .map((c) => ({ name: String(c.name), path: `${parentPath}/${(c as { path: string }).path}` })) } const ADMIN_PAGES = childrenPages('/admin') const MONITOR_PAGES = childrenPages('/monitor') const MANAGED_PAGE_NAMES = new Set([...ADMIN_PAGES, ...MONITOR_PAGES].map((p) => p.name)) const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach(async (to) => { const auth = useAuthStore() if (to.meta.public) return true if (!auth.isAuthed) { return { path: '/login', query: { redirect: to.fullPath } } } // 关键守卫:localStorage 里只要有 token 字符串 isAuthed 就为 true,但服务端可能已经 // 不认这个 token(最常见:Platform.Server 用占位 Jwt:Secret 启动,每次重启 secret 重生 → // 历史 token 全部失效)。如果进入受保护路由后页面恰好没立刻发 API,旧 401 拦截路径不会 // 触发,用户就能「看到」框架页 → 看起来是「跳过了登录页」。 // // 解决:本浏览器会话内首次进入受保护路由前 await 一次 validate(GET /api/auth/me), // 由后端 [Authorize] 判定 token 是否仍有效。失败 → store 已清空、跳 /login 并带 redirect。 if (!auth.validated) { try { await auth.validate() } catch { return { path: '/login', query: { redirect: to.fullPath } } } } // 会话 45 AR-6:switchScope 改为后端发起 —— // 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。 // 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。 const needScope: 'Platform' | 'RCSMonitor' | null = to.path.startsWith('/admin') ? 'Platform' : to.path.startsWith('/monitor') ? 'RCSMonitor' : null if (needScope && auth.scope !== needScope) { try { await auth.switchScope(needScope) } catch (_) { // 偶发失败(网络/超时)重试一次:避免带着「另一个域」的 effectivePermissions 渲染当前页, // 否则配置页会被 PermissionGuard 误判为隐藏 → 整页空白(刷新后重新 switch 才恢复)。 try { await auth.switchScope(needScope) } catch (_2) { /* 仍失败:维持原 scope,UI 多为 readonly */ } } } // RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。 // 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。 const name = typeof to.name === 'string' ? to.name : '' if (name && MANAGED_PAGE_NAMES.has(name) && !auth.hasPage(name)) { const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES const fallback = list.find((p) => auth.hasPage(p.name))?.path if (fallback && fallback !== to.path) { ElMessage.warning('无权访问该页面,已跳转到可访问页面') return fallback } if (!fallback) { ElMessage.error('当前账号在该区域没有任何可访问页面,请联系管理员分配权限') return { path: '/status' } } } return true }) router.afterEach((to) => { const base = '迷毂' document.title = to.meta.title ? `${to.meta.title} · ${base}` : base }) // 懒加载 chunk 失败自愈:重新部署后产物 hash 变化、或网络抖动会让动态 import() 失败, // 表现为点击导航后白屏、刷新才好。这里捕获该类错误并整页重载到目标路由, // 让浏览器重新拉取最新的 index.html 与对应 chunk 引用。 router.onError((error, to) => { const m = (error as Error)?.message ?? '' if (/dynamically imported module|Loading (chunk|CSS chunk)|module script failed|Failed to fetch/i.test(m)) { if (to?.fullPath) window.location.assign(to.fullPath) else window.location.reload() } }) export default router