/**
* index.ts —— 路由表 + 守卫链(README §3.3 路由与鉴权 / §3.4 守卫链顺序)。
*
* 守卫链顺序(全局 beforeEach,按序短路):
* ① 登录态:login.isLoggedIn(有 access 且未过期,提前 30s 判过期);
* ② 刷新:access 过期但有 refresh → 静默 refresh(refreshAccessToken 单例)→ 成功放行 / 失败跳登录;
* ③ 白名单:登录页/SSO 回调等公开路由直接放行(在 Auth 守卫之前,对齐后端"公开路由先注册"纪律);
* ①.5 视图恢复:已登录但响应式视图未恢复(刷新后 token 在、roles/menuTree/scope 空)
* → 凭 access 调一次 /auth/me(login.restoreMe,store 内 in-flight 单例)恢复菜单/权限/作用域;
* ④ 作用域:路由 meta.scopeRequired(默认 true)且 scope 未就绪 → 跳作用域选择页。
*
* 路由 path 与后端端点对齐(`/{端}/{模块}/{资源}`,README §3.1);市场角色登录入口暂禁用——
* 不注册任何市场端路由(后续启用时单独拍板,勿自行加)。
*/
import { useLogin } from '@/store/login';
import { useScope } from '@/store/scope';
import { refreshAccessToken } from '@/api/http';
import type { RouteRecordRaw } from 'vue-router';
/** 路由 meta 扩展(业务路由可声明的守卫参数) */
declare module 'vue-router' {
interface RouteMeta {
/** 是否需要登录(默认 true;登录页/SSO 回调设 false) */
public?: boolean;
/** 是否要求作用域就绪(默认 true;登录页/作用域选择页设 false) */
scopeRequired?: boolean;
/** 路由标题(document.title 同步用,中文) */
title?: string;
}
}
/**
* 路由表(createRouter 的 routes 入参;vite define 注入为 VueRouter.createRouter)。
*
* 套壳模型(嵌套路由,README §2 layout 壳):
* - 顶层路由**不套壳**:/login(登录,公开)/ /org/scope(作用域选择,登录后全屏过渡页);
* - **layout 父路由**(path '/',component = views/layout):内容区 渲染 children;
* 业务域页面全部作为其 children(相对 path),统一带侧边菜单 + 顶栏骨架;
* - 访问 '/' 自动 redirect 到首个业务页 /org/institutions(scope 未就绪时守卫会拦到 /org/scope)。
*
* 入参:无。
*
* 出参:
* - RouteRecordRaw[]:/login / /org/scope / layout(children: org/institutions) / 兜底 404。
*
* 异常场景:
* - 路径未匹配:通配路由落到 404(本样例 redirect 到登录页,避免白屏)。
*/
export const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { public: true, scopeRequired: false, title: '登录' },
},
{
path: '/org/scope',
name: 'ScopeSelect',
component: () => import('@/views/org/scope-select.vue'),
meta: { scopeRequired: false, title: '选择机构' },
},
{
// layout 父路由:套壳(侧边菜单 + 顶栏 + 内容区 router-view)。业务域路由嵌套其下。
path: '/',
component: () => import('@/views/layout/index.vue'),
redirect: '/org/institutions',
children: [
{
path: 'org/institutions',
name: 'InstitutionList',
component: () => import('@/views/org/institution/list.vue'),
meta: { title: '机构管理' },
},
{
path: 'org/campuses',
name: 'CampusList',
component: () => import('@/views/org/campus/list.vue'),
meta: { title: '校区管理' },
},
{
path: 'org/members',
name: 'MemberList',
component: () => import('@/views/org/member/list.vue'),
meta: { title: '成员/班级' },
},
{
path: 'academic/fee',
name: 'FeeManage',
component: () => import('@/views/academic/fee/index.vue'),
meta: { title: '费用管理' },
},
{
path: 'academic/schedule',
name: 'Schedule',
component: () => import('@/views/academic/schedule/index.vue'),
meta: { title: '排班管理' },
},
{
path: 'academic/activity',
name: 'Activity',
component: () => import('@/views/academic/activity/index.vue'),
meta: { title: '活动' },
},
{
path: 'academic/divide',
name: 'Divide',
component: () => import('@/views/academic/divide/index.vue'),
meta: { title: '分账' },
},
{
path: 'academic/payroll',
name: 'Payroll',
component: () => import('@/views/academic/payroll/index.vue'),
meta: { title: '课酬方案' },
},
{
path: 'academic/expenditure',
name: 'Expenditure',
component: () => import('@/views/academic/expenditure/index.vue'),
meta: { title: '收支管理' },
},
{
path: 'teaching/exam',
name: 'TeachingExam',
component: () => import('@/views/teaching/exam/index.vue'),
meta: { title: '题库/组卷' },
},
{
path: 'teaching/analysis',
name: 'TeachingAnalysis',
component: () => import('@/views/teaching/analysis/index.vue'),
meta: { title: '练习分析' },
},
{
path: 'system/users',
name: 'SystemUsers',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '用户/角色' },
},
{
path: 'system/tasks',
name: 'SystemTasks',
component: () => import('@/views/system/tasks/index.vue'),
meta: { title: '任务监控' },
},
{
path: 'system/logs',
name: 'SystemLogs',
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '操作日志' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
},
];
/**
* 创建路由实例(history 模式;生产由 nginx 按 location 回退 index.html)。
*
* 入参:无。
*
* 出参:
* - 路由实例:main.ts 装配用。
*
* 异常场景:
* - 非法 URL(带未编码字符):createWebHistory 内部兜底,不抛。
*/
export function buildRouter() {
return createRouter({
history: createWebHistory(),
routes,
});
}
/**
* 全局前置守卫(挂载后生效;README §3.3 守卫链)。
*
* 入参(由 vue-router 注入):
* - to:目标路由(含 meta)。
* - from:来源路由(本样例未使用,保留签名完整性)。
*
* 出参:
* - 省略返回值 = 放行;返回 false / 跳转 = 拦截。
*
* 异常场景:
* - 守卫内 pinia 未激活(极端早启动):useLogin 抛错 → 由全局 Recover 思路兜底(本仓为纯前端,
* 表现为控制台报错 + 页面不跳转;main.ts 保证 app.use(pinia) 早于 router.push 首帧)。
*/
export function setupGuards(router: ReturnType): void {
router.beforeEach(async (to, _from) => {
const login = useLogin();
const scope = useScope();
// ③ 白名单(放在登录判断前:未登录访问公开页直接放行)
if (to.meta.public) {
return true;
}
// ① 登录态
if (!login.isLoggedIn) {
// 两步登录①→② 过渡态:票据在手(pendingTicket)但未签 token,允许进入作用域选择页
// (scope-select);未签发前刷新会丢失过渡态,需回登录页重走①(守卫拦截自然兜底)。
if (to.name === 'ScopeSelect' && login.pendingTicket) {
return true;
}
// ② 有 refresh 则静默刷新(单例;并发多路由切换只刷一次)
const refreshed = await refreshAccessToken();
if (refreshed && login.accessToken) {
login.hydrate();
return true;
}
// 刷新失败:清本地态(refresh 失败时 http 层已 clear,此处幂等兜底)后跳登录
return { name: 'Login' };
}
// ①.5 视图恢复:已登录但响应式视图未恢复(页面刷新后 token 在、roles/menuTree/scope 空)
// → 凭 access 调一次 /auth/me 恢复(store 内 in-flight 单例,防并发导航重复请求)。
// 仅作用于需要 scope 的业务页;scope-select 本身不依赖 roles/menuTree(只凭 token),跳过。
// 恢复失败(用户被删→store 已登出;网络故障→保持未恢复):继续走 ④ 作用域判定兜底。
if (!login.restored && to.meta.scopeRequired !== false) {
await login.restoreMe();
}
// ④ 作用域就绪
if (to.meta.scopeRequired !== false && !scope.ready) {
return { name: 'ScopeSelect' };
}
return true;
});
// 路由标题同步(document.title 中文,README §1.4 默认简体中文)
router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} · 统一管理平台` : '统一管理平台';
});
}