index.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * index.ts —— 路由表 + 守卫链(README §3.3 路由与鉴权 / §3.4 守卫链顺序)。
  3. *
  4. * 守卫链顺序(全局 beforeEach,按序短路):
  5. * ① 登录态:login.isLoggedIn(有 access 且未过期,提前 30s 判过期);
  6. * ② 刷新:access 过期但有 refresh → 静默 refresh(refreshAccessToken 单例)→ 成功放行 / 失败跳登录;
  7. * ③ 白名单:登录页/SSO 回调等公开路由直接放行(在 Auth 守卫之前,对齐后端"公开路由先注册"纪律);
  8. * ④ 作用域:路由 meta.scopeRequired(默认 true)且 scope 未就绪 → 跳作用域选择页。
  9. *
  10. * 路由 path 与后端端点对齐(`/{端}/{模块}/{资源}`,README §3.1);市场角色登录入口暂禁用——
  11. * 不注册任何市场端路由(后续启用时单独拍板,勿自行加)。
  12. */
  13. import { useLogin } from '@/store/login';
  14. import { useScope } from '@/store/scope';
  15. import { refreshAccessToken } from '@/api/http';
  16. import type { RouteRecordRaw } from 'vue-router';
  17. /** 路由 meta 扩展(业务路由可声明的守卫参数) */
  18. declare module 'vue-router' {
  19. interface RouteMeta {
  20. /** 是否需要登录(默认 true;登录页/SSO 回调设 false) */
  21. public?: boolean;
  22. /** 是否要求作用域就绪(默认 true;登录页/作用域选择页设 false) */
  23. scopeRequired?: boolean;
  24. /** 路由标题(document.title 同步用,中文) */
  25. title?: string;
  26. }
  27. }
  28. /**
  29. * 路由表(createRouter 的 routes 入参;vite define 注入为 VueRouter.createRouter)。
  30. *
  31. * 套壳模型(嵌套路由,README §2 layout 壳):
  32. * - 顶层路由**不套壳**:/login(登录,公开)/ /org/scope(作用域选择,登录后全屏过渡页);
  33. * - **layout 父路由**(path '/',component = views/layout):内容区 <router-view/> 渲染 children;
  34. * 业务域页面全部作为其 children(相对 path),统一带侧边菜单 + 顶栏骨架;
  35. * - 访问 '/' 自动 redirect 到首个业务页 /org/institutions(scope 未就绪时守卫会拦到 /org/scope)。
  36. *
  37. * 入参:无。
  38. *
  39. * 出参:
  40. * - RouteRecordRaw[]:/login / /org/scope / layout(children: org/institutions) / 兜底 404。
  41. *
  42. * 异常场景:
  43. * - 路径未匹配:通配路由落到 404(本样例 redirect 到登录页,避免白屏)。
  44. */
  45. export const routes: RouteRecordRaw[] = [
  46. {
  47. path: '/login',
  48. name: 'Login',
  49. component: () => import('@/views/login/index.vue'),
  50. meta: { public: true, scopeRequired: false, title: '登录' },
  51. },
  52. {
  53. path: '/org/scope',
  54. name: 'ScopeSelect',
  55. component: () => import('@/views/org/scope-select.vue'),
  56. meta: { scopeRequired: false, title: '选择机构' },
  57. },
  58. {
  59. // layout 父路由:套壳(侧边菜单 + 顶栏 + 内容区 router-view)。业务域路由嵌套其下。
  60. path: '/',
  61. component: () => import('@/views/layout/index.vue'),
  62. redirect: '/org/institutions',
  63. children: [
  64. {
  65. path: 'org/institutions',
  66. name: 'InstitutionList',
  67. component: () => import('@/views/org/institution/list.vue'),
  68. meta: { title: '机构管理' },
  69. },
  70. ],
  71. },
  72. {
  73. path: '/:pathMatch(.*)*',
  74. redirect: '/login',
  75. },
  76. ];
  77. /**
  78. * 创建路由实例(history 模式;生产由 nginx 按 location 回退 index.html)。
  79. *
  80. * 入参:无。
  81. *
  82. * 出参:
  83. * - 路由实例:main.ts 装配用。
  84. *
  85. * 异常场景:
  86. * - 非法 URL(带未编码字符):createWebHistory 内部兜底,不抛。
  87. */
  88. export function buildRouter() {
  89. return createRouter({
  90. history: createWebHistory(),
  91. routes,
  92. });
  93. }
  94. /**
  95. * 全局前置守卫(挂载后生效;README §3.3 守卫链)。
  96. *
  97. * 入参(由 vue-router 注入):
  98. * - to:目标路由(含 meta)。
  99. * - from:来源路由(本样例未使用,保留签名完整性)。
  100. *
  101. * 出参:
  102. * - 省略返回值 = 放行;返回 false / 跳转 = 拦截。
  103. *
  104. * 异常场景:
  105. * - 守卫内 pinia 未激活(极端早启动):useLogin 抛错 → 由全局 Recover 思路兜底(本仓为纯前端,
  106. * 表现为控制台报错 + 页面不跳转;main.ts 保证 app.use(pinia) 早于 router.push 首帧)。
  107. */
  108. export function setupGuards(router: ReturnType<typeof buildRouter>): void {
  109. router.beforeEach(async (to, _from) => {
  110. const login = useLogin();
  111. const scope = useScope();
  112. // ③ 白名单(放在登录判断前:未登录访问公开页直接放行)
  113. if (to.meta.public) {
  114. return true;
  115. }
  116. // ① 登录态
  117. if (!login.isLoggedIn) {
  118. // 两步登录①→② 过渡态:票据在手(pendingTicket)但未签 token,允许进入作用域选择页
  119. // (scope-select);未签发前刷新会丢失过渡态,需回登录页重走①(守卫拦截自然兜底)。
  120. if (to.name === 'ScopeSelect' && login.pendingTicket) {
  121. return true;
  122. }
  123. // ② 有 refresh 则静默刷新(单例;并发多路由切换只刷一次)
  124. const refreshed = await refreshAccessToken();
  125. if (refreshed && login.accessToken) {
  126. login.hydrate();
  127. return true;
  128. }
  129. // 刷新失败:清本地态(refresh 失败时 http 层已 clear,此处幂等兜底)后跳登录
  130. return { name: 'Login' };
  131. }
  132. // ④ 作用域就绪
  133. if (to.meta.scopeRequired !== false && !scope.ready) {
  134. return { name: 'ScopeSelect' };
  135. }
  136. return true;
  137. });
  138. // 路由标题同步(document.title 中文,README §1.4 默认简体中文)
  139. router.afterEach((to) => {
  140. document.title = to.meta.title ? `${to.meta.title} · 统一管理平台` : '统一管理平台';
  141. });
  142. }