From 514e26ebb44ce711a8cb83b09f92b905aaa4c43b Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:06:09 +0800 Subject: [PATCH] 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 --- .commitlintrc.js | 2 +- apps/portal-shell/.env.example | 19 ++ apps/portal-shell/.eslintrc.tokens.js | 49 ++++ apps/portal-shell/Dockerfile | 56 +++++ apps/portal-shell/eslint.config.js | 93 +++++++ apps/portal-shell/next-env.d.ts | 5 + apps/portal-shell/next.config.js | 52 ++++ apps/portal-shell/package.json | 43 ++++ apps/portal-shell/postcss.config.js | 6 + apps/portal-shell/src/app/api/health/route.ts | 13 + apps/portal-shell/src/app/api/ready/route.ts | 43 ++++ apps/portal-shell/src/app/globals.css | 55 +++++ apps/portal-shell/src/app/layout.tsx | 55 +++++ apps/portal-shell/src/app/page.tsx | 8 + .../src/app/shell/[[...route]]/page.tsx | 29 +++ apps/portal-shell/src/lib/apollo-client.ts | 75 ++++++ apps/portal-shell/src/lib/config-fetcher.ts | 117 +++++++++ apps/portal-shell/src/lib/types.ts | 131 ++++++++++ apps/portal-shell/src/lib/usePluginConfig.ts | 86 +++++++ .../portal-shell/src/lib/useWidgetMutation.ts | 29 +++ apps/portal-shell/src/lib/useWidgetQuery.ts | 51 ++++ .../src/providers/ApolloProvider.tsx | 38 +++ .../src/providers/AuthProvider.tsx | 51 ++++ .../src/providers/ThemeI18nProvider.tsx | 55 +++++ apps/portal-shell/src/shell/ClientShell.tsx | 85 +++++++ apps/portal-shell/src/shell/LayoutManager.tsx | 145 +++++++++++ apps/portal-shell/src/shell/PluginLoader.tsx | 151 ++++++++++++ apps/portal-shell/src/shell/PluginStore.ts | 31 +++ apps/portal-shell/src/shell/PropsMerger.ts | 83 +++++++ apps/portal-shell/src/shell/Registry.tsx | 63 +++++ apps/portal-shell/src/shell/Shell.tsx | 34 +++ apps/portal-shell/src/shell/SlotRenderer.tsx | 107 +++++++++ apps/portal-shell/src/styles/tokens.css | 6 + .../widgets/sidebar/class-selector/index.tsx | 70 ++++++ .../sidebar/class-selector/plugin.manifest.ts | 21 ++ .../topbar/notification-bell/index.tsx | 77 ++++++ .../notification-bell/plugin.manifest.ts | 27 +++ .../src/widgets/topbar/user-menu/index.tsx | 76 ++++++ .../topbar/user-menu/plugin.manifest.ts | 20 ++ .../widgets/universal/grades-widget/index.tsx | 81 +++++++ .../grades-widget/plugin.manifest.ts | 28 +++ apps/portal-shell/tailwind.config.js | 83 +++++++ apps/portal-shell/tsconfig.json | 33 +++ apps/portal-shell/vitest.config.ts | 18 ++ infra/apollo-router/router.yaml | 12 + infra/docker-compose.yml | 30 +++ pnpm-lock.yaml | 226 +++++++++++++++++- .../src/graphql/graphql.module.ts | 2 + .../resolvers/plugin-config.resolver.ts | 138 +++++++++++ 49 files changed, 2802 insertions(+), 6 deletions(-) create mode 100644 apps/portal-shell/.env.example create mode 100644 apps/portal-shell/.eslintrc.tokens.js create mode 100644 apps/portal-shell/Dockerfile create mode 100644 apps/portal-shell/eslint.config.js create mode 100644 apps/portal-shell/next-env.d.ts create mode 100644 apps/portal-shell/next.config.js create mode 100644 apps/portal-shell/package.json create mode 100644 apps/portal-shell/postcss.config.js create mode 100644 apps/portal-shell/src/app/api/health/route.ts create mode 100644 apps/portal-shell/src/app/api/ready/route.ts create mode 100644 apps/portal-shell/src/app/globals.css create mode 100644 apps/portal-shell/src/app/layout.tsx create mode 100644 apps/portal-shell/src/app/page.tsx create mode 100644 apps/portal-shell/src/app/shell/[[...route]]/page.tsx create mode 100644 apps/portal-shell/src/lib/apollo-client.ts create mode 100644 apps/portal-shell/src/lib/config-fetcher.ts create mode 100644 apps/portal-shell/src/lib/types.ts create mode 100644 apps/portal-shell/src/lib/usePluginConfig.ts create mode 100644 apps/portal-shell/src/lib/useWidgetMutation.ts create mode 100644 apps/portal-shell/src/lib/useWidgetQuery.ts create mode 100644 apps/portal-shell/src/providers/ApolloProvider.tsx create mode 100644 apps/portal-shell/src/providers/AuthProvider.tsx create mode 100644 apps/portal-shell/src/providers/ThemeI18nProvider.tsx create mode 100644 apps/portal-shell/src/shell/ClientShell.tsx create mode 100644 apps/portal-shell/src/shell/LayoutManager.tsx create mode 100644 apps/portal-shell/src/shell/PluginLoader.tsx create mode 100644 apps/portal-shell/src/shell/PluginStore.ts create mode 100644 apps/portal-shell/src/shell/PropsMerger.ts create mode 100644 apps/portal-shell/src/shell/Registry.tsx create mode 100644 apps/portal-shell/src/shell/Shell.tsx create mode 100644 apps/portal-shell/src/shell/SlotRenderer.tsx create mode 100644 apps/portal-shell/src/styles/tokens.css create mode 100644 apps/portal-shell/src/widgets/sidebar/class-selector/index.tsx create mode 100644 apps/portal-shell/src/widgets/sidebar/class-selector/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/topbar/notification-bell/index.tsx create mode 100644 apps/portal-shell/src/widgets/topbar/notification-bell/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/topbar/user-menu/index.tsx create mode 100644 apps/portal-shell/src/widgets/topbar/user-menu/plugin.manifest.ts create mode 100644 apps/portal-shell/src/widgets/universal/grades-widget/index.tsx create mode 100644 apps/portal-shell/src/widgets/universal/grades-widget/plugin.manifest.ts create mode 100644 apps/portal-shell/tailwind.config.js create mode 100644 apps/portal-shell/tsconfig.json create mode 100644 apps/portal-shell/vitest.config.ts create mode 100644 services/config-service/src/graphql/resolvers/plugin-config.resolver.ts diff --git a/.commitlintrc.js b/.commitlintrc.js index 1128214..737c104 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -14,7 +14,7 @@ module.exports = { 'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai', 'config-service', 'teacher-bff', 'student-bff', 'parent-bff', - 'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal', + 'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal', 'portal-shell', 'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens', 'arch-scan', 'infra', 'docs', 'deps', 'release', ], diff --git a/apps/portal-shell/.env.example b/apps/portal-shell/.env.example new file mode 100644 index 0000000..b81c39f --- /dev/null +++ b/apps/portal-shell/.env.example @@ -0,0 +1,19 @@ +# portal-shell 环境变量模板(v2.1 M8) +# +# 复制为 .env.local 后按实际环境填写。 +# 服务端变量不加 NEXT_PUBLIC_ 前缀;前端变量必须加。 + +# Apollo Router(GraphQL 联邦入口,M8 验收点) +# 前端 Apollo Client 直连此地址 +NEXT_PUBLIC_APOLLO_ROUTER_URL=http://localhost:3000/graphql +# 服务端 RSC 预取用(容器内走内部网络) +APOLLO_ROUTER_URL=http://localhost:3000/graphql + +# API Gateway(JWT 校验 + 注入 x-user-id / x-user-role) +NEXT_PUBLIC_API_GATEWAY_URL=http://localhost:8080 + +# Realtime Gateway(SSE 推送) +NEXT_PUBLIC_REALTIME_GATEWAY_URL=http://localhost:8081 + +# 开发模式(未登录时使用 dev-user / teacher 兜底) +NEXT_PUBLIC_DEV_MODE=true diff --git a/apps/portal-shell/.eslintrc.tokens.js b/apps/portal-shell/.eslintrc.tokens.js new file mode 100644 index 0000000..9608e7a --- /dev/null +++ b/apps/portal-shell/.eslintrc.tokens.js @@ -0,0 +1,49 @@ +/** + * ESLint Design Tokens 配置(独立运行:eslint -c .eslintrc.tokens.js src) + * + * 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。 + * 关联:project_rules §3.10 + */ +let tsParser; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + tsParser = require("@typescript-eslint/parser"); +} catch { + tsParser = undefined; +} + +/** @type {import('eslint').Linter.Config[]} */ +module.exports = [ + { + files: ["**/*.{ts,tsx,js,jsx}"], + languageOptions: { + parser: tsParser, + ecmaVersion: 2024, + sourceType: "module", + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + rules: { + "no-restricted-syntax": [ + "error", + { + selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]', + message: + "禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)", + }, + { + selector: 'Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]', + message: + "禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)", + }, + ], + }, + }, + { + files: ["**/primitive.css", "**/manifest.ts"], + rules: { + "no-restricted-syntax": "off", + }, + }, +]; diff --git a/apps/portal-shell/Dockerfile b/apps/portal-shell/Dockerfile new file mode 100644 index 0000000..66ba63b --- /dev/null +++ b/apps/portal-shell/Dockerfile @@ -0,0 +1,56 @@ +# 多阶段构建:Next.js 生产镜像(standalone 模式) +# 用法:docker build -t edu/portal-shell:latest -f apps/portal-shell/Dockerfile . +# 端口规范:portal-shell :4010(避开旧 portal 4000-4003 段位) + +# ============ Builder ============ +FROM node:22-alpine AS builder +WORKDIR /app + +# 启用 pnpm +RUN corepack enable && corepack prepare pnpm@11.13.0 --activate + +# 先拷依赖清单,利用缓存(含 workspace 共享包) +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* tsconfig.base.json* ./ +COPY apps/portal-shell/package.json ./apps/portal-shell/ +COPY packages/ui-tokens/package.json ./packages/ui-tokens/ +COPY packages/ui-components/package.json ./packages/ui-components/ +COPY packages/hooks/package.json ./packages/hooks/ +# 安装依赖(含 devDependencies,构建需要) +RUN pnpm install --filter @edu/portal-shell... --frozen-lockfile || pnpm install --filter @edu/portal-shell... + +# 拷源码 +COPY apps/portal-shell ./apps/portal-shell +COPY packages/ui-tokens ./packages/ui-tokens +COPY packages/ui-components ./packages/ui-components +COPY packages/hooks ./packages/hooks + +# 构建(禁用 telemetry,生产模式,standalone 输出) +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm --filter @edu/portal-shell run build + +# ============ Runtime ============ +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=4010 + +# 非 root 用户运行 +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 + +# 拷 standalone 产物(已含 node_modules 和 server.js,自包含) +COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/public ./public + +USER nextjs +EXPOSE 4010 + +# 健康检查(/api/health liveness 端点) +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget --quiet --spider http://localhost:4010/api/health || exit 1 + +# standalone 模式下直接用 node server.js 启动(已自包含所有依赖) +WORKDIR /app/apps/portal-shell +CMD ["node", "server.js"] diff --git a/apps/portal-shell/eslint.config.js b/apps/portal-shell/eslint.config.js new file mode 100644 index 0000000..a0d1186 --- /dev/null +++ b/apps/portal-shell/eslint.config.js @@ -0,0 +1,93 @@ +/** + * portal-shell ESLint flat config + * + * 包含设计令牌强制规则(project_rules §3.10): + * - 禁止 #hex 颜色字面量 + * - 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字体名字面量 + * + * 关联:project_rules §3.10、portal-shell spec §7 + */ +const js = require("@eslint/js"); +const tseslint = require("typescript-eslint"); +const prettierConfig = require("eslint-config-prettier"); + +module.exports = tseslint.config( + { + ignores: [ + "**/dist/**", + "**/node_modules/**", + "**/.next/**", + "**/coverage/**", + "**/*.config.js", + "**/*.config.mjs", + ], + }, + + js.configs.recommended, + ...tseslint.configs.recommended, + + { + languageOptions: { + ecmaVersion: 2024, + sourceType: "module", + }, + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "no-console": "off", + }, + }, + + // 设计令牌强制规则(project_rules §3.10) + { + files: ["**/*.{ts,tsx,js,jsx}"], + rules: { + "no-restricted-syntax": [ + "error", + { + // 禁止 #hex 颜色字面量(如 "#fff"、"#000000") + selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]", + message: + "禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)", + }, + { + // 禁止字体名字面量(next/font 的 import 标识符不受影响) + selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]", + message: + "禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)", + }, + ], + }, + }, + + // 白名单:令牌原始定义、PWA manifest + { + files: ["**/primitive.css", "**/manifest.ts"], + rules: { + "no-restricted-syntax": "off", + }, + }, + + // 测试文件放宽规则 + { + files: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/__tests__/**", + ], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + }, + }, + + prettierConfig, +); diff --git a/apps/portal-shell/next-env.d.ts b/apps/portal-shell/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/portal-shell/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/portal-shell/next.config.js b/apps/portal-shell/next.config.js new file mode 100644 index 0000000..b9947ae --- /dev/null +++ b/apps/portal-shell/next.config.js @@ -0,0 +1,52 @@ +/** + * portal-shell Next.js 配置(v2.1 M8) + * + * 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker) + * - output: standalone(单容器部署) + * - transpilePackages: @edu/* workspace 包 + * - 反向代理:/api/v1/* → api-gateway :8080(JWT 校验 + 注入 x-user-id/x-user-role) + * - GraphQL 查询走 apollo-router :3000(M8 验收点,由 Apollo Client 直连) + * + * 关联:portal-shell spec §2、project_rules §3.2 + */ + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: "standalone", + transpilePackages: [ + "@edu/ui-components", + "@edu/ui-tokens", + "@edu/hooks", + "@edu/contracts", + "@edu/shared-ts", + ], + experimental: { + serverActions: { bodySizeLimit: "2mb" }, + }, + webpack(config) { + // ESM 包使用 .js 后缀导入源码(TS 文件),需映射 .js → .ts + config.resolve = config.resolve || {}; + config.resolve.extensionAlias = { + ...config.resolve.extensionAlias, + ".js": [".ts", ".tsx", ".js"], + }; + return config; + }, + async rewrites() { + const gateway = + process.env.API_GATEWAY_URL || "http://localhost:8080"; + return [ + { + source: "/api/v1/:path*", + destination: `${gateway}/api/v1/:path*`, + }, + { + source: "/api/auth/:path*", + destination: `${gateway}/api/v1/iam/:path*`, + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/apps/portal-shell/package.json b/apps/portal-shell/package.json new file mode 100644 index 0000000..32ab8b0 --- /dev/null +++ b/apps/portal-shell/package.json @@ -0,0 +1,43 @@ +{ + "name": "@edu/portal-shell", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 4010", + "build": "next build", + "start": "next start -p 4010", + "lint": "eslint src", + "lint:tokens": "eslint -c .eslintrc.tokens.js src", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@apollo/client": "^3.11.0", + "@edu/hooks": "workspace:*", + "@edu/ui-components": "workspace:*", + "@edu/ui-tokens": "workspace:*", + "graphql": "^16.8.0", + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "swr": "^2.2.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.1.0", + "jsdom": "^25.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.6.0", + "vitest": "^2.0.0" + } +} diff --git a/apps/portal-shell/postcss.config.js b/apps/portal-shell/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/portal-shell/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/portal-shell/src/app/api/health/route.ts b/apps/portal-shell/src/app/api/health/route.ts new file mode 100644 index 0000000..6c50841 --- /dev/null +++ b/apps/portal-shell/src/app/api/health/route.ts @@ -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 }, + ); +} diff --git a/apps/portal-shell/src/app/api/ready/route.ts b/apps/portal-shell/src/app/api/ready/route.ts new file mode 100644 index 0000000..45b9e13 --- /dev/null +++ b/apps/portal-shell/src/app/api/ready/route.ts @@ -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 { + 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 }, + ); + } +} diff --git a/apps/portal-shell/src/app/globals.css b/apps/portal-shell/src/app/globals.css new file mode 100644 index 0000000..1083ec7 --- /dev/null +++ b/apps/portal-shell/src/app/globals.css @@ -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); + } +} diff --git a/apps/portal-shell/src/app/layout.tsx b/apps/portal-shell/src/app/layout.tsx new file mode 100644 index 0000000..3d3a86e --- /dev/null +++ b/apps/portal-shell/src/app/layout.tsx @@ -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 + * + * 仅负责 / 与字体变量。需要 RSC 数据的 Providers + * (Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。 + */ +export default function RootLayout({ + children, +}: { + children: ReactNode; +}): ReactNode { + return ( + + {children} + + ); +} diff --git a/apps/portal-shell/src/app/page.tsx b/apps/portal-shell/src/app/page.tsx new file mode 100644 index 0000000..ad7ce8f --- /dev/null +++ b/apps/portal-shell/src/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +/** + * 根路径重定向到 /shell(portal-shell spec §8.2:路由前缀 /shell/*)。 + */ +export default function RootPage(): never { + redirect("/shell"); +} diff --git a/apps/portal-shell/src/app/shell/[[...route]]/page.tsx b/apps/portal-shell/src/app/shell/[[...route]]/page.tsx new file mode 100644 index 0000000..c176f95 --- /dev/null +++ b/apps/portal-shell/src/app/shell/[[...route]]/page.tsx @@ -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 { + 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 ; +} diff --git a/apps/portal-shell/src/lib/apollo-client.ts b/apps/portal-shell/src/lib/apollo-client.ts new file mode 100644 index 0000000..45062d4 --- /dev/null +++ b/apps/portal-shell/src/lib/apollo-client.ts @@ -0,0 +1,75 @@ +/** + * Apollo Client(v2.1 M8 验收点) + * + * 所有 portal-shell 查询走 apollo-router(GraphQL 联邦入口): + * portal-shell → apollo-router :3000/graphql → 各子图(iam/core-edu/content/msg/data-ana/ai/config-service) + * + * 双端使用: + * - 服务端(RSC):createApolloClient() 每次请求新建实例,ssrMode=true + * - 客户端:getApolloClient() 单例,复用 InMemoryCache + * + * 关联:portal-shell spec §5.5 RSC 预取、§5.6 统一 Hook、M8 验收标准 + */ +import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client"; +import { setContext } from "@apollo/client/link/context"; + +const APOLLO_ROUTER_URL = + process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL || + process.env.APOLLO_ROUTER_URL || + "http://localhost:3000/graphql"; + +/** + * 创建 Apollo Client 实例。 + * + * @param getAuthToken 可选,返回 JWT 用于注入 Authorization 头(客户端从 cookie/localStorage 读取) + */ +export function createApolloClient( + getAuthToken?: () => string | null, +): ApolloClient { + const httpLink = new HttpLink({ + uri: APOLLO_ROUTER_URL, + credentials: "include", + }); + + const authLink = setContext((_, { headers }) => { + const token = getAuthToken?.() ?? null; + return { + headers: { + ...headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }; + }); + + return new ApolloClient({ + link: from([authLink, httpLink]), + cache: new InMemoryCache(), + ssrMode: typeof window === "undefined", + defaultOptions: { + query: { + errorPolicy: "all", + fetchPolicy: typeof window === "undefined" ? "no-cache" : "cache-first", + }, + watchQuery: { + errorPolicy: "all", + }, + }, + }); +} + +let clientSingleton: ApolloClient | null = null; + +/** + * 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。 + */ +export function getApolloClient( + getAuthToken?: () => string | null, +): ApolloClient { + if (typeof window === "undefined") { + return createApolloClient(getAuthToken); + } + if (!clientSingleton) { + clientSingleton = createApolloClient(getAuthToken); + } + return clientSingleton; +} diff --git a/apps/portal-shell/src/lib/config-fetcher.ts b/apps/portal-shell/src/lib/config-fetcher.ts new file mode 100644 index 0000000..3a432a7 --- /dev/null +++ b/apps/portal-shell/src/lib/config-fetcher.ts @@ -0,0 +1,117 @@ +/** + * 配置获取(服务端 RSC 预取) + * + * 通过 apollo-router 查询 config-service 子图的 pluginConfig(userId, role), + * 返回三层合并后的 PluginConfigResponse(M8 验收点:查询走 apollo-router)。 + * + * 设计意图(portal-shell spec §5.5): + * - portal-shell 是 Next.js 前端,不直接调 config-service gRPC + * - 统一走 apollo-router GraphQL,由 Router 路由到 config-service 子图 + * - RSC 服务端预取消除 CSR 瀑布流,Config 随 HTML 直出 + * + * 关联:portal-shell spec §5.5、§6.2、M8 验收标准 + */ +import { gql } from "@apollo/client"; +import { createApolloClient } from "./apollo-client"; +import type { PluginConfigResponse, Role } from "./types"; + +/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */ +export const GET_PLUGIN_CONFIG = gql` + 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 + } + } + } +`; + +/** + * 获取用户合并后的插件配置(服务端调用)。 + * + * @param userId 用户 ID(来自 RSC 的 x-user-id 头) + * @param role 用户角色(来自 RSC 的 x-user-role 头) + * @returns 三层合并后的 PluginConfigResponse;查询失败时返回默认 classic 配置 + */ +export async function fetchPluginConfig( + userId: string, + role: Role, +): Promise { + const client = createApolloClient(); + try { + const { data, error } = await client.query<{ + pluginConfig: PluginConfigResponse; + }>({ + query: GET_PLUGIN_CONFIG, + variables: { userId, role }, + }); + if (error) { + console.warn( + `[portal-shell] fetchPluginConfig partial error: ${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: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return getDefaultConfig(); + } +} + +/** + * 默认 classic 布局配置(Router 未就绪 / 查询失败时降级)。 + * 保证 Shell 始终可渲染,不因下游不可用而白屏。 + */ +export function getDefaultConfig(): PluginConfigResponse { + return { + activeLayout: { + layoutId: "classic", + displayName: "经典三栏", + description: "TopBar + SideNav + Main", + availableSlots: ["top", "side", "main"], + layoutSchemaJson: JSON.stringify({ + grid: { rows: 1, cols: 1, areas: [["main"]] }, + }), + }, + slots: [ + { slotName: "top", navItems: [] }, + { slotName: "side", navItems: [] }, + { slotName: "main", navItems: [] }, + ], + plugins: [], + registry: [], + }; +} diff --git a/apps/portal-shell/src/lib/types.ts b/apps/portal-shell/src/lib/types.ts new file mode 100644 index 0000000..5ccd22a --- /dev/null +++ b/apps/portal-shell/src/lib/types.ts @@ -0,0 +1,131 @@ +/** + * portal-shell 共享类型(v2.1 M8) + * + * 对应 config-service PluginConfigResponse(三层合并后的插件配置)。 + * 类型与 services/config-service/src/config-config/config.service.ts 的接口对齐, + * 通过 apollo-router GraphQL 查询 pluginConfig(userId, role) 获取。 + * + * 关联:portal-shell spec §5.1 PluginProps 契约、§6.2 PluginConfigResponse + */ + +/** 用户角色 */ +export type Role = "admin" | "teacher" | "student" | "parent"; + +/** Layout 模板信息(对应 LayoutTemplateInfo) */ +export interface LayoutTemplateInfo { + layoutId: string; + displayName: string; + description: string; + availableSlots: string[]; + layoutSchemaJson: string; +} + +/** Slot 配置(对应 SlotConfig) */ +export interface SlotConfig { + slotName: string; + navItems: string[]; +} + +/** 插件放置(对应 PluginPlacement,三层合并后) */ +export interface PluginPlacement { + pluginId: string; + slot: string; + sortOrder: number; + sizeJson: string; + propsJson: string; + isVisible: boolean; +} + +/** 插件注册项(对应 PluginRegistryItem) */ +export interface PluginRegistryItem { + pluginId: string; + category: string; + version: string; + displayName: string; + description: string; + requiredRoles: string[]; + isBuiltin: boolean; + isActive: boolean; +} + +/** 三层合并后的插件配置响应(对应 PluginConfigResponse) */ +export interface PluginConfigResponse { + activeLayout: LayoutTemplateInfo | null; + slots: SlotConfig[]; + plugins: PluginPlacement[]; + registry: PluginRegistryItem[]; +} + +/** 插件尺寸(colSpan / rowSpan) */ +export interface PluginSize { + colSpan: number; + rowSpan: number; +} + +/** 插件分类 */ +export type PluginCategory = + | "universal" + | "sidebar" + | "topbar" + | "teacher" + | "student" + | "parent" + | "admin"; + +/** 插件 Props 契约(spec §5.1) */ +export interface PluginProps> { + /** 插件实例 ID(同一插件多实例时区分) */ + instanceId: string; + /** 当前用户角色 */ + role: Role; + /** 当前用户信息 */ + user: { + id: string; + name: string; + email: string; + dataScope: string; + }; + /** 当前 slot 信息 */ + slot: { + name: string; + layoutId: string; + size?: PluginSize; + }; + /** 插件自定义 props(三层合并后的最终值) */ + props: TProps; + /** 服务端预取的初始数据(RSC 直出) */ + initialData?: unknown; +} + +/** 简易 JSON Schema 类型(用于 propsSchema 声明) */ +export interface JsonSchema { + type?: string; + properties?: Record; + items?: JsonSchema; + description?: string; + default?: unknown; + [key: string]: unknown; +} + +/** 插件清单(spec §5.1 PluginManifest) */ +export interface PluginManifest { + pluginId: string; + version: string; + /** 兼容的 Shell 版本范围(semver range) */ + requiredShellVersion: string; + /** React 组件(默认导出) */ + Component: React.ComponentType; + /** 插件元数据 */ + metadata: { + displayName: string; + description: string; + category: PluginCategory; + requiredRoles: Role[]; + defaultSlot: string; + defaultSize: PluginSize; + /** 插件可配置的 props schema(admin 配置面板用) */ + propsSchema?: JsonSchema; + /** 系统默认 props */ + defaultProps?: Record; + }; +} diff --git a/apps/portal-shell/src/lib/usePluginConfig.ts b/apps/portal-shell/src/lib/usePluginConfig.ts new file mode 100644 index 0000000..2806cca --- /dev/null +++ b/apps/portal-shell/src/lib/usePluginConfig.ts @@ -0,0 +1,86 @@ +"use client"; + +/** + * 插件配置 SWR 静默刷新(v2.1 M8) + * + * 抛弃 Kafka + WebSocket 推送链路,改用 SWR 静默后台刷新配置: + * - revalidateOnFocus:用户切回 Tab 时静默刷新 + * - refreshInterval:5 分钟轮询 + * - 检测到配置变化时回调通知上层 Toast 提示 + * + * 刷新请求仍走 apollo-router(M8:portal-shell 查询走 Router)。 + * + * 关联:portal-shell spec §6.4、M8 验收标准 + */ +import useSWR from "swr"; +import { getApolloClient } from "./apollo-client"; +import { GET_PLUGIN_CONFIG } from "./config-fetcher"; +import type { PluginConfigResponse, Role } from "./types"; + +export interface UsePluginConfigOptions { + /** RSC 直出的初始配置(fallbackData) */ + initialConfig: PluginConfigResponse; + /** 当前用户 ID */ + userId: string; + /** 当前用户角色 */ + role: Role; + /** 配置变化回调(上层用于 Toast 提示) */ + onChanged?: () => void; +} + +/** + * 静默刷新插件配置。 + * fetcher 经 Apollo Client 查询 apollo-router 的 pluginConfig(userId, role)。 + */ +export function usePluginConfig(options: UsePluginConfigOptions): { + config: PluginConfigResponse; + refresh: () => void; +} { + const { initialConfig, userId, role, onChanged } = options; + + const { data, mutate } = useSWR( + ["plugin-config", userId, role], + async () => { + const client = getApolloClient(); + const { data } = await client.query<{ + pluginConfig: PluginConfigResponse; + }>({ + query: GET_PLUGIN_CONFIG, + variables: { userId, role }, + fetchPolicy: "network-only", + }); + return data.pluginConfig; + }, + { + fallbackData: initialConfig, + revalidateOnFocus: true, + revalidateOnReconnect: true, + refreshInterval: 300_000, + dedupingInterval: 60_000, + onSuccess: (newData) => { + if (hasConfigChanged(initialConfig, newData)) { + onChanged?.(); + } + }, + }, + ); + + return { config: data ?? initialConfig, refresh: () => void mutate() }; +} + +/** 浅比较配置是否变化(layoutId / 插件集合 / 可见性) */ +function hasConfigChanged( + prev: PluginConfigResponse, + next: PluginConfigResponse | undefined, +): boolean { + if (!next) return false; + if (prev.activeLayout?.layoutId !== next.activeLayout?.layoutId) return true; + if (prev.plugins.length !== next.plugins.length) return true; + const prevIds = prev.plugins + .map((p) => `${p.pluginId}:${p.isVisible}`) + .sort(); + const nextIds = next.plugins + .map((p) => `${p.pluginId}:${p.isVisible}`) + .sort(); + return prevIds.some((id, i) => id !== nextIds[i]); +} diff --git a/apps/portal-shell/src/lib/useWidgetMutation.ts b/apps/portal-shell/src/lib/useWidgetMutation.ts new file mode 100644 index 0000000..d49b9b0 --- /dev/null +++ b/apps/portal-shell/src/lib/useWidgetMutation.ts @@ -0,0 +1,29 @@ +"use client"; + +/** + * 统一 GraphQL 变更 Hook(v2.1 M8) + * + * widget 插件通过此 Hook 发起变更,经 Apollo Client → apollo-router → 子图。 + * + * 关联:portal-shell spec §5.6、M8 验收标准 + */ +import { + useMutation, + type DocumentNode, + type TypedDocumentNode, +} from "@apollo/client"; + +export function useWidgetMutation>( + mutation: DocumentNode | TypedDocumentNode, +) { + const [mutate, result] = useMutation(mutation, { + errorPolicy: "all", + }); + + const run = async (variables: TVars): Promise => { + const res = await mutate({ variables }); + return res.data ?? undefined; + }; + + return { run, ...result }; +} diff --git a/apps/portal-shell/src/lib/useWidgetQuery.ts b/apps/portal-shell/src/lib/useWidgetQuery.ts new file mode 100644 index 0000000..0cfd216 --- /dev/null +++ b/apps/portal-shell/src/lib/useWidgetQuery.ts @@ -0,0 +1,51 @@ +"use client"; + +/** + * 统一 GraphQL 查询 Hook(v2.1 M8) + * + * 所有 widget 插件通过此 Hook 查询数据,查询经 Apollo Client → apollo-router → 子图。 + * 这是 M8 验收点的客户端侧:portal-shell 查询走 apollo-router。 + * + * 特性: + * - 自动注入 Apollo Client(来自 ApolloProvider) + * - 支持 fallbackData(RSC 预取的 initialData,消除首屏瀑布流) + * - 支持 enabled / pollInterval 控制 + * + * 关联:portal-shell spec §5.6、M8 验收标准 + */ +import { useQuery, type DocumentNode, type FetchPolicy } from "@apollo/client"; +import type { TypedDocumentNode } from "@apollo/client"; +import { useMemo } from "react"; + +export interface UseWidgetQueryOptions { + /** RSC 预取的初始数据(首次渲染无需客户端请求) */ + fallbackData?: TData; + /** 轮询间隔(ms) */ + pollInterval?: number; + /** 是否启用查询(false 时跳过) */ + enabled?: boolean; + /** Apollo fetchPolicy */ + fetchPolicy?: FetchPolicy; +} + +export function useWidgetQuery>( + query: DocumentNode | TypedDocumentNode, + variables: TVars, + options?: UseWidgetQueryOptions, +) { + const { data, loading, error, refetch } = useQuery(query, { + variables, + skip: options?.enabled === false, + fetchPolicy: options?.fetchPolicy ?? "cache-first", + pollInterval: options?.pollInterval, + errorPolicy: "all", + }); + + // RSC 预取数据作为首次渲染兜底,消除客户端瀑布流 + const mergedData = useMemo( + () => data ?? options?.fallbackData, + [data, options?.fallbackData], + ); + + return { data: mergedData, loading, error, refetch }; +} diff --git a/apps/portal-shell/src/providers/ApolloProvider.tsx b/apps/portal-shell/src/providers/ApolloProvider.tsx new file mode 100644 index 0000000..9a7b93f --- /dev/null +++ b/apps/portal-shell/src/providers/ApolloProvider.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * ApolloProvider(v2.1 M8) + * + * 注入 Apollo Client 单例,所有 widget 的 useWidgetQuery 经此 Client + * 查询 apollo-router(M8 验收点:portal-shell 查询走 Router)。 + * + * Token 注入:从 localStorage 读取 JWT(对齐 teacher-portal F12 约定), + * cookie 凭证通过 credentials:"include" 一并发送。 + * + * 关联:portal-shell spec §5.6、M8 验收标准 + */ +import { useMemo, type ReactNode } from "react"; +import { ApolloProvider as ApolloGraphQLProvider } from "@apollo/client"; +import { getApolloClient } from "@/lib/apollo-client"; + +const TOKEN_KEY = "edu_token"; + +function readToken(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function ApolloProvider({ + children, +}: { + children: ReactNode; +}): ReactNode { + const client = useMemo(() => getApolloClient(readToken), []); + return ( + {children} + ); +} diff --git a/apps/portal-shell/src/providers/AuthProvider.tsx b/apps/portal-shell/src/providers/AuthProvider.tsx new file mode 100644 index 0000000..5cf688f --- /dev/null +++ b/apps/portal-shell/src/providers/AuthProvider.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * AuthProvider(v2.1 M8) + * + * 提供 useAuth(),供 widget 插件读取当前用户角色与信息。 + * 用户身份由 RSC 从请求头(x-user-id / x-user-role)解析后作为 props 注入, + * 客户端 AuthProvider 仅做 context 下发,不重复解析 JWT。 + * + * 关联:portal-shell spec §5.1 PluginProps.user、§9.1 providers + */ +import { createContext, useContext, type ReactNode } from "react"; +import type { Role } from "@/lib/types"; + +export interface AuthUser { + id: string; + name: string; + email: string; + role: Role; + dataScope: string; +} + +interface AuthContextValue { + user: AuthUser; + role: Role; + userId: string; +} + +const AuthContext = createContext(null); + +export interface AuthProviderProps { + user: AuthUser; + children: ReactNode; +} + +export function AuthProvider({ user, children }: AuthProviderProps): ReactNode { + const value: AuthContextValue = { + user, + role: user.role, + userId: user.id, + }; + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within AuthProvider"); + } + return ctx; +} diff --git a/apps/portal-shell/src/providers/ThemeI18nProvider.tsx b/apps/portal-shell/src/providers/ThemeI18nProvider.tsx new file mode 100644 index 0000000..f6c2f98 --- /dev/null +++ b/apps/portal-shell/src/providers/ThemeI18nProvider.tsx @@ -0,0 +1,55 @@ +"use client"; + +/** + * 主题 + i18n Provider(v2.1 M8) + * + * 通过 PluginStore(Zustand)管理 theme(light/dark)与 locale(zh-CN/en), + * 将主题类名同步到 ,locale 用于基础文案切换。 + * + * 关联:portal-shell spec §5.2.2 Zustand Store、§9.1 providers + */ +import { useEffect, type ReactNode } from "react"; +import { usePluginStore } from "@/shell/PluginStore"; + +const DEFAULT_MESSAGES = { + "zh-CN": { + "shell.toggleSidebar": "切换侧栏", + "shell.layout": "布局", + "shell.empty": "暂无可见插件", + }, + en: { + "shell.toggleSidebar": "Toggle sidebar", + "shell.layout": "Layout", + "shell.empty": "No visible plugins", + }, +} as const; + +type MessageKey = keyof (typeof DEFAULT_MESSAGES)["zh-CN"]; + +export function ThemeI18nProvider({ + children, +}: { + children: ReactNode; +}): ReactNode { + const theme = usePluginStore((s) => s.theme); + const locale = usePluginStore((s) => s.locale); + + useEffect(() => { + if (typeof document === "undefined") return; + const root = document.documentElement; + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + root.lang = locale; + }, [theme, locale]); + + return <>{children}; +} + +/** 简易 i18n 翻译函数(MVP,复用 PluginStore 的 locale) */ +export function useT(): (key: MessageKey) => string { + const locale = usePluginStore((s) => s.locale); + return (key: MessageKey) => DEFAULT_MESSAGES[locale][key]; +} diff --git a/apps/portal-shell/src/shell/ClientShell.tsx b/apps/portal-shell/src/shell/ClientShell.tsx new file mode 100644 index 0000000..6afa1a0 --- /dev/null +++ b/apps/portal-shell/src/shell/ClientShell.tsx @@ -0,0 +1,85 @@ +"use client"; + +/** + * ClientShell - 客户端入口(v2.1 M8) + * + * 接收 RSC props(Config + userId + role),挂载 Providers(Apollo/Auth/ThemeI18n), + * 启用 SWR 静默刷新配置(usePluginConfig),渲染 Shell。 + * + * 数据流(portal-shell spec §5.5): + * RSC 预取 Config → ClientShell(fallbackData)→ SWR 静默刷新 → Shell 重渲染 + * + * 关联:portal-shell spec §5.5、§6.4、M8 验收标准 + */ +import { 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"; + +export interface ClientShellProps { + config: PluginConfigResponse; + role: Role; + userId: string; + /** 可选:服务端解析的用户名/邮箱(user-menu 插件会自行查询 me) */ + userName?: string; + userEmail?: string; +} + +export function ClientShell({ + config, + role, + userId, + userName, + userEmail, +}: ClientShellProps): ReactNode { + const [configChanged, setConfigChanged] = useState(false); + + const user: AuthUser = { + id: userId, + name: userName ?? userId, + email: userEmail ?? "", + role, + dataScope: "", + }; + + const { config: liveConfig } = usePluginConfig({ + initialConfig: config, + userId, + role, + onChanged: () => setConfigChanged(true), + }); + + return ( + + + + + {configChanged ? ( +
+

