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

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

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

View File

@@ -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

View 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"
}

View File

@@ -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/**",

View File

@@ -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.

View File

@@ -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 :8080JWT 校验 + 注入 x-user-id/x-user-role * - 反向代理:/api/v1/* → api-gateway :8080JWT 校验 + 注入 x-user-id/x-user-role
* - GraphQL 查询走 apollo-router :3000M8 验收点,由 Apollo Client 直连) * - GraphQL 查询走 apollo-router :3000M8 验收点,由 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;

View File

@@ -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"

View File

@@ -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: {},
}, },
}; };

View File

@@ -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");

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

View File

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

View File

@@ -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仅 Intershadcn 标准)
*/ */
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)。 * 业务 ProvidersApollo/Auth/ThemeI18n在 ClientShell 中挂载spec §5.5)。
*
* suppressHydrationWarningThemeI18nProvider 在客户端切换 .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>
); );
} }

View File

@@ -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 Componentv2.1 M8 验收点) * Shell 入口RSC Server Componentv2.1 M8 验收点 + 流式渲染
* *
* 数据流portal-shell spec §5.5 * 数据流portal-shell spec §5.5、README v2.0 §5.3 流式渲染
* ① 从请求头获取 userId / roleapi-gateway 注入 x-user-id / x-user-role * ① 从请求头获取 userId / roleapi-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-routerfetchPluginConfig 经 Apollo Client * M8 验收portal-shell 查询走 apollo-routerfetchPluginConfig 经 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} />
);
} }

View 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" />;
}

View 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 之前显示
* - 配合 PluginBoundarywidget 级 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>
);
}

View 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" });
// 只更新 classIdtermId 保留
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);
});
});

View File

@@ -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;
} }
/** /**

View File

@@ -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 schemaadmin 配置面板用) */ /** 插件可配置的 props schemaadmin 配置面板用) */

View File

@@ -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, {

View File

@@ -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>,

View File

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

View File

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

View 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
* - 折叠态:仅图标 + Tooltiphover 显示标题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>
);
}

View File

@@ -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
* - 自动检测 mobilewindow.innerWidth < 768resize 防抖 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";

View File

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

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

View File

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

View File

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

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

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

View 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,
};

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

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

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

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

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

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

View 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.themelight/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 };

View 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 传入则包裹 Linkhover 微交互
*
* @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>
);
}

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

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

View 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";

View File

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

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

View File

