feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
@@ -4,16 +4,10 @@
|
||||
* 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。
|
||||
* 关联:project_rules §3.10
|
||||
*/
|
||||
let tsParser;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
tsParser = require("@typescript-eslint/parser");
|
||||
} catch {
|
||||
tsParser = undefined;
|
||||
}
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
module.exports = [
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||
languageOptions: {
|
||||
@@ -28,12 +22,12 @@ module.exports = [
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]',
|
||||
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
|
||||
message:
|
||||
"禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)",
|
||||
},
|
||||
{
|
||||
selector: 'Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]',
|
||||
selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]",
|
||||
message:
|
||||
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)",
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
apps/portal-shell/components.json
Normal file
21
apps/portal-shell/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared/components",
|
||||
"utils": "@/shared/lib/utils",
|
||||
"ui": "@/shared/components/ui",
|
||||
"lib": "@/shared/lib",
|
||||
"hooks": "@/shared/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* portal-shell ESLint flat config
|
||||
* portal-shell ESLint flat config (ESM)
|
||||
*
|
||||
* 包含设计令牌强制规则(project_rules §3.10):
|
||||
* - 禁止 #hex 颜色字面量
|
||||
@@ -7,11 +7,11 @@
|
||||
*
|
||||
* 关联:project_rules §3.10、portal-shell spec §7
|
||||
*/
|
||||
const js = require("@eslint/js");
|
||||
const tseslint = require("typescript-eslint");
|
||||
const prettierConfig = require("eslint-config-prettier");
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
|
||||
module.exports = tseslint.config(
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
|
||||
3
apps/portal-shell/next-env.d.ts
vendored
3
apps/portal-shell/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* portal-shell Next.js 配置(v2.1 M8)
|
||||
* Next.js 配置(v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack)
|
||||
*
|
||||
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker)
|
||||
* - output: standalone(单容器部署)
|
||||
@@ -7,6 +7,10 @@
|
||||
* - 反向代理:/api/v1/* → api-gateway :8080(JWT 校验 + 注入 x-user-id/x-user-role)
|
||||
* - GraphQL 查询走 apollo-router :3000(M8 验收点,由 Apollo Client 直连)
|
||||
*
|
||||
* Next 16 默认 Turbopack:
|
||||
* - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射
|
||||
* - webpack 配置保留作为 fallback(--webpack flag 时生效)
|
||||
*
|
||||
* 关联:portal-shell spec §2、project_rules §3.2
|
||||
*/
|
||||
|
||||
@@ -24,8 +28,20 @@ const nextConfig = {
|
||||
experimental: {
|
||||
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) {
|
||||
// ESM 包使用 .js 后缀导入源码(TS 文件),需映射 .js → .ts
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.extensionAlias = {
|
||||
...config.resolve.extensionAlias,
|
||||
@@ -49,4 +65,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@edu/portal-shell",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4010",
|
||||
"build": "next build",
|
||||
@@ -21,31 +22,47 @@
|
||||
"@edu/hooks": "workspace:*",
|
||||
"@edu/ui-components": "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",
|
||||
"graphql": "^16.8.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.0",
|
||||
"zustand": "^4.5.0"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^5.0.0",
|
||||
"@graphql-codegen/typescript": "^4.0.0",
|
||||
"@graphql-codegen/typescript-document-nodes": "^4.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.0.0"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
module.exports = {
|
||||
/**
|
||||
* PostCSS 配置(Tailwind v4)
|
||||
*
|
||||
* Tailwind v4 使用 @tailwindcss/postcss 插件,配置通过 CSS 内的
|
||||
* @import "tailwindcss" + @theme inline 指令完成,不再需要 tailwind.config.js。
|
||||
*/
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,6 +113,48 @@ function normalizeSchema(content: string): {
|
||||
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 {
|
||||
const schemas = loadSchemas();
|
||||
const allStaticDefs: string[] = [];
|
||||
@@ -130,7 +172,7 @@ function main(): void {
|
||||
new Set(allQueryFields.map((f) => f.trim())),
|
||||
);
|
||||
|
||||
const combined = [
|
||||
let combined = [
|
||||
"# Combined normalized schema for graphql-codegen (federation stripped)",
|
||||
"# DO NOT EDIT - generated by scripts/normalize-schema.ts",
|
||||
"",
|
||||
@@ -142,6 +184,8 @@ function main(): void {
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
combined = sanitizeInputFields(combined);
|
||||
|
||||
const outDir = path.resolve(process.cwd(), "src/lib/api/__generated__");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outPath = path.join(outDir, "combined-schema.graphql");
|
||||
|
||||
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 客户端错误上报端点(mock 实现)
|
||||
*
|
||||
* 当前阶段:输出到 stdout,便于开发调试
|
||||
* 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log
|
||||
*
|
||||
* 端点:POST /api/log
|
||||
* Body: ErrorReportPayload(见 @edu/hooks/use-error-report)
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface ErrorReportPayload {
|
||||
level: "error" | "warning";
|
||||
message: string;
|
||||
stack?: string;
|
||||
digest?: string;
|
||||
path: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
pluginId?: string;
|
||||
userId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as ErrorReportPayload;
|
||||
|
||||
// 开发阶段:结构化输出到 stdout
|
||||
// 生产阶段:这里应替换为 OTel export 或 Sentry capture
|
||||
console.error("[client-error]", {
|
||||
level: payload.level,
|
||||
message: payload.message,
|
||||
digest: payload.digest,
|
||||
path: payload.path,
|
||||
pluginId: payload.pluginId,
|
||||
userId: payload.userId,
|
||||
timestamp: payload.timestamp,
|
||||
// stack 太长,单独一行输出便于阅读
|
||||
stack: payload.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
});
|
||||
|
||||
// 返回 204,让 sendBeacon 认为成功
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch {
|
||||
// 解析失败也返回 204,避免客户端重试
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康检查 */
|
||||
export function GET(): NextResponse {
|
||||
return NextResponse.json({ ok: true, endpoint: "/api/log" });
|
||||
}
|
||||
@@ -1,55 +1,62 @@
|
||||
/**
|
||||
* portal-shell 全局样式
|
||||
* portal-shell 全局样式(Tailwind v4 + shadcn 标准令牌)
|
||||
*
|
||||
* 引入 @edu/ui-tokens 三层设计令牌(primitive → semantic → tailwind-theme)
|
||||
* 业务代码使用 Tailwind 类(bg-background / text-foreground / bg-card ...)或 hsl(var(--*)) 引用。
|
||||
*
|
||||
* 禁止规则(ESLint + project_rules §3.10):
|
||||
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
|
||||
* - 禁止字体名字面量(用 var(--font-family-*))
|
||||
* - 禁止 font-size: Npx(用 var(--font-size-*) 或 Tailwind text-* 类)
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/globals.css
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "@edu/ui-tokens/all.css";
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* 排除非源码目录,防止文档中的 Tailwind 任意值语法字符串被误识别为类名 */
|
||||
@source not "../../docs";
|
||||
@source not "../../scripts";
|
||||
@source not "../../tests";
|
||||
|
||||
/* Reduced Motion */
|
||||
@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 {
|
||||
background: var(--bg-paper);
|
||||
color: var(--color-ink);
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-family-sans);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
|
||||
/**
|
||||
* 字体加载(next/font/google self-host)
|
||||
*
|
||||
* 通过 CSS 变量暴露字体族(--font-inter / --font-fraunces / --font-jetbrains-mono),
|
||||
* ui-tokens 的 semantic 层将它们映射为 --font-family-sans/serif/mono。
|
||||
* 通过 CSS 变量 --font-inter 暴露字体族。
|
||||
* ui-tokens 的 primitive 层将 --font-family-sans 映射为 var(--font-inter, ...)。
|
||||
* 禁止字体名字面量(project_rules §3.10)。
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/layout.tsx(仅 Inter,shadcn 标准)
|
||||
*/
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -16,28 +20,24 @@ const inter = Inter({
|
||||
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 = {
|
||||
title: "Edu Portal Shell",
|
||||
description: "K12 智慧教务平台 - 插件化仪表盘",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* RootLayout
|
||||
*
|
||||
* 仅负责 <html>/<body> 与字体变量。需要 RSC 数据的 Providers
|
||||
* (Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
||||
* 仅负责 <html>/<body> 与字体变量 + 全局 Toaster。
|
||||
* 业务 Providers(Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
||||
*
|
||||
* suppressHydrationWarning:ThemeI18nProvider 在客户端切换 .dark class,
|
||||
* 与 SSR 输出的 <html class=""> 不一致,需抑制 hydration 警告。
|
||||
*/
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -45,11 +45,11 @@ export default function RootLayout({
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
||||
>
|
||||
<body>{children}</body>
|
||||
<html lang="zh-CN" suppressHydrationWarning className={inter.variable}>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,28 +2,44 @@ import { headers } from "next/headers";
|
||||
import { fetchPluginConfig } from "@/lib/config-fetcher";
|
||||
import { ClientShell } from "@/shell/ClientShell";
|
||||
import type { Role } from "@/lib/types";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点)
|
||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染)
|
||||
*
|
||||
* 数据流(portal-shell spec §5.5):
|
||||
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||
* ③ Config 作为 props 传给 ClientShell,随 HTML 直出,消除 CSR 瀑布流
|
||||
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||
*
|
||||
* 流式渲染分层(README v2.0 §5.3):
|
||||
* - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中
|
||||
* - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹
|
||||
* - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense
|
||||
*
|
||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
||||
*
|
||||
* 关联: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> {
|
||||
const headerList = headers();
|
||||
const headerList = await headers();
|
||||
const userId =
|
||||
headerList.get("x-user-id") ||
|
||||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
||||
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
||||
|
||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||
const config = await fetchPluginConfig(userId, role);
|
||||
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
|
||||
userId,
|
||||
role,
|
||||
);
|
||||
|
||||
return <ClientShell config={config} role={role} userId={userId} />;
|
||||
// 将 Promise 作为 prop 传递,ClientShell 内部通过 use() 消费
|
||||
// Next.js 会自动用 loading.tsx 作为 Suspense fallback 流式输出 HTML
|
||||
return (
|
||||
<ClientShell configPromise={configPromise} role={role} userId={userId} />
|
||||
);
|
||||
}
|
||||
|
||||
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
|
||||
|
||||
/**
|
||||
* Shell 路由错误兜底(Next.js App Router error.tsx)
|
||||
*
|
||||
* 触发条件:
|
||||
* - RSC 渲染抛错(如 fetchPluginConfig 失败)
|
||||
* - ClientShell 渲染抛错(Provider 嵌套问题)
|
||||
* - 任何子 segment 未捕获的错误
|
||||
*
|
||||
* 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级):
|
||||
* 1. 隔离错误,避免整页白屏
|
||||
* 2. 通过 useErrorReport 上报到 /api/log
|
||||
* 3. 提供 reset 按钮重试
|
||||
*
|
||||
* 注意:error.tsx 必须是 Client Component("use client")
|
||||
*
|
||||
* 关联:Next.js App Router § error.tsx、portal-shell README v2.0 §5.4
|
||||
*/
|
||||
export default function ShellError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactNode {
|
||||
return <RouteErrorBoundary error={error} reset={reset} namespace="Shell" />;
|
||||
}
|
||||
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Shell 路由加载兜底(Next.js App Router loading.tsx)
|
||||
*
|
||||
* 触发条件:
|
||||
* - RSC 正在解析(fetchPluginConfig 等待中)
|
||||
* - 路由切换时的过渡态
|
||||
*
|
||||
* 职责:
|
||||
* - 整页骨架占位,避免白屏闪烁
|
||||
* - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区)
|
||||
*
|
||||
* 流式渲染上下文(portal-shell README v2.0 §5.3):
|
||||
* - loading.tsx 在 RSC Promise resolve 之前显示
|
||||
* - 配合 PluginBoundary(widget 级 Suspense)形成多层流式体验
|
||||
*
|
||||
* 关联:Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3
|
||||
*/
|
||||
export default function ShellLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="flex h-16 items-center gap-3 px-6">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1">
|
||||
{/* 侧栏 */}
|
||||
<aside className="w-64 border-r bg-card p-4">
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主区:仪表盘骨架 */}
|
||||
<main className="flex-1 p-6">
|
||||
{/* 标题区 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* 统计卡片网格 */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-6">
|
||||
<Skeleton className="mb-3 h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区:图表 + 列表 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
||||
<Skeleton className="mb-4 h-6 w-32" />
|
||||
<div className="flex h-64 items-end gap-2">
|
||||
{[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map(
|
||||
(h, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="flex-1 rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<Skeleton className="mb-4 h-6 w-24" />
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/portal-shell/src/lib/__tests__/plugin-context.test.ts
Normal file
130
apps/portal-shell/src/lib/__tests__/plugin-context.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseUrlContext,
|
||||
writeUrlContext,
|
||||
URL_CONTEXT_KEYS,
|
||||
type UrlPluginContext,
|
||||
} from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* URL Search Params 上下文测试(portal-shell spec §5.2.1、§9.9)
|
||||
*
|
||||
* 覆盖:
|
||||
* - parseUrlContext:从 URLSearchParams 解析上下文
|
||||
* - writeUrlContext:将上下文写入 URLSearchParams
|
||||
* - URL_CONTEXT_KEYS:常量正确性
|
||||
*/
|
||||
describe("URL_CONTEXT_KEYS", () => {
|
||||
it("key 名称与 URL 参数名一致", () => {
|
||||
expect(URL_CONTEXT_KEYS.classId).toBe("classId");
|
||||
expect(URL_CONTEXT_KEYS.childId).toBe("childId");
|
||||
expect(URL_CONTEXT_KEYS.termId).toBe("termId");
|
||||
expect(URL_CONTEXT_KEYS.view).toBe("view");
|
||||
expect(URL_CONTEXT_KEYS.subjectId).toBe("subjectId");
|
||||
expect(URL_CONTEXT_KEYS.examId).toBe("examId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrlContext", () => {
|
||||
it("空参数 → 空上下文", () => {
|
||||
const ctx = parseUrlContext(new URLSearchParams());
|
||||
expect(ctx).toEqual({});
|
||||
});
|
||||
|
||||
it("解析 classId", () => {
|
||||
const params = new URLSearchParams("?classId=cls-001");
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx.classId).toBe("cls-001");
|
||||
});
|
||||
|
||||
it("解析多个参数", () => {
|
||||
const params = new URLSearchParams(
|
||||
"?classId=cls-001&termId=2024-fall&view=chart",
|
||||
);
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx).toEqual({
|
||||
classId: "cls-001",
|
||||
termId: "2024-fall",
|
||||
view: "chart",
|
||||
});
|
||||
});
|
||||
|
||||
it("解析全部参数", () => {
|
||||
const params = new URLSearchParams(
|
||||
"?classId=cls-1&childId=child-1&termId=t-1&view=list&subjectId=subj-1&examId=exam-1",
|
||||
);
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx).toEqual({
|
||||
classId: "cls-1",
|
||||
childId: "child-1",
|
||||
termId: "t-1",
|
||||
view: "list",
|
||||
subjectId: "subj-1",
|
||||
examId: "exam-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("忽略空值参数", () => {
|
||||
const params = new URLSearchParams("?classId=&termId=t-1");
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx.classId).toBeUndefined();
|
||||
expect(ctx.termId).toBe("t-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeUrlContext", () => {
|
||||
it("写入单个值", () => {
|
||||
const params = new URLSearchParams();
|
||||
writeUrlContext(params, { classId: "cls-001" });
|
||||
expect(params.get("classId")).toBe("cls-001");
|
||||
});
|
||||
|
||||
it("写入多个值", () => {
|
||||
const params = new URLSearchParams();
|
||||
writeUrlContext(params, {
|
||||
classId: "cls-1",
|
||||
termId: "t-1",
|
||||
view: "chart",
|
||||
});
|
||||
expect(params.get("classId")).toBe("cls-1");
|
||||
expect(params.get("termId")).toBe("t-1");
|
||||
expect(params.get("view")).toBe("chart");
|
||||
});
|
||||
|
||||
it("空字符串 → 删除参数", () => {
|
||||
const params = new URLSearchParams("?classId=cls-1");
|
||||
writeUrlContext(params, { classId: "" });
|
||||
expect(params.has("classId")).toBe(false);
|
||||
});
|
||||
|
||||
it("undefined → 保留原值不变", () => {
|
||||
const params = new URLSearchParams("?classId=cls-1&termId=t-1");
|
||||
writeUrlContext(params, { classId: "cls-2" });
|
||||
// 只更新 classId,termId 保留
|
||||
expect(params.get("classId")).toBe("cls-2");
|
||||
expect(params.get("termId")).toBe("t-1");
|
||||
});
|
||||
|
||||
it("空对象 → 不修改任何参数", () => {
|
||||
const params = new URLSearchParams("?classId=cls-1");
|
||||
writeUrlContext(params, {});
|
||||
expect(params.get("classId")).toBe("cls-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrlContext + writeUrlContext 往返", () => {
|
||||
it("写入后解析应得到相同上下文", () => {
|
||||
const original: UrlPluginContext = {
|
||||
classId: "cls-001",
|
||||
childId: "child-001",
|
||||
termId: "2024-fall",
|
||||
view: "list",
|
||||
subjectId: "math",
|
||||
examId: "exam-001",
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
writeUrlContext(params, original);
|
||||
const parsed = parseUrlContext(params);
|
||||
expect(parsed).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@
|
||||
* - 统一走 apollo-router GraphQL,由 Router 路由到 config-service 子图
|
||||
* - 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 验收标准
|
||||
*/
|
||||
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 role 用户角色(来自 RSC 的 x-user-role 头)
|
||||
* @returns 三层合并后的 PluginConfigResponse;查询失败时返回默认 classic 配置
|
||||
@@ -63,8 +73,9 @@ export async function fetchPluginConfig(
|
||||
userId: string,
|
||||
role: Role,
|
||||
): Promise<PluginConfigResponse> {
|
||||
const client = createApolloClient();
|
||||
// 1. 优先走 apollo-router(生产路径)
|
||||
try {
|
||||
const client = createApolloClient();
|
||||
const { data, error } = await client.query<{
|
||||
pluginConfig: PluginConfigResponse;
|
||||
}>({
|
||||
@@ -73,22 +84,106 @@ export async function fetchPluginConfig(
|
||||
});
|
||||
if (error) {
|
||||
console.warn(
|
||||
`[portal-shell] fetchPluginConfig partial error: ${error.message}`,
|
||||
`[portal-shell] fetchPluginConfig partial error from apollo-router: ${error.message}`,
|
||||
);
|
||||
}
|
||||
if (data?.pluginConfig) {
|
||||
return data.pluginConfig;
|
||||
}
|
||||
return getDefaultConfig();
|
||||
} catch (err) {
|
||||
// Router 未就绪时降级为默认配置,保证 Shell 可渲染(开发态友好)
|
||||
console.warn(
|
||||
`[portal-shell] fetchPluginConfig failed, falling back to default config: ${
|
||||
`[portal-shell] apollo-router query failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return getDefaultConfig();
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发态降级:直连 config-service GraphQL 查询 pluginConfig。
|
||||
*
|
||||
* 当 apollo-router 不可用时(如本地开发未启动 Router),
|
||||
* 直接请求 config-service 的 /graphql 端点获取插件配置。
|
||||
* 生产环境不应触发此路径(apollo-router 必须可用)。
|
||||
*/
|
||||
async function fetchPluginConfigDirect(
|
||||
userId: string,
|
||||
role: string,
|
||||
configServiceUrl: string,
|
||||
): Promise<PluginConfigResponse | null> {
|
||||
const endpoint = `${configServiceUrl.replace(/\/$/, "")}/graphql`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query GetPluginConfig($userId: ID!, $role: String) {
|
||||
pluginConfig(userId: $userId, role: $role) {
|
||||
activeLayout {
|
||||
layoutId
|
||||
displayName
|
||||
description
|
||||
availableSlots
|
||||
layoutSchemaJson
|
||||
}
|
||||
slots { slotName navItems }
|
||||
plugins { pluginId slot sortOrder sizeJson propsJson isVisible }
|
||||
registry { pluginId category version displayName description requiredRoles isBuiltin isActive }
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { userId, role },
|
||||
}),
|
||||
// RSC 服务端调用,不携带 cookie
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`config-service HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
data?: { pluginConfig?: PluginConfigResponse };
|
||||
errors?: unknown;
|
||||
};
|
||||
|
||||
if (json.errors) {
|
||||
throw new Error(
|
||||
`config-service GraphQL errors: ${JSON.stringify(json.errors)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return json.data?.pluginConfig ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,7 +120,13 @@ export interface PluginManifest {
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: PluginCategory;
|
||||
/** L1 角色门禁:可访问此插件的角色列表(粗粒度) */
|
||||
requiredRoles: Role[];
|
||||
/**
|
||||
* L2 权限点门禁:访问此插件所需的权限点列表(细粒度,AND 语义)
|
||||
* 权限点必须来自 PERMISSION_BITMAP_ORDER
|
||||
*/
|
||||
requiredPermissions?: string[];
|
||||
defaultSlot: string;
|
||||
defaultSize: PluginSize;
|
||||
/** 插件可配置的 props schema(admin 配置面板用) */
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type TypedDocumentNode,
|
||||
} 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>,
|
||||
) {
|
||||
const [mutate, result] = useMutation<TData, TVars>(mutation, {
|
||||
|
||||
@@ -28,7 +28,10 @@ export interface UseWidgetQueryOptions<TData> {
|
||||
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>,
|
||||
variables: TVars,
|
||||
options?: UseWidgetQueryOptions<TData>,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
|
||||
import { useErrorReport } from "@edu/hooks";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* DashboardSection - 仪表盘分区包装器(对齐 CICD dashboard-section.tsx)
|
||||
*
|
||||
* 三件套组合:SectionErrorBoundary + Suspense + 5 种骨架变体
|
||||
*
|
||||
* 职责:
|
||||
* 1. 隔离分区渲染错误(不影响其他分区)
|
||||
* 2. 流式渲染:Suspense 边界显示骨架屏,数据到达后替换
|
||||
* 3. a11y:传入 ariaLabel 时渲染 role="region" tabIndex={0}
|
||||
*
|
||||
* 5 种骨架变体:
|
||||
* - stats:统计卡片骨架(大数字 + 标签)
|
||||
* - card:通用卡片骨架(标题 + 内容块)
|
||||
* - chart:图表骨架(坐标轴 + 柱状)
|
||||
* - table:表格骨架(表头 + 多行)
|
||||
* - list:列表骨架(多行)
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L2 区块级)
|
||||
*
|
||||
* @example
|
||||
* <DashboardSection title="今日课程" variant="table">
|
||||
* <ScheduleList />
|
||||
* </DashboardSection>
|
||||
*/
|
||||
|
||||
export type DashboardSectionVariant =
|
||||
"stats" | "card" | "chart" | "table" | "list";
|
||||
|
||||
export interface DashboardSectionProps {
|
||||
/** 分区标题(显示在 CardHeader) */
|
||||
title?: string;
|
||||
/** 分区描述(显示在 CardHeader) */
|
||||
description?: string;
|
||||
/** 子节点(分区内容) */
|
||||
children: ReactNode;
|
||||
/** 骨架变体(默认 card) */
|
||||
variant?: DashboardSectionVariant;
|
||||
/** a11y 标签(传入时渲染 role="region" tabIndex={0}) */
|
||||
ariaLabel?: string;
|
||||
/** 右侧操作区(如"查看全部"链接) */
|
||||
actions?: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 5 种骨架变体实现
|
||||
*/
|
||||
export function DashboardSectionSkeleton({
|
||||
variant = "card",
|
||||
className,
|
||||
}: {
|
||||
variant?: DashboardSectionVariant;
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
if (variant === "table") {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "chart") {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-48 items-end gap-2">
|
||||
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="flex-1 rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "stats") {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// card(默认)
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardSection - 仪表盘分区(ErrorBoundary + Suspense + Skeleton)
|
||||
*/
|
||||
export function DashboardSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
variant = "card",
|
||||
ariaLabel,
|
||||
actions,
|
||||
className,
|
||||
}: DashboardSectionProps): ReactNode {
|
||||
const reportError = useErrorReport();
|
||||
|
||||
const sectionProps = ariaLabel
|
||||
? { role: "region" as const, tabIndex: 0, "aria-label": ariaLabel }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<section
|
||||
{...sectionProps}
|
||||
className={cn(
|
||||
ariaLabel &&
|
||||
"rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SectionErrorBoundary
|
||||
title={title}
|
||||
onError={(error) => {
|
||||
void reportError(error, {
|
||||
level: "error",
|
||||
context: { section: title },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
|
||||
{(title || actions) && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
{title && (
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</Suspense>
|
||||
</SectionErrorBoundary>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PageHeader } from "@/shared/components/ui/page-header";
|
||||
import { StatsGrid } from "@/shared/components/ui/stats-grid";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* DashboardShell - 仪表盘外壳(对齐 CICD dashboard-shell.tsx)
|
||||
*
|
||||
* 极简结构:PageHeader + StatsGrid(可选)+ children
|
||||
* - stats 为空数组时不渲染统计区(适配无统计指标的页面)
|
||||
* - children 是页面主体内容
|
||||
*
|
||||
* @example
|
||||
* <DashboardShell
|
||||
* title="教师仪表盘"
|
||||
* description="今日教学概览"
|
||||
* stats={<StatCard title="班级" value={6} />}
|
||||
* actions={<Button>导出</Button>}
|
||||
* >
|
||||
* <DashboardSection title="今日课程">
|
||||
* <ScheduleList />
|
||||
* </DashboardSection>
|
||||
* </DashboardShell>
|
||||
*/
|
||||
export interface DashboardShellProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面描述 */
|
||||
description?: string;
|
||||
/** 标题前图标 */
|
||||
icon?: ReactNode;
|
||||
/** 右侧操作区 */
|
||||
actions?: ReactNode;
|
||||
/** 统计卡片组(传入 StatsGrid 或多个 StatCard) */
|
||||
stats?: ReactNode;
|
||||
/** 主体内容 */
|
||||
children: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DashboardShell({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
stats,
|
||||
children,
|
||||
className,
|
||||
}: DashboardShellProps): ReactNode {
|
||||
return (
|
||||
<div className={cn("space-y-6 p-6", className)}>
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={actions}
|
||||
/>
|
||||
{stats && <StatsGrid>{stats}</StatsGrid>}
|
||||
<div className="space-y-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
apps/portal-shell/src/shared/components/layout/app-sidebar.tsx
Normal file
238
apps/portal-shell/src/shared/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import {
|
||||
SIDEBAR_WIDTH_COLLAPSED,
|
||||
SIDEBAR_WIDTH_EXPANDED,
|
||||
useSidebar,
|
||||
} from "./sidebar-provider";
|
||||
|
||||
/**
|
||||
* AppSidebar - 侧边栏实现(对齐 CICD app-sidebar.tsx)
|
||||
*
|
||||
* 关键设计:
|
||||
* - 桌面端:<aside> + transition-[width](w-64 ↔ w-16)
|
||||
* - 折叠态:仅图标 + Tooltip(hover 显示标题),sr-only 标签保证 a11y
|
||||
* - 展开态:Collapsible 子菜单(defaultOpen={isActive} 自动展开当前路由所在组)
|
||||
* - 导航项按权限过滤(hasPermission(item.permission))
|
||||
*
|
||||
* 注意:本组件是基础组件库的一部分,portal-shell 的 LayoutManager 在 P1 阶段
|
||||
* 重构时将使用此组件替换现有 5 种布局模板中的侧边栏部分。
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.3 布局组件
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
/** 显示名称 */
|
||||
title: string;
|
||||
/** 跳转链接 */
|
||||
href?: string;
|
||||
/** 图标(lucide-react 图标组件) */
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
/** 所需权限点(无权限不显示) */
|
||||
permission?: string;
|
||||
/** 子菜单 */
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
export interface AppSidebarProps {
|
||||
/** 导航配置(按角色分组) */
|
||||
items: NavItem[];
|
||||
/** 权限检查函数(从 usePermission().hasPermission 注入) */
|
||||
hasPermission?: (perm: string) => boolean;
|
||||
/** 侧边栏底部内容(如用户信息、版本号) */
|
||||
footer?: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
items,
|
||||
hasPermission,
|
||||
footer,
|
||||
className,
|
||||
}: AppSidebarProps): ReactNode {
|
||||
const { expanded } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
|
||||
// 权限过滤
|
||||
const visibleItems = items.filter(
|
||||
(item) => !item.permission || hasPermission?.(item.permission) !== false,
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-screen flex-col border-r bg-card transition-[width] duration-200",
|
||||
expanded ? SIDEBAR_WIDTH_EXPANDED : SIDEBAR_WIDTH_COLLAPSED,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
<ul className="space-y-1">
|
||||
{visibleItems.map((item) => (
|
||||
<li key={item.title}>
|
||||
<NavMenuItem
|
||||
item={item}
|
||||
expanded={expanded}
|
||||
pathname={pathname}
|
||||
hasPermission={hasPermission}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
{footer && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="p-2">{footer}</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function NavMenuItem({
|
||||
item,
|
||||
expanded,
|
||||
pathname,
|
||||
hasPermission,
|
||||
}: {
|
||||
item: NavItem;
|
||||
expanded: boolean;
|
||||
pathname: string;
|
||||
hasPermission?: (perm: string) => boolean;
|
||||
}): ReactNode {
|
||||
const isActive = item.href === pathname;
|
||||
const visibleChildren = item.children?.filter(
|
||||
(c) => !c.permission || hasPermission?.(c.permission) !== false,
|
||||
);
|
||||
|
||||
// 折叠态:仅图标 + Tooltip
|
||||
if (!expanded) {
|
||||
const Icon = item.icon;
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="w-full"
|
||||
>
|
||||
<Link href={item.href ?? "#"}>
|
||||
<Icon className="size-4" />
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 展开态 + 无子菜单
|
||||
if (!visibleChildren?.length) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href={item.href ?? "#"}>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// 展开态 + 有子菜单(Collapsible)
|
||||
return (
|
||||
<CollapsibleNavItem
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
hasPermission={hasPermission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleNavItem({
|
||||
item,
|
||||
pathname,
|
||||
hasPermission,
|
||||
}: {
|
||||
item: NavItem;
|
||||
pathname: string;
|
||||
hasPermission?: (perm: string) => boolean;
|
||||
}): ReactNode {
|
||||
const visibleChildren =
|
||||
item.children?.filter(
|
||||
(c) => !c.permission || hasPermission?.(c.permission) !== false,
|
||||
) ?? [];
|
||||
const hasActiveChild = visibleChildren.some((c) => c.href === pathname);
|
||||
const [open, setOpen] = useState(hasActiveChild);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4" />}
|
||||
<span>{item.title}</span>
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{open && (
|
||||
<ul className="ml-4 mt-1 space-y-1 border-l pl-2">
|
||||
{visibleChildren.map((child) => {
|
||||
const isActive = child.href === pathname;
|
||||
const ChildIcon = child.icon;
|
||||
return (
|
||||
<li key={child.title}>
|
||||
<Button
|
||||
asChild
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Link href={child.href ?? "#"}>
|
||||
{ChildIcon && <ChildIcon className="size-4" />}
|
||||
<span>{child.title}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* SidebarProvider - 侧边栏状态容器(对齐 CICD sidebar-provider.tsx)
|
||||
*
|
||||
* 职责:
|
||||
* - 管理桌面端折叠状态(expanded: w-64 ↔ w-16)
|
||||
* - 管理移动端 Sheet 开合(openMobile)
|
||||
* - 自动检测 mobile(window.innerWidth < 768),resize 防抖 200ms
|
||||
*
|
||||
* 用法:
|
||||
* <SidebarProvider>
|
||||
* <AppSidebar />
|
||||
* <main className="flex-1">...</main>
|
||||
* </SidebarProvider>
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.3 布局组件
|
||||
*/
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export interface SidebarContextValue {
|
||||
/** 桌面端是否展开 */
|
||||
expanded: boolean;
|
||||
/** 移动端 Sheet 是否打开 */
|
||||
openMobile: boolean;
|
||||
/** 是否移动端 */
|
||||
isMobile: boolean;
|
||||
/** 切换桌面端展开/折叠 */
|
||||
toggleExpanded: () => void;
|
||||
/** 设置桌面端展开状态 */
|
||||
setExpanded: (v: boolean) => void;
|
||||
/** 切换移动端 Sheet */
|
||||
toggleMobile: () => void;
|
||||
/** 设置移动端 Sheet */
|
||||
setOpenMobile: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||
|
||||
export function SidebarProvider({
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [openMobile, setOpenMobile] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
check();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const debounced = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(check, 200);
|
||||
};
|
||||
window.addEventListener("resize", debounced);
|
||||
return () => {
|
||||
window.removeEventListener("resize", debounced);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
|
||||
const toggleMobile = useCallback(() => setOpenMobile((v) => !v), []);
|
||||
|
||||
const value = useMemo<SidebarContextValue>(
|
||||
() => ({
|
||||
expanded,
|
||||
openMobile,
|
||||
isMobile,
|
||||
toggleExpanded,
|
||||
setExpanded,
|
||||
toggleMobile,
|
||||
setOpenMobile,
|
||||
}),
|
||||
[expanded, openMobile, isMobile, toggleExpanded, toggleMobile],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={value}>
|
||||
<div className={cn("flex min-h-screen w-full", className)}>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar(): SidebarContextValue {
|
||||
const ctx = useContext(SidebarContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useSidebar 必须在 <SidebarProvider> 内部使用");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** 侧边栏宽度类名(展开 256px / 折叠 64px) */
|
||||
export const SIDEBAR_WIDTH_EXPANDED = "w-64";
|
||||
export const SIDEBAR_WIDTH_COLLAPSED = "w-16";
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Menu } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useSidebar } from "./sidebar-provider";
|
||||
|
||||
/**
|
||||
* SiteHeader - 顶部头部组件(对齐 CICD site-header.tsx)
|
||||
*
|
||||
* 结构:Mobile Toggle + Separator + Breadcrumb + 右侧 actions(搜索/通知/头像)
|
||||
* - sticky top-0 z-50 h-16 bg-background/95 backdrop-blur-sm
|
||||
* - 面包屑从 pathname 自动生成
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.3 布局组件
|
||||
*/
|
||||
export interface SiteHeaderProps {
|
||||
/** 面包屑映射表(path → title),未命中时 fallback 到首字母大写 */
|
||||
breadcrumbMap?: Record<string, string>;
|
||||
/** 右侧操作区(搜索/通知/头像等) */
|
||||
actions?: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SiteHeader({
|
||||
breadcrumbMap = {},
|
||||
actions,
|
||||
className,
|
||||
}: SiteHeaderProps): ReactNode {
|
||||
const pathname = usePathname();
|
||||
const { toggleMobile, isMobile } = useSidebar();
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-50 flex h-16 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMobile}
|
||||
className="md:hidden"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
<span className="sr-only">打开菜单</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* 面包屑 */}
|
||||
<nav aria-label="面包屑" className="flex items-center gap-1 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
首页
|
||||
</Link>
|
||||
{segments.map((seg, idx) => {
|
||||
const href = "/" + segments.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === segments.length - 1;
|
||||
const title =
|
||||
breadcrumbMap[href] ?? seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
return (
|
||||
<span key={href} className="flex items-center gap-1">
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
{isLast ? (
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">{actions}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
216
apps/portal-shell/src/shared/components/plugin-boundary.tsx
Normal file
216
apps/portal-shell/src/shared/components/plugin-boundary.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useErrorReport } from "@edu/hooks";
|
||||
import { ErrorBoundary } from "@edu/ui-components";
|
||||
|
||||
/**
|
||||
* PluginBoundary - 插件级错误边界 + 流式 Suspense(替代 PluginLoader)
|
||||
*
|
||||
* 三件套组合:ErrorBoundary + Suspense + Skeleton
|
||||
* 职责:
|
||||
* 1. 隔离单个插件渲染错误,不影响其他插件和 Shell
|
||||
* 2. 插件 dynamic import 期间显示骨架屏(流式渲染)
|
||||
* 3. 错误自动上报到 /api/log(通过 onError 回调,避免 fallback render phase 副作用)
|
||||
*
|
||||
* 5 种骨架变体(对齐 CICD DashboardSectionSkeleton):
|
||||
* - card:通用卡片骨架(标题 + 内容块)
|
||||
* - list:列表骨架(多行)
|
||||
* - chart:图表骨架(坐标轴 + 柱状)
|
||||
* - stats:统计数据骨架(大数字 + 标签)
|
||||
* - table:表格骨架(表头 + 多行)
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L3 插件级)
|
||||
*/
|
||||
|
||||
export type PluginSkeletonVariant =
|
||||
"card" | "list" | "chart" | "stats" | "table";
|
||||
|
||||
export interface PluginBoundaryProps {
|
||||
/** 插件实例 ID(用于错误标识和上报) */
|
||||
pluginId: string;
|
||||
/** 子节点(插件组件) */
|
||||
children: ReactNode;
|
||||
/** 骨架变体(默认 card) */
|
||||
skeletonVariant?: PluginSkeletonVariant;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 5 种骨架变体实现(对齐 CICD DashboardSectionSkeleton)
|
||||
*/
|
||||
export function PluginSkeleton({
|
||||
variant = "card",
|
||||
className,
|
||||
}: {
|
||||
variant?: PluginSkeletonVariant;
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
if (variant === "table") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
aria-live="polite"
|
||||
className={cn("space-y-3 rounded-xl border bg-card p-6", className)}
|
||||
>
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
aria-live="polite"
|
||||
className={cn("space-y-2", className)}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "chart") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
aria-live="polite"
|
||||
className={cn("rounded-xl border bg-card p-6", className)}
|
||||
>
|
||||
<Skeleton className="mb-4 h-6 w-1/3" />
|
||||
<div className="flex h-40 items-end gap-2">
|
||||
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="flex-1 rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "stats") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
aria-live="polite"
|
||||
className={cn("rounded-xl border bg-card p-6", className)}
|
||||
>
|
||||
<Skeleton className="mb-4 h-6 w-1/3" />
|
||||
<div className="flex gap-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex-1 space-y-2">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// card(默认)
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
aria-live="polite"
|
||||
className={cn("rounded-xl border bg-card p-6", className)}
|
||||
>
|
||||
<Skeleton className="mb-4 h-6 w-1/3" />
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件错误兜底 UI(纯展示组件,不上报——上报由外层 onError 负责)
|
||||
*/
|
||||
function PluginErrorFallback({
|
||||
pluginId,
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
pluginId: string;
|
||||
error: Error;
|
||||
onReset: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6"
|
||||
>
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">插件加载失败</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{pluginId}: {error.message}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onReset} variant="outline" size="sm">
|
||||
<RefreshCw className="size-4" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PluginBoundary - 插件错误边界 + 流式 Suspense
|
||||
*
|
||||
* @example
|
||||
* <PluginBoundary pluginId="grades-widget" skeletonVariant="table">
|
||||
* <GradesWidget {...pluginProps} />
|
||||
* </PluginBoundary>
|
||||
*/
|
||||
export function PluginBoundary({
|
||||
pluginId,
|
||||
children,
|
||||
skeletonVariant = "card",
|
||||
className,
|
||||
}: PluginBoundaryProps): ReactNode {
|
||||
const reportError = useErrorReport();
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<PluginErrorFallback
|
||||
pluginId={pluginId}
|
||||
error={error}
|
||||
onReset={reset}
|
||||
/>
|
||||
)}
|
||||
onError={(error) => {
|
||||
void reportError(error, { pluginId, level: "error" });
|
||||
}}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<PluginSkeleton variant={skeletonVariant} className={className} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useErrorReport } from "@edu/hooks";
|
||||
|
||||
/**
|
||||
* RouteErrorBoundary - 路由级错误兜底(用于 app/shell/error.tsx)
|
||||
*
|
||||
* Next.js App Router 的 error.tsx 接收 { error, reset } props:
|
||||
* - error: 触发的错误实例(含 digest)
|
||||
* - reset: 重置错误边界,重新渲染 Route Segment
|
||||
*
|
||||
* 本组件职责:
|
||||
* 1. 上报错误到 /api/log(通过 useErrorReport)
|
||||
* 2. 渲染统一错误 UI(图标 + 标题 + 描述 + 重试按钮)
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L1 路由级)
|
||||
*
|
||||
* @example
|
||||
* // app/shell/error.tsx
|
||||
* "use client";
|
||||
* import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
|
||||
* export default function ShellError({ error, reset }) {
|
||||
* return <RouteErrorBoundary error={error} reset={reset} namespace="shell" />;
|
||||
* }
|
||||
*/
|
||||
export interface RouteErrorBoundaryProps {
|
||||
/** Next.js error.tsx 注入的错误实例 */
|
||||
error: Error & { digest?: string };
|
||||
/** Next.js error.tsx 注入的重置函数 */
|
||||
reset: () => void;
|
||||
/** 命名空间(用于错误标题,如 "shell" / "admin" / "teacher") */
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export function RouteErrorBoundary({
|
||||
error,
|
||||
reset,
|
||||
namespace = "page",
|
||||
}: RouteErrorBoundaryProps): React.ReactNode {
|
||||
const reportError = useErrorReport();
|
||||
|
||||
useEffect(() => {
|
||||
void reportError(error, { level: "error" });
|
||||
}, [error, reportError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8"
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="size-6 text-destructive" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold">{namespace}页面出错了</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{error.message || "发生未知错误,请稍后重试"}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/70">
|
||||
错误编号:{error.digest}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={reset} variant="outline" size="sm">
|
||||
<RefreshCw className="size-4" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* SectionErrorBoundary - 区块级错误边界(用于 DashboardSection 内)
|
||||
*
|
||||
* 职责:隔离单个区块(如统计卡片组、图表区、列表区)的渲染错误,
|
||||
* 不影响其他区块和整个页面。
|
||||
*
|
||||
* 与 RouteErrorBoundary 的区别:
|
||||
* - RouteErrorBoundary:整页崩溃兜底,由 Next.js error.tsx 触发
|
||||
* - SectionErrorBoundary:区块崩溃隔离,由 DashboardSection 内部挂载
|
||||
*
|
||||
* 与 PluginBoundary 的区别:
|
||||
* - PluginBoundary:单个插件崩溃隔离,含 Suspense + Skeleton
|
||||
* - SectionErrorBoundary:区块级(可能含多个插件),无 Suspense
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理(L2 区块级)
|
||||
*/
|
||||
|
||||
export interface SectionErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** 区块标题(用于错误 UI 显示,如 "统计概览") */
|
||||
title?: string;
|
||||
/** 自定义错误降级 UI */
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||
/** 错误回调(上报) */
|
||||
onError?: (error: Error, info: ErrorInfo) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SectionErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class SectionErrorBoundary extends Component<
|
||||
SectionErrorBoundaryProps,
|
||||
SectionErrorBoundaryState
|
||||
> {
|
||||
override state: SectionErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): SectionErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
this.props.onError?.(error, info);
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
override render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
const { children, fallback, title, className } = this.props;
|
||||
|
||||
if (error) {
|
||||
if (fallback) {
|
||||
return fallback(error, this.reset);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
"flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">{title ?? "区块加载失败"}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={this.reset} variant="outline" size="sm">
|
||||
<RefreshCw className="size-4" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
37
apps/portal-shell/src/shared/components/ui/badge.tsx
Normal file
37
apps/portal-shell/src/shared/components/ui/badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps): React.ReactNode {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
60
apps/portal-shell/src/shared/components/ui/button.tsx
Normal file
60
apps/portal-shell/src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps): React.ReactNode {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
75
apps/portal-shell/src/shared/components/ui/card.tsx
Normal file
75
apps/portal-shell/src/shared/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground rounded-xl border shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
90
apps/portal-shell/src/shared/components/ui/empty-state.tsx
Normal file
90
apps/portal-shell/src/shared/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { type ReactNode, memo } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button, type ButtonProps } from "@/shared/components/ui/button";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* EmptyState - 空态/错误降级展示(对齐 CICD empty-state.tsx)
|
||||
*
|
||||
* 用途:
|
||||
* - 空列表(如"暂无成绩记录")
|
||||
* - 空搜索结果(如"未找到匹配项")
|
||||
* - 错误降级(配合 ErrorBoundary)
|
||||
*
|
||||
* React.memo 优化:高频渲染场景避免无谓重渲染
|
||||
*
|
||||
* @example
|
||||
* <EmptyState
|
||||
* icon={InboxIcon}
|
||||
* title="暂无数据"
|
||||
* description="点击下方按钮添加第一条记录"
|
||||
* action={{ label: "添加", href: "/new", onClick: handleAdd }}
|
||||
* />
|
||||
*/
|
||||
export interface EmptyStateAction {
|
||||
/** 按钮文字 */
|
||||
label: string;
|
||||
/** 跳转链接(与 onClick 二选一) */
|
||||
href?: string;
|
||||
/** 点击回调(与 href 二选一) */
|
||||
onClick?: () => void;
|
||||
/** 按钮变体(默认 outline) */
|
||||
variant?: ButtonProps["variant"];
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** 图标(lucide-react 图标组件) */
|
||||
icon?: LucideIcon;
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 描述文字 */
|
||||
description?: string;
|
||||
/** 操作按钮 */
|
||||
action?: EmptyStateAction;
|
||||
/** 自定义类名(默认最小高度 450px) */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmptyState = memo(function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[400px] flex-col items-center justify-center gap-4 p-8 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-semibold">{title}</p>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action &&
|
||||
(action.href ? (
|
||||
<Button asChild variant={action.variant ?? "outline"}>
|
||||
<Link href={action.href}>{action.label}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={action.onClick}
|
||||
variant={action.variant ?? "outline"}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
118
apps/portal-shell/src/shared/components/ui/filter-bar.tsx
Normal file
118
apps/portal-shell/src/shared/components/ui/filter-bar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* FilterBar - 筛选栏布局容器(对齐 CICD filter-bar.tsx)
|
||||
*
|
||||
* 三种布局变体:
|
||||
* - default:左对齐(默认)
|
||||
* - wrap:自动换行(筛选条件多时)
|
||||
* - between:两端对齐(左筛选 + 右操作)
|
||||
*
|
||||
* 移动端纵向 flex-col,桌面端 md:flex-row md:items-center
|
||||
* URL 状态管理方式由各模块自行处理,FilterBar 只负责布局
|
||||
*
|
||||
* @example
|
||||
* <FilterBar variant="between">
|
||||
* <FilterSearchInput placeholder="搜索..." value={q} onChange={setQ} />
|
||||
* <FilterResetButton onClick={reset} />
|
||||
* <Button>新建</Button>
|
||||
* </FilterBar>
|
||||
*/
|
||||
export interface FilterBarProps {
|
||||
children: ReactNode;
|
||||
/** 布局变体 */
|
||||
variant?: "default" | "wrap" | "between";
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<NonNullable<FilterBarProps["variant"]>, string> = {
|
||||
default: "md:flex-row md:items-center",
|
||||
wrap: "md:flex-row md:items-center md:flex-wrap",
|
||||
between: "md:flex-row md:items-center md:justify-between",
|
||||
};
|
||||
|
||||
export function FilterBar({
|
||||
children,
|
||||
variant = "default",
|
||||
className,
|
||||
}: FilterBarProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", VARIANT_CLASS[variant], className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterSearchInput - 带搜索图标的输入框
|
||||
*
|
||||
* 固定宽度 md:w-80,移动端 100%
|
||||
*/
|
||||
export function FilterSearchInput({
|
||||
placeholder = "搜索...",
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={cn("relative w-full md:w-80", className)}>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterResetButton - 重置筛选按钮
|
||||
*/
|
||||
export function FilterResetButton({
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={cn("h-9", className)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
重置
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
31
apps/portal-shell/src/shared/components/ui/input.tsx
Normal file
31
apps/portal-shell/src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* Input - shadcn 输入框(基础组件)
|
||||
*
|
||||
* 对齐 shadcn/ui 标准 Input 实现。
|
||||
* 关联:components.json aliases.ui
|
||||
*/
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}: React.ComponentProps<"input">): React.ReactNode {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
64
apps/portal-shell/src/shared/components/ui/page-header.tsx
Normal file
64
apps/portal-shell/src/shared/components/ui/page-header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* PageHeader - 页面标题区(对齐 CICD page-header.tsx)
|
||||
*
|
||||
* 结构:左侧(图标 + 标题 + 描述)+ 右侧 actions
|
||||
* 响应式:移动端纵向 flex-col,桌面端 md:flex-row md:items-center
|
||||
*
|
||||
* @example
|
||||
* <PageHeader
|
||||
* title="成绩管理"
|
||||
* description="查看和管理学生成绩"
|
||||
* icon={<GraduationCap />}
|
||||
* actions={<Button>导出</Button>}
|
||||
* />
|
||||
*/
|
||||
export interface PageHeaderProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 描述文字(可选) */
|
||||
description?: string;
|
||||
/** 标题前图标(可选) */
|
||||
icon?: ReactNode;
|
||||
/** 右侧操作区(按钮、筛选器等) */
|
||||
actions?: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 md:flex-row md:items-center md:justify-between",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="mt-1 text-muted-foreground [&_svg]:size-7">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/portal-shell/src/shared/components/ui/separator.tsx
Normal file
26
apps/portal-shell/src/shared/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>): React.ReactNode {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-[1px] data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
18
apps/portal-shell/src/shared/components/ui/skeleton.tsx
Normal file
18
apps/portal-shell/src/shared/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
38
apps/portal-shell/src/shared/components/ui/sonner.tsx
Normal file
38
apps/portal-shell/src/shared/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
import { usePluginStore } from "@/shell/PluginStore";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
/**
|
||||
* Toast 容器(基于 sonner)
|
||||
*
|
||||
* 主题跟随 portal-shell PluginStore.theme(light/dark),不依赖 next-themes。
|
||||
* 业务代码通过 `import { toast } from "sonner"` 直接调用。
|
||||
*/
|
||||
function Toaster({ ...props }: ToasterProps): React.ReactNode {
|
||||
const theme = usePluginStore((s) => s.theme);
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster };
|
||||
127
apps/portal-shell/src/shared/components/ui/stat-card.tsx
Normal file
127
apps/portal-shell/src/shared/components/ui/stat-card.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* StatCard - 统计卡片(对齐 CICD stat-card.tsx)
|
||||
*
|
||||
* 结构:CardHeader(标题 + 图标)+ CardContent(数值 + 描述)
|
||||
* - 加载态:StatCardSkeleton
|
||||
* - 高亮态:border-amber-200 bg-amber-50/50(用于关键指标)
|
||||
* - 可点击:href 传入则包裹 Link,hover 微交互
|
||||
*
|
||||
* @example
|
||||
* <StatCard
|
||||
* title="学生总数"
|
||||
* value={1234}
|
||||
* icon={UsersIcon}
|
||||
* description="较上月 +12"
|
||||
* href="/admin/users"
|
||||
* />
|
||||
*/
|
||||
export interface StatCardProps {
|
||||
/** 卡片标题 */
|
||||
title: string;
|
||||
/** 数值(数字或字符串) */
|
||||
value: number | string;
|
||||
/** 图标(lucide-react 图标组件) */
|
||||
icon?: LucideIcon;
|
||||
/** 描述文字(如"较上月 +12") */
|
||||
description?: string;
|
||||
/** 是否高亮(关键指标,默认 false) */
|
||||
highlight?: boolean;
|
||||
/** 点击跳转链接 */
|
||||
href?: string;
|
||||
/** 是否加载中 */
|
||||
isLoading?: boolean;
|
||||
/** 数值类名(如 tabular-nums) */
|
||||
valueClassName?: string;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
description,
|
||||
highlight = false,
|
||||
href,
|
||||
isLoading = false,
|
||||
valueClassName,
|
||||
className,
|
||||
}: StatCardProps): ReactNode {
|
||||
if (isLoading) {
|
||||
return <StatCardSkeleton className={className} />;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-all",
|
||||
href && "hover:-translate-y-1 hover:shadow-md",
|
||||
highlight && "border-amber-200 bg-amber-50/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{Icon && <Icon className="size-4 text-muted-foreground" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={cn("text-2xl font-bold tracking-tight", valueClassName)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{description && (
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/** StatCard 骨架屏 */
|
||||
export function StatCardSkeleton({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-20" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
50
apps/portal-shell/src/shared/components/ui/stats-grid.tsx
Normal file
50
apps/portal-shell/src/shared/components/ui/stats-grid.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* StatsGrid - 统计卡片网格(对齐 CICD stats-grid.tsx)
|
||||
*
|
||||
* 响应式列数:mobile=1, md=2, lg=N(由 columns prop 控制)
|
||||
* 统一 isLoading 透传到所有子 StatCard
|
||||
*
|
||||
* @example
|
||||
* <StatsGrid columns={4} isLoading={loading}>
|
||||
* <StatCard title="学生" value={100} />
|
||||
* <StatCard title="教师" value={20} />
|
||||
* </StatsGrid>
|
||||
*/
|
||||
export interface StatsGridProps {
|
||||
/** 子节点(通常是多个 StatCard) */
|
||||
children: ReactNode;
|
||||
/** 桌面端列数(1-5,默认 4) */
|
||||
columns?: 1 | 2 | 3 | 4 | 5;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLUMNS_CLASS: Record<number, string> = {
|
||||
1: "md:grid-cols-1",
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-3",
|
||||
4: "md:grid-cols-4",
|
||||
5: "md:grid-cols-5",
|
||||
};
|
||||
|
||||
export function StatsGrid({
|
||||
children,
|
||||
columns = 4,
|
||||
className,
|
||||
}: StatsGridProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-4",
|
||||
COLUMNS_CLASS[columns],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/shared/components/ui/tooltip.tsx
Normal file
38
apps/portal-shell/src/shared/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/**
|
||||
* Tooltip - shadcn 提示组件
|
||||
*
|
||||
* 对齐 shadcn/ui 标准 Tooltip 实现。
|
||||
* 关联:components.json aliases.ui
|
||||
*/
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>): React.ReactNode {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
73
apps/portal-shell/src/shared/lib/notify.ts
Normal file
73
apps/portal-shell/src/shared/lib/notify.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* notify - 统一 Toast 通知封装(对齐 CICD notify.ts)
|
||||
*
|
||||
* 业务代码统一通过 notify 调用,禁止直接 `import { toast } from "sonner"`。
|
||||
* 优势:便于测试 mock、未来替换底层库、统一 i18n 入口。
|
||||
*
|
||||
* 用法:
|
||||
* import { notify } from "@/shared/lib/notify";
|
||||
* notify.success("保存成功");
|
||||
* notify.error("网络错误");
|
||||
* notify.promise(asyncFn, { loading: "保存中...", success: "成功", error: "失败" });
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4
|
||||
*/
|
||||
import { toast, type ExternalToast } from "sonner";
|
||||
|
||||
type Message = string;
|
||||
|
||||
interface NotifyPromiseOptions<T> {
|
||||
loading: Message;
|
||||
success: Message | ((data: T) => Message);
|
||||
error: Message | ((error: unknown) => Message);
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
/** 成功提示(默认 4 秒) */
|
||||
success(message: Message, options?: ExternalToast): void {
|
||||
toast.success(message, options);
|
||||
},
|
||||
|
||||
/** 错误提示(默认 6 秒,更长便于阅读) */
|
||||
error(message: Message, options?: ExternalToast): void {
|
||||
toast.error(message, { duration: 6000, ...options });
|
||||
},
|
||||
|
||||
/** 警告提示 */
|
||||
warning(message: Message, options?: ExternalToast): void {
|
||||
toast.warning(message, options);
|
||||
},
|
||||
|
||||
/** 信息提示 */
|
||||
info(message: Message, options?: ExternalToast): void {
|
||||
toast.info(message, options);
|
||||
},
|
||||
|
||||
/** 带加载状态的 Promise 提示(透传原 Promise,便于链式调用) */
|
||||
promise<T>(
|
||||
promise: Promise<T>,
|
||||
options: NotifyPromiseOptions<T>,
|
||||
): Promise<T> {
|
||||
toast.promise(promise, options);
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** 加载中提示(返回 toast id,可用 toast.dismiss(id) 关闭) */
|
||||
loading(message: Message, options?: ExternalToast): string | number {
|
||||
return toast.loading(message, options);
|
||||
},
|
||||
|
||||
/** 自定义提示(escape hatch,业务慎用) */
|
||||
message(message: Message, options?: ExternalToast): void {
|
||||
toast(message, options);
|
||||
},
|
||||
|
||||
/** 关闭所有提示 */
|
||||
dismiss(): void {
|
||||
toast.dismiss();
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { toast as rawToast } from "sonner";
|
||||
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 路由权限配置表(对齐 CICD 项目 route-permissions.ts)
|
||||
*
|
||||
* 4 张表按优先级顺序匹配(精确 > 前缀 > 仪表盘 > API):
|
||||
* 1. EXACT_ROUTE_PERMISSIONS:精确路由(如 /shell/admin/users)
|
||||
* 2. PREFIX_ROUTE_PERMISSIONS:前缀路由(如 /shell/admin/*)
|
||||
* 3. DASHBOARD_ROUTE_PERMISSIONS:仪表盘路由(按角色分发)
|
||||
* 4. API_ROUTE_PERMISSIONS:Next.js API Route(/api/*)
|
||||
*
|
||||
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||
* - L1 角色门禁:requiredRoles(4 角色之一)
|
||||
* - L2 权限点门禁:requiredPermissions(AND 语义,必须全部满足)
|
||||
* - L3 数据范围:运行时由插件/page 内 usePermission 校验
|
||||
*
|
||||
* 使用方式(middleware / page / layout):
|
||||
* ```ts
|
||||
* import { checkRoutePermission } from "@/shared/lib/route-permissions";
|
||||
*
|
||||
* const result = checkRoutePermission(pathname, userBitmap, userRole);
|
||||
* if (!result.allowed) redirect("/shell/forbidden");
|
||||
* ```
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §3.3、project_rules §3.1(禁止 role === "xxx" 硬编码)
|
||||
*/
|
||||
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
import {
|
||||
hasAllPermissionsInBitmap,
|
||||
hasAnyPermissionInBitmap,
|
||||
isValidPermission,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
|
||||
/**
|
||||
* 路由权限配置项
|
||||
*/
|
||||
export interface RoutePermissionConfig {
|
||||
/** 所需角色(任一满足即可;空数组表示不限制角色) */
|
||||
requiredRoles?: Role[];
|
||||
/**
|
||||
* 所需权限点(AND 语义,必须全部满足)
|
||||
* 权限点必须来自 PERMISSION_BITMAP_ORDER
|
||||
*/
|
||||
requiredPermissions?: string[];
|
||||
/**
|
||||
* 所需权限点(OR 语义,任一满足即可)
|
||||
* 与 requiredPermissions 同时存在时,先 AND 再 OR
|
||||
*/
|
||||
anyOfPermissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查结果
|
||||
*/
|
||||
export interface RoutePermissionResult {
|
||||
/** 是否允许访问 */
|
||||
allowed: boolean;
|
||||
/** 拒绝原因(allowed=false 时填充) */
|
||||
reason?: "missing_role" | "missing_permission" | "no_config";
|
||||
/** 匹配到的配置(用于调试) */
|
||||
matchedPath?: string;
|
||||
/** 缺失的权限点(allowed=false 且 reason=missing_permission 时填充) */
|
||||
missingPermissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 精确路由权限表
|
||||
*
|
||||
* 高优先级,pathname 完全匹配时生效。
|
||||
* 适用于功能明确、URL 固定的页面(用户管理、RBAC、审计日志等)。
|
||||
*/
|
||||
export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// ── admin 专属 ────────────────────────────────────────────
|
||||
"/shell/admin/users": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["USER_MANAGE"],
|
||||
},
|
||||
"/shell/admin/roles": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["ROLE_MANAGE"],
|
||||
},
|
||||
"/shell/admin/permissions": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["PERMISSION_MANAGE"],
|
||||
},
|
||||
"/shell/admin/audit-logs": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["AUDIT_LOG_READ"],
|
||||
},
|
||||
"/shell/admin/school": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["SCHOOL_MANAGE"],
|
||||
},
|
||||
"/shell/admin/plugins": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["PLUGIN_REGISTRY_MANAGE"],
|
||||
},
|
||||
"/shell/admin/invitation-codes": {
|
||||
requiredRoles: ["admin"],
|
||||
anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"],
|
||||
},
|
||||
|
||||
// ── teacher 专属 ──────────────────────────────────────────
|
||||
"/shell/teacher/lesson-plans": {
|
||||
requiredRoles: ["teacher"],
|
||||
anyOfPermissions: [
|
||||
"LESSON_PLAN_READ",
|
||||
"LESSON_PLAN_CREATE",
|
||||
"LESSON_PLAN_UPDATE",
|
||||
],
|
||||
},
|
||||
"/shell/teacher/question-bank": {
|
||||
requiredRoles: ["teacher"],
|
||||
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE"],
|
||||
},
|
||||
"/shell/teacher/textbooks": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
requiredPermissions: ["TEXTBOOK_READ"],
|
||||
},
|
||||
"/shell/teacher/scheduling-rules": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
|
||||
},
|
||||
|
||||
// ── student 专属 ──────────────────────────────────────────
|
||||
"/shell/student/error-book": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["ERROR_BOOK_READ"],
|
||||
},
|
||||
"/shell/student/learning-path": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["LEARNING_PATH_READ"],
|
||||
},
|
||||
"/shell/student/electives": {
|
||||
requiredRoles: ["student"],
|
||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_SELECT"],
|
||||
},
|
||||
"/shell/student/ai-tutor": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["AI_TUTOR_USE"],
|
||||
},
|
||||
|
||||
// ── parent 专属 ───────────────────────────────────────────
|
||||
"/shell/parent/children": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["GRADE_READ_CHILD"],
|
||||
},
|
||||
"/shell/parent/leave-approval": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["LEAVE_APPROVAL_MANAGE"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. 前缀路由权限表
|
||||
*
|
||||
* 中优先级,pathname 以指定前缀开头时生效。
|
||||
* 适用于功能集合下的所有子路由(/shell/admin/* /shell/teacher/exams/* 等)。
|
||||
*
|
||||
* 注意:前缀必须以 / 结尾,避免误匹配(如 /shell/admin 不能匹配 /shell/admin-users)。
|
||||
*/
|
||||
export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
prefix: string;
|
||||
config: RoutePermissionConfig;
|
||||
}> = [
|
||||
// admin 区所有子路由默认要求 admin 角色
|
||||
{
|
||||
prefix: "/shell/admin/",
|
||||
config: { requiredRoles: ["admin"] },
|
||||
},
|
||||
// 考试管理
|
||||
{
|
||||
prefix: "/shell/teacher/exams/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: [
|
||||
"EXAM_READ",
|
||||
"EXAM_CREATE",
|
||||
"EXAM_UPDATE",
|
||||
"EXAM_GRADE",
|
||||
],
|
||||
},
|
||||
},
|
||||
// 作业管理
|
||||
{
|
||||
prefix: "/shell/teacher/homework/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
|
||||
},
|
||||
},
|
||||
// 成绩录入
|
||||
{
|
||||
prefix: "/shell/teacher/grades/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"],
|
||||
},
|
||||
},
|
||||
// 考勤
|
||||
{
|
||||
prefix: "/shell/teacher/attendance/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 班级管理
|
||||
{
|
||||
prefix: "/shell/admin/classes/",
|
||||
config: {
|
||||
requiredRoles: ["admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 学情诊断
|
||||
{
|
||||
prefix: "/shell/teacher/diagnostics/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 公告管理
|
||||
{
|
||||
prefix: "/shell/admin/announcements/",
|
||||
config: {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 3. 仪表盘路由权限表
|
||||
*
|
||||
* 低优先级,按角色分发的根仪表盘。
|
||||
* 当 pathname 不匹配前两张表时,检查是否为角色仪表盘根路径。
|
||||
*/
|
||||
export const DASHBOARD_ROUTE_PERMISSIONS: Record<
|
||||
string,
|
||||
RoutePermissionConfig
|
||||
> = {
|
||||
"/shell/admin": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["DASHBOARD_ADMIN_READ"],
|
||||
},
|
||||
"/shell/teacher": {
|
||||
requiredRoles: ["teacher"],
|
||||
requiredPermissions: ["DASHBOARD_TEACHER_READ"],
|
||||
},
|
||||
"/shell/student": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["DASHBOARD_STUDENT_READ"],
|
||||
},
|
||||
"/shell/parent": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["DASHBOARD_PARENT_READ"],
|
||||
},
|
||||
// 通用仪表盘
|
||||
"/shell": {
|
||||
requiredPermissions: ["DASHBOARD_READ"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 4. Next.js API Route 权限表
|
||||
*
|
||||
* 用于 /api/* 路径的权限校验。
|
||||
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
||||
*/
|
||||
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// 错误上报端点:所有登录用户可访问
|
||||
"/api/log": {},
|
||||
// 健康检查:公开
|
||||
"/api/healthz": {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验权限配置的合法性(开发时辅助)
|
||||
*
|
||||
* 检查所有声明的权限点是否在 PERMISSION_BITMAP_ORDER 中。
|
||||
* 在 dev 模式下打 warning,生产构建时可阻断。
|
||||
*
|
||||
* @returns 非法权限点列表(空数组表示全部合法)
|
||||
*/
|
||||
export function validateRoutePermissionConfigs(): string[] {
|
||||
const invalid: string[] = [];
|
||||
const allConfigs: Array<{ source: string; config: RoutePermissionConfig }> = [
|
||||
...Object.entries(EXACT_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `EXACT:${path}`,
|
||||
config,
|
||||
})),
|
||||
...PREFIX_ROUTE_PERMISSIONS.map(({ prefix, config }) => ({
|
||||
source: `PREFIX:${prefix}`,
|
||||
config,
|
||||
})),
|
||||
...Object.entries(DASHBOARD_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `DASHBOARD:${path}`,
|
||||
config,
|
||||
})),
|
||||
...Object.entries(API_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `API:${path}`,
|
||||
config,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const { source, config } of allConfigs) {
|
||||
for (const perm of config.requiredPermissions ?? []) {
|
||||
if (!isValidPermission(perm)) {
|
||||
invalid.push(`${source}:requiredPermissions:${perm}`);
|
||||
}
|
||||
}
|
||||
for (const perm of config.anyOfPermissions ?? []) {
|
||||
if (!isValidPermission(perm)) {
|
||||
invalid.push(`${source}:anyOfPermissions:${perm}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查主函数
|
||||
*
|
||||
* 按优先级顺序匹配 4 张表,返回检查结果。
|
||||
*
|
||||
* @param pathname 当前路径(如 /shell/admin/users)
|
||||
* @param userBitmap 用户权限位图(base36 字符串,从 JWT cookie 解析)
|
||||
* @param userRole 用户角色
|
||||
* @returns 检查结果,allowed=true 表示放行
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = checkRoutePermission("/shell/admin/users", "abc123", "admin");
|
||||
* if (!result.allowed) {
|
||||
* redirect("/shell/forbidden");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function checkRoutePermission(
|
||||
pathname: string,
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): RoutePermissionResult {
|
||||
// 1. 匹配精确路由
|
||||
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
||||
if (exactConfig) {
|
||||
return evaluateConfig(exactConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
|
||||
// 2. 匹配前缀路由
|
||||
for (const { prefix, config } of PREFIX_ROUTE_PERMISSIONS) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
return evaluateConfig(config, userBitmap, userRole, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 匹配仪表盘路由
|
||||
const dashboardConfig = DASHBOARD_ROUTE_PERMISSIONS[pathname];
|
||||
if (dashboardConfig) {
|
||||
return evaluateConfig(dashboardConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
|
||||
// 4. 匹配 API 路由
|
||||
if (pathname.startsWith("/api/")) {
|
||||
const apiConfig = API_ROUTE_PERMISSIONS[pathname];
|
||||
if (apiConfig) {
|
||||
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
// 未配置的 API 路由默认拒绝
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估单个权限配置
|
||||
*/
|
||||
function evaluateConfig(
|
||||
config: RoutePermissionConfig,
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
matchedPath: string,
|
||||
): RoutePermissionResult {
|
||||
// L1 角色门禁
|
||||
if (config.requiredRoles && config.requiredRoles.length > 0) {
|
||||
if (!config.requiredRoles.includes(userRole)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_role",
|
||||
matchedPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// L2 权限点门禁 - AND 语义
|
||||
const missingPermissions: string[] = [];
|
||||
if (config.requiredPermissions && config.requiredPermissions.length > 0) {
|
||||
for (const perm of config.requiredPermissions) {
|
||||
// 使用 hasAllPermissionsInBitmap 不合适(它返回 boolean 不告知哪些缺失)
|
||||
// 这里手动遍历以便收集缺失项
|
||||
const bit = hasPermissionInBitmapSimple(userBitmap, perm);
|
||||
if (!bit) {
|
||||
missingPermissions.push(perm);
|
||||
}
|
||||
}
|
||||
if (missingPermissions.length > 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_permission",
|
||||
matchedPath,
|
||||
missingPermissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// L2 权限点门禁 - OR 语义
|
||||
if (config.anyOfPermissions && config.anyOfPermissions.length > 0) {
|
||||
if (!hasAnyPermissionInBitmap(userBitmap, config.anyOfPermissions)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_permission",
|
||||
matchedPath,
|
||||
missingPermissions: config.anyOfPermissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, matchedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版单权限检查(避免循环依赖 hasAllPermissionsInBitmap)
|
||||
*
|
||||
* 直接调用 hasAllPermissionsInBitmap 检查单个权限点
|
||||
*/
|
||||
function hasPermissionInBitmapSimple(
|
||||
bitmap: string,
|
||||
permission: string,
|
||||
): boolean {
|
||||
return hasAllPermissionsInBitmap(bitmap, [permission]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查用户是否拥有所有指定路由的访问权限
|
||||
*
|
||||
* 用于侧边栏导航项过滤:一次性检查多个路由,避免重复调用。
|
||||
*
|
||||
* @param paths 路径列表
|
||||
* @param userBitmap 用户权限位图
|
||||
* @param userRole 用户角色
|
||||
* @returns 路径 → 是否允许 的映射
|
||||
*/
|
||||
export function batchCheckRoutePermission(
|
||||
paths: readonly string[],
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const path of paths) {
|
||||
result[path] = checkRoutePermission(path, userBitmap, userRole).allowed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
54
apps/portal-shell/src/shared/lib/utils.ts
Normal file
54
apps/portal-shell/src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 类名合并 + 通用工具函数(对齐 CICD 项目 src/shared/lib/utils.ts)
|
||||
*
|
||||
* shadcn/ui 组件统一通过 `@/shared/lib/utils` 引用 cn()。
|
||||
* 关联:project_rules §3.9、components.json aliases.utils
|
||||
*/
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Next.js App Router 搜索参数类型 */
|
||||
export type SearchParams = { [key: string]: string | string[] | undefined };
|
||||
|
||||
/** 从 SearchParams 中安全提取单个字符串值 */
|
||||
export function getSearchParam(
|
||||
params: SearchParams,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const v = params[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v)) return v[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 格式化数字,null/undefined/非有限数返回 "-" */
|
||||
export function formatNumber(v: number | null | undefined, digits = 1): string {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return "-";
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从姓名生成头像占位用的首字母(最多 2 个字符)。
|
||||
* 用于 AvatarFallback 组件。
|
||||
* - 含空格的姓名:取各单词首字母拼接(如 "John Doe" -> "JD")
|
||||
* - 无空格的姓名:取前 2 个字符(如 "张三" -> "张三")
|
||||
* - 空值:返回 "U"(User 通用占位)
|
||||
*/
|
||||
export function getInitials(name: string | null | undefined): string {
|
||||
if (!name) return "U";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "U";
|
||||
if (trimmed.includes(" ")) {
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return trimmed.slice(0, 2).toUpperCase();
|
||||
}
|
||||
@@ -1,26 +1,44 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ClientShell - 客户端入口(v2.1 M8)
|
||||
* ClientShell - 客户端入口(v2.1 M8 + React 19 流式渲染)
|
||||
*
|
||||
* 接收 RSC props(Config + userId + role),挂载 Providers(Apollo/Auth/ThemeI18n),
|
||||
* 启用 SWR 静默刷新配置(usePluginConfig),渲染 Shell。
|
||||
* 接收 RSC props(configPromise + userId + role),挂载 Providers(Apollo/Auth/ThemeI18n),
|
||||
* 通过 React 19 use() hook 消费 Promise 启用流式渲染:
|
||||
* - RSC 直出 HTML:Promise resolve 前 Suspense 显示 fallback
|
||||
* - Promise resolve 后自动重渲染,无需 useEffect 二次请求
|
||||
*
|
||||
* 数据流(portal-shell spec §5.5):
|
||||
* RSC 预取 Config → ClientShell(fallbackData)→ SWR 静默刷新 → Shell 重渲染
|
||||
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||
* 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 { AuthProvider, type AuthUser } from "@/providers/AuthProvider";
|
||||
import { ThemeI18nProvider } from "@/providers/ThemeI18nProvider";
|
||||
import { Shell } from "./Shell";
|
||||
import { usePluginConfig } from "@/lib/usePluginConfig";
|
||||
import type { PluginConfigResponse, Role } from "@/lib/types";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
export interface ClientShellProps {
|
||||
config: PluginConfigResponse;
|
||||
/**
|
||||
* 服务端预取的 pluginConfig Promise(流式渲染)
|
||||
*
|
||||
* 通过 React 19 use() hook 消费,启用 HTML 流式输出:
|
||||
* - Promise pending:Suspense fallback(loading.tsx 整页骨架)
|
||||
* - Promise resolved:自动重渲染为真实 UI
|
||||
*
|
||||
* 与原 config prop 互斥(二选一,configPromise 优先)
|
||||
*/
|
||||
configPromise?: Promise<PluginConfigResponse>;
|
||||
/**
|
||||
* 同步 config(向后兼容,无流式渲染)
|
||||
* 当不启用流式时使用,与 configPromise 互斥
|
||||
*/
|
||||
config?: PluginConfigResponse;
|
||||
role: Role;
|
||||
userId: string;
|
||||
/** 可选:服务端解析的用户名/邮箱(user-menu 插件会自行查询 me) */
|
||||
@@ -28,8 +46,44 @@ export interface ClientShellProps {
|
||||
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({
|
||||
config,
|
||||
configPromise,
|
||||
config: fallbackConfig,
|
||||
role,
|
||||
userId,
|
||||
userName,
|
||||
@@ -45,41 +99,99 @@ export function ClientShell({
|
||||
dataScope: "",
|
||||
};
|
||||
|
||||
const { config: liveConfig } = usePluginConfig({
|
||||
initialConfig: config,
|
||||
userId,
|
||||
role,
|
||||
onChanged: () => setConfigChanged(true),
|
||||
});
|
||||
// 兼容模式:未传 configPromise 时走同步 config
|
||||
const useStreaming = Boolean(configPromise);
|
||||
|
||||
return (
|
||||
<ApolloProvider>
|
||||
<AuthProvider user={user}>
|
||||
<ThemeI18nProvider>
|
||||
<Shell config={liveConfig} user={user} role={role} userId={userId} />
|
||||
{configChanged ? (
|
||||
<div className="fixed bottom-xl right-xl z-50 rounded-card border border-rule bg-surface p-md shadow-md">
|
||||
<p className="text-small text-ink">
|
||||
发现新布局配置,刷新后生效。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigChanged(false)}
|
||||
className="mt-sm ml-sm rounded-button border border-rule px-md py-xs text-small text-ink"
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{useStreaming && configPromise ? (
|
||||
<ShellContent
|
||||
configPromise={configPromise}
|
||||
user={user}
|
||||
role={role}
|
||||
userId={userId}
|
||||
/>
|
||||
) : (
|
||||
<LegacyShell
|
||||
config={fallbackConfig}
|
||||
user={user}
|
||||
role={role}
|
||||
userId={userId}
|
||||
onConfigChange={() => setConfigChanged(true)}
|
||||
configChanged={configChanged}
|
||||
onDismiss={() => setConfigChanged(false)}
|
||||
/>
|
||||
)}
|
||||
</ThemeI18nProvider>
|
||||
</AuthProvider>
|
||||
</ApolloProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版同步渲染路径(向后兼容)
|
||||
*
|
||||
* 当 RSC 未传 configPromise 时使用,行为与 v1.1 一致
|
||||
*/
|
||||
function LegacyShell({
|
||||
config,
|
||||
user,
|
||||
role,
|
||||
userId,
|
||||
onConfigChange,
|
||||
configChanged,
|
||||
onDismiss,
|
||||
}: {
|
||||
config?: PluginConfigResponse;
|
||||
user: AuthUser;
|
||||
role: Role;
|
||||
userId: string;
|
||||
onConfigChange: () => void;
|
||||
configChanged: boolean;
|
||||
onDismiss: () => void;
|
||||
}): ReactNode {
|
||||
const initialConfig: PluginConfigResponse = config ?? {
|
||||
activeLayout: null,
|
||||
slots: [],
|
||||
plugins: [],
|
||||
registry: [],
|
||||
};
|
||||
|
||||
const { config: liveConfig } = usePluginConfig({
|
||||
initialConfig: initialConfig,
|
||||
userId,
|
||||
role,
|
||||
onChanged: onConfigChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Shell config={liveConfig} user={user} role={role} userId={userId} />
|
||||
{configChanged ? (
|
||||
<div className="fixed bottom-4 right-4 z-50 rounded-xl border bg-card p-4 shadow-md">
|
||||
<p className="text-sm text-foreground">
|
||||
发现新布局配置,刷新后生效。
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded-md border px-3 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LayoutManager - 5 种 Layout 模板渲染器(v2.1 M8)
|
||||
* LayoutManager - 5 种 Layout 模板渲染器(v2.1 M8 + shadcn 令牌)
|
||||
*
|
||||
* | layoutId | 布局 | slots |
|
||||
* | -------- | ------------------------- | ------------------------ |
|
||||
* | classic | TopBar + SideNav + Main | top / side / main |
|
||||
* | focus | TopBar + 全宽 Main | top / main |
|
||||
* | split | TopBar + 左右等分 Main | top / main-left / main-right |
|
||||
* | triple | TopBar + SideNav + Main + RightRail | top / side / main / right |
|
||||
* | canvas | TopBar + 自由摆放 | top / canvas-grid |
|
||||
* | layoutId | 布局 | slots |
|
||||
* | -------- | --------------------------------- | ---------------------------------- |
|
||||
* | classic | TopBar + SideNav + Main | top / side / main |
|
||||
* | focus | TopBar + 全宽 Main | top / main |
|
||||
* | split | TopBar + 左右等分 Main | top / main-left / main-right |
|
||||
* | triple | TopBar + SideNav + Main + RightRail | top / side / main / right |
|
||||
* | 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 { SlotRenderer, type SlotRendererProps } from "./SlotRenderer";
|
||||
@@ -47,15 +54,15 @@ interface LayoutShellProps {
|
||||
/** classic:TopBar + SideNav + Main */
|
||||
function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper">
|
||||
<header className="border-b border-rule bg-surface">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<header className="border-b bg-card">
|
||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||
</header>
|
||||
<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} />
|
||||
</aside>
|
||||
<main className="flex-1 p-lg">
|
||||
<main className="flex-1 p-6">
|
||||
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||
</main>
|
||||
</div>
|
||||
@@ -66,11 +73,11 @@ function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
/** focus:TopBar + 全宽 Main */
|
||||
function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper">
|
||||
<header className="border-b border-rule bg-surface">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<header className="border-b bg-card">
|
||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||
</header>
|
||||
<main className="flex-1 p-lg">
|
||||
<main className="flex-1 p-6">
|
||||
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||
</main>
|
||||
</div>
|
||||
@@ -80,11 +87,11 @@ function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
/** split:TopBar + 左右等分 Main */
|
||||
function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper">
|
||||
<header className="border-b border-rule bg-surface">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<header className="border-b bg-card">
|
||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||
</header>
|
||||
<div className="flex flex-1 gap-md p-lg">
|
||||
<div className="flex flex-1 gap-4 p-6">
|
||||
<section className="flex-1">
|
||||
<SlotRenderer
|
||||
slotName="main-left"
|
||||
@@ -107,18 +114,18 @@ function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
/** triple:TopBar + SideNav + Main + RightRail */
|
||||
function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper">
|
||||
<header className="border-b border-rule bg-surface">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<header className="border-b bg-card">
|
||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||
</header>
|
||||
<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} />
|
||||
</aside>
|
||||
<main className="flex-1 p-lg">
|
||||
<main className="flex-1 p-6">
|
||||
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||
</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} />
|
||||
</aside>
|
||||
</div>
|
||||
@@ -129,11 +136,11 @@ function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
/** canvas:TopBar + 自由摆放 grid(MVP 按 grid 排列,不实现拖拽) */
|
||||
function CanvasLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper">
|
||||
<header className="border-b border-rule bg-surface">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<header className="border-b bg-card">
|
||||
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||
</header>
|
||||
<main className="flex-1 p-lg">
|
||||
<main className="flex-1 p-6">
|
||||
<SlotRenderer
|
||||
slotName="canvas-grid"
|
||||
layoutId={layoutId}
|
||||
|
||||
114
apps/portal-shell/src/shell/PluginLifecycle.ts
Normal file
114
apps/portal-shell/src/shell/PluginLifecycle.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* PluginLifecycle - 插件生命周期管理(portal-shell spec §5.4)
|
||||
*
|
||||
* 插件生命周期阶段:
|
||||
* registered → enabled → loaded → active → disabled → uninstalled
|
||||
*
|
||||
* 内置插件:
|
||||
* - registered:编译时登记到 Registry(src/shell/Registry.tsx)
|
||||
* - enabled:admin 通过 config-service 启用(plugin_registry.is_active)
|
||||
* - loaded:dynamic import 加载(PluginLoader)
|
||||
* - active:渲染并挂载(SlotRenderer)
|
||||
* - disabled:admin 禁用,不渲染
|
||||
* - uninstalled:不可卸载(内置)
|
||||
*
|
||||
* 第三方插件(二期):
|
||||
* - registered:安装时登记到 DB(plugin_packages 表)
|
||||
* - loaded:script 注入加载(沙箱 iframe + postMessage)
|
||||
* - uninstalled:admin 卸载,删除包
|
||||
*
|
||||
* 关联:portal-shell spec §5.4、§9.1
|
||||
*/
|
||||
|
||||
/** 插件生命周期阶段 */
|
||||
export type PluginLifecyclePhase =
|
||||
"registered" | "enabled" | "loaded" | "active" | "disabled" | "uninstalled";
|
||||
|
||||
/** 插件状态转换结果 */
|
||||
export interface LifecycleTransitionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
currentPhase: PluginLifecyclePhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验插件版本兼容性
|
||||
*
|
||||
* 插件 manifest 携带 requiredShellVersion,Shell 启动时校验,
|
||||
* 不兼容则拒绝加载并提示 admin 升级。
|
||||
*
|
||||
* @param requiredShellVersion 插件要求的 Shell 版本范围(semver range)
|
||||
* @param currentShellVersion 当前 Shell 版本
|
||||
*/
|
||||
export function checkVersionCompatibility(
|
||||
requiredShellVersion: string,
|
||||
currentShellVersion: string,
|
||||
): boolean {
|
||||
// MVP 简化实现:只校验 major 版本
|
||||
// 完整 semver range 校验待引入 semver 库
|
||||
const requiredMajor = parseMajorVersion(requiredShellVersion);
|
||||
const currentMajor = parseMajorVersion(currentShellVersion);
|
||||
if (requiredMajor === null || currentMajor === null) {
|
||||
return true; // 无法解析时放行
|
||||
}
|
||||
return requiredMajor === currentMajor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算插件从注册到激活的转换路径
|
||||
*
|
||||
* @param isBuiltin 是否内置插件
|
||||
* @param isAdminEnabled admin 是否已启用
|
||||
* @returns 转换路径描述
|
||||
*/
|
||||
export function getActivationPath(
|
||||
isBuiltin: boolean,
|
||||
isAdminEnabled: boolean,
|
||||
): PluginLifecyclePhase[] {
|
||||
if (!isAdminEnabled) {
|
||||
return ["registered", "disabled"];
|
||||
}
|
||||
if (isBuiltin) {
|
||||
return ["registered", "enabled", "loaded", "active"];
|
||||
}
|
||||
// 第三方插件(二期)
|
||||
return ["registered", "enabled", "loaded", "active"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断插件是否可渲染
|
||||
*
|
||||
* 综合考虑:admin 启用状态 + 版本兼容性 + 角色权限
|
||||
*/
|
||||
export function isPluginRenderable(params: {
|
||||
isActive: boolean;
|
||||
requiredShellVersion: string;
|
||||
currentShellVersion: string;
|
||||
userRole: string;
|
||||
requiredRoles: string[];
|
||||
}): boolean {
|
||||
const {
|
||||
isActive,
|
||||
requiredShellVersion,
|
||||
currentShellVersion,
|
||||
userRole,
|
||||
requiredRoles,
|
||||
} = params;
|
||||
|
||||
if (!isActive) return false;
|
||||
if (!checkVersionCompatibility(requiredShellVersion, currentShellVersion)) {
|
||||
return false;
|
||||
}
|
||||
if (requiredRoles.length > 0 && !requiredRoles.includes(userRole)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 解析 semver 字符串的 major 版本号 */
|
||||
function parseMajorVersion(version: string): number | null {
|
||||
// 移除 ^ ~ >= 等 range 前缀
|
||||
const cleaned = version.replace(/^[^0-9]*/, "");
|
||||
const match = cleaned.match(/^(\d+)/);
|
||||
return match?.[1] ? Number(match[1]) : null;
|
||||
}
|
||||
@@ -1,151 +1,22 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PluginLoader - 插件加载器(v2.1 M8)
|
||||
* PluginLoader - 已弃用,改为 re-export(v2.1 M8 + 流式渲染)
|
||||
*
|
||||
* 职责:
|
||||
* - PluginSkeleton:5 种 skeleton 变体(card/list/chart/stats/table),供 dynamic loading 使用
|
||||
* - PluginErrorFallback:插件加载/渲染失败兜底
|
||||
* - PluginErrorBoundary:隔离单个插件错误,不影响其他插件
|
||||
* - PluginLoader:包裹插件组件,注入 PluginProps,挂载 ErrorBoundary
|
||||
* 本模块在 v1.x 中包含 PluginLoader / PluginSkeleton / PluginErrorFallback / PluginErrorBoundary,
|
||||
* v2.0 已被 `@/shared/components/plugin-boundary` 替代:
|
||||
* - PluginSkeleton → 从 plugin-boundary 重新导出(5 种变体,shadcn 令牌)
|
||||
* - PluginErrorBoundary → 由 @edu/ui-components 的 ErrorBoundary + PluginBoundary 替代
|
||||
* - 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";
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
|
||||
type SkeletonVariant = "card" | "list" | "chart" | "stats" | "table";
|
||||
|
||||
/** 插件骨架屏(纸感风格,使用设计令牌) */
|
||||
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>
|
||||
);
|
||||
}
|
||||
export {
|
||||
PluginSkeleton,
|
||||
type PluginSkeletonVariant,
|
||||
} from "@/shared/components/plugin-boundary";
|
||||
|
||||
@@ -6,27 +6,73 @@
|
||||
* 编译时登记内置插件:plugin_id → { Component(dynamic import), metadata }。
|
||||
* 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。
|
||||
*
|
||||
* 内置插件(M8 验证管道,4 个示例):
|
||||
* - grades-widget(universal / main)
|
||||
* - notification-bell(topbar / top)
|
||||
* - user-menu(topbar / top)
|
||||
* - class-selector(sidebar / side)
|
||||
* 内置插件共 28 个,按 spec §3 分 7 类:
|
||||
* - universal(7):grades / homework / schedule / attendance / exams / notifications / announcements
|
||||
* - sidebar(4):class-selector / child-selector / term-switcher / quick-actions
|
||||
* - topbar(4):notification-bell / user-menu / global-search / locale-switcher
|
||||
* - teacher(4):lesson-plan-editor / question-bank / textbook-manager / scheduling-rules
|
||||
* - student(4):error-book / learning-path / elective-selector / ai-tutor
|
||||
* - parent(2):child-overview / leave-approval
|
||||
* - admin(3):user-management / rbac-manager / plugin-manager
|
||||
*
|
||||
* 关联:portal-shell spec §2.2、§5.3
|
||||
* 关联:portal-shell spec §2.2、§5.3、§3
|
||||
*/
|
||||
import dynamic from "next/dynamic";
|
||||
import type { PluginManifest } from "@/lib/types";
|
||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
|
||||
// universal(7)
|
||||
import { manifestMeta as gradesWidgetMeta } from "@/widgets/universal/grades-widget/plugin.manifest";
|
||||
import { manifestMeta as homeworkWidgetMeta } from "@/widgets/universal/homework-widget/plugin.manifest";
|
||||
import { manifestMeta as scheduleWidgetMeta } from "@/widgets/universal/schedule-widget/plugin.manifest";
|
||||
import { manifestMeta as attendanceWidgetMeta } from "@/widgets/universal/attendance-widget/plugin.manifest";
|
||||
import { manifestMeta as examsWidgetMeta } from "@/widgets/universal/exams-widget/plugin.manifest";
|
||||
import { manifestMeta as notificationsWidgetMeta } from "@/widgets/universal/notifications-widget/plugin.manifest";
|
||||
import { manifestMeta as announcementsWidgetMeta } from "@/widgets/universal/announcements-widget/plugin.manifest";
|
||||
|
||||
// sidebar(4)
|
||||
import { manifestMeta as classSelectorMeta } from "@/widgets/sidebar/class-selector/plugin.manifest";
|
||||
import { manifestMeta as childSelectorMeta } from "@/widgets/sidebar/child-selector/plugin.manifest";
|
||||
import { manifestMeta as termSwitcherMeta } from "@/widgets/sidebar/term-switcher/plugin.manifest";
|
||||
import { manifestMeta as quickActionsMeta } from "@/widgets/sidebar/quick-actions/plugin.manifest";
|
||||
|
||||
// topbar(4)
|
||||
import { manifestMeta as notificationBellMeta } from "@/widgets/topbar/notification-bell/plugin.manifest";
|
||||
import { manifestMeta as userMenuMeta } from "@/widgets/topbar/user-menu/plugin.manifest";
|
||||
import { manifestMeta as classSelectorMeta } from "@/widgets/sidebar/class-selector/plugin.manifest";
|
||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
import { manifestMeta as globalSearchMeta } from "@/widgets/topbar/global-search/plugin.manifest";
|
||||
import { manifestMeta as localeSwitcherMeta } from "@/widgets/topbar/locale-switcher/plugin.manifest";
|
||||
|
||||
// teacher(4)
|
||||
import { manifestMeta as lessonPlanEditorMeta } from "@/widgets/teacher/lesson-plan-editor/plugin.manifest";
|
||||
import { manifestMeta as questionBankMeta } from "@/widgets/teacher/question-bank/plugin.manifest";
|
||||
import { manifestMeta as textbookManagerMeta } from "@/widgets/teacher/textbook-manager/plugin.manifest";
|
||||
import { manifestMeta as schedulingRulesMeta } from "@/widgets/teacher/scheduling-rules/plugin.manifest";
|
||||
|
||||
// student(4)
|
||||
import { manifestMeta as errorBookMeta } from "@/widgets/student/error-book/plugin.manifest";
|
||||
import { manifestMeta as learningPathMeta } from "@/widgets/student/learning-path/plugin.manifest";
|
||||
import { manifestMeta as electiveSelectorMeta } from "@/widgets/student/elective-selector/plugin.manifest";
|
||||
import { manifestMeta as aiTutorMeta } from "@/widgets/student/ai-tutor/plugin.manifest";
|
||||
|
||||
// parent(2)
|
||||
import { manifestMeta as childOverviewMeta } from "@/widgets/parent/child-overview/plugin.manifest";
|
||||
import { manifestMeta as leaveApprovalMeta } from "@/widgets/parent/leave-approval/plugin.manifest";
|
||||
|
||||
// admin(6)
|
||||
import { manifestMeta as userManagementMeta } from "@/widgets/admin/user-management/plugin.manifest";
|
||||
import { manifestMeta as rbacManagerMeta } from "@/widgets/admin/rbac-manager/plugin.manifest";
|
||||
import { manifestMeta as pluginManagerMeta } from "@/widgets/admin/plugin-manager/plugin.manifest";
|
||||
import { manifestMeta as schoolSettingsMeta } from "@/widgets/admin/school-settings/plugin.manifest";
|
||||
import { manifestMeta as auditLogsMeta } from "@/widgets/admin/audit-logs/plugin.manifest";
|
||||
import { manifestMeta as invitationCodesMeta } from "@/widgets/admin/invitation-codes/plugin.manifest";
|
||||
|
||||
/**
|
||||
* 内置插件注册表。
|
||||
* Component 使用 next/dynamic 懒加载(ssr:false),避免插件 JS 阻塞首屏。
|
||||
* loading 展示骨架屏变体,与目标 slot 视觉一致。
|
||||
*/
|
||||
export const REGISTRY: Record<string, PluginManifest> = {
|
||||
// ─── universal(main 区跨角色) ───────────────────────────────
|
||||
"grades-widget": {
|
||||
...gradesWidgetMeta,
|
||||
Component: dynamic(() => import("@/widgets/universal/grades-widget"), {
|
||||
@@ -34,6 +80,86 @@ export const REGISTRY: Record<string, PluginManifest> = {
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
"homework-widget": {
|
||||
...homeworkWidgetMeta,
|
||||
Component: dynamic(() => import("@/widgets/universal/homework-widget"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"schedule-widget": {
|
||||
...scheduleWidgetMeta,
|
||||
Component: dynamic(() => import("@/widgets/universal/schedule-widget"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
"attendance-widget": {
|
||||
...attendanceWidgetMeta,
|
||||
Component: dynamic(() => import("@/widgets/universal/attendance-widget"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="stats" />,
|
||||
}),
|
||||
},
|
||||
"exams-widget": {
|
||||
...examsWidgetMeta,
|
||||
Component: dynamic(() => import("@/widgets/universal/exams-widget"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"notifications-widget": {
|
||||
...notificationsWidgetMeta,
|
||||
Component: dynamic(
|
||||
() => import("@/widgets/universal/notifications-widget"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
},
|
||||
),
|
||||
},
|
||||
"announcements-widget": {
|
||||
...announcementsWidgetMeta,
|
||||
Component: dynamic(
|
||||
() => import("@/widgets/universal/announcements-widget"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
// ─── sidebar(side 区) ──────────────────────────────────────
|
||||
"class-selector": {
|
||||
...classSelectorMeta,
|
||||
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"child-selector": {
|
||||
...childSelectorMeta,
|
||||
Component: dynamic(() => import("@/widgets/sidebar/child-selector"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"term-switcher": {
|
||||
...termSwitcherMeta,
|
||||
Component: dynamic(() => import("@/widgets/sidebar/term-switcher"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"quick-actions": {
|
||||
...quickActionsMeta,
|
||||
Component: dynamic(() => import("@/widgets/sidebar/quick-actions"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
|
||||
// ─── topbar(top 区) ────────────────────────────────────────
|
||||
"notification-bell": {
|
||||
...notificationBellMeta,
|
||||
Component: dynamic(() => import("@/widgets/topbar/notification-bell"), {
|
||||
@@ -48,13 +174,140 @@ export const REGISTRY: Record<string, PluginManifest> = {
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"class-selector": {
|
||||
...classSelectorMeta,
|
||||
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
|
||||
"global-search": {
|
||||
...globalSearchMeta,
|
||||
Component: dynamic(() => import("@/widgets/topbar/global-search"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"locale-switcher": {
|
||||
...localeSwitcherMeta,
|
||||
Component: dynamic(() => import("@/widgets/topbar/locale-switcher"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
|
||||
// ─── teacher(main 区教师专属) ───────────────────────────────
|
||||
"lesson-plan-editor": {
|
||||
...lessonPlanEditorMeta,
|
||||
Component: dynamic(() => import("@/widgets/teacher/lesson-plan-editor"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"question-bank": {
|
||||
...questionBankMeta,
|
||||
Component: dynamic(() => import("@/widgets/teacher/question-bank"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
"textbook-manager": {
|
||||
...textbookManagerMeta,
|
||||
Component: dynamic(() => import("@/widgets/teacher/textbook-manager"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"scheduling-rules": {
|
||||
...schedulingRulesMeta,
|
||||
Component: dynamic(() => import("@/widgets/teacher/scheduling-rules"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
|
||||
// ─── student(main 区学生专属) ───────────────────────────────
|
||||
"error-book": {
|
||||
...errorBookMeta,
|
||||
Component: dynamic(() => import("@/widgets/student/error-book"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"learning-path": {
|
||||
...learningPathMeta,
|
||||
Component: dynamic(() => import("@/widgets/student/learning-path"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"elective-selector": {
|
||||
...electiveSelectorMeta,
|
||||
Component: dynamic(() => import("@/widgets/student/elective-selector"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
"ai-tutor": {
|
||||
...aiTutorMeta,
|
||||
Component: dynamic(() => import("@/widgets/student/ai-tutor"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
|
||||
// ─── parent(main 区家长专属) ────────────────────────────────
|
||||
"child-overview": {
|
||||
...childOverviewMeta,
|
||||
Component: dynamic(() => import("@/widgets/parent/child-overview"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="stats" />,
|
||||
}),
|
||||
},
|
||||
"leave-approval": {
|
||||
...leaveApprovalMeta,
|
||||
Component: dynamic(() => import("@/widgets/parent/leave-approval"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="list" />,
|
||||
}),
|
||||
},
|
||||
|
||||
// ─── admin(main 区管理员专属) ───────────────────────────────
|
||||
"user-management": {
|
||||
...userManagementMeta,
|
||||
Component: dynamic(() => import("@/widgets/admin/user-management"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
"rbac-manager": {
|
||||
...rbacManagerMeta,
|
||||
Component: dynamic(() => import("@/widgets/admin/rbac-manager"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
"plugin-manager": {
|
||||
...pluginManagerMeta,
|
||||
Component: dynamic(() => import("@/widgets/admin/plugin-manager"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"school-settings": {
|
||||
...schoolSettingsMeta,
|
||||
Component: dynamic(() => import("@/widgets/admin/school-settings"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="card" />,
|
||||
}),
|
||||
},
|
||||
"audit-logs": {
|
||||
...auditLogsMeta,
|
||||
Component: dynamic(() => import("@/widgets/admin/audit-logs"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
"invitation-codes": {
|
||||
...invitationCodesMeta,
|
||||
Component: dynamic(() => import("@/widgets/admin/invitation-codes"), {
|
||||
ssr: false,
|
||||
loading: () => <PluginSkeleton variant="table" />,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/** 判断插件是否已注册 */
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SlotRenderer - 按 Config 渲染插件列表(v2.1 M8)
|
||||
* SlotRenderer - 按 Config 渲染插件列表(v2.1 M8 + 流式渲染)
|
||||
*
|
||||
* 给定一个 slot 名称,从配置中过滤出该 slot 的可见插件,按 sortOrder 排序,
|
||||
* 查 Registry 取组件,注入 PluginProps,经 PluginLoader 挂载(含 ErrorBoundary)。
|
||||
* 查 Registry 取组件,注入 PluginProps,经 PluginBoundary 挂载(含 ErrorBoundary + Suspense)。
|
||||
*
|
||||
* 关联:portal-shell spec §2.2、§5.1
|
||||
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||
* - L1 角色门禁:由 config-service 三层合并时已过滤(requiredRoles),SlotRenderer 信任输入
|
||||
* - L2 权限点门禁:由 config-service 三层合并时已过滤(requiredPermissions),SlotRenderer 信任输入
|
||||
* (manifest.metadata.requiredPermissions 作为元数据,供 admin 配置面板和开发时审查使用)
|
||||
* - L3 数据范围:由插件内部 usePermission 校验
|
||||
*
|
||||
* 错误隔离(portal-shell README v2.0 §5.4 L3 插件级):
|
||||
* - 每个插件被 PluginBoundary 包裹,单个插件崩溃不影响其他插件
|
||||
* - 流式 Suspense:插件 dynamic import 期间显示骨架屏
|
||||
*
|
||||
* 关联:portal-shell spec §2.2、§5.1、README v2.0 §3.3 §5.3 §5.4
|
||||
*/
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { 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 type { PluginPlacement, PluginProps, Role } from "@/lib/types";
|
||||
import type { AuthUser } from "@/providers/AuthProvider";
|
||||
@@ -24,6 +38,33 @@ export interface SlotRendererProps {
|
||||
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({
|
||||
slotName,
|
||||
layoutId,
|
||||
@@ -44,20 +85,20 @@ export function SlotRenderer({
|
||||
// 空 slot:main 区显示占位,topbar/side 区不渲染
|
||||
if (slotName === "top" || slotName === "side") return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-md">
|
||||
<div className="space-y-4">
|
||||
{visible.map((placement) => {
|
||||
if (!isPluginRegistered(placement.pluginId)) {
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
@@ -67,6 +108,7 @@ export function SlotRenderer({
|
||||
if (!manifest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginProps: PluginProps = {
|
||||
instanceId: `${placement.pluginId}-${slotName}-${placement.sortOrder}`,
|
||||
role,
|
||||
@@ -83,12 +125,22 @@ export function SlotRenderer({
|
||||
},
|
||||
props: parsePropsJson(placement.propsJson),
|
||||
};
|
||||
|
||||
const skeletonVariant = inferSkeletonVariant(
|
||||
slotName,
|
||||
placement.pluginId,
|
||||
);
|
||||
|
||||
const Component =
|
||||
manifest.Component as React.ComponentType<PluginProps>;
|
||||
return (
|
||||
<PluginLoader
|
||||
<PluginBoundary
|
||||
key={pluginProps.instanceId}
|
||||
Component={manifest.Component}
|
||||
pluginProps={pluginProps}
|
||||
/>
|
||||
pluginId={placement.pluginId}
|
||||
skeletonVariant={skeletonVariant}
|
||||
>
|
||||
<Component {...pluginProps} />
|
||||
</PluginBoundary>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -96,11 +148,17 @@ export function SlotRenderer({
|
||||
}
|
||||
|
||||
/** Slot 加载态占位(layout 切换瞬间) */
|
||||
export function SlotSkeleton({ count = 1 }: { count?: number }): ReactNode {
|
||||
export function SlotSkeleton({
|
||||
count = 1,
|
||||
variant = "card",
|
||||
}: {
|
||||
count?: number;
|
||||
variant?: PluginSkeletonVariant;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="space-y-md">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<PluginSkeleton key={i} variant="card" />
|
||||
<PluginSkeleton key={i} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
104
apps/portal-shell/src/shell/__tests__/PluginLifecycle.test.ts
Normal file
104
apps/portal-shell/src/shell/__tests__/PluginLifecycle.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkVersionCompatibility,
|
||||
getActivationPath,
|
||||
isPluginRenderable,
|
||||
} from "@/shell/PluginLifecycle";
|
||||
|
||||
/**
|
||||
* PluginLifecycle 单元测试(portal-shell spec §5.4、§9.9)
|
||||
*
|
||||
* 覆盖核心纯函数:
|
||||
* - checkVersionCompatibility:major 版本校验
|
||||
* - getActivationPath:生命周期转换路径
|
||||
* - isPluginRenderable:综合可渲染判断
|
||||
*/
|
||||
describe("checkVersionCompatibility", () => {
|
||||
it("同 major 版本兼容", () => {
|
||||
expect(checkVersionCompatibility("^1.0.0", "1.2.3")).toBe(true);
|
||||
expect(checkVersionCompatibility("~1.2.0", "1.2.5")).toBe(true);
|
||||
expect(checkVersionCompatibility("2.0.0", "2.5.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("不同 major 版本不兼容", () => {
|
||||
expect(checkVersionCompatibility("^1.0.0", "2.0.0")).toBe(false);
|
||||
expect(checkVersionCompatibility("^2.0.0", "1.5.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("无法解析时放行(容错)", () => {
|
||||
expect(checkVersionCompatibility("invalid", "1.0.0")).toBe(true);
|
||||
expect(checkVersionCompatibility("^1.0.0", "unknown")).toBe(true);
|
||||
expect(checkVersionCompatibility("", "")).toBe(true);
|
||||
});
|
||||
|
||||
it("带 range 前缀的版本号", () => {
|
||||
expect(checkVersionCompatibility("^1.5.0", "1.6.0")).toBe(true);
|
||||
expect(checkVersionCompatibility(">=2.0.0", "2.1.0")).toBe(true);
|
||||
expect(checkVersionCompatibility("~3.0.0", "3.0.1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivationPath", () => {
|
||||
it("admin 未启用的插件 → disabled", () => {
|
||||
const path = getActivationPath(true, false);
|
||||
expect(path).toEqual(["registered", "disabled"]);
|
||||
});
|
||||
|
||||
it("内置插件 + admin 启用 → 完整激活路径", () => {
|
||||
const path = getActivationPath(true, true);
|
||||
expect(path).toEqual(["registered", "enabled", "loaded", "active"]);
|
||||
});
|
||||
|
||||
it("第三方插件 + admin 启用 → 完整激活路径", () => {
|
||||
const path = getActivationPath(false, true);
|
||||
expect(path).toEqual(["registered", "enabled", "loaded", "active"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginRenderable", () => {
|
||||
const baseParams = {
|
||||
isActive: true,
|
||||
requiredShellVersion: "^1.0.0",
|
||||
currentShellVersion: "1.0.0",
|
||||
userRole: "teacher",
|
||||
requiredRoles: ["teacher", "student"],
|
||||
};
|
||||
|
||||
it("全部满足 → 可渲染", () => {
|
||||
expect(isPluginRenderable(baseParams)).toBe(true);
|
||||
});
|
||||
|
||||
it("未激活 → 不可渲染", () => {
|
||||
expect(isPluginRenderable({ ...baseParams, isActive: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("版本不兼容 → 不可渲染", () => {
|
||||
expect(
|
||||
isPluginRenderable({
|
||||
...baseParams,
|
||||
requiredShellVersion: "^2.0.0",
|
||||
currentShellVersion: "1.0.0",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("角色不匹配 → 不可渲染", () => {
|
||||
expect(
|
||||
isPluginRenderable({
|
||||
...baseParams,
|
||||
userRole: "parent",
|
||||
requiredRoles: ["teacher", "student"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requiredRoles 为空 → 任意角色可渲染", () => {
|
||||
expect(
|
||||
isPluginRenderable({
|
||||
...baseParams,
|
||||
requiredRoles: [],
|
||||
userRole: "admin",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
101
apps/portal-shell/src/shell/__tests__/Registry.test.ts
Normal file
101
apps/portal-shell/src/shell/__tests__/Registry.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REGISTRY, isPluginRegistered } from "@/shell/Registry";
|
||||
|
||||
/**
|
||||
* Registry 单元测试(portal-shell spec §5.3、§9.9)
|
||||
*
|
||||
* 覆盖:
|
||||
* - REGISTRY 包含全部 31 个内置插件
|
||||
* - isPluginRegistered 正确判断
|
||||
* - 每个插件 manifest 必填字段完整
|
||||
*/
|
||||
const EXPECTED_PLUGIN_IDS = [
|
||||
// universal(7)
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"schedule-widget",
|
||||
"attendance-widget",
|
||||
"exams-widget",
|
||||
"notifications-widget",
|
||||
"announcements-widget",
|
||||
// sidebar(4)
|
||||
"class-selector",
|
||||
"child-selector",
|
||||
"term-switcher",
|
||||
"quick-actions",
|
||||
// topbar(4)
|
||||
"notification-bell",
|
||||
"user-menu",
|
||||
"global-search",
|
||||
"locale-switcher",
|
||||
// teacher(4)
|
||||
"lesson-plan-editor",
|
||||
"question-bank",
|
||||
"textbook-manager",
|
||||
"scheduling-rules",
|
||||
// student(4)
|
||||
"error-book",
|
||||
"learning-path",
|
||||
"elective-selector",
|
||||
"ai-tutor",
|
||||
// parent(2)
|
||||
"child-overview",
|
||||
"leave-approval",
|
||||
// admin(6)
|
||||
"user-management",
|
||||
"rbac-manager",
|
||||
"plugin-manager",
|
||||
"school-settings",
|
||||
"audit-logs",
|
||||
"invitation-codes",
|
||||
] as const;
|
||||
|
||||
describe("REGISTRY", () => {
|
||||
it("包含全部 31 个内置插件", () => {
|
||||
expect(Object.keys(REGISTRY).length).toBe(EXPECTED_PLUGIN_IDS.length);
|
||||
});
|
||||
|
||||
it("每个预期 pluginId 都已注册", () => {
|
||||
for (const id of EXPECTED_PLUGIN_IDS) {
|
||||
const manifest = REGISTRY[id];
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest!.pluginId).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("每个插件 manifest 必填字段完整", () => {
|
||||
for (const id of EXPECTED_PLUGIN_IDS) {
|
||||
const manifest = REGISTRY[id];
|
||||
expect(manifest).toBeDefined();
|
||||
expect(manifest!.pluginId).toBeTruthy();
|
||||
expect(manifest!.version).toBeTruthy();
|
||||
expect(manifest!.requiredShellVersion).toBeTruthy();
|
||||
expect(manifest!.Component).toBeTruthy();
|
||||
expect(manifest!.metadata.displayName).toBeTruthy();
|
||||
expect(manifest!.metadata.description).toBeTruthy();
|
||||
expect(manifest!.metadata.category).toBeTruthy();
|
||||
expect(manifest!.metadata.requiredRoles).toBeInstanceOf(Array);
|
||||
expect(manifest!.metadata.defaultSlot).toBeTruthy();
|
||||
expect(manifest!.metadata.defaultSize).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("每个插件的 pluginId 与 key 一致", () => {
|
||||
for (const [key, manifest] of Object.entries(REGISTRY)) {
|
||||
expect(manifest.pluginId).toBe(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginRegistered", () => {
|
||||
it("已注册的 pluginId → true", () => {
|
||||
expect(isPluginRegistered("grades-widget")).toBe(true);
|
||||
expect(isPluginRegistered("plugin-manager")).toBe(true);
|
||||
expect(isPluginRegistered("notification-bell")).toBe(true);
|
||||
});
|
||||
|
||||
it("未注册的 pluginId → false", () => {
|
||||
expect(isPluginRegistered("nonexistent-plugin")).toBe(false);
|
||||
expect(isPluginRegistered("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* portal-shell 设计令牌入口(引用 @edu/ui-tokens)
|
||||
*
|
||||
* 业务代码通过 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";
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Tailwind 配置 - 对齐三层设计令牌
|
||||
*
|
||||
* Layer 3 映射:将 Layer 2 Semantic CSS 变量暴露为 Tailwind 类名
|
||||
* 业务代码使用 bg-paper / text-ink / font-serif 等语义类
|
||||
*
|
||||
* 禁止:
|
||||
* - 禁止 hex 字面量(colors 引用 var(--*))
|
||||
* - 禁止字体名字面量(fontFamily 引用 var(--font-family-*))
|
||||
*/
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
paper: "var(--bg-paper)",
|
||||
surface: "var(--bg-surface)",
|
||||
subtle: "var(--bg-subtle)",
|
||||
ink: {
|
||||
DEFAULT: "var(--color-ink)",
|
||||
muted: "var(--color-ink-muted)",
|
||||
subtle: "var(--color-ink-subtle)",
|
||||
onAccent: "var(--color-ink-on-accent)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "var(--color-accent)",
|
||||
hover: "var(--color-accent-hover)",
|
||||
subtle: "var(--color-accent-subtle)",
|
||||
},
|
||||
rule: {
|
||||
DEFAULT: "var(--color-rule)",
|
||||
strong: "var(--color-rule-strong)",
|
||||
},
|
||||
success: "var(--color-success)",
|
||||
warning: "var(--color-warning)",
|
||||
danger: "var(--color-danger)",
|
||||
info: "var(--color-info)",
|
||||
border: "var(--color-border)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: "var(--font-family-sans)",
|
||||
serif: "var(--font-family-serif)",
|
||||
mono: "var(--font-family-mono)",
|
||||
},
|
||||
fontSize: {
|
||||
body: "var(--font-size-body)",
|
||||
small: "var(--font-size-small)",
|
||||
tiny: "var(--font-size-tiny)",
|
||||
"heading-1": "var(--font-size-heading-1)",
|
||||
"heading-2": "var(--font-size-heading-2)",
|
||||
"heading-3": "var(--font-size-heading-3)",
|
||||
display: "var(--font-size-display)",
|
||||
"large-number": "var(--font-size-large-number)",
|
||||
},
|
||||
spacing: {
|
||||
xs: "var(--space-xs)",
|
||||
sm: "var(--space-sm)",
|
||||
md: "var(--space-md)",
|
||||
lg: "var(--space-lg)",
|
||||
xl: "var(--space-xl)",
|
||||
"2xl": "var(--space-2xl)",
|
||||
},
|
||||
borderRadius: {
|
||||
DEFAULT: "var(--radius-default)",
|
||||
card: "var(--radius-card)",
|
||||
button: "var(--radius-button)",
|
||||
},
|
||||
boxShadow: {
|
||||
sm: "var(--shadow-sm)",
|
||||
md: "var(--shadow-md)",
|
||||
lg: "var(--shadow-lg)",
|
||||
xl: "var(--shadow-xl)",
|
||||
},
|
||||
transitionDuration: {
|
||||
fast: "150ms",
|
||||
normal: "200ms",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -16,9 +16,16 @@
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/shared/*": ["./src/shared/*"],
|
||||
"@edu/hooks": ["../../packages/hooks/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
|
||||
},
|
||||
|
||||
@@ -2,17 +2,36 @@ import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* portal-shell 单元测试配置(v2.1 spec §9.9)
|
||||
*
|
||||
* 环境:jsdom(支持 React Testing Library)
|
||||
* 路径别名:与 tsconfig.json 对齐
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/__tests__/**/*.test.{ts,tsx}"],
|
||||
globals: true,
|
||||
setupFiles: [],
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
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",
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user