Phase 1 — 品牌替换:
- BRAND_DEFAULTS 回退值改为暖记品牌 (themes.ts)
- 登录页/侧边栏/底部回退文字 → 暖记 (Login, MainLayout)
- index.html title/meta/favicon → 暖记
- localStorage key → nuanji-theme, 默认主题 → warm
- 4 套主题色适配暖记设计系统 (珊瑚 #E07A5F / 蓝 / 深色 / 鼠尾草绿)
- 品牌信息通过系统设置配置,不硬编码
Phase 2 — 清理 HMS 模块:
- 删除 health/ai 页面 (~55+2)、API (~30+9)、组件、stores、hooks
- 重写 Home.tsx 为暖记 Dashboard
- 重写 NotificationPanel/MediaPicker 移除 health 依赖
- 清理 routeConfig 移除所有 health/ai 路由权限
Phase 3 — 暖记管理页面:
- API 层: api/diary/{types,journals,classes,topics,comments,stickers}.ts
- 班级管理: 班级列表+创建+成员查看+班级码复制 (ClassList)
- 日记审核: 日记列表+筛选+详情+老师点评 (JournalList)
- 主题管理: 班级选择+主题卡片+创建+过期标记 (TopicList)
- 贴纸管理: 贴纸包卡片+贴纸详情网格 (StickerPackList)
- 路由注册: /diary/classes, /diary/journals, /diary/topics, /diary/stickers
验证: tsc 0 error, vite build ✓, vitest 226/226 pass
108 lines
4.2 KiB
TypeScript
108 lines
4.2 KiB
TypeScript
/**
|
||
* 路由权限配置 — 权限声明的单一真相源
|
||
*
|
||
* 规则:
|
||
* 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<string, string[]> = 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<string, string | undefined> = 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,
|
||
);
|
||
}
|
||
}
|