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:
@@ -14,7 +14,7 @@ module.exports = {
|
|||||||
'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai',
|
'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai',
|
||||||
'config-service',
|
'config-service',
|
||||||
'teacher-bff', 'student-bff', 'parent-bff',
|
'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',
|
'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens',
|
||||||
'arch-scan', 'infra', 'docs', 'deps', 'release',
|
'arch-scan', 'infra', 'docs', 'deps', 'release',
|
||||||
],
|
],
|
||||||
|
|||||||
19
apps/portal-shell/.env.example
Normal file
19
apps/portal-shell/.env.example
Normal file
@@ -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
|
||||||
49
apps/portal-shell/.eslintrc.tokens.js
Normal file
49
apps/portal-shell/.eslintrc.tokens.js
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
56
apps/portal-shell/Dockerfile
Normal file
56
apps/portal-shell/Dockerfile
Normal file
@@ -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"]
|
||||||
93
apps/portal-shell/eslint.config.js
Normal file
93
apps/portal-shell/eslint.config.js
Normal file
@@ -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,
|
||||||
|
);
|
||||||
5
apps/portal-shell/next-env.d.ts
vendored
Normal file
5
apps/portal-shell/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
52
apps/portal-shell/next.config.js
Normal file
52
apps/portal-shell/next.config.js
Normal file
@@ -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;
|
||||||
43
apps/portal-shell/package.json
Normal file
43
apps/portal-shell/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/portal-shell/postcss.config.js
Normal file
6
apps/portal-shell/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
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} />;
|
||||||
|
}
|
||||||
75
apps/portal-shell/src/lib/apollo-client.ts
Normal file
75
apps/portal-shell/src/lib/apollo-client.ts
Normal file
@@ -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<unknown> {
|
||||||
|
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<unknown> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
|
||||||
|
*/
|
||||||
|
export function getApolloClient(
|
||||||
|
getAuthToken?: () => string | null,
|
||||||
|
): ApolloClient<unknown> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return createApolloClient(getAuthToken);
|
||||||
|
}
|
||||||
|
if (!clientSingleton) {
|
||||||
|
clientSingleton = createApolloClient(getAuthToken);
|
||||||
|
}
|
||||||
|
return clientSingleton;
|
||||||
|
}
|
||||||
117
apps/portal-shell/src/lib/config-fetcher.ts
Normal file
117
apps/portal-shell/src/lib/config-fetcher.ts
Normal file
@@ -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<PluginConfigResponse> {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
131
apps/portal-shell/src/lib/types.ts
Normal file
131
apps/portal-shell/src/lib/types.ts
Normal file
@@ -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<TProps = Record<string, unknown>> {
|
||||||
|
/** 插件实例 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<string, JsonSchema>;
|
||||||
|
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<PluginProps>;
|
||||||
|
/** 插件元数据 */
|
||||||
|
metadata: {
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
category: PluginCategory;
|
||||||
|
requiredRoles: Role[];
|
||||||
|
defaultSlot: string;
|
||||||
|
defaultSize: PluginSize;
|
||||||
|
/** 插件可配置的 props schema(admin 配置面板用) */
|
||||||
|
propsSchema?: JsonSchema;
|
||||||
|
/** 系统默认 props */
|
||||||
|
defaultProps?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
86
apps/portal-shell/src/lib/usePluginConfig.ts
Normal file
86
apps/portal-shell/src/lib/usePluginConfig.ts
Normal file
@@ -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<PluginConfigResponse>(
|
||||||
|
["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]);
|
||||||
|
}
|
||||||
29
apps/portal-shell/src/lib/useWidgetMutation.ts
Normal file
29
apps/portal-shell/src/lib/useWidgetMutation.ts
Normal file
@@ -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<TData, TVars extends Record<string, unknown>>(
|
||||||
|
mutation: DocumentNode | TypedDocumentNode<TData, TVars>,
|
||||||
|
) {
|
||||||
|
const [mutate, result] = useMutation<TData, TVars>(mutation, {
|
||||||
|
errorPolicy: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (variables: TVars): Promise<TData | undefined> => {
|
||||||
|
const res = await mutate({ variables });
|
||||||
|
return res.data ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { run, ...result };
|
||||||
|
}
|
||||||
51
apps/portal-shell/src/lib/useWidgetQuery.ts
Normal file
51
apps/portal-shell/src/lib/useWidgetQuery.ts
Normal file
@@ -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<TData> {
|
||||||
|
/** RSC 预取的初始数据(首次渲染无需客户端请求) */
|
||||||
|
fallbackData?: TData;
|
||||||
|
/** 轮询间隔(ms) */
|
||||||
|
pollInterval?: number;
|
||||||
|
/** 是否启用查询(false 时跳过) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Apollo fetchPolicy */
|
||||||
|
fetchPolicy?: FetchPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetQuery<TData, TVars extends Record<string, unknown>>(
|
||||||
|
query: DocumentNode | TypedDocumentNode<TData, TVars>,
|
||||||
|
variables: TVars,
|
||||||
|
options?: UseWidgetQueryOptions<TData>,
|
||||||
|
) {
|
||||||
|
const { data, loading, error, refetch } = useQuery<TData, TVars>(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 };
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/providers/ApolloProvider.tsx
Normal file
38
apps/portal-shell/src/providers/ApolloProvider.tsx
Normal file
@@ -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 (
|
||||||
|
<ApolloGraphQLProvider client={client}>{children}</ApolloGraphQLProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/portal-shell/src/providers/AuthProvider.tsx
Normal file
51
apps/portal-shell/src/providers/AuthProvider.tsx
Normal file
@@ -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<AuthContextValue | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
55
apps/portal-shell/src/providers/ThemeI18nProvider.tsx
Normal file
55
apps/portal-shell/src/providers/ThemeI18nProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题 + i18n Provider(v2.1 M8)
|
||||||
|
*
|
||||||
|
* 通过 PluginStore(Zustand)管理 theme(light/dark)与 locale(zh-CN/en),
|
||||||
|
* 将主题类名同步到 <html>,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];
|
||||||
|
}
|
||||||
85
apps/portal-shell/src/shell/ClientShell.tsx
Normal file
85
apps/portal-shell/src/shell/ClientShell.tsx
Normal file
@@ -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 (
|
||||||
|
<ApolloProvider>
|
||||||
|
<AuthProvider user={user}>
|
||||||
|
<ThemeI18nProvider>
|
||||||
|
<Shell config={liveConfig} user={user} role={role} userId={userId} />
|
||||||
|
{configChanged ? (
|
||||||
|
<div className="fixed bottom-xl right-xl z-50 rounded-card border border-rule bg-surface p-md shadow-md">
|
||||||
|
<p className="text-small text-ink">
|
||||||
|
发现新布局配置,刷新后生效。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfigChanged(false)}
|
||||||
|
className="mt-sm ml-sm rounded-button border border-rule px-md py-xs text-small text-ink"
|
||||||
|
>
|
||||||
|
稍后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ThemeI18nProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ApolloProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/portal-shell/src/shell/LayoutManager.tsx
Normal file
145
apps/portal-shell/src/shell/LayoutManager.tsx
Normal file
@@ -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<SlotRendererProps, "slotName" | "layoutId">;
|
||||||
|
|
||||||
|
export interface LayoutManagerProps extends SlotRendererInput {
|
||||||
|
layoutId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutManager(props: LayoutManagerProps): ReactNode {
|
||||||
|
const { layoutId, ...slotInput } = props;
|
||||||
|
switch (layoutId) {
|
||||||
|
case "focus":
|
||||||
|
return <FocusLayout slotInput={slotInput} layoutId={layoutId} />;
|
||||||
|
case "split":
|
||||||
|
return <SplitLayout slotInput={slotInput} layoutId={layoutId} />;
|
||||||
|
case "triple":
|
||||||
|
return <TripleLayout slotInput={slotInput} layoutId={layoutId} />;
|
||||||
|
case "canvas":
|
||||||
|
return <CanvasLayout slotInput={slotInput} layoutId={layoutId} />;
|
||||||
|
case "classic":
|
||||||
|
default:
|
||||||
|
return <ClassicLayout slotInput={slotInput} layoutId={layoutId} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutShellProps {
|
||||||
|
slotInput: SlotRendererInput;
|
||||||
|
layoutId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** classic:TopBar + SideNav + Main */
|
||||||
|
function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-paper">
|
||||||
|
<header className="border-b border-rule bg-surface">
|
||||||
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<aside className="w-64 border-r border-rule bg-surface p-md">
|
||||||
|
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 p-lg">
|
||||||
|
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** focus:TopBar + 全宽 Main */
|
||||||
|
function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-paper">
|
||||||
|
<header className="border-b border-rule bg-surface">
|
||||||
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 p-lg">
|
||||||
|
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** split:TopBar + 左右等分 Main */
|
||||||
|
function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-paper">
|
||||||
|
<header className="border-b border-rule bg-surface">
|
||||||
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1 gap-md p-lg">
|
||||||
|
<section className="flex-1">
|
||||||
|
<SlotRenderer
|
||||||
|
slotName="main-left"
|
||||||
|
layoutId={layoutId}
|
||||||
|
{...slotInput}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="flex-1">
|
||||||
|
<SlotRenderer
|
||||||
|
slotName="main-right"
|
||||||
|
layoutId={layoutId}
|
||||||
|
{...slotInput}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** triple:TopBar + SideNav + Main + RightRail */
|
||||||
|
function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-paper">
|
||||||
|
<header className="border-b border-rule bg-surface">
|
||||||
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<aside className="w-64 border-r border-rule bg-surface p-md">
|
||||||
|
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 p-lg">
|
||||||
|
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
|
||||||
|
</main>
|
||||||
|
<aside className="w-72 border-l border-rule bg-surface p-md">
|
||||||
|
<SlotRenderer slotName="right" layoutId={layoutId} {...slotInput} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** canvas:TopBar + 自由摆放 grid(MVP 按 grid 排列,不实现拖拽) */
|
||||||
|
function CanvasLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-paper">
|
||||||
|
<header className="border-b border-rule bg-surface">
|
||||||
|
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 p-lg">
|
||||||
|
<SlotRenderer
|
||||||
|
slotName="canvas-grid"
|
||||||
|
layoutId={layoutId}
|
||||||
|
{...slotInput}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
apps/portal-shell/src/shell/PluginLoader.tsx
Normal file
151
apps/portal-shell/src/shell/PluginLoader.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="rounded-card bg-surface p-md animate-pulse"
|
||||||
|
role="status"
|
||||||
|
aria-label="loading"
|
||||||
|
>
|
||||||
|
<div className="h-heading-3 bg-subtle rounded-button mb-md w-1/4" />
|
||||||
|
<div className="space-y-sm">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-body bg-subtle rounded-button w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (variant === "list") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-sm" role="status" aria-label="loading">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-body bg-subtle rounded-button w-full animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// card / stats / chart 默认卡片骨架
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-card bg-surface p-md animate-pulse"
|
||||||
|
role="status"
|
||||||
|
aria-label="loading"
|
||||||
|
>
|
||||||
|
<div className="h-heading-3 bg-subtle rounded-button mb-md w-1/3" />
|
||||||
|
<div className="h-large-number bg-subtle rounded-button w-1/2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件错误兜底(居中错误图标 + 重试) */
|
||||||
|
export function PluginErrorFallback({
|
||||||
|
instanceId,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
instanceId: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-card border border-rule bg-surface p-md text-ink-muted"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-small">插件加载失败({instanceId})</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PluginErrorFallback
|
||||||
|
instanceId={this.props.instanceId}
|
||||||
|
onRetry={this.props.onRetry ?? this.handleRetry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件加载器:包裹组件 + ErrorBoundary */
|
||||||
|
export function PluginLoader({
|
||||||
|
Component,
|
||||||
|
pluginProps,
|
||||||
|
}: {
|
||||||
|
Component: React.ComponentType<PluginProps>;
|
||||||
|
pluginProps: PluginProps;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<PluginErrorBoundary instanceId={pluginProps.instanceId}>
|
||||||
|
<Component {...pluginProps} />
|
||||||
|
</PluginErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/portal-shell/src/shell/PluginStore.ts
Normal file
31
apps/portal-shell/src/shell/PluginStore.ts
Normal file
@@ -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<PluginStore>((set) => ({
|
||||||
|
theme: "light",
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
locale: "zh-CN",
|
||||||
|
setLocale: (locale) => set({ locale }),
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||||
|
}));
|
||||||
83
apps/portal-shell/src/shell/PropsMerger.ts
Normal file
83
apps/portal-shell/src/shell/PropsMerger.ts
Normal file
@@ -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<string, unknown> | undefined | null)[]
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>,
|
||||||
|
next as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result[key] = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全解析 JSON 字符串为对象,失败返回空对象 */
|
||||||
|
export function parsePropsJson(
|
||||||
|
json: string | undefined | null,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!json) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
return isPlainObject(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||||
|
} 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<string, unknown>;
|
||||||
|
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<string, unknown> {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.getPrototypeOf(value) === Object.prototype
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/portal-shell/src/shell/Registry.tsx
Normal file
63
apps/portal-shell/src/shell/Registry.tsx
Normal file
@@ -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<string, PluginManifest> = {
|
||||||
|
"grades-widget": {
|
||||||
|
...gradesWidgetMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/universal/grades-widget"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="table" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"notification-bell": {
|
||||||
|
...notificationBellMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/topbar/notification-bell"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"user-menu": {
|
||||||
|
...userMenuMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/topbar/user-menu"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="card" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"class-selector": {
|
||||||
|
...classSelectorMeta,
|
||||||
|
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <PluginSkeleton variant="list" />,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 判断插件是否已注册 */
|
||||||
|
export function isPluginRegistered(pluginId: string): boolean {
|
||||||
|
return pluginId in REGISTRY;
|
||||||
|
}
|
||||||
34
apps/portal-shell/src/shell/Shell.tsx
Normal file
34
apps/portal-shell/src/shell/Shell.tsx
Normal file
@@ -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 (
|
||||||
|
<LayoutManager
|
||||||
|
layoutId={layoutId}
|
||||||
|
plugins={config.plugins}
|
||||||
|
user={user}
|
||||||
|
role={role}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/portal-shell/src/shell/SlotRenderer.tsx
Normal file
107
apps/portal-shell/src/shell/SlotRenderer.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-card border border-rule bg-surface p-md text-ink-muted text-small">
|
||||||
|
暂无可见插件
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-md">
|
||||||
|
{visible.map((placement) => {
|
||||||
|
if (!isPluginRegistered(placement.pluginId)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={placement.pluginId}
|
||||||
|
className="rounded-card border border-rule bg-surface p-md text-ink-muted text-small"
|
||||||
|
>
|
||||||
|
未注册插件:{placement.pluginId}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<PluginLoader
|
||||||
|
key={pluginProps.instanceId}
|
||||||
|
Component={manifest.Component}
|
||||||
|
pluginProps={pluginProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slot 加载态占位(layout 切换瞬间) */
|
||||||
|
export function SlotSkeleton({ count = 1 }: { count?: number }): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="space-y-md">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<PluginSkeleton key={i} variant="card" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/portal-shell/src/styles/tokens.css
Normal file
6
apps/portal-shell/src/styles/tokens.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* portal-shell 设计令牌入口(引用 @edu/ui-tokens)
|
||||||
|
*
|
||||||
|
* 业务代码通过 Tailwind 类(bg-paper / text-ink)或 hsl(var(--*)) 引用。
|
||||||
|
*/
|
||||||
|
@import "@edu/ui-tokens/all.css";
|
||||||
@@ -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<string, never>
|
||||||
|
>(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 <PluginSkeleton variant="list" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = data?.myClasses ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-small text-ink-muted">班级</p>
|
||||||
|
<select
|
||||||
|
value={currentClassId}
|
||||||
|
onChange={(e) => handleSelect(e.target.value)}
|
||||||
|
className="mt-xs w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
|
||||||
|
aria-label="选择班级"
|
||||||
|
>
|
||||||
|
<option value="">请选择班级</option>
|
||||||
|
{classes.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<PluginManifest, "Component"> = {
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<NotificationsQueryData, { limit: number }>(
|
||||||
|
GET_NOTIFICATIONS,
|
||||||
|
{ limit },
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = data?.notifications ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="通知"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="relative rounded-button bg-surface px-sm py-xs text-ink"
|
||||||
|
>
|
||||||
|
<span aria-hidden>🔔</span>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<span className="absolute -right-xs -top-xs rounded-full bg-danger px-xs text-tiny text-ink-onAccent">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<ul className="absolute right-0 z-50 mt-sm w-64 rounded-card border border-rule bg-surface p-sm shadow-md">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<li className="text-small text-ink-muted">暂无通知</li>
|
||||||
|
) : (
|
||||||
|
items.map((n) => (
|
||||||
|
<li
|
||||||
|
key={n.id}
|
||||||
|
className="border-b border-rule py-xs text-small text-ink"
|
||||||
|
>
|
||||||
|
{n.title}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* notification-bell 插件清单(topbar)
|
||||||
|
*
|
||||||
|
* 通知铃铛,插入 top slot。查询 apollo-router → msg 子图 notifications 数据。
|
||||||
|
*/
|
||||||
|
import type { PluginManifest } from "@/lib/types";
|
||||||
|
|
||||||
|
export const manifestMeta: Omit<PluginManifest, "Component"> = {
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
76
apps/portal-shell/src/widgets/topbar/user-menu/index.tsx
Normal file
76
apps/portal-shell/src/widgets/topbar/user-menu/index.tsx
Normal file
@@ -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<MeQueryData, Record<string, never>>(
|
||||||
|
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 (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="用户菜单"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-sm rounded-button bg-surface px-sm py-xs text-ink"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex h-heading-3 w-heading-3 items-center justify-center rounded-full bg-accent text-ink-onAccent"
|
||||||
|
>
|
||||||
|
{displayName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-small">{displayName}</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<ul className="absolute right-0 z-50 mt-sm w-56 rounded-card border border-rule bg-surface p-sm shadow-md">
|
||||||
|
<li className="border-b border-rule py-xs">
|
||||||
|
<p className="text-small text-ink">{displayName}</p>
|
||||||
|
<p className="text-tiny text-ink-muted">{displayEmail}</p>
|
||||||
|
</li>
|
||||||
|
<li className="py-xs text-small text-ink-muted">
|
||||||
|
角色:{displayRole}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* user-menu 插件清单(topbar)
|
||||||
|
*
|
||||||
|
* 用户菜单,插入 top slot。查询 apollo-router → iam 子图的 me 数据。
|
||||||
|
*/
|
||||||
|
import type { PluginManifest } from "@/lib/types";
|
||||||
|
|
||||||
|
export const manifestMeta: Omit<PluginManifest, "Component"> = {
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 <PluginSkeleton variant="table" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classId) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-card border border-rule bg-surface p-md">
|
||||||
|
<h3 className="text-heading-3 text-ink">成绩</h3>
|
||||||
|
<p className="text-small text-ink-muted">请先选择班级</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data?.grades ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-card border border-rule bg-surface p-md">
|
||||||
|
<h3 className="text-heading-3 text-ink">成绩</h3>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-small text-ink-muted">暂无成绩数据</p>
|
||||||
|
) : (
|
||||||
|
<table className="mt-sm w-full text-small">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-rule text-ink-muted">
|
||||||
|
<th className="py-xs text-left">学号</th>
|
||||||
|
<th className="py-xs text-right">分数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.slice(0, limit).map((row) => (
|
||||||
|
<tr key={row.studentId} className="border-b border-rule">
|
||||||
|
<td className="py-xs text-ink">{row.studentId}</td>
|
||||||
|
<td className="py-xs text-right text-ink">{row.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<PluginManifest, "Component"> = {
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
83
apps/portal-shell/tailwind.config.js
Normal file
83
apps/portal-shell/tailwind.config.js
Normal file
@@ -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: [],
|
||||||
|
};
|
||||||
33
apps/portal-shell/tsconfig.json
Normal file
33
apps/portal-shell/tsconfig.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
18
apps/portal-shell/vitest.config.ts
Normal file
18
apps/portal-shell/vitest.config.ts
Normal file
@@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -27,10 +27,12 @@ cors:
|
|||||||
- "http://localhost:4001"
|
- "http://localhost:4001"
|
||||||
- "http://localhost:4002"
|
- "http://localhost:4002"
|
||||||
- "http://localhost:4003"
|
- "http://localhost:4003"
|
||||||
|
- "http://localhost:4010"
|
||||||
- "http://teacher-portal:4000"
|
- "http://teacher-portal:4000"
|
||||||
- "http://student-portal:4001"
|
- "http://student-portal:4001"
|
||||||
- "http://parent-portal:4002"
|
- "http://parent-portal:4002"
|
||||||
- "http://admin-portal:4003"
|
- "http://admin-portal:4003"
|
||||||
|
- "http://portal-shell:4010"
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@@ -45,12 +47,22 @@ cors:
|
|||||||
|
|
||||||
# 向所有子图注入 Router-Authorization Header(ADR-036)
|
# 向所有子图注入 Router-Authorization Header(ADR-036)
|
||||||
# 子图的 RouterAuthGuard 校验此 Header,拒绝非 Router 的直接 GraphQL 请求
|
# 子图的 RouterAuthGuard 校验此 Header,拒绝非 Router 的直接 GraphQL 请求
|
||||||
|
# 同时透传用户身份头(x-user-id / x-user-role)与 Authorization 到子图,
|
||||||
|
# 供 iam/core-edu/msg 等子图做用户级鉴权(M8:portal-shell 查询走 Router)
|
||||||
headers:
|
headers:
|
||||||
all:
|
all:
|
||||||
request:
|
request:
|
||||||
- add:
|
- add:
|
||||||
name: "router-authorization"
|
name: "router-authorization"
|
||||||
value: "${env.ROUTER_AUTH_SECRET}"
|
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:
|
traffic_shaping:
|
||||||
|
|||||||
@@ -347,6 +347,36 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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)
|
# Temporal Server - AI 工作流引擎(v2.1 §8.2 ADR-030)
|
||||||
# 仅用于 AI 耗时工作流 + Saga,CRUD 短事务禁止
|
# 仅用于 AI 耗时工作流 + Saga,CRUD 短事务禁止
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
226
pnpm-lock.yaml
generated
226
pnpm-lock.yaml
generated
@@ -182,7 +182,7 @@ importers:
|
|||||||
version: 5.101.2(react@18.3.0)
|
version: 5.101.2(react@18.3.0)
|
||||||
'@urql/next':
|
'@urql/next':
|
||||||
specifier: ^1.1.0
|
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:
|
clsx:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.1
|
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)
|
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:
|
nuqs:
|
||||||
specifier: ^1.19.0
|
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:
|
react:
|
||||||
specifier: ^18.3.0
|
specifier: ^18.3.0
|
||||||
version: 18.3.0
|
version: 18.3.0
|
||||||
@@ -303,6 +303,82 @@ importers:
|
|||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.4
|
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:
|
apps/student-portal:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@edu/contracts':
|
'@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)
|
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:
|
nuqs:
|
||||||
specifier: ^1.19.0
|
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:
|
react:
|
||||||
specifier: ^18.3.0
|
specifier: ^18.3.0
|
||||||
version: 18.3.0
|
version: 18.3.0
|
||||||
@@ -1648,6 +1724,24 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
graphql: 14.x || 15.x || 16.x
|
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':
|
'@apollo/federation-internals@2.14.2':
|
||||||
resolution: {integrity: sha512-6+de7KrV53x3I+RNjTaFzjT6INmI1dJ1uLbgPhVBcBEk9lbJ9BwyOWwBYpMuVFAgEuqMHb0/2qy/gme93j/ciA==}
|
resolution: {integrity: sha512-6+de7KrV53x3I+RNjTaFzjT6INmI1dJ1uLbgPhVBcBEk9lbJ9BwyOWwBYpMuVFAgEuqMHb0/2qy/gme93j/ciA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5822,6 +5916,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==}
|
resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
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':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@@ -8890,6 +9000,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-YYamqKu48bZCSTJKSWLLO4SSk8tKN2Gg2z1sJZVzHJYVObMO/xQpIzAh6re9HCMHRdB1dJvBjJH18DW7xYOicg==}
|
resolution: {integrity: sha512-YYamqKu48bZCSTJKSWLLO4SSk8tKN2Gg2z1sJZVzHJYVObMO/xQpIzAh6re9HCMHRdB1dJvBjJH18DW7xYOicg==}
|
||||||
engines: {node: ^22 || ^21 || ^20 || ^18 || ^16}
|
engines: {node: ^22 || ^21 || ^20 || ^18 || ^16}
|
||||||
|
|
||||||
|
optimism@0.18.1:
|
||||||
|
resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -9408,6 +9521,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
remedial@1.0.8:
|
||||||
resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==}
|
resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==}
|
||||||
|
|
||||||
@@ -9969,6 +10093,11 @@ packages:
|
|||||||
swap-case@2.0.2:
|
swap-case@2.0.2:
|
||||||
resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==}
|
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:
|
symbol-observable@1.2.0:
|
||||||
resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==}
|
resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -10211,6 +10340,10 @@ packages:
|
|||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
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:
|
ts-log@2.2.3:
|
||||||
resolution: {integrity: sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w==}
|
resolution: {integrity: sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w==}
|
||||||
|
|
||||||
@@ -10494,6 +10627,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
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:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -10851,6 +10989,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
||||||
engines: {node: '>=12.20'}
|
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:
|
zip-stream@6.0.1:
|
||||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -10948,6 +11092,30 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
graphql: 16.14.2
|
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)':
|
'@apollo/federation-internals@2.14.2(graphql@16.14.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/uuid': 9.0.8
|
'@types/uuid': 9.0.8
|
||||||
@@ -15912,7 +16080,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- graphql
|
- 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:
|
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)
|
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
|
react: 18.3.0
|
||||||
@@ -16131,6 +16299,22 @@ snapshots:
|
|||||||
'@whatwg-node/promise-helpers': 1.3.2
|
'@whatwg-node/promise-helpers': 1.3.2
|
||||||
tslib: 2.8.1
|
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/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@@ -19414,7 +19598,7 @@ snapshots:
|
|||||||
gauge: 3.0.2
|
gauge: 3.0.2
|
||||||
set-blocking: 2.0.0
|
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:
|
dependencies:
|
||||||
mitt: 3.0.1
|
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)
|
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: {}
|
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:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -20043,6 +20234,11 @@ snapshots:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
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: {}
|
remedial@1.0.8: {}
|
||||||
|
|
||||||
remove-trailing-separator@1.1.0: {}
|
remove-trailing-separator@1.1.0: {}
|
||||||
@@ -20675,6 +20871,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
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@1.2.0: {}
|
||||||
|
|
||||||
symbol-observable@4.0.0: {}
|
symbol-observable@4.0.0: {}
|
||||||
@@ -20969,6 +21171,10 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
ts-invariant@0.10.3:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
ts-log@2.2.3: {}
|
ts-log@2.2.3: {}
|
||||||
|
|
||||||
ts-morph@24.0.0:
|
ts-morph@24.0.0:
|
||||||
@@ -21208,6 +21414,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.0
|
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: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
@@ -21658,6 +21868,12 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@1.2.2: {}
|
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:
|
zip-stream@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
archiver-utils: 5.0.2
|
archiver-utils: 5.0.2
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ConfigModule } from "../config-config/config.module.js";
|
|||||||
import { PluginResolver } from "./resolvers/plugin.resolver.js";
|
import { PluginResolver } from "./resolvers/plugin.resolver.js";
|
||||||
import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js";
|
import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js";
|
||||||
import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js";
|
import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js";
|
||||||
|
import { PluginConfigResolver } from "./resolvers/plugin-config.resolver.js";
|
||||||
import { DataLoaderService } from "./dataloader.service.js";
|
import { DataLoaderService } from "./dataloader.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -50,6 +51,7 @@ import { DataLoaderService } from "./dataloader.service.js";
|
|||||||
PluginResolver,
|
PluginResolver,
|
||||||
LayoutTemplateResolver,
|
LayoutTemplateResolver,
|
||||||
UserLayoutResolver,
|
UserLayoutResolver,
|
||||||
|
PluginConfigResolver,
|
||||||
DataLoaderService,
|
DataLoaderService,
|
||||||
],
|
],
|
||||||
exports: [DataLoaderService],
|
exports: [DataLoaderService],
|
||||||
|
|||||||
@@ -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<PluginConfigResponseGql> {
|
||||||
|
const result = await this.service.getPluginConfig(userId, role);
|
||||||
|
return result as PluginConfigResponseGql;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user