feat(portal-shell): implement portal-shell with apollo-router integration

M8: portal-shell unified frontend shell (Modular Monolith + micro-kernel).

- Apollo Client -> apollo-router (port 4010, RSC prefetch)

- 5 layouts: classic/focus/split/triple/canvas

- Registry + PluginLoader (dynamic import ssr:false)

- 3-layer props merge, Zustand PluginStore

- 4 widgets: grades/notification-bell/user-menu/class-selector

- config-service: new pluginConfig GraphQL resolver

- apollo-router: CORS + header propagation for portal-shell

- docker-compose.yml: portal-shell service block
This commit is contained in:
SpecialX
2026-07-15 08:06:09 +08:00
parent 47e950c664
commit 514e26ebb4
49 changed files with 2802 additions and 6 deletions

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Liveness 健康检查project_rules §12
* GET /api/health — 进程存活即返回 200。
*/
export function GET(_request: NextRequest): NextResponse {
return NextResponse.json(
{ status: "ok", service: "portal-shell", timestamp: Date.now() },
{ status: 200 },
);
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Readiness 健康检查project_rules §12
* GET /api/ready — 检查下游 apollo-router 是否可达。
*/
export async function GET(_request: NextRequest): Promise<NextResponse> {
const routerUrl =
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
process.env.APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
try {
const res = await fetch(routerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: "{ __typename }",
}),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
return NextResponse.json(
{ status: "degraded", router: routerUrl, code: res.status },
{ status: 503 },
);
}
return NextResponse.json(
{ status: "ready", router: routerUrl, timestamp: Date.now() },
{ status: 200 },
);
} catch (err) {
return NextResponse.json(
{
status: "not-ready",
router: routerUrl,
error: err instanceof Error ? err.message : String(err),
},
{ status: 503 },
);
}
}

View File

@@ -0,0 +1,55 @@
/**
* portal-shell 全局样式
*
* 引入 @edu/ui-tokens 三层设计令牌primitive → semantic → tailwind-theme
*
* 禁止规则ESLint + project_rules §3.10
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
* - 禁止字体名字面量(用 var(--font-family-*))
* - 禁止 font-size: Npx用 var(--font-size-*) 或 Tailwind text-* 类)
*/
@import "@edu/ui-tokens/all.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body {
background: var(--bg-paper);
color: var(--color-ink);
font-family: var(--font-family-sans);
-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);
}
}

View File

@@ -0,0 +1,55 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
import type { ReactNode } from "react";
/**
* 字体加载next/font/google self-host
*
* 通过 CSS 变量暴露字体族(--font-inter / --font-fraunces / --font-jetbrains-mono
* ui-tokens 的 semantic 层将它们映射为 --font-family-sans/serif/mono。
* 禁止字体名字面量project_rules §3.10)。
*/
const inter = Inter({
subsets: ["latin"],
variable: "--font-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 智慧教务平台 - 插件化仪表盘",
};
/**
* RootLayout
*
* 仅负责 <html>/<body> 与字体变量。需要 RSC 数据的 Providers
* Apollo/Auth/ThemeI18n在 ClientShell 中挂载spec §5.5)。
*/
export default function RootLayout({
children,
}: {
children: ReactNode;
}): ReactNode {
return (
<html
lang="zh-CN"
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
>
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
/**
* 根路径重定向到 /shellportal-shell spec §8.2:路由前缀 /shell/*)。
*/
export default function RootPage(): never {
redirect("/shell");
}

View File

@@ -0,0 +1,29 @@
import { headers } from "next/headers";
import { fetchPluginConfig } from "@/lib/config-fetcher";
import { ClientShell } from "@/shell/ClientShell";
import type { Role } from "@/lib/types";
/**
* Shell 入口RSC Server Componentv2.1 M8 验收点)
*
* 数据流portal-shell spec §5.5
* ① 从请求头获取 userId / roleapi-gateway 注入 x-user-id / x-user-role
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig三层合并
* ③ Config 作为 props 传给 ClientShell随 HTML 直出,消除 CSR 瀑布流
*
* M8 验收portal-shell 查询走 apollo-routerfetchPluginConfig 经 Apollo Client
*
* 关联portal-shell spec §5.5、§6.2、M8 验收标准
*/
export default async function ShellPage(): Promise<React.ReactElement> {
const headerList = 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);
return <ClientShell config={config} role={role} userId={userId} />;
}