@@ -1,26 +1,44 @@
"use client"; "use client";
/** /**
* ClientShell - 客户端入口v2.1 M8 * ClientShell - 客户端入口v2.1 M8 + React 19 流式渲染
* *
* 接收 RSC propsConfig + userId + role挂载 ProvidersApollo/Auth/ThemeI18n * 接收 RSC propsconfigPromise + userId + role挂载 ProvidersApollo/Auth/ThemeI18n
* 启用 SWR 静默刷新配置usePluginConfig渲染 Shell。 * 通过 React 19 use() hook 消费 Promise 启用流式渲染:
* - RSC 直出 HTMLPromise resolve 前 Suspense 显示 fallback
* - Promise resolve 后自动重渲染,无需 useEffect 二次请求
* *
* 数据流portal-shell spec §5.5 * 数据流portal-shell spec §5.5、README v2.0 §5.3 流式渲染
* RSC 预取 Config → ClientShellfallbackData→ 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 pendingSuspense fallbackloading.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}
</>
);
}

View File

@@ -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 {
/** classicTopBar + SideNav + Main */ /** classicTopBar + 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 {
/** focusTopBar + 全宽 Main */ /** focusTopBar + 全宽 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 {
/** splitTopBar + 左右等分 Main */ /** splitTopBar + 左右等分 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 {
/** tripleTopBar + SideNav + Main + RightRail */ /** tripleTopBar + 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 {
/** canvasTopBar + 自由摆放 gridMVP 按 grid 排列,不实现拖拽) */ /** canvasTopBar + 自由摆放 gridMVP 按 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}

View File

@@ -0,0 +1,114 @@
/**
* PluginLifecycle - 插件生命周期管理portal-shell spec §5.4
*
* 插件生命周期阶段:
* registered → enabled → loaded → active → disabled → uninstalled
*
* 内置插件:
* - registered编译时登记到 Registrysrc/shell/Registry.tsx
* - enabledadmin 通过 config-service 启用plugin_registry.is_active
* - loadeddynamic import 加载PluginLoader
* - active渲染并挂载SlotRenderer
* - disabledadmin 禁用,不渲染
* - uninstalled不可卸载内置
*
* 第三方插件(二期):
* - registered安装时登记到 DBplugin_packages 表)
* - loadedscript 注入加载(沙箱 iframe + postMessage
* - uninstalledadmin 卸载,删除包
*
* 关联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 携带 requiredShellVersionShell 启动时校验,
* 不兼容则拒绝加载并提示 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;
}

View File

@@ -1,151 +1,22 @@
"use client"; "use client";
/** /**
* PluginLoader - 插件加载器v2.1 M8 * PluginLoader - 已弃用,改为 re-exportv2.1 M8 + 流式渲染
* *
* 职责: * 本模块在 v1.x 中包含 PluginLoader / PluginSkeleton / PluginErrorFallback / PluginErrorBoundary
* - PluginSkeleton5 种 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>
);
}

View File

@@ -6,27 +6,73 @@
* 编译时登记内置插件plugin_id → { Componentdynamic import, metadata }。 * 编译时登记内置插件plugin_id → { Componentdynamic import, metadata }。
* 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。 * 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。
* *
* 内置插件M8 验证管道4 个示例) * 内置插件共 28 个,按 spec §3 分 7 类
* - grades-widgetuniversal / main * - universal7grades / homework / schedule / attendance / exams / notifications / announcements
* - notification-belltopbar / top * - sidebar4class-selector / child-selector / term-switcher / quick-actions
* - user-menutopbar / top * - topbar4notification-bell / user-menu / global-search / locale-switcher
* - class-selectorsidebar / side * - teacher4lesson-plan-editor / question-bank / textbook-manager / scheduling-rules
* - student4error-book / learning-path / elective-selector / ai-tutor
* - parent2child-overview / leave-approval
* - admin3user-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";
// universal7
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";
// sidebar4
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";
// topbar4
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";
// teacher4
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";
// student4
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";
// parent2
import { manifestMeta as childOverviewMeta } from "@/widgets/parent/child-overview/plugin.manifest";
import { manifestMeta as leaveApprovalMeta } from "@/widgets/parent/leave-approval/plugin.manifest";
// admin6
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> = {
// ─── universalmain 区跨角色) ───────────────────────────────
"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" />,
},
),
},
// ─── sidebarside 区) ──────────────────────────────────────
"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" />,
}),
},
// ─── topbartop 区) ────────────────────────────────────────
"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" />,
}),
},
// ─── teachermain 区教师专属) ───────────────────────────────
"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" />,
}),
},
// ─── studentmain 区学生专属) ───────────────────────────────
"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" />,
}),
},
// ─── parentmain 区家长专属) ────────────────────────────────
"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" />,
}),
},
// ─── adminmain 区管理员专属) ───────────────────────────────
"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" />,
}),
},
}; };
/** 判断插件是否已注册 */ /** 判断插件是否已注册 */

View File

@@ -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 三层合并时已过滤requiredRolesSlotRenderer 信任输入
* - L2 权限点门禁:由 config-service 三层合并时已过滤requiredPermissionsSlotRenderer 信任输入
* 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({
// 空 slotmain 区显示占位topbar/side 区不渲染 // 空 slotmain 区显示占位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>
); );

View 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
*
* 覆盖核心纯函数:
* - checkVersionCompatibilitymajor 版本校验
* - 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);
});
});

View 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 = [
// universal7
"grades-widget",
"homework-widget",
"schedule-widget",
"attendance-widget",
"exams-widget",
"notifications-widget",
"announcements-widget",
// sidebar4
"class-selector",
"child-selector",
"term-switcher",
"quick-actions",
// topbar4
"notification-bell",
"user-menu",
"global-search",
"locale-switcher",
// teacher4
"lesson-plan-editor",
"question-bank",
"textbook-manager",
"scheduling-rules",
// student4
"error-book",
"learning-path",
"elective-selector",
"ai-tutor",
// parent2
"child-overview",
"leave-approval",
// admin6
"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);
});
});

View File

@@ -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";

View File

@@ -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: [],
};

View File

@@ -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
}, },

View File

@@ -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",
),
}, },
}, },
}); });