+ 发现新布局配置,刷新后生效。 +

+ + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/shell/LayoutManager.tsx b/apps/portal-shell/src/shell/LayoutManager.tsx new file mode 100644 index 0000000..ab473c5 --- /dev/null +++ b/apps/portal-shell/src/shell/LayoutManager.tsx @@ -0,0 +1,145 @@ +"use client"; + +/** + * LayoutManager - 5 种 Layout 模板渲染器(v2.1 M8) + * + * | 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 + */ +import { type ReactNode } from "react"; +import { SlotRenderer, type SlotRendererProps } from "./SlotRenderer"; + +type SlotRendererInput = Omit; + +export interface LayoutManagerProps extends SlotRendererInput { + layoutId: string; +} + +export function LayoutManager(props: LayoutManagerProps): ReactNode { + const { layoutId, ...slotInput } = props; + switch (layoutId) { + case "focus": + return ; + case "split": + return ; + case "triple": + return ; + case "canvas": + return ; + case "classic": + default: + return ; + } +} + +interface LayoutShellProps { + slotInput: SlotRendererInput; + layoutId: string; +} + +/** classic:TopBar + SideNav + Main */ +function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode { + return ( +
+
+ +
+
+ +
+ +
+
+
+ ); +} + +/** focus:TopBar + 全宽 Main */ +function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode { + return ( +
+
+ +
+
+ +
+
+ ); +} + +/** split:TopBar + 左右等分 Main */ +function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode { + return ( +
+
+ +
+
+
+ +
+
+ +
+
+
+ ); +} + +/** triple:TopBar + SideNav + Main + RightRail */ +function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode { + return ( +
+
+ +
+
+ +
+ +
+ +
+
+ ); +} + +/** canvas:TopBar + 自由摆放 grid(MVP 按 grid 排列,不实现拖拽) */ +function CanvasLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/portal-shell/src/shell/PluginLoader.tsx b/apps/portal-shell/src/shell/PluginLoader.tsx new file mode 100644 index 0000000..2454056 --- /dev/null +++ b/apps/portal-shell/src/shell/PluginLoader.tsx @@ -0,0 +1,151 @@ +"use client"; + +/** + * PluginLoader - 插件加载器(v2.1 M8) + * + * 职责: + * - PluginSkeleton:5 种 skeleton 变体(card/list/chart/stats/table),供 dynamic loading 使用 + * - PluginErrorFallback:插件加载/渲染失败兜底 + * - PluginErrorBoundary:隔离单个插件错误,不影响其他插件 + * - PluginLoader:包裹插件组件,注入 PluginProps,挂载 ErrorBoundary + * + * 关联:portal-shell spec §5.3、§7.3 + */ +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 ( +
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } + if (variant === "list") { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ); + } + // card / stats / chart 默认卡片骨架 + return ( +
+
+
+
+ ); +} + +/** 插件错误兜底(居中错误图标 + 重试) */ +export function PluginErrorFallback({ + instanceId, + onRetry, +}: { + instanceId: string; + onRetry?: () => void; +}): ReactNode { + return ( +
+

插件加载失败({instanceId})

+ {onRetry ? ( + + ) : null} +
+ ); +} + +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 ( + + ); + } + return this.props.children; + } +} + +/** 插件加载器:包裹组件 + ErrorBoundary */ +export function PluginLoader({ + Component, + pluginProps, +}: { + Component: React.ComponentType; + pluginProps: PluginProps; +}): ReactNode { + return ( + + + + ); +} diff --git a/apps/portal-shell/src/shell/PluginStore.ts b/apps/portal-shell/src/shell/PluginStore.ts new file mode 100644 index 0000000..b25c77e --- /dev/null +++ b/apps/portal-shell/src/shell/PluginStore.ts @@ -0,0 +1,31 @@ +/** + * PluginStore - Zustand 全局状态(v2.1 M8) + * + * 管理纯 UI、不可分享的全局状态(portal-shell spec §5.2.2): + * - theme(light/dark) + * - locale(zh-CN/en) + * - sidebarCollapsed + * + * 跨插件可分享状态走 URL Search Params,不进此 Store。 + * + * 关联:portal-shell spec §5.2.2、§9.1 + */ +import { create } from "zustand"; + +export interface PluginStore { + theme: "light" | "dark"; + locale: "zh-CN" | "en"; + sidebarCollapsed: boolean; + setTheme: (theme: "light" | "dark") => void; + setLocale: (locale: "zh-CN" | "en") => void; + toggleSidebar: () => void; +} + +export const usePluginStore = create((set) => ({ + theme: "light", + setTheme: (theme) => set({ theme }), + locale: "zh-CN", + setLocale: (locale) => set({ locale }), + sidebarCollapsed: false, + toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), +})); diff --git a/apps/portal-shell/src/shell/PropsMerger.ts b/apps/portal-shell/src/shell/PropsMerger.ts new file mode 100644 index 0000000..4e5fb85 --- /dev/null +++ b/apps/portal-shell/src/shell/PropsMerger.ts @@ -0,0 +1,83 @@ +/** + * PropsMerger - 三层 props 合并(v2.1 M8) + * + * 合并优先级(portal-shell spec §4.4):用户调整 > 角色默认 > 系统默认 + * - 系统默认:plugin.manifest.ts 的 defaultProps + * - 角色默认:role_plugin_mapping.widget_props + * - 用户调整:user_layout_override.plugin_placements[].props + * + * 合并算法:深合并(对象递归合并,数组与原始值后者覆盖前者)。 + * + * 关联:portal-shell spec §4.4、§5.1 + */ + +/** + * 深合并多个 props 层级,后者覆盖前者。 + * - 普通对象递归合并 + * - 数组、原始值直接覆盖 + * - null/undefined 跳过 + */ +export function mergeProps( + ...layers: (Record | undefined | null)[] +): Record { + const result: Record = {}; + for (const layer of layers) { + if (!layer) continue; + for (const key of Object.keys(layer)) { + const next = layer[key]; + const prev = result[key]; + if (isPlainObject(next) && isPlainObject(prev)) { + result[key] = mergeProps( + prev as Record, + next as Record, + ); + } else { + result[key] = next; + } + } + } + return result; +} + +/** 安全解析 JSON 字符串为对象,失败返回空对象 */ +export function parsePropsJson( + json: string | undefined | null, +): Record { + if (!json) return {}; + try { + const parsed = JSON.parse(json); + return isPlainObject(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** 安全解析 size JSON */ +export function parseSizeJson(json: string | undefined | null): { + colSpan: number; + rowSpan: number; +} { + if (!json) return { colSpan: 1, rowSpan: 1 }; + try { + const parsed = JSON.parse(json); + if (isPlainObject(parsed)) { + const obj = parsed as Record; + return { + colSpan: typeof obj.colSpan === "number" ? obj.colSpan : 1, + rowSpan: typeof obj.rowSpan === "number" ? obj.rowSpan : 1, + }; + } + } catch { + // ignore + } + return { colSpan: 1, rowSpan: 1 }; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} diff --git a/apps/portal-shell/src/shell/Registry.tsx b/apps/portal-shell/src/shell/Registry.tsx new file mode 100644 index 0000000..bf6e871 --- /dev/null +++ b/apps/portal-shell/src/shell/Registry.tsx @@ -0,0 +1,63 @@ +"use client"; + +/** + * 插件 Registry(v2.1 M8) + * + * 编译时登记内置插件:plugin_id → { Component(dynamic import), metadata }。 + * 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。 + * + * 内置插件(M8 验证管道,4 个示例): + * - grades-widget(universal / main) + * - notification-bell(topbar / top) + * - user-menu(topbar / top) + * - class-selector(sidebar / side) + * + * 关联:portal-shell spec §2.2、§5.3 + */ +import dynamic from "next/dynamic"; +import type { PluginManifest } from "@/lib/types"; +import { manifestMeta as gradesWidgetMeta } from "@/widgets/universal/grades-widget/plugin.manifest"; +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"; + +/** + * 内置插件注册表。 + * Component 使用 next/dynamic 懒加载(ssr:false),避免插件 JS 阻塞首屏。 + */ +export const REGISTRY: Record = { + "grades-widget": { + ...gradesWidgetMeta, + Component: dynamic(() => import("@/widgets/universal/grades-widget"), { + ssr: false, + loading: () => , + }), + }, + "notification-bell": { + ...notificationBellMeta, + Component: dynamic(() => import("@/widgets/topbar/notification-bell"), { + ssr: false, + loading: () => , + }), + }, + "user-menu": { + ...userMenuMeta, + Component: dynamic(() => import("@/widgets/topbar/user-menu"), { + ssr: false, + loading: () => , + }), + }, + "class-selector": { + ...classSelectorMeta, + Component: dynamic(() => import("@/widgets/sidebar/class-selector"), { + ssr: false, + loading: () => , + }), + }, +}; + +/** 判断插件是否已注册 */ +export function isPluginRegistered(pluginId: string): boolean { + return pluginId in REGISTRY; +} diff --git a/apps/portal-shell/src/shell/Shell.tsx b/apps/portal-shell/src/shell/Shell.tsx new file mode 100644 index 0000000..8c6c64a --- /dev/null +++ b/apps/portal-shell/src/shell/Shell.tsx @@ -0,0 +1,34 @@ +"use client"; + +/** + * Shell - Layout 框架 + Slots(v2.1 M8) + * + * 微内核宿主:根据 activeLayout 选择 LayoutManager 模板, + * 将配置中的插件分发到对应 slot 渲染。Shell 本身不含业务逻辑。 + * + * 关联:portal-shell spec §2.2、§4.1 + */ +import { type ReactNode } from "react"; +import { LayoutManager } from "./LayoutManager"; +import type { PluginConfigResponse, Role } from "@/lib/types"; +import type { AuthUser } from "@/providers/AuthProvider"; + +export interface ShellProps { + config: PluginConfigResponse; + user: AuthUser; + role: Role; + userId: string; +} + +export function Shell({ config, user, role, userId }: ShellProps): ReactNode { + const layoutId = config.activeLayout?.layoutId ?? "classic"; + return ( + + ); +} diff --git a/apps/portal-shell/src/shell/SlotRenderer.tsx b/apps/portal-shell/src/shell/SlotRenderer.tsx new file mode 100644 index 0000000..6b0f432 --- /dev/null +++ b/apps/portal-shell/src/shell/SlotRenderer.tsx @@ -0,0 +1,107 @@ +"use client"; + +/** + * SlotRenderer - 按 Config 渲染插件列表(v2.1 M8) + * + * 给定一个 slot 名称,从配置中过滤出该 slot 的可见插件,按 sortOrder 排序, + * 查 Registry 取组件,注入 PluginProps,经 PluginLoader 挂载(含 ErrorBoundary)。 + * + * 关联:portal-shell spec §2.2、§5.1 + */ +import { useMemo, type ReactNode } from "react"; +import { REGISTRY, isPluginRegistered } from "./Registry"; +import { PluginLoader, PluginSkeleton } from "./PluginLoader"; +import { parsePropsJson, parseSizeJson } from "./PropsMerger"; +import type { PluginPlacement, PluginProps, Role } from "@/lib/types"; +import type { AuthUser } from "@/providers/AuthProvider"; + +export interface SlotRendererProps { + slotName: string; + layoutId: string; + plugins: PluginPlacement[]; + user: AuthUser; + role: Role; + userId: string; +} + +export function SlotRenderer({ + slotName, + layoutId, + plugins, + user, + role, + userId, +}: SlotRendererProps): ReactNode { + const visible = useMemo( + () => + plugins + .filter((p) => p.slot === slotName && p.isVisible) + .sort((a, b) => a.sortOrder - b.sortOrder), + [plugins, slotName], + ); + + if (visible.length === 0) { + // 空 slot:main 区显示占位,topbar/side 区不渲染 + if (slotName === "top" || slotName === "side") return null; + return ( +
+ 暂无可见插件 +
+ ); + } + + return ( +
+ {visible.map((placement) => { + if (!isPluginRegistered(placement.pluginId)) { + return ( +
+ 未注册插件:{placement.pluginId} +
+ ); + } + const manifest = REGISTRY[placement.pluginId]; + if (!manifest) { + return null; + } + const pluginProps: PluginProps = { + instanceId: `${placement.pluginId}-${slotName}-${placement.sortOrder}`, + role, + user: { + id: userId, + name: user.name, + email: user.email, + dataScope: user.dataScope, + }, + slot: { + name: slotName, + layoutId, + size: parseSizeJson(placement.sizeJson), + }, + props: parsePropsJson(placement.propsJson), + }; + return ( + + ); + })} +
+ ); +} + +/** Slot 加载态占位(layout 切换瞬间) */ +export function SlotSkeleton({ count = 1 }: { count?: number }): ReactNode { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/apps/portal-shell/src/styles/tokens.css b/apps/portal-shell/src/styles/tokens.css new file mode 100644 index 0000000..6ccb846 --- /dev/null +++ b/apps/portal-shell/src/styles/tokens.css @@ -0,0 +1,6 @@ +/** + * portal-shell 设计令牌入口(引用 @edu/ui-tokens) + * + * 业务代码通过 Tailwind 类(bg-paper / text-ink)或 hsl(var(--*)) 引用。 + */ +@import "@edu/ui-tokens/all.css"; diff --git a/apps/portal-shell/src/widgets/sidebar/class-selector/index.tsx b/apps/portal-shell/src/widgets/sidebar/class-selector/index.tsx new file mode 100644 index 0000000..c645874 --- /dev/null +++ b/apps/portal-shell/src/widgets/sidebar/class-selector/index.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * class-selector(sidebar / side) + * + * 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 myClasses 数据。 + * 切换班级时写入 URL Search Params(classId),grades-widget 等插件自动响应。 + * + * 关联:portal-shell spec §5.2.1 URL 驱动、M8 验收 + */ +import { gql } from "@apollo/client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useWidgetQuery } from "@/lib/useWidgetQuery"; +import { PluginSkeleton } from "@/shell/PluginLoader"; +import type { PluginProps } from "@/lib/types"; + +const GET_MY_CLASSES = gql` + query GetMyClasses { + myClasses { + id + name + } + } +`; + +interface MyClassesQueryData { + myClasses: Array<{ id: string; name: string }>; +} + +export default function ClassSelector(_props: PluginProps): React.ReactElement { + const router = useRouter(); + const searchParams = useSearchParams(); + const currentClassId = searchParams.get("classId") ?? ""; + + const { data, loading } = useWidgetQuery< + MyClassesQueryData, + Record + >(GET_MY_CLASSES, {}); + + const handleSelect = (classId: string): void => { + const params = new URLSearchParams(searchParams.toString()); + params.set("classId", classId); + router.push(`?${params.toString()}`); + }; + + if (loading && !data) { + return ; + } + + const classes = data?.myClasses ?? []; + + return ( +
+

班级

+ +
+ ); +} diff --git a/apps/portal-shell/src/widgets/sidebar/class-selector/plugin.manifest.ts b/apps/portal-shell/src/widgets/sidebar/class-selector/plugin.manifest.ts new file mode 100644 index 0000000..105a0fe --- /dev/null +++ b/apps/portal-shell/src/widgets/sidebar/class-selector/plugin.manifest.ts @@ -0,0 +1,21 @@ +/** + * class-selector 插件清单(sidebar) + * + * 班级选择器,插入 side slot。查询 apollo-router → core-edu 子图 myClasses。 + * 切换 classId 时写入 URL Search Params,其他插件自动响应。 + */ +import type { PluginManifest } from "@/lib/types"; + +export const manifestMeta: Omit = { + pluginId: "class-selector", + version: "0.1.0", + requiredShellVersion: "^1.0.0", + metadata: { + displayName: "班级选择", + description: "切换当前班级(写入 URL classId)", + category: "sidebar", + requiredRoles: ["teacher"], + defaultSlot: "side", + defaultSize: { colSpan: 1, rowSpan: 1 }, + }, +}; diff --git a/apps/portal-shell/src/widgets/topbar/notification-bell/index.tsx b/apps/portal-shell/src/widgets/topbar/notification-bell/index.tsx new file mode 100644 index 0000000..96e1691 --- /dev/null +++ b/apps/portal-shell/src/widgets/topbar/notification-bell/index.tsx @@ -0,0 +1,77 @@ +"use client"; + +/** + * notification-bell(topbar / top) + * + * 通过 useWidgetQuery 查询 apollo-router → msg 子图的 notifications 数据。 + * 点击铃铛展开下拉列表。 + * + * 关联:portal-shell spec §5.6 统一 Hook、M8 验收 + */ +import { gql } from "@apollo/client"; +import { useState } from "react"; +import { useWidgetQuery } from "@/lib/useWidgetQuery"; +import type { PluginProps } from "@/lib/types"; + +const GET_NOTIFICATIONS = gql` + query GetNotifications($limit: Int) { + notifications(limit: $limit) { + id + title + } + } +`; + +interface NotificationsQueryData { + notifications: Array<{ id: string; title: string }>; +} + +export default function NotificationBell( + props: PluginProps, +): React.ReactElement { + const rawLimit = props.props.limit; + const limit = typeof rawLimit === "number" ? rawLimit : 10; + const [open, setOpen] = useState(false); + + const { data } = useWidgetQuery( + GET_NOTIFICATIONS, + { limit }, + ); + + const items = data?.notifications ?? []; + + return ( +
+ + {open ? ( +
    + {items.length === 0 ? ( +
  • 暂无通知
  • + ) : ( + items.map((n) => ( +
  • + {n.title} +
  • + )) + )} +
+ ) : null} +
+ ); +} diff --git a/apps/portal-shell/src/widgets/topbar/notification-bell/plugin.manifest.ts b/apps/portal-shell/src/widgets/topbar/notification-bell/plugin.manifest.ts new file mode 100644 index 0000000..47fdc3b --- /dev/null +++ b/apps/portal-shell/src/widgets/topbar/notification-bell/plugin.manifest.ts @@ -0,0 +1,27 @@ +/** + * notification-bell 插件清单(topbar) + * + * 通知铃铛,插入 top slot。查询 apollo-router → msg 子图 notifications 数据。 + */ +import type { PluginManifest } from "@/lib/types"; + +export const manifestMeta: Omit = { + pluginId: "notification-bell", + version: "0.1.0", + requiredShellVersion: "^1.0.0", + metadata: { + displayName: "通知", + description: "通知铃铛与下拉列表", + category: "topbar", + requiredRoles: ["admin", "teacher", "student", "parent"], + defaultSlot: "top", + defaultSize: { colSpan: 1, rowSpan: 1 }, + defaultProps: { limit: 10 }, + propsSchema: { + type: "object", + properties: { + limit: { type: "number", description: "拉取条数", default: 10 }, + }, + }, + }, +}; diff --git a/apps/portal-shell/src/widgets/topbar/user-menu/index.tsx b/apps/portal-shell/src/widgets/topbar/user-menu/index.tsx new file mode 100644 index 0000000..ea32b6d --- /dev/null +++ b/apps/portal-shell/src/widgets/topbar/user-menu/index.tsx @@ -0,0 +1,76 @@ +"use client"; + +/** + * user-menu(topbar / top) + * + * 通过 useWidgetQuery 查询 apollo-router → iam 子图的 me 数据。 + * 展示用户头像 + 下拉菜单(昵称 / 邮箱 / 角色)。 + * + * 关联:portal-shell spec §5.6 统一 Hook、M8 验收 + */ +import { gql } from "@apollo/client"; +import { useState } from "react"; +import { useWidgetQuery } from "@/lib/useWidgetQuery"; +import { useAuth } from "@/providers/AuthProvider"; +import type { PluginProps } from "@/lib/types"; + +const GET_CURRENT_USER = gql` + query GetCurrentUser { + me { + id + name + email + role + } + } +`; + +interface MeQueryData { + me: { id: string; name: string; email: string; role: string } | null; +} + +export default function UserMenu(_props: PluginProps): React.ReactElement { + const { user } = useAuth(); + const [open, setOpen] = useState(false); + + const { data } = useWidgetQuery>( + GET_CURRENT_USER, + {}, + ); + + const me = data?.me; + const displayName = me?.name ?? user.name; + const displayEmail = me?.email ?? user.email; + const displayRole = me?.role ?? user.role; + + return ( +
+ + {open ? ( +
    +
  • +

    {displayName}

    +

    {displayEmail}

    +
  • +
  • + 角色:{displayRole} +
  • +
+ ) : null} +
+ ); +} diff --git a/apps/portal-shell/src/widgets/topbar/user-menu/plugin.manifest.ts b/apps/portal-shell/src/widgets/topbar/user-menu/plugin.manifest.ts new file mode 100644 index 0000000..29d9600 --- /dev/null +++ b/apps/portal-shell/src/widgets/topbar/user-menu/plugin.manifest.ts @@ -0,0 +1,20 @@ +/** + * user-menu 插件清单(topbar) + * + * 用户菜单,插入 top slot。查询 apollo-router → iam 子图的 me 数据。 + */ +import type { PluginManifest } from "@/lib/types"; + +export const manifestMeta: Omit = { + pluginId: "user-menu", + version: "0.1.0", + requiredShellVersion: "^1.0.0", + metadata: { + displayName: "用户菜单", + description: "用户头像与下拉菜单", + category: "topbar", + requiredRoles: ["admin", "teacher", "student", "parent"], + defaultSlot: "top", + defaultSize: { colSpan: 1, rowSpan: 1 }, + }, +}; diff --git a/apps/portal-shell/src/widgets/universal/grades-widget/index.tsx b/apps/portal-shell/src/widgets/universal/grades-widget/index.tsx new file mode 100644 index 0000000..3e947d8 --- /dev/null +++ b/apps/portal-shell/src/widgets/universal/grades-widget/index.tsx @@ -0,0 +1,81 @@ +"use client"; + +/** + * grades-widget(universal / main) + * + * 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 grades 数据。 + * classId 从 URL Search Params 读取(class-selector 切换时自动响应)。 + * + * 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收 + */ +import { gql } from "@apollo/client"; +import { useSearchParams } from "next/navigation"; +import { useWidgetQuery } from "@/lib/useWidgetQuery"; +import { PluginSkeleton } from "@/shell/PluginLoader"; +import type { PluginProps } from "@/lib/types"; + +const GET_GRADES = gql` + query GetGrades($classId: ID!) { + grades(classId: $classId) { + studentId + score + } + } +`; + +interface GradesQueryData { + grades: Array<{ studentId: string; score: number }>; +} + +export default function GradesWidget(props: PluginProps): React.ReactElement { + const searchParams = useSearchParams(); + const classId = searchParams.get("classId") ?? ""; + const rawLimit = props.props.limit; + const limit = typeof rawLimit === "number" ? rawLimit : 20; + + const { data, loading } = useWidgetQuery< + GradesQueryData, + { classId: string } + >(GET_GRADES, { classId }, { enabled: classId.length > 0 }); + + if (loading && !data) { + return ; + } + + if (!classId) { + return ( +
+

成绩

+

请先选择班级

+
+ ); + } + + const rows = data?.grades ?? []; + + return ( +
+

成绩

+ {rows.length === 0 ? ( +

暂无成绩数据

+ ) : ( + + + + + + + + + {rows.slice(0, limit).map((row) => ( + + + + + ))} + +
学号分数
{row.studentId}{row.score}
+ )} +
+ ); +} diff --git a/apps/portal-shell/src/widgets/universal/grades-widget/plugin.manifest.ts b/apps/portal-shell/src/widgets/universal/grades-widget/plugin.manifest.ts new file mode 100644 index 0000000..b9464ea --- /dev/null +++ b/apps/portal-shell/src/widgets/universal/grades-widget/plugin.manifest.ts @@ -0,0 +1,28 @@ +/** + * grades-widget 插件清单(universal) + * + * 跨角色通用成绩卡片,插入 main slot。 + * 通过 useWidgetQuery 查询 apollo-router 的 core-edu 子图 grades 数据。 + */ +import type { PluginManifest } from "@/lib/types"; + +export const manifestMeta: Omit = { + pluginId: "grades-widget", + version: "0.1.0", + requiredShellVersion: "^1.0.0", + metadata: { + displayName: "成绩", + description: "班级成绩列表(按 classId 过滤)", + category: "universal", + requiredRoles: ["teacher", "student", "parent"], + defaultSlot: "main", + defaultSize: { colSpan: 2, rowSpan: 1 }, + defaultProps: { limit: 20 }, + propsSchema: { + type: "object", + properties: { + limit: { type: "number", description: "展示条数", default: 20 }, + }, + }, + }, +}; diff --git a/apps/portal-shell/tailwind.config.js b/apps/portal-shell/tailwind.config.js new file mode 100644 index 0000000..cc49a2f --- /dev/null +++ b/apps/portal-shell/tailwind.config.js @@ -0,0 +1,83 @@ +/** + * Tailwind 配置 - 对齐三层设计令牌 + * + * Layer 3 映射:将 Layer 2 Semantic CSS 变量暴露为 Tailwind 类名 + * 业务代码使用 bg-paper / text-ink / font-serif 等语义类 + * + * 禁止: + * - 禁止 hex 字面量(colors 引用 var(--*)) + * - 禁止字体名字面量(fontFamily 引用 var(--font-family-*)) + */ + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + paper: "var(--bg-paper)", + surface: "var(--bg-surface)", + subtle: "var(--bg-subtle)", + ink: { + DEFAULT: "var(--color-ink)", + muted: "var(--color-ink-muted)", + subtle: "var(--color-ink-subtle)", + onAccent: "var(--color-ink-on-accent)", + }, + accent: { + DEFAULT: "var(--color-accent)", + hover: "var(--color-accent-hover)", + subtle: "var(--color-accent-subtle)", + }, + rule: { + DEFAULT: "var(--color-rule)", + strong: "var(--color-rule-strong)", + }, + success: "var(--color-success)", + warning: "var(--color-warning)", + danger: "var(--color-danger)", + info: "var(--color-info)", + border: "var(--color-border)", + }, + fontFamily: { + sans: "var(--font-family-sans)", + serif: "var(--font-family-serif)", + mono: "var(--font-family-mono)", + }, + fontSize: { + body: "var(--font-size-body)", + small: "var(--font-size-small)", + tiny: "var(--font-size-tiny)", + "heading-1": "var(--font-size-heading-1)", + "heading-2": "var(--font-size-heading-2)", + "heading-3": "var(--font-size-heading-3)", + display: "var(--font-size-display)", + "large-number": "var(--font-size-large-number)", + }, + spacing: { + xs: "var(--space-xs)", + sm: "var(--space-sm)", + md: "var(--space-md)", + lg: "var(--space-lg)", + xl: "var(--space-xl)", + "2xl": "var(--space-2xl)", + }, + borderRadius: { + DEFAULT: "var(--radius-default)", + card: "var(--radius-card)", + button: "var(--radius-button)", + }, + boxShadow: { + sm: "var(--shadow-sm)", + md: "var(--shadow-md)", + lg: "var(--shadow-lg)", + xl: "var(--shadow-xl)", + }, + transitionDuration: { + fast: "150ms", + normal: "200ms", + }, + }, + }, + plugins: [], +}; diff --git a/apps/portal-shell/tsconfig.json b/apps/portal-shell/tsconfig.json new file mode 100644 index 0000000..17e9e29 --- /dev/null +++ b/apps/portal-shell/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "allowJs": true, + "noEmit": true, + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"], + "@edu/hooks": ["../../packages/hooks/src/index.ts"], + "@edu/ui-components": ["../../packages/ui-components/src/index.ts"], + "@edu/ui-tokens": ["../../packages/ui-tokens/src/index.ts"] + }, + "isolatedModules": true + }, + "include": [ + "next-env.d.ts", + "src/**/*", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/portal-shell/vitest.config.ts b/apps/portal-shell/vitest.config.ts new file mode 100644 index 0000000..c332adc --- /dev/null +++ b/apps/portal-shell/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: [], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); diff --git a/infra/apollo-router/router.yaml b/infra/apollo-router/router.yaml index 9526b56..d4dca5f 100644 --- a/infra/apollo-router/router.yaml +++ b/infra/apollo-router/router.yaml @@ -27,10 +27,12 @@ cors: - "http://localhost:4001" - "http://localhost:4002" - "http://localhost:4003" + - "http://localhost:4010" - "http://teacher-portal:4000" - "http://student-portal:4001" - "http://parent-portal:4002" - "http://admin-portal:4003" + - "http://portal-shell:4010" methods: - GET - POST @@ -45,12 +47,22 @@ cors: # 向所有子图注入 Router-Authorization Header(ADR-036) # 子图的 RouterAuthGuard 校验此 Header,拒绝非 Router 的直接 GraphQL 请求 +# 同时透传用户身份头(x-user-id / x-user-role)与 Authorization 到子图, +# 供 iam/core-edu/msg 等子图做用户级鉴权(M8:portal-shell 查询走 Router) headers: all: request: - add: name: "router-authorization" value: "${env.ROUTER_AUTH_SECRET}" + - propagate: + named: "Authorization" + - propagate: + named: "x-user-id" + - propagate: + named: "x-user-role" + - propagate: + named: "X-Request-Id" # 流量控制 traffic_shaping: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 38b01e1..498143f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -347,6 +347,36 @@ services: timeout: 5s retries: 5 # ============================================================ + # portal-shell - 统一前端壳(v2.1 M8) + # Modular Monolith + 微内核架构,端口 4010(避开 4000-4003 portal 段) + # 查询走 apollo-router(M8 验收标准),非 gRPC 直连 + # ============================================================ + portal-shell: + build: + context: .. + dockerfile: apps/portal-shell/Dockerfile + container_name: edu-portal-shell + profiles: ["p3", "p4", "p5", "p6"] + restart: unless-stopped + environment: + NODE_ENV: production + NEXT_PUBLIC_APOLLO_ROUTER_URL: http://apollo-router:3000/graphql + APOLLO_ROUTER_URL: http://apollo-router:3000/graphql + NEXT_PUBLIC_API_GATEWAY_URL: http://api-gateway:8080 + NEXT_PUBLIC_REALTIME_GATEWAY_URL: http://realtime-gateway:8081 + NEXT_PUBLIC_DEV_MODE: "true" + ports: + - "4010:4010" + depends_on: + apollo-router: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:4010/api/health"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + # ============================================================ # Temporal Server - AI 工作流引擎(v2.1 §8.2 ADR-030) # 仅用于 AI 耗时工作流 + Saga,CRUD 短事务禁止 # ============================================================ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf012cf..606f191 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,7 +182,7 @@ importers: version: 5.101.2(react@18.3.0) '@urql/next': specifier: ^1.1.0 - version: 1.1.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0)) + version: 1.1.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0)) clsx: specifier: ^2.1.0 version: 2.1.1 @@ -194,7 +194,7 @@ importers: version: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0) nuqs: specifier: ^1.19.0 - version: 1.19.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)) + version: 1.19.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)) react: specifier: ^18.3.0 version: 18.3.0 @@ -303,6 +303,82 @@ importers: specifier: ^4.2.0 version: 4.2.4 + apps/portal-shell: + dependencies: + '@apollo/client': + specifier: ^3.11.0 + version: 3.14.1(@types/react@18.3.31)(graphql-ws@6.1.0(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)(subscriptions-transport-ws@0.11.0(graphql@16.14.2)) + '@edu/hooks': + specifier: workspace:* + version: link:../../packages/hooks + '@edu/ui-components': + specifier: workspace:* + version: link:../../packages/ui-components + '@edu/ui-tokens': + specifier: workspace:* + version: link:../../packages/ui-tokens + graphql: + specifier: ^16.8.0 + version: 16.14.2 + next: + specifier: ^14.2.0 + version: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + react: + specifier: ^18.3.0 + version: 18.3.0 + react-dom: + specifier: ^18.3.0 + version: 18.3.0(react@18.3.0) + swr: + specifier: ^2.2.0 + version: 2.4.2(react@18.3.0) + zustand: + specifier: ^4.5.0 + version: 4.5.0(@types/react@18.3.31)(react@18.3.0) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.4.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.3.0(vite@5.0.0(@types/node@22.20.1)(terser@5.49.0)) + autoprefixer: + specifier: ^10.4.0 + version: 10.5.2(postcss@8.5.18) + eslint: + specifier: ^9.0.0 + version: 9.39.5(jiti@2.7.0) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@9.39.5(jiti@2.7.0)) + jsdom: + specifier: ^25.0.0 + version: 25.0.0 + postcss: + specifier: ^8.4.0 + version: 8.5.18 + tailwindcss: + specifier: ^3.4.0 + version: 3.4.19(tsx@4.23.1)(yaml@2.9.0) + typescript: + specifier: ^5.6.0 + version: 5.6.2 + vitest: + specifier: ^2.0.0 + version: 2.1.0(@types/node@22.20.1)(jsdom@25.0.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.6.2))(terser@5.49.0) + apps/student-portal: dependencies: '@edu/contracts': @@ -343,7 +419,7 @@ importers: version: 3.20.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0) nuqs: specifier: ^1.19.0 - version: 1.19.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)) + version: 1.19.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)) react: specifier: ^18.3.0 version: 18.3.0 @@ -1648,6 +1724,24 @@ packages: peerDependencies: graphql: 14.x || 15.x || 16.x + '@apollo/client@3.14.1': + resolution: {integrity: sha512-SgGX6E23JsZhUdG2anxiyHvEvvN6CUaI4ZfMsndZFeuHPXL3H0IsaiNAhLITSISbeyeYd+CBd9oERXQDdjXWZw==} + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + '@apollo/federation-internals@2.14.2': resolution: {integrity: sha512-6+de7KrV53x3I+RNjTaFzjT6INmI1dJ1uLbgPhVBcBEk9lbJ9BwyOWwBYpMuVFAgEuqMHb0/2qy/gme93j/ciA==} engines: {node: '>=18'} @@ -5822,6 +5916,22 @@ packages: resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==} engines: {node: '>=18.0.0'} + '@wry/caches@1.0.1': + resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} + engines: {node: '>=8'} + + '@wry/context@0.7.4': + resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} + engines: {node: '>=8'} + + '@wry/equality@0.5.7': + resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} + engines: {node: '>=8'} + + '@wry/trie@0.5.0': + resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} + engines: {node: '>=8'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -8890,6 +9000,9 @@ packages: resolution: {integrity: sha512-YYamqKu48bZCSTJKSWLLO4SSk8tKN2Gg2z1sJZVzHJYVObMO/xQpIzAh6re9HCMHRdB1dJvBjJH18DW7xYOicg==} engines: {node: ^22 || ^21 || ^20 || ^18 || ^16} + optimism@0.18.1: + resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -9408,6 +9521,17 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehackt@0.1.0: + resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} + peerDependencies: + '@types/react': '*' + react: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + remedial@1.0.8: resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} @@ -9969,6 +10093,11 @@ packages: swap-case@2.0.2: resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} + swr@2.4.2: + resolution: {integrity: sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-observable@1.2.0: resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==} engines: {node: '>=0.10.0'} @@ -10211,6 +10340,10 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-invariant@0.10.3: + resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} + engines: {node: '>=8'} + ts-log@2.2.3: resolution: {integrity: sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w==} @@ -10494,6 +10627,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -10851,6 +10989,12 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zen-observable-ts@1.2.5: + resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} + + zen-observable@0.8.15: + resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -10948,6 +11092,30 @@ snapshots: dependencies: graphql: 16.14.2 + '@apollo/client@3.14.1(@types/react@18.3.31)(graphql-ws@6.1.0(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)(subscriptions-transport-ws@0.11.0(graphql@16.14.2))': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@wry/caches': 1.0.1 + '@wry/equality': 0.5.7 + '@wry/trie': 0.5.0 + graphql: 16.14.2 + graphql-tag: 2.12.7(graphql@16.14.2) + hoist-non-react-statics: 3.3.2 + optimism: 0.18.1 + prop-types: 15.8.1 + rehackt: 0.1.0(@types/react@18.3.31)(react@18.3.0) + symbol-observable: 4.0.0 + ts-invariant: 0.10.3 + tslib: 2.8.1 + zen-observable-ts: 1.2.5 + optionalDependencies: + graphql-ws: 6.1.0(graphql@16.14.2)(ws@8.21.0) + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + subscriptions-transport-ws: 0.11.0(graphql@16.14.2) + transitivePeerDependencies: + - '@types/react' + '@apollo/federation-internals@2.14.2(graphql@16.14.2)': dependencies: '@types/uuid': 9.0.8 @@ -15912,7 +16080,7 @@ snapshots: transitivePeerDependencies: - graphql - '@urql/next@1.1.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0))': + '@urql/next@1.1.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0))': dependencies: next: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0) react: 18.3.0 @@ -16131,6 +16299,22 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@wry/caches@1.0.1': + dependencies: + tslib: 2.8.1 + + '@wry/context@0.7.4': + dependencies: + tslib: 2.8.1 + + '@wry/equality@0.5.7': + dependencies: + tslib: 2.8.1 + + '@wry/trie@0.5.0': + dependencies: + tslib: 2.8.1 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -19414,7 +19598,7 @@ snapshots: gauge: 3.0.2 set-blocking: 2.0.0 - nuqs@1.19.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)): + nuqs@1.19.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)): dependencies: mitt: 3.0.1 next: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0) @@ -19474,6 +19658,13 @@ snapshots: opossum@8.4.0: {} + optimism@0.18.1: + dependencies: + '@wry/caches': 1.0.1 + '@wry/context': 0.7.4 + '@wry/trie': 0.5.0 + tslib: 2.8.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -20043,6 +20234,11 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehackt@0.1.0(@types/react@18.3.31)(react@18.3.0): + optionalDependencies: + '@types/react': 18.3.31 + react: 18.3.0 + remedial@1.0.8: {} remove-trailing-separator@1.1.0: {} @@ -20675,6 +20871,12 @@ snapshots: dependencies: tslib: 2.8.1 + swr@2.4.2(react@18.3.0): + dependencies: + dequal: 2.0.3 + react: 18.3.0 + use-sync-external-store: 1.6.0(react@18.3.0) + symbol-observable@1.2.0: {} symbol-observable@4.0.0: {} @@ -20969,6 +21171,10 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-invariant@0.10.3: + dependencies: + tslib: 2.8.1 + ts-log@2.2.3: {} ts-morph@24.0.0: @@ -21208,6 +21414,10 @@ snapshots: dependencies: react: 18.3.0 + use-sync-external-store@1.6.0(react@18.3.0): + dependencies: + react: 18.3.0 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -21658,6 +21868,12 @@ snapshots: yocto-queue@1.2.2: {} + zen-observable-ts@1.2.5: + dependencies: + zen-observable: 0.8.15 + + zen-observable@0.8.15: {} + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/services/config-service/src/graphql/graphql.module.ts b/services/config-service/src/graphql/graphql.module.ts index 9b9f4bf..dc49035 100644 --- a/services/config-service/src/graphql/graphql.module.ts +++ b/services/config-service/src/graphql/graphql.module.ts @@ -20,6 +20,7 @@ import { ConfigModule } from "../config-config/config.module.js"; import { PluginResolver } from "./resolvers/plugin.resolver.js"; import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js"; import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js"; +import { PluginConfigResolver } from "./resolvers/plugin-config.resolver.js"; import { DataLoaderService } from "./dataloader.service.js"; @Module({ @@ -50,6 +51,7 @@ import { DataLoaderService } from "./dataloader.service.js"; PluginResolver, LayoutTemplateResolver, UserLayoutResolver, + PluginConfigResolver, DataLoaderService, ], exports: [DataLoaderService], diff --git a/services/config-service/src/graphql/resolvers/plugin-config.resolver.ts b/services/config-service/src/graphql/resolvers/plugin-config.resolver.ts new file mode 100644 index 0000000..06203de --- /dev/null +++ b/services/config-service/src/graphql/resolvers/plugin-config.resolver.ts @@ -0,0 +1,138 @@ +/** + * PluginConfig Resolver(v2.1 M8) + * + * Apollo Federation 子图统一查询入口:portal-shell 通过 apollo-router + * 查询 `pluginConfig(userId, role)` 获取三层合并后的 PluginConfigResponse。 + * + * 设计意图(portal-shell spec §5.5 / §6.2): + * - portal-shell 是 Next.js 前端,不直接调 gRPC,统一走 apollo-router GraphQL + * - 本 Resolver 仅做 GraphQL 出参适配,业务逻辑复用 ConfigService.getPluginConfig + * - role 由调用方(portal-shell RSC 从 x-user-role 头)传入,避免依赖 Router 头透传 + * + * 关联:ADR-026 双入口策略、M8 验收点(portal-shell 查询走 apollo-router) + */ +import { + Resolver, + Query, + Args, + ID, + ObjectType, + Field, + Int, +} from "@nestjs/graphql"; +import { ConfigService } from "../../config-config/config.service.js"; + +@ObjectType() +export class PluginConfigLayoutGql { + @Field(() => ID) + layoutId!: string; + + @Field() + displayName!: string; + + @Field() + description!: string; + + @Field(() => [String]) + availableSlots!: string[]; + + @Field() + layoutSchemaJson!: string; +} + +@ObjectType() +export class PluginConfigSlotGql { + @Field() + slotName!: string; + + @Field(() => [String]) + navItems!: string[]; +} + +@ObjectType() +export class PluginConfigPlacementGql { + @Field(() => ID) + pluginId!: string; + + @Field() + slot!: string; + + @Field(() => Int) + sortOrder!: number; + + @Field() + sizeJson!: string; + + @Field() + propsJson!: string; + + @Field() + isVisible!: boolean; +} + +@ObjectType() +export class PluginConfigRegistryItemGql { + @Field(() => ID) + pluginId!: string; + + @Field() + category!: string; + + @Field() + version!: string; + + @Field() + displayName!: string; + + @Field() + description!: string; + + @Field(() => [String]) + requiredRoles!: string[]; + + @Field() + isBuiltin!: boolean; + + @Field() + isActive!: boolean; +} + +@ObjectType() +export class PluginConfigResponseGql { + @Field(() => PluginConfigLayoutGql, { nullable: true }) + activeLayout!: PluginConfigLayoutGql | null; + + @Field(() => [PluginConfigSlotGql]) + slots!: PluginConfigSlotGql[]; + + @Field(() => [PluginConfigPlacementGql]) + plugins!: PluginConfigPlacementGql[]; + + @Field(() => [PluginConfigRegistryItemGql]) + registry!: PluginConfigRegistryItemGql[]; +} + +@Resolver() +export class PluginConfigResolver { + constructor(private readonly service: ConfigService) {} + + /** + * 获取用户合并后的插件配置(三层合并)。 + * + * portal-shell RSC 调用:传入 userId(来自 x-user-id 头)与 role + * (来自 x-user-role 头),返回与 gRPC GetPluginConfig 等价的结果。 + */ + @Query(() => PluginConfigResponseGql) + async pluginConfig( + @Args("userId", { type: () => ID }) userId: string, + @Args("role", { + type: () => String, + nullable: true, + defaultValue: "student", + }) + role: string, + ): Promise { + const result = await this.service.getPluginConfig(userId, role); + return result as PluginConfigResponseGql; + } +}