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

@@ -15,6 +15,18 @@
"./federation": {
"types": "./dist/federation/index.d.ts",
"default": "./dist/federation/index.js"
},
"./contracts": {
"types": "./dist/contracts/index.d.ts",
"default": "./dist/contracts/index.js"
},
"./env-loader": {
"types": "./dist/env-loader/index.d.ts",
"default": "./dist/env-loader/index.js"
},
"./permission-bitmap": {
"types": "./dist/permission-bitmap.d.ts",
"default": "./dist/permission-bitmap.js"
}
},
"scripts": {

View File

@@ -0,0 +1,41 @@
/**
* portal-shell 契约汇总导出portal-shell spec §9.3
*
* 由 portal-shell / ui-components / hooks 共享的类型契约。
* 后端 config-service 的 GraphQL schema 与此对齐。
*
* 关联portal-shell spec §5.1 PluginProps 契约、§4 Layout 模型、§5.2 跨插件状态管理
*/
export type {
Role,
PluginSize,
PluginCategory,
JsonSchema,
PluginProps,
PluginManifest,
PluginManifestMeta,
} from "./plugin.js";
export type {
LayoutTemplateInfo,
SlotConfig,
PluginPlacement,
PluginRegistryItem,
PluginConfigResponse,
LayoutTemplateId,
SlotName,
RoleLayoutDefault,
UserLayoutOverride,
} from "./layout.js";
export { LAYOUT_TEMPLATE_IDS, SLOT_NAMES } from "./layout.js";
export type { ThemeMode, Locale, PluginStoreState } from "./plugin-store.js";
export type { UrlPluginContext, UrlContextKey } from "./plugin-context.js";
export {
URL_CONTEXT_KEYS,
parseUrlContext,
writeUrlContext,
} from "./plugin-context.js";

View File

@@ -0,0 +1,135 @@
/**
* Layout 与 Slot 配置类型portal-shell spec §4、§6.2
*
* 对应 config-service PluginConfigResponse三层合并后的插件配置
* 类型与 services/config-service 的 GraphQL schema 对齐,
* 通过 apollo-router GraphQL 查询 pluginConfig(userId, role) 获取。
*
* 关联portal-shell spec §4.1 5 种 Layout 模板、§4.2 Slot 系统、§6.2 PluginConfigResponse
*/
import type { Role } from "./plugin.js";
/** Layout 模板信息(对应 config-service LayoutTemplateInfo */
export interface LayoutTemplateInfo {
/** Layout ID'classic' | 'focus' | 'split' | 'triple' | 'canvas' */
layoutId: string;
/** 显示名 */
displayName: string;
/** 描述 */
description: string;
/** 可用 slot 列表,如 ["top", "side", "main"] */
availableSlots: string[];
/** Layout schema JSON 字符串grid 配置等) */
layoutSchemaJson: string;
}
/** Slot 配置(对应 config-service SlotConfig */
export interface SlotConfig {
/** Slot 名称:'top' | 'side' | 'main' | 'main-left' | 'main-right' | 'right' | 'canvas-grid' */
slotName: string;
/** 导航项列表side slot 的导航菜单项) */
navItems: string[];
}
/** 插件放置(三层合并后,对应 config-service PluginPlacement */
export interface PluginPlacement {
/** 插件 ID */
pluginId: string;
/** 插入的 slot 名称 */
slot: string;
/** 显示顺序(升序) */
sortOrder: number;
/** 尺寸 JSON 字符串({colSpan, rowSpan} */
sizeJson: string;
/** 三层合并后的最终 props JSON 字符串 */
propsJson: string;
/** 是否可见 */
isVisible: boolean;
}
/** 插件注册项(对应 config-service PluginRegistryItem */
export interface PluginRegistryItem {
/** 插件 ID */
pluginId: string;
/** 分类:'universal' | 'sidebar' | 'topbar' | 'teacher' | 'student' | 'parent' | 'admin' */
category: string;
/** 版本semver */
version: string;
/** 显示名 */
displayName: string;
/** 描述 */
description: string;
/** 可访问此插件的角色列表 */
requiredRoles: string[];
/** 是否内置插件 */
isBuiltin: boolean;
/** 是否全局启用 */
isActive: boolean;
}
/**
* 三层合并后的插件配置响应(对应 config-service PluginConfigResponse
*
* 由 config-service 合并三层配置后返回:
* Layer 1: plugin_registry系统默认
* Layer 2: role_plugin_mapping角色模板
* Layer 3: user_layout_override用户覆盖
*/
export interface PluginConfigResponse {
/** 当前启用的 Layout 模板 */
activeLayout: LayoutTemplateInfo | null;
/** Slot 配置列表 */
slots: SlotConfig[];
/** 插件放置列表(三层合并后) */
plugins: PluginPlacement[];
/** 插件注册表(所有可用插件) */
registry: PluginRegistryItem[];
}
/** Layout 模板 ID 枚举portal-shell spec §4.1 */
export const LAYOUT_TEMPLATE_IDS = [
"classic",
"focus",
"split",
"triple",
"canvas",
] as const;
/** Layout 模板 ID 类型 */
export type LayoutTemplateId = (typeof LAYOUT_TEMPLATE_IDS)[number];
/** Slot 名称枚举portal-shell spec §4.2 */
export const SLOT_NAMES = [
"top",
"side",
"main",
"main-left",
"main-right",
"right",
"canvas-grid",
] as const;
/** Slot 名称类型 */
export type SlotName = (typeof SLOT_NAMES)[number];
/** 角色-Layout 默认配置admin 配置角色默认模板) */
export interface RoleLayoutDefault {
role: Role;
layoutId: string;
slotOverrides: Record<string, unknown>;
}
/** 用户布局覆盖(用户自定义) */
export interface UserLayoutOverride {
userId: string;
activeLayout: string;
slotOverrides: Record<string, unknown>;
pluginPlacements: Array<{
pluginId: string;
slot: string;
sortOrder: number;
size: { colSpan: number; rowSpan: number };
props: Record<string, unknown>;
}>;
hiddenPlugins: string[];
}

View File

@@ -0,0 +1,105 @@
/**
* URL Search Params 上下文 schemaportal-shell spec §5.2.1
*
* 适合需要 URL 分享、浏览器前进后退的全局上下文。
* 读取useSearchParams()next/navigation
* 写入router.push('?classId=xxx')
* 响应:其他插件通过 useSearchParams() 自动响应,触发重新渲染。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.2.3 URL vs Zustand 选型标准、§9.3 共享包扩展
*/
/**
* URL 驱动的全局上下文(可分享、可前进后退)
*
* 这些 key 对应 URL Search Params 的参数名。
* 插件通过 useSearchParams().get(key) 读取router.push 更新。
*/
export interface UrlPluginContext {
/** 当前选中的班级 ID教师视角class-selector 切换时更新 URL */
classId?: string;
/** 当前选中的孩子 ID家长视角child-selector 切换时更新 URL */
childId?: string;
/** 当前选中的学期term-switcher 切换时更新 URL */
termId?: string;
/** 当前视图模式(如 grades-widget 的 'list' | 'chart' */
view?: string;
/** 当前选中的科目(部分插件按科目过滤) */
subjectId?: string;
/** 当前选中的考试 IDexams-widget 切换时更新 URL */
examId?: string;
}
/** URL 上下文参数名常量(避免拼写错误) */
export const URL_CONTEXT_KEYS = {
classId: "classId",
childId: "childId",
termId: "termId",
view: "view",
subjectId: "subjectId",
examId: "examId",
} as const;
/** URL 上下文参数名类型 */
export type UrlContextKey = keyof UrlPluginContext;
/**
* 从 URLSearchParams 解析 UrlPluginContext
*
* @example
* const ctx = parseUrlContext(new URLSearchParams(window.location.search));
*/
export function parseUrlContext(params: URLSearchParams): UrlPluginContext {
const ctx: UrlPluginContext = {};
const classId = params.get(URL_CONTEXT_KEYS.classId);
if (classId) ctx.classId = classId;
const childId = params.get(URL_CONTEXT_KEYS.childId);
if (childId) ctx.childId = childId;
const termId = params.get(URL_CONTEXT_KEYS.termId);
if (termId) ctx.termId = termId;
const view = params.get(URL_CONTEXT_KEYS.view);
if (view) ctx.view = view;
const subjectId = params.get(URL_CONTEXT_KEYS.subjectId);
if (subjectId) ctx.subjectId = subjectId;
const examId = params.get(URL_CONTEXT_KEYS.examId);
if (examId) ctx.examId = examId;
return ctx;
}
/**
* 将 UrlPluginContext 写入 URLSearchParams
*
* @example
* const params = new URLSearchParams();
* writeUrlContext(params, { classId: 'cls-1' });
* router.push(`?${params.toString()}`);
*/
export function writeUrlContext(
params: URLSearchParams,
ctx: Partial<UrlPluginContext>,
): void {
if (ctx.classId !== undefined) {
if (ctx.classId) params.set(URL_CONTEXT_KEYS.classId, ctx.classId);
else params.delete(URL_CONTEXT_KEYS.classId);
}
if (ctx.childId !== undefined) {
if (ctx.childId) params.set(URL_CONTEXT_KEYS.childId, ctx.childId);
else params.delete(URL_CONTEXT_KEYS.childId);
}
if (ctx.termId !== undefined) {
if (ctx.termId) params.set(URL_CONTEXT_KEYS.termId, ctx.termId);
else params.delete(URL_CONTEXT_KEYS.termId);
}
if (ctx.view !== undefined) {
if (ctx.view) params.set(URL_CONTEXT_KEYS.view, ctx.view);
else params.delete(URL_CONTEXT_KEYS.view);
}
if (ctx.subjectId !== undefined) {
if (ctx.subjectId) params.set(URL_CONTEXT_KEYS.subjectId, ctx.subjectId);
else params.delete(URL_CONTEXT_KEYS.subjectId);
}
if (ctx.examId !== undefined) {
if (ctx.examId) params.set(URL_CONTEXT_KEYS.examId, ctx.examId);
else params.delete(URL_CONTEXT_KEYS.examId);
}
}

View File

@@ -0,0 +1,36 @@
/**
* Zustand 全局状态 schemaportal-shell spec §5.2.2
*
* 管理纯 UI、不可分享的全局状态theme/locale/sidebarCollapsed
* 跨插件可分享状态走 URL Search Params见 plugin-context.ts不进此 Store。
*
* 此文件仅定义 schema 类型,实际 create() 实现在 portal-shell/src/shell/PluginStore.ts。
* shared-ts 不依赖 zustand仅提供类型契约供 hooks 包引用。
*
* 关联portal-shell spec §5.2.2 Zustand Store、§9.3 共享包扩展
*/
/** 主题模式 */
export type ThemeMode = "light" | "dark";
/** i18n locale */
export type Locale = "zh-CN" | "en";
/** PluginStore 状态形状portal-shell spec §5.2.2 */
export interface PluginStoreState {
/** 主题模式light/dark */
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
/** i18n locale */
locale: Locale;
setLocale: (locale: Locale) => void;
/** Sidebar 折叠状态 */
sidebarCollapsed: boolean;
toggleSidebar: () => void;
/** 通知已读标记(纯 UI 状态,不进 URL */
unreadNotificationIds: string[];
markNotificationsRead: (ids: string[]) => void;
}

View File

@@ -0,0 +1,134 @@
/**
* 插件契约类型定义portal-shell spec §5.1
*
* Portal Shell 插件化的核心类型契约,由 portal-shell / ui-components / hooks 共享。
* 所有内置插件通过 PluginManifest 声明元数据Shell 通过 PluginProps 注入运行时上下文。
*
* 关联portal-shell spec §5.1 PluginProps 契约、§9.3 共享包扩展
*/
/** 用户角色 */
export type Role = "admin" | "teacher" | "student" | "parent";
/** 插件尺寸colSpan / rowSpan */
export interface PluginSize {
colSpan: number;
rowSpan: number;
}
/** 插件分类portal-shell spec §3 */
export type PluginCategory =
| "universal"
| "sidebar"
| "topbar"
| "teacher"
| "student"
| "parent"
| "admin";
/** 简易 JSON Schema 类型(用于 propsSchema 声明) */
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
description?: string;
default?: unknown;
enum?: unknown[];
[key: string]: unknown;
}
/**
* 插件 Props 契约portal-shell spec §5.1
*
* Shell 通过此契约向插件注入运行时上下文,插件只通过此契约与 Shell 交互。
* 跨插件状态不通过 props 传递,而是插件自行调用:
* - useSearchParams() 读取 URL 上下文classId/childId/termId/view
* - usePluginStore() 读取 Zustand 全局状态theme/locale/sidebarCollapsed
* - useWidgetQuery() 读取 BFF 业务数据(自动按 role 路由)
*/
export interface PluginProps<TProps = Record<string, unknown>> {
/** 插件实例 ID同一插件多实例时区分 */
instanceId: string;
/** 当前用户角色 */
role: Role;
/** 当前用户信息(来自 IAM */
user: {
id: string;
name: string;
email: string;
/** 数据范围DataScopeIAM 计算后的可见范围 token */
dataScope: string;
};
/** 当前 slot 信息 */
slot: {
/** slot 名称:'main' | 'side' | 'top' | 'main-left' | 'main-right' | 'right' | 'canvas-grid' */
name: string;
/** Layout 模板 ID'classic' | 'focus' | 'split' | 'triple' | 'canvas' */
layoutId: string;
/** 插件尺寸colSpan / rowSpan */
size?: PluginSize;
};
/** 插件自定义 props三层合并后的最终值 */
props: TProps;
/** 服务端预取的初始数据RSC 直出,避免客户端瀑布流) */
initialData?: unknown;
}
/**
* 插件清单portal-shell spec §5.1 PluginManifest
*
* 每个内置插件通过 plugin.manifest.ts 声明此清单,
* Registry 编译时登记,运行时由 SlotRenderer 查表渲染。
*
* 三层安全边界portal-shell README v2.0 §3.3
* - L1 角色门禁requiredRoles粗粒度4 角色之一即可访问
* - L2 权限点门禁requiredPermissions细粒度基于 PERMISSION_BITMAP_ORDER
* - L3 数据范围user.dataScope运行时由插件内部 usePermission 校验
*
* 注意shared-ts 是后端共享包,不依赖 React。
* Component 字段在此为 unknown前端包portal-shell引用时
* 通过类型断言转换为 React.ComponentType<PluginProps>。
*/
export interface PluginManifest {
/** 插件 ID唯一kebab-case如 'grades-widget' */
pluginId: string;
/** 插件版本semver */
version: string;
/** 兼容的 Shell 版本范围semver range如 "^1.0.0" */
requiredShellVersion: string;
/** React 组件(前端包引用时断言为 React.ComponentType<PluginProps> */
Component: unknown;
/** 插件元数据 */
metadata: {
displayName: string;
description: string;
category: PluginCategory;
/** L1 角色门禁:可访问此插件的角色列表(粗粒度) */
requiredRoles: Role[];
/**
* L2 权限点门禁:访问此插件所需的权限点列表(细粒度)
*
* - 空数组或 undefined仅 L1 角色门禁生效
* - 非空数组用户必须同时拥有所有权限点AND 语义)
* - 权限点必须来自 PERMISSION_BITMAP_ORDER运行时由 isValidPermission 校验)
*
* @example
* // 仅 USER_MANAGE 权限可访问
* requiredPermissions: ["USER_MANAGE"]
* // 需同时拥有 EXAM_CREATE 和 EXAM_GRADE
* requiredPermissions: ["EXAM_CREATE", "EXAM_GRADE"]
*/
requiredPermissions?: string[];
/** 默认插入的 slot 名称 */
defaultSlot: string;
/** 默认尺寸 */
defaultSize: PluginSize;
/** 插件可配置的 props schemaJSON Schemaadmin 配置面板自动渲染表单) */
propsSchema?: JsonSchema;
/** 系统默认 props与 propsSchema 配合) */
defaultProps?: Record<string, unknown>;
};
}
/** 插件清单元数据(不含 Component用于 plugin.manifest.ts 声明) */
export type PluginManifestMeta = Omit<PluginManifest, "Component">;