View 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 |
| 应用服务 | 本地进程 | NestJSpnpm exec nest start/ Pythonuvicorn/ Gogo run |
| 前端 | 本地进程 | Next.js dev serverportal-shell |
> Apollo Router 在 Docker 容器中运行,通过 `host.docker.internal` 访问 host 上的 5 个 Federation 2 子图iam/core-edu/content/msg/config-servicecompose 出 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 RouterDocker 容器,组合 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` headerADR-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 内部通信**:服务间通过 gRPC50052-50059不通过 GraphQL
- **CDC + Outbox**:业务代码写 outbox 表Debezium 监听 binlog 投递 Kafka
- **DEV_MODE**:开发模式绕过 JWT 校验,仅限本地开发

View File

@@ -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 signatureinterface 不满足) | | TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下widget 文件用 `interface XxxVars` 会报 TS2344改用 `type XxxVars = {...}` 类型别名type alias 满足 index signatureinterface 不满足) |
| 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` 后缀,不修改) |

View File

@@ -13,7 +13,29 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# 安装 roverApollo CLI for supergraph composition # 安装 roverApollo 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

View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Apollo Router 开发态启动脚本
#
# 与 entrypoint.sh 的差异:
# - 跳过子图等待(子图已在 host 上运行)
# - 使用 dev-supergraph.yamlhost.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
# 启动 routerrouter 二进制位于 /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

View 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

View File

@@ -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",

View File

@@ -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"
} }
} }

View File

@@ -20,42 +20,39 @@
* - token 存储使用 localStorageF12 裁决P2 阶段) * - token 存储使用 localStorageF12 裁决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 插件系统 Hooksv2.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";

View File

@@ -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 - 会话状态管理

View File

@@ -0,0 +1,190 @@
"use client";
import { useCallback } from "react";
/**
* useErrorReport - 客户端错误上报 Hook对齐 CICD use-error-report.ts
*
* 通过 navigator.sendBeacon 上报到 /api/logfallback 到 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.digestNext.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;
}

View File

@@ -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

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
/**
* usePluginConfig - 插件配置静默刷新 Hookportal-shell spec §6.4
*
* 通用封装,不直接依赖 SWR 或 Apollo Client。
* fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 的设计原则。
*
* 特性:
* - 支持 fallbackDataRSC 预取的 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]);
}

View File

@@ -0,0 +1,80 @@
import { useSyncExternalStore } from "react";
import type {
PluginStoreState,
ThemeMode,
Locale,
} from "@edu/shared-ts/contracts";
/**
* usePluginStore - Zustand 全局状态 Hookportal-shell spec §5.2.2
*
* 封装 Zustand store 的订阅,提供 theme/locale/sidebarCollapsed 状态管理。
* 此 hook 是通用封装,实际 store 实例由 portal-shell 创建并注入。
*
* 设计原则(@edu/hookshooks 不直接依赖特定 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 未注入时的空操作
},
};

View File

@@ -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 通知管理

View File

@@ -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 - 视口列表查询

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
} }
} }

View File

@@ -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",
}} }}
> >

View File

@@ -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"
/> />
))} ))}

View File

@@ -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,
)} )}

View File

@@ -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

View File

@@ -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>

View File

@@ -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,
)} )}
> >

View File

@@ -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";

View File

@@ -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 - 骨架屏 / 加载占位

View File

@@ -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}

View File

@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn";
/**
* PluginCard - 插件卡片容器portal-shell spec §7.3
*
* 所有插件内容应使用 PluginCard 包裹,强制设计令牌一致性:
* - 卡片背景 bg-cardv2.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-cardvariant 仅作语义标识(向后兼容)
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>
);
}

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

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

View File

@@ -0,0 +1,182 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn";
/**
* PropsConfigForm - 基于 JSON Schema 的插件配置表单portal-shell spec §7.3
*
* 根据插件的 propsSchemaJSON 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>
);
}

View File

@@ -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}

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

View File

@@ -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,
)} )}

View File

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

View File

@@ -1,112 +1,116 @@
/** /**
* Layer 1: Primitive Tokens原始色板/字号/间距/阴影) * Layer 1: Primitive Tokens原始色板/字号/间距/阴影/字体家族
* *
* 业务代码不直接引用本层令牌,仅 Layer 2 Semantic 引用。 * 仅 Layer 2 Semantic 引用,业务代码不直接使用
* 维护者ai13teacher-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-91 最小 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;
} }

View File

@@ -1,66 +1,73 @@
/** /**
* Layer 2: Semantic Tokens - Dark Theme暗色语义令牌 * Layer 2: Semantic Tokens - Dark Theme暗色语义令牌
* *
* 维护者ai13teacher-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);
} }

View File

@@ -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-* 类
* 维护者ai13teacher-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