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,273 @@
/**
* 权限位图编解码(对齐 CICD 项目 permission-bitmap.ts
*
* 核心价值:将 N 个权限点压缩为 base36 字符串JWT cookie 体积减少 ~99%。
* - 67 权限点数组JSON ~1.1KB)→ base36 字符串(~14 字符)
* - Edge Runtime 单点检查 hasPermissionInBitmap 无需完整解码
*
* 关键约束(不可破坏):
* - PERMISSION_BITMAP_ORDER 顺序一经确定不可变,新增权限只能追加末尾
* - 因 Number 仅支持 53 bit 精度,使用 BigInt 实现
* - 未知权限静默忽略,无效字符返回空数组
*
* 编码原理:
* - 每个权限点对应一个 bit 位(按 ORDER 数组下标)
* - permissions 数组 → BigInt每个有权限的 bit 置 1→ base36 字符串
* - 解码base36 → BigInt → 遍历 ORDERbit 为 1 的权限加入结果
*
* 关联project_rules §3.1(前端禁止 role === "xxx" 硬编码)、
* portal-shell README v2.0 §3.3 三层安全边界
*/
/**
* 权限点位图顺序(一经确定不可变,新增只能追加末尾)
*
* 命名规范:`<RESOURCE>_<ACTION>`F7 裁决)
* 数据范围后缀:`_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`
*
* 注意:顺序变更会破坏所有已签发的 JWT cookie只能在版本升级时追加。
*/
export const PERMISSION_BITMAP_ORDER = [
// ── 仪表盘5─────────────────────────────────────────
"DASHBOARD_ADMIN_READ",
"DASHBOARD_TEACHER_READ",
"DASHBOARD_STUDENT_READ",
"DASHBOARD_PARENT_READ",
"DASHBOARD_READ",
// ── 用户管理4──────────────────────────────────────
"USER_MANAGE",
"USER_CREATE",
"USER_UPDATE",
"USER_DELETE",
// ── 角色权限4──────────────────────────────────────
"ROLE_READ",
"ROLE_MANAGE",
"PERMISSION_READ",
"PERMISSION_MANAGE",
// ── 审计与邀请3────────────────────────────────────
"AUDIT_LOG_READ",
"INVITATION_CODE_MANAGE",
"INVITATION_CODE_CREATE",
// ── 学校设置2──────────────────────────────────────
"SCHOOL_READ",
"SCHOOL_MANAGE",
// ── 班级与年级4────────────────────────────────────
"CLASS_READ",
"CLASS_MANAGE",
"GRADE_READ",
"GRADE_MANAGE",
// ── 考试5──────────────────────────────────────────
"EXAM_READ",
"EXAM_CREATE",
"EXAM_UPDATE",
"EXAM_DELETE",
"EXAM_GRADE",
// ── 作业4──────────────────────────────────────────
"HOMEWORK_READ",
"HOMEWORK_CREATE",
"HOMEWORK_SUBMIT",
"HOMEWORK_GRADE",
// ── 成绩5──────────────────────────────────────────
"GRADE_READ",
"GRADE_READ_OWN",
"GRADE_READ_CHILD",
"GRADE_RECORD_MANAGE",
"GRADE_RECORD_READ",
// ── 考勤3──────────────────────────────────────────
"ATTENDANCE_READ",
"ATTENDANCE_MANAGE",
"ATTENDANCE_RECORD",
// ── 课表与排课4────────────────────────────────────
"SCHEDULE_READ",
"SCHEDULE_AUTO",
"SCHEDULE_ADJUST",
"SCHEDULE_MANAGE",
// ── 备课与教材5────────────────────────────────────
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
"LESSON_PLAN_DELETE",
"TEXTBOOK_READ",
// ── 题库4──────────────────────────────────────────
"QUESTION_READ",
"QUESTION_CREATE",
"QUESTION_UPDATE",
"QUESTION_DELETE",
// ── 学情诊断2──────────────────────────────────────
"DIAGNOSTIC_READ",
"DIAGNOSTIC_MANAGE",
// ── 选修课3────────────────────────────────────────
"ELECTIVE_READ",
"ELECTIVE_MANAGE",
"ELECTIVE_SELECT",
// ── 错题本与学习路径2──────────────────────────────
"ERROR_BOOK_READ",
"LEARNING_PATH_READ",
// ── AI 辅导2───────────────────────────────────────
"AI_CHAT",
"AI_TUTOR_USE",
// ── 公告与消息4────────────────────────────────────
"ANNOUNCEMENT_READ",
"ANNOUNCEMENT_MANAGE",
"MESSAGE_READ",
"MESSAGE_SEND",
// ── 请假2──────────────────────────────────────────
"LEAVE_REQUEST_CREATE",
"LEAVE_APPROVAL_MANAGE",
// ── 插件与布局4────────────────────────────────────
"PLUGIN_REGISTRY_READ",
"PLUGIN_REGISTRY_MANAGE",
"LAYOUT_TEMPLATE_MANAGE",
"ROLE_LAYOUT_MANAGE",
] as const;
/** 权限点类型(从 ORDER 数组推导) */
export type Permission = (typeof PERMISSION_BITMAP_ORDER)[number];
/** 权限点 → bit 位映射表(启动时构建一次) */
const PERMISSION_BIT_INDEX: ReadonlyMap<string, bigint> = new Map(
PERMISSION_BITMAP_ORDER.map((perm, idx) => [perm, 1n << BigInt(idx)]),
);
/**
* 将权限点数组编码为 base36 字符串
*
* @example
* encodePermissionsBitmap(["USER_MANAGE", "ROLE_READ"])
* // => "j" (前 14 个权限点中 USER_MANAGE=bit5, ROLE_READ=bit9 → 0b10100100000 → base36="j"
*/
export function encodePermissionsBitmap(
permissions: readonly string[],
): string {
let bits = 0n;
for (const perm of permissions) {
const bit = PERMISSION_BIT_INDEX.get(perm);
if (bit !== undefined) {
bits |= bit;
}
// 未知权限静默忽略
}
return bits.toString(36);
}
/**
* 将 base36 字符串解码为权限点数组
*
* 容错:无效字符返回空数组,未知 bit 位静默忽略
*/
export function decodePermissionsBitmap(bitmap: string): Permission[] {
if (!bitmap || !/^[0-9a-z]+$/.test(bitmap)) {
return [];
}
const bits = parseBase36BigInt(bitmap);
if (bits === null) {
return [];
}
const result: Permission[] = [];
for (let i = 0; i < PERMISSION_BITMAP_ORDER.length; i++) {
const bit = 1n << BigInt(i);
if ((bits & bit) !== 0n) {
result.push(PERMISSION_BITMAP_ORDER[i]!);
}
}
return result;
}
/**
* 单点权限检查(不解码整个位图,性能优)
*
* 适用场景Edge Runtime / middleware / proxy 等高频检查
*
* @example
* hasPermissionInBitmap("j", "USER_MANAGE") // true
* hasPermissionInBitmap("j", "EXAM_CREATE") // false
*/
export function hasPermissionInBitmap(
bitmap: string,
permission: string,
): boolean {
const bit = PERMISSION_BIT_INDEX.get(permission);
if (bit === undefined) {
return false; // 未知权限点
}
const bits = parseBase36BigInt(bitmap);
if (bits === null) {
return false;
}
return (bits & bit) !== 0n;
}
/**
* 批量权限检查(任一满足即 true
*/
export function hasAnyPermissionInBitmap(
bitmap: string,
permissions: readonly string[],
): boolean {
return permissions.some((p) => hasPermissionInBitmap(bitmap, p));
}
/**
* 批量权限检查(全部满足才 true
*/
export function hasAllPermissionsInBitmap(
bitmap: string,
permissions: readonly string[],
): boolean {
return permissions.every((p) => hasPermissionInBitmap(bitmap, p));
}
/**
* 解析 base36 字符串为 BigInt
*
* base36 字符集0-9, a-z小写
* 实现原理:从高位到低位逐字符累加
*/
function parseBase36BigInt(str: string): bigint | null {
if (!str) return 0n;
let result = 0n;
const base = 36n;
for (let i = 0; i < str.length; i++) {
const ch = str[i]!;
let digit: number;
if (ch >= "0" && ch <= "9") {
digit = ch.charCodeAt(0) - 48; // '0' = 48
} else if (ch >= "a" && ch <= "z") {
digit = ch.charCodeAt(0) - 87; // 'a' = 97, 97-10=87
} else if (ch >= "A" && ch <= "Z") {
digit = ch.charCodeAt(0) - 55; // 'A' = 65, 65-10=55
} else {
return null; // 非法字符
}
result = result * base + BigInt(digit);
}
return result;
}
/**
* 校验权限点是否在位图顺序表中
*
* 用于开发时校验 manifest 声明的权限点是否合法
*/
export function isValidPermission(permission: string): boolean {
return PERMISSION_BIT_INDEX.has(permission);
}