View File

@@ -0,0 +1,102 @@
/**
* .env 文件加载器dev 模式专用).
*
* 背景:
* - NestJS 服务的 main.ts 直接 `process.env` 读取环境变量,无 dotenv 自动加载。
* - 在 PowerShell + pnpm dev 链中部分变量可能丢失Start-Process 子进程继承问题)。
* - 本模块在每个 NestJS 服务的 main.ts 顶部最先调用,从 monorepo 根 .env 加载。
*
* 行为:
* - 从调用方 cwd 向上查找 .env最多 5 层),加载第一个找到的文件。
* - 仅在 `process.env[KEY]` 为空/未定义时设置,不覆盖真实环境变量。
* - 支持 `KEY=VALUE`、`KEY="VALUE"`、`KEY='VALUE'`,忽略注释与空行。
*
* 用法:
* ```ts
* import "@edu/shared-ts/env-loader"; // 副作用导入main.ts 第一行
* ```
*
* 仲裁依据:
* - coord-final-decisions §1 G4pino 结构化日志)
* - DEV_MODE=true 旁路鉴权ADR-019
*/
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
/**
* 解析单个 .env 文件内容为 [key, value] 数组。
* @internal
*/
function parseEnvContent(content: string): Array<[string, string]> {
const entries: Array<[string, string]> = [];
const lines = content.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eqIdx = line.indexOf("=");
if (eqIdx === -1) continue;
const key = line.substring(0, eqIdx).trim();
if (!key) continue;
let val = line.substring(eqIdx + 1).trim();
// 移除引号
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.substring(1, val.length - 1);
}
entries.push([key, val]);
}
return entries;
}
/**
* 从 startDir 向上查找 .env 文件,最多向上 maxDepth 层。
* @internal
*/
function findEnvFile(startDir: string, maxDepth = 5): string | null {
let current = startDir;
for (let i = 0; i < maxDepth; i++) {
const candidate = join(current, ".env");
if (existsSync(candidate)) return candidate;
const parent = resolve(current, "..");
if (parent === current) break; // 到达根目录
current = parent;
}
return null;
}
/**
* 加载 .env 文件到 process.env仅 dev 模式)。
*
* - 当 NODE_ENV === "production" 时跳过(生产环境必须用真实环境变量)。
* - 当 DEV_MODE === "false" 时不跳过DEV_MODE 仅控制鉴权旁路,不影响 .env 加载)。
*
* @returns 加载的变量数量(已存在的不计)
*/
export function loadEnvFile(): number {
if (process.env.NODE_ENV === "production") return 0;
const envPath = findEnvFile(process.cwd());
if (!envPath) return 0;
let loaded = 0;
try {
const content = readFileSync(envPath, "utf-8");
const entries = parseEnvContent(content);
for (const [key, val] of entries) {
// 仅在未设置或空时填充,不覆盖真实环境变量
const existing = process.env[key];
if (existing === undefined || existing === "") {
process.env[key] = val;
loaded++;
}
}
} catch {
// 静默失败env.ts 的 zod 校验会给出明确错误
}
return loaded;
}
// 模块导入时自动加载一次(副作用导入模式)
loadEnvFile();

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);
}