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

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

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

View File

@@ -0,0 +1,56 @@
/**
* 客户端错误上报端点mock 实现)
*
* 当前阶段:输出到 stdout便于开发调试
* 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log
*
* 端点POST /api/log
* Body: ErrorReportPayload见 @edu/hooks/use-error-report
*
* 关联portal-shell README v2.0 §5.4 三级错误处理
*/
import { NextResponse } from "next/server";
interface ErrorReportPayload {
level: "error" | "warning";
message: string;
stack?: string;
digest?: string;
path: string;
userAgent: string;
timestamp: string;
pluginId?: string;
userId?: string;
context?: Record<string, unknown>;
}
export async function POST(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as ErrorReportPayload;
// 开发阶段:结构化输出到 stdout
// 生产阶段:这里应替换为 OTel export 或 Sentry capture
console.error("[client-error]", {
level: payload.level,
message: payload.message,
digest: payload.digest,
path: payload.path,
pluginId: payload.pluginId,
userId: payload.userId,
timestamp: payload.timestamp,
// stack 太长,单独一行输出便于阅读
stack: payload.stack?.split("\n").slice(0, 5).join("\n"),
});
// 返回 204让 sendBeacon 认为成功
return new NextResponse(null, { status: 204 });
} catch {
// 解析失败也返回 204避免客户端重试
return new NextResponse(null, { status: 204 });
}
}
/** 健康检查 */
export function GET(): NextResponse {
return NextResponse.json({ ok: true, endpoint: "/api/log" });
}

View File

@@ -1,55 +1,62 @@
/**
* portal-shell 全局样式
* portal-shell 全局样式Tailwind v4 + shadcn 标准令牌)
*
* 引入 @edu/ui-tokens 三层设计令牌primitive → semantic → tailwind-theme
* 业务代码使用 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;
}
}

View File

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

View File

@@ -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 Componentv2.1 M8 验收点)
* Shell 入口RSC Server Componentv2.1 M8 验收点 + 流式渲染
*
* 数据流portal-shell spec §5.5
* 数据流portal-shell spec §5.5、README v2.0 §5.3 流式渲染
* ① 从请求头获取 userId / roleapi-gateway 注入 x-user-id / x-user-role
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig三层合并
* ③ Config 作为 props 传给 ClientShell随 HTML 直出,消除 CSR 瀑布流
* ③ Config Promise 直接传给 ClientShell由客户端 use() 消费,启用流式渲染:
* - HTML 流式输出loading.tsx 先行Promise resolve 后替换为真实 UI
* - 客户端 Suspense避免客户端瀑布流不用 useEffect 二次请求)
*
* 流式渲染分层README v2.0 §5.3
* - L1 路由级loading.tsx整页骨架fetchPluginConfig 进行中
* - L2 区块级DashboardSection单一区块骨架Suspense 包裹
* - L3 插件级PluginBoundary单插件骨架dynamic import + Suspense
*
* M8 验收portal-shell 查询走 apollo-routerfetchPluginConfig 经 Apollo Client
*
* 关联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} />
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
/**
* Shell 路由错误兜底Next.js App Router error.tsx
*
* 触发条件:
* - RSC 渲染抛错(如 fetchPluginConfig 失败)
* - ClientShell 渲染抛错Provider 嵌套问题)
* - 任何子 segment 未捕获的错误
*
* 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级):
* 1. 隔离错误,避免整页白屏
* 2. 通过 useErrorReport 上报到 /api/log
* 3. 提供 reset 按钮重试
*
* 注意error.tsx 必须是 Client Component"use client"
*
* 关联Next.js App Router § error.tsx、portal-shell README v2.0 §5.4
*/
export default function ShellError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactNode {
return <RouteErrorBoundary error={error} reset={reset} namespace="Shell" />;
}

View File

@@ -0,0 +1,104 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Shell 路由加载兜底Next.js App Router loading.tsx
*
* 触发条件:
* - RSC 正在解析fetchPluginConfig 等待中)
* - 路由切换时的过渡态
*
* 职责:
* - 整页骨架占位,避免白屏闪烁
* - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区)
*
* 流式渲染上下文portal-shell README v2.0 §5.3
* - loading.tsx 在 RSC Promise resolve 之前显示
* - 配合 PluginBoundarywidget 级 Suspense形成多层流式体验
*
* 关联Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3
*/
export default function ShellLoading(): React.ReactNode {
return (
<div className="flex min-h-screen flex-col bg-background">
{/* 顶栏 */}
<header className="border-b bg-card">
<div className="flex h-16 items-center gap-3 px-6">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-6 w-32" />
<div className="ml-auto flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="size-9 rounded-full" />
</div>
</div>
</header>
<div className="flex flex-1">
{/* 侧栏 */}
<aside className="w-64 border-r bg-card p-4">
<div className="space-y-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-4 flex-1" />
</div>
))}
</div>
</aside>
{/* 主区:仪表盘骨架 */}
<main className="flex-1 p-6">
{/* 标题区 */}
<div className="mb-6 flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-9 w-24 rounded-md" />
</div>
{/* 统计卡片网格 */}
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border bg-card p-6">
<Skeleton className="mb-3 h-4 w-24" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* 内容区:图表 + 列表 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
<Skeleton className="mb-4 h-6 w-32" />
<div className="flex h-64 items-end gap-2">
{[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map(
(h, i) => (
<Skeleton
key={i}
className="flex-1 rounded-t"
style={{ height: `${h}%` }}
/>
),
)}
</div>
</div>
<div className="rounded-xl border bg-card p-6">
<Skeleton className="mb-4 h-6 w-24" />
<div className="space-y-3">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
parseUrlContext,
writeUrlContext,
URL_CONTEXT_KEYS,
type UrlPluginContext,
} from "@edu/shared-ts/contracts";
/**
* URL Search Params 上下文测试portal-shell spec §5.2.1、§9.9
*
* 覆盖:
* - parseUrlContext从 URLSearchParams 解析上下文
* - writeUrlContext将上下文写入 URLSearchParams
* - URL_CONTEXT_KEYS常量正确性
*/
describe("URL_CONTEXT_KEYS", () => {
it("key 名称与 URL 参数名一致", () => {
expect(URL_CONTEXT_KEYS.classId).toBe("classId");
expect(URL_CONTEXT_KEYS.childId).toBe("childId");
expect(URL_CONTEXT_KEYS.termId).toBe("termId");
expect(URL_CONTEXT_KEYS.view).toBe("view");
expect(URL_CONTEXT_KEYS.subjectId).toBe("subjectId");
expect(URL_CONTEXT_KEYS.examId).toBe("examId");
});
});
describe("parseUrlContext", () => {
it("空参数 → 空上下文", () => {
const ctx = parseUrlContext(new URLSearchParams());
expect(ctx).toEqual({});
});
it("解析 classId", () => {
const params = new URLSearchParams("?classId=cls-001");
const ctx = parseUrlContext(params);
expect(ctx.classId).toBe("cls-001");
});
it("解析多个参数", () => {
const params = new URLSearchParams(
"?classId=cls-001&termId=2024-fall&view=chart",
);
const ctx = parseUrlContext(params);
expect(ctx).toEqual({
classId: "cls-001",
termId: "2024-fall",
view: "chart",
});
});
it("解析全部参数", () => {
const params = new URLSearchParams(
"?classId=cls-1&childId=child-1&termId=t-1&view=list&subjectId=subj-1&examId=exam-1",
);
const ctx = parseUrlContext(params);
expect(ctx).toEqual({
classId: "cls-1",
childId: "child-1",
termId: "t-1",
view: "list",
subjectId: "subj-1",
examId: "exam-1",
});
});
it("忽略空值参数", () => {
const params = new URLSearchParams("?classId=&termId=t-1");
const ctx = parseUrlContext(params);
expect(ctx.classId).toBeUndefined();
expect(ctx.termId).toBe("t-1");
});
});
describe("writeUrlContext", () => {
it("写入单个值", () => {
const params = new URLSearchParams();
writeUrlContext(params, { classId: "cls-001" });
expect(params.get("classId")).toBe("cls-001");
});
it("写入多个值", () => {
const params = new URLSearchParams();
writeUrlContext(params, {
classId: "cls-1",
termId: "t-1",
view: "chart",
});
expect(params.get("classId")).toBe("cls-1");
expect(params.get("termId")).toBe("t-1");
expect(params.get("view")).toBe("chart");
});
it("空字符串 → 删除参数", () => {
const params = new URLSearchParams("?classId=cls-1");
writeUrlContext(params, { classId: "" });
expect(params.has("classId")).toBe(false);
});
it("undefined → 保留原值不变", () => {
const params = new URLSearchParams("?classId=cls-1&termId=t-1");
writeUrlContext(params, { classId: "cls-2" });
// 只更新 classIdtermId 保留
expect(params.get("classId")).toBe("cls-2");
expect(params.get("termId")).toBe("t-1");
});
it("空对象 → 不修改任何参数", () => {
const params = new URLSearchParams("?classId=cls-1");
writeUrlContext(params, {});
expect(params.get("classId")).toBe("cls-1");
});
});
describe("parseUrlContext + writeUrlContext 往返", () => {
it("写入后解析应得到相同上下文", () => {
const original: UrlPluginContext = {
classId: "cls-001",
childId: "child-001",
termId: "2024-fall",
view: "list",
subjectId: "math",
examId: "exam-001",
};
const params = new URLSearchParams();
writeUrlContext(params, original);
const parsed = parseUrlContext(params);
expect(parsed).toEqual(original);
});
});

