index.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  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. * ①.5 视图恢复:已登录但响应式视图未恢复(刷新后 token 在、roles/menuTree/scope 空)
  9. * → 凭 access 调一次 /auth/me(login.restoreMe,store 内 in-flight 单例)恢复菜单/权限/作用域;
  10. * ④ 作用域:路由 meta.scopeRequired(默认 true)且 scope 未就绪 → 跳作用域选择页。
  11. *
  12. * 路由 path 与后端端点对齐(`/{端}/{模块}/{资源}`,README §3.1);市场角色登录入口暂禁用——
  13. * 不注册任何市场端路由(后续启用时单独拍板,勿自行加)。
  14. */
  15. import { useLogin } from '@/store/login';
  16. import { useScope } from '@/store/scope';
  17. import { refreshAccessToken } from '@/api/http';
  18. import type { RouteRecordRaw } from 'vue-router';
  19. /** 路由 meta 扩展(业务路由可声明的守卫参数) */
  20. declare module 'vue-router' {
  21. interface RouteMeta {
  22. /** 是否需要登录(默认 true;登录页/SSO 回调设 false) */
  23. public?: boolean;
  24. /** 是否要求作用域就绪(默认 true;登录页/作用域选择页设 false) */
  25. scopeRequired?: boolean;
  26. /** 路由标题(document.title 同步用,中文) */
  27. title?: string;
  28. }
  29. }
  30. /**
  31. * 路由表(createRouter 的 routes 入参;vite define 注入为 VueRouter.createRouter)。
  32. *
  33. * 套壳模型(嵌套路由,README §2 layout 壳):
  34. * - 顶层路由**不套壳**:/login(登录,公开)/ /org/scope(作用域选择,登录后全屏过渡页);
  35. * - **layout 父路由**(path '/',component = views/layout):内容区 <router-view/> 渲染 children;
  36. * 业务域页面全部作为其 children(相对 path),统一带侧边菜单 + 顶栏骨架;
  37. * - 访问 '/' 自动 redirect 到首个业务页 /org/institutions(scope 未就绪时守卫会拦到 /org/scope)。
  38. *
  39. * 入参:无。
  40. *
  41. * 出参:
  42. * - RouteRecordRaw[]:/login / /org/scope / layout(children: org/institutions) / 兜底 404。
  43. *
  44. * 异常场景:
  45. * - 路径未匹配:通配路由落到 404(本样例 redirect 到登录页,避免白屏)。
  46. */
  47. export const routes: RouteRecordRaw[] = [
  48. {
  49. path: '/login',
  50. name: 'Login',
  51. component: () => import('@/views/login/index.vue'),
  52. meta: { public: true, scopeRequired: false, title: '登录' },
  53. },
  54. {
  55. path: '/org/scope',
  56. name: 'ScopeSelect',
  57. component: () => import('@/views/org/scope-select.vue'),
  58. meta: { scopeRequired: false, title: '选择机构' },
  59. },
  60. {
  61. // layout 父路由:套壳(侧边菜单 + 顶栏 + 内容区 router-view)。业务域路由嵌套其下。
  62. path: '/',
  63. component: () => import('@/views/layout/index.vue'),
  64. redirect: '/org/institutions',
  65. children: [
  66. {
  67. path: 'org/institutions',
  68. name: 'InstitutionList',
  69. component: () => import('@/views/org/institution/list.vue'),
  70. meta: { title: '机构管理' },
  71. },
  72. {
  73. path: 'org/campuses',
  74. name: 'CampusList',
  75. component: () => import('@/views/org/campus/list.vue'),
  76. meta: { title: '校区管理' },
  77. },
  78. {
  79. path: 'org/members',
  80. name: 'MemberList',
  81. component: () => import('@/views/org/member/list.vue'),
  82. meta: { title: '成员/班级' },
  83. },
  84. {
  85. path: 'academic/fee',
  86. name: 'FeeManage',
  87. component: () => import('@/views/academic/fee/index.vue'),
  88. meta: { title: '费用管理' },
  89. },
  90. {
  91. path: 'academic/schedule',
  92. name: 'Schedule',
  93. component: () => import('@/views/academic/schedule/index.vue'),
  94. meta: { title: '排班管理' },
  95. },
  96. {
  97. path: 'academic/activity',
  98. name: 'Activity',
  99. component: () => import('@/views/academic/activity/index.vue'),
  100. meta: { title: '活动' },
  101. },
  102. {
  103. path: 'academic/divide',
  104. name: 'Divide',
  105. component: () => import('@/views/academic/divide/index.vue'),
  106. meta: { title: '分账' },
  107. },
  108. {
  109. path: 'academic/payroll',
  110. name: 'Payroll',
  111. component: () => import('@/views/academic/payroll/index.vue'),
  112. meta: { title: '课酬方案' },
  113. },
  114. {
  115. path: 'academic/expenditure',
  116. name: 'Expenditure',
  117. component: () => import('@/views/academic/expenditure/index.vue'),
  118. meta: { title: '收支管理' },
  119. },
  120. {
  121. path: 'teaching/exam',
  122. name: 'TeachingExam',
  123. component: () => import('@/views/teaching/exam/index.vue'),
  124. meta: { title: '题库/组卷' },
  125. },
  126. {
  127. path: 'teaching/analysis',
  128. name: 'TeachingAnalysis',
  129. component: () => import('@/views/teaching/analysis/index.vue'),
  130. meta: { title: '练习分析' },
  131. },
  132. {
  133. path: 'system/users',
  134. name: 'SystemUsers',
  135. component: () => import('@/views/system/users/index.vue'),
  136. meta: { title: '用户/角色' },
  137. },
  138. {
  139. path: 'system/tasks',
  140. name: 'SystemTasks',
  141. component: () => import('@/views/system/tasks/index.vue'),
  142. meta: { title: '任务监控' },
  143. },
  144. {
  145. path: 'system/logs',
  146. name: 'SystemLogs',
  147. component: () => import('@/views/system/logs/index.vue'),
  148. meta: { title: '操作日志' },
  149. },
  150. ],
  151. },
  152. {
  153. path: '/:pathMatch(.*)*',
  154. redirect: '/login',
  155. },
  156. ];
  157. /**
  158. * 创建路由实例(history 模式;生产由 nginx 按 location 回退 index.html)。
  159. *
  160. * 入参:无。
  161. *
  162. * 出参:
  163. * - 路由实例:main.ts 装配用。
  164. *
  165. * 异常场景:
  166. * - 非法 URL(带未编码字符):createWebHistory 内部兜底,不抛。
  167. */
  168. export function buildRouter() {
  169. return createRouter({
  170. history: createWebHistory(),
  171. routes,
  172. });
  173. }
  174. /**
  175. * 全局前置守卫(挂载后生效;README §3.3 守卫链)。
  176. *
  177. * 入参(由 vue-router 注入):
  178. * - to:目标路由(含 meta)。
  179. * - from:来源路由(本样例未使用,保留签名完整性)。
  180. *
  181. * 出参:
  182. * - 省略返回值 = 放行;返回 false / 跳转 = 拦截。
  183. *
  184. * 异常场景:
  185. * - 守卫内 pinia 未激活(极端早启动):useLogin 抛错 → 由全局 Recover 思路兜底(本仓为纯前端,
  186. * 表现为控制台报错 + 页面不跳转;main.ts 保证 app.use(pinia) 早于 router.push 首帧)。
  187. */
  188. export function setupGuards(router: ReturnType<typeof buildRouter>): void {
  189. router.beforeEach(async (to, _from) => {
  190. const login = useLogin();
  191. const scope = useScope();
  192. // ③ 白名单(放在登录判断前:未登录访问公开页直接放行)
  193. if (to.meta.public) {
  194. return true;
  195. }
  196. // ① 登录态
  197. if (!login.isLoggedIn) {
  198. // 两步登录①→② 过渡态:票据在手(pendingTicket)但未签 token,允许进入作用域选择页
  199. // (scope-select);未签发前刷新会丢失过渡态,需回登录页重走①(守卫拦截自然兜底)。
  200. if (to.name === 'ScopeSelect' && login.pendingTicket) {
  201. return true;
  202. }
  203. // ② 有 refresh 则静默刷新(单例;并发多路由切换只刷一次)
  204. const refreshed = await refreshAccessToken();
  205. if (refreshed && login.accessToken) {
  206. login.hydrate();
  207. return true;
  208. }
  209. // 刷新失败:清本地态(refresh 失败时 http 层已 clear,此处幂等兜底)后跳登录
  210. return { name: 'Login' };
  211. }
  212. // ①.5 视图恢复:已登录但响应式视图未恢复(页面刷新后 token 在、roles/menuTree/scope 空)
  213. // → 凭 access 调一次 /auth/me 恢复(store 内 in-flight 单例,防并发导航重复请求)。
  214. // 仅作用于需要 scope 的业务页;scope-select 本身不依赖 roles/menuTree(只凭 token),跳过。
  215. // 恢复失败(用户被删→store 已登出;网络故障→保持未恢复):继续走 ④ 作用域判定兜底。
  216. if (!login.restored && to.meta.scopeRequired !== false) {
  217. await login.restoreMe();
  218. }
  219. // ④ 作用域就绪
  220. if (to.meta.scopeRequired !== false && !scope.ready) {
  221. return { name: 'ScopeSelect' };
  222. }
  223. return true;
  224. });
  225. // 路由标题同步(document.title 中文,README §1.4 默认简体中文)
  226. router.afterEach((to) => {
  227. document.title = to.meta.title ? `${to.meta.title} · 统一管理平台` : '统一管理平台';
  228. });
  229. }