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:
@@ -4,16 +4,10 @@
|
|||||||
* 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。
|
* 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。
|
||||||
* 关联:project_rules §3.10
|
* 关联:project_rules §3.10
|
||||||
*/
|
*/
|
||||||
let tsParser;
|
import tsParser from "@typescript-eslint/parser";
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
tsParser = require("@typescript-eslint/parser");
|
|
||||||
} catch {
|
|
||||||
tsParser = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {import('eslint').Linter.Config[]} */
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
module.exports = [
|
export default [
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -28,12 +22,12 @@ module.exports = [
|
|||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]',
|
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
|
||||||
message:
|
message:
|
||||||
"禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)",
|
"禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
selector: 'Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]',
|
selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]",
|
||||||
message:
|
message:
|
||||||
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)",
|
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)",
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
21
apps/portal-shell/components.json
Normal file
21
apps/portal-shell/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/shared/components",
|
||||||
|
"utils": "@/shared/lib/utils",
|
||||||
|
"ui": "@/shared/components/ui",
|
||||||
|
"lib": "@/shared/lib",
|
||||||
|
"hooks": "@/shared/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* portal-shell ESLint flat config
|
* portal-shell ESLint flat config (ESM)
|
||||||
*
|
*
|
||||||
* 包含设计令牌强制规则(project_rules §3.10):
|
* 包含设计令牌强制规则(project_rules §3.10):
|
||||||
* - 禁止 #hex 颜色字面量
|
* - 禁止 #hex 颜色字面量
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
*
|
*
|
||||||
* 关联:project_rules §3.10、portal-shell spec §7
|
* 关联:project_rules §3.10、portal-shell spec §7
|
||||||
*/
|
*/
|
||||||
const js = require("@eslint/js");
|
import js from "@eslint/js";
|
||||||
const tseslint = require("typescript-eslint");
|
import tseslint from "typescript-eslint";
|
||||||
const prettierConfig = require("eslint-config-prettier");
|
import prettierConfig from "eslint-config-prettier";
|
||||||
|
|
||||||
module.exports = tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
|
|||||||
3
apps/portal-shell/next-env.d.ts
vendored
3
apps/portal-shell/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* portal-shell Next.js 配置(v2.1 M8)
|
* Next.js 配置(v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack)
|
||||||
*
|
*
|
||||||
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker)
|
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker)
|
||||||
* - output: standalone(单容器部署)
|
* - output: standalone(单容器部署)
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
* - 反向代理:/api/v1/* → api-gateway :8080(JWT 校验 + 注入 x-user-id/x-user-role)
|
* - 反向代理:/api/v1/* → api-gateway :8080(JWT 校验 + 注入 x-user-id/x-user-role)
|
||||||
* - GraphQL 查询走 apollo-router :3000(M8 验收点,由 Apollo Client 直连)
|
* - GraphQL 查询走 apollo-router :3000(M8 验收点,由 Apollo Client 直连)
|
||||||
*
|
*
|
||||||
|
* Next 16 默认 Turbopack:
|
||||||
|
* - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射
|
||||||
|
* - webpack 配置保留作为 fallback(--webpack flag 时生效)
|
||||||
|
*
|
||||||
* 关联:portal-shell spec §2、project_rules §3.2
|
* 关联:portal-shell spec §2、project_rules §3.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,8 +28,20 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
serverActions: { bodySizeLimit: "2mb" },
|
serverActions: { bodySizeLimit: "2mb" },
|
||||||
},
|
},
|
||||||
|
// Turbopack 配置(Next 16 默认):处理 ESM 包 .js 后缀导入源码 .ts/.tsx 文件
|
||||||
|
turbopack: {
|
||||||
|
resolveExtensions: [
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".json",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Webpack 配置(fallback,使用 --webpack flag 时生效)
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
// ESM 包使用 .js 后缀导入源码(TS 文件),需映射 .js → .ts
|
|
||||||
config.resolve = config.resolve || {};
|
config.resolve = config.resolve || {};
|
||||||
config.resolve.extensionAlias = {
|
config.resolve.extensionAlias = {
|
||||||
...config.resolve.extensionAlias,
|
...config.resolve.extensionAlias,
|
||||||
@@ -49,4 +65,4 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@edu/portal-shell",
|
"name": "@edu/portal-shell",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4010",
|
"dev": "next dev -p 4010",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -21,31 +22,47 @@
|
|||||||
"@edu/hooks": "workspace:*",
|
"@edu/hooks": "workspace:*",
|
||||||
"@edu/ui-components": "workspace:*",
|
"@edu/ui-components": "workspace:*",
|
||||||
"@edu/ui-tokens": "workspace:*",
|
"@edu/ui-tokens": "workspace:*",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"crypto-hash": "^4.0.1",
|
"crypto-hash": "^4.0.1",
|
||||||
"graphql": "^16.8.0",
|
"graphql": "^16.8.0",
|
||||||
"next": "^14.2.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^18.3.0",
|
"next": "^16.0.10",
|
||||||
"react-dom": "^18.3.0",
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.2.0",
|
"swr": "^2.2.0",
|
||||||
"zustand": "^4.5.0"
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^5.0.0",
|
"@graphql-codegen/cli": "^5.0.0",
|
||||||
"@graphql-codegen/typescript": "^4.0.0",
|
"@graphql-codegen/typescript": "^4.0.0",
|
||||||
"@graphql-codegen/typescript-document-nodes": "^4.0.0",
|
"@graphql-codegen/typescript-document-nodes": "^4.0.0",
|
||||||
"@graphql-codegen/typescript-operations": "^4.0.0",
|
"@graphql-codegen/typescript-operations": "^4.0.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.4.0",
|
"@testing-library/jest-dom": "^6.4.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"autoprefixer": "^10.4.0",
|
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"postcss": "^8.4.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
module.exports = {
|
/**
|
||||||
|
* PostCSS 配置(Tailwind v4)
|
||||||
|
*
|
||||||
|
* Tailwind v4 使用 @tailwindcss/postcss 插件,配置通过 CSS 内的
|
||||||
|
* @import "tailwindcss" + @theme inline 指令完成,不再需要 tailwind.config.js。
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,6 +113,48 @@ function normalizeSchema(content: string): {
|
|||||||
return { staticDefs: staticDefs.join("\n"), queryFields };
|
return { staticDefs: staticDefs.join("\n"), queryFields };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize invalid input field types.
|
||||||
|
//
|
||||||
|
// Problem: services/ai subgraph declares `input ChatRequestInput { messages:
|
||||||
|
// ChatMessage }` and `input ChatResponseInput { usage: Usage }` where
|
||||||
|
// ChatMessage/Usage are OUTPUT types. GraphQL spec forbids input fields
|
||||||
|
// referencing output types; graphql-codegen's typescript plugin rejects this.
|
||||||
|
//
|
||||||
|
// Solution: rewrite those offending input field types to `String` in the
|
||||||
|
// combined schema. This is a codegen-only sanitize; the runtime apollo-router
|
||||||
|
// uses the original subgraph schemas directly.
|
||||||
|
//
|
||||||
|
// Related: spec section 2.4
|
||||||
|
const SANITIZE_INPUT_FIELD_REPLACEMENTS: Array<{
|
||||||
|
inputName: string;
|
||||||
|
fieldName: string;
|
||||||
|
replacement: string;
|
||||||
|
}> = [
|
||||||
|
// services/ai: input ChatRequestInput { messages: ChatMessage }
|
||||||
|
{
|
||||||
|
inputName: "ChatRequestInput",
|
||||||
|
fieldName: "messages",
|
||||||
|
replacement: "String",
|
||||||
|
},
|
||||||
|
// services/ai: input ChatResponseInput { usage: Usage }
|
||||||
|
{ inputName: "ChatResponseInput", fieldName: "usage", replacement: "String" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function sanitizeInputFields(content: string): string {
|
||||||
|
let out = content;
|
||||||
|
for (const r of SANITIZE_INPUT_FIELD_REPLACEMENTS) {
|
||||||
|
// Match ` fieldName: OriginalType` lines within `input InputName { ... }`
|
||||||
|
// blocks. We rely on the simple field-line format generated above.
|
||||||
|
const inputBlockRe = new RegExp(
|
||||||
|
`(input\\s+${r.inputName}\\s*\\{[^}]*?)` +
|
||||||
|
`(\\s{2,}${r.fieldName}\\s*:\\s*)[A-Za-z_][A-Za-z0-9_\\[\\]!]*`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
out = out.replace(inputBlockRe, `$1$2${r.replacement}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function main(): void {
|
function main(): void {
|
||||||
const schemas = loadSchemas();
|
const schemas = loadSchemas();
|
||||||
const allStaticDefs: string[] = [];
|
const allStaticDefs: string[] = [];
|
||||||
@@ -130,7 +172,7 @@ function main(): void {
|
|||||||
new Set(allQueryFields.map((f) => f.trim())),
|
new Set(allQueryFields.map((f) => f.trim())),
|
||||||
);
|
);
|
||||||
|
|
||||||
const combined = [
|
let combined = [
|
||||||
"# Combined normalized schema for graphql-codegen (federation stripped)",
|
"# Combined normalized schema for graphql-codegen (federation stripped)",
|
||||||
"# DO NOT EDIT - generated by scripts/normalize-schema.ts",
|
"# DO NOT EDIT - generated by scripts/normalize-schema.ts",
|
||||||
"",
|
"",
|
||||||
@@ -142,6 +184,8 @@ function main(): void {
|
|||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
combined = sanitizeInputFields(combined);
|
||||||
|
|
||||||
const outDir = path.resolve(process.cwd(), "src/lib/api/__generated__");
|
const outDir = path.resolve(process.cwd(), "src/lib/api/__generated__");
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const outPath = path.join(outDir, "combined-schema.graphql");
|
const outPath = path.join(outDir, "combined-schema.graphql");
|
||||||
|
|||||||
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 客户端错误上报端点(mock 实现)
|
||||||
|
*
|
||||||
|
* 当前阶段:输出到 stdout,便于开发调试
|
||||||
|
* 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log
|
||||||
|
*
|
||||||
|
* 端点:POST /api/log
|
||||||
|
* Body: ErrorReportPayload(见 @edu/hooks/use-error-report)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
interface ErrorReportPayload {
|
||||||
|
level: "error" | "warning";
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
digest?: string;
|
||||||
|
path: string;
|
||||||
|
userAgent: string;
|
||||||
|
timestamp: string;
|
||||||
|
pluginId?: string;
|
||||||
|
userId?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as ErrorReportPayload;
|
||||||
|
|
||||||
|
// 开发阶段:结构化输出到 stdout
|
||||||
|
// 生产阶段:这里应替换为 OTel export 或 Sentry capture
|
||||||
|
console.error("[client-error]", {
|
||||||
|
level: payload.level,
|
||||||
|
message: payload.message,
|
||||||
|
digest: payload.digest,
|
||||||
|
path: payload.path,
|
||||||
|
pluginId: payload.pluginId,
|
||||||
|
userId: payload.userId,
|
||||||
|
timestamp: payload.timestamp,
|
||||||
|
// stack 太长,单独一行输出便于阅读
|
||||||
|
stack: payload.stack?.split("\n").slice(0, 5).join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回 204,让 sendBeacon 认为成功
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch {
|
||||||
|
// 解析失败也返回 204,避免客户端重试
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 健康检查 */
|
||||||
|
export function GET(): NextResponse {
|
||||||
|
return NextResponse.json({ ok: true, endpoint: "/api/log" });
|
||||||
|
}
|
||||||
@@ -1,55 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* portal-shell 全局样式
|
* portal-shell 全局样式(Tailwind v4 + shadcn 标准令牌)
|
||||||
*
|
*
|
||||||
* 引入 @edu/ui-tokens 三层设计令牌(primitive → semantic → tailwind-theme)
|
* 引入 @edu/ui-tokens 三层设计令牌(primitive → semantic → tailwind-theme)
|
||||||
|
* 业务代码使用 Tailwind 类(bg-background / text-foreground / bg-card ...)或 hsl(var(--*)) 引用。
|
||||||
*
|
*
|
||||||
* 禁止规则(ESLint + project_rules §3.10):
|
* 禁止规则(ESLint + project_rules §3.10):
|
||||||
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
|
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
|
||||||
* - 禁止字体名字面量(用 var(--font-family-*))
|
* - 禁止字体名字面量(用 var(--font-family-*))
|
||||||
* - 禁止 font-size: Npx(用 var(--font-size-*) 或 Tailwind text-* 类)
|
* - 禁止 font-size: Npx(用 var(--font-size-*) 或 Tailwind text-* 类)
|
||||||
|
*
|
||||||
|
* 对齐:CICD 项目 src/app/globals.css
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
@import "@edu/ui-tokens/all.css";
|
@import "@edu/ui-tokens/all.css";
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@tailwind base;
|
/* 排除非源码目录,防止文档中的 Tailwind 任意值语法字符串被误识别为类名 */
|
||||||
@tailwind components;
|
@source not "../../docs";
|
||||||
@tailwind utilities;
|
@source not "../../scripts";
|
||||||
|
@source not "../../tests";
|
||||||
|
|
||||||
|
/* Reduced Motion */
|
||||||
@layer base {
|
@layer base {
|
||||||
html,
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Styles */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
background: var(--bg-paper);
|
@apply bg-background text-foreground;
|
||||||
color: var(--color-ink);
|
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
font-family: var(--font-family-serif);
|
font-family: var(--font-family-sans);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
letter-spacing: var(--letter-spacing-tight);
|
letter-spacing: -0.01em;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
/* 纸感分隔线 */
|
|
||||||
.rule {
|
|
||||||
border-top: 1px solid var(--color-rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-thin {
|
|
||||||
border-top: 2px solid var(--color-rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧竖线标记 */
|
|
||||||
.mark-left {
|
|
||||||
border-left: 2px solid var(--color-rule);
|
|
||||||
padding-left: var(--space-md);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字体加载(next/font/google self-host)
|
* 字体加载(next/font/google self-host)
|
||||||
*
|
*
|
||||||
* 通过 CSS 变量暴露字体族(--font-inter / --font-fraunces / --font-jetbrains-mono),
|
* 通过 CSS 变量 --font-inter 暴露字体族。
|
||||||
* ui-tokens 的 semantic 层将它们映射为 --font-family-sans/serif/mono。
|
* ui-tokens 的 primitive 层将 --font-family-sans 映射为 var(--font-inter, ...)。
|
||||||
* 禁止字体名字面量(project_rules §3.10)。
|
* 禁止字体名字面量(project_rules §3.10)。
|
||||||
|
*
|
||||||
|
* 对齐:CICD 项目 src/app/layout.tsx(仅 Inter,shadcn 标准)
|
||||||
*/
|
*/
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -16,28 +20,24 @@ const inter = Inter({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const fraunces = Fraunces({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-fraunces",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const mono = JetBrains_Mono({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-jetbrains-mono",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Edu Portal Shell",
|
title: "Edu Portal Shell",
|
||||||
description: "K12 智慧教务平台 - 插件化仪表盘",
|
description: "K12 智慧教务平台 - 插件化仪表盘",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RootLayout
|
* RootLayout
|
||||||
*
|
*
|
||||||
* 仅负责 <html>/<body> 与字体变量。需要 RSC 数据的 Providers
|
* 仅负责 <html>/<body> 与字体变量 + 全局 Toaster。
|
||||||
* (Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
* 业务 Providers(Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
||||||
|
*
|
||||||
|
* suppressHydrationWarning:ThemeI18nProvider 在客户端切换 .dark class,
|
||||||
|
* 与 SSR 输出的 <html class=""> 不一致,需抑制 hydration 警告。
|
||||||
*/
|
*/
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -45,11 +45,11 @@ export default function RootLayout({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="zh-CN" suppressHydrationWarning className={inter.variable}>
|
||||||
lang="zh-CN"
|
<body className="font-sans antialiased">
|
||||||
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
{children}
|
||||||
>
|
<Toaster />
|
||||||
<body>{children}</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,44 @@ import { headers } from "next/headers";
|
|||||||
import { fetchPluginConfig } from "@/lib/config-fetcher";
|
import { fetchPluginConfig } from "@/lib/config-fetcher";
|
||||||
import { ClientShell } from "@/shell/ClientShell";
|
import { ClientShell } from "@/shell/ClientShell";
|
||||||
import type { Role } from "@/lib/types";
|
import type { Role } from "@/lib/types";
|
||||||
|
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点)
|
* Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染)
|
||||||
*
|
*
|
||||||
* 数据流(portal-shell spec §5.5):
|
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
||||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||||
* ③ Config 作为 props 传给 ClientShell,随 HTML 直出,消除 CSR 瀑布流
|
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||||
|
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||||
|
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||||
|
*
|
||||||
|
* 流式渲染分层(README v2.0 §5.3):
|
||||||
|
* - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中
|
||||||
|
* - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹
|
||||||
|
* - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense
|
||||||
*
|
*
|
||||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准
|
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3
|
||||||
*/
|
*/
|
||||||
export default async function ShellPage(): Promise<React.ReactElement> {
|
export default async function ShellPage(): Promise<React.ReactElement> {
|
||||||
const headerList = headers();
|
const headerList = await headers();
|
||||||
const userId =
|
const userId =
|
||||||
headerList.get("x-user-id") ||
|
headerList.get("x-user-id") ||
|
||||||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
||||||
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
||||||
|
|
||||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||||
const config = await fetchPluginConfig(userId, role);
|
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||||
|
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
|
||||||
return <ClientShell config={config} role={role} userId={userId} />;
|
// 将 Promise 作为 prop 传递,ClientShell 内部通过 use() 消费
|
||||||
|
// Next.js 会自动用 loading.tsx 作为 Suspense fallback 流式输出 HTML
|
||||||
|
return (
|
||||||
|
<ClientShell configPromise={configPromise} role={role} userId={userId} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell 路由错误兜底(Next.js App Router error.tsx)
|
||||||
|
*
|
||||||
|
* 触发条件:
|
||||||
|
* - RSC 渲染抛错(如 fetchPluginConfig 失败)
|
||||||
|
* - ClientShell 渲染抛错(Provider 嵌套问题)
|
||||||
|
* - 任何子 segment 未捕获的错误
|
||||||
|
*
|
||||||
|
* 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级):
|
||||||
|
* 1. 隔离错误,避免整页白屏
|
||||||
|
* 2. 通过 useErrorReport 上报到 /api/log
|
||||||
|
* 3. 提供 reset 按钮重试
|
||||||
|
*
|
||||||
|
* 注意:error.tsx 必须是 Client Component("use client")
|
||||||
|
*
|
||||||
|
* 关联:Next.js App Router § error.tsx、portal-shell README v2.0 §5.4
|
||||||
|
*/
|
||||||
|
export default function ShellError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}): React.ReactNode {
|
||||||
|
return <RouteErrorBoundary error={error} reset={reset} namespace="Shell" />;
|
||||||
|
}
|
||||||
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell 路由加载兜底(Next.js App Router loading.tsx)
|
||||||
|
*
|
||||||
|
* 触发条件:
|
||||||
|
* - RSC 正在解析(fetchPluginConfig 等待中)
|
||||||
|
* - 路由切换时的过渡态
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 整页骨架占位,避免白屏闪烁
|
||||||
|
* - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区)
|
||||||
|
*
|
||||||
|
* 流式渲染上下文(portal-shell README v2.0 §5.3):
|
||||||
|
* - loading.tsx 在 RSC Promise resolve 之前显示
|
||||||
|
* - 配合 PluginBoundary(widget 级 Suspense)形成多层流式体验
|
||||||
|
*
|
||||||
|
* 关联:Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3
|
||||||
|
*/
|
||||||
|
export default function ShellLoading(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
{/* 顶栏 */}
|
||||||
|
<header className="border-b bg-card">
|
||||||
|
<div className="flex h-16 items-center gap-3 px-6">
|
||||||
|
<Skeleton className="size-8 rounded-md" />
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<Skeleton className="size-9 rounded-full" />
|
||||||
|
<Skeleton className="size-9 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1">
|
||||||
|
{/* 侧栏 */}
|
||||||
|
<aside className="w-64 border-r bg-card p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="size-8 rounded-md" />
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 主区:仪表盘骨架 */}
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片网格 */}
|
||||||
|
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="rounded-xl border bg-card p-6">
|
||||||
|
<Skeleton className="mb-3 h-4 w-24" />
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区:图表 + 列表 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
||||||
|
<Skeleton className="mb-4 h-6 w-32" />
|
||||||
|
<div className="flex h-64 items-end gap-2">
|
||||||
|
{[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map(
|
||||||
|
(h, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<Skeleton className="mb-4 h-6 w-24" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="size-9 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/portal-shell/src/lib/__tests__/plugin-context.test.ts
Normal file
130
apps/portal-shell/src/lib/__tests__/plugin-context.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
parseUrlContext,
|
||||||
|
writeUrlContext,
|
||||||
|
URL_CONTEXT_KEYS,
|
||||||
|
type UrlPluginContext,
|
||||||
|
} from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL Search Params 上下文测试(portal-shell spec §5.2.1、§9.9)
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* - parseUrlContext:从 URLSearchParams 解析上下文
|
||||||
|
* - writeUrlContext:将上下文写入 URLSearchParams
|
||||||
|
* - URL_CONTEXT_KEYS:常量正确性
|
||||||
|
*/
|
||||||
|
describe("URL_CONTEXT_KEYS", () => {
|
||||||
|
it("key 名称与 URL 参数名一致", () => {
|
||||||
|
expect(URL_CONTEXT_KEYS.classId).toBe("classId");
|
||||||
|
expect(URL_CONTEXT_KEYS.childId).toBe("childId");
|
||||||
|
expect(URL_CONTEXT_KEYS.termId).toBe("termId");
|
||||||
|
expect(URL_CONTEXT_KEYS.view).toBe("view");
|
||||||
|
expect(URL_CONTEXT_KEYS.subjectId).toBe("subjectId");
|
||||||
|
expect(URL_CONTEXT_KEYS.examId).toBe("examId");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseUrlContext", () => {
|
||||||
|
it("空参数 → 空上下文", () => {
|
||||||
|
const ctx = parseUrlContext(new URLSearchParams());
|
||||||
|
expect(ctx).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("解析 classId", () => {
|
||||||
|
const params = new URLSearchParams("?classId=cls-001");
|
||||||
|
const ctx = parseUrlContext(params);
|
||||||
|
expect(ctx.classId).toBe("cls-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("解析多个参数", () => {
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
"?classId=cls-001&termId=2024-fall&view=chart",
|
||||||
|
);
|
||||||
|
const ctx = parseUrlContext(params);
|
||||||
|
expect(ctx).toEqual({
|
||||||
|
classId: "cls-001",
|
||||||
|
termId: "2024-fall",
|
||||||
|
view: "chart",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("解析全部参数", () => {
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
"?classId=cls-1&childId=child-1&termId=t-1&view=list&subjectId=subj-1&examId=exam-1",
|
||||||
|
);
|
||||||
|
const ctx = parseUrlContext(params);
|
||||||
|
expect(ctx).toEqual({
|
||||||
|
classId: "cls-1",
|
||||||
|
childId: "child-1",
|
||||||
|
termId: "t-1",
|
||||||
|
view: "list",
|
||||||
|
subjectId: "subj-1",
|
||||||
|
examId: "exam-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略空值参数", () => {
|
||||||
|
const params = new URLSearchParams("?classId=&termId=t-1");
|
||||||
|
const ctx = parseUrlContext(params);
|
||||||
|
expect(ctx.classId).toBeUndefined();
|
||||||
|
expect(ctx.termId).toBe("t-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeUrlContext", () => {
|
||||||
|
it("写入单个值", () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
writeUrlContext(params, { classId: "cls-001" });
|
||||||
|
expect(params.get("classId")).toBe("cls-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("写入多个值", () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
writeUrlContext(params, {
|
||||||
|
classId: "cls-1",
|
||||||
|
termId: "t-1",
|
||||||
|
view: "chart",
|
||||||
|
});
|
||||||
|
expect(params.get("classId")).toBe("cls-1");
|
||||||
|
expect(params.get("termId")).toBe("t-1");
|
||||||
|
expect(params.get("view")).toBe("chart");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空字符串 → 删除参数", () => {
|
||||||
|
const params = new URLSearchParams("?classId=cls-1");
|
||||||
|
writeUrlContext(params, { classId: "" });
|
||||||
|
expect(params.has("classId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undefined → 保留原值不变", () => {
|
||||||
|
const params = new URLSearchParams("?classId=cls-1&termId=t-1");
|
||||||
|
writeUrlContext(params, { classId: "cls-2" });
|
||||||
|
// 只更新 classId,termId 保留
|
||||||
|
expect(params.get("classId")).toBe("cls-2");
|
||||||
|
expect(params.get("termId")).toBe("t-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空对象 → 不修改任何参数", () => {
|
||||||
|
const params = new URLSearchParams("?classId=cls-1");
|
||||||
|
writeUrlContext(params, {});
|
||||||
|
expect(params.get("classId")).toBe("cls-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseUrlContext + writeUrlContext 往返", () => {
|
||||||
|
it("写入后解析应得到相同上下文", () => {
|
||||||
|
const original: UrlPluginContext = {
|
||||||
|
classId: "cls-001",
|
||||||
|
childId: "child-001",
|
||||||
|
termId: "2024-fall",
|
||||||
|
view: "list",
|
||||||
|
subjectId: "math",
|
||||||
|
examId: "exam-001",
|
||||||
|
};
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
writeUrlContext(params, original);
|
||||||
|
const parsed = parseUrlContext(params);
|
||||||
|
expect(parsed).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
* - 统一走 apollo-router GraphQL,由 Router 路由到 config-service 子图
|
* - 统一走 apollo-router GraphQL,由 Router 路由到 config-service 子图
|
||||||
* - RSC 服务端预取消除 CSR 瀑布流,Config 随 HTML 直出
|
* - RSC 服务端预取消除 CSR 瀑布流,Config 随 HTML 直出
|
||||||
*
|
*
|
||||||
|
* 开发态降级(spec §5.5 容错):
|
||||||
|
* - 优先走 apollo-router(生产路径)
|
||||||
|
* - Router 不可用时降级直连 config-service /graphql(仅开发态,由 CONFIG_SERVICE_URL 触发)
|
||||||
|
* - 二者均失败时返回空默认配置,保证 Shell 可渲染
|
||||||
|
*
|
||||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准
|
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准
|
||||||
*/
|
*/
|
||||||
import { gql } from "@apollo/client";
|
import { gql } from "@apollo/client";
|
||||||
@@ -55,6 +60,11 @@ export const GET_PLUGIN_CONFIG = gql`
|
|||||||
/**
|
/**
|
||||||
* 获取用户合并后的插件配置(服务端调用)。
|
* 获取用户合并后的插件配置(服务端调用)。
|
||||||
*
|
*
|
||||||
|
* 查询顺序(开发态容错):
|
||||||
|
* 1. apollo-router(生产路径,M8 验收点)
|
||||||
|
* 2. config-service 直连(仅当 CONFIG_SERVICE_URL 配置时启用,开发态降级)
|
||||||
|
* 3. 空默认配置(最后兜底)
|
||||||
|
*
|
||||||
* @param userId 用户 ID(来自 RSC 的 x-user-id 头)
|
* @param userId 用户 ID(来自 RSC 的 x-user-id 头)
|
||||||
* @param role 用户角色(来自 RSC 的 x-user-role 头)
|
* @param role 用户角色(来自 RSC 的 x-user-role 头)
|
||||||
* @returns 三层合并后的 PluginConfigResponse;查询失败时返回默认 classic 配置
|
* @returns 三层合并后的 PluginConfigResponse;查询失败时返回默认 classic 配置
|
||||||
@@ -63,8 +73,9 @@ export async function fetchPluginConfig(
|
|||||||
userId: string,
|
userId: string,
|
||||||
role: Role,
|
role: Role,
|
||||||
): Promise<PluginConfigResponse> {
|
): Promise<PluginConfigResponse> {
|
||||||
const client = createApolloClient();
|
// 1. 优先走 apollo-router(生产路径)
|
||||||
try {
|
try {
|
||||||
|
const client = createApolloClient();
|
||||||
const { data, error } = await client.query<{
|
const { data, error } = await client.query<{
|
||||||
pluginConfig: PluginConfigResponse;
|
pluginConfig: PluginConfigResponse;
|
||||||
}>({
|
}>({
|
||||||
@@ -73,22 +84,106 @@ export async function fetchPluginConfig(
|
|||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[portal-shell] fetchPluginConfig partial error: ${error.message}`,
|
`[portal-shell] fetchPluginConfig partial error from apollo-router: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (data?.pluginConfig) {
|
if (data?.pluginConfig) {
|
||||||
return data.pluginConfig;
|
return data.pluginConfig;
|
||||||
}
|
}
|
||||||
return getDefaultConfig();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Router 未就绪时降级为默认配置,保证 Shell 可渲染(开发态友好)
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[portal-shell] fetchPluginConfig failed, falling back to default config: ${
|
`[portal-shell] apollo-router query failed: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 开发态降级:直连 config-service GraphQL
|
||||||
|
const configServiceUrl =
|
||||||
|
process.env.CONFIG_SERVICE_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_CONFIG_SERVICE_URL;
|
||||||
|
if (configServiceUrl) {
|
||||||
|
try {
|
||||||
|
const result = await fetchPluginConfigDirect(
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
configServiceUrl,
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
console.info(
|
||||||
|
`[portal-shell] fetchPluginConfig fallback to config-service direct`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[portal-shell] config-service direct fallback failed: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 最终兜底:空默认配置
|
||||||
|
console.warn(`[portal-shell] fetchPluginConfig returning empty default`);
|
||||||
return getDefaultConfig();
|
return getDefaultConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发态降级:直连 config-service GraphQL 查询 pluginConfig。
|
||||||
|
*
|
||||||
|
* 当 apollo-router 不可用时(如本地开发未启动 Router),
|
||||||
|
* 直接请求 config-service 的 /graphql 端点获取插件配置。
|
||||||
|
* 生产环境不应触发此路径(apollo-router 必须可用)。
|
||||||
|
*/
|
||||||
|
async function fetchPluginConfigDirect(
|
||||||
|
userId: string,
|
||||||
|
role: string,
|
||||||
|
configServiceUrl: string,
|
||||||
|
): Promise<PluginConfigResponse | null> {
|
||||||
|
const endpoint = `${configServiceUrl.replace(/\/$/, "")}/graphql`;
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
query GetPluginConfig($userId: ID!, $role: String) {
|
||||||
|
pluginConfig(userId: $userId, role: $role) {
|
||||||
|
activeLayout {
|
||||||
|
layoutId
|
||||||
|
displayName
|
||||||
|
description
|
||||||
|
availableSlots
|
||||||
|
layoutSchemaJson
|
||||||
|
}
|
||||||
|
slots { slotName navItems }
|
||||||
|
plugins { pluginId slot sortOrder sizeJson propsJson isVisible }
|
||||||
|
registry { pluginId category version displayName description requiredRoles isBuiltin isActive }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { userId, role },
|
||||||
|
}),
|
||||||
|
// RSC 服务端调用,不携带 cookie
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`config-service HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as {
|
||||||
|
data?: { pluginConfig?: PluginConfigResponse };
|
||||||
|
errors?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json.errors) {
|
||||||
|
throw new Error(
|
||||||
|
`config-service GraphQL errors: ${JSON.stringify(json.errors)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data?.pluginConfig ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ export interface PluginManifest {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: PluginCategory;
|
category: PluginCategory;
|
||||||
|
/** L1 角色门禁:可访问此插件的角色列表(粗粒度) */
|
||||||
requiredRoles: Role[];
|
requiredRoles: Role[];
|
||||||
|
/**
|
||||||
|
* L2 权限点门禁:访问此插件所需的权限点列表(细粒度,AND 语义)
|
||||||
|
* 权限点必须来自 PERMISSION_BITMAP_ORDER
|
||||||
|
*/
|
||||||
|
requiredPermissions?: string[];
|
||||||
defaultSlot: string;
|
defaultSlot: string;
|
||||||
defaultSize: PluginSize;
|
defaultSize: PluginSize;
|
||||||
/** 插件可配置的 props schema(admin 配置面板用) */
|
/** 插件可配置的 props schema(admin 配置面板用) */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type TypedDocumentNode,
|
type TypedDocumentNode,
|
||||||
} from "@apollo/client";
|
} from "@apollo/client";
|
||||||
|
|
||||||
export function useWidgetMutation<TData, TVars extends Record<string, unknown>>(
|
export function useWidgetMutation<TData, TVars = Record<string, unknown>>(
|
||||||
mutation: DocumentNode | TypedDocumentNode<TData, TVars>,
|
mutation: DocumentNode | TypedDocumentNode<TData, TVars>,
|
||||||
) {
|
) {
|
||||||
const [mutate, result] = useMutation<TData, TVars>(mutation, {
|
const [mutate, result] = useMutation<TData, TVars>(mutation, {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export interface UseWidgetQueryOptions<TData> {
|
|||||||
fetchPolicy?: FetchPolicy;
|
fetchPolicy?: FetchPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWidgetQuery<TData, TVars extends Record<string, unknown>>(
|
export function useWidgetQuery<
|
||||||
|
TData,
|
||||||
|
TVars extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
>(
|
||||||
query: DocumentNode | TypedDocumentNode<TData, TVars>,
|
query: DocumentNode | TypedDocumentNode<TData, TVars>,
|
||||||
variables: TVars,
|
variables: TVars,
|
||||||
options?: UseWidgetQueryOptions<TData>,
|
options?: UseWidgetQueryOptions<TData>,
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
|
||||||
|
import { useErrorReport } from "@edu/hooks";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardSection - 仪表盘分区包装器(对齐 CICD dashboard-section.tsx)
|
||||||
|
*
|
||||||
|
* 三件套组合:SectionErrorBoundary + Suspense + 5 种骨架变体
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 隔离分区渲染错误(不影响其他分区)
|
||||||
|
* 2. 流式渲染:Suspense 边界显示骨架屏,数据到达后替换
|
||||||
|
* 3. a11y:传入 ariaLabel 时渲染 role="region" tabIndex={0}
|
||||||
|
*
|
||||||
|
* 5 种骨架变体:
|
||||||
|
* - stats:统计卡片骨架(大数字 + 标签)
|
||||||
|
* - card:通用卡片骨架(标题 + 内容块)
|
||||||
|
* - chart:图表骨架(坐标轴 + 柱状)
|
||||||
|
* - table:表格骨架(表头 + 多行)
|
||||||
|
* - list:列表骨架(多行)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L2 区块级)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <DashboardSection title="今日课程" variant="table">
|
||||||
|
* <ScheduleList />
|
||||||
|
* </DashboardSection>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DashboardSectionVariant =
|
||||||
|
"stats" | "card" | "chart" | "table" | "list";
|
||||||
|
|
||||||
|
export interface DashboardSectionProps {
|
||||||
|
/** 分区标题(显示在 CardHeader) */
|
||||||
|
title?: string;
|
||||||
|
/** 分区描述(显示在 CardHeader) */
|
||||||
|
description?: string;
|
||||||
|
/** 子节点(分区内容) */
|
||||||
|
children: ReactNode;
|
||||||
|
/** 骨架变体(默认 card) */
|
||||||
|
variant?: DashboardSectionVariant;
|
||||||
|
/** a11y 标签(传入时渲染 role="region" tabIndex={0}) */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** 右侧操作区(如"查看全部"链接) */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5 种骨架变体实现
|
||||||
|
*/
|
||||||
|
export function DashboardSectionSkeleton({
|
||||||
|
variant = "card",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
variant?: DashboardSectionVariant;
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
if (variant === "table") {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-1/4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "list") {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-1/4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "chart") {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex h-48 items-end gap-2">
|
||||||
|
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "stats") {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// card(默认)
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardSection - 仪表盘分区(ErrorBoundary + Suspense + Skeleton)
|
||||||
|
*/
|
||||||
|
export function DashboardSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
variant = "card",
|
||||||
|
ariaLabel,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: DashboardSectionProps): ReactNode {
|
||||||
|
const reportError = useErrorReport();
|
||||||
|
|
||||||
|
const sectionProps = ariaLabel
|
||||||
|
? { role: "region" as const, tabIndex: 0, "aria-label": ariaLabel }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
{...sectionProps}
|
||||||
|
className={cn(
|
||||||
|
ariaLabel &&
|
||||||
|
"rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SectionErrorBoundary
|
||||||
|
title={title}
|
||||||
|
onError={(error) => {
|
||||||
|
void reportError(error, {
|
||||||
|
level: "error",
|
||||||
|
context: { section: title },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
|
||||||
|
{(title || actions) && (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { PageHeader } from "@/shared/components/ui/page-header";
|
||||||
|
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardShell - 仪表盘外壳(对齐 CICD dashboard-shell.tsx)
|
||||||
|
*
|
||||||
|
* 极简结构:PageHeader + StatsGrid(可选)+ children
|
||||||
|
* - stats 为空数组时不渲染统计区(适配无统计指标的页面)
|
||||||
|
* - children 是页面主体内容
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <DashboardShell
|
||||||
|
* title="教师仪表盘"
|
||||||
|
* description="今日教学概览"
|
||||||
|
* stats={<StatCard title="班级" value={6} />}
|
||||||
|
* actions={<Button>导出</Button>}
|
||||||
|
* >
|
||||||
|
* <DashboardSection title="今日课程">
|
||||||
|
* <ScheduleList />
|
||||||
|
* </DashboardSection>
|
||||||
|
* </DashboardShell>
|
||||||
|
*/
|
||||||
|
export interface DashboardShellProps {
|
||||||
|
/** 页面标题 */
|
||||||
|
title: string;
|
||||||
|
/** 页面描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 标题前图标 */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** 右侧操作区 */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** 统计卡片组(传入 StatsGrid 或多个 StatCard) */
|
||||||
|
stats?: ReactNode;
|
||||||
|
/** 主体内容 */
|
||||||
|
children: ReactNode;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
actions,
|
||||||
|
stats,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: DashboardShellProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-6 p-6", className)}>
|
||||||
|
<PageHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
icon={icon}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
{stats && <StatsGrid>{stats}</StatsGrid>}
|
||||||
|
<div className="space-y-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
apps/portal-shell/src/shared/components/layout/app-sidebar.tsx
Normal file
238
apps/portal-shell/src/shared/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import {
|
||||||
|
SIDEBAR_WIDTH_COLLAPSED,
|
||||||
|
SIDEBAR_WIDTH_EXPANDED,
|
||||||
|
useSidebar,
|
||||||
|
} from "./sidebar-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppSidebar - 侧边栏实现(对齐 CICD app-sidebar.tsx)
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - 桌面端:<aside> + transition-[width](w-64 ↔ w-16)
|
||||||
|
* - 折叠态:仅图标 + Tooltip(hover 显示标题),sr-only 标签保证 a11y
|
||||||
|
* - 展开态:Collapsible 子菜单(defaultOpen={isActive} 自动展开当前路由所在组)
|
||||||
|
* - 导航项按权限过滤(hasPermission(item.permission))
|
||||||
|
*
|
||||||
|
* 注意:本组件是基础组件库的一部分,portal-shell 的 LayoutManager 在 P1 阶段
|
||||||
|
* 重构时将使用此组件替换现有 5 种布局模板中的侧边栏部分。
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.3 布局组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
/** 显示名称 */
|
||||||
|
title: string;
|
||||||
|
/** 跳转链接 */
|
||||||
|
href?: string;
|
||||||
|
/** 图标(lucide-react 图标组件) */
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
/** 所需权限点(无权限不显示) */
|
||||||
|
permission?: string;
|
||||||
|
/** 子菜单 */
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSidebarProps {
|
||||||
|
/** 导航配置(按角色分组) */
|
||||||
|
items: NavItem[];
|
||||||
|
/** 权限检查函数(从 usePermission().hasPermission 注入) */
|
||||||
|
hasPermission?: (perm: string) => boolean;
|
||||||
|
/** 侧边栏底部内容(如用户信息、版本号) */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({
|
||||||
|
items,
|
||||||
|
hasPermission,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
}: AppSidebarProps): ReactNode {
|
||||||
|
const { expanded } = useSidebar();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// 权限过滤
|
||||||
|
const visibleItems = items.filter(
|
||||||
|
(item) => !item.permission || hasPermission?.(item.permission) !== false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"flex h-screen flex-col border-r bg-card transition-[width] duration-200",
|
||||||
|
expanded ? SIDEBAR_WIDTH_EXPANDED : SIDEBAR_WIDTH_COLLAPSED,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<nav className="flex-1 overflow-y-auto p-2">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{visibleItems.map((item) => (
|
||||||
|
<li key={item.title}>
|
||||||
|
<NavMenuItem
|
||||||
|
item={item}
|
||||||
|
expanded={expanded}
|
||||||
|
pathname={pathname}
|
||||||
|
hasPermission={hasPermission}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{footer && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="p-2">{footer}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavMenuItem({
|
||||||
|
item,
|
||||||
|
expanded,
|
||||||
|
pathname,
|
||||||
|
hasPermission,
|
||||||
|
}: {
|
||||||
|
item: NavItem;
|
||||||
|
expanded: boolean;
|
||||||
|
pathname: string;
|
||||||
|
hasPermission?: (perm: string) => boolean;
|
||||||
|
}): ReactNode {
|
||||||
|
const isActive = item.href === pathname;
|
||||||
|
const visibleChildren = item.children?.filter(
|
||||||
|
(c) => !c.permission || hasPermission?.(c.permission) !== false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 折叠态:仅图标 + Tooltip
|
||||||
|
if (!expanded) {
|
||||||
|
const Icon = item.icon;
|
||||||
|
if (!Icon) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
|
size="icon"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Link href={item.href ?? "#"}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span className="sr-only">{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开态 + 无子菜单
|
||||||
|
if (!visibleChildren?.length) {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Link href={item.href ?? "#"}>
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开态 + 有子菜单(Collapsible)
|
||||||
|
return (
|
||||||
|
<CollapsibleNavItem
|
||||||
|
item={item}
|
||||||
|
pathname={pathname}
|
||||||
|
hasPermission={hasPermission}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleNavItem({
|
||||||
|
item,
|
||||||
|
pathname,
|
||||||
|
hasPermission,
|
||||||
|
}: {
|
||||||
|
item: NavItem;
|
||||||
|
pathname: string;
|
||||||
|
hasPermission?: (perm: string) => boolean;
|
||||||
|
}): ReactNode {
|
||||||
|
const visibleChildren =
|
||||||
|
item.children?.filter(
|
||||||
|
(c) => !c.permission || hasPermission?.(c.permission) !== false,
|
||||||
|
) ?? [];
|
||||||
|
const hasActiveChild = visibleChildren.some((c) => c.href === pathname);
|
||||||
|
const [open, setOpen] = useState(hasActiveChild);
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-between"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</span>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<ul className="ml-4 mt-1 space-y-1 border-l pl-2">
|
||||||
|
{visibleChildren.map((child) => {
|
||||||
|
const isActive = child.href === pathname;
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
return (
|
||||||
|
<li key={child.title}>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Link href={child.href ?? "#"}>
|
||||||
|
{ChildIcon && <ChildIcon className="size-4" />}
|
||||||
|
<span>{child.title}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SidebarProvider - 侧边栏状态容器(对齐 CICD sidebar-provider.tsx)
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 管理桌面端折叠状态(expanded: w-64 ↔ w-16)
|
||||||
|
* - 管理移动端 Sheet 开合(openMobile)
|
||||||
|
* - 自动检测 mobile(window.innerWidth < 768),resize 防抖 200ms
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* <SidebarProvider>
|
||||||
|
* <AppSidebar />
|
||||||
|
* <main className="flex-1">...</main>
|
||||||
|
* </SidebarProvider>
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.3 布局组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export interface SidebarContextValue {
|
||||||
|
/** 桌面端是否展开 */
|
||||||
|
expanded: boolean;
|
||||||
|
/** 移动端 Sheet 是否打开 */
|
||||||
|
openMobile: boolean;
|
||||||
|
/** 是否移动端 */
|
||||||
|
isMobile: boolean;
|
||||||
|
/** 切换桌面端展开/折叠 */
|
||||||
|
toggleExpanded: () => void;
|
||||||
|
/** 设置桌面端展开状态 */
|
||||||
|
setExpanded: (v: boolean) => void;
|
||||||
|
/** 切换移动端 Sheet */
|
||||||
|
toggleMobile: () => void;
|
||||||
|
/** 设置移动端 Sheet */
|
||||||
|
setOpenMobile: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||||
|
|
||||||
|
export function SidebarProvider({
|
||||||
|
children,
|
||||||
|
defaultExpanded = true,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
const [openMobile, setOpenMobile] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
check();
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const debounced = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(check, 200);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", debounced);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", debounced);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
|
||||||
|
const toggleMobile = useCallback(() => setOpenMobile((v) => !v), []);
|
||||||
|
|
||||||
|
const value = useMemo<SidebarContextValue>(
|
||||||
|
() => ({
|
||||||
|
expanded,
|
||||||
|
openMobile,
|
||||||
|
isMobile,
|
||||||
|
toggleExpanded,
|
||||||
|
setExpanded,
|
||||||
|
toggleMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
}),
|
||||||
|
[expanded, openMobile, isMobile, toggleExpanded, toggleMobile],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={value}>
|
||||||
|
<div className={cn("flex min-h-screen w-full", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar(): SidebarContextValue {
|
||||||
|
const ctx = useContext(SidebarContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useSidebar 必须在 <SidebarProvider> 内部使用");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧边栏宽度类名(展开 256px / 折叠 64px) */
|
||||||
|
export const SIDEBAR_WIDTH_EXPANDED = "w-64";
|
||||||
|
export const SIDEBAR_WIDTH_COLLAPSED = "w-16";
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronRight, Menu } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { useSidebar } from "./sidebar-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SiteHeader - 顶部头部组件(对齐 CICD site-header.tsx)
|
||||||
|
*
|
||||||
|
* 结构:Mobile Toggle + Separator + Breadcrumb + 右侧 actions(搜索/通知/头像)
|
||||||
|
* - sticky top-0 z-50 h-16 bg-background/95 backdrop-blur-sm
|
||||||
|
* - 面包屑从 pathname 自动生成
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.3 布局组件
|
||||||
|
*/
|
||||||
|
export interface SiteHeaderProps {
|
||||||
|
/** 面包屑映射表(path → title),未命中时 fallback 到首字母大写 */
|
||||||
|
breadcrumbMap?: Record<string, string>;
|
||||||
|
/** 右侧操作区(搜索/通知/头像等) */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteHeader({
|
||||||
|
breadcrumbMap = {},
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: SiteHeaderProps): ReactNode {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { toggleMobile, isMobile } = useSidebar();
|
||||||
|
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-50 flex h-16 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleMobile}
|
||||||
|
className="md:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
<span className="sr-only">打开菜单</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<nav aria-label="面包屑" className="flex items-center gap-1 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
首页
|
||||||
|
</Link>
|
||||||
|
{segments.map((seg, idx) => {
|
||||||
|
const href = "/" + segments.slice(0, idx + 1).join("/");
|
||||||
|
const isLast = idx === segments.length - 1;
|
||||||
|
const title =
|
||||||
|
breadcrumbMap[href] ?? seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||||
|
return (
|
||||||
|
<span key={href} className="flex items-center gap-1">
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground" />
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-foreground">{title}</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">{actions}</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
apps/portal-shell/src/shared/components/plugin-boundary.tsx
Normal file
216
apps/portal-shell/src/shared/components/plugin-boundary.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, type ReactNode } from "react";
|
||||||
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { useErrorReport } from "@edu/hooks";
|
||||||
|
import { ErrorBoundary } from "@edu/ui-components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginBoundary - 插件级错误边界 + 流式 Suspense(替代 PluginLoader)
|
||||||
|
*
|
||||||
|
* 三件套组合:ErrorBoundary + Suspense + Skeleton
|
||||||
|
* 职责:
|
||||||
|
* 1. 隔离单个插件渲染错误,不影响其他插件和 Shell
|
||||||
|
* 2. 插件 dynamic import 期间显示骨架屏(流式渲染)
|
||||||
|
* 3. 错误自动上报到 /api/log(通过 onError 回调,避免 fallback render phase 副作用)
|
||||||
|
*
|
||||||
|
* 5 种骨架变体(对齐 CICD DashboardSectionSkeleton):
|
||||||
|
* - card:通用卡片骨架(标题 + 内容块)
|
||||||
|
* - list:列表骨架(多行)
|
||||||
|
* - chart:图表骨架(坐标轴 + 柱状)
|
||||||
|
* - stats:统计数据骨架(大数字 + 标签)
|
||||||
|
* - table:表格骨架(表头 + 多行)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L3 插件级)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PluginSkeletonVariant =
|
||||||
|
"card" | "list" | "chart" | "stats" | "table";
|
||||||
|
|
||||||
|
export interface PluginBoundaryProps {
|
||||||
|
/** 插件实例 ID(用于错误标识和上报) */
|
||||||
|
pluginId: string;
|
||||||
|
/** 子节点(插件组件) */
|
||||||
|
children: ReactNode;
|
||||||
|
/** 骨架变体(默认 card) */
|
||||||
|
skeletonVariant?: PluginSkeletonVariant;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5 种骨架变体实现(对齐 CICD DashboardSectionSkeleton)
|
||||||
|
*/
|
||||||
|
export function PluginSkeleton({
|
||||||
|
variant = "card",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
variant?: PluginSkeletonVariant;
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
if (variant === "table") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn("space-y-3 rounded-xl border bg-card p-6", className)}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-6 w-1/4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "list") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn("space-y-2", className)}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "chart") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn("rounded-xl border bg-card p-6", className)}
|
||||||
|
>
|
||||||
|
<Skeleton className="mb-4 h-6 w-1/3" />
|
||||||
|
<div className="flex h-40 items-end gap-2">
|
||||||
|
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "stats") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn("rounded-xl border bg-card p-6", className)}
|
||||||
|
>
|
||||||
|
<Skeleton className="mb-4 h-6 w-1/3" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-8 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// card(默认)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn("rounded-xl border bg-card p-6", className)}
|
||||||
|
>
|
||||||
|
<Skeleton className="mb-4 h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-8 w-1/2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件错误兜底 UI(纯展示组件,不上报——上报由外层 onError 负责)
|
||||||
|
*/
|
||||||
|
function PluginErrorFallback({
|
||||||
|
pluginId,
|
||||||
|
error,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
pluginId: string;
|
||||||
|
error: Error;
|
||||||
|
onReset: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6"
|
||||||
|
>
|
||||||
|
<AlertCircle className="size-8 text-destructive" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium">插件加载失败</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{pluginId}: {error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onReset} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginBoundary - 插件错误边界 + 流式 Suspense
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PluginBoundary pluginId="grades-widget" skeletonVariant="table">
|
||||||
|
* <GradesWidget {...pluginProps} />
|
||||||
|
* </PluginBoundary>
|
||||||
|
*/
|
||||||
|
export function PluginBoundary({
|
||||||
|
pluginId,
|
||||||
|
children,
|
||||||
|
skeletonVariant = "card",
|
||||||
|
className,
|
||||||
|
}: PluginBoundaryProps): ReactNode {
|
||||||
|
const reportError = useErrorReport();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(error, reset) => (
|
||||||
|
<PluginErrorFallback
|
||||||
|
pluginId={pluginId}
|
||||||
|
error={error}
|
||||||
|
onReset={reset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onError={(error) => {
|
||||||
|
void reportError(error, { pluginId, level: "error" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<PluginSkeleton variant={skeletonVariant} className={className} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { useErrorReport } from "@edu/hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RouteErrorBoundary - 路由级错误兜底(用于 app/shell/error.tsx)
|
||||||
|
*
|
||||||
|
* Next.js App Router 的 error.tsx 接收 { error, reset } props:
|
||||||
|
* - error: 触发的错误实例(含 digest)
|
||||||
|
* - reset: 重置错误边界,重新渲染 Route Segment
|
||||||
|
*
|
||||||
|
* 本组件职责:
|
||||||
|
* 1. 上报错误到 /api/log(通过 useErrorReport)
|
||||||
|
* 2. 渲染统一错误 UI(图标 + 标题 + 描述 + 重试按钮)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L1 路由级)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // app/shell/error.tsx
|
||||||
|
* "use client";
|
||||||
|
* import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
|
||||||
|
* export default function ShellError({ error, reset }) {
|
||||||
|
* return <RouteErrorBoundary error={error} reset={reset} namespace="shell" />;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export interface RouteErrorBoundaryProps {
|
||||||
|
/** Next.js error.tsx 注入的错误实例 */
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
/** Next.js error.tsx 注入的重置函数 */
|
||||||
|
reset: () => void;
|
||||||
|
/** 命名空间(用于错误标题,如 "shell" / "admin" / "teacher") */
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteErrorBoundary({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
namespace = "page",
|
||||||
|
}: RouteErrorBoundaryProps): React.ReactNode {
|
||||||
|
const reportError = useErrorReport();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reportError(error, { level: "error" });
|
||||||
|
}, [error, reportError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8"
|
||||||
|
>
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<AlertTriangle className="size-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-semibold">{namespace}页面出错了</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{error.message || "发生未知错误,请稍后重试"}
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground/70">
|
||||||
|
错误编号:{error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={reset} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SectionErrorBoundary - 区块级错误边界(用于 DashboardSection 内)
|
||||||
|
*
|
||||||
|
* 职责:隔离单个区块(如统计卡片组、图表区、列表区)的渲染错误,
|
||||||
|
* 不影响其他区块和整个页面。
|
||||||
|
*
|
||||||
|
* 与 RouteErrorBoundary 的区别:
|
||||||
|
* - RouteErrorBoundary:整页崩溃兜底,由 Next.js error.tsx 触发
|
||||||
|
* - SectionErrorBoundary:区块崩溃隔离,由 DashboardSection 内部挂载
|
||||||
|
*
|
||||||
|
* 与 PluginBoundary 的区别:
|
||||||
|
* - PluginBoundary:单个插件崩溃隔离,含 Suspense + Skeleton
|
||||||
|
* - SectionErrorBoundary:区块级(可能含多个插件),无 Suspense
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L2 区块级)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SectionErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** 区块标题(用于错误 UI 显示,如 "统计概览") */
|
||||||
|
title?: string;
|
||||||
|
/** 自定义错误降级 UI */
|
||||||
|
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||||
|
/** 错误回调(上报) */
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SectionErrorBoundary extends Component<
|
||||||
|
SectionErrorBoundaryProps,
|
||||||
|
SectionErrorBoundaryState
|
||||||
|
> {
|
||||||
|
override state: SectionErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): SectionErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
this.props.onError?.(error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = (): void => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
override render(): ReactNode {
|
||||||
|
const { error } = this.state;
|
||||||
|
const { children, fallback, title, className } = this.props;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (fallback) {
|
||||||
|
return fallback(error, this.reset);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="size-8 text-destructive" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium">{title ?? "区块加载失败"}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={this.reset} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/portal-shell/src/shared/components/ui/badge.tsx
Normal file
37
apps/portal-shell/src/shared/components/ui/badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
60
apps/portal-shell/src/shared/components/ui/button.tsx
Normal file
60
apps/portal-shell/src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: ButtonProps): React.ReactNode {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants, type ButtonProps };
|
||||||
75
apps/portal-shell/src/shared/components/ui/card.tsx
Normal file
75
apps/portal-shell/src/shared/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground rounded-xl border shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
90
apps/portal-shell/src/shared/components/ui/empty-state.tsx
Normal file
90
apps/portal-shell/src/shared/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { type ReactNode, memo } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Button, type ButtonProps } from "@/shared/components/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState - 空态/错误降级展示(对齐 CICD empty-state.tsx)
|
||||||
|
*
|
||||||
|
* 用途:
|
||||||
|
* - 空列表(如"暂无成绩记录")
|
||||||
|
* - 空搜索结果(如"未找到匹配项")
|
||||||
|
* - 错误降级(配合 ErrorBoundary)
|
||||||
|
*
|
||||||
|
* React.memo 优化:高频渲染场景避免无谓重渲染
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <EmptyState
|
||||||
|
* icon={InboxIcon}
|
||||||
|
* title="暂无数据"
|
||||||
|
* description="点击下方按钮添加第一条记录"
|
||||||
|
* action={{ label: "添加", href: "/new", onClick: handleAdd }}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export interface EmptyStateAction {
|
||||||
|
/** 按钮文字 */
|
||||||
|
label: string;
|
||||||
|
/** 跳转链接(与 onClick 二选一) */
|
||||||
|
href?: string;
|
||||||
|
/** 点击回调(与 href 二选一) */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** 按钮变体(默认 outline) */
|
||||||
|
variant?: ButtonProps["variant"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmptyStateProps {
|
||||||
|
/** 图标(lucide-react 图标组件) */
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/** 标题 */
|
||||||
|
title: string;
|
||||||
|
/** 描述文字 */
|
||||||
|
description?: string;
|
||||||
|
/** 操作按钮 */
|
||||||
|
action?: EmptyStateAction;
|
||||||
|
/** 自定义类名(默认最小高度 450px) */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState = memo(function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[400px] flex-col items-center justify-center gap-4 p-8 text-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Icon className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-lg font-semibold">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action &&
|
||||||
|
(action.href ? (
|
||||||
|
<Button asChild variant={action.variant ?? "outline"}>
|
||||||
|
<Link href={action.href}>{action.label}</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={action.onClick}
|
||||||
|
variant={action.variant ?? "outline"}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
118
apps/portal-shell/src/shared/components/ui/filter-bar.tsx
Normal file
118
apps/portal-shell/src/shared/components/ui/filter-bar.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterBar - 筛选栏布局容器(对齐 CICD filter-bar.tsx)
|
||||||
|
*
|
||||||
|
* 三种布局变体:
|
||||||
|
* - default:左对齐(默认)
|
||||||
|
* - wrap:自动换行(筛选条件多时)
|
||||||
|
* - between:两端对齐(左筛选 + 右操作)
|
||||||
|
*
|
||||||
|
* 移动端纵向 flex-col,桌面端 md:flex-row md:items-center
|
||||||
|
* URL 状态管理方式由各模块自行处理,FilterBar 只负责布局
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <FilterBar variant="between">
|
||||||
|
* <FilterSearchInput placeholder="搜索..." value={q} onChange={setQ} />
|
||||||
|
* <FilterResetButton onClick={reset} />
|
||||||
|
* <Button>新建</Button>
|
||||||
|
* </FilterBar>
|
||||||
|
*/
|
||||||
|
export interface FilterBarProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** 布局变体 */
|
||||||
|
variant?: "default" | "wrap" | "between";
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASS: Record<NonNullable<FilterBarProps["variant"]>, string> = {
|
||||||
|
default: "md:flex-row md:items-center",
|
||||||
|
wrap: "md:flex-row md:items-center md:flex-wrap",
|
||||||
|
between: "md:flex-row md:items-center md:justify-between",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
}: FilterBarProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col gap-2", VARIANT_CLASS[variant], className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterSearchInput - 带搜索图标的输入框
|
||||||
|
*
|
||||||
|
* 固定宽度 md:w-80,移动端 100%
|
||||||
|
*/
|
||||||
|
export function FilterSearchInput({
|
||||||
|
placeholder = "搜索...",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
placeholder?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative w-full md:w-80", className)}>
|
||||||
|
<svg
|
||||||
|
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FilterResetButton - 重置筛选按钮
|
||||||
|
*/
|
||||||
|
export function FilterResetButton({
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn("h-9", className)}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/portal-shell/src/shared/components/ui/input.tsx
Normal file
31
apps/portal-shell/src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input - shadcn 输入框(基础组件)
|
||||||
|
*
|
||||||
|
* 对齐 shadcn/ui 标准 Input 实现。
|
||||||
|
* 关联:components.json aliases.ui
|
||||||
|
*/
|
||||||
|
function Input({
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
64
apps/portal-shell/src/shared/components/ui/page-header.tsx
Normal file
64
apps/portal-shell/src/shared/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageHeader - 页面标题区(对齐 CICD page-header.tsx)
|
||||||
|
*
|
||||||
|
* 结构:左侧(图标 + 标题 + 描述)+ 右侧 actions
|
||||||
|
* 响应式:移动端纵向 flex-col,桌面端 md:flex-row md:items-center
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PageHeader
|
||||||
|
* title="成绩管理"
|
||||||
|
* description="查看和管理学生成绩"
|
||||||
|
* icon={<GraduationCap />}
|
||||||
|
* actions={<Button>导出</Button>}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export interface PageHeaderProps {
|
||||||
|
/** 页面标题 */
|
||||||
|
title: string;
|
||||||
|
/** 描述文字(可选) */
|
||||||
|
description?: string;
|
||||||
|
/** 标题前图标(可选) */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** 右侧操作区(按钮、筛选器等) */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: PageHeaderProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 md:flex-row md:items-center md:justify-between",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{icon && (
|
||||||
|
<div className="mt-1 text-muted-foreground [&_svg]:size-7">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/portal-shell/src/shared/components/ui/separator.tsx
Normal file
26
apps/portal-shell/src/shared/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-[1px] data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
18
apps/portal-shell/src/shared/components/ui/skeleton.tsx
Normal file
18
apps/portal-shell/src/shared/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
38
apps/portal-shell/src/shared/components/ui/sonner.tsx
Normal file
38
apps/portal-shell/src/shared/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
|
import { usePluginStore } from "@/shell/PluginStore";
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 容器(基于 sonner)
|
||||||
|
*
|
||||||
|
* 主题跟随 portal-shell PluginStore.theme(light/dark),不依赖 next-themes。
|
||||||
|
* 业务代码通过 `import { toast } from "sonner"` 直接调用。
|
||||||
|
*/
|
||||||
|
function Toaster({ ...props }: ToasterProps): React.ReactNode {
|
||||||
|
const theme = usePluginStore((s) => s.theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
127
apps/portal-shell/src/shared/components/ui/stat-card.tsx
Normal file
127
apps/portal-shell/src/shared/components/ui/stat-card.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatCard - 统计卡片(对齐 CICD stat-card.tsx)
|
||||||
|
*
|
||||||
|
* 结构:CardHeader(标题 + 图标)+ CardContent(数值 + 描述)
|
||||||
|
* - 加载态:StatCardSkeleton
|
||||||
|
* - 高亮态:border-amber-200 bg-amber-50/50(用于关键指标)
|
||||||
|
* - 可点击:href 传入则包裹 Link,hover 微交互
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <StatCard
|
||||||
|
* title="学生总数"
|
||||||
|
* value={1234}
|
||||||
|
* icon={UsersIcon}
|
||||||
|
* description="较上月 +12"
|
||||||
|
* href="/admin/users"
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export interface StatCardProps {
|
||||||
|
/** 卡片标题 */
|
||||||
|
title: string;
|
||||||
|
/** 数值(数字或字符串) */
|
||||||
|
value: number | string;
|
||||||
|
/** 图标(lucide-react 图标组件) */
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/** 描述文字(如"较上月 +12") */
|
||||||
|
description?: string;
|
||||||
|
/** 是否高亮(关键指标,默认 false) */
|
||||||
|
highlight?: boolean;
|
||||||
|
/** 点击跳转链接 */
|
||||||
|
href?: string;
|
||||||
|
/** 是否加载中 */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** 数值类名(如 tabular-nums) */
|
||||||
|
valueClassName?: string;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
description,
|
||||||
|
highlight = false,
|
||||||
|
href,
|
||||||
|
isLoading = false,
|
||||||
|
valueClassName,
|
||||||
|
className,
|
||||||
|
}: StatCardProps): ReactNode {
|
||||||
|
if (isLoading) {
|
||||||
|
return <StatCardSkeleton className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"transition-all",
|
||||||
|
href && "hover:-translate-y-1 hover:shadow-md",
|
||||||
|
highlight && "border-amber-200 bg-amber-50/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className={cn("text-2xl font-bold tracking-tight", valueClassName)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<CardDescription className="mt-1 text-xs">
|
||||||
|
{description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="block">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** StatCard 骨架屏 */
|
||||||
|
export function StatCardSkeleton({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/portal-shell/src/shared/components/ui/stats-grid.tsx
Normal file
50
apps/portal-shell/src/shared/components/ui/stats-grid.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatsGrid - 统计卡片网格(对齐 CICD stats-grid.tsx)
|
||||||
|
*
|
||||||
|
* 响应式列数:mobile=1, md=2, lg=N(由 columns prop 控制)
|
||||||
|
* 统一 isLoading 透传到所有子 StatCard
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <StatsGrid columns={4} isLoading={loading}>
|
||||||
|
* <StatCard title="学生" value={100} />
|
||||||
|
* <StatCard title="教师" value={20} />
|
||||||
|
* </StatsGrid>
|
||||||
|
*/
|
||||||
|
export interface StatsGridProps {
|
||||||
|
/** 子节点(通常是多个 StatCard) */
|
||||||
|
children: ReactNode;
|
||||||
|
/** 桌面端列数(1-5,默认 4) */
|
||||||
|
columns?: 1 | 2 | 3 | 4 | 5;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS_CLASS: Record<number, string> = {
|
||||||
|
1: "md:grid-cols-1",
|
||||||
|
2: "md:grid-cols-2",
|
||||||
|
3: "md:grid-cols-3",
|
||||||
|
4: "md:grid-cols-4",
|
||||||
|
5: "md:grid-cols-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatsGrid({
|
||||||
|
children,
|
||||||
|
columns = 4,
|
||||||
|
className,
|
||||||
|
}: StatsGridProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 gap-4",
|
||||||
|
COLUMNS_CLASS[columns],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/shared/components/ui/tooltip.tsx
Normal file
38
apps/portal-shell/src/shared/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tooltip - shadcn 提示组件
|
||||||
|
*
|
||||||
|
* 对齐 shadcn/ui 标准 Tooltip 实现。
|
||||||
|
* 关联:components.json aliases.ui
|
||||||
|
*/
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
73
apps/portal-shell/src/shared/lib/notify.ts
Normal file
73
apps/portal-shell/src/shared/lib/notify.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* notify - 统一 Toast 通知封装(对齐 CICD notify.ts)
|
||||||
|
*
|
||||||
|
* 业务代码统一通过 notify 调用,禁止直接 `import { toast } from "sonner"`。
|
||||||
|
* 优势:便于测试 mock、未来替换底层库、统一 i18n 入口。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* import { notify } from "@/shared/lib/notify";
|
||||||
|
* notify.success("保存成功");
|
||||||
|
* notify.error("网络错误");
|
||||||
|
* notify.promise(asyncFn, { loading: "保存中...", success: "成功", error: "失败" });
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4
|
||||||
|
*/
|
||||||
|
import { toast, type ExternalToast } from "sonner";
|
||||||
|
|
||||||
|
type Message = string;
|
||||||
|
|
||||||
|
interface NotifyPromiseOptions<T> {
|
||||||
|
loading: Message;
|
||||||
|
success: Message | ((data: T) => Message);
|
||||||
|
error: Message | ((error: unknown) => Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notify = {
|
||||||
|
/** 成功提示(默认 4 秒) */
|
||||||
|
success(message: Message, options?: ExternalToast): void {
|
||||||
|
toast.success(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 错误提示(默认 6 秒,更长便于阅读) */
|
||||||
|
error(message: Message, options?: ExternalToast): void {
|
||||||
|
toast.error(message, { duration: 6000, ...options });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 警告提示 */
|
||||||
|
warning(message: Message, options?: ExternalToast): void {
|
||||||
|
toast.warning(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 信息提示 */
|
||||||
|
info(message: Message, options?: ExternalToast): void {
|
||||||
|
toast.info(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 带加载状态的 Promise 提示(透传原 Promise,便于链式调用) */
|
||||||
|
promise<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
options: NotifyPromiseOptions<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
toast.promise(promise, options);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 加载中提示(返回 toast id,可用 toast.dismiss(id) 关闭) */
|
||||||
|
loading(message: Message, options?: ExternalToast): string | number {
|
||||||
|
return toast.loading(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 自定义提示(escape hatch,业务慎用) */
|
||||||
|
message(message: Message, options?: ExternalToast): void {
|
||||||
|
toast(message, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 关闭所有提示 */
|
||||||
|
dismiss(): void {
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { toast as rawToast } from "sonner";
|
||||||
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal 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_PERMISSIONS:Next.js API Route(/api/*)
|
||||||
|
*
|
||||||
|
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||||
|
* - L1 角色门禁:requiredRoles(4 角色之一)
|
||||||
|
* - L2 权限点门禁:requiredPermissions(AND 语义,必须全部满足)
|
||||||
|
* - 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;
|
||||||
|
}
|
||||||
54
apps/portal-shell/src/shared/lib/utils.ts
Normal file
54
apps/portal-shell/src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 类名合并 + 通用工具函数(对齐 CICD 项目 src/shared/lib/utils.ts)
|
||||||
|
*
|
||||||
|
* shadcn/ui 组件统一通过 `@/shared/lib/utils` 引用 cn()。
|
||||||
|
* 关联:project_rules §3.9、components.json aliases.utils
|
||||||
|
*/
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next.js App Router 搜索参数类型 */
|
||||||
|
export type SearchParams = { [key: string]: string | string[] | undefined };
|
||||||
|
|
||||||
|
/** 从 SearchParams 中安全提取单个字符串值 */
|
||||||
|
export function getSearchParam(
|
||||||
|
params: SearchParams,
|
||||||
|
key: string,
|
||||||
|
): string | undefined {
|
||||||
|
const v = params[key];
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
if (Array.isArray(v)) return v[0];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化数字,null/undefined/非有限数返回 "-" */
|
||||||
|
export function formatNumber(v: number | null | undefined, digits = 1): string {
|
||||||
|
if (typeof v !== "number" || !Number.isFinite(v)) return "-";
|
||||||
|
return v.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从姓名生成头像占位用的首字母(最多 2 个字符)。
|
||||||
|
* 用于 AvatarFallback 组件。
|
||||||
|
* - 含空格的姓名:取各单词首字母拼接(如 "John Doe" -> "JD")
|
||||||
|
* - 无空格的姓名:取前 2 个字符(如 "张三" -> "张三")
|
||||||
|
* - 空值:返回 "U"(User 通用占位)
|
||||||
|
*/
|
||||||
|
export function getInitials(name: string | null | undefined): string {
|
||||||
|
if (!name) return "U";
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return "U";
|
||||||
|
if (trimmed.includes(" ")) {
|
||||||
|
return trimmed
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
return trimmed.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
@@ -1,26 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClientShell - 客户端入口(v2.1 M8)
|
* ClientShell - 客户端入口(v2.1 M8 + React 19 流式渲染)
|
||||||
*
|
*
|
||||||
* 接收 RSC props(Config + userId + role),挂载 Providers(Apollo/Auth/ThemeI18n),
|
* 接收 RSC props(configPromise + userId + role),挂载 Providers(Apollo/Auth/ThemeI18n),
|
||||||
* 启用 SWR 静默刷新配置(usePluginConfig),渲染 Shell。
|
* 通过 React 19 use() hook 消费 Promise 启用流式渲染:
|
||||||
|
* - RSC 直出 HTML:Promise resolve 前 Suspense 显示 fallback
|
||||||
|
* - Promise resolve 后自动重渲染,无需 useEffect 二次请求
|
||||||
*
|
*
|
||||||
* 数据流(portal-shell spec §5.5):
|
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||||
* RSC 预取 Config → ClientShell(fallbackData)→ SWR 静默刷新 → Shell 重渲染
|
* RSC 预取 configPromise → ClientShell use() 消费 → Shell 渲染
|
||||||
|
* (SWR 静默刷新保留,作为客户端实时性补充)
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §5.5、§6.4、M8 验收标准
|
* 关联:portal-shell spec §5.5、§6.4、M8 验收标准、README v2.0 §5.3
|
||||||
*/
|
*/
|
||||||
import { useState, type ReactNode } from "react";
|
import { use, useState, type ReactNode } from "react";
|
||||||
import { ApolloProvider } from "@/providers/ApolloProvider";
|
import { ApolloProvider } from "@/providers/ApolloProvider";
|
||||||
import { AuthProvider, type AuthUser } from "@/providers/AuthProvider";
|
import { AuthProvider, type AuthUser } from "@/providers/AuthProvider";
|
||||||
import { ThemeI18nProvider } from "@/providers/ThemeI18nProvider";
|
import { ThemeI18nProvider } from "@/providers/ThemeI18nProvider";
|
||||||
import { Shell } from "./Shell";
|
import { Shell } from "./Shell";
|
||||||
import { usePluginConfig } from "@/lib/usePluginConfig";
|
import { usePluginConfig } from "@/lib/usePluginConfig";
|
||||||
import type { PluginConfigResponse, Role } from "@/lib/types";
|
import type { PluginConfigResponse, Role } from "@/lib/types";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
export interface ClientShellProps {
|
export interface ClientShellProps {
|
||||||
config: PluginConfigResponse;
|
/**
|
||||||
|
* 服务端预取的 pluginConfig Promise(流式渲染)
|
||||||
|
*
|
||||||
|
* 通过 React 19 use() hook 消费,启用 HTML 流式输出:
|
||||||
|
* - Promise pending:Suspense fallback(loading.tsx 整页骨架)
|
||||||
|
* - Promise resolved:自动重渲染为真实 UI
|
||||||
|
*
|
||||||
|
* 与原 config prop 互斥(二选一,configPromise 优先)
|
||||||
|
*/
|
||||||
|
configPromise?: Promise<PluginConfigResponse>;
|
||||||
|
/**
|
||||||
|
* 同步 config(向后兼容,无流式渲染)
|
||||||
|
* 当不启用流式时使用,与 configPromise 互斥
|
||||||
|
*/
|
||||||
|
config?: PluginConfigResponse;
|
||||||
role: Role;
|
role: Role;
|
||||||
userId: string;
|
userId: string;
|
||||||
/** 可选:服务端解析的用户名/邮箱(user-menu 插件会自行查询 me) */
|
/** 可选:服务端解析的用户名/邮箱(user-menu 插件会自行查询 me) */
|
||||||
@@ -28,8 +46,44 @@ export interface ClientShellProps {
|
|||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部组件:通过 use() 消费 configPromise
|
||||||
|
*
|
||||||
|
* 必须拆分为子组件:use() 必须在 Suspense 边界内的组件中调用,
|
||||||
|
* 而不能在挂载 Provider 的根组件中调用(否则 Provider 也会被 Suspense 暂停)
|
||||||
|
*/
|
||||||
|
function ShellContent({
|
||||||
|
configPromise,
|
||||||
|
user,
|
||||||
|
role,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
configPromise: Promise<PluginConfigResponse>;
|
||||||
|
user: AuthUser;
|
||||||
|
role: Role;
|
||||||
|
userId: string;
|
||||||
|
}): ReactNode {
|
||||||
|
// React 19 use():消费 Promise,启用流式渲染
|
||||||
|
// 当 Promise pending 时,自动 throw 给最近的 Suspense 边界
|
||||||
|
const resolvedConfig = use(configPromise);
|
||||||
|
|
||||||
|
// SWR 静默刷新:作为客户端实时性补充
|
||||||
|
// initialConfig 使用已 resolved 的 config,避免重复请求
|
||||||
|
const { config: liveConfig } = usePluginConfig({
|
||||||
|
initialConfig: resolvedConfig,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
onChanged: () => {
|
||||||
|
notify.info("发现新布局配置,刷新后生效");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Shell config={liveConfig} user={user} role={role} userId={userId} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientShell({
|
export function ClientShell({
|
||||||
config,
|
configPromise,
|
||||||
|
config: fallbackConfig,
|
||||||
role,
|
role,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
@@ -45,41 +99,99 @@ export function ClientShell({
|
|||||||
dataScope: "",
|
dataScope: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { config: liveConfig } = usePluginConfig({
|
// 兼容模式:未传 configPromise 时走同步 config
|
||||||
initialConfig: config,
|
const useStreaming = Boolean(configPromise);
|
||||||
userId,
|
|
||||||
role,
|
|
||||||
onChanged: () => setConfigChanged(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ApolloProvider>
|
<ApolloProvider>
|
||||||
<AuthProvider user={user}>
|
<AuthProvider user={user}>
|
||||||
<ThemeI18nProvider>
|
<ThemeI18nProvider>
|
||||||
<Shell config={liveConfig} user={user} role={role} userId={userId} />
|
{useStreaming && configPromise ? (
|
||||||
{configChanged ? (
|
<ShellContent
|
||||||
<div className="fixed bottom-xl right-xl z-50 rounded-card border border-rule bg-surface p-md shadow-md">
|
configPromise={configPromise}
|
||||||
<p className="text-small text-ink">
|
user={user}
|
||||||
发现新布局配置,刷新后生效。
|
role={role}
|
||||||
</p>
|
userId={userId}
|
||||||
<button
|
/>
|
||||||
type="button"
|
) : (
|
||||||
onClick={() => window.location.reload()}
|
<LegacyShell
|
||||||
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
config={fallbackConfig}
|
||||||
>
|
user={user}
|
||||||
刷新
|
role={role}
|
||||||
</button>
|
userId={userId}
|
||||||
<button
|
onConfigChange={() => setConfigChanged(true)}
|
||||||
type="button"
|
configChanged={configChanged}
|
||||||
onClick={() => setConfigChanged(false)}
|
onDismiss={() => setConfigChanged(false)}
|
||||||
className="mt-sm ml-sm rounded-button border border-rule px-md py-xs text-small text-ink"
|
/>
|
||||||
>
|
)}
|
||||||
稍后
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</ThemeI18nProvider>
|
</ThemeI18nProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧版同步渲染路径(向后兼容)
|
||||||
|
*
|
||||||
|
* 当 RSC 未传 configPromise 时使用,行为与 v1.1 一致
|
||||||
|
*/
|
||||||
|
function LegacyShell({
|
||||||
|
config,
|
||||||
|
user,
|
||||||
|
role,
|
||||||
|
userId,
|
||||||
|
onConfigChange,
|
||||||
|
configChanged,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
config?: PluginConfigResponse;
|
||||||
|
user: AuthUser;
|
||||||
|
role: Role;
|
||||||
|
userId: string;
|
||||||
|
onConfigChange: () => void;
|
||||||
|
configChanged: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const initialConfig: PluginConfigResponse = config ?? {
|
||||||
|
activeLayout: null,
|
||||||
|
slots: [],
|
||||||
|
plugins: [],
|
||||||
|
registry: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { config: liveConfig } = usePluginConfig({
|
||||||
|
initialConfig: initialConfig,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
onChanged: onConfigChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Shell config={liveConfig} user={user} role={role} userId={userId} />
|
||||||
|
{configChanged ? (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 rounded-xl border bg-card p-4 shadow-md">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
发现新布局配置,刷新后生效。
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="rounded-md border px-3 py-1.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
稍后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LayoutManager - 5 种 Layout 模板渲染器(v2.1 M8)
|
* LayoutManager - 5 种 Layout 模板渲染器(v2.1 M8 + shadcn 令牌)
|
||||||
*
|
*
|
||||||
* | layoutId | 布局 | slots |
|
* | layoutId | 布局 | slots |
|
||||||
* | -------- | ------------------------- | ------------------------ |
|
* | -------- | --------------------------------- | ---------------------------------- |
|
||||||
* | classic | TopBar + SideNav + Main | top / side / main |
|
* | classic | TopBar + SideNav + Main | top / side / main |
|
||||||
* | focus | TopBar + 全宽 Main | top / main |
|
* | focus | TopBar + 全宽 Main | top / main |
|
||||||
* | split | TopBar + 左右等分 Main | top / main-left / main-right |
|
* | split | TopBar + 左右等分 Main | top / main-left / main-right |
|
||||||
* | triple | TopBar + SideNav + Main + RightRail | top / side / main / right |
|
* | triple | TopBar + SideNav + Main + RightRail | top / side / main / right |
|
||||||
* | canvas | TopBar + 自由摆放 | top / canvas-grid |
|
* | canvas | TopBar + 自由摆放 | top / canvas-grid |
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §4.1、§4.2
|
* 令牌迁移:v2.0 shadcn 标准令牌
|
||||||
|
* - bg-paper → bg-background(页面底色)
|
||||||
|
* - bg-surface → bg-card(容器表面)
|
||||||
|
* - border-rule → border(默认边框色)
|
||||||
|
* - p-md/p-lg → p-4/p-6(间距阶梯)
|
||||||
|
* - gap-md → gap-4
|
||||||
|
*
|
||||||
|
* 关联:portal-shell spec §4.1、§4.2、README v2.0 §3.5 shadcn 令牌
|
||||||
*/
|
*/
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { SlotRenderer, type SlotRendererProps } from "./SlotRenderer";
|
import { SlotRenderer, type SlotRendererProps } from "./SlotRenderer";
|
||||||
@@ -47,15 +54,15 @@ interface LayoutShellProps {
|
|||||||
/** classic:TopBar + SideNav + Main */
|
/** classic:TopBar + SideNav + Main */
|
||||||
function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-paper">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<header className="border-b border-rule bg-surface">
|
<header className="border-b bg-card">
|
||||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<aside className="w-64 border-r border-rule bg-surface p-md">
|
<aside className="w-64 border-r bg-card p-4">
|
||||||
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
|
||||||
</aside>
|
</aside>
|
||||||
<main className="flex-1 p-lg">
|
<main className="flex-1 p-6">
|
||||||
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,11 +73,11 @@ function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
|||||||
/** focus:TopBar + 全宽 Main */
|
/** focus:TopBar + 全宽 Main */
|
||||||
function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-paper">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<header className="border-b border-rule bg-surface">
|
<header className="border-b bg-card">
|
||||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 p-lg">
|
<main className="flex-1 p-6">
|
||||||
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,11 +87,11 @@ function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
|||||||
/** split:TopBar + 左右等分 Main */
|
/** split:TopBar + 左右等分 Main */
|
||||||
function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-paper">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<header className="border-b border-rule bg-surface">
|
<header className="border-b bg-card">
|
||||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1 gap-md p-lg">
|
<div className="flex flex-1 gap-4 p-6">
|
||||||
<section className="flex-1">
|
<section className="flex-1">
|
||||||
<SlotRenderer
|
<SlotRenderer
|
||||||
slotName="main-left"
|
slotName="main-left"
|
||||||
@@ -107,18 +114,18 @@ function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
|||||||
/** triple:TopBar + SideNav + Main + RightRail */
|
/** triple:TopBar + SideNav + Main + RightRail */
|
||||||
function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-paper">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<header className="border-b border-rule bg-surface">
|
<header className="border-b bg-card">
|
||||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
<aside className="w-64 border-r border-rule bg-surface p-md">
|
<aside className="w-64 border-r bg-card p-4">
|
||||||
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
|
||||||
</aside>
|
</aside>
|
||||||
<main className="flex-1 p-lg">
|
<main className="flex-1 p-6">
|
||||||
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||||
</main>
|
</main>
|
||||||
<aside className="w-72 border-l border-rule bg-surface p-md">
|
<aside className="w-72 border-l bg-card p-4">
|
||||||
<SlotRenderer slotName="right" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="right" layoutId={layoutId} {...slotInput} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,11 +136,11 @@ function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
|||||||
/** canvas:TopBar + 自由摆放 grid(MVP 按 grid 排列,不实现拖拽) */
|
/** canvas:TopBar + 自由摆放 grid(MVP 按 grid 排列,不实现拖拽) */
|
||||||
function CanvasLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
function CanvasLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-paper">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<header className="border-b border-rule bg-surface">
|
<header className="border-b bg-card">
|
||||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 p-lg">
|
<main className="flex-1 p-6">
|
||||||
<SlotRenderer
|
<SlotRenderer
|
||||||
slotName="canvas-grid"
|
slotName="canvas-grid"
|
||||||
layoutId={layoutId}
|
layoutId={layoutId}
|
||||||
|
|||||||
114
apps/portal-shell/src/shell/PluginLifecycle.ts
Normal file
114
apps/portal-shell/src/shell/PluginLifecycle.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* PluginLifecycle - 插件生命周期管理(portal-shell spec §5.4)
|
||||||
|
*
|
||||||
|
* 插件生命周期阶段:
|
||||||
|
* registered → enabled → loaded → active → disabled → uninstalled
|
||||||
|
*
|
||||||
|
* 内置插件:
|
||||||
|
* - registered:编译时登记到 Registry(src/shell/Registry.tsx)
|
||||||
|
* - enabled:admin 通过 config-service 启用(plugin_registry.is_active)
|
||||||
|
* - loaded:dynamic import 加载(PluginLoader)
|
||||||
|
* - active:渲染并挂载(SlotRenderer)
|
||||||
|
* - disabled:admin 禁用,不渲染
|
||||||
|
* - uninstalled:不可卸载(内置)
|
||||||
|
*
|
||||||
|
* 第三方插件(二期):
|
||||||
|
* - registered:安装时登记到 DB(plugin_packages 表)
|
||||||
|
* - loaded:script 注入加载(沙箱 iframe + postMessage)
|
||||||
|
* - uninstalled:admin 卸载,删除包
|
||||||
|
*
|
||||||
|
* 关联:portal-shell spec §5.4、§9.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 插件生命周期阶段 */
|
||||||
|
export type PluginLifecyclePhase =
|
||||||
|
"registered" | "enabled" | "loaded" | "active" | "disabled" | "uninstalled";
|
||||||
|
|
||||||
|
/** 插件状态转换结果 */
|
||||||
|
export interface LifecycleTransitionResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
currentPhase: PluginLifecyclePhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验插件版本兼容性
|
||||||
|
*
|
||||||
|
* 插件 manifest 携带 requiredShellVersion,Shell 启动时校验,
|
||||||
|
* 不兼容则拒绝加载并提示 admin 升级。
|
||||||
|
*
|
||||||
|
* @param requiredShellVersion 插件要求的 Shell 版本范围(semver range)
|
||||||
|
* @param currentShellVersion 当前 Shell 版本
|
||||||
|
*/
|
||||||
|
export function checkVersionCompatibility(
|
||||||
|
requiredShellVersion: string,
|
||||||
|
currentShellVersion: string,
|
||||||
|
): boolean {
|
||||||
|
// MVP 简化实现:只校验 major 版本
|
||||||
|
// 完整 semver range 校验待引入 semver 库
|
||||||
|
const requiredMajor = parseMajorVersion(requiredShellVersion);
|
||||||
|
const currentMajor = parseMajorVersion(currentShellVersion);
|
||||||
|
if (requiredMajor === null || currentMajor === null) {
|
||||||
|
return true; // 无法解析时放行
|
||||||
|
}
|
||||||
|
return requiredMajor === currentMajor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算插件从注册到激活的转换路径
|
||||||
|
*
|
||||||
|
* @param isBuiltin 是否内置插件
|
||||||
|
* @param isAdminEnabled admin 是否已启用
|
||||||
|
* @returns 转换路径描述
|
||||||
|
*/
|
||||||
|
export function getActivationPath(
|
||||||
|
isBuiltin: boolean,
|
||||||
|
isAdminEnabled: boolean,
|
||||||
|
): PluginLifecyclePhase[] {
|
||||||
|
if (!isAdminEnabled) {
|
||||||
|
return ["registered", "disabled"];
|
||||||
|
}
|
||||||
|
if (isBuiltin) {
|
||||||
|
return ["registered", "enabled", "loaded", "active"];
|
||||||
|
}
|
||||||
|
// 第三方插件(二期)
|
||||||
|
return ["registered", "enabled", "loaded", "active"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断插件是否可渲染
|
||||||
|
*
|
||||||
|
* 综合考虑:admin 启用状态 + 版本兼容性 + 角色权限
|
||||||
|
*/
|
||||||
|
export function isPluginRenderable(params: {
|
||||||
|
isActive: boolean;
|
||||||
|
requiredShellVersion: string;
|
||||||
|
currentShellVersion: string;
|
||||||
|
userRole: string;
|
||||||
|
requiredRoles: string[];
|
||||||
|
}): boolean {
|
||||||
|
const {
|
||||||
|
isActive,
|
||||||
|
requiredShellVersion,
|
||||||
|
currentShellVersion,
|
||||||
|
userRole,
|
||||||
|
requiredRoles,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
if (!isActive) return false;
|
||||||
|
if (!checkVersionCompatibility(requiredShellVersion, currentShellVersion)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requiredRoles.length > 0 && !requiredRoles.includes(userRole)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 semver 字符串的 major 版本号 */
|
||||||
|
function parseMajorVersion(version: string): number | null {
|
||||||
|
// 移除 ^ ~ >= 等 range 前缀
|
||||||
|
const cleaned = version.replace(/^[^0-9]*/, "");
|
||||||
|
const match = cleaned.match(/^(\d+)/);
|
||||||
|
return match?.[1] ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
@@ -1,151 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PluginLoader - 插件加载器(v2.1 M8)
|
* PluginLoader - 已弃用,改为 re-export(v2.1 M8 + 流式渲染)
|
||||||
*
|
*
|
||||||
* 职责:
|
* 本模块在 v1.x 中包含 PluginLoader / PluginSkeleton / PluginErrorFallback / PluginErrorBoundary,
|
||||||
* - PluginSkeleton:5 种 skeleton 变体(card/list/chart/stats/table),供 dynamic loading 使用
|
* v2.0 已被 `@/shared/components/plugin-boundary` 替代:
|
||||||
* - PluginErrorFallback:插件加载/渲染失败兜底
|
* - PluginSkeleton → 从 plugin-boundary 重新导出(5 种变体,shadcn 令牌)
|
||||||
* - PluginErrorBoundary:隔离单个插件错误,不影响其他插件
|
* - PluginErrorBoundary → 由 @edu/ui-components 的 ErrorBoundary + PluginBoundary 替代
|
||||||
* - PluginLoader:包裹插件组件,注入 PluginProps,挂载 ErrorBoundary
|
* - PluginLoader → 由 PluginBoundary 替代(ErrorBoundary + Suspense + Skeleton 三件套)
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §5.3、§7.3
|
* 本文件保留为 re-export 入口,避免破坏 28 个 widget 的 import:
|
||||||
|
* import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||||
|
*
|
||||||
|
* 新代码应直接从 `@/shared/components/plugin-boundary` 导入。
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||||
*/
|
*/
|
||||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
export {
|
||||||
import type { PluginProps } from "@/lib/types";
|
PluginSkeleton,
|
||||||
|
type PluginSkeletonVariant,
|
||||||
type SkeletonVariant = "card" | "list" | "chart" | "stats" | "table";
|
} from "@/shared/components/plugin-boundary";
|
||||||
|
|
||||||
/** 插件骨架屏(纸感风格,使用设计令牌) */
|
|
||||||
export function PluginSkeleton({
|
|
||||||
variant = "card",
|
|
||||||
}: {
|
|
||||||
variant?: SkeletonVariant;
|
|
||||||
}): ReactNode {
|
|
||||||
if (variant === "table") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-card bg-surface p-md animate-pulse"
|
|
||||||
role="status"
|
|
||||||
aria-label="loading"
|
|
||||||
>
|
|
||||||
<div className="h-heading-3 bg-subtle rounded-button mb-md w-1/4" />
|
|
||||||
<div className="space-y-sm">
|
|
||||||
{[0, 1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-body bg-subtle rounded-button w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (variant === "list") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-sm" role="status" aria-label="loading">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-body bg-subtle rounded-button w-full animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// card / stats / chart 默认卡片骨架
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-card bg-surface p-md animate-pulse"
|
|
||||||
role="status"
|
|
||||||
aria-label="loading"
|
|
||||||
>
|
|
||||||
<div className="h-heading-3 bg-subtle rounded-button mb-md w-1/3" />
|
|
||||||
<div className="h-large-number bg-subtle rounded-button w-1/2" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 插件错误兜底(居中错误图标 + 重试) */
|
|
||||||
export function PluginErrorFallback({
|
|
||||||
instanceId,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
instanceId: string;
|
|
||||||
onRetry?: () => void;
|
|
||||||
}): ReactNode {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-card border border-rule bg-surface p-md text-ink-muted"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<p className="text-small">插件加载失败({instanceId})</p>
|
|
||||||
{onRetry ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRetry}
|
|
||||||
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
|
||||||
>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
|
||||||
instanceId: string;
|
|
||||||
children: ReactNode;
|
|
||||||
onRetry?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
hasError: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单插件错误隔离边界 */
|
|
||||||
class PluginErrorBoundary extends Component<
|
|
||||||
ErrorBoundaryProps,
|
|
||||||
ErrorBoundaryState
|
|
||||||
> {
|
|
||||||
override state: ErrorBoundaryState = { hasError: false };
|
|
||||||
|
|
||||||
static getDerivedStateFromError(): ErrorBoundaryState {
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
||||||
console.error(
|
|
||||||
`[portal-shell] plugin ${this.props.instanceId} error: ${error.message}`,
|
|
||||||
info.componentStack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRetry = (): void => {
|
|
||||||
this.setState({ hasError: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
override render(): ReactNode {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<PluginErrorFallback
|
|
||||||
instanceId={this.props.instanceId}
|
|
||||||
onRetry={this.props.onRetry ?? this.handleRetry}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 插件加载器:包裹组件 + ErrorBoundary */
|
|
||||||
export function PluginLoader({
|
|
||||||
Component,
|
|
||||||
pluginProps,
|
|
||||||
}: {
|
|
||||||
Component: React.ComponentType<PluginProps>;
|
|
||||||
pluginProps: PluginProps;
|
|
||||||
}): ReactNode {
|
|
||||||
return (
|
|
||||||
<PluginErrorBoundary instanceId={pluginProps.instanceId}>
|
|
||||||
<Component {...pluginProps} />
|
|
||||||
</PluginErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,27 +6,73 @@
|
|||||||
* 编译时登记内置插件:plugin_id → { Component(dynamic import), metadata }。
|
* 编译时登记内置插件:plugin_id → { Component(dynamic import), metadata }。
|
||||||
* 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。
|
* 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。
|
||||||
*
|
*
|
||||||
* 内置插件(M8 验证管道,4 个示例):
|
* 内置插件共 28 个,按 spec §3 分 7 类:
|
||||||
* - grades-widget(universal / main)
|
* - universal(7):grades / homework / schedule / attendance / exams / notifications / announcements
|
||||||
* - notification-bell(topbar / top)
|
* - sidebar(4):class-selector / child-selector / term-switcher / quick-actions
|
||||||
* - user-menu(topbar / top)
|
* - topbar(4):notification-bell / user-menu / global-search / locale-switcher
|
||||||
* - class-selector(sidebar / side)
|
* - teacher(4):lesson-plan-editor / question-bank / textbook-manager / scheduling-rules
|
||||||
|
* - student(4):error-book / learning-path / elective-selector / ai-tutor
|
||||||
|
* - parent(2):child-overview / leave-approval
|
||||||
|
* - admin(3):user-management / rbac-manager / plugin-manager
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §2.2、§5.3
|
* 关联:portal-shell spec §2.2、§5.3、§3
|
||||||
*/
|
*/
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { PluginManifest } from "@/lib/types";
|
import type { PluginManifest } from "@/lib/types";
|
||||||
|
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||||
|
|
||||||
|
// universal(7)
|
||||||
import { manifestMeta as gradesWidgetMeta } from "@/widgets/universal/grades-widget/plugin.manifest";
|
import { manifestMeta as gradesWidgetMeta } from "@/widgets/universal/grades-widget/plugin.manifest";
|
||||||
|
import { manifestMeta as homeworkWidgetMeta } from "@/widgets/universal/homework-widget/plugin.manifest";
|
||||||
|
import { manifestMeta as scheduleWidgetMeta } from "@/widgets/universal/schedule-widget/plugin.manifest";
|
||||||
|
import { manifestMeta as attendanceWidgetMeta } from "@/widgets/universal/attendance-widget/plugin.manifest";
|
||||||
|
import { manifestMeta as examsWidgetMeta } from "@/widgets/universal/exams-widget/plugin.manifest";
|
||||||
|
import { manifestMeta as notificationsWidgetMeta } from "@/widgets/universal/notifications-widget/plugin.manifest";
|
||||||
|
import { manifestMeta as announcementsWidgetMeta } from "@/widgets/universal/announcements-widget/plugin.manifest";
|
||||||
|
|
||||||
|
// sidebar(4)
|
||||||
|
import { manifestMeta as classSelectorMeta } from "@/widgets/sidebar/class-selector/plugin.manifest";
|
||||||
|
import { manifestMeta as childSelectorMeta } from "@/widgets/sidebar/child-selector/plugin.manifest";
|
||||||
|
import { manifestMeta as termSwitcherMeta } from "@/widgets/sidebar/term-switcher/plugin.manifest";
|
||||||
|
import { manifestMeta as quickActionsMeta } from "@/widgets/sidebar/quick-actions/plugin.manifest";
|
||||||
|
|
||||||
|
// topbar(4)
|
||||||
import { manifestMeta as notificationBellMeta } from "@/widgets/topbar/notification-bell/plugin.manifest";
|
import { manifestMeta as notificationBellMeta } from "@/widgets/topbar/notification-bell/plugin.manifest";
|
||||||
import { manifestMeta as userMenuMeta } from "@/widgets/topbar/user-menu/plugin.manifest";
|
import { manifestMeta as userMenuMeta } from "@/widgets/topbar/user-menu/plugin.manifest";
|
||||||
import { manifestMeta as classSelectorMeta } from "@/widgets/sidebar/class-selector/plugin.manifest";
|
import { manifestMeta as globalSearchMeta } from "@/widgets/topbar/global-search/plugin.manifest";
|
||||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
import { manifestMeta as localeSwitcherMeta } from "@/widgets/topbar/locale-switcher/plugin.manifest";
|
||||||
|
|
||||||
|
// teacher(4)
|
||||||
|
import { manifestMeta as lessonPlanEditorMeta } from "@/widgets/teacher/lesson-plan-editor/plugin.manifest";
|
||||||
|
import { manifestMeta as questionBankMeta } from "@/widgets/teacher/question-bank/plugin.manifest";
|
||||||
|
import { manifestMeta as textbookManagerMeta } from "@/widgets/teacher/textbook-manager/plugin.manifest";
|
||||||
|
import { manifestMeta as schedulingRulesMeta } from "@/widgets/teacher/scheduling-rules/plugin.manifest";
|
||||||
|
|
||||||
|
// student(4)
|
||||||
|
import { manifestMeta as errorBookMeta } from "@/widgets/student/error-book/plugin.manifest";
|
||||||
|
import { manifestMeta as learningPathMeta } from "@/widgets/student/learning-path/plugin.manifest";
|
||||||
|
import { manifestMeta as electiveSelectorMeta } from "@/widgets/student/elective-selector/plugin.manifest";
|
||||||
|
import { manifestMeta as aiTutorMeta } from "@/widgets/student/ai-tutor/plugin.manifest";
|
||||||
|
|
||||||
|
// parent(2)
|
||||||
|
import { manifestMeta as childOverviewMeta } from "@/widgets/parent/child-overview/plugin.manifest";
|
||||||
|
import { manifestMeta as leaveApprovalMeta } from "@/widgets/parent/leave-approval/plugin.manifest";
|
||||||
|
|
||||||
|
// admin(6)
|
||||||
|
import { manifestMeta as userManagementMeta } from "@/widgets/admin/user-management/plugin.manifest";
|
||||||
|
import { manifestMeta as rbacManagerMeta } from "@/widgets/admin/rbac-manager/plugin.manifest";
|
||||||
|
import { manifestMeta as pluginManagerMeta } from "@/widgets/admin/plugin-manager/plugin.manifest";
|
||||||
|
import { manifestMeta as schoolSettingsMeta } from "@/widgets/admin/school-settings/plugin.manifest";
|
||||||
|
import { manifestMeta as auditLogsMeta } from "@/widgets/admin/audit-logs/plugin.manifest";
|
||||||
|
import { manifestMeta as invitationCodesMeta } from "@/widgets/admin/invitation-codes/plugin.manifest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置插件注册表。
|
* 内置插件注册表。
|
||||||
* Component 使用 next/dynamic 懒加载(ssr:false),避免插件 JS 阻塞首屏。
|
* Component 使用 next/dynamic 懒加载(ssr:false),避免插件 JS 阻塞首屏。
|
||||||
|
* loading 展示骨架屏变体,与目标 slot 视觉一致。
|
||||||
*/
|
*/
|
||||||
export const REGISTRY: Record<string, PluginManifest> = {
|
export const REGISTRY: Record<string, PluginManifest> = {
|
||||||
|
// ─── universal(main 区跨角色) ───────────────────────────────
|
||||||
"grades-widget": {
|
"grades-widget": {
|
||||||
...gradesWidgetMeta,
|
...gradesWidgetMeta,
|
||||||
Component: dynamic(() => import("@/widgets/universal/grades-widget"), {
|
Component: dynamic(() => import("@/widgets/universal/grades-widget"), {
|
||||||
@@ -34,6 +80,86 @@ export const REGISTRY: Record<string, PluginManifest> = {
|
|||||||
loading: () => <PluginSkeleton variant="table" />,
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
"homework-widget": {
|
||||||
|
...homeworkWidgetMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/universal/homework-widget"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"schedule-widget": {
|
||||||
|
...scheduleWidgetMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/universal/schedule-widget"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"attendance-widget": {
|
||||||
|
...attendanceWidgetMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/universal/attendance-widget"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="stats" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"exams-widget": {
|
||||||
|
...examsWidgetMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/universal/exams-widget"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"notifications-widget": {
|
||||||
|
...notificationsWidgetMeta,
|
||||||
|
Component: dynamic(
|
||||||
|
() => import("@/widgets/universal/notifications-widget"),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"announcements-widget": {
|
||||||
|
...announcementsWidgetMeta,
|
||||||
|
Component: dynamic(
|
||||||
|
() => import("@/widgets/universal/announcements-widget"),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── sidebar(side 区) ──────────────────────────────────────
|
||||||
|
"class-selector": {
|
||||||
|
...classSelectorMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"child-selector": {
|
||||||
|
...childSelectorMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/sidebar/child-selector"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"term-switcher": {
|
||||||
|
...termSwitcherMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/sidebar/term-switcher"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"quick-actions": {
|
||||||
|
...quickActionsMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/sidebar/quick-actions"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── topbar(top 区) ────────────────────────────────────────
|
||||||
"notification-bell": {
|
"notification-bell": {
|
||||||
...notificationBellMeta,
|
...notificationBellMeta,
|
||||||
Component: dynamic(() => import("@/widgets/topbar/notification-bell"), {
|
Component: dynamic(() => import("@/widgets/topbar/notification-bell"), {
|
||||||
@@ -48,13 +174,140 @@ export const REGISTRY: Record<string, PluginManifest> = {
|
|||||||
loading: () => <PluginSkeleton variant="card" />,
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
"class-selector": {
|
"global-search": {
|
||||||
...classSelectorMeta,
|
...globalSearchMeta,
|
||||||
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
|
Component: dynamic(() => import("@/widgets/topbar/global-search"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"locale-switcher": {
|
||||||
|
...localeSwitcherMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/topbar/locale-switcher"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── teacher(main 区教师专属) ───────────────────────────────
|
||||||
|
"lesson-plan-editor": {
|
||||||
|
...lessonPlanEditorMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/teacher/lesson-plan-editor"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"question-bank": {
|
||||||
|
...questionBankMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/teacher/question-bank"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"textbook-manager": {
|
||||||
|
...textbookManagerMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/teacher/textbook-manager"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <PluginSkeleton variant="list" />,
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
"scheduling-rules": {
|
||||||
|
...schedulingRulesMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/teacher/scheduling-rules"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── student(main 区学生专属) ───────────────────────────────
|
||||||
|
"error-book": {
|
||||||
|
...errorBookMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/student/error-book"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"learning-path": {
|
||||||
|
...learningPathMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/student/learning-path"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"elective-selector": {
|
||||||
|
...electiveSelectorMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/student/elective-selector"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"ai-tutor": {
|
||||||
|
...aiTutorMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/student/ai-tutor"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── parent(main 区家长专属) ────────────────────────────────
|
||||||
|
"child-overview": {
|
||||||
|
...childOverviewMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/parent/child-overview"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="stats" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"leave-approval": {
|
||||||
|
...leaveApprovalMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/parent/leave-approval"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── admin(main 区管理员专属) ───────────────────────────────
|
||||||
|
"user-management": {
|
||||||
|
...userManagementMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/admin/user-management"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"rbac-manager": {
|
||||||
|
...rbacManagerMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/admin/rbac-manager"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"plugin-manager": {
|
||||||
|
...pluginManagerMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/admin/plugin-manager"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"school-settings": {
|
||||||
|
...schoolSettingsMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/admin/school-settings"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"audit-logs": {
|
||||||
|
...auditLogsMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/admin/audit-logs"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"invitation-codes": {
|
||||||
|
...invitationCodesMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/admin/invitation-codes"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 判断插件是否已注册 */
|
/** 判断插件是否已注册 */
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SlotRenderer - 按 Config 渲染插件列表(v2.1 M8)
|
* SlotRenderer - 按 Config 渲染插件列表(v2.1 M8 + 流式渲染)
|
||||||
*
|
*
|
||||||
* 给定一个 slot 名称,从配置中过滤出该 slot 的可见插件,按 sortOrder 排序,
|
* 给定一个 slot 名称,从配置中过滤出该 slot 的可见插件,按 sortOrder 排序,
|
||||||
* 查 Registry 取组件,注入 PluginProps,经 PluginLoader 挂载(含 ErrorBoundary)。
|
* 查 Registry 取组件,注入 PluginProps,经 PluginBoundary 挂载(含 ErrorBoundary + Suspense)。
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §2.2、§5.1
|
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||||
|
* - L1 角色门禁:由 config-service 三层合并时已过滤(requiredRoles),SlotRenderer 信任输入
|
||||||
|
* - L2 权限点门禁:由 config-service 三层合并时已过滤(requiredPermissions),SlotRenderer 信任输入
|
||||||
|
* (manifest.metadata.requiredPermissions 作为元数据,供 admin 配置面板和开发时审查使用)
|
||||||
|
* - L3 数据范围:由插件内部 usePermission 校验
|
||||||
|
*
|
||||||
|
* 错误隔离(portal-shell README v2.0 §5.4 L3 插件级):
|
||||||
|
* - 每个插件被 PluginBoundary 包裹,单个插件崩溃不影响其他插件
|
||||||
|
* - 流式 Suspense:插件 dynamic import 期间显示骨架屏
|
||||||
|
*
|
||||||
|
* 关联:portal-shell spec §2.2、§5.1、README v2.0 §3.3 §5.3 §5.4
|
||||||
*/
|
*/
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import { REGISTRY, isPluginRegistered } from "./Registry";
|
import { REGISTRY, isPluginRegistered } from "./Registry";
|
||||||
import { PluginLoader, PluginSkeleton } from "./PluginLoader";
|
import {
|
||||||
|
PluginBoundary,
|
||||||
|
PluginSkeleton,
|
||||||
|
type PluginSkeletonVariant,
|
||||||
|
} from "@/shared/components/plugin-boundary";
|
||||||
import { parsePropsJson, parseSizeJson } from "./PropsMerger";
|
import { parsePropsJson, parseSizeJson } from "./PropsMerger";
|
||||||
import type { PluginPlacement, PluginProps, Role } from "@/lib/types";
|
import type { PluginPlacement, PluginProps, Role } from "@/lib/types";
|
||||||
import type { AuthUser } from "@/providers/AuthProvider";
|
import type { AuthUser } from "@/providers/AuthProvider";
|
||||||
@@ -24,6 +38,33 @@ export interface SlotRendererProps {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 slot + pluginId 推导骨架变体
|
||||||
|
*
|
||||||
|
* 不同 slot 的插件在加载时显示对应形态的骨架,提升视觉一致性:
|
||||||
|
* - topbar:紧凑卡片
|
||||||
|
* - side / main-left:列表
|
||||||
|
* - main:根据 pluginId 推断(默认 card)
|
||||||
|
*/
|
||||||
|
function inferSkeletonVariant(
|
||||||
|
slotName: string,
|
||||||
|
pluginId: string,
|
||||||
|
): PluginSkeletonVariant {
|
||||||
|
if (slotName === "side" || slotName === "main-left") return "list";
|
||||||
|
if (slotName === "top") return "card";
|
||||||
|
|
||||||
|
// main 区按 pluginId 推断
|
||||||
|
if (pluginId.includes("grades") || pluginId.includes("schedule"))
|
||||||
|
return "table";
|
||||||
|
if (pluginId.includes("attendance") || pluginId.includes("overview"))
|
||||||
|
return "stats";
|
||||||
|
if (pluginId.includes("chart") || pluginId.includes("trend")) return "chart";
|
||||||
|
if (pluginId.includes("list") || pluginId.includes("notification"))
|
||||||
|
return "list";
|
||||||
|
|
||||||
|
return "card";
|
||||||
|
}
|
||||||
|
|
||||||
export function SlotRenderer({
|
export function SlotRenderer({
|
||||||
slotName,
|
slotName,
|
||||||
layoutId,
|
layoutId,
|
||||||
@@ -44,20 +85,20 @@ export function SlotRenderer({
|
|||||||
// 空 slot:main 区显示占位,topbar/side 区不渲染
|
// 空 slot:main 区显示占位,topbar/side 区不渲染
|
||||||
if (slotName === "top" || slotName === "side") return null;
|
if (slotName === "top" || slotName === "side") return null;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-card border border-rule bg-surface p-md text-ink-muted text-small">
|
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||||
暂无可见插件
|
暂无可见插件
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md">
|
<div className="space-y-4">
|
||||||
{visible.map((placement) => {
|
{visible.map((placement) => {
|
||||||
if (!isPluginRegistered(placement.pluginId)) {
|
if (!isPluginRegistered(placement.pluginId)) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={placement.pluginId}
|
key={placement.pluginId}
|
||||||
className="rounded-card border border-rule bg-surface p-md text-ink-muted text-small"
|
className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
未注册插件:{placement.pluginId}
|
未注册插件:{placement.pluginId}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +108,7 @@ export function SlotRenderer({
|
|||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginProps: PluginProps = {
|
const pluginProps: PluginProps = {
|
||||||
instanceId: `${placement.pluginId}-${slotName}-${placement.sortOrder}`,
|
instanceId: `${placement.pluginId}-${slotName}-${placement.sortOrder}`,
|
||||||
role,
|
role,
|
||||||
@@ -83,12 +125,22 @@ export function SlotRenderer({
|
|||||||
},
|
},
|
||||||
props: parsePropsJson(placement.propsJson),
|
props: parsePropsJson(placement.propsJson),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skeletonVariant = inferSkeletonVariant(
|
||||||
|
slotName,
|
||||||
|
placement.pluginId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const Component =
|
||||||
|
manifest.Component as React.ComponentType<PluginProps>;
|
||||||
return (
|
return (
|
||||||
<PluginLoader
|
<PluginBoundary
|
||||||
key={pluginProps.instanceId}
|
key={pluginProps.instanceId}
|
||||||
Component={manifest.Component}
|
pluginId={placement.pluginId}
|
||||||
pluginProps={pluginProps}
|
skeletonVariant={skeletonVariant}
|
||||||
/>
|
>
|
||||||
|
<Component {...pluginProps} />
|
||||||
|
</PluginBoundary>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -96,11 +148,17 @@ export function SlotRenderer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Slot 加载态占位(layout 切换瞬间) */
|
/** Slot 加载态占位(layout 切换瞬间) */
|
||||||
export function SlotSkeleton({ count = 1 }: { count?: number }): ReactNode {
|
export function SlotSkeleton({
|
||||||
|
count = 1,
|
||||||
|
variant = "card",
|
||||||
|
}: {
|
||||||
|
count?: number;
|
||||||
|
variant?: PluginSkeletonVariant;
|
||||||
|
}): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<PluginSkeleton key={i} variant="card" />
|
<PluginSkeleton key={i} variant={variant} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
104
apps/portal-shell/src/shell/__tests__/PluginLifecycle.test.ts
Normal file
104
apps/portal-shell/src/shell/__tests__/PluginLifecycle.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
checkVersionCompatibility,
|
||||||
|
getActivationPath,
|
||||||
|
isPluginRenderable,
|
||||||
|
} from "@/shell/PluginLifecycle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginLifecycle 单元测试(portal-shell spec §5.4、§9.9)
|
||||||
|
*
|
||||||
|
* 覆盖核心纯函数:
|
||||||
|
* - checkVersionCompatibility:major 版本校验
|
||||||
|
* - getActivationPath:生命周期转换路径
|
||||||
|
* - isPluginRenderable:综合可渲染判断
|
||||||
|
*/
|
||||||
|
describe("checkVersionCompatibility", () => {
|
||||||
|
it("同 major 版本兼容", () => {
|
||||||
|
expect(checkVersionCompatibility("^1.0.0", "1.2.3")).toBe(true);
|
||||||
|
expect(checkVersionCompatibility("~1.2.0", "1.2.5")).toBe(true);
|
||||||
|
expect(checkVersionCompatibility("2.0.0", "2.5.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不同 major 版本不兼容", () => {
|
||||||
|
expect(checkVersionCompatibility("^1.0.0", "2.0.0")).toBe(false);
|
||||||
|
expect(checkVersionCompatibility("^2.0.0", "1.5.0")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无法解析时放行(容错)", () => {
|
||||||
|
expect(checkVersionCompatibility("invalid", "1.0.0")).toBe(true);
|
||||||
|
expect(checkVersionCompatibility("^1.0.0", "unknown")).toBe(true);
|
||||||
|
expect(checkVersionCompatibility("", "")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("带 range 前缀的版本号", () => {
|
||||||
|
expect(checkVersionCompatibility("^1.5.0", "1.6.0")).toBe(true);
|
||||||
|
expect(checkVersionCompatibility(">=2.0.0", "2.1.0")).toBe(true);
|
||||||
|
expect(checkVersionCompatibility("~3.0.0", "3.0.1")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getActivationPath", () => {
|
||||||
|
it("admin 未启用的插件 → disabled", () => {
|
||||||
|
const path = getActivationPath(true, false);
|
||||||
|
expect(path).toEqual(["registered", "disabled"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("内置插件 + admin 启用 → 完整激活路径", () => {
|
||||||
|
const path = getActivationPath(true, true);
|
||||||
|
expect(path).toEqual(["registered", "enabled", "loaded", "active"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("第三方插件 + admin 启用 → 完整激活路径", () => {
|
||||||
|
const path = getActivationPath(false, true);
|
||||||
|
expect(path).toEqual(["registered", "enabled", "loaded", "active"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPluginRenderable", () => {
|
||||||
|
const baseParams = {
|
||||||
|
isActive: true,
|
||||||
|
requiredShellVersion: "^1.0.0",
|
||||||
|
currentShellVersion: "1.0.0",
|
||||||
|
userRole: "teacher",
|
||||||
|
requiredRoles: ["teacher", "student"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("全部满足 → 可渲染", () => {
|
||||||
|
expect(isPluginRenderable(baseParams)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未激活 → 不可渲染", () => {
|
||||||
|
expect(isPluginRenderable({ ...baseParams, isActive: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("版本不兼容 → 不可渲染", () => {
|
||||||
|
expect(
|
||||||
|
isPluginRenderable({
|
||||||
|
...baseParams,
|
||||||
|
requiredShellVersion: "^2.0.0",
|
||||||
|
currentShellVersion: "1.0.0",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("角色不匹配 → 不可渲染", () => {
|
||||||
|
expect(
|
||||||
|
isPluginRenderable({
|
||||||
|
...baseParams,
|
||||||
|
userRole: "parent",
|
||||||
|
requiredRoles: ["teacher", "student"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requiredRoles 为空 → 任意角色可渲染", () => {
|
||||||
|
expect(
|
||||||
|
isPluginRenderable({
|
||||||
|
...baseParams,
|
||||||
|
requiredRoles: [],
|
||||||
|
userRole: "admin",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/portal-shell/src/shell/__tests__/Registry.test.ts
Normal file
101
apps/portal-shell/src/shell/__tests__/Registry.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { REGISTRY, isPluginRegistered } from "@/shell/Registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry 单元测试(portal-shell spec §5.3、§9.9)
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* - REGISTRY 包含全部 31 个内置插件
|
||||||
|
* - isPluginRegistered 正确判断
|
||||||
|
* - 每个插件 manifest 必填字段完整
|
||||||
|
*/
|
||||||
|
const EXPECTED_PLUGIN_IDS = [
|
||||||
|
// universal(7)
|
||||||
|
"grades-widget",
|
||||||
|
"homework-widget",
|
||||||
|
"schedule-widget",
|
||||||
|
"attendance-widget",
|
||||||
|
"exams-widget",
|
||||||
|
"notifications-widget",
|
||||||
|
"announcements-widget",
|
||||||
|
// sidebar(4)
|
||||||
|
"class-selector",
|
||||||
|
"child-selector",
|
||||||
|
"term-switcher",
|
||||||
|
"quick-actions",
|
||||||
|
// topbar(4)
|
||||||
|
"notification-bell",
|
||||||
|
"user-menu",
|
||||||
|
"global-search",
|
||||||
|
"locale-switcher",
|
||||||
|
// teacher(4)
|
||||||
|
"lesson-plan-editor",
|
||||||
|
"question-bank",
|
||||||
|
"textbook-manager",
|
||||||
|
"scheduling-rules",
|
||||||
|
// student(4)
|
||||||
|
"error-book",
|
||||||
|
"learning-path",
|
||||||
|
"elective-selector",
|
||||||
|
"ai-tutor",
|
||||||
|
// parent(2)
|
||||||
|
"child-overview",
|
||||||
|
"leave-approval",
|
||||||
|
// admin(6)
|
||||||
|
"user-management",
|
||||||
|
"rbac-manager",
|
||||||
|
"plugin-manager",
|
||||||
|
"school-settings",
|
||||||
|
"audit-logs",
|
||||||
|
"invitation-codes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("REGISTRY", () => {
|
||||||
|
it("包含全部 31 个内置插件", () => {
|
||||||
|
expect(Object.keys(REGISTRY).length).toBe(EXPECTED_PLUGIN_IDS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("每个预期 pluginId 都已注册", () => {
|
||||||
|
for (const id of EXPECTED_PLUGIN_IDS) {
|
||||||
|
const manifest = REGISTRY[id];
|
||||||
|
expect(manifest).toBeDefined();
|
||||||
|
expect(manifest!.pluginId).toBe(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("每个插件 manifest 必填字段完整", () => {
|
||||||
|
for (const id of EXPECTED_PLUGIN_IDS) {
|
||||||
|
const manifest = REGISTRY[id];
|
||||||
|
expect(manifest).toBeDefined();
|
||||||
|
expect(manifest!.pluginId).toBeTruthy();
|
||||||
|
expect(manifest!.version).toBeTruthy();
|
||||||
|
expect(manifest!.requiredShellVersion).toBeTruthy();
|
||||||
|
expect(manifest!.Component).toBeTruthy();
|
||||||
|
expect(manifest!.metadata.displayName).toBeTruthy();
|
||||||
|
expect(manifest!.metadata.description).toBeTruthy();
|
||||||
|
expect(manifest!.metadata.category).toBeTruthy();
|
||||||
|
expect(manifest!.metadata.requiredRoles).toBeInstanceOf(Array);
|
||||||
|
expect(manifest!.metadata.defaultSlot).toBeTruthy();
|
||||||
|
expect(manifest!.metadata.defaultSize).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("每个插件的 pluginId 与 key 一致", () => {
|
||||||
|
for (const [key, manifest] of Object.entries(REGISTRY)) {
|
||||||
|
expect(manifest.pluginId).toBe(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPluginRegistered", () => {
|
||||||
|
it("已注册的 pluginId → true", () => {
|
||||||
|
expect(isPluginRegistered("grades-widget")).toBe(true);
|
||||||
|
expect(isPluginRegistered("plugin-manager")).toBe(true);
|
||||||
|
expect(isPluginRegistered("notification-bell")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未注册的 pluginId → false", () => {
|
||||||
|
expect(isPluginRegistered("nonexistent-plugin")).toBe(false);
|
||||||
|
expect(isPluginRegistered("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* portal-shell 设计令牌入口(引用 @edu/ui-tokens)
|
* portal-shell 设计令牌入口(引用 @edu/ui-tokens)
|
||||||
*
|
*
|
||||||
* 业务代码通过 Tailwind 类(bg-paper / text-ink)或 hsl(var(--*)) 引用。
|
* 业务代码通过 Tailwind 类(bg-background / text-foreground / bg-card ...)
|
||||||
|
* 或 hsl(var(--*)) 引用 shadcn 标准令牌。
|
||||||
|
*
|
||||||
|
* 关联:project_rules §3.10、packages/ui-tokens/src/all.css
|
||||||
*/
|
*/
|
||||||
@import "@edu/ui-tokens/all.css";
|
@import "@edu/ui-tokens/all.css";
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tailwind 配置 - 对齐三层设计令牌
|
|
||||||
*
|
|
||||||
* Layer 3 映射:将 Layer 2 Semantic CSS 变量暴露为 Tailwind 类名
|
|
||||||
* 业务代码使用 bg-paper / text-ink / font-serif 等语义类
|
|
||||||
*
|
|
||||||
* 禁止:
|
|
||||||
* - 禁止 hex 字面量(colors 引用 var(--*))
|
|
||||||
* - 禁止字体名字面量(fontFamily 引用 var(--font-family-*))
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
paper: "var(--bg-paper)",
|
|
||||||
surface: "var(--bg-surface)",
|
|
||||||
subtle: "var(--bg-subtle)",
|
|
||||||
ink: {
|
|
||||||
DEFAULT: "var(--color-ink)",
|
|
||||||
muted: "var(--color-ink-muted)",
|
|
||||||
subtle: "var(--color-ink-subtle)",
|
|
||||||
onAccent: "var(--color-ink-on-accent)",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "var(--color-accent)",
|
|
||||||
hover: "var(--color-accent-hover)",
|
|
||||||
subtle: "var(--color-accent-subtle)",
|
|
||||||
},
|
|
||||||
rule: {
|
|
||||||
DEFAULT: "var(--color-rule)",
|
|
||||||
strong: "var(--color-rule-strong)",
|
|
||||||
},
|
|
||||||
success: "var(--color-success)",
|
|
||||||
warning: "var(--color-warning)",
|
|
||||||
danger: "var(--color-danger)",
|
|
||||||
info: "var(--color-info)",
|
|
||||||
border: "var(--color-border)",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: "var(--font-family-sans)",
|
|
||||||
serif: "var(--font-family-serif)",
|
|
||||||
mono: "var(--font-family-mono)",
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
body: "var(--font-size-body)",
|
|
||||||
small: "var(--font-size-small)",
|
|
||||||
tiny: "var(--font-size-tiny)",
|
|
||||||
"heading-1": "var(--font-size-heading-1)",
|
|
||||||
"heading-2": "var(--font-size-heading-2)",
|
|
||||||
"heading-3": "var(--font-size-heading-3)",
|
|
||||||
display: "var(--font-size-display)",
|
|
||||||
"large-number": "var(--font-size-large-number)",
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
xs: "var(--space-xs)",
|
|
||||||
sm: "var(--space-sm)",
|
|
||||||
md: "var(--space-md)",
|
|
||||||
lg: "var(--space-lg)",
|
|
||||||
xl: "var(--space-xl)",
|
|
||||||
"2xl": "var(--space-2xl)",
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
DEFAULT: "var(--radius-default)",
|
|
||||||
card: "var(--radius-card)",
|
|
||||||
button: "var(--radius-button)",
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
sm: "var(--shadow-sm)",
|
|
||||||
md: "var(--shadow-md)",
|
|
||||||
lg: "var(--shadow-lg)",
|
|
||||||
xl: "var(--shadow-xl)",
|
|
||||||
},
|
|
||||||
transitionDuration: {
|
|
||||||
fast: "150ms",
|
|
||||||
normal: "200ms",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -16,9 +16,16 @@
|
|||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
|
"@/shared/*": ["./src/shared/*"],
|
||||||
"@edu/hooks": ["../../packages/hooks/src/index.ts"],
|
"@edu/hooks": ["../../packages/hooks/src/index.ts"],
|
||||||
"@edu/ui-components": ["../../packages/ui-components/src/index.ts"],
|
"@edu/ui-components": ["../../packages/ui-components/src/index.ts"],
|
||||||
"@edu/ui-tokens": ["../../packages/ui-tokens/src/index.ts"]
|
"@edu/ui-tokens": ["../../packages/ui-tokens/src/index.ts"],
|
||||||
|
"@edu/shared-ts/contracts": [
|
||||||
|
"../../packages/shared-ts/src/contracts/index.ts"
|
||||||
|
],
|
||||||
|
"@edu/shared-ts/permission-bitmap": [
|
||||||
|
"../../packages/shared-ts/src/permission-bitmap.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"isolatedModules": true
|
"isolatedModules": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,17 +2,36 @@ import { defineConfig } from "vitest/config";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* portal-shell 单元测试配置(v2.1 spec §9.9)
|
||||||
|
*
|
||||||
|
* 环境:jsdom(支持 React Testing Library)
|
||||||
|
* 路径别名:与 tsconfig.json 对齐
|
||||||
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
include: ["src/**/__tests__/**/*.test.{ts,tsx}"],
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: [],
|
setupFiles: [],
|
||||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "./src"),
|
"@": resolve(__dirname, "src"),
|
||||||
|
"@edu/hooks": resolve(__dirname, "../../packages/hooks/src/index.ts"),
|
||||||
|
"@edu/ui-components": resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../packages/ui-components/src/index.ts",
|
||||||
|
),
|
||||||
|
"@edu/ui-tokens": resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../packages/ui-tokens/src/index.ts",
|
||||||
|
),
|
||||||
|
"@edu/shared-ts/contracts": resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../packages/shared-ts/src/contracts/index.ts",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
411
docs/runbooks/local-stack.md
Normal file
411
docs/runbooks/local-stack.md
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# 本地全栈启动运维手册
|
||||||
|
|
||||||
|
> 版本:v2.1(混合部署模式)
|
||||||
|
> 日期:2026-07-17
|
||||||
|
> 适用范围:Edu v2.1 架构本地开发与测试环境
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构概览
|
||||||
|
|
||||||
|
### 1.1 部署模式
|
||||||
|
|
||||||
|
**混合部署**:基础设施 + Apollo Router 用 Docker,应用服务本地运行。
|
||||||
|
|
||||||
|
| 层级 | 运行方式 | 说明 |
|
||||||
|
| ------------- | -------------- | --------------------------------------------------------------- |
|
||||||
|
| 基础设施 | Docker Compose | MySQL/Redis/Kafka/ClickHouse/Neo4j/ES 等 |
|
||||||
|
| Apollo Router | Docker 容器 | 联邦 GraphQL 网关(组合 5 个子图,端口 3000) |
|
||||||
|
| 应用服务 | 本地进程 | NestJS(pnpm exec nest start)/ Python(uvicorn)/ Go(go run) |
|
||||||
|
| 前端 | 本地进程 | Next.js dev server(portal-shell) |
|
||||||
|
|
||||||
|
> Apollo Router 在 Docker 容器中运行,通过 `host.docker.internal` 访问 host 上的 5 个 Federation 2 子图(iam/core-edu/content/msg/config-service),compose 出 supergraph 后对外暴露 `http://localhost:3000/graphql`。
|
||||||
|
|
||||||
|
### 1.2 服务端口映射
|
||||||
|
|
||||||
|
#### 基础设施(Docker)
|
||||||
|
|
||||||
|
| 服务 | 端口 | 容器名 |
|
||||||
|
| ------------- | --------- | ----------------- |
|
||||||
|
| MySQL | 3306 | edu-mysql |
|
||||||
|
| Redis | 6379 | edu-redis |
|
||||||
|
| Kafka | 9092 | edu-kafka |
|
||||||
|
| Zookeeper | 2181 | edu-zookeeper |
|
||||||
|
| ClickHouse | 8123 | edu-clickhouse |
|
||||||
|
| Neo4j | 7474/7687 | edu-neo4j |
|
||||||
|
| Elasticsearch | 9200 | edu-es |
|
||||||
|
| Debezium | 8083 | edu-debezium |
|
||||||
|
| Apollo Router | 3000/8088 | edu-apollo-router |
|
||||||
|
| Jaeger | 16686 | edu-jaeger |
|
||||||
|
| Prometheus | 9090 | edu-prometheus |
|
||||||
|
| Grafana | 3030 | edu-grafana |
|
||||||
|
| Alertmanager | 9093 | edu-alertmanager |
|
||||||
|
| Loki | 3100 | edu-loki |
|
||||||
|
|
||||||
|
#### 应用服务(本地)
|
||||||
|
|
||||||
|
| 服务 | 端口 | gRPC 端口 | 类型 | 健康端点 |
|
||||||
|
| -------------- | ---- | --------- | ------- | ----------- |
|
||||||
|
| iam | 3002 | 50052 | NestJS | /healthz |
|
||||||
|
| config-service | 3011 | 50059 | NestJS | /healthz |
|
||||||
|
| classes | 3001 | 50053 | NestJS | /healthz |
|
||||||
|
| core-edu | 3004 | 50054 | NestJS | /healthz |
|
||||||
|
| content | 3005 | 50055 | NestJS | /healthz |
|
||||||
|
| msg | 3007 | 50056 | NestJS | /healthz |
|
||||||
|
| data-ana | 3006 | - | Python | /healthz |
|
||||||
|
| ai | 3008 | - | Python | /healthz |
|
||||||
|
| api-gateway | 8080 | - | Go | /healthz |
|
||||||
|
| push-gateway | 8081 | - | Go | /healthz |
|
||||||
|
| portal-shell | 4010 | - | Next.js | /api/health |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 一键脚本使用
|
||||||
|
|
||||||
|
### 2.1 一键启动
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 启动全部(基础设施 + 应用服务 + Apollo Router)
|
||||||
|
.\scripts\start-all.ps1
|
||||||
|
|
||||||
|
# 仅启动基础设施
|
||||||
|
.\scripts\start-all.ps1 -SkipApps
|
||||||
|
|
||||||
|
# 仅启动应用服务(基础设施已运行)
|
||||||
|
.\scripts\start-all.ps1 -SkipInfra
|
||||||
|
|
||||||
|
# 自动杀掉占用端口的进程(不交互确认)
|
||||||
|
.\scripts\start-all.ps1 -Force
|
||||||
|
|
||||||
|
# 跳过 Apollo Router(仅用 config-service 直连,调试子图时使用)
|
||||||
|
.\scripts\start-all.ps1 -SkipRouter
|
||||||
|
```
|
||||||
|
|
||||||
|
**启动流程**(8 阶段):
|
||||||
|
|
||||||
|
1. 加载 `.env` 文件
|
||||||
|
2. 启动 Docker 基础设施(p3+p5 profile + observability)
|
||||||
|
3. 基础设施健康检查
|
||||||
|
4. 端口冲突检查与清理
|
||||||
|
5. 确保 `@edu/shared-ts` 已编译
|
||||||
|
6. 清理 NestJS tsbuildinfo 缓存
|
||||||
|
7. 启动应用服务 + 健康检查
|
||||||
|
8. 启动 Apollo Router(Docker 容器,组合 5 子图 supergraph)
|
||||||
|
|
||||||
|
> Apollo Router 依赖 5 个 Federation 2 子图(iam/core-edu/content/msg/config-service)就绪后才能 compose supergraph,因此在第 7 步应用服务健康检查通过后才启动。如果跳过 `-SkipRouter`,portal-shell 会自动降级到 config-service 直连(http://localhost:3011)。
|
||||||
|
|
||||||
|
### 2.2 一键关闭
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 关闭应用服务 + Apollo Router
|
||||||
|
.\scripts\stop-all.ps1
|
||||||
|
|
||||||
|
# 强制按端口杀进程(窗口关闭后进程残留时使用)
|
||||||
|
.\scripts\stop-all.ps1 -KillByPort
|
||||||
|
|
||||||
|
# 关闭应用 + Apollo Router + Docker 基础设施
|
||||||
|
.\scripts\stop-all.ps1 -IncludeDocker
|
||||||
|
|
||||||
|
# 全部关闭
|
||||||
|
.\scripts\stop-all.ps1 -KillByPort -IncludeDocker
|
||||||
|
|
||||||
|
# 仅关闭应用,保留 Router 容器
|
||||||
|
.\scripts\stop-all.ps1 -SkipRouter
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关闭顺序:Apollo Router 容器 → 应用服务窗口 → 按端口杀残留 → Docker 基础设施。先关 Router 避免子图关闭时 router 刷连接错误日志。
|
||||||
|
|
||||||
|
### 2.3 典型工作流
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 早晨开始工作:一键启动
|
||||||
|
.\scripts\start-all.ps1
|
||||||
|
|
||||||
|
# 中午休息:仅关闭应用(保留 Docker 数据)
|
||||||
|
.\scripts\stop-all.ps1 -KillByPort
|
||||||
|
|
||||||
|
# 下午继续:仅启动应用
|
||||||
|
.\scripts\start-all.ps1 -SkipInfra -Force
|
||||||
|
|
||||||
|
# 下班:全部关闭
|
||||||
|
.\scripts\stop-all.ps1 -KillByPort -IncludeDocker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 手动操作
|
||||||
|
|
||||||
|
### 3.1 仅启动基础设施
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd infra
|
||||||
|
# 基础设施服务(跳过需构建镜像的 app 服务)
|
||||||
|
docker compose -f docker-compose.yml --profile p3 --profile p5 up -d mysql redis kafka zookeeper clickhouse neo4j elasticsearch debezium-connect
|
||||||
|
# 可观测性栈
|
||||||
|
docker compose -f docker-compose.yml --profile observability up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 单独启动某个应用服务
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# NestJS 服务
|
||||||
|
pnpm --filter @edu/iam-service exec nest start
|
||||||
|
pnpm --filter @edu/config-service exec nest start
|
||||||
|
pnpm --filter @edu/classes-service exec nest start
|
||||||
|
pnpm --filter @edu/core-edu-service exec nest start
|
||||||
|
pnpm --filter @edu/content-service exec nest start
|
||||||
|
pnpm --filter @edu/msg-service exec nest start
|
||||||
|
|
||||||
|
# Python 服务
|
||||||
|
cd services\data-ana; uv run uvicorn data_ana.main:app --app-dir src --host 0.0.0.0 --port 3006
|
||||||
|
cd services\ai; uv run uvicorn ai.main:app --app-dir src --host 0.0.0.0 --port 3008
|
||||||
|
|
||||||
|
# Go 服务
|
||||||
|
cd services\api-gateway; go run .
|
||||||
|
cd services\push-gateway; go run .
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
pnpm --filter @edu/portal-shell dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 健康检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 单个服务
|
||||||
|
curl http://localhost:3002/healthz # iam
|
||||||
|
curl http://localhost:4010/api/health # portal-shell
|
||||||
|
|
||||||
|
# 批量健康检查脚本
|
||||||
|
.\scripts\health-check.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 查看 Docker 容器状态
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 运行中的容器
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
|
# 查看容器日志
|
||||||
|
docker logs edu-mysql --tail 50
|
||||||
|
docker logs edu-kafka --tail 50 -f
|
||||||
|
|
||||||
|
# 容器健康状态
|
||||||
|
docker inspect -f '{{.State.Health.Status}}' edu-mysql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 手动启动 Apollo Router
|
||||||
|
|
||||||
|
Apollo Router 通常由 `start-all.ps1` 自动启动。如需单独启动(例如调试 router 配置):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 前置:5 个 Federation 2 子图必须先在 host 上运行
|
||||||
|
# iam (3002) / core-edu (3004) / content (3005) / msg (3007) / config-service (3011)
|
||||||
|
# 验证子图:rover subgraph introspect http://localhost:3002/graphql
|
||||||
|
# 返回应包含 @key、@link、_service 字段
|
||||||
|
|
||||||
|
# 启动 Router 容器(dev 模式,子图在 host 上)
|
||||||
|
docker rm -f edu-apollo-router 2>&1 | Out-Null
|
||||||
|
docker run -d --name edu-apollo-router `
|
||||||
|
--add-host=host.docker.internal:host-gateway `
|
||||||
|
-p 3000:3000 -p 8088:8088 `
|
||||||
|
-v e:\Desktop\Edu\infra\apollo-router\dev-supergraph.yaml:/dist/supergraph.yaml `
|
||||||
|
-v e:\Desktop\Edu\infra\apollo-router\dev-entrypoint.sh:/dist/entrypoint.sh `
|
||||||
|
-v e:\Desktop\Edu\infra\apollo-router\router.yaml:/dist/configuration.yaml `
|
||||||
|
-e ROUTER_AUTH_SECRET=dev-router-secret `
|
||||||
|
-e APOLLO_ELV2_LICENSE=accept `
|
||||||
|
-e HTTP_PROXY=http://host.docker.internal:7897 `
|
||||||
|
-e HTTPS_PROXY=http://host.docker.internal:7897 `
|
||||||
|
-e NO_PROXY=localhost,127.0.0.1,host.docker.internal `
|
||||||
|
edu/apollo-router:dev `
|
||||||
|
/dist/entrypoint.sh
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
curl http://localhost:8088/health # {"status":"UP"}
|
||||||
|
curl -X POST http://localhost:3000/graphql `
|
||||||
|
-H "Content-Type: application/json" `
|
||||||
|
-d '{\"query\":\"{ __schema { queryType { fields { name } } } }\"}'
|
||||||
|
|
||||||
|
# 查看日志(supergraph compose 过程 + router 启动)
|
||||||
|
docker logs edu-apollo-router -f
|
||||||
|
```
|
||||||
|
|
||||||
|
> 首次启动需 `docker build -t edu/apollo-router:dev infra/apollo-router/` 构建镜像(包含 rover CLI 和预下载的 supergraph 插件)。`HTTP_PROXY` 用于容器内 rover 下载 supergraph 插件(如已预下载到镜像则可省略)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 环境变量
|
||||||
|
|
||||||
|
### 4.1 必需的 .env 变量
|
||||||
|
|
||||||
|
根目录 `.env` 文件必须包含以下变量:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 数据库
|
||||||
|
DATABASE_URL=mysql://edu:changeme@localhost:3306/next_edu_cloud
|
||||||
|
MYSQL_ROOT_PASSWORD=changeme
|
||||||
|
MYSQL_DATABASE=next_edu_cloud
|
||||||
|
MYSQL_USER=edu
|
||||||
|
MYSQL_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Kafka
|
||||||
|
KAFKA_BROKERS=localhost:9092
|
||||||
|
|
||||||
|
# JWT 密钥路径
|
||||||
|
IAM_PRIVATE_KEY_PATH=E:\Desktop\Edu\keys\iam-private.pem
|
||||||
|
IAM_PUBLIC_KEY_PATH=E:\Desktop\Edu\keys\iam-public.pem
|
||||||
|
|
||||||
|
# 开发模式(绕过 JWT 校验,接受 dev-token)
|
||||||
|
DEV_MODE=true
|
||||||
|
|
||||||
|
# Router 认证密钥(子图校验 Apollo Router 请求)
|
||||||
|
ROUTER_AUTH_SECRET=dev-router-secret
|
||||||
|
|
||||||
|
# Neo4j
|
||||||
|
NEO4J_PASSWORD=changeme
|
||||||
|
|
||||||
|
# 可观测性
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 开发模式(DEV_MODE=true)
|
||||||
|
|
||||||
|
- 绕过 JWT 校验
|
||||||
|
- 接受 `Authorization: Bearer dev-token` 作为有效令牌
|
||||||
|
- 预定义用户角色
|
||||||
|
|
||||||
|
### 4.3 env-loader 机制
|
||||||
|
|
||||||
|
NestJS 服务通过 `@edu/shared-ts/env-loader` 在启动时自动从 monorepo 根加载 `.env` 文件,解决 PowerShell → pnpm → nest 子进程环境变量丢失问题。所有 NestJS 服务的 `main.ts` 顶部已添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import "@edu/shared-ts/env-loader";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 前置准备
|
||||||
|
|
||||||
|
### 5.1 首次运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 安装依赖
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
|
# 2. 编译 shared-ts(其他服务依赖它)
|
||||||
|
pnpm --filter @edu/shared-ts build
|
||||||
|
|
||||||
|
# 3. 生成 JWT 密钥(如不存在)
|
||||||
|
mkdir keys
|
||||||
|
openssl genrsa -out keys/iam-private.pem 2048
|
||||||
|
openssl rsa -in keys/iam-private.pem -pubout -out keys/iam-public.pem
|
||||||
|
|
||||||
|
# 4. 配置 .env(从 .env.example 复制并修改)
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 工具链要求
|
||||||
|
|
||||||
|
| 工具 | 版本 | 用途 |
|
||||||
|
| ---------- | ------ | ---------------------------- |
|
||||||
|
| Node.js | 22+ | NestJS / Next.js |
|
||||||
|
| pnpm | 11+ | 包管理 |
|
||||||
|
| Go | 1.22+ | api-gateway / push-gateway |
|
||||||
|
| uv | latest | Python 服务(data-ana / ai) |
|
||||||
|
| Docker | 25+ | 基础设施容器 |
|
||||||
|
| PowerShell | 7+ | 启动脚本 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 访问入口
|
||||||
|
|
||||||
|
### 6.1 应用入口
|
||||||
|
|
||||||
|
| 入口 | URL | 说明 |
|
||||||
|
| ------------ | ----------------------------- | -------------------------- |
|
||||||
|
| Portal Shell | http://localhost:4010 | 统一前端 |
|
||||||
|
| API Gateway | http://localhost:8080 | REST API 网关 |
|
||||||
|
| IAM GraphQL | http://localhost:3002/graphql | IAM 子图(需 Router Auth) |
|
||||||
|
|
||||||
|
### 6.2 监控入口
|
||||||
|
|
||||||
|
| 入口 | URL | 登录 |
|
||||||
|
| ------------ | ---------------------- | ------------- |
|
||||||
|
| Grafana | http://localhost:3030 | admin / admin |
|
||||||
|
| Jaeger | http://localhost:16686 | 无需登录 |
|
||||||
|
| Prometheus | http://localhost:9090 | 无需登录 |
|
||||||
|
| Alertmanager | http://localhost:9093 | 无需登录 |
|
||||||
|
|
||||||
|
### 6.3 基础设施管理
|
||||||
|
|
||||||
|
| 服务 | URL / 端口 | 登录 |
|
||||||
|
| ------------- | --------------------- | ------------------ |
|
||||||
|
| MySQL | localhost:3306 | edu / changeme |
|
||||||
|
| Redis | localhost:6379 | 无密码 |
|
||||||
|
| Neo4j | http://localhost:7474 | neo4j / changeme |
|
||||||
|
| ClickHouse | http://localhost:8123 | default / (无密码) |
|
||||||
|
| Elasticsearch | http://localhost:9200 | 无需认证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 故障排查
|
||||||
|
|
||||||
|
### 7.1 常见问题
|
||||||
|
|
||||||
|
| 问题 | 解决方案 |
|
||||||
|
| ----------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| 端口被占用 | `.\scripts\stop-all.ps1 -KillByPort` 或 `start-all.ps1 -Force` |
|
||||||
|
| NestJS 环境变量缺失 | 确认 `.env` 存在且 `main.ts` 顶部有 `import "@edu/shared-ts/env-loader"` |
|
||||||
|
| shared-ts 找不到 | `pnpm --filter @edu/shared-ts build` |
|
||||||
|
| pnpm install 失败(lockfile) | `pnpm install --no-frozen-lockfile` |
|
||||||
|
| Docker 容器启动失败 | `docker logs <container>` 查看日志 |
|
||||||
|
| MySQL 连接失败 | 确认 `edu-mysql` 容器 healthy:`docker ps` |
|
||||||
|
| Kafka 连接失败 | 确认用 `localhost:9092`(OUTSIDE listener) |
|
||||||
|
| 前端首页超时 | Next.js dev 首次编译慢,等待 30 秒后重试 |
|
||||||
|
| GraphQL 子图返回 401 | 子图需 `Router-Authorization` header(ADR-036),非直接访问 |
|
||||||
|
|
||||||
|
### 7.2 日志查看
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Docker 容器日志
|
||||||
|
docker logs edu-mysql --tail 100 -f
|
||||||
|
docker logs edu-kafka --tail 100 -f
|
||||||
|
|
||||||
|
# 应用服务日志
|
||||||
|
# 查看 edu-app-* 命名的 PowerShell 窗口
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 重置环境
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 完全重置(删除所有数据)
|
||||||
|
.\scripts\stop-all.ps1 -KillByPort -IncludeDocker
|
||||||
|
cd infra
|
||||||
|
docker compose -f docker-compose.yml --profile p3 --profile p5 --profile observability down -v
|
||||||
|
cd ..
|
||||||
|
.\scripts\start-all.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Temporal 服务(可选)
|
||||||
|
|
||||||
|
Temporal 服务用于 AI 工作流引擎(ADR-030),当前未包含在一键启动脚本中。如需启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd infra
|
||||||
|
docker compose -f docker-compose.yml --profile p3 up -d temporal-postgresql temporal temporal-ui
|
||||||
|
# 注意:temporal auto-setup 镜像需调整 DB 环境变量为 postgres,当前配置有已知问题
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 架构约束
|
||||||
|
|
||||||
|
> 以下约束来自 project_rules.md 与 ADR,脚本已内置遵守。
|
||||||
|
|
||||||
|
- **混合部署**:基础设施 Docker + 应用本地,避免应用镜像构建失败
|
||||||
|
- **GraphQL 子图隔离**:子图仅接受 Apollo Router 请求(带 `Router-Authorization` header),不直接对外
|
||||||
|
- **gRPC 内部通信**:服务间通过 gRPC(50052-50059),不通过 GraphQL
|
||||||
|
- **CDC + Outbox**:业务代码写 outbox 表,Debezium 监听 binlog 投递 Kafka
|
||||||
|
- **DEV_MODE**:开发模式绕过 JWT 校验,仅限本地开发
|
||||||
@@ -728,7 +728,7 @@
|
|||||||
> v2.1 架构:单 Next.js App Router 容器 + Micro-kernel 插件系统,替代旧 4 端微前端。关联 spec `2026-07-14-portal-shell-widget-dashboard-design.md`。
|
> v2.1 架构:单 Next.js App Router 容器 + Micro-kernel 插件系统,替代旧 4 端微前端。关联 spec `2026-07-14-portal-shell-widget-dashboard-design.md`。
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`(shared-ts 不依赖 React),前端包引用时断言为 `React.ComponentType<PluginProps>` |
|
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`(shared-ts 不依赖 React),前端包引用时断言为 `React.ComponentType<PluginProps>` |
|
||||||
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下,widget 文件用 `interface XxxVars` 会报 TS2344;改用 `type XxxVars = {...}` 类型别名(type alias 满足 index signature,interface 不满足) |
|
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下,widget 文件用 `interface XxxVars` 会报 TS2344;改用 `type XxxVars = {...}` 类型别名(type alias 满足 index signature,interface 不满足) |
|
||||||
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`;hooks 包通过 `@edu/shared-ts/contracts` 引用;typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
|
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`;hooks 包通过 `@edu/shared-ts/contracts` 引用;typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
|
||||||
@@ -757,3 +757,4 @@
|
|||||||
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错;commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
|
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错;commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
|
||||||
| commitlint body-max-line-length | commit body 每行 ≤100 字符,Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
|
| commitlint body-max-line-length | commit body 每行 ≤100 字符,Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
|
||||||
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |
|
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |
|
||||||
|
| Turbopack 不支持 .js 后缀 import | Next 16 默认 Turbopack 无法像 webpack 那样通过 `resolve.extensionAlias` 将 `.js` 映射到 `.ts/.tsx`;`packages/ui-components` 和 `packages/hooks` 源码内部 import 必须去掉 `.js` 后缀(shared-ts 是 NestJS ESM 模式按规则 §3.4 保留 `.js` 后缀,不修改) |
|
||||||
|
|||||||
@@ -13,7 +13,29 @@ RUN apt-get update && \
|
|||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 安装 rover(Apollo CLI for supergraph composition)
|
# 安装 rover(Apollo CLI for supergraph composition)
|
||||||
RUN curl -sSL https://rover.apollo.dev/nix/v0.30.0 | sh -s -- --install /usr/local/bin
|
# 直接下载 release tarball 解压到 /usr/local/bin(避免 install 脚本参数兼容问题)
|
||||||
|
# tarball 内 rover 位于 dist/rover 子目录
|
||||||
|
RUN curl -sSL -o /tmp/rover.tar.gz https://github.com/apollographql/rover/releases/download/v0.30.0/rover-v0.30.0-x86_64-unknown-linux-gnu.tar.gz && \
|
||||||
|
mkdir -p /tmp/rover-extract && \
|
||||||
|
tar -xzf /tmp/rover.tar.gz -C /tmp/rover-extract && \
|
||||||
|
cp /tmp/rover-extract/dist/rover /usr/local/bin/rover && \
|
||||||
|
chmod +x /usr/local/bin/rover && \
|
||||||
|
rm -rf /tmp/rover.tar.gz /tmp/rover-extract && \
|
||||||
|
rover --version
|
||||||
|
|
||||||
|
# 预下载 supergraph 插件(federation-rs supergraph v2.9.0)
|
||||||
|
# 避免运行时因网络问题(如 GitHub 不可达)导致 rover compose 失败
|
||||||
|
# 插件会被缓存到 /root/.rover/bin/supergraph-v2.9.0
|
||||||
|
RUN APOLLO_ELV2_LICENSE=accept rover supergraph compose --config /dev/null --output /tmp/test-supergraph.graphql 2>&1 || \
|
||||||
|
(echo "[build] Pre-downloading supergraph plugin..." && \
|
||||||
|
curl -sSL -o /tmp/supergraph-plugin.tar.gz \
|
||||||
|
"https://github.com/apollographql/federation-rs/releases/download/supergraph%40v2.9.0/supergraph-v2.9.0-x86_64-unknown-linux-gnu.tar.gz" && \
|
||||||
|
mkdir -p /root/.rover/bin && \
|
||||||
|
tar -xzf /tmp/supergraph-plugin.tar.gz -C /root/.rover/bin/ && \
|
||||||
|
mv /root/.rover/bin/supergraph /root/.rover/bin/supergraph-v2.9.0 2>/dev/null || true && \
|
||||||
|
chmod +x /root/.rover/bin/supergraph-v2.9.0 && \
|
||||||
|
rm -f /tmp/supergraph-plugin.tar.gz /tmp/test-supergraph.graphql && \
|
||||||
|
echo "[build] Supergraph plugin pre-installed")
|
||||||
|
|
||||||
# 接受 ELv2 许可
|
# 接受 ELv2 许可
|
||||||
ENV APOLLO_ELV2_LICENSE=accept
|
ENV APOLLO_ELV2_LICENSE=accept
|
||||||
|
|||||||
48
infra/apollo-router/dev-entrypoint.sh
Normal file
48
infra/apollo-router/dev-entrypoint.sh
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Apollo Router 开发态启动脚本
|
||||||
|
#
|
||||||
|
# 与 entrypoint.sh 的差异:
|
||||||
|
# - 跳过子图等待(子图已在 host 上运行)
|
||||||
|
# - 使用 dev-supergraph.yaml(host.docker.internal)
|
||||||
|
# - 容器需 --add-host=host.docker.internal:host-gateway
|
||||||
|
#
|
||||||
|
# 使用:
|
||||||
|
# docker run -d --name edu-apollo-router \
|
||||||
|
# --add-host=host.docker.internal:host-gateway \
|
||||||
|
# -p 3000:3000 -p 8088:8088 \
|
||||||
|
# -v $(pwd)/dev-supergraph.yaml:/dist/supergraph.yaml \
|
||||||
|
# -v $(pwd)/dev-entrypoint.sh:/dist/entrypoint.sh \
|
||||||
|
# -v $(pwd)/router.yaml:/dist/configuration.yaml \
|
||||||
|
# -e ROUTER_AUTH_SECRET=dev-router-secret \
|
||||||
|
# -e APOLLO_ELV2_LICENSE=accept \
|
||||||
|
# ghcr.io/apollographql/router:v1.45.0 \
|
||||||
|
# /dist/entrypoint.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[apollo-router:dev] Dev mode - skipping subgraph wait (subgraphs run on host)"
|
||||||
|
|
||||||
|
# 组合 supergraph SDL
|
||||||
|
echo "[apollo-router:dev] Composing supergraph SDL..."
|
||||||
|
export APOLLO_ELV2_LICENSE=accept
|
||||||
|
|
||||||
|
max_compose_retries=5
|
||||||
|
compose_retry=0
|
||||||
|
while [ $compose_retry -lt $max_compose_retries ]; do
|
||||||
|
compose_retry=$((compose_retry + 1))
|
||||||
|
if rover supergraph compose --config /dist/supergraph.yaml --output /tmp/supergraph.graphql 2>&1; then
|
||||||
|
echo "[apollo-router:dev] Supergraph composed successfully"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "[apollo-router:dev] Compose attempt ${compose_retry}/${max_compose_retries} failed, retrying in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f /tmp/supergraph.graphql ]; then
|
||||||
|
echo "[apollo-router:dev] ERROR: Failed to compose supergraph after ${max_compose_retries} attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 router(router 二进制位于 /dist/router,由基础镜像 ghcr.io/apollographql/router 提供)
|
||||||
|
echo "[apollo-router:dev] Starting Apollo Router on port 3000..."
|
||||||
|
exec /dist/router --config /dist/configuration.yaml --supergraph /tmp/supergraph.graphql --hot-reload
|
||||||
48
infra/apollo-router/dev-supergraph.yaml
Normal file
48
infra/apollo-router/dev-supergraph.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Apollo Federation 2 Supergraph 组合配置(开发态 - host 模式)
|
||||||
|
#
|
||||||
|
# 用于本地开发:应用服务跑在 host 上,apollo-router 在 Docker 容器内,
|
||||||
|
# 通过 host.docker.internal 访问 host 上的子图 /graphql 端点。
|
||||||
|
#
|
||||||
|
# 使用:docker run --add-host=host.docker.internal:host-gateway ...
|
||||||
|
#
|
||||||
|
# 与 supergraph.yaml 的差异:routing_url 从容器内服务名改为 host.docker.internal
|
||||||
|
#
|
||||||
|
# 当前可用子图(5 个 TS 服务):
|
||||||
|
# - iam (3002): 用户/RBAC/JWT
|
||||||
|
# - core-edu (3004): 作业/考试/成绩/考勤
|
||||||
|
# - content (3005): 内容管理
|
||||||
|
# - msg (3007): 消息
|
||||||
|
# - config (3011): 插件配置中心(portal-shell 主要数据源)
|
||||||
|
#
|
||||||
|
# 暂不包含的子图:
|
||||||
|
# - classes (3001): 无 GraphQL 模块(仅 REST + gRPC)
|
||||||
|
# - data-ana (3006): Python 服务,无 Federation 子图
|
||||||
|
# - ai (3008): Python 服务,无 Federation 子图
|
||||||
|
|
||||||
|
federation_version: =2.9.0
|
||||||
|
|
||||||
|
subgraphs:
|
||||||
|
iam:
|
||||||
|
routing_url: http://host.docker.internal:3002/graphql
|
||||||
|
schema:
|
||||||
|
subgraph_url: http://host.docker.internal:3002/graphql
|
||||||
|
|
||||||
|
core-edu:
|
||||||
|
routing_url: http://host.docker.internal:3004/graphql
|
||||||
|
schema:
|
||||||
|
subgraph_url: http://host.docker.internal:3004/graphql
|
||||||
|
|
||||||
|
content:
|
||||||
|
routing_url: http://host.docker.internal:3005/graphql
|
||||||
|
schema:
|
||||||
|
subgraph_url: http://host.docker.internal:3005/graphql
|
||||||
|
|
||||||
|
msg:
|
||||||
|
routing_url: http://host.docker.internal:3007/graphql
|
||||||
|
schema:
|
||||||
|
subgraph_url: http://host.docker.internal:3007/graphql
|
||||||
|
|
||||||
|
config:
|
||||||
|
routing_url: http://host.docker.internal:3011/graphql
|
||||||
|
schema:
|
||||||
|
subgraph_url: http://host.docker.internal:3011/graphql
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"prettier": "^3.3.0",
|
"prettier": "^3.3.0",
|
||||||
|
|||||||
@@ -10,16 +10,17 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@edu/shared-ts": "workspace:*",
|
||||||
"@edu/ui-components": "workspace:*"
|
"@edu/ui-components": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0 || ^19.0.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0 || ^19.0.0",
|
||||||
"urql": "^2.2.0"
|
"urql": "^2.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,42 +20,39 @@
|
|||||||
* - token 存储使用 localStorage(F12 裁决,P2 阶段)
|
* - token 存储使用 localStorage(F12 裁决,P2 阶段)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { useAuth } from "./use-auth.js";
|
export { useAuth } from "./use-auth";
|
||||||
export type { UseAuthReturn } from "./use-auth.js";
|
export type { UseAuthReturn } from "./use-auth";
|
||||||
|
|
||||||
export { usePermission } from "./use-permission.js";
|
export { usePermission } from "./use-permission";
|
||||||
export type {
|
export type { UsePermissionProps, UsePermissionReturn } from "./use-permission";
|
||||||
UsePermissionProps,
|
|
||||||
UsePermissionReturn,
|
|
||||||
} from "./use-permission.js";
|
|
||||||
|
|
||||||
export { useViewports } from "./use-viewports.js";
|
export { useViewports } from "./use-viewports";
|
||||||
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports.js";
|
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports";
|
||||||
|
|
||||||
export { useApi } from "./use-api.js";
|
export { useApi } from "./use-api";
|
||||||
export type { UseApiProps, UseApiReturn } from "./use-api.js";
|
export type { UseApiProps, UseApiReturn } from "./use-api";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useA11yId,
|
useA11yId,
|
||||||
useA11yIds,
|
useA11yIds,
|
||||||
mergeA11yProps,
|
mergeA11yProps,
|
||||||
describeInput,
|
describeInput,
|
||||||
} from "./use-a11y-id.js";
|
} from "./use-a11y-id";
|
||||||
|
|
||||||
export { useAriaLive } from "./use-aria-live.js";
|
export { useAriaLive } from "./use-aria-live";
|
||||||
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live.js";
|
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live";
|
||||||
|
|
||||||
export { useToast } from "./use-toast.js";
|
export { useToast } from "./use-toast";
|
||||||
export type { UseToastReturn } from "./use-toast.js";
|
export type { UseToastReturn } from "./use-toast";
|
||||||
|
|
||||||
export { useTraceId } from "./use-trace-id.js";
|
export { useTraceId } from "./use-trace-id";
|
||||||
export type { UseTraceIdReturn } from "./use-trace-id.js";
|
export type { UseTraceIdReturn } from "./use-trace-id";
|
||||||
|
|
||||||
export {
|
export { useErrorReport } from "./use-error-report";
|
||||||
useGraphQLClient,
|
export type { ErrorReportPayload } from "./use-error-report";
|
||||||
GraphQLClientContext,
|
|
||||||
} from "./use-graphql-client.js";
|
export { useGraphQLClient, GraphQLClientContext } from "./use-graphql-client";
|
||||||
export type { UseGraphQLClientReturn } from "./use-graphql-client.js";
|
export type { UseGraphQLClientReturn } from "./use-graphql-client";
|
||||||
|
|
||||||
// 共享类型
|
// 共享类型
|
||||||
export type {
|
export type {
|
||||||
@@ -65,4 +62,14 @@ export type {
|
|||||||
Viewport,
|
Viewport,
|
||||||
ToastMessage,
|
ToastMessage,
|
||||||
AuthState,
|
AuthState,
|
||||||
} from "./types.js";
|
} from "./types";
|
||||||
|
|
||||||
|
// portal-shell 插件系统 Hooks(v2.1 spec §9.3)
|
||||||
|
export { usePluginStore, injectPluginStore } from "./use-plugin-store";
|
||||||
|
export type { PluginStoreInstance } from "./use-plugin-store";
|
||||||
|
|
||||||
|
export { usePluginConfig } from "./use-plugin-config";
|
||||||
|
export type {
|
||||||
|
UsePluginConfigOptions,
|
||||||
|
UsePluginConfigReturn,
|
||||||
|
} from "./use-plugin-config";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { AuthState, UserSession } from "./types.js";
|
import type { AuthState, UserSession } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useAuth - 会话状态管理
|
* useAuth - 会话状态管理
|
||||||
|
|||||||
190
packages/hooks/src/use-error-report.ts
Normal file
190
packages/hooks/src/use-error-report.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useErrorReport - 客户端错误上报 Hook(对齐 CICD use-error-report.ts)
|
||||||
|
*
|
||||||
|
* 通过 navigator.sendBeacon 上报到 /api/log(fallback 到 fetch keepalive)
|
||||||
|
* 节流:基于 error.digest 在 sessionStorage 中记录,1 分钟内同 digest 只上报一次
|
||||||
|
* 上报失败静默降级,不影响用户体验
|
||||||
|
*
|
||||||
|
* 上报 payload 结构:
|
||||||
|
* {
|
||||||
|
* level: "error" | "warning",
|
||||||
|
* message: string,
|
||||||
|
* stack?: string,
|
||||||
|
* digest?: string, // Next.js 自动生成的错误摘要
|
||||||
|
* path: string, // window.location.pathname
|
||||||
|
* userAgent: string,
|
||||||
|
* timestamp: string, // ISO 8601
|
||||||
|
* pluginId?: string, // 插件级错误标识
|
||||||
|
* userId?: string, // 当前用户 ID(从 localStorage 读取)
|
||||||
|
* context?: Record<string, unknown> // 额外上下文
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 错误上报 payload */
|
||||||
|
export interface ErrorReportPayload {
|
||||||
|
level: "error" | "warning";
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
digest?: string;
|
||||||
|
path: string;
|
||||||
|
userAgent: string;
|
||||||
|
timestamp: string;
|
||||||
|
pluginId?: string;
|
||||||
|
userId?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上报端点(Next.js API Route mock,未来切换到 OTel / Sentry) */
|
||||||
|
const REPORT_ENDPOINT = "/api/log";
|
||||||
|
|
||||||
|
/** 节流窗口(1 分钟内同 digest 只上报一次) */
|
||||||
|
const THROTTLE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
/** sessionStorage key 前缀 */
|
||||||
|
const THROTTLE_KEY_PREFIX = "edu_err_reported_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前用户 ID(从 localStorage,避免引入 auth 依赖)
|
||||||
|
*/
|
||||||
|
function readUserId(): string | undefined {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("edu_user_id");
|
||||||
|
return raw ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成错误 digest(简单 hash,用于节流去重)
|
||||||
|
*
|
||||||
|
* 优先使用 error.digest(Next.js 自动生成),
|
||||||
|
* 否则基于 message + stack 前 200 字符生成简单 hash
|
||||||
|
*/
|
||||||
|
function makeDigest(error: Error): string {
|
||||||
|
const nextDigest = (error as Error & { digest?: string }).digest;
|
||||||
|
if (nextDigest) return nextDigest;
|
||||||
|
const stackSnippet = (error.stack ?? "").slice(0, 200);
|
||||||
|
const input = `${error.message}::${stackSnippet}`;
|
||||||
|
// 简单 FNV-1a hash
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并更新节流记录
|
||||||
|
*
|
||||||
|
* @returns true 表示应该上报,false 表示已被节流
|
||||||
|
*/
|
||||||
|
function checkThrottle(digest: string): boolean {
|
||||||
|
try {
|
||||||
|
const key = `${THROTTLE_KEY_PREFIX}${digest}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = sessionStorage.getItem(key);
|
||||||
|
if (last) {
|
||||||
|
const lastTime = parseInt(last, 10);
|
||||||
|
if (Number.isFinite(lastTime) && now - lastTime < THROTTLE_WINDOW_MS) {
|
||||||
|
return false; // 节流窗口内,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(key, String(now));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// sessionStorage 不可用时不过节流,直接上报
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际执行上报
|
||||||
|
*/
|
||||||
|
function sendReport(payload: ErrorReportPayload): void {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
|
// 优先 sendBeacon(不阻塞页面卸载)
|
||||||
|
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([body], { type: "application/json" });
|
||||||
|
if (navigator.sendBeacon(REPORT_ENDPOINT, blob)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sendBeacon 失败,降级到 fetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到 fetch keepalive
|
||||||
|
try {
|
||||||
|
void fetch(REPORT_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
keepalive: true,
|
||||||
|
credentials: "include",
|
||||||
|
}).catch(() => {
|
||||||
|
// 上报失败静默降级
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 完全失败,静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误上报 Hook
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* function MyComponent() {
|
||||||
|
* const reportError = useErrorReport();
|
||||||
|
* try { riskyOperation(); }
|
||||||
|
* catch (e) { reportError(e, { pluginId: "grades-widget" }); }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example 与 ErrorBoundary 配合
|
||||||
|
* <ErrorBoundary onError={(err) => reportError(err)}>
|
||||||
|
* <Plugin />
|
||||||
|
* </ErrorBoundary>
|
||||||
|
*/
|
||||||
|
export function useErrorReport() {
|
||||||
|
const reportError = useCallback(
|
||||||
|
(
|
||||||
|
error: Error,
|
||||||
|
options?: {
|
||||||
|
pluginId?: string;
|
||||||
|
level?: "error" | "warning";
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): void => {
|
||||||
|
const digest = makeDigest(error);
|
||||||
|
if (!checkThrottle(digest)) return;
|
||||||
|
|
||||||
|
const payload: ErrorReportPayload = {
|
||||||
|
level: options?.level ?? "error",
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
digest,
|
||||||
|
path: typeof window !== "undefined" ? window.location.pathname : "/",
|
||||||
|
userAgent:
|
||||||
|
typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
pluginId: options?.pluginId,
|
||||||
|
userId: readUserId(),
|
||||||
|
context: options?.context,
|
||||||
|
};
|
||||||
|
|
||||||
|
sendReport(payload);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return reportError;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import type { PermissionContext } from "./types.js";
|
import type { PermissionContext } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* usePermission - 权限查询 Hook
|
* usePermission - 权限查询 Hook
|
||||||
|
|||||||
133
packages/hooks/src/use-plugin-config.ts
Normal file
133
packages/hooks/src/use-plugin-config.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePluginConfig - 插件配置静默刷新 Hook(portal-shell spec §6.4)
|
||||||
|
*
|
||||||
|
* 通用封装,不直接依赖 SWR 或 Apollo Client。
|
||||||
|
* fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 的设计原则。
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 支持 fallbackData(RSC 预取的 initialData)
|
||||||
|
* - 支持轮询刷新(refreshInterval)
|
||||||
|
* - 支持配置变化回调(onChanged)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell spec §6.4、§9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UsePluginConfigOptions {
|
||||||
|
/** RSC 直出的初始配置(fallbackData) */
|
||||||
|
initialConfig: PluginConfigResponse;
|
||||||
|
/** 当前用户 ID */
|
||||||
|
userId: string;
|
||||||
|
/** 当前用户角色 */
|
||||||
|
role: string;
|
||||||
|
/** 配置变化回调(上层用于 Toast 提示) */
|
||||||
|
onChanged?: () => void;
|
||||||
|
/** 刷新间隔(ms),默认 5 分钟 */
|
||||||
|
refreshInterval?: number;
|
||||||
|
/** fetcher 函数(由消费者注入) */
|
||||||
|
fetcher: (userId: string, role: string) => Promise<PluginConfigResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePluginConfigReturn {
|
||||||
|
/** 当前配置 */
|
||||||
|
config: PluginConfigResponse;
|
||||||
|
/** 手动刷新 */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** 是否正在刷新 */
|
||||||
|
isValidating: boolean;
|
||||||
|
/** 刷新错误 */
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默刷新插件配置。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { config, refresh } = usePluginConfig({
|
||||||
|
* initialConfig,
|
||||||
|
* userId,
|
||||||
|
* role,
|
||||||
|
* fetcher: async (uid, r) => {
|
||||||
|
* const client = getApolloClient();
|
||||||
|
* const { data } = await client.query({ query: GET_PLUGIN_CONFIG, variables: { userId: uid, role: r } });
|
||||||
|
* return data.pluginConfig;
|
||||||
|
* },
|
||||||
|
* onChanged: () => showToast("配置已更新"),
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function usePluginConfig(
|
||||||
|
options: UsePluginConfigOptions,
|
||||||
|
): UsePluginConfigReturn {
|
||||||
|
const {
|
||||||
|
initialConfig,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
onChanged,
|
||||||
|
fetcher,
|
||||||
|
refreshInterval = 300_000,
|
||||||
|
} = options;
|
||||||
|
const [config, setConfig] = useState<PluginConfigResponse>(initialConfig);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const doRefresh = async (): Promise<void> => {
|
||||||
|
setIsValidating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const newConfig = await fetcher(userId, role);
|
||||||
|
if (hasConfigChanged(config, newConfig)) {
|
||||||
|
setConfig(newConfig);
|
||||||
|
onChanged?.();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 轮询刷新 + 网络恢复刷新
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(doRefresh, refreshInterval);
|
||||||
|
const handleOnline = (): void => {
|
||||||
|
void doRefresh();
|
||||||
|
};
|
||||||
|
const handleVisibility = (): void => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void doRefresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
|
};
|
||||||
|
}, [refreshInterval, userId, role]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
refresh: doRefresh,
|
||||||
|
isValidating,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浅比较配置是否变化(layoutId / 插件集合 / 可见性) */
|
||||||
|
function hasConfigChanged(
|
||||||
|
prev: PluginConfigResponse,
|
||||||
|
next: PluginConfigResponse,
|
||||||
|
): boolean {
|
||||||
|
if (prev.activeLayout?.layoutId !== next.activeLayout?.layoutId) return true;
|
||||||
|
if (prev.plugins.length !== next.plugins.length) return true;
|
||||||
|
const prevIds = prev.plugins
|
||||||
|
.map((p) => `${p.pluginId}:${p.isVisible}`)
|
||||||
|
.sort();
|
||||||
|
const nextIds = next.plugins
|
||||||
|
.map((p) => `${p.pluginId}:${p.isVisible}`)
|
||||||
|
.sort();
|
||||||
|
return prevIds.some((id, i) => id !== nextIds[i]);
|
||||||
|
}
|
||||||
80
packages/hooks/src/use-plugin-store.ts
Normal file
80
packages/hooks/src/use-plugin-store.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import type {
|
||||||
|
PluginStoreState,
|
||||||
|
ThemeMode,
|
||||||
|
Locale,
|
||||||
|
} from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePluginStore - Zustand 全局状态 Hook(portal-shell spec §5.2.2)
|
||||||
|
*
|
||||||
|
* 封装 Zustand store 的订阅,提供 theme/locale/sidebarCollapsed 状态管理。
|
||||||
|
* 此 hook 是通用封装,实际 store 实例由 portal-shell 创建并注入。
|
||||||
|
*
|
||||||
|
* 设计原则(@edu/hooks):hooks 不直接依赖特定 store 实例,
|
||||||
|
* 通过 subscribe/getSnapshot 与外部 store 交互。
|
||||||
|
*
|
||||||
|
* 关联:portal-shell spec §5.2.2、§9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Store 实例接口(与 Zustand create() 返回值兼容) */
|
||||||
|
export interface PluginStoreInstance extends PluginStoreState {
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
getState: () => PluginStoreState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局 store 引用(由 portal-shell 注入) */
|
||||||
|
let globalStore: PluginStoreInstance | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入全局 PluginStore 实例(portal-shell 启动时调用)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { usePluginStore as originalStore } from "@/shell/PluginStore";
|
||||||
|
* injectPluginStore(originalStore as PluginStoreInstance);
|
||||||
|
*/
|
||||||
|
export function injectPluginStore(store: PluginStoreInstance): void {
|
||||||
|
globalStore = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 PluginStore 全局状态(theme/locale/sidebarCollapsed)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { theme, setTheme } = usePluginStore();
|
||||||
|
*/
|
||||||
|
export function usePluginStore(): PluginStoreState {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
if (!globalStore) return () => {};
|
||||||
|
return globalStore.subscribe(listener);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!globalStore) {
|
||||||
|
return DEFAULT_STATE;
|
||||||
|
}
|
||||||
|
return globalStore.getState();
|
||||||
|
},
|
||||||
|
() => DEFAULT_STATE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认状态(store 未注入时使用) */
|
||||||
|
const DEFAULT_STATE: PluginStoreState = {
|
||||||
|
theme: "light",
|
||||||
|
setTheme: (_theme: ThemeMode) => {
|
||||||
|
// store 未注入时的空操作
|
||||||
|
},
|
||||||
|
locale: "zh-CN",
|
||||||
|
setLocale: (_locale: Locale) => {
|
||||||
|
// store 未注入时的空操作
|
||||||
|
},
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
toggleSidebar: () => {
|
||||||
|
// store 未注入时的空操作
|
||||||
|
},
|
||||||
|
unreadNotificationIds: [],
|
||||||
|
markNotificationsRead: (_ids: string[]) => {
|
||||||
|
// store 未注入时的空操作
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { ToastMessage } from "./types.js";
|
import type { ToastMessage } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useToast - 全局 Toast 通知管理
|
* useToast - 全局 Toast 通知管理
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { Viewport } from "./types.js";
|
import type { Viewport } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useViewports - 视口列表查询
|
* useViewports - 视口列表查询
|
||||||
|
|||||||
@@ -15,6 +15,18 @@
|
|||||||
"./federation": {
|
"./federation": {
|
||||||
"types": "./dist/federation/index.d.ts",
|
"types": "./dist/federation/index.d.ts",
|
||||||
"default": "./dist/federation/index.js"
|
"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": {
|
"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);
|
||||||
|
}
|
||||||
@@ -10,15 +10,17 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edu/ui-tokens": "workspace:*"
|
"@edu/ui-tokens": "workspace:*",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0 || ^19.0.0",
|
||||||
"react-dom": "^18.3.0"
|
"react-dom": "^18.3.0 || ^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,14 +123,7 @@ export function Calendar({
|
|||||||
const today = formatDate(new Date());
|
const today = formatDate(new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full rounded-xl border bg-card p-4">
|
||||||
className="w-full"
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-surface)",
|
|
||||||
borderRadius: "var(--radius-card)",
|
|
||||||
padding: "var(--space-md)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 月份导航 */}
|
{/* 月份导航 */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
@@ -197,14 +190,14 @@ export function Calendar({
|
|||||||
type="button"
|
type="button"
|
||||||
key={`day-${dateStr}`}
|
key={`day-${dateStr}`}
|
||||||
onClick={() => onDateClick?.(cell.date!)}
|
onClick={() => onDateClick?.(cell.date!)}
|
||||||
className="min-h-[60px] p-1 text-left transition-colors hover:bg-[var(--bg-subtle)]"
|
className="min-h-[60px] p-1 text-left transition-colors hover:bg-muted"
|
||||||
style={{
|
style={{
|
||||||
background: isToday
|
background: isToday
|
||||||
? "var(--color-accent-subtle)"
|
? "hsl(var(--primary) / 0.1)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
borderRadius: "var(--radius-default)",
|
borderRadius: "var(--radius)",
|
||||||
border: isToday
|
border: isToday
|
||||||
? "1px solid var(--color-accent)"
|
? "1px solid hsl(var(--primary))"
|
||||||
: "1px solid transparent",
|
: "1px solid transparent",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
@@ -213,8 +206,8 @@ export function Calendar({
|
|||||||
className="text-xs mb-1"
|
className="text-xs mb-1"
|
||||||
style={{
|
style={{
|
||||||
color: isToday
|
color: isToday
|
||||||
? "var(--color-accent)"
|
? "hsl(var(--primary))"
|
||||||
: "var(--color-ink-muted)",
|
: "hsl(var(--muted-foreground))",
|
||||||
fontWeight: isToday ? "600" : "400",
|
fontWeight: isToday ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ function PieChart({
|
|||||||
key={`slice-${i}`}
|
key={`slice-${i}`}
|
||||||
d={s.d}
|
d={s.d}
|
||||||
fill={s.color}
|
fill={s.color}
|
||||||
stroke="var(--bg-paper)"
|
stroke="hsl(var(--background))"
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
import { Loading } from "./loading.js";
|
import { Loading } from "./loading";
|
||||||
import { Empty } from "./empty.js";
|
import { Empty } from "./empty";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataTable - 通用数据表格
|
* DataTable - 通用数据表格
|
||||||
@@ -77,21 +77,16 @@ export function DataTable<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("overflow-x-auto rounded-xl border", className)}>
|
||||||
className={cn(
|
|
||||||
"overflow-x-auto border border-rule rounded-card",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-subtle">
|
<thead className="bg-muted">
|
||||||
<tr className="border-b border-rule">
|
<tr className="border-b">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
style={col.width ? { width: col.width } : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted font-medium",
|
"py-2 px-3 text-xs uppercase tracking-wide text-muted-foreground font-medium",
|
||||||
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -108,15 +103,15 @@ export function DataTable<T>({
|
|||||||
key={key}
|
key={key}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-rule",
|
"border-b",
|
||||||
onRowClick && "cursor-pointer hover:bg-subtle",
|
onRowClick && "cursor-pointer hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"py-2 px-3 text-ink",
|
"py-2 px-3 text-foreground",
|
||||||
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
||||||
col.className,
|
col.className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty - 空态展示(插画占位 + 文案 + CTA)
|
* Empty - 空态展示(插画占位 + 文案 + CTA)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FilterBar - 通用筛选栏
|
* FilterBar - 通用筛选栏
|
||||||
@@ -42,7 +42,7 @@ export function FilterBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
className="text-xs uppercase tracking-wide text-muted-foreground hover:opacity-70"
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</button>
|
</button>
|
||||||
@@ -51,7 +51,7 @@ export function FilterBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onApply}
|
onClick={onApply}
|
||||||
className="px-4 py-1.5 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
className="rounded-md bg-primary px-4 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
应用
|
应用
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type ReactNode, type FormEvent, type ChangeEvent } from "react";
|
import { type ReactNode, type FormEvent, type ChangeEvent } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form - 轻量级表单(不依赖 react-hook-form)
|
* Form - 轻量级表单(不依赖 react-hook-form)
|
||||||
@@ -68,13 +68,13 @@ export function FormField({
|
|||||||
<div className={className}>
|
<div className={className}>
|
||||||
<label
|
<label
|
||||||
htmlFor={name}
|
htmlFor={name}
|
||||||
className="mb-1 block text-tiny uppercase tracking-wide text-ink-muted"
|
className="mb-1 block text-xs uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-danger"> *</span>}
|
{required && <span className="text-destructive"> *</span>}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
{error && <p className="mt-1 text-tiny text-danger">{error}</p>}
|
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ export function FormInput({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
"w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground focus:outline-none disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -157,7 +157,7 @@ export function FormSelect({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
"w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground focus:outline-none disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -205,7 +205,7 @@ export function FormTextarea({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
"w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground focus:outline-none disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -234,7 +234,7 @@ export function FormCheckbox({
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm text-ink",
|
"flex items-center gap-2 text-sm text-foreground",
|
||||||
disabled && "opacity-50",
|
disabled && "opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -249,7 +249,7 @@ export function FormCheckbox({
|
|||||||
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)
|
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className="rounded border-rule"
|
className="rounded border"
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
@@ -276,7 +276,7 @@ export function SubmitButton({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-button bg-accent px-4 py-2 text-sm text-ink-on-accent hover:bg-accent-hover disabled:opacity-50",
|
"rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -26,32 +26,29 @@
|
|||||||
* - RichTextEditor 升级为 Tiptap 封装
|
* - RichTextEditor 升级为 Tiptap 封装
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ErrorBoundary } from "./error-boundary.js";
|
export { ErrorBoundary } from "./error-boundary";
|
||||||
export type {
|
export type { ErrorBoundaryProps, ErrorBoundaryState } from "./error-boundary";
|
||||||
ErrorBoundaryProps,
|
|
||||||
ErrorBoundaryState,
|
|
||||||
} from "./error-boundary.js";
|
|
||||||
|
|
||||||
export { Loading } from "./loading.js";
|
export { Loading } from "./loading";
|
||||||
export type { LoadingProps } from "./loading.js";
|
export type { LoadingProps } from "./loading";
|
||||||
|
|
||||||
export { Empty } from "./empty.js";
|
export { Empty } from "./empty";
|
||||||
export type { EmptyProps } from "./empty.js";
|
export type { EmptyProps } from "./empty";
|
||||||
|
|
||||||
export { RequirePermission } from "./require-permission.js";
|
export { RequirePermission } from "./require-permission";
|
||||||
export type { RequirePermissionProps } from "./require-permission.js";
|
export type { RequirePermissionProps } from "./require-permission";
|
||||||
|
|
||||||
export { DataTable } from "./data-table.js";
|
export { DataTable } from "./data-table";
|
||||||
export type { Column, ColumnAlign, DataTableProps } from "./data-table.js";
|
export type { Column, ColumnAlign, DataTableProps } from "./data-table";
|
||||||
|
|
||||||
export { FilterBar } from "./filter-bar.js";
|
export { FilterBar } from "./filter-bar";
|
||||||
export type { FilterBarProps } from "./filter-bar.js";
|
export type { FilterBarProps } from "./filter-bar";
|
||||||
|
|
||||||
export { StatusBadge } from "./status-badge.js";
|
export { StatusBadge } from "./status-badge";
|
||||||
export type { StatusBadgeProps, StatusVariant } from "./status-badge.js";
|
export type { StatusBadgeProps, StatusVariant } from "./status-badge";
|
||||||
|
|
||||||
export { Modal } from "./modal.js";
|
export { Modal } from "./modal";
|
||||||
export type { ModalProps, ModalSize } from "./modal.js";
|
export type { ModalProps, ModalSize } from "./modal";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Form,
|
Form,
|
||||||
@@ -61,7 +58,7 @@ export {
|
|||||||
FormTextarea,
|
FormTextarea,
|
||||||
FormCheckbox,
|
FormCheckbox,
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
} from "./form.js";
|
} from "./form";
|
||||||
export type {
|
export type {
|
||||||
FormProps,
|
FormProps,
|
||||||
FormFieldProps,
|
FormFieldProps,
|
||||||
@@ -71,15 +68,34 @@ export type {
|
|||||||
FormCheckboxProps,
|
FormCheckboxProps,
|
||||||
SubmitButtonProps,
|
SubmitButtonProps,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
} from "./form.js";
|
} from "./form";
|
||||||
|
|
||||||
export { cn } from "./utils/cn.js";
|
export { cn } from "./utils/cn";
|
||||||
|
|
||||||
export { Chart } from "./chart.js";
|
export { Chart } from "./chart";
|
||||||
export type { ChartProps, ChartType, ChartDataPoint } from "./chart.js";
|
export type { ChartProps, ChartType, ChartDataPoint } from "./chart";
|
||||||
|
|
||||||
export { Calendar } from "./calendar.js";
|
export { Calendar } from "./calendar";
|
||||||
export type { CalendarProps, CalendarEvent } from "./calendar.js";
|
export type { CalendarProps, CalendarEvent } from "./calendar";
|
||||||
|
|
||||||
export { RichTextEditor } from "./rich-text-editor.js";
|
export { RichTextEditor } from "./rich-text-editor";
|
||||||
export type { RichTextEditorProps } from "./rich-text-editor.js";
|
export type { RichTextEditorProps } from "./rich-text-editor";
|
||||||
|
|
||||||
|
// portal-shell 插件系统组件(v2.1 spec §7.3)
|
||||||
|
export { PluginCard } from "./plugin-card";
|
||||||
|
export type { PluginCardProps } from "./plugin-card";
|
||||||
|
|
||||||
|
export { PluginSkeleton } from "./plugin-skeleton";
|
||||||
|
export type { PluginSkeletonProps, SkeletonVariant } from "./plugin-skeleton";
|
||||||
|
|
||||||
|
export { PluginErrorFallback } from "./plugin-error-fallback";
|
||||||
|
export type { PluginErrorFallbackProps } from "./plugin-error-fallback";
|
||||||
|
|
||||||
|
export { SlotPlaceholder } from "./slot-placeholder";
|
||||||
|
export type { SlotPlaceholderProps } from "./slot-placeholder";
|
||||||
|
|
||||||
|
export { PropsConfigForm } from "./props-config-form";
|
||||||
|
export type {
|
||||||
|
PropsConfigFormProps,
|
||||||
|
PropsJsonSchema,
|
||||||
|
} from "./props-config-form";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading - 骨架屏 / 加载占位
|
* Loading - 骨架屏 / 加载占位
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal - 模态对话框
|
* Modal - 模态对话框
|
||||||
@@ -72,23 +72,23 @@ export function Modal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[90vh] w-full overflow-y-auto rounded-card border border-rule bg-paper p-6 shadow-xl",
|
"max-h-[90vh] w-full overflow-y-auto rounded-xl border bg-background p-6 shadow-xl",
|
||||||
SIZE_CLASS[size],
|
SIZE_CLASS[size],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-lg font-serif text-ink">{title}</h2>
|
<h2 className="text-lg font-serif text-foreground">{title}</h2>
|
||||||
<div className="rule-thin mb-4 mt-2" />
|
<div className="mb-4 mt-2 border-t" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="text-ink">{children}</div>
|
<div className="text-foreground">{children}</div>
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="mt-6 flex items-center justify-end gap-3">
|
<div className="mt-6 flex items-center justify-end gap-3">
|
||||||
{footer}
|
{footer}
|
||||||
|
|||||||
75
packages/ui-components/src/plugin-card.tsx
Normal file
75
packages/ui-components/src/plugin-card.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginCard - 插件卡片容器(portal-shell spec §7.3)
|
||||||
|
*
|
||||||
|
* 所有插件内容应使用 PluginCard 包裹,强制设计令牌一致性:
|
||||||
|
* - 卡片背景 bg-card(v2.0 之前 paper/surface 双变体,v2.0 统一为 bg-card)
|
||||||
|
* - 圆角 rounded-xl
|
||||||
|
* - 内边距 p-4(可通过 className 覆盖)
|
||||||
|
* - 边框 border(默认 border 颜色)
|
||||||
|
*
|
||||||
|
* v2.0 令牌迁移:shadcn 标准令牌
|
||||||
|
* - rounded-card → rounded-xl
|
||||||
|
* - border-rule → border
|
||||||
|
* - bg-paper/bg-surface → bg-card(统一)
|
||||||
|
* - p-md → p-4
|
||||||
|
* - mb-md → mb-4
|
||||||
|
* - text-heading-3 → text-lg
|
||||||
|
* - text-ink → text-foreground
|
||||||
|
* - gap-sm → gap-2
|
||||||
|
* - variant 属性保留但仅作语义标识,样式统一为 bg-card
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PluginCard title="成绩">
|
||||||
|
* <GradesTable />
|
||||||
|
* </PluginCard>
|
||||||
|
*/
|
||||||
|
export interface PluginCardProps {
|
||||||
|
/** 卡片标题(显示在顶部) */
|
||||||
|
title?: string;
|
||||||
|
/** 标题右侧的操作区(如刷新按钮、筛选按钮) */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** 卡片内容 */
|
||||||
|
children: ReactNode;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 内容区类名 */
|
||||||
|
contentClassName?: string;
|
||||||
|
/**
|
||||||
|
* 卡片变体(v2.0 仅作语义标识,样式统一为 bg-card)
|
||||||
|
* - surface:用于 side/top 区(保持兼容)
|
||||||
|
* - paper:用于 main 区(保持兼容)
|
||||||
|
*/
|
||||||
|
variant?: "surface" | "paper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginCard({
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
variant = "surface",
|
||||||
|
}: PluginCardProps): ReactNode {
|
||||||
|
// v2.0:统一为 bg-card,variant 仅作语义标识(向后兼容)
|
||||||
|
void variant;
|
||||||
|
return (
|
||||||
|
<section className={cn("rounded-xl border bg-card p-4", className)}>
|
||||||
|
{title || actions ? (
|
||||||
|
<header className="mb-4 flex items-center justify-between">
|
||||||
|
{title ? (
|
||||||
|
<h3 className="text-lg text-foreground">{title}</h3>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex items-center gap-2">{actions}</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className={cn(contentClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
packages/ui-components/src/plugin-error-fallback.tsx
Normal file
62
packages/ui-components/src/plugin-error-fallback.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginErrorFallback - 插件错误兜底组件(portal-shell spec §5.3、§7.3)
|
||||||
|
*
|
||||||
|
* 插件加载失败或渲染异常时显示此组件,居中显示错误信息 + 重试按钮。
|
||||||
|
* ErrorBoundary 隔离单个插件错误,不影响其他插件。
|
||||||
|
*
|
||||||
|
* v2.0 令牌迁移:shadcn 标准令牌
|
||||||
|
* - rounded-card → rounded-xl
|
||||||
|
* - border-rule → border(默认 border 颜色)
|
||||||
|
* - bg-surface → bg-card
|
||||||
|
* - text-ink-muted → text-muted-foreground
|
||||||
|
* - bg-accent → bg-primary
|
||||||
|
* - text-ink-onAccent → text-primary-foreground
|
||||||
|
* - rounded-button → rounded-md
|
||||||
|
* - p-md → p-4, px-md → px-4, py-xs → py-1
|
||||||
|
* - text-small → text-sm
|
||||||
|
* - mt-sm → mt-2
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PluginErrorFallback instanceId="grades-widget-main-1" onRetry={() => refetch()} />
|
||||||
|
*/
|
||||||
|
export interface PluginErrorFallbackProps {
|
||||||
|
/** 插件实例 ID */
|
||||||
|
instanceId: string;
|
||||||
|
/** 错误信息(可选,默认显示通用提示) */
|
||||||
|
message?: string;
|
||||||
|
/** 重试回调(不提供则不显示重试按钮) */
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginErrorFallback({
|
||||||
|
instanceId,
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
className,
|
||||||
|
}: PluginErrorFallbackProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card p-4 text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm">{message ?? `插件加载失败(${instanceId})`}</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-2 rounded-md bg-primary px-4 py-1 text-sm text-primary-foreground"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
packages/ui-components/src/plugin-skeleton.tsx
Normal file
138
packages/ui-components/src/plugin-skeleton.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginSkeleton - 插件骨架屏(portal-shell spec §7.3)
|
||||||
|
*
|
||||||
|
* 5 种 skeleton 变体,对应不同插件类型:
|
||||||
|
* - card:通用卡片骨架(标题 + 内容块)
|
||||||
|
* - list:列表骨架(多行)
|
||||||
|
* - chart:图表骨架(坐标轴 + 柱状)
|
||||||
|
* - stats:统计数据骨架(大数字 + 标签)
|
||||||
|
* - table:表格骨架(表头 + 多行)
|
||||||
|
*
|
||||||
|
* v2.0 令牌迁移:shadcn 标准令牌
|
||||||
|
* - rounded-card → rounded-xl
|
||||||
|
* - bg-surface → bg-card
|
||||||
|
* - bg-subtle → bg-muted
|
||||||
|
* - p-md → p-4
|
||||||
|
* - mb-md → mb-4
|
||||||
|
* - h-heading-3 → h-6
|
||||||
|
* - h-body → h-4
|
||||||
|
* - h-large-number → h-8
|
||||||
|
* - h-tiny → h-3
|
||||||
|
* - rounded-button → rounded-md
|
||||||
|
* - rounded-t-button → rounded-t-md
|
||||||
|
* - gap-sm → gap-2
|
||||||
|
* - gap-md → gap-4
|
||||||
|
* - space-y-sm → space-y-2
|
||||||
|
* - mt-xs → mt-1
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PluginSkeleton variant="table" />
|
||||||
|
*/
|
||||||
|
export type SkeletonVariant = "card" | "list" | "chart" | "stats" | "table";
|
||||||
|
|
||||||
|
export interface PluginSkeletonProps {
|
||||||
|
/** 骨架变体 */
|
||||||
|
variant?: SkeletonVariant;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
/** aria-label(无障碍) */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginSkeleton({
|
||||||
|
variant = "card",
|
||||||
|
className,
|
||||||
|
ariaLabel = "加载中",
|
||||||
|
}: PluginSkeletonProps): ReactNode {
|
||||||
|
const baseClass = "rounded-xl bg-card p-4 animate-pulse";
|
||||||
|
|
||||||
|
if (variant === "table") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(baseClass, className)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 h-6 w-1/4 rounded-md bg-muted" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-4 w-full rounded-md bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "list") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn("space-y-2", className)}
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-full animate-pulse rounded-md bg-muted"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "chart") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(baseClass, className)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 h-6 w-1/3 rounded-md bg-muted" />
|
||||||
|
<div className="flex h-32 items-end gap-2">
|
||||||
|
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t-md bg-muted"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "stats") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(baseClass, className)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 h-6 w-1/3 rounded-md bg-muted" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex-1">
|
||||||
|
<div className="h-8 w-1/2 rounded-md bg-muted" />
|
||||||
|
<div className="mt-1 h-3 w-1/3 rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// card(默认)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(baseClass, className)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 h-6 w-1/3 rounded-md bg-muted" />
|
||||||
|
<div className="h-8 w-1/2 rounded-md bg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
packages/ui-components/src/props-config-form.tsx
Normal file
182
packages/ui-components/src/props-config-form.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PropsConfigForm - 基于 JSON Schema 的插件配置表单(portal-shell spec §7.3)
|
||||||
|
*
|
||||||
|
* 根据插件的 propsSchema(JSON Schema)自动渲染配置表单,
|
||||||
|
* admin 通过此表单配置插件的默认 props。
|
||||||
|
*
|
||||||
|
* 支持的字段类型:
|
||||||
|
* - string:文本输入
|
||||||
|
* - number:数字输入
|
||||||
|
* - integer:整数输入
|
||||||
|
* - boolean:复选框
|
||||||
|
* - enum:下拉选择
|
||||||
|
* - object:嵌套对象(递归渲染)
|
||||||
|
*
|
||||||
|
* v2.0 令牌迁移:shadcn 标准令牌
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PropsConfigForm
|
||||||
|
* schema={{ type: "object", properties: { limit: { type: "number", default: 20 } } }}
|
||||||
|
* value={{ limit: 20 }}
|
||||||
|
* onChange={(v) => console.log(v)}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** JSON Schema 类型定义(与 portal-shell spec §5.1 对齐) */
|
||||||
|
export interface PropsJsonSchema {
|
||||||
|
type?: string;
|
||||||
|
properties?: Record<string, PropsJsonSchema>;
|
||||||
|
items?: PropsJsonSchema;
|
||||||
|
description?: string;
|
||||||
|
default?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropsConfigFormProps {
|
||||||
|
/** JSON Schema */
|
||||||
|
schema: PropsJsonSchema;
|
||||||
|
/** 当前值 */
|
||||||
|
value: Record<string, unknown>;
|
||||||
|
/** 值变更回调 */
|
||||||
|
onChange: (value: Record<string, unknown>) => void;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PropsConfigForm({
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: PropsConfigFormProps): ReactNode {
|
||||||
|
const properties = schema.properties;
|
||||||
|
if (!properties) {
|
||||||
|
return <p className="text-sm text-muted-foreground">此插件无可配置项</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
{Object.entries(properties).map(([key, fieldSchema]) => (
|
||||||
|
<FieldRenderer
|
||||||
|
key={key}
|
||||||
|
name={key}
|
||||||
|
schema={fieldSchema}
|
||||||
|
value={value[key]}
|
||||||
|
onChange={(fieldValue) => {
|
||||||
|
onChange({ ...value, [key]: fieldValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldRendererProps {
|
||||||
|
name: string;
|
||||||
|
schema: PropsJsonSchema;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRenderer({
|
||||||
|
name,
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: FieldRendererProps): ReactNode {
|
||||||
|
const fieldType = schema.type ?? "string";
|
||||||
|
const label = schema.description ?? name;
|
||||||
|
|
||||||
|
// enum 下拉
|
||||||
|
if (schema.enum && schema.enum.length > 0) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<select
|
||||||
|
value={String(value ?? schema.default ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{schema.enum.map((opt) => (
|
||||||
|
<option key={String(opt)} value={String(opt)}>
|
||||||
|
{String(opt)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolean 复选框
|
||||||
|
if (fieldType === "boolean") {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(value ?? schema.default ?? false)}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
className="rounded-md border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// number / integer 数字输入
|
||||||
|
if (fieldType === "number" || fieldType === "integer") {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Number(value ?? schema.default ?? 0)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const num = Number(e.target.value);
|
||||||
|
onChange(fieldType === "integer" ? Math.floor(num) : num);
|
||||||
|
}}
|
||||||
|
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// object 嵌套递归
|
||||||
|
if (fieldType === "object" && schema.properties) {
|
||||||
|
const objValue = (value as Record<string, unknown>) ?? {};
|
||||||
|
return (
|
||||||
|
<fieldset className="rounded-xl border p-2">
|
||||||
|
<legend className="px-2 text-sm text-foreground">{label}</legend>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(schema.properties).map(([childKey, childSchema]) => (
|
||||||
|
<FieldRenderer
|
||||||
|
key={childKey}
|
||||||
|
name={childKey}
|
||||||
|
schema={childSchema}
|
||||||
|
value={objValue[childKey]}
|
||||||
|
onChange={(childValue) => {
|
||||||
|
onChange({ ...objValue, [childKey]: childValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// string 默认文本输入
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={String(value ?? schema.default ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,11 +78,10 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
const toolbarButtons = readOnly ? null : (
|
const toolbarButtons = readOnly ? null : (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 p-2 border-b flex-wrap"
|
className="flex items-center gap-1 p-2 border-b flex-wrap bg-muted"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "var(--color-rule)",
|
borderColor: "hsl(var(--border))",
|
||||||
background: "var(--bg-subtle)",
|
borderRadius: "var(--radius) var(--radius) 0 0",
|
||||||
borderRadius: "var(--radius-default) var(--radius-default) 0 0",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarButton label="加粗" onClick={() => exec("bold")} icon="B" bold />
|
<ToolbarButton label="加粗" onClick={() => exec("bold")} icon="B" bold />
|
||||||
@@ -131,14 +130,7 @@ export function RichTextEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full overflow-hidden rounded-xl border bg-background">
|
||||||
className="w-full overflow-hidden"
|
|
||||||
style={{
|
|
||||||
border: "1px solid var(--color-rule)",
|
|
||||||
borderRadius: "var(--radius-card)",
|
|
||||||
background: "var(--bg-paper)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toolbarButtons}
|
{toolbarButtons}
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|||||||
58
packages/ui-components/src/slot-placeholder.tsx
Normal file
58
packages/ui-components/src/slot-placeholder.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SlotPlaceholder - 空 slot 占位组件(portal-shell spec §7.3)
|
||||||
|
*
|
||||||
|
* 当 slot 中没有可见插件时显示此占位。
|
||||||
|
* admin 模式下显示"添加插件"按钮,普通模式下显示空态提示。
|
||||||
|
*
|
||||||
|
* v2.0 令牌迁移:shadcn 标准令牌
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <SlotPlaceholder slotName="main" isAdmin={false} />
|
||||||
|
* <SlotPlaceholder slotName="side" isAdmin={true} onAddPlugin={() => openDialog()} />
|
||||||
|
*/
|
||||||
|
export interface SlotPlaceholderProps {
|
||||||
|
/** slot 名称 */
|
||||||
|
slotName: string;
|
||||||
|
/** 是否为 admin 模式(admin 模式显示添加按钮) */
|
||||||
|
isAdmin?: boolean;
|
||||||
|
/** 添加插件回调(admin 模式下点击触发) */
|
||||||
|
onAddPlugin?: () => void;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlotPlaceholder({
|
||||||
|
slotName,
|
||||||
|
isAdmin = false,
|
||||||
|
onAddPlugin,
|
||||||
|
className,
|
||||||
|
}: SlotPlaceholderProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card p-4 text-sm text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAdmin ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>slot「{slotName}」暂无插件</span>
|
||||||
|
{onAddPlugin ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddPlugin}
|
||||||
|
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground"
|
||||||
|
>
|
||||||
|
添加插件
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>暂无可见插件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "./utils/cn.js";
|
import { cn } from "./utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StatusBadge - 状态徽章
|
* StatusBadge - 状态徽章
|
||||||
@@ -56,13 +56,15 @@ const STATUS_VARIANT_MAP: Record<string, StatusVariant> = {
|
|||||||
loading: "info",
|
loading: "info",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** variant → Tailwind 类名映射 */
|
/** variant → Tailwind 类名映射(v2.0 shadcn 标准令牌) */
|
||||||
const VARIANT_CLASS: Record<StatusVariant, string> = {
|
const VARIANT_CLASS: Record<StatusVariant, string> = {
|
||||||
success: "border-success text-success bg-success/10",
|
success:
|
||||||
warning: "border-warning text-warning bg-warning/10",
|
"border-emerald-500 text-emerald-700 bg-emerald-500/10 dark:text-emerald-400",
|
||||||
danger: "border-danger text-danger bg-danger/10",
|
warning:
|
||||||
info: "border-info text-info bg-info/10",
|
"border-amber-500 text-amber-700 bg-amber-500/10 dark:text-amber-400",
|
||||||
neutral: "border-rule text-ink-muted bg-subtle",
|
danger: "border-destructive text-destructive bg-destructive/10",
|
||||||
|
info: "border-sky-500 text-sky-700 bg-sky-500/10 dark:text-sky-400",
|
||||||
|
neutral: "border text-muted-foreground bg-muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
function inferVariant(status: string): StatusVariant {
|
function inferVariant(status: string): StatusVariant {
|
||||||
@@ -81,7 +83,7 @@ export function StatusBadge({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-button border px-2 py-0.5 text-tiny font-medium",
|
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||||
VARIANT_CLASS[resolvedVariant],
|
VARIANT_CLASS[resolvedVariant],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,82 +2,14 @@
|
|||||||
* 类名合并工具(project_rules §3.9 强制使用)
|
* 类名合并工具(project_rules §3.9 强制使用)
|
||||||
*
|
*
|
||||||
* 用于管理条件类名,禁止字符串拼接动态类名(如 `bg-${color}-500`)。
|
* 用于管理条件类名,禁止字符串拼接动态类名(如 `bg-${color}-500`)。
|
||||||
* 基于 clsx + tailwind-merge 的轻量实现,后续接入 shadcn/ui 时替换为官方 cn()。
|
* 基于 clsx + tailwind-merge 的标准实现(对齐 shadcn/ui 官方 cn())。
|
||||||
*/
|
|
||||||
|
|
||||||
type ClassValue =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| null
|
|
||||||
| false
|
|
||||||
| undefined
|
|
||||||
| ClassValue[]
|
|
||||||
| { [key: string]: unknown };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并类名,过滤 falsy 值。
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* cn("px-2 py-1", isActive && "bg-primary", { "text-muted": isDisabled })
|
* cn("px-2 py-1", isActive && "bg-primary", { "text-muted": isDisabled })
|
||||||
*/
|
*/
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
const classes: string[] = [];
|
return twMerge(clsx(inputs));
|
||||||
|
|
||||||
for (const input of inputs) {
|
|
||||||
if (!input) continue;
|
|
||||||
|
|
||||||
if (typeof input === "string" || typeof input === "number") {
|
|
||||||
classes.push(String(input));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
const nested = cn(...input);
|
|
||||||
if (nested) classes.push(nested);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input === "object") {
|
|
||||||
for (const [key, value] of Object.entries(input)) {
|
|
||||||
if (value) classes.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去重 + 合并 Tailwind 冲突类(基础实现,后续替换为 tailwind-merge)
|
|
||||||
return dedupeTailwindClasses(classes.join(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 基础 Tailwind 类去重(同一前缀后者覆盖前者)。
|
|
||||||
* 完整实现待引入 tailwind-merge。
|
|
||||||
*/
|
|
||||||
function dedupeTailwindClasses(className: string): string {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const tokens = className.split(/\s+/).filter(Boolean);
|
|
||||||
|
|
||||||
// 反向遍历,保留后出现的同类令牌
|
|
||||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
||||||
const token = tokens[i];
|
|
||||||
if (!token) continue;
|
|
||||||
|
|
||||||
const prefix = getTailwindPrefix(token);
|
|
||||||
const key = prefix ? `__${prefix}` : token;
|
|
||||||
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复原始顺序
|
|
||||||
return tokens
|
|
||||||
.filter((t) => t && (seen.has(t) || seen.has(`__${getTailwindPrefix(t)}`)))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTailwindPrefix(token: string): string | null {
|
|
||||||
// 匹配 Tailwind 前缀:bg- text- p- m- w- h- border- 等
|
|
||||||
const match = token.match(
|
|
||||||
/^(bg|text|p|m|px|py|mx|my|w|h|min-h|min-w|border|rounded|shadow|font|leading|tracking|gap|space|flex|grid|col|row|inset|top|right|bottom|left|z|opacity|transition|duration|delay|animate|hover|focus|sm|md|lg|xl|2xl)-/,
|
|
||||||
);
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,116 @@
|
|||||||
/**
|
/**
|
||||||
* Layer 1: Primitive Tokens(原始色板/字号/间距/阴影)
|
* Layer 1: Primitive Tokens(原始色板/字号/间距/阴影/字体家族)
|
||||||
*
|
*
|
||||||
* 业务代码不直接引用本层令牌,仅 Layer 2 Semantic 引用。
|
* 仅被 Layer 2 Semantic 引用,业务代码不直接使用。
|
||||||
* 维护者:ai13(teacher-portal)
|
* 色板层不区分明暗,主题差异在 Semantic 层体现。
|
||||||
|
*
|
||||||
|
* 对齐:CICD 项目 src/app/styles/tokens/primitive.css
|
||||||
* 关联:project_rules §3.10 设计令牌规范
|
* 关联:project_rules §3.10 设计令牌规范
|
||||||
*
|
|
||||||
* 命名规范:
|
|
||||||
* - 颜色:--color-<hue>-<level>(HSL 分量,供 Layer 2 组合)
|
|
||||||
* - 字号:--font-size-<n>(1-9 阶梯)
|
|
||||||
* - 间距:--space-<n>(4px 基准阶梯)
|
|
||||||
* - 阴影:--shadow-<n>
|
|
||||||
* - 字重:--font-weight-<name>
|
|
||||||
* - 圆角:--radius-<name>
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ============ 颜色原始色板(HSL 分量,非完整颜色) ============ */
|
/* ============ 色板(HSL 分量,非完整颜色) ============ */
|
||||||
/* 中性色(纸张/墨色) */
|
|
||||||
--color-paper-h: 40;
|
|
||||||
--color-paper-s: 20%;
|
|
||||||
--color-paper-l-50: 50%;
|
|
||||||
--color-paper-l-98: 98%;
|
|
||||||
--color-paper-l-99: 99%;
|
|
||||||
|
|
||||||
--color-ink-h: 25;
|
/* Zinc 中性色板(shadcn 默认) */
|
||||||
--color-ink-s: 3%;
|
--color-zinc-50: 0 0% 99%;
|
||||||
--color-ink-l-15: 15%;
|
--color-zinc-100: 240 4.8% 95.9%;
|
||||||
--color-ink-l-45: 45%;
|
--color-zinc-200: 240 5.9% 90%;
|
||||||
--color-ink-l-60: 60%;
|
--color-zinc-300: 240 4.8% 83.9%;
|
||||||
|
--color-zinc-400: 240 5% 64.9%;
|
||||||
|
--color-zinc-500: 240 3.8% 46.1%;
|
||||||
|
--color-zinc-600: 240 5.2% 33.9%;
|
||||||
|
--color-zinc-700: 240 5.3% 26.1%;
|
||||||
|
--color-zinc-800: 240 5.9% 10%;
|
||||||
|
--color-zinc-900: 240 5.9% 3.9%;
|
||||||
|
--color-zinc-950: 240 10% 3.9%;
|
||||||
|
|
||||||
/* 强调色(深蓝) */
|
/* Stone 暖灰(用于纸感业务扩展,如备课编辑器) */
|
||||||
--color-accent-h: 220;
|
--color-stone-50: 60 4.8% 95.9%;
|
||||||
--color-accent-s: 60%;
|
--color-stone-100: 60 5.1% 90%;
|
||||||
--color-accent-l-35: 35%;
|
--color-stone-200: 20 5.9% 90%;
|
||||||
--color-accent-l-50: 50%;
|
--color-stone-300: 24 5.7% 82.9%;
|
||||||
|
--color-stone-400: 24 5.4% 63.9%;
|
||||||
|
--color-stone-500: 25 5.1% 44.7%;
|
||||||
|
--color-stone-600: 33 5% 39.8%;
|
||||||
|
--color-stone-700: 30 5.2% 32.7%;
|
||||||
|
--color-stone-800: 12 6.5% 31.4%;
|
||||||
|
--color-stone-900: 24 10% 10%;
|
||||||
|
--color-stone-950: 20 14.3% 4.1%;
|
||||||
|
|
||||||
/* 分隔线(暖灰) */
|
/* Indigo 强调色(业务交互强调) */
|
||||||
--color-rule-h: 30;
|
--color-indigo-500: 238.7 83.5% 66.7%;
|
||||||
--color-rule-s: 10%;
|
--color-indigo-600: 238.6 84.5% 59.8%;
|
||||||
--color-rule-l-85: 85%;
|
|
||||||
--color-rule-l-90: 90%;
|
|
||||||
|
|
||||||
/* 语义原始色 */
|
/* ============ 字号阶梯(10 级,0 最小 9 最大) ============ */
|
||||||
--color-success-h: 142;
|
--font-size-0: 9px; /* 角色标签微字号 */
|
||||||
--color-success-s: 71%;
|
--font-size-1: 12px; /* 元信息/角色标签 */
|
||||||
--color-success-l-45: 45%;
|
--font-size-2: 13px; /* inline-node body */
|
||||||
|
--font-size-3: 13.5px; /* inline-node 主文(纸感) */
|
||||||
|
--font-size-4: 14px; /* 标题/按钮 */
|
||||||
|
--font-size-5: 16px; /* 正文(Fraunces 16px) */
|
||||||
|
--font-size-6: 18px; /* H2 */
|
||||||
|
--font-size-7: 20px; /* H1 */
|
||||||
|
--font-size-8: 24px; /* 区块标题 */
|
||||||
|
--font-size-9: 32px; /* 页面标题 */
|
||||||
|
|
||||||
--color-warning-h: 38;
|
/* ============ 间距阶梯 ============ */
|
||||||
--color-warning-s: 92%;
|
--space-0: 0;
|
||||||
--color-warning-l-50: 50%;
|
--space-0_5: 0.125rem; /* 2px */
|
||||||
|
|
||||||
--color-danger-h: 0;
|
|
||||||
--color-danger-s: 84%;
|
|
||||||
--color-danger-l-60: 60%;
|
|
||||||
|
|
||||||
--color-info-h: 199;
|
|
||||||
--color-info-s: 89%;
|
|
||||||
--color-info-l-48: 48%;
|
|
||||||
|
|
||||||
/* ============ 字号阶梯(1-9,1 最小 9 最大) ============ */
|
|
||||||
--font-size-1: 0.75rem; /* 12px - 辅助说明 */
|
|
||||||
--font-size-2: 0.875rem; /* 14px - 次要正文 */
|
|
||||||
--font-size-3: 1rem; /* 16px - 正文 body */
|
|
||||||
--font-size-4: 1.125rem; /* 18px - 强调正文 */
|
|
||||||
--font-size-5: 1.25rem; /* 20px - 小标题 */
|
|
||||||
--font-size-6: 1.5rem; /* 24px - 区块标题 */
|
|
||||||
--font-size-7: 1.875rem; /* 30px - 页面标题 */
|
|
||||||
--font-size-8: 2.25rem; /* 36px - Hero */
|
|
||||||
--font-size-9: 3rem; /* 48px - 大数字 */
|
|
||||||
|
|
||||||
/* ============ 间距阶梯(4px 基准) ============ */
|
|
||||||
--space-1: 0.25rem; /* 4px */
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-1_5: 0.375rem; /* 6px */
|
||||||
--space-2: 0.5rem; /* 8px */
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-2_5: 0.625rem; /* 10px */
|
||||||
--space-3: 0.75rem; /* 12px */
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-3_5: 0.875rem; /* 14px */
|
||||||
--space-4: 1rem; /* 16px */
|
--space-4: 1rem; /* 16px */
|
||||||
--space-5: 1.25rem; /* 20px */
|
--space-5: 1.25rem; /* 20px */
|
||||||
--space-6: 1.5rem; /* 24px */
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-7: 1.75rem; /* 28px */
|
||||||
--space-8: 2rem; /* 32px */
|
--space-8: 2rem; /* 32px */
|
||||||
--space-10: 2.5rem; /* 40px */
|
--space-10: 2.5rem; /* 40px */
|
||||||
--space-12: 3rem; /* 48px */
|
--space-12: 3rem; /* 48px */
|
||||||
--space-16: 4rem; /* 64px */
|
--space-16: 4rem; /* 64px */
|
||||||
--space-20: 5rem; /* 80px */
|
--space-18: 4.5rem; /* 72px */
|
||||||
|
|
||||||
/* ============ 阴影 ============ */
|
/* ============ 阴影阶梯 ============ */
|
||||||
--shadow-1: 0 1px 2px 0 hsl(25 3% 15% / 0.05);
|
--shadow-1: 0 1px 2px rgba(15, 15, 15, 0.04);
|
||||||
--shadow-2: 0 1px 3px 0 hsl(25 3% 15% / 0.1), 0 1px 2px -1px hsl(25 3% 15% / 0.1);
|
--shadow-2: 0 1px 3px rgba(15, 15, 15, 0.06), 0 1px 2px rgba(15, 15, 15, 0.04);
|
||||||
--shadow-3: 0 4px 6px -1px hsl(25 3% 15% / 0.1), 0 2px 4px -2px hsl(25 3% 15% / 0.1);
|
--shadow-3: 0 4px 6px rgba(15, 15, 15, 0.05), 0 2px 4px rgba(15, 15, 15, 0.04);
|
||||||
--shadow-4: 0 10px 15px -3px hsl(25 3% 15% / 0.1), 0 4px 6px -4px hsl(25 3% 15% / 0.1);
|
--shadow-4: 0 1px 2px rgba(15, 15, 15, 0.04), 0 8px 24px rgba(15, 15, 15, 0.04);
|
||||||
|
--shadow-5: 0 1px 2px rgba(15, 15, 15, 0.06), 0 12px 36px rgba(15, 15, 15, 0.08);
|
||||||
|
--shadow-6: 0 10px 15px rgba(15, 15, 15, 0.1), 0 4px 6px rgba(15, 15, 15, 0.05);
|
||||||
|
|
||||||
/* ============ 字重 ============ */
|
/* ============ 字体家族 ============ */
|
||||||
--font-weight-regular: 400;
|
/* 引用 next/font 在 <html> 上注入的 CSS 变量(fallback 保证 SSR/无字体时降级) */
|
||||||
--font-weight-medium: 500;
|
--font-family-sans: var(--font-inter, 'Inter'), system-ui, sans-serif;
|
||||||
--font-weight-semibold: 600;
|
--font-family-serif: var(--font-fraunces, 'Fraunces'), Georgia, serif;
|
||||||
--font-weight-bold: 700;
|
--font-family-mono: var(--font-jetbrains-mono, 'JetBrains Mono'), ui-monospace, monospace;
|
||||||
|
|
||||||
/* ============ 圆角 ============ */
|
/* ============ 行高阶梯 ============ */
|
||||||
--radius-sm: 0.25rem; /* 4px */
|
--leading-tight: 1.2;
|
||||||
--radius-md: 0.375rem; /* 6px */
|
--leading-snug: 1.35;
|
||||||
--radius-lg: 0.5rem; /* 8px */
|
--leading-normal: 1.5;
|
||||||
--radius-full: 9999px;
|
--leading-relaxed: 1.65;
|
||||||
|
--leading-loose: 1.8;
|
||||||
|
|
||||||
/* ============ 行高 ============ */
|
/* ============ 字重阶梯 ============ */
|
||||||
--line-height-tight: 1.25;
|
--weight-regular: 400;
|
||||||
--line-height-normal: 1.5;
|
--weight-medium: 500;
|
||||||
--line-height-relaxed: 1.75;
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
|
||||||
/* ============ 字间距 ============ */
|
/* ============ 动效 ============ */
|
||||||
--letter-spacing-tight: -0.01em;
|
--duration-fast: 150ms;
|
||||||
--letter-spacing-normal: 0;
|
--duration-normal: 200ms;
|
||||||
--letter-spacing-wide: 0.025em;
|
--duration-slow: 300ms;
|
||||||
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* ============ z-index ============ */
|
||||||
|
--z-dropdown: 1000;
|
||||||
|
--z-sticky: 1100;
|
||||||
|
--z-modal: 1300;
|
||||||
|
--z-popover: 1400;
|
||||||
|
--z-toast: 1500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,73 @@
|
|||||||
/**
|
/**
|
||||||
* Layer 2: Semantic Tokens - Dark Theme(暗色语义令牌)
|
* Layer 2: Semantic Tokens - Dark Theme(暗色语义令牌)
|
||||||
*
|
*
|
||||||
* 维护者:ai13(teacher-portal)
|
* 通过 .dark class 激活(由 next-themes 切换)。
|
||||||
* 关联:project_rules §3.10
|
* 所有令牌都有 :root (light) 对应定义。
|
||||||
*
|
*
|
||||||
* 暗色主题覆盖,通过 [data-theme="dark"] 或 prefers-color-scheme: dark 激活。
|
* 对齐:CICD 项目 src/app/styles/tokens/semantic-dark.css
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root:not([data-theme="light"]) {
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--bg-paper: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-15));
|
/* ============ shadcn 标准令牌 ============ */
|
||||||
--bg-surface: hsl(var(--color-ink-h) var(--color-ink-s) 20%);
|
--background: 240 10% 3.9%;
|
||||||
--bg-subtle: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
--color-ink: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-98));
|
/* ============ chart 令牌 ============ */
|
||||||
--color-ink-muted: hsl(var(--color-paper-h) var(--color-paper-s) 70%);
|
--chart-1: 220 70% 50%;
|
||||||
--color-ink-subtle: hsl(var(--color-paper-h) var(--color-paper-s) 60%);
|
--chart-2: 160 60% 45%;
|
||||||
--color-ink-on-accent: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
|
||||||
--color-accent: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-50));
|
/* ============ sidebar 令牌 ============ */
|
||||||
--color-accent-hover: hsl(var(--color-accent-h) var(--color-accent-s) 60%);
|
--sidebar-background: 240 5.9% 10%;
|
||||||
--color-accent-subtle: hsl(var(--color-accent-h) var(--color-accent-s) 25%);
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
--color-rule: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
/* ============ 语义层扩展 ============ */
|
||||||
--color-rule-strong: hsl(var(--color-ink-h) var(--color-ink-s) 30%);
|
--background-elevated: 240 6% 10%;
|
||||||
|
--background-sunken: 240 6% 8%;
|
||||||
|
--text-primary: 0 0% 98%; /* = --foreground */
|
||||||
|
--text-secondary: 240 5% 64.9%; /* = --muted-foreground */
|
||||||
|
--text-tertiary: 240 5% 50%;
|
||||||
|
--border-strong: 240 5% 40%;
|
||||||
|
--border-subtle: 240 5% 18%;
|
||||||
|
|
||||||
--color-border: var(--color-rule);
|
/* ============ 业务语义令牌 ============ */
|
||||||
--color-input-bg: var(--bg-surface);
|
--diff-add: 142 71% 55%;
|
||||||
|
--diff-add-bg: 142 71% 55% / 0.15;
|
||||||
|
--diff-remove: 0 84% 70%;
|
||||||
|
--diff-remove-bg: 0 84% 70% / 0.15;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.3);
|
--graph-node-1: 220 70% 50%;
|
||||||
--shadow-md: 0 1px 3px 0 hsl(0 0% 0% / 0.4), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
|
--graph-node-2: 160 60% 45%;
|
||||||
--shadow-lg: 0 4px 6px -1px hsl(0 0% 0% / 0.4), 0 2px 4px -2px hsl(0 0% 0% / 0.4);
|
--graph-node-3: 30 80% 55%;
|
||||||
--shadow-xl: 0 10px 15px -3px hsl(0 0% 0% / 0.5), 0 4px 6px -4px hsl(0 0% 0% / 0.5);
|
--graph-node-4: 280 65% 60%;
|
||||||
}
|
--graph-node-5: 340 75% 55%;
|
||||||
}
|
--graph-node-6: 200 80% 60%;
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
|
|
||||||
--bg-paper: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-15));
|
|
||||||
--bg-surface: hsl(var(--color-ink-h) var(--color-ink-s) 20%);
|
|
||||||
--bg-subtle: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
|
||||||
|
|
||||||
--color-ink: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-98));
|
|
||||||
--color-ink-muted: hsl(var(--color-paper-h) var(--color-paper-s) 70%);
|
|
||||||
--color-ink-subtle: hsl(var(--color-paper-h) var(--color-paper-s) 60%);
|
|
||||||
--color-ink-on-accent: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
|
||||||
|
|
||||||
--color-accent: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-50));
|
|
||||||
--color-accent-hover: hsl(var(--color-accent-h) var(--color-accent-s) 60%);
|
|
||||||
--color-accent-subtle: hsl(var(--color-accent-h) var(--color-accent-s) 25%);
|
|
||||||
|
|
||||||
--color-rule: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
|
||||||
--color-rule-strong: hsl(var(--color-ink-h) var(--color-ink-s) 30%);
|
|
||||||
|
|
||||||
--color-border: var(--color-rule);
|
|
||||||
--color-input-bg: var(--bg-surface);
|
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.3);
|
|
||||||
--shadow-md: 0 1px 3px 0 hsl(0 0% 0% / 0.4), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
|
|
||||||
--shadow-lg: 0 4px 6px -1px hsl(0 0% 0% / 0.4), 0 2px 4px -2px hsl(0 0% 0% / 0.4);
|
|
||||||
--shadow-xl: 0 10px 15px -3px hsl(0 0% 0% / 0.5), 0 4px 6px -4px hsl(0 0% 0% / 0.4);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,76 @@
|
|||||||
/**
|
/**
|
||||||
* Layer 2: Semantic Tokens - Light Theme(亮色语义令牌)
|
* Layer 2: Semantic Tokens - Light Theme(亮色语义令牌)
|
||||||
*
|
*
|
||||||
* 业务代码唯一引用入口:业务 TSX/CSS 引用 var(--color-*) / var(--font-*) 等。
|
* 业务代码唯一引用入口:业务 TSX/CSS 引用 hsl(var(--*)) 或 Tailwind bg-* 类。
|
||||||
* 维护者:ai13(teacher-portal)
|
* 所有令牌都有 .dark 对应定义。
|
||||||
* 关联:project_rules §3.10、03-long-term-architecture.md §1.5
|
|
||||||
*
|
*
|
||||||
* 引用 Layer 1 Primitive 组合成完整语义值。
|
* 对齐:CICD 项目 src/app/styles/tokens/semantic-light.css
|
||||||
|
* 关联:project_rules §3.10
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/* ============ 背景与表面 ============ */
|
/* ============ shadcn 标准令牌 ============ */
|
||||||
--bg-paper: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-98));
|
--background: 0 0% 100%;
|
||||||
--bg-surface: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
--foreground: 240 10% 3.9%;
|
||||||
--bg-subtle: hsl(var(--color-rule-h) var(--color-rule-s) var(--color-rule-l-90));
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* ============ 文字 ============ */
|
/* ============ chart 令牌 ============ */
|
||||||
--color-ink: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-15));
|
--chart-1: 12 76% 61%;
|
||||||
--color-ink-muted: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-45));
|
--chart-2: 173 58% 39%;
|
||||||
--color-ink-subtle: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-60));
|
--chart-3: 197 37% 24%;
|
||||||
--color-ink-on-accent: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
|
||||||
/* ============ 强调色 ============ */
|
/* ============ sidebar 令牌 ============ */
|
||||||
--color-accent: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-35));
|
--sidebar-background: 0 0% 98%;
|
||||||
--color-accent-hover: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-50));
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
--color-accent-subtle: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-rule-l-90));
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
/* ============ 分隔线 ============ */
|
/* ============ 语义层扩展(新增层级) ============ */
|
||||||
--color-rule: hsl(var(--color-rule-h) var(--color-rule-s) var(--color-rule-l-90));
|
--background-elevated: 0 0% 100%;
|
||||||
--color-rule-strong: hsl(var(--color-rule-h) var(--color-rule-s) var(--color-rule-l-85));
|
--background-sunken: 240 4.8% 95.9%;
|
||||||
|
--text-primary: 240 10% 3.9%; /* = --foreground */
|
||||||
|
--text-secondary: 240 3.8% 46.1%; /* = --muted-foreground */
|
||||||
|
--text-tertiary: 240 4% 65%;
|
||||||
|
--border-strong: 240 5.9% 70%;
|
||||||
|
--border-subtle: 240 5.9% 95%;
|
||||||
|
|
||||||
/* ============ 语义色 ============ */
|
/* ============ 业务语义令牌 ============ */
|
||||||
--color-success: hsl(var(--color-success-h) var(--color-success-s) var(--color-success-l-45));
|
/* 版本对比 */
|
||||||
--color-warning: hsl(var(--color-warning-h) var(--color-warning-s) var(--color-warning-l-50));
|
--diff-add: 142 71% 45%;
|
||||||
--color-danger: hsl(var(--color-danger-h) var(--color-danger-s) var(--color-danger-l-60));
|
--diff-add-bg: 142 71% 45% / 0.1;
|
||||||
--color-info: hsl(var(--color-info-h) var(--color-info-s) var(--color-info-l-48));
|
--diff-remove: 0 84% 60%;
|
||||||
|
--diff-remove-bg: 0 84% 60% / 0.1;
|
||||||
|
|
||||||
/* ============ 边框/输入 ============ */
|
/* 知识图谱节点色 */
|
||||||
--color-border: var(--color-rule);
|
--graph-node-1: 12 76% 61%; /* = --chart-1 */
|
||||||
--color-border-focus: var(--color-accent);
|
--graph-node-2: 173 58% 39%; /* = --chart-2 */
|
||||||
--color-input-bg: var(--bg-surface);
|
--graph-node-3: 197 37% 24%; /* = --chart-3 */
|
||||||
|
--graph-node-4: 43 74% 66%; /* = --chart-4 */
|
||||||
/* ============ 字体族(通过 var 引用,禁止字面量) ============ */
|
--graph-node-5: 27 87% 67%; /* = --chart-5 */
|
||||||
--font-family-sans: var(--font-inter, system-ui), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--graph-node-6: 280 65% 60%;
|
||||||
--font-family-serif: var(--font-fraunces, Georgia), "Times New Roman", serif;
|
|
||||||
--font-family-mono: var(--font-jetbrains-mono, "SF Mono"), Monaco, Consolas, monospace;
|
|
||||||
|
|
||||||
/* ============ 语义字号 ============ */
|
|
||||||
--font-size-body: var(--font-size-3);
|
|
||||||
--font-size-small: var(--font-size-2);
|
|
||||||
--font-size-tiny: var(--font-size-1);
|
|
||||||
--font-size-heading-1: var(--font-size-7);
|
|
||||||
--font-size-heading-2: var(--font-size-6);
|
|
||||||
--font-size-heading-3: var(--font-size-5);
|
|
||||||
--font-size-display: var(--font-size-9);
|
|
||||||
--font-size-large-number: var(--font-size-8);
|
|
||||||
|
|
||||||
/* ============ 语义间距 ============ */
|
|
||||||
--space-xs: var(--space-1);
|
|
||||||
--space-sm: var(--space-2);
|
|
||||||
--space-md: var(--space-4);
|
|
||||||
--space-lg: var(--space-6);
|
|
||||||
--space-xl: var(--space-8);
|
|
||||||
--space-2xl: var(--space-12);
|
|
||||||
|
|
||||||
/* ============ 语义阴影 ============ */
|
|
||||||
--shadow-sm: var(--shadow-1);
|
|
||||||
--shadow-md: var(--shadow-2);
|
|
||||||
--shadow-lg: var(--shadow-3);
|
|
||||||
--shadow-xl: var(--shadow-4);
|
|
||||||
|
|
||||||
/* ============ 语义圆角 ============ */
|
|
||||||
--radius-default: var(--radius-md);
|
|
||||||
--radius-card: var(--radius-lg);
|
|
||||||
--radius-button: var(--radius-md);
|
|
||||||
|
|
||||||
/* ============ 过渡 ============ */
|
|
||||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user