下线运营总览幽灵页,补 ExpandKeysForScope / PlatformToMonitor,同步导航与角色配置 UI。 Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
13 KiB
TypeScript
232 lines
13 KiB
TypeScript
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { isWizardSetupActive, WIZARD_SETUP_ROUTES } from '@/utils/wizardSetup'
|
||
|
||
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: '服务状态' }
|
||
},
|
||
{
|
||
// 部署配置向导:首次部署强制完成平台选型(导航方式 / 模块 / 功能)。需登录,但不属于 admin/monitor scope。
|
||
path: '/wizard',
|
||
name: 'wizard',
|
||
component: () => import('@/views/WizardView.vue'),
|
||
meta: { layout: 'blank', 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: 'setup', name: 'admin-setup', component: () => import('@/views/admin/SetupChecklistView.vue'), meta: { title: '初始配置' } },
|
||
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
|
||
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } },
|
||
{ path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } },
|
||
{ path: 'ops-log', name: 'admin-ops-log', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维记录' } },
|
||
{ path: 'notes', name: 'admin-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运维备注' } },
|
||
{ path: 'logs', name: 'admin-logs', component: () => import('@/views/admin/LogManagementView.vue'), meta: { title: '日志管理' } },
|
||
{ path: 'playback', name: 'admin-playback', component: () => import('@/views/admin/PlaybackView.vue'), meta: { title: '调度回放' } },
|
||
{ path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.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: 'task-templates', name: 'admin-task-templates', component: () => import('@/views/admin/TaskTemplateView.vue'), meta: { title: '任务编排' } },
|
||
{ path: 'wcs-template-proto', name: 'admin-wcs-template-proto', component: () => import('@/views/admin/WcsTemplateProtoView.vue'), meta: { title: 'WCS模板引擎原型' } },
|
||
{ path: 'simple-fields', name: 'admin-simple-fields', component: () => import('@/views/admin/SimpleFieldManagementView.vue'), meta: { title: '字段管理' } },
|
||
{ path: 'project-properties', name: 'admin-project-properties', component: () => import('@/views/admin/ProjectPropertiesView.vue'), meta: { title: '项目属性' } },
|
||
{ path: 'data-center', redirect: '/admin/data-center/stations' },
|
||
{
|
||
path: 'data-center/:tableId',
|
||
name: 'admin-data-center',
|
||
component: () => import('@/views/admin/DataCenterView.vue'),
|
||
meta: { title: '数据中心' }
|
||
},
|
||
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
||
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
||
{ path: 'config/facility', name: 'admin-config-facility', component: () => import('@/views/admin/config/FacilityConfigView.vue'), meta: { title: '设备接入' } },
|
||
{ path: 'config/warehouse', name: 'admin-config-warehouse', component: () => import('@/views/admin/WarehouseManagementView.vue'), meta: { title: '仓储管理' } },
|
||
{ path: 'config/ops-center', name: 'admin-config-ops-center', component: () => import('@/views/admin/config/OpsCenterView.vue'), meta: { title: '监控配置' } },
|
||
{ path: 'config/system', name: 'admin-config-system', component: () => import('@/views/admin/config/SystemConfigView.vue'), meta: { title: '系统配置' } },
|
||
{ path: 'config/auth', name: 'admin-config-auth', component: () => import('@/views/admin/config/AuthRoleView.vue'), meta: { title: '权限与角色' } },
|
||
|
||
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
|
||
{ path: 'config/ops-center/playback', redirect: '/admin/playback' },
|
||
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
|
||
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
|
||
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
|
||
{ path: 'config/routing', redirect: '/admin/dashboard' },
|
||
{ path: 'config/task', redirect: '/admin/dashboard' },
|
||
{ path: 'config/traffic', redirect: '/admin/dashboard' },
|
||
{ path: 'config/charge', redirect: '/admin/dashboard' },
|
||
{ path: 'config/strategy', redirect: '/admin/dashboard' },
|
||
{ path: 'config/device', redirect: '/admin/config/facility' },
|
||
{ path: 'config/location', redirect: '/admin/config/warehouse' },
|
||
{ path: 'config/business', redirect: '/admin/dashboard' },
|
||
{ path: 'config/integrations', redirect: '/admin/dashboard' },
|
||
{ path: 'config/scenario', redirect: '/admin/dashboard' },
|
||
{ path: 'config/widget', redirect: '/admin/dashboard' },
|
||
{ path: 'config/ops', redirect: { path: '/admin/config/ops-center', query: { tab: 'ops' } } },
|
||
{ path: 'config/logs', redirect: '/admin/logs' },
|
||
{ path: 'config/map-monitor', redirect: { path: '/admin/config/ops-center', query: { tab: 'map-monitor' } } },
|
||
{ path: 'config/system-center', redirect: (to) => (to.query.tab === 'auth' ? '/admin/config/auth' : '/admin/config/system') },
|
||
]
|
||
},
|
||
{
|
||
path: '/monitor',
|
||
component: () => import('@/layouts/AppShell.vue'),
|
||
meta: { scope: 'RCSMonitor' },
|
||
redirect: '/monitor/map',
|
||
children: [
|
||
{ path: 'dashboard', redirect: '/monitor/map' },
|
||
{ 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
|
||
})
|
||
|
||
function safeRedirect(raw: unknown, fallback: string): string {
|
||
const redirect = Array.isArray(raw) ? raw[0] : raw
|
||
if (
|
||
typeof redirect === 'string' &&
|
||
redirect.startsWith('/') &&
|
||
!redirect.startsWith('//') &&
|
||
!redirect.startsWith('/login')
|
||
) {
|
||
return redirect
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
router.beforeEach(async (to) => {
|
||
const auth = useAuthStore()
|
||
if (to.meta.public) {
|
||
// 已登录再进登录页:直接送去向导 / 业务页,避免「登录成功仍停在 /login」。
|
||
if (to.name === 'login' && auth.isAuthed) {
|
||
if (!auth.validated) {
|
||
try { await auth.validate() } catch { return true }
|
||
}
|
||
if (auth.needsWizard) return { name: 'wizard' }
|
||
const fallback = auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard'
|
||
return { path: safeRedirect(to.query.redirect, fallback) }
|
||
}
|
||
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 } }
|
||
}
|
||
}
|
||
|
||
// 首次部署:未完成向导时默认拦在 /wizard。
|
||
// 选型完成后的「进入配置」允许跳到车辆 / 地图 / 设备等白名单页(inSetup=true)。
|
||
if (auth.needsWizard && to.name !== 'wizard') {
|
||
const dest = typeof to.name === 'string' ? to.name : ''
|
||
if (!(isWizardSetupActive() && WIZARD_SETUP_ROUTES.has(dest))) {
|
||
return { name: 'wizard' }
|
||
}
|
||
}
|
||
|
||
// 账号角色决定工作区:管理账号走 /admin,运营账号走 /monitor。
|
||
// 不再自动 switchScope 切到另一端——配置的运营页只在运营权限账号登录后可见。
|
||
const needScope: 'Platform' | 'RCSMonitor' | null =
|
||
to.path.startsWith('/admin')
|
||
? 'Platform'
|
||
: to.path.startsWith('/monitor') ? 'RCSMonitor'
|
||
: null
|
||
if (needScope && auth.scope !== needScope) {
|
||
const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES
|
||
const fallback = list.find((p) => auth.hasPage(p.name))?.path
|
||
?? (auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
||
if (fallback !== to.path) {
|
||
ElMessage.warning('当前账号不能进入该区域,已回到可访问页面')
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
// RBAC 页面级权限:scope 已切换到目标域,allowedPages 已刷新。
|
||
// 若目标页面不在当前账号的可访问页面集合内,跳到该 scope 下首个可访问页面(菜单顺序)。
|
||
const name = typeof to.name === 'string' ? to.name : ''
|
||
const pageKey = typeof to.meta.pageKey === 'string' ? to.meta.pageKey : name
|
||
if (pageKey && MANAGED_PAGE_NAMES.has(pageKey) && !auth.hasPage(pageKey)) {
|
||
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
|