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