View File

@@ -9,6 +9,11 @@
* - 统一走 apollo-router GraphQL由 Router 路由到 config-service 子图
* - 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;
}
/**

View File

@@ -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 schemaadmin 配置面板用) */

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
"use client";
import { Suspense, type ReactNode } from "react";
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { useErrorReport } from "@edu/hooks";
import { cn } from "@/shared/lib/utils";
/**
* DashboardSection - 仪表盘分区包装器(对齐 CICD dashboard-section.tsx
*
* 三件套组合SectionErrorBoundary + Suspense + 5 种骨架变体
*
* 职责:
* 1. 隔离分区渲染错误(不影响其他分区)
* 2. 流式渲染Suspense 边界显示骨架屏,数据到达后替换
* 3. a11y传入 ariaLabel 时渲染 role="region" tabIndex={0}
*
* 5 种骨架变体:
* - stats统计卡片骨架大数字 + 标签)
* - card通用卡片骨架标题 + 内容块)
* - chart图表骨架坐标轴 + 柱状)
* - table表格骨架表头 + 多行)
* - list列表骨架多行
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L2 区块级)
*
* @example
* <DashboardSection title="今日课程" variant="table">
* <ScheduleList />
* </DashboardSection>
*/
export type DashboardSectionVariant =
"stats" | "card" | "chart" | "table" | "list";
export interface DashboardSectionProps {
/** 分区标题(显示在 CardHeader */
title?: string;
/** 分区描述(显示在 CardHeader */
description?: string;
/** 子节点(分区内容) */
children: ReactNode;
/** 骨架变体(默认 card */
variant?: DashboardSectionVariant;
/** a11y 标签(传入时渲染 role="region" tabIndex={0} */
ariaLabel?: string;
/** 右侧操作区(如"查看全部"链接) */
actions?: ReactNode;
/** 自定义类名 */
className?: string;
}
/**
* 5 种骨架变体实现
*/
export function DashboardSectionSkeleton({
variant = "card",
className,
}: {
variant?: DashboardSectionVariant;
className?: string;
}): ReactNode {
if (variant === "table") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/4" />
</CardHeader>
<CardContent className="space-y-2">
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
);
}
if (variant === "list") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/4" />
</CardHeader>
<CardContent className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
);
}
if (variant === "chart") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/3" />
</CardHeader>
<CardContent>
<div className="flex h-48 items-end gap-2">
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
<Skeleton
key={i}
className="flex-1 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</CardContent>
</Card>
);
}
if (variant === "stats") {
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/3" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
// card默认
return (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-1/3" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
);
}
/**
* DashboardSection - 仪表盘分区ErrorBoundary + Suspense + Skeleton
*/
export function DashboardSection({
title,
description,
children,
variant = "card",
ariaLabel,
actions,
className,
}: DashboardSectionProps): ReactNode {
const reportError = useErrorReport();
const sectionProps = ariaLabel
? { role: "region" as const, tabIndex: 0, "aria-label": ariaLabel }
: {};
return (
<section
{...sectionProps}
className={cn(
ariaLabel &&
"rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<SectionErrorBoundary
title={title}
onError={(error) => {
void reportError(error, {
level: "error",
context: { section: title },
});
}}
>
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
{(title || actions) && (
<div className="mb-4 flex items-center justify-between">
<div>
{title && (
<h2 className="text-lg font-semibold tracking-tight">
{title}
</h2>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2">{actions}</div>
)}
</div>
)}
{children}
</Suspense>
</SectionErrorBoundary>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { PageHeader } from "@/shared/components/ui/page-header";
import { StatsGrid } from "@/shared/components/ui/stats-grid";
import { cn } from "@/shared/lib/utils";
/**
* DashboardShell - 仪表盘外壳(对齐 CICD dashboard-shell.tsx
*
* 极简结构PageHeader + StatsGrid可选+ children
* - stats 为空数组时不渲染统计区(适配无统计指标的页面)
* - children 是页面主体内容
*
* @example
* <DashboardShell
* title="教师仪表盘"
* description="今日教学概览"
* stats={<StatCard title="班级" value={6} />}
* actions={<Button>导出</Button>}
* >
* <DashboardSection title="今日课程">
* <ScheduleList />
* </DashboardSection>
* </DashboardShell>
*/
export interface DashboardShellProps {
/** 页面标题 */
title: string;
/** 页面描述 */
description?: string;
/** 标题前图标 */
icon?: ReactNode;
/** 右侧操作区 */
actions?: ReactNode;
/** 统计卡片组(传入 StatsGrid 或多个 StatCard */
stats?: ReactNode;
/** 主体内容 */
children: ReactNode;
/** 自定义类名 */
className?: string;
}
export function DashboardShell({
title,
description,
icon,
actions,
stats,
children,
className,
}: DashboardShellProps): ReactNode {
return (
<div className={cn("space-y-6 p-6", className)}>
<PageHeader
title={title}
description={description}
icon={icon}
actions={actions}
/>
{stats && <StatsGrid>{stats}</StatsGrid>}
<div className="space-y-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import { useState, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Separator } from "@/shared/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/lib/utils";
import {
SIDEBAR_WIDTH_COLLAPSED,
SIDEBAR_WIDTH_EXPANDED,
useSidebar,
} from "./sidebar-provider";
/**
* AppSidebar - 侧边栏实现(对齐 CICD app-sidebar.tsx
*
* 关键设计:
* - 桌面端:<aside> + transition-[width]w-64 ↔ w-16
* - 折叠态:仅图标 + Tooltiphover 显示标题sr-only 标签保证 a11y
* - 展开态Collapsible 子菜单defaultOpen={isActive} 自动展开当前路由所在组)
* - 导航项按权限过滤hasPermission(item.permission)
*
* 注意本组件是基础组件库的一部分portal-shell 的 LayoutManager 在 P1 阶段
* 重构时将使用此组件替换现有 5 种布局模板中的侧边栏部分。
*
* 关联portal-shell README v2.0 §5.3 布局组件
*/
export interface NavItem {
/** 显示名称 */
title: string;
/** 跳转链接 */
href?: string;
/** 图标lucide-react 图标组件) */
icon?: React.ComponentType<{ className?: string }>;
/** 所需权限点(无权限不显示) */
permission?: string;
/** 子菜单 */
children?: NavItem[];
}
export interface AppSidebarProps {
/** 导航配置(按角色分组) */
items: NavItem[];
/** 权限检查函数(从 usePermission().hasPermission 注入) */
hasPermission?: (perm: string) => boolean;
/** 侧边栏底部内容(如用户信息、版本号) */
footer?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function AppSidebar({
items,
hasPermission,
footer,
className,
}: AppSidebarProps): ReactNode {
const { expanded } = useSidebar();
const pathname = usePathname();
// 权限过滤
const visibleItems = items.filter(
(item) => !item.permission || hasPermission?.(item.permission) !== false,
);
return (
<TooltipProvider delayDuration={200}>
<aside
className={cn(
"flex h-screen flex-col border-r bg-card transition-[width] duration-200",
expanded ? SIDEBAR_WIDTH_EXPANDED : SIDEBAR_WIDTH_COLLAPSED,
className,
)}
>
<nav className="flex-1 overflow-y-auto p-2">
<ul className="space-y-1">
{visibleItems.map((item) => (
<li key={item.title}>
<NavMenuItem
item={item}
expanded={expanded}
pathname={pathname}
hasPermission={hasPermission}
/>
</li>
))}
</ul>
</nav>
{footer && (
<>
<Separator />
<div className="p-2">{footer}</div>
</>
)}
</aside>
</TooltipProvider>
);
}
function NavMenuItem({
item,
expanded,
pathname,
hasPermission,
}: {
item: NavItem;
expanded: boolean;
pathname: string;
hasPermission?: (perm: string) => boolean;
}): ReactNode {
const isActive = item.href === pathname;
const visibleChildren = item.children?.filter(
(c) => !c.permission || hasPermission?.(c.permission) !== false,
);
// 折叠态:仅图标 + Tooltip
if (!expanded) {
const Icon = item.icon;
if (!Icon) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant={isActive ? "secondary" : "ghost"}
size="icon"
className="w-full"
>
<Link href={item.href ?? "#"}>
<Icon className="size-4" />
<span className="sr-only">{item.title}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
);
}
// 展开态 + 无子菜单
if (!visibleChildren?.length) {
const Icon = item.icon;
return (
<Button
asChild
variant={isActive ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start"
>
<Link href={item.href ?? "#"}>
{Icon && <Icon className="size-4" />}
<span>{item.title}</span>
</Link>
</Button>
);
}
// 展开态 + 有子菜单Collapsible
return (
<CollapsibleNavItem
item={item}
pathname={pathname}
hasPermission={hasPermission}
/>
);
}
function CollapsibleNavItem({
item,
pathname,
hasPermission,
}: {
item: NavItem;
pathname: string;
hasPermission?: (perm: string) => boolean;
}): ReactNode {
const visibleChildren =
item.children?.filter(
(c) => !c.permission || hasPermission?.(c.permission) !== false,
) ?? [];
const hasActiveChild = visibleChildren.some((c) => c.href === pathname);
const [open, setOpen] = useState(hasActiveChild);
const Icon = item.icon;
return (
<div>
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
onClick={() => setOpen((v) => !v)}
>
<span className="flex items-center gap-2">
{Icon && <Icon className="size-4" />}
<span>{item.title}</span>
</span>
{open ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
{open && (
<ul className="ml-4 mt-1 space-y-1 border-l pl-2">
{visibleChildren.map((child) => {
const isActive = child.href === pathname;
const ChildIcon = child.icon;
return (
<li key={child.title}>
<Button
asChild
variant={isActive ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start"
>
<Link href={child.href ?? "#"}>
{ChildIcon && <ChildIcon className="size-4" />}
<span>{child.title}</span>
</Link>
</Button>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { cn } from "@/shared/lib/utils";
/**
* SidebarProvider - 侧边栏状态容器(对齐 CICD sidebar-provider.tsx
*
* 职责:
* - 管理桌面端折叠状态expanded: w-64 ↔ w-16
* - 管理移动端 Sheet 开合openMobile
* - 自动检测 mobilewindow.innerWidth < 768resize 防抖 200ms
*
* 用法:
* <SidebarProvider>
* <AppSidebar />
* <main className="flex-1">...</main>
* </SidebarProvider>
*
* 关联portal-shell README v2.0 §5.3 布局组件
*/
const MOBILE_BREAKPOINT = 768;
export interface SidebarContextValue {
/** 桌面端是否展开 */
expanded: boolean;
/** 移动端 Sheet 是否打开 */
openMobile: boolean;
/** 是否移动端 */
isMobile: boolean;
/** 切换桌面端展开/折叠 */
toggleExpanded: () => void;
/** 设置桌面端展开状态 */
setExpanded: (v: boolean) => void;
/** 切换移动端 Sheet */
toggleMobile: () => void;
/** 设置移动端 Sheet */
setOpenMobile: (v: boolean) => void;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
export function SidebarProvider({
children,
defaultExpanded = true,
className,
}: {
children: ReactNode;
defaultExpanded?: boolean;
className?: string;
}): ReactNode {
const [expanded, setExpanded] = useState(defaultExpanded);
const [openMobile, setOpenMobile] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
check();
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(check, 200);
};
window.addEventListener("resize", debounced);
return () => {
window.removeEventListener("resize", debounced);
if (timer) clearTimeout(timer);
};
}, []);
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
const toggleMobile = useCallback(() => setOpenMobile((v) => !v), []);
const value = useMemo<SidebarContextValue>(
() => ({
expanded,
openMobile,
isMobile,
toggleExpanded,
setExpanded,
toggleMobile,
setOpenMobile,
}),
[expanded, openMobile, isMobile, toggleExpanded, toggleMobile],
);
return (
<SidebarContext.Provider value={value}>
<div className={cn("flex min-h-screen w-full", className)}>
{children}
</div>
</SidebarContext.Provider>
);
}
export function useSidebar(): SidebarContextValue {
const ctx = useContext(SidebarContext);
if (!ctx) {
throw new Error("useSidebar 必须在 <SidebarProvider> 内部使用");
}
return ctx;
}
/** 侧边栏宽度类名(展开 256px / 折叠 64px */
export const SIDEBAR_WIDTH_EXPANDED = "w-64";
export const SIDEBAR_WIDTH_COLLAPSED = "w-16";

View File

@@ -0,0 +1,96 @@
"use client";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight, Menu } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Separator } from "@/shared/components/ui/separator";
import { cn } from "@/shared/lib/utils";
import { useSidebar } from "./sidebar-provider";
/**
* SiteHeader - 顶部头部组件(对齐 CICD site-header.tsx
*
* 结构Mobile Toggle + Separator + Breadcrumb + 右侧 actions搜索/通知/头像)
* - sticky top-0 z-50 h-16 bg-background/95 backdrop-blur-sm
* - 面包屑从 pathname 自动生成
*
* 关联portal-shell README v2.0 §5.3 布局组件
*/
export interface SiteHeaderProps {
/** 面包屑映射表path → title未命中时 fallback 到首字母大写 */
breadcrumbMap?: Record<string, string>;
/** 右侧操作区(搜索/通知/头像等) */
actions?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function SiteHeader({
breadcrumbMap = {},
actions,
className,
}: SiteHeaderProps): ReactNode {
const pathname = usePathname();
const { toggleMobile, isMobile } = useSidebar();
const segments = pathname.split("/").filter(Boolean);
return (
<header
className={cn(
"sticky top-0 z-50 flex h-16 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur-sm supports-[backdrop-filter]:bg-background/60",
className,
)}
>
{isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleMobile}
className="md:hidden"
>
<Menu className="size-5" />
<span className="sr-only"></span>
</Button>
)}
<Separator orientation="vertical" className="mx-1 h-6" />
{/* 面包屑 */}
<nav aria-label="面包屑" className="flex items-center gap-1 text-sm">
<Link
href="/"
className="text-muted-foreground transition-colors hover:text-foreground"
>
</Link>
{segments.map((seg, idx) => {
const href = "/" + segments.slice(0, idx + 1).join("/");
const isLast = idx === segments.length - 1;
const title =
breadcrumbMap[href] ?? seg.charAt(0).toUpperCase() + seg.slice(1);
return (
<span key={href} className="flex items-center gap-1">
<ChevronRight className="size-3 text-muted-foreground" />
{isLast ? (
<span className="font-medium text-foreground">{title}</span>
) : (
<Link
href={href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{title}
</Link>
)}
</span>
);
})}
</nav>
<div className="ml-auto flex items-center gap-2">{actions}</div>
</header>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { Suspense, type ReactNode } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
import { useErrorReport } from "@edu/hooks";
import { ErrorBoundary } from "@edu/ui-components";
/**
* PluginBoundary - 插件级错误边界 + 流式 Suspense替代 PluginLoader
*
* 三件套组合ErrorBoundary + Suspense + Skeleton
* 职责:
* 1. 隔离单个插件渲染错误,不影响其他插件和 Shell
* 2. 插件 dynamic import 期间显示骨架屏(流式渲染)
* 3. 错误自动上报到 /api/log通过 onError 回调,避免 fallback render phase 副作用)
*
* 5 种骨架变体(对齐 CICD DashboardSectionSkeleton
* - card通用卡片骨架标题 + 内容块)
* - list列表骨架多行
* - chart图表骨架坐标轴 + 柱状)
* - stats统计数据骨架大数字 + 标签)
* - table表格骨架表头 + 多行)
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L3 插件级)
*/
export type PluginSkeletonVariant =
"card" | "list" | "chart" | "stats" | "table";
export interface PluginBoundaryProps {
/** 插件实例 ID用于错误标识和上报 */
pluginId: string;
/** 子节点(插件组件) */
children: ReactNode;
/** 骨架变体(默认 card */
skeletonVariant?: PluginSkeletonVariant;
/** 自定义类名 */
className?: string;
}
/**
* 5 种骨架变体实现(对齐 CICD DashboardSectionSkeleton
*/
export function PluginSkeleton({
variant = "card",
className,
}: {
variant?: PluginSkeletonVariant;
className?: string;
}): ReactNode {
if (variant === "table") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("space-y-3 rounded-xl border bg-card p-6", className)}
>
<Skeleton className="h-6 w-1/4" />
<div className="space-y-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
);
}
if (variant === "list") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("space-y-2", className)}
>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (variant === "chart") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("rounded-xl border bg-card p-6", className)}
>
<Skeleton className="mb-4 h-6 w-1/3" />
<div className="flex h-40 items-end gap-2">
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
<Skeleton
key={i}
className="flex-1 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</div>
);
}
if (variant === "stats") {
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("rounded-xl border bg-card p-6", className)}
>
<Skeleton className="mb-4 h-6 w-1/3" />
<div className="flex gap-4">
{[0, 1, 2].map((i) => (
<div key={i} className="flex-1 space-y-2">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
</div>
);
}
// card默认
return (
<div
role="status"
aria-label="加载中"
aria-live="polite"
className={cn("rounded-xl border bg-card p-6", className)}
>
<Skeleton className="mb-4 h-6 w-1/3" />
<Skeleton className="h-8 w-1/2" />
</div>
);
}
/**
* 插件错误兜底 UI纯展示组件不上报——上报由外层 onError 负责)
*/
function PluginErrorFallback({
pluginId,
error,
onReset,
}: {
pluginId: string;
error: Error;
onReset: () => void;
}): ReactNode {
return (
<div
role="alert"
aria-live="assertive"
className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6"
>
<AlertCircle className="size-8 text-destructive" />
<div className="text-center">
<p className="text-sm font-medium"></p>
<p className="mt-1 text-xs text-muted-foreground">
{pluginId}: {error.message}
</p>
</div>
<Button onClick={onReset} variant="outline" size="sm">
<RefreshCw className="size-4" />
</Button>
</div>
);
}
/**
* PluginBoundary - 插件错误边界 + 流式 Suspense
*
* @example
* <PluginBoundary pluginId="grades-widget" skeletonVariant="table">
* <GradesWidget {...pluginProps} />
* </PluginBoundary>
*/
export function PluginBoundary({
pluginId,
children,
skeletonVariant = "card",
className,
}: PluginBoundaryProps): ReactNode {
const reportError = useErrorReport();
return (
<ErrorBoundary
fallback={(error, reset) => (
<PluginErrorFallback
pluginId={pluginId}
error={error}
onReset={reset}
/>
)}
onError={(error) => {
void reportError(error, { pluginId, level: "error" });
}}
>
<Suspense
fallback={
<PluginSkeleton variant={skeletonVariant} className={className} />
}
>
{children}
</Suspense>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { useErrorReport } from "@edu/hooks";
/**
* RouteErrorBoundary - 路由级错误兜底(用于 app/shell/error.tsx
*
* Next.js App Router 的 error.tsx 接收 { error, reset } props
* - error: 触发的错误实例(含 digest
* - reset: 重置错误边界,重新渲染 Route Segment
*
* 本组件职责:
* 1. 上报错误到 /api/log通过 useErrorReport
* 2. 渲染统一错误 UI图标 + 标题 + 描述 + 重试按钮)
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L1 路由级)
*
* @example
* // app/shell/error.tsx
* "use client";
* import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
* export default function ShellError({ error, reset }) {
* return <RouteErrorBoundary error={error} reset={reset} namespace="shell" />;
* }
*/
export interface RouteErrorBoundaryProps {
/** Next.js error.tsx 注入的错误实例 */
error: Error & { digest?: string };
/** Next.js error.tsx 注入的重置函数 */
reset: () => void;
/** 命名空间(用于错误标题,如 "shell" / "admin" / "teacher" */
namespace?: string;
}
export function RouteErrorBoundary({
error,
reset,
namespace = "page",
}: RouteErrorBoundaryProps): React.ReactNode {
const reportError = useErrorReport();
useEffect(() => {
void reportError(error, { level: "error" });
}, [error, reportError]);
return (
<div
role="alert"
aria-live="assertive"
className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8"
>
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-6 text-destructive" />
</div>
<div className="text-center">
<h2 className="text-lg font-semibold">{namespace}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{error.message || "发生未知错误,请稍后重试"}
</p>
{error.digest && (
<p className="mt-2 text-xs text-muted-foreground/70">
{error.digest}
</p>
)}
</div>
<Button onClick={reset} variant="outline" size="sm">
<RefreshCw className="size-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
/**
* SectionErrorBoundary - 区块级错误边界(用于 DashboardSection 内)
*
* 职责:隔离单个区块(如统计卡片组、图表区、列表区)的渲染错误,
* 不影响其他区块和整个页面。
*
* 与 RouteErrorBoundary 的区别:
* - RouteErrorBoundary整页崩溃兜底由 Next.js error.tsx 触发
* - SectionErrorBoundary区块崩溃隔离由 DashboardSection 内部挂载
*
* 与 PluginBoundary 的区别:
* - PluginBoundary单个插件崩溃隔离含 Suspense + Skeleton
* - SectionErrorBoundary区块级可能含多个插件无 Suspense
*
* 关联portal-shell README v2.0 §5.4 三级错误处理L2 区块级)
*/
export interface SectionErrorBoundaryProps {
children: ReactNode;
/** 区块标题(用于错误 UI 显示,如 "统计概览" */
title?: string;
/** 自定义错误降级 UI */
fallback?: (error: Error, reset: () => void) => ReactNode;
/** 错误回调(上报) */
onError?: (error: Error, info: ErrorInfo) => void;
/** 自定义类名 */
className?: string;
}
interface SectionErrorBoundaryState {
error: Error | null;
}
export class SectionErrorBoundary extends Component<
SectionErrorBoundaryProps,
SectionErrorBoundaryState
> {
override state: SectionErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): SectionErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
this.props.onError?.(error, info);
}
reset = (): void => {
this.setState({ error: null });
};
override render(): ReactNode {
const { error } = this.state;
const { children, fallback, title, className } = this.props;
if (error) {
if (fallback) {
return fallback(error, this.reset);
}
return (
<div
role="alert"
aria-live="assertive"
className={cn(
"flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6",
className,
)}
>
<AlertCircle className="size-8 text-destructive" />
<div className="text-center">
<p className="text-sm font-medium">{title ?? "区块加载失败"}</p>
<p className="mt-1 text-xs text-muted-foreground">
{error.message}
</p>
</div>
<Button onClick={this.reset} variant="outline" size="sm">
<RefreshCw className="size-4" />
</Button>
</div>
);
}
return children;
}
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps): React.ReactNode {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps): React.ReactNode {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground rounded-xl border shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold tracking-tight", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("p-6 pt-0", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,90 @@
import { type ReactNode, memo } from "react";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import { Button, type ButtonProps } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
/**
* EmptyState - 空态/错误降级展示(对齐 CICD empty-state.tsx
*
* 用途:
* - 空列表(如"暂无成绩记录"
* - 空搜索结果(如"未找到匹配项"
* - 错误降级(配合 ErrorBoundary
*
* React.memo 优化:高频渲染场景避免无谓重渲染
*
* @example
* <EmptyState
* icon={InboxIcon}
* title="暂无数据"
* description="点击下方按钮添加第一条记录"
* action={{ label: "添加", href: "/new", onClick: handleAdd }}
* />
*/
export interface EmptyStateAction {
/** 按钮文字 */
label: string;
/** 跳转链接(与 onClick 二选一) */
href?: string;
/** 点击回调(与 href 二选一) */
onClick?: () => void;
/** 按钮变体(默认 outline */
variant?: ButtonProps["variant"];
}
export interface EmptyStateProps {
/** 图标lucide-react 图标组件) */
icon?: LucideIcon;
/** 标题 */
title: string;
/** 描述文字 */
description?: string;
/** 操作按钮 */
action?: EmptyStateAction;
/** 自定义类名(默认最小高度 450px */
className?: string;
}
export const EmptyState = memo(function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps): ReactNode {
return (
<div
className={cn(
"flex min-h-[400px] flex-col items-center justify-center gap-4 p-8 text-center",
className,
)}
>
{Icon && (
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Icon className="size-6 text-muted-foreground" />
</div>
)}
<div className="space-y-1">
<p className="text-lg font-semibold">{title}</p>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{action &&
(action.href ? (
<Button asChild variant={action.variant ?? "outline"}>
<Link href={action.href}>{action.label}</Link>
</Button>
) : (
<Button
onClick={action.onClick}
variant={action.variant ?? "outline"}
>
{action.label}
</Button>
))}
</div>
);
});

View File

@@ -0,0 +1,118 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { cn } from "@/shared/lib/utils";
/**
* FilterBar - 筛选栏布局容器(对齐 CICD filter-bar.tsx
*
* 三种布局变体:
* - default左对齐默认
* - wrap自动换行筛选条件多时
* - between两端对齐左筛选 + 右操作)
*
* 移动端纵向 flex-col桌面端 md:flex-row md:items-center
* URL 状态管理方式由各模块自行处理FilterBar 只负责布局
*
* @example
* <FilterBar variant="between">
* <FilterSearchInput placeholder="搜索..." value={q} onChange={setQ} />
* <FilterResetButton onClick={reset} />
* <Button>新建</Button>
* </FilterBar>
*/
export interface FilterBarProps {
children: ReactNode;
/** 布局变体 */
variant?: "default" | "wrap" | "between";
/** 自定义类名 */
className?: string;
}
const VARIANT_CLASS: Record<NonNullable<FilterBarProps["variant"]>, string> = {
default: "md:flex-row md:items-center",
wrap: "md:flex-row md:items-center md:flex-wrap",
between: "md:flex-row md:items-center md:justify-between",
};
export function FilterBar({
children,
variant = "default",
className,
}: FilterBarProps): ReactNode {
return (
<div
className={cn("flex flex-col gap-2", VARIANT_CLASS[variant], className)}
>
{children}
</div>
);
}
/**
* FilterSearchInput - 带搜索图标的输入框
*
* 固定宽度 md:w-80移动端 100%
*/
export function FilterSearchInput({
placeholder = "搜索...",
value,
onChange,
className,
}: {
placeholder?: string;
value: string;
onChange: (v: string) => void;
className?: string;
}): ReactNode {
return (
<div className={cn("relative w-full md:w-80", className)}>
<svg
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Input
type="search"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9"
/>
</div>
);
}
/**
* FilterResetButton - 重置筛选按钮
*/
export function FilterResetButton({
onClick,
className,
}: {
onClick: () => void;
className?: string;
}): ReactNode {
return (
<Button
variant="ghost"
size="sm"
onClick={onClick}
className={cn("h-9", className)}
>
<X className="size-4" />
</Button>
);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
/**
* Input - shadcn 输入框(基础组件)
*
* 对齐 shadcn/ui 标准 Input 实现。
* 关联components.json aliases.ui
*/
function Input({
className,
type,
...props
}: React.ComponentProps<"input">): React.ReactNode {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/utils";
/**
* PageHeader - 页面标题区(对齐 CICD page-header.tsx
*
* 结构:左侧(图标 + 标题 + 描述)+ 右侧 actions
* 响应式:移动端纵向 flex-col桌面端 md:flex-row md:items-center
*
* @example
* <PageHeader
* title="成绩管理"
* description="查看和管理学生成绩"
* icon={<GraduationCap />}
* actions={<Button>导出</Button>}
* />
*/
export interface PageHeaderProps {
/** 页面标题 */
title: string;
/** 描述文字(可选) */
description?: string;
/** 标题前图标(可选) */
icon?: ReactNode;
/** 右侧操作区(按钮、筛选器等) */
actions?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function PageHeader({
title,
description,
icon,
actions,
className,
}: PageHeaderProps): ReactNode {
return (
<div
className={cn(
"flex flex-col gap-4 md:flex-row md:items-center md:justify-between",
className,
)}
>
<div className="flex items-start gap-3">
{icon && (
<div className="mt-1 text-muted-foreground [&_svg]:size-7">
{icon}
</div>
)}
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
{title}
</h1>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/shared/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>): React.ReactNode {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-[1px] data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[1px]",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/shared/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactNode {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,38 @@
"use client";
import { Toaster as Sonner } from "sonner";
import { usePluginStore } from "@/shell/PluginStore";
type ToasterProps = React.ComponentProps<typeof Sonner>;
/**
* Toast 容器(基于 sonner
*
* 主题跟随 portal-shell PluginStore.themelight/dark不依赖 next-themes。
* 业务代码通过 `import { toast } from "sonner"` 直接调用。
*/
function Toaster({ ...props }: ToasterProps): React.ReactNode {
const theme = usePluginStore((s) => s.theme);
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
}
export { Toaster };

View File

@@ -0,0 +1,127 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
/**
* StatCard - 统计卡片(对齐 CICD stat-card.tsx
*
* 结构CardHeader标题 + 图标)+ CardContent数值 + 描述)
* - 加载态StatCardSkeleton
* - 高亮态border-amber-200 bg-amber-50/50用于关键指标
* - 可点击href 传入则包裹 Linkhover 微交互
*
* @example
* <StatCard
* title="学生总数"
* value={1234}
* icon={UsersIcon}
* description="较上月 +12"
* href="/admin/users"
* />
*/
export interface StatCardProps {
/** 卡片标题 */
title: string;
/** 数值(数字或字符串) */
value: number | string;
/** 图标lucide-react 图标组件) */
icon?: LucideIcon;
/** 描述文字(如"较上月 +12" */
description?: string;
/** 是否高亮(关键指标,默认 false */
highlight?: boolean;
/** 点击跳转链接 */
href?: string;
/** 是否加载中 */
isLoading?: boolean;
/** 数值类名(如 tabular-nums */
valueClassName?: string;
/** 自定义类名 */
className?: string;
}
export function StatCard({
title,
value,
icon: Icon,
description,
highlight = false,
href,
isLoading = false,
valueClassName,
className,
}: StatCardProps): ReactNode {
if (isLoading) {
return <StatCardSkeleton className={className} />;
}
const content = (
<Card
className={cn(
"transition-all",
href && "hover:-translate-y-1 hover:shadow-md",
highlight && "border-amber-200 bg-amber-50/50",
className,
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{Icon && <Icon className="size-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div
className={cn("text-2xl font-bold tracking-tight", valueClassName)}
>
{value}
</div>
{description && (
<CardDescription className="mt-1 text-xs">
{description}
</CardDescription>
)}
</CardContent>
</Card>
);
if (href) {
return (
<Link href={href} className="block">
{content}
</Link>
);
}
return content;
}
/** StatCard 骨架屏 */
export function StatCardSkeleton({
className,
}: {
className?: string;
}): ReactNode {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="size-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-16" />
<Skeleton className="mt-2 h-3 w-20" />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/utils";
/**
* StatsGrid - 统计卡片网格(对齐 CICD stats-grid.tsx
*
* 响应式列数mobile=1, md=2, lg=N由 columns prop 控制)
* 统一 isLoading 透传到所有子 StatCard
*
* @example
* <StatsGrid columns={4} isLoading={loading}>
* <StatCard title="学生" value={100} />
* <StatCard title="教师" value={20} />
* </StatsGrid>
*/
export interface StatsGridProps {
/** 子节点(通常是多个 StatCard */
children: ReactNode;
/** 桌面端列数1-5默认 4 */
columns?: 1 | 2 | 3 | 4 | 5;
/** 自定义类名 */
className?: string;
}
const COLUMNS_CLASS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
5: "md:grid-cols-5",
};
export function StatsGrid({
children,
columns = 4,
className,
}: StatsGridProps): ReactNode {
return (
<div
className={cn(
"grid grid-cols-1 gap-4",
COLUMNS_CLASS[columns],
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/utils";
/**
* Tooltip - shadcn 提示组件
*
* 对齐 shadcn/ui 标准 Tooltip 实现。
* 关联components.json aliases.ui
*/
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>): React.ReactNode {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,73 @@
"use client";
/**
* notify - 统一 Toast 通知封装(对齐 CICD notify.ts
*
* 业务代码统一通过 notify 调用,禁止直接 `import { toast } from "sonner"`。
* 优势:便于测试 mock、未来替换底层库、统一 i18n 入口。
*
* 用法:
* import { notify } from "@/shared/lib/notify";
* notify.success("保存成功");
* notify.error("网络错误");
* notify.promise(asyncFn, { loading: "保存中...", success: "成功", error: "失败" });
*
* 关联portal-shell README v2.0 §5.4
*/
import { toast, type ExternalToast } from "sonner";
type Message = string;
interface NotifyPromiseOptions<T> {
loading: Message;
success: Message | ((data: T) => Message);
error: Message | ((error: unknown) => Message);
}
export const notify = {
/** 成功提示(默认 4 秒) */
success(message: Message, options?: ExternalToast): void {
toast.success(message, options);
},
/** 错误提示(默认 6 秒,更长便于阅读) */
error(message: Message, options?: ExternalToast): void {
toast.error(message, { duration: 6000, ...options });
},
/** 警告提示 */
warning(message: Message, options?: ExternalToast): void {
toast.warning(message, options);
},
/** 信息提示 */
info(message: Message, options?: ExternalToast): void {
toast.info(message, options);
},
/** 带加载状态的 Promise 提示(透传原 Promise便于链式调用 */
promise<T>(
promise: Promise<T>,
options: NotifyPromiseOptions<T>,
): Promise<T> {
toast.promise(promise, options);
return promise;
},
/** 加载中提示(返回 toast id可用 toast.dismiss(id) 关闭) */
loading(message: Message, options?: ExternalToast): string | number {
return toast.loading(message, options);
},
/** 自定义提示escape hatch业务慎用 */
message(message: Message, options?: ExternalToast): void {
toast(message, options);
},
/** 关闭所有提示 */
dismiss(): void {
toast.dismiss();
},
} as const;
export { toast as rawToast } from "sonner";

View File

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

View File

@@ -0,0 +1,54 @@
/**
* 类名合并 + 通用工具函数(对齐 CICD 项目 src/shared/lib/utils.ts
*
* shadcn/ui 组件统一通过 `@/shared/lib/utils` 引用 cn()。
* 关联project_rules §3.9、components.json aliases.utils
*/
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/** Next.js App Router 搜索参数类型 */
export type SearchParams = { [key: string]: string | string[] | undefined };
/** 从 SearchParams 中安全提取单个字符串值 */
export function getSearchParam(
params: SearchParams,
key: string,
): string | undefined {
const v = params[key];
if (typeof v === "string") return v;
if (Array.isArray(v)) return v[0];
return undefined;
}
/** 格式化数字null/undefined/非有限数返回 "-" */
export function formatNumber(v: number | null | undefined, digits = 1): string {
if (typeof v !== "number" || !Number.isFinite(v)) return "-";
return v.toFixed(digits);
}
/**
* 从姓名生成头像占位用的首字母(最多 2 个字符)。
* 用于 AvatarFallback 组件。
* - 含空格的姓名:取各单词首字母拼接(如 "John Doe" -> "JD"
* - 无空格的姓名:取前 2 个字符(如 "张三" -> "张三"
* - 空值:返回 "U"User 通用占位)
*/
export function getInitials(name: string | null | undefined): string {
if (!name) return "U";
const trimmed = name.trim();
if (!trimmed) return "U";
if (trimmed.includes(" ")) {
return trimmed
.split(/\s+/)
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
return trimmed.slice(0, 2).toUpperCase();
}

View File

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

View File

@@ -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 {
/** classicTopBar + 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 {
/** focusTopBar + 全宽 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 {
/** splitTopBar + 左右等分 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 {
/** tripleTopBar + 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 {
/** canvasTopBar + 自由摆放 gridMVP 按 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}

View File

@@ -0,0 +1,114 @@
/**
* PluginLifecycle - 插件生命周期管理portal-shell spec §5.4
*
* 插件生命周期阶段:
* registered → enabled → loaded → active → disabled → uninstalled
*
* 内置插件:
* - registered编译时登记到 Registrysrc/shell/Registry.tsx
* - enabledadmin 通过 config-service 启用plugin_registry.is_active
* - loadeddynamic import 加载PluginLoader
* - active渲染并挂载SlotRenderer
* - disabledadmin 禁用,不渲染
* - uninstalled不可卸载内置
*
* 第三方插件(二期):
* - registered安装时登记到 DBplugin_packages 表)
* - loadedscript 注入加载(沙箱 iframe + postMessage
* - uninstalledadmin 卸载,删除包
*
* 关联portal-shell spec §5.4、§9.1
*/
/** 插件生命周期阶段 */
export type PluginLifecyclePhase =
"registered" | "enabled" | "loaded" | "active" | "disabled" | "uninstalled";
/** 插件状态转换结果 */
export interface LifecycleTransitionResult {
success: boolean;
message: string;
currentPhase: PluginLifecyclePhase;
}
/**
* 校验插件版本兼容性
*
* 插件 manifest 携带 requiredShellVersionShell 启动时校验,
* 不兼容则拒绝加载并提示 admin 升级。
*
* @param requiredShellVersion 插件要求的 Shell 版本范围semver range
* @param currentShellVersion 当前 Shell 版本
*/
export function checkVersionCompatibility(
requiredShellVersion: string,
currentShellVersion: string,
): boolean {
// MVP 简化实现:只校验 major 版本
// 完整 semver range 校验待引入 semver 库
const requiredMajor = parseMajorVersion(requiredShellVersion);
const currentMajor = parseMajorVersion(currentShellVersion);
if (requiredMajor === null || currentMajor === null) {
return true; // 无法解析时放行
}
return requiredMajor === currentMajor;
}
/**
* 计算插件从注册到激活的转换路径
*
* @param isBuiltin 是否内置插件
* @param isAdminEnabled admin 是否已启用
* @returns 转换路径描述
*/
export function getActivationPath(
isBuiltin: boolean,
isAdminEnabled: boolean,
): PluginLifecyclePhase[] {
if (!isAdminEnabled) {
return ["registered", "disabled"];
}
if (isBuiltin) {
return ["registered", "enabled", "loaded", "active"];
}
// 第三方插件(二期)
return ["registered", "enabled", "loaded", "active"];
}
/**
* 判断插件是否可渲染
*
* 综合考虑admin 启用状态 + 版本兼容性 + 角色权限
*/
export function isPluginRenderable(params: {
isActive: boolean;
requiredShellVersion: string;
currentShellVersion: string;
userRole: string;
requiredRoles: string[];
}): boolean {
const {
isActive,
requiredShellVersion,
currentShellVersion,
userRole,
requiredRoles,
} = params;
if (!isActive) return false;
if (!checkVersionCompatibility(requiredShellVersion, currentShellVersion)) {
return false;
}
if (requiredRoles.length > 0 && !requiredRoles.includes(userRole)) {
return false;
}
return true;
}
/** 解析 semver 字符串的 major 版本号 */
function parseMajorVersion(version: string): number | null {
// 移除 ^ ~ >= 等 range 前缀
const cleaned = version.replace(/^[^0-9]*/, "");
const match = cleaned.match(/^(\d+)/);
return match?.[1] ? Number(match[1]) : null;
}

View File

@@ -1,151 +1,22 @@
"use client";
/**
* PluginLoader - 插件加载器v2.1 M8
* PluginLoader - 已弃用,改为 re-exportv2.1 M8 + 流式渲染
*
* 职责:
* - PluginSkeleton5 种 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";

View File

@@ -6,27 +6,73 @@
* 编译时登记内置插件plugin_id → { Componentdynamic import, metadata }。
* 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。
*
* 内置插件M8 验证管道4 个示例)
* - grades-widgetuniversal / main
* - notification-belltopbar / top
* - user-menutopbar / top
* - class-selectorsidebar / side
* 内置插件共 28 个,按 spec §3 分 7 类
* - universal7grades / homework / schedule / attendance / exams / notifications / announcements
* - sidebar4class-selector / child-selector / term-switcher / quick-actions
* - topbar4notification-bell / user-menu / global-search / locale-switcher
* - teacher4lesson-plan-editor / question-bank / textbook-manager / scheduling-rules
* - student4error-book / learning-path / elective-selector / ai-tutor
* - parent2child-overview / leave-approval
* - admin3user-management / rbac-manager / plugin-manager
*
* 关联portal-shell spec §2.2、§5.3
* 关联portal-shell spec §2.2、§5.3、§3
*/
import dynamic from "next/dynamic";
import type { PluginManifest } from "@/lib/types";
import { PluginSkeleton } from "@/shell/PluginLoader";
// universal7
import { manifestMeta as gradesWidgetMeta } from "@/widgets/universal/grades-widget/plugin.manifest";
import { manifestMeta as homeworkWidgetMeta } from "@/widgets/universal/homework-widget/plugin.manifest";
import { manifestMeta as scheduleWidgetMeta } from "@/widgets/universal/schedule-widget/plugin.manifest";
import { manifestMeta as attendanceWidgetMeta } from "@/widgets/universal/attendance-widget/plugin.manifest";
import { manifestMeta as examsWidgetMeta } from "@/widgets/universal/exams-widget/plugin.manifest";
import { manifestMeta as notificationsWidgetMeta } from "@/widgets/universal/notifications-widget/plugin.manifest";
import { manifestMeta as announcementsWidgetMeta } from "@/widgets/universal/announcements-widget/plugin.manifest";
// sidebar4
import { manifestMeta as classSelectorMeta } from "@/widgets/sidebar/class-selector/plugin.manifest";
import { manifestMeta as childSelectorMeta } from "@/widgets/sidebar/child-selector/plugin.manifest";
import { manifestMeta as termSwitcherMeta } from "@/widgets/sidebar/term-switcher/plugin.manifest";
import { manifestMeta as quickActionsMeta } from "@/widgets/sidebar/quick-actions/plugin.manifest";
// topbar4
import { manifestMeta as notificationBellMeta } from "@/widgets/topbar/notification-bell/plugin.manifest";
import { manifestMeta as 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";
// teacher4
import { manifestMeta as lessonPlanEditorMeta } from "@/widgets/teacher/lesson-plan-editor/plugin.manifest";
import { manifestMeta as questionBankMeta } from "@/widgets/teacher/question-bank/plugin.manifest";
import { manifestMeta as textbookManagerMeta } from "@/widgets/teacher/textbook-manager/plugin.manifest";
import { manifestMeta as schedulingRulesMeta } from "@/widgets/teacher/scheduling-rules/plugin.manifest";
// student4
import { manifestMeta as errorBookMeta } from "@/widgets/student/error-book/plugin.manifest";
import { manifestMeta as learningPathMeta } from "@/widgets/student/learning-path/plugin.manifest";
import { manifestMeta as electiveSelectorMeta } from "@/widgets/student/elective-selector/plugin.manifest";
import { manifestMeta as aiTutorMeta } from "@/widgets/student/ai-tutor/plugin.manifest";
// parent2
import { manifestMeta as childOverviewMeta } from "@/widgets/parent/child-overview/plugin.manifest";
import { manifestMeta as leaveApprovalMeta } from "@/widgets/parent/leave-approval/plugin.manifest";
// admin6
import { manifestMeta as userManagementMeta } from "@/widgets/admin/user-management/plugin.manifest";
import { manifestMeta as rbacManagerMeta } from "@/widgets/admin/rbac-manager/plugin.manifest";
import { manifestMeta as pluginManagerMeta } from "@/widgets/admin/plugin-manager/plugin.manifest";
import { manifestMeta as schoolSettingsMeta } from "@/widgets/admin/school-settings/plugin.manifest";
import { manifestMeta as auditLogsMeta } from "@/widgets/admin/audit-logs/plugin.manifest";
import { manifestMeta as invitationCodesMeta } from "@/widgets/admin/invitation-codes/plugin.manifest";
/**
* 内置插件注册表。
* Component 使用 next/dynamic 懒加载ssr:false避免插件 JS 阻塞首屏。
* loading 展示骨架屏变体,与目标 slot 视觉一致。
*/
export const REGISTRY: Record<string, PluginManifest> = {
// ─── universalmain 区跨角色) ───────────────────────────────
"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" />,
},
),
},
// ─── sidebarside 区) ──────────────────────────────────────
"class-selector": {
...classSelectorMeta,
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
"child-selector": {
...childSelectorMeta,
Component: dynamic(() => import("@/widgets/sidebar/child-selector"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
"term-switcher": {
...termSwitcherMeta,
Component: dynamic(() => import("@/widgets/sidebar/term-switcher"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"quick-actions": {
...quickActionsMeta,
Component: dynamic(() => import("@/widgets/sidebar/quick-actions"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
// ─── topbartop 区) ────────────────────────────────────────
"notification-bell": {
...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" />,
}),
},
// ─── teachermain 区教师专属) ───────────────────────────────
"lesson-plan-editor": {
...lessonPlanEditorMeta,
Component: dynamic(() => import("@/widgets/teacher/lesson-plan-editor"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"question-bank": {
...questionBankMeta,
Component: dynamic(() => import("@/widgets/teacher/question-bank"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
"textbook-manager": {
...textbookManagerMeta,
Component: dynamic(() => import("@/widgets/teacher/textbook-manager"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
"scheduling-rules": {
...schedulingRulesMeta,
Component: dynamic(() => import("@/widgets/teacher/scheduling-rules"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
// ─── studentmain 区学生专属) ───────────────────────────────
"error-book": {
...errorBookMeta,
Component: dynamic(() => import("@/widgets/student/error-book"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
"learning-path": {
...learningPathMeta,
Component: dynamic(() => import("@/widgets/student/learning-path"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"elective-selector": {
...electiveSelectorMeta,
Component: dynamic(() => import("@/widgets/student/elective-selector"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
"ai-tutor": {
...aiTutorMeta,
Component: dynamic(() => import("@/widgets/student/ai-tutor"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
// ─── parentmain 区家长专属) ────────────────────────────────
"child-overview": {
...childOverviewMeta,
Component: dynamic(() => import("@/widgets/parent/child-overview"), {
ssr: false,
loading: () => <PluginSkeleton variant="stats" />,
}),
},
"leave-approval": {
...leaveApprovalMeta,
Component: dynamic(() => import("@/widgets/parent/leave-approval"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
// ─── adminmain 区管理员专属) ───────────────────────────────
"user-management": {
...userManagementMeta,
Component: dynamic(() => import("@/widgets/admin/user-management"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
"rbac-manager": {
...rbacManagerMeta,
Component: dynamic(() => import("@/widgets/admin/rbac-manager"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
"plugin-manager": {
...pluginManagerMeta,
Component: dynamic(() => import("@/widgets/admin/plugin-manager"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"school-settings": {
...schoolSettingsMeta,
Component: dynamic(() => import("@/widgets/admin/school-settings"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"audit-logs": {
...auditLogsMeta,
Component: dynamic(() => import("@/widgets/admin/audit-logs"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
"invitation-codes": {
...invitationCodesMeta,
Component: dynamic(() => import("@/widgets/admin/invitation-codes"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
};
/** 判断插件是否已注册 */

View File

@@ -1,16 +1,30 @@
"use client";
/**
* 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 三层合并时已过滤requiredRolesSlotRenderer 信任输入
* - L2 权限点门禁:由 config-service 三层合并时已过滤requiredPermissionsSlotRenderer 信任输入
* manifest.metadata.requiredPermissions 作为元数据,供 admin 配置面板和开发时审查使用)
* - L3 数据范围:由插件内部 usePermission 校验
*
* 错误隔离portal-shell README v2.0 §5.4 L3 插件级):
* - 每个插件被 PluginBoundary 包裹,单个插件崩溃不影响其他插件
* - 流式 Suspense插件 dynamic import 期间显示骨架屏
*
* 关联portal-shell spec §2.2、§5.1、README v2.0 §3.3 §5.3 §5.4
*/
import { useMemo, type ReactNode } from "react";
import { 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({
// 空 slotmain 区显示占位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>
);

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
checkVersionCompatibility,
getActivationPath,
isPluginRenderable,
} from "@/shell/PluginLifecycle";
/**
* PluginLifecycle 单元测试portal-shell spec §5.4、§9.9
*
* 覆盖核心纯函数:
* - checkVersionCompatibilitymajor 版本校验
* - getActivationPath生命周期转换路径
* - isPluginRenderable综合可渲染判断
*/
describe("checkVersionCompatibility", () => {
it("同 major 版本兼容", () => {
expect(checkVersionCompatibility("^1.0.0", "1.2.3")).toBe(true);
expect(checkVersionCompatibility("~1.2.0", "1.2.5")).toBe(true);
expect(checkVersionCompatibility("2.0.0", "2.5.1")).toBe(true);
});
it("不同 major 版本不兼容", () => {
expect(checkVersionCompatibility("^1.0.0", "2.0.0")).toBe(false);
expect(checkVersionCompatibility("^2.0.0", "1.5.0")).toBe(false);
});
it("无法解析时放行(容错)", () => {
expect(checkVersionCompatibility("invalid", "1.0.0")).toBe(true);
expect(checkVersionCompatibility("^1.0.0", "unknown")).toBe(true);
expect(checkVersionCompatibility("", "")).toBe(true);
});
it("带 range 前缀的版本号", () => {
expect(checkVersionCompatibility("^1.5.0", "1.6.0")).toBe(true);
expect(checkVersionCompatibility(">=2.0.0", "2.1.0")).toBe(true);
expect(checkVersionCompatibility("~3.0.0", "3.0.1")).toBe(true);
});
});
describe("getActivationPath", () => {
it("admin 未启用的插件 → disabled", () => {
const path = getActivationPath(true, false);
expect(path).toEqual(["registered", "disabled"]);
});
it("内置插件 + admin 启用 → 完整激活路径", () => {
const path = getActivationPath(true, true);
expect(path).toEqual(["registered", "enabled", "loaded", "active"]);
});
it("第三方插件 + admin 启用 → 完整激活路径", () => {
const path = getActivationPath(false, true);
expect(path).toEqual(["registered", "enabled", "loaded", "active"]);
});
});
describe("isPluginRenderable", () => {
const baseParams = {
isActive: true,
requiredShellVersion: "^1.0.0",
currentShellVersion: "1.0.0",
userRole: "teacher",
requiredRoles: ["teacher", "student"],
};
it("全部满足 → 可渲染", () => {
expect(isPluginRenderable(baseParams)).toBe(true);
});
it("未激活 → 不可渲染", () => {
expect(isPluginRenderable({ ...baseParams, isActive: false })).toBe(false);
});
it("版本不兼容 → 不可渲染", () => {
expect(
isPluginRenderable({
...baseParams,
requiredShellVersion: "^2.0.0",
currentShellVersion: "1.0.0",
}),
).toBe(false);
});
it("角色不匹配 → 不可渲染", () => {
expect(
isPluginRenderable({
...baseParams,
userRole: "parent",
requiredRoles: ["teacher", "student"],
}),
).toBe(false);
});
it("requiredRoles 为空 → 任意角色可渲染", () => {
expect(
isPluginRenderable({
...baseParams,
requiredRoles: [],
userRole: "admin",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { REGISTRY, isPluginRegistered } from "@/shell/Registry";
/**
* Registry 单元测试portal-shell spec §5.3、§9.9
*
* 覆盖:
* - REGISTRY 包含全部 31 个内置插件
* - isPluginRegistered 正确判断
* - 每个插件 manifest 必填字段完整
*/
const EXPECTED_PLUGIN_IDS = [
// universal7
"grades-widget",
"homework-widget",
"schedule-widget",
"attendance-widget",
"exams-widget",
"notifications-widget",
"announcements-widget",
// sidebar4
"class-selector",
"child-selector",
"term-switcher",
"quick-actions",
// topbar4
"notification-bell",
"user-menu",
"global-search",
"locale-switcher",
// teacher4
"lesson-plan-editor",
"question-bank",
"textbook-manager",
"scheduling-rules",
// student4
"error-book",
"learning-path",
"elective-selector",
"ai-tutor",
// parent2
"child-overview",
"leave-approval",
// admin6
"user-management",
"rbac-manager",
"plugin-manager",
"school-settings",
"audit-logs",
"invitation-codes",
] as const;
describe("REGISTRY", () => {
it("包含全部 31 个内置插件", () => {
expect(Object.keys(REGISTRY).length).toBe(EXPECTED_PLUGIN_IDS.length);
});
it("每个预期 pluginId 都已注册", () => {
for (const id of EXPECTED_PLUGIN_IDS) {
const manifest = REGISTRY[id];
expect(manifest).toBeDefined();
expect(manifest!.pluginId).toBe(id);
}
});
it("每个插件 manifest 必填字段完整", () => {
for (const id of EXPECTED_PLUGIN_IDS) {
const manifest = REGISTRY[id];
expect(manifest).toBeDefined();
expect(manifest!.pluginId).toBeTruthy();
expect(manifest!.version).toBeTruthy();
expect(manifest!.requiredShellVersion).toBeTruthy();
expect(manifest!.Component).toBeTruthy();
expect(manifest!.metadata.displayName).toBeTruthy();
expect(manifest!.metadata.description).toBeTruthy();
expect(manifest!.metadata.category).toBeTruthy();
expect(manifest!.metadata.requiredRoles).toBeInstanceOf(Array);
expect(manifest!.metadata.defaultSlot).toBeTruthy();
expect(manifest!.metadata.defaultSize).toBeTruthy();
}
});
it("每个插件的 pluginId 与 key 一致", () => {
for (const [key, manifest] of Object.entries(REGISTRY)) {
expect(manifest.pluginId).toBe(key);
}
});
});
describe("isPluginRegistered", () => {
it("已注册的 pluginId → true", () => {
expect(isPluginRegistered("grades-widget")).toBe(true);
expect(isPluginRegistered("plugin-manager")).toBe(true);
expect(isPluginRegistered("notification-bell")).toBe(true);
});
it("未注册的 pluginId → false", () => {
expect(isPluginRegistered("nonexistent-plugin")).toBe(false);
expect(isPluginRegistered("")).toBe(false);
});
});

View File

@@ -1,6 +1,9 @@
/**
* portal-shell 设计令牌入口(引用 @edu/ui-tokens
*
* 业务代码通过 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";