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:
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 客户端错误上报端点(mock 实现)
|
||||
*
|
||||
* 当前阶段:输出到 stdout,便于开发调试
|
||||
* 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log
|
||||
*
|
||||
* 端点:POST /api/log
|
||||
* Body: ErrorReportPayload(见 @edu/hooks/use-error-report)
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface ErrorReportPayload {
|
||||
level: "error" | "warning";
|
||||
message: string;
|
||||
stack?: string;
|
||||
digest?: string;
|
||||
path: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
pluginId?: string;
|
||||
userId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as ErrorReportPayload;
|
||||
|
||||
// 开发阶段:结构化输出到 stdout
|
||||
// 生产阶段:这里应替换为 OTel export 或 Sentry capture
|
||||
console.error("[client-error]", {
|
||||
level: payload.level,
|
||||
message: payload.message,
|
||||
digest: payload.digest,
|
||||
path: payload.path,
|
||||
pluginId: payload.pluginId,
|
||||
userId: payload.userId,
|
||||
timestamp: payload.timestamp,
|
||||
// stack 太长,单独一行输出便于阅读
|
||||
stack: payload.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
});
|
||||
|
||||
// 返回 204,让 sendBeacon 认为成功
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch {
|
||||
// 解析失败也返回 204,避免客户端重试
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康检查 */
|
||||
export function GET(): NextResponse {
|
||||
return NextResponse.json({ ok: true, endpoint: "/api/log" });
|
||||
}
|
||||
@@ -1,55 +1,62 @@
|
||||
/**
|
||||
* portal-shell 全局样式
|
||||
* portal-shell 全局样式(Tailwind v4 + shadcn 标准令牌)
|
||||
*
|
||||
* 引入 @edu/ui-tokens 三层设计令牌(primitive → semantic → tailwind-theme)
|
||||
* 业务代码使用 Tailwind 类(bg-background / text-foreground / bg-card ...)或 hsl(var(--*)) 引用。
|
||||
*
|
||||
* 禁止规则(ESLint + project_rules §3.10):
|
||||
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
|
||||
* - 禁止字体名字面量(用 var(--font-family-*))
|
||||
* - 禁止 font-size: Npx(用 var(--font-size-*) 或 Tailwind text-* 类)
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/globals.css
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "@edu/ui-tokens/all.css";
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* 排除非源码目录,防止文档中的 Tailwind 任意值语法字符串被误识别为类名 */
|
||||
@source not "../../docs";
|
||||
@source not "../../scripts";
|
||||
@source not "../../tests";
|
||||
|
||||
/* Reduced Motion */
|
||||
@layer base {
|
||||
html,
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
background: var(--bg-paper);
|
||||
color: var(--color-ink);
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-family-sans);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* 纸感分隔线 */
|
||||
.rule {
|
||||
border-top: 1px solid var(--color-rule);
|
||||
}
|
||||
|
||||
.rule-thin {
|
||||
border-top: 2px solid var(--color-rule);
|
||||
}
|
||||
|
||||
/* 左侧竖线标记 */
|
||||
.mark-left {
|
||||
border-left: 2px solid var(--color-rule);
|
||||
padding-left: var(--space-md);
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
|
||||
/**
|
||||
* 字体加载(next/font/google self-host)
|
||||
*
|
||||
* 通过 CSS 变量暴露字体族(--font-inter / --font-fraunces / --font-jetbrains-mono),
|
||||
* ui-tokens 的 semantic 层将它们映射为 --font-family-sans/serif/mono。
|
||||
* 通过 CSS 变量 --font-inter 暴露字体族。
|
||||
* ui-tokens 的 primitive 层将 --font-family-sans 映射为 var(--font-inter, ...)。
|
||||
* 禁止字体名字面量(project_rules §3.10)。
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/layout.tsx(仅 Inter,shadcn 标准)
|
||||
*/
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -16,28 +20,24 @@ const inter = Inter({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-fraunces",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edu Portal Shell",
|
||||
description: "K12 智慧教务平台 - 插件化仪表盘",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* RootLayout
|
||||
*
|
||||
* 仅负责 <html>/<body> 与字体变量。需要 RSC 数据的 Providers
|
||||
* (Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
||||
* 仅负责 <html>/<body> 与字体变量 + 全局 Toaster。
|
||||
* 业务 Providers(Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。
|
||||
*
|
||||
* suppressHydrationWarning:ThemeI18nProvider 在客户端切换 .dark class,
|
||||
* 与 SSR 输出的 <html class=""> 不一致,需抑制 hydration 警告。
|
||||
*/
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -45,11 +45,11 @@ export default function RootLayout({
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
||||
>
|
||||
<body>{children}</body>
|
||||
<html lang="zh-CN" suppressHydrationWarning className={inter.variable}>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,28 +2,44 @@ import { headers } from "next/headers";
|
||||
import { fetchPluginConfig } from "@/lib/config-fetcher";
|
||||
import { ClientShell } from "@/shell/ClientShell";
|
||||
import type { Role } from "@/lib/types";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点)
|
||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染)
|
||||
*
|
||||
* 数据流(portal-shell spec §5.5):
|
||||
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||
* ③ Config 作为 props 传给 ClientShell,随 HTML 直出,消除 CSR 瀑布流
|
||||
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||
*
|
||||
* 流式渲染分层(README v2.0 §5.3):
|
||||
* - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中
|
||||
* - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹
|
||||
* - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense
|
||||
*
|
||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
||||
*
|
||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准
|
||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3
|
||||
*/
|
||||
export default async function ShellPage(): Promise<React.ReactElement> {
|
||||
const headerList = headers();
|
||||
const headerList = await headers();
|
||||
const userId =
|
||||
headerList.get("x-user-id") ||
|
||||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
||||
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
||||
|
||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||
const config = await fetchPluginConfig(userId, role);
|
||||
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
|
||||
userId,
|
||||
role,
|
||||
);
|
||||
|
||||
return <ClientShell config={config} role={role} userId={userId} />;
|
||||
// 将 Promise 作为 prop 传递,ClientShell 内部通过 use() 消费
|
||||
// Next.js 会自动用 loading.tsx 作为 Suspense fallback 流式输出 HTML
|
||||
return (
|
||||
<ClientShell configPromise={configPromise} role={role} userId={userId} />
|
||||
);
|
||||
}
|
||||
|
||||
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
|
||||
|
||||
/**
|
||||
* Shell 路由错误兜底(Next.js App Router error.tsx)
|
||||
*
|
||||
* 触发条件:
|
||||
* - RSC 渲染抛错(如 fetchPluginConfig 失败)
|
||||
* - ClientShell 渲染抛错(Provider 嵌套问题)
|
||||
* - 任何子 segment 未捕获的错误
|
||||
*
|
||||
* 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级):
|
||||
* 1. 隔离错误,避免整页白屏
|
||||
* 2. 通过 useErrorReport 上报到 /api/log
|
||||
* 3. 提供 reset 按钮重试
|
||||
*
|
||||
* 注意:error.tsx 必须是 Client Component("use client")
|
||||
*
|
||||
* 关联:Next.js App Router § error.tsx、portal-shell README v2.0 §5.4
|
||||
*/
|
||||
export default function ShellError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactNode {
|
||||
return <RouteErrorBoundary error={error} reset={reset} namespace="Shell" />;
|
||||
}
|
||||
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Shell 路由加载兜底(Next.js App Router loading.tsx)
|
||||
*
|
||||
* 触发条件:
|
||||
* - RSC 正在解析(fetchPluginConfig 等待中)
|
||||
* - 路由切换时的过渡态
|
||||
*
|
||||
* 职责:
|
||||
* - 整页骨架占位,避免白屏闪烁
|
||||
* - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区)
|
||||
*
|
||||
* 流式渲染上下文(portal-shell README v2.0 §5.3):
|
||||
* - loading.tsx 在 RSC Promise resolve 之前显示
|
||||
* - 配合 PluginBoundary(widget 级 Suspense)形成多层流式体验
|
||||
*
|
||||
* 关联:Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3
|
||||
*/
|
||||
export default function ShellLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="flex h-16 items-center gap-3 px-6">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1">
|
||||
{/* 侧栏 */}
|
||||
<aside className="w-64 border-r bg-card p-4">
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主区:仪表盘骨架 */}
|
||||
<main className="flex-1 p-6">
|
||||
{/* 标题区 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* 统计卡片网格 */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-6">
|
||||
<Skeleton className="mb-3 h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区:图表 + 列表 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
||||
<Skeleton className="mb-4 h-6 w-32" />
|
||||
<div className="flex h-64 items-end gap-2">
|
||||
{[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map(
|
||||
(h, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="flex-1 rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<Skeleton className="mb-4 h-6 w-24" />
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user