/** * 路由权限配置 — 权限声明的单一真相源 * * 规则: * 1. 每个受保护路由必须在此声明至少一个权限码(TypeScript 强制非空数组) * 2. 子路由(如 /diary/classes/:id)通过前缀匹配自动继承父路由权限,无需重复声明 * 3. 新增路由必须在此添加对应条目,否则 PrivateRoute 默认 403 * 4. 冻结路由标记 frozen: true,自动归入 FROZEN_ROUTES * * 排列规则:精确路径优先于前缀路径(如 /plugins/admin 在 /plugins 之前) */ // 非空数组类型 — 确保每个路由至少有一个权限码 type Permissions = [string, ...string[]]; interface RoutePermissionEntry { path: string; permissions: Permissions; frozen?: boolean; } const ENTRIES: RoutePermissionEntry[] = [ // ===== 基础模块 ===== { path: "/users", permissions: ["user.list", "user.update"] }, { path: "/roles", permissions: ["role.list", "role.update"] }, { path: "/organizations", permissions: ["organization.list", "organization.update"], }, { path: "/workflow", permissions: ["workflow.list", "workflow.read"] }, { path: "/messages", permissions: ["message.list"] }, { path: "/settings", permissions: ["setting.read", "setting.update"] }, // ===== 插件模块(精确路径优先于前缀通配) ===== { path: "/plugins/admin", permissions: ["plugin.admin"] }, { path: "/plugins/market", permissions: ["plugin.admin"] }, // 动态路由 catch-all: /plugins/:pluginId/:entityName 等 { path: "/plugins", permissions: ["plugin.list", "plugin.admin"] }, // ===== 暖记日记模块 ===== { path: "/diary/classes", permissions: ["diary.class.manage", "diary.journal.read"] }, { path: "/diary/journals", permissions: ["diary.journal.read", "diary.journal.manage"] }, { path: "/diary/topics", permissions: ["diary.topic.assign", "diary.journal.read"] }, { path: "/diary/stickers", permissions: ["diary.journal.read"] }, ]; /** 活跃路由的权限映射 — 自动从配置生成,供 PrivateRoute 使用 */ export const ROUTE_PERMISSIONS: Record = Object.fromEntries( ENTRIES.filter((e) => !e.frozen).map((e) => [e.path, [...e.permissions]]), ); /** 冻结路由路径列表 — 自动从配置生成 */ export const FROZEN_ROUTES: string[] = ENTRIES.filter((e) => e.frozen).map( (e) => e.path, ); /** 开发模式下校验:检查是否有路由路径重复 */ if (import.meta.env.DEV) { const paths = ENTRIES.map((e) => e.path); const dupes = paths.filter((p, i) => paths.indexOf(p) !== i); if (dupes.length > 0) { console.error("[routeConfig] 检测到重复路径:", dupes); } } /** * Tab 级权限映射 — 详情页内 Tab 可见性的单一真相源 * * key 格式: "{routePrefix}#{tabKey}" * value: 权限码(undefined 表示始终可见) * * 新增详情页 Tab 时必须在此声明,否则 Tab 默认不可见(安全默认)。 */ const TAB_PERM_ENTRIES: Array<{ key: string; permission?: string }> = [ // ===== 暖记日记模块 Tab 权限 ===== { key: "class#members", permission: "diary.class.manage" }, { key: "class#topics", permission: "diary.topic.assign" }, { key: "journal#detail", permission: "diary.journal.read" }, { key: "journal#comments", permission: "diary.comment.write" }, ]; export const TAB_PERMISSIONS: Record = Object.fromEntries( TAB_PERM_ENTRIES.map((e) => [e.key, e.permission]), ); /** * DEV 模式路由覆盖率校验。 * 在 App.tsx 挂载后调用,检查所有实际路由是否都有权限声明。 * 忽略带参数的子路由(如 /diary/classes/:id),它们通过前缀匹配继承权限。 */ export function validateRouteCoverage(registeredPaths: string[]): void { if (!import.meta.env.DEV) return; const allConfigPaths = ENTRIES.map((e) => e.path); const uncovered = registeredPaths.filter((p) => { if (p === "/" || p === "/login" || p === "/*") return false; if (p.includes(":")) return false; // 子路由通过前缀继承 return !allConfigPaths.includes(p); }); if (uncovered.length > 0) { console.warn( "[routeConfig] 以下路由未在 routeConfig.ts 中声明权限,将被 PrivateRoute 默认 403 拦截:", uncovered, ); } }