feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling

- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -0,0 +1,470 @@
/**
* 路由权限配置表(对齐 CICD 项目 route-permissions.ts
*
* 4 张表按优先级顺序匹配(精确 > 前缀 > 仪表盘 > API
* 1. EXACT_ROUTE_PERMISSIONS精确路由如 /shell/admin/users
* 2. PREFIX_ROUTE_PERMISSIONS前缀路由如 /shell/admin/*
* 3. DASHBOARD_ROUTE_PERMISSIONS仪表盘路由按角色分发
* 4. API_ROUTE_PERMISSIONSNext.js API Route/api/*
*
* 三层安全边界portal-shell README v2.0 §3.3
* - L1 角色门禁requiredRoles4 角色之一)
* - L2 权限点门禁requiredPermissionsAND 语义,必须全部满足)
* - L3 数据范围:运行时由插件/page 内 usePermission 校验
*
* 使用方式middleware / page / layout
* ```ts
* import { checkRoutePermission } from "@/shared/lib/route-permissions";
*
* const result = checkRoutePermission(pathname, userBitmap, userRole);
* if (!result.allowed) redirect("/shell/forbidden");
* ```
*
* 关联portal-shell README v2.0 §3.3、project_rules §3.1(禁止 role === "xxx" 硬编码)
*/
import type { Role } from "@edu/shared-ts/contracts";
import {
hasAllPermissionsInBitmap,
hasAnyPermissionInBitmap,
isValidPermission,
} from "@edu/shared-ts/permission-bitmap";
/**
* 路由权限配置项
*/
export interface RoutePermissionConfig {
/** 所需角色(任一满足即可;空数组表示不限制角色) */
requiredRoles?: Role[];
/**
* 所需权限点AND 语义,必须全部满足)
* 权限点必须来自 PERMISSION_BITMAP_ORDER
*/
requiredPermissions?: string[];
/**
* 所需权限点OR 语义,任一满足即可)
* 与 requiredPermissions 同时存在时,先 AND 再 OR
*/
anyOfPermissions?: string[];
}
/**
* 路由权限检查结果
*/
export interface RoutePermissionResult {
/** 是否允许访问 */
allowed: boolean;
/** 拒绝原因allowed=false 时填充) */
reason?: "missing_role" | "missing_permission" | "no_config";
/** 匹配到的配置(用于调试) */
matchedPath?: string;
/** 缺失的权限点allowed=false 且 reason=missing_permission 时填充) */
missingPermissions?: string[];
}
/**
* 1. 精确路由权限表
*
* 高优先级pathname 完全匹配时生效。
* 适用于功能明确、URL 固定的页面用户管理、RBAC、审计日志等
*/
export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
// ── admin 专属 ────────────────────────────────────────────
"/shell/admin/users": {
requiredRoles: ["admin"],
requiredPermissions: ["USER_MANAGE"],
},
"/shell/admin/roles": {
requiredRoles: ["admin"],
requiredPermissions: ["ROLE_MANAGE"],
},
"/shell/admin/permissions": {
requiredRoles: ["admin"],
requiredPermissions: ["PERMISSION_MANAGE"],
},
"/shell/admin/audit-logs": {
requiredRoles: ["admin"],
requiredPermissions: ["AUDIT_LOG_READ"],
},
"/shell/admin/school": {
requiredRoles: ["admin"],
requiredPermissions: ["SCHOOL_MANAGE"],
},
"/shell/admin/plugins": {
requiredRoles: ["admin"],
requiredPermissions: ["PLUGIN_REGISTRY_MANAGE"],
},
"/shell/admin/invitation-codes": {
requiredRoles: ["admin"],
anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"],
},
// ── teacher 专属 ──────────────────────────────────────────
"/shell/teacher/lesson-plans": {
requiredRoles: ["teacher"],
anyOfPermissions: [
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
],
},
"/shell/teacher/question-bank": {
requiredRoles: ["teacher"],
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE"],
},
"/shell/teacher/textbooks": {
requiredRoles: ["teacher", "admin"],
requiredPermissions: ["TEXTBOOK_READ"],
},
"/shell/teacher/scheduling-rules": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
},
// ── student 专属 ──────────────────────────────────────────
"/shell/student/error-book": {
requiredRoles: ["student"],
requiredPermissions: ["ERROR_BOOK_READ"],
},
"/shell/student/learning-path": {
requiredRoles: ["student"],
requiredPermissions: ["LEARNING_PATH_READ"],
},
"/shell/student/electives": {
requiredRoles: ["student"],
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_SELECT"],
},
"/shell/student/ai-tutor": {
requiredRoles: ["student"],
requiredPermissions: ["AI_TUTOR_USE"],
},
// ── parent 专属 ───────────────────────────────────────────
"/shell/parent/children": {
requiredRoles: ["parent"],
requiredPermissions: ["GRADE_READ_CHILD"],
},
"/shell/parent/leave-approval": {
requiredRoles: ["parent"],
requiredPermissions: ["LEAVE_APPROVAL_MANAGE"],
},
};
/**
* 2. 前缀路由权限表
*
* 中优先级pathname 以指定前缀开头时生效。
* 适用于功能集合下的所有子路由(/shell/admin/* /shell/teacher/exams/* 等)。
*
* 注意:前缀必须以 / 结尾,避免误匹配(如 /shell/admin 不能匹配 /shell/admin-users
*/
export const PREFIX_ROUTE_PERMISSIONS: Array<{
prefix: string;
config: RoutePermissionConfig;
}> = [
// admin 区所有子路由默认要求 admin 角色
{
prefix: "/shell/admin/",
config: { requiredRoles: ["admin"] },
},
// 考试管理
{
prefix: "/shell/teacher/exams/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: [
"EXAM_READ",
"EXAM_CREATE",
"EXAM_UPDATE",
"EXAM_GRADE",
],
},
},
// 作业管理
{
prefix: "/shell/teacher/homework/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
},
},
// 成绩录入
{
prefix: "/shell/teacher/grades/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"],
},
},
// 考勤
{
prefix: "/shell/teacher/attendance/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
},
},
// 班级管理
{
prefix: "/shell/admin/classes/",
config: {
requiredRoles: ["admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
},
// 学情诊断
{
prefix: "/shell/teacher/diagnostics/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
},
},
// 公告管理
{
prefix: "/shell/admin/announcements/",
config: {
requiredRoles: ["admin"],
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
},
},
];
/**
* 3. 仪表盘路由权限表
*
* 低优先级,按角色分发的根仪表盘。
* 当 pathname 不匹配前两张表时,检查是否为角色仪表盘根路径。
*/
export const DASHBOARD_ROUTE_PERMISSIONS: Record<
string,
RoutePermissionConfig
> = {
"/shell/admin": {
requiredRoles: ["admin"],
requiredPermissions: ["DASHBOARD_ADMIN_READ"],
},
"/shell/teacher": {
requiredRoles: ["teacher"],
requiredPermissions: ["DASHBOARD_TEACHER_READ"],
},
"/shell/student": {
requiredRoles: ["student"],
requiredPermissions: ["DASHBOARD_STUDENT_READ"],
},
"/shell/parent": {
requiredRoles: ["parent"],
requiredPermissions: ["DASHBOARD_PARENT_READ"],
},
// 通用仪表盘
"/shell": {
requiredPermissions: ["DASHBOARD_READ"],
},
};
/**
* 4. Next.js API Route 权限表
*
* 用于 /api/* 路径的权限校验。
* 注意API Route 通常需要更严格的权限校验,因为它们直接操作数据。
*/
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
// 错误上报端点:所有登录用户可访问
"/api/log": {},
// 健康检查:公开
"/api/healthz": {},
};
/**
* 校验权限配置的合法性(开发时辅助)
*
* 检查所有声明的权限点是否在 PERMISSION_BITMAP_ORDER 中。
* 在 dev 模式下打 warning生产构建时可阻断。
*
* @returns 非法权限点列表(空数组表示全部合法)
*/
export function validateRoutePermissionConfigs(): string[] {
const invalid: string[] = [];
const allConfigs: Array<{ source: string; config: RoutePermissionConfig }> = [
...Object.entries(EXACT_ROUTE_PERMISSIONS).map(([path, config]) => ({
source: `EXACT:${path}`,
config,
})),
...PREFIX_ROUTE_PERMISSIONS.map(({ prefix, config }) => ({
source: `PREFIX:${prefix}`,
config,
})),
...Object.entries(DASHBOARD_ROUTE_PERMISSIONS).map(([path, config]) => ({
source: `DASHBOARD:${path}`,
config,
})),
...Object.entries(API_ROUTE_PERMISSIONS).map(([path, config]) => ({
source: `API:${path}`,
config,
})),
];
for (const { source, config } of allConfigs) {
for (const perm of config.requiredPermissions ?? []) {
if (!isValidPermission(perm)) {
invalid.push(`${source}:requiredPermissions:${perm}`);
}
}
for (const perm of config.anyOfPermissions ?? []) {
if (!isValidPermission(perm)) {
invalid.push(`${source}:anyOfPermissions:${perm}`);
}
}
}
return invalid;
}
/**
* 路由权限检查主函数
*
* 按优先级顺序匹配 4 张表,返回检查结果。
*
* @param pathname 当前路径(如 /shell/admin/users
* @param userBitmap 用户权限位图base36 字符串,从 JWT cookie 解析)
* @param userRole 用户角色
* @returns 检查结果allowed=true 表示放行
*
* @example
* ```ts
* const result = checkRoutePermission("/shell/admin/users", "abc123", "admin");
* if (!result.allowed) {
* redirect("/shell/forbidden");
* }
* ```
*/
export function checkRoutePermission(
pathname: string,
userBitmap: string,
userRole: Role,
): RoutePermissionResult {
// 1. 匹配精确路由
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
if (exactConfig) {
return evaluateConfig(exactConfig, userBitmap, userRole, pathname);
}
// 2. 匹配前缀路由
for (const { prefix, config } of PREFIX_ROUTE_PERMISSIONS) {
if (pathname.startsWith(prefix)) {
return evaluateConfig(config, userBitmap, userRole, prefix);
}
}
// 3. 匹配仪表盘路由
const dashboardConfig = DASHBOARD_ROUTE_PERMISSIONS[pathname];
if (dashboardConfig) {
return evaluateConfig(dashboardConfig, userBitmap, userRole, pathname);
}
// 4. 匹配 API 路由
if (pathname.startsWith("/api/")) {
const apiConfig = API_ROUTE_PERMISSIONS[pathname];
if (apiConfig) {
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
}
// 未配置的 API 路由默认拒绝
return {
allowed: false,
reason: "no_config",
};
}
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
return { allowed: true };
}
/**
* 评估单个权限配置
*/
function evaluateConfig(
config: RoutePermissionConfig,
userBitmap: string,
userRole: Role,
matchedPath: string,
): RoutePermissionResult {
// L1 角色门禁
if (config.requiredRoles && config.requiredRoles.length > 0) {
if (!config.requiredRoles.includes(userRole)) {
return {
allowed: false,
reason: "missing_role",
matchedPath,
};
}
}
// L2 权限点门禁 - AND 语义
const missingPermissions: string[] = [];
if (config.requiredPermissions && config.requiredPermissions.length > 0) {
for (const perm of config.requiredPermissions) {
// 使用 hasAllPermissionsInBitmap 不合适(它返回 boolean 不告知哪些缺失)
// 这里手动遍历以便收集缺失项
const bit = hasPermissionInBitmapSimple(userBitmap, perm);
if (!bit) {
missingPermissions.push(perm);
}
}
if (missingPermissions.length > 0) {
return {
allowed: false,
reason: "missing_permission",
matchedPath,
missingPermissions,
};
}
}
// L2 权限点门禁 - OR 语义
if (config.anyOfPermissions && config.anyOfPermissions.length > 0) {
if (!hasAnyPermissionInBitmap(userBitmap, config.anyOfPermissions)) {
return {
allowed: false,
reason: "missing_permission",
matchedPath,
missingPermissions: config.anyOfPermissions,
};
}
}
return { allowed: true, matchedPath };
}
/**
* 简化版单权限检查(避免循环依赖 hasAllPermissionsInBitmap
*
* 直接调用 hasAllPermissionsInBitmap 检查单个权限点
*/
function hasPermissionInBitmapSimple(
bitmap: string,
permission: string,
): boolean {
return hasAllPermissionsInBitmap(bitmap, [permission]);
}
/**
* 批量检查用户是否拥有所有指定路由的访问权限
*
* 用于侧边栏导航项过滤:一次性检查多个路由,避免重复调用。
*
* @param paths 路径列表
* @param userBitmap 用户权限位图
* @param userRole 用户角色
* @returns 路径 → 是否允许 的映射
*/
export function batchCheckRoutePermission(
paths: readonly string[],
userBitmap: string,
userRole: Role,
): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const path of paths) {
result[path] = checkRoutePermission(path, userBitmap, userRole).allowed;
}
return result;
}