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:
13
apps/portal-shell/src/app/api/health/route.ts
Normal file
13
apps/portal-shell/src/app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
43
apps/portal-shell/src/app/api/ready/route.ts
Normal file
43
apps/portal-shell/src/app/api/ready/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
55
apps/portal-shell/src/app/globals.css
Normal file
55
apps/portal-shell/src/app/globals.css
Normal 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);
|
||||
}
|
||||
}
|
||||
55
apps/portal-shell/src/app/layout.tsx
Normal file
55
apps/portal-shell/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/portal-shell/src/app/page.tsx
Normal file
8
apps/portal-shell/src/app/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 根路径重定向到 /shell(portal-shell spec §8.2:路由前缀 /shell/*)。
|
||||
*/
|
||||
export default function RootPage(): never {
|
||||
redirect("/shell");
|
||||
}
|
||||
29
apps/portal-shell/src/app/shell/[[...route]]/page.tsx
Normal file
29
apps/portal-shell/src/app/shell/[[...route]]/page.tsx
Normal 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 Component,v2.1 M8 验收点)
|
||||
*
|
||||
* 数据流(portal-shell spec §5.5):
|
||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||
* ③ Config 作为 props 传给 ClientShell,随 HTML 直出,消除 CSR 瀑布流
|
||||
*
|
||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user