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:
@@ -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": {
|
||||
|
||||
41
packages/shared-ts/src/contracts/index.ts
Normal file
41
packages/shared-ts/src/contracts/index.ts
Normal 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";
|
||||
135
packages/shared-ts/src/contracts/layout.ts
Normal file
135
packages/shared-ts/src/contracts/layout.ts
Normal 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[];
|
||||
}
|
||||
105
packages/shared-ts/src/contracts/plugin-context.ts
Normal file
105
packages/shared-ts/src/contracts/plugin-context.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* URL Search Params 上下文 schema(portal-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;
|
||||
/** 当前选中的考试 ID(exams-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);
|
||||
}
|
||||
}
|
||||
36
packages/shared-ts/src/contracts/plugin-store.ts
Normal file
36
packages/shared-ts/src/contracts/plugin-store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Zustand 全局状态 schema(portal-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;
|
||||
}
|
||||
134
packages/shared-ts/src/contracts/plugin.ts
Normal file
134
packages/shared-ts/src/contracts/plugin.ts
Normal 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;
|
||||
/** 数据范围(DataScope,IAM 计算后的可见范围 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 schema(JSON Schema,admin 配置面板自动渲染表单) */
|
||||
propsSchema?: JsonSchema;
|
||||
/** 系统默认 props(与 propsSchema 配合) */
|
||||
defaultProps?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** 插件清单元数据(不含 Component,用于 plugin.manifest.ts 声明) */
|
||||
export type PluginManifestMeta = Omit<PluginManifest, "Component">;
|
||||
102
packages/shared-ts/src/env-loader/index.ts
Normal file
102
packages/shared-ts/src/env-loader/index.ts
Normal 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 G4(pino 结构化日志)
|
||||
* - 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();
|
||||
273
packages/shared-ts/src/permission-bitmap.ts
Normal file
273
packages/shared-ts/src/permission-bitmap.ts
Normal 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 → 遍历 ORDER,bit 为 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);
|
||||
}
|
||||
Reference in New Issue
Block a user