diff --git a/.env.example b/.env.example index 7fb9245..1c8900e 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,17 @@ DEV_MODE=false # 服务端口 API_GATEWAY_PORT=8080 CLASSES_SERVICE_PORT=3001 -TEACHER_PORTAL_PORT=3000 +TEACHER_PORTAL_PORT=4000 + +# teacher-portal(ai13,MF Shell) +# GraphQL endpoint(经 api-gateway 代理到 teacher-bff,contract.md §2.3) +NEXT_PUBLIC_TEACHER_BFF_GRAPHQL_URL=/api/v1/teacher/graphql +# MSW mock 开关(contract.md §4.2,enabled 时启用 MSW 拦截上游) +NEXT_PUBLIC_API_MOCKING=enabled +# MF Remote 开关(ARB-002 §2.3,P2 默认 false,P3+ 启用 student-portal) +NEXT_PUBLIC_MF_ENABLED=false +# api-gateway URL(next.config.js rewrites 代理目标) +API_GATEWAY_URL=http://localhost:8080 # 数据库连接 DATABASE_URL=mysql://edu:changeme@localhost:3306/next_edu_cloud diff --git a/apps/teacher-portal/Dockerfile b/apps/teacher-portal/Dockerfile index 4fda909..baac88a 100644 --- a/apps/teacher-portal/Dockerfile +++ b/apps/teacher-portal/Dockerfile @@ -1,5 +1,6 @@ # 多阶段构建:Next.js 生产镜像 # 用法:docker build -t edu/teacher-portal:latest -f apps/teacher-portal/Dockerfile . +# 端口规范:teacher-portal :4000(matrix.md §1 微前端层) # ============ Builder ============ FROM node:20-alpine AS builder @@ -8,14 +9,20 @@ WORKDIR /app # 启用 pnpm RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -# 先拷依赖清单,利用缓存 +# 先拷依赖清单,利用缓存(含 workspace 共享包) COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ COPY apps/teacher-portal/package.json ./apps/teacher-portal/ +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/teacher-portal... --frozen-lockfile || pnpm install --filter @edu/teacher-portal... # 拷源码 COPY apps/teacher-portal ./apps/teacher-portal +COPY packages/ui-tokens ./packages/ui-tokens +COPY packages/ui-components ./packages/ui-components +COPY packages/hooks ./packages/hooks # 构建(禁用 telemetry,生产模式) ENV NEXT_TELEMETRY_DISABLED=1 @@ -27,7 +34,7 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV PORT=3000 +ENV PORT=4000 # 非 root 用户运行 RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 @@ -40,10 +47,10 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/apps/teacher-portal/next.config.js ./next.config.js USER nextjs -EXPOSE 3000 +EXPOSE 4000 -# 健康检查 +# 健康检查(/api/health liveness 端点) HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD wget --quiet --spider http://localhost:3000/ || exit 1 + CMD wget --quiet --spider http://localhost:4000/api/health || exit 1 -CMD ["node_modules/.bin/next", "start", "-p", "3000"] +CMD ["node_modules/.bin/next", "start", "-p", "4000"] diff --git a/apps/teacher-portal/next.config.js b/apps/teacher-portal/next.config.js index 027d357..43eb457 100644 --- a/apps/teacher-portal/next.config.js +++ b/apps/teacher-portal/next.config.js @@ -1,11 +1,114 @@ +/** + * teacher-portal Next.js 配置 + * + * 角色:MF Shell 宿主(ARB-002 §2.3) + * - exposes: AppShell + GraphQLProvider + 共享 hooks + 共享 UI 组件 + * - shared: react/react-dom/urql/graphql/@edu/* 设为 singleton + * - remotes: P2 为空(NEXT_PUBLIC_MF_ENABLED=false),P3+ 逐步接入 + * + * 维护者:ai13(teacher-portal) + * 关联:ARB-002 §2.2 暴露清单、03-long-term-architecture.md §1.3 绞杀者模式 + */ + /** @type {import('next').NextConfig} */ + +// NextFederationPlugin 动态 require,避免构建时硬依赖(MF 2.0 在 Next 14 App Router 下按需加载) +let NextFederationPlugin; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + NextFederationPlugin = require("@module-federation/nextjs-mf").NextFederationPlugin; +} catch { + // 依赖未安装时跳过 MF 配置(如纯 typecheck 环境) + NextFederationPlugin = null; +} + +/** + * 构建 Remote 列表(P2 为空,P3+ 按 NEXT_PUBLIC_MF_ENABLED 启用) + * + * ARB-002 §2.3:P2 Remote 数量 = 0,NEXT_PUBLIC_MF_ENABLED=false 默认关 + * P3 首个 Remote:student-portal(总裁 §1.3 step 4) + */ +function buildRemotes(isServer) { + if (process.env.NEXT_PUBLIC_MF_ENABLED !== "true") return {}; + const chunkPath = isServer ? "ssr" : "chunks"; + return { + student: `student_app@http://localhost:4001/_next/static/${chunkPath}/remoteEntry.js`, + parent: `parent_app@http://localhost:4002/_next/static/${chunkPath}/remoteEntry.js`, + admin: `admin_app@http://localhost:4003/_next/static/${chunkPath}/remoteEntry.js`, + }; +} + +/** + * ARB-002 §2.2 MF Shell 暴露清单 + */ +function buildExposes() { + return { + // 1. AppShell(布局 + 导航 + 认证守卫) + "./AppShell": "./src/components/AppShell.tsx", + // 2. GraphQLProvider(urql client 单例,总裁 §2.17 方案 A) + "./GraphQLProvider": "./src/app/providers.tsx", + // 3. 共享 hooks + "./useAuth": "./packages/hooks/src/use-auth.ts", + "./usePermission": "./packages/hooks/src/use-permission.ts", + "./useGraphQLClient": "./packages/hooks/src/use-graphql-client.ts", + // 4. 共享 UI 组件 + "./ErrorBoundary": "./packages/ui-components/src/error-boundary.tsx", + "./Loading": "./packages/ui-components/src/loading.tsx", + "./Empty": "./packages/ui-components/src/empty.tsx", + "./RequirePermission": "./packages/ui-components/src/require-permission.tsx", + }; +} + +/** + * ARB-002 §2.2 MF shared singleton 配置 + * 避免 Remote 多实例 + 缓存不一致 + */ +function buildShared() { + return { + react: { singleton: true, requiredVersion: "^18.3.0" }, + "react-dom": { singleton: true, requiredVersion: "^18.3.0" }, + urql: { singleton: true, requiredVersion: "^2.2.0" }, + graphql: { singleton: true, requiredVersion: "^16.8.0" }, + "@edu/ui-tokens": { singleton: true }, + "@edu/ui-components": { singleton: true }, + "@edu/hooks": { singleton: true }, + }; +} + const nextConfig = { reactStrictMode: true, + + // ARB-002:MF Shell 配置(exposes + shared,P2 无 remotes) + webpack(config, { isServer }) { + if (NextFederationPlugin) { + config.plugins.push( + new NextFederationPlugin({ + name: "teacher_app", + filename: "static/chunks/remoteEntry.js", + remotes: buildRemotes(isServer), + exposes: buildExposes(), + shared: buildShared(), + extraOptions: { + exposePages: false, + }, + }), + ); + } + return config; + }, + + // 反向代理:/api/v1/* → api-gateway :8080 async rewrites() { + const gateway = process.env.API_GATEWAY_URL || "http://localhost:8080"; return [ { - source: '/api/v1/:path*', - destination: `${process.env.API_GATEWAY_URL || 'http://localhost:8080'}/api/v1/:path*`, + source: "/api/v1/:path*", + destination: `${gateway}/api/v1/:path*`, + }, + { + // 登录端点(非 GraphQL,认证前提,F12: JWT 存 localStorage) + source: "/api/auth/:path*", + destination: `${gateway}/api/auth/:path*`, }, ]; }, diff --git a/apps/teacher-portal/package.json b/apps/teacher-portal/package.json index e6ec4bd..0b632a7 100644 --- a/apps/teacher-portal/package.json +++ b/apps/teacher-portal/package.json @@ -3,25 +3,37 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3000", + "dev": "next dev -p 4000", "build": "next build", - "start": "next start -p 3000", + "start": "next start -p 4000", "lint": "eslint src", "typecheck": "tsc --noEmit" }, "dependencies": { + "@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" + "react-dom": "^18.3.0", + "urql": "^2.2.0" }, "devDependencies": { + "@module-federation/nextjs-mf": "^8.3.0", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.0", "eslint": "^9.0.0", + "msw": "^2.4.0", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.6.0" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/apps/teacher-portal/public/mockServiceWorker.js b/apps/teacher-portal/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/apps/teacher-portal/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/teacher-portal/src/app/(app)/classes/page.tsx b/apps/teacher-portal/src/app/(app)/classes/page.tsx index b546990..e2ddc5e 100644 --- a/apps/teacher-portal/src/app/(app)/classes/page.tsx +++ b/apps/teacher-portal/src/app/(app)/classes/page.tsx @@ -1,293 +1,97 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { getToken } from "@/lib/auth"; +/** + * Classes 页面 - 班级管理(只读列表) + * + * 数据来源(ARB-001 §1.2):GraphQL ClassesQuery + * - 返回当前教师 dataScope 范围内的班级列表 + * - P2 阶段为只读展示(Mutation 在 P3 启用) + * + * 维护者:ai13(teacher-portal) + */ -interface ClassItem { - id: string; - name: string; - gradeId: string; - description?: string; - createdAt: number; - updatedAt: number; -} - -interface ApiResponse { - success: boolean; - data?: T; - error?: { code: string; message: string }; -} +import { useQuery } from "urql"; +import Link from "next/link"; +import { Loading, Empty } from "@edu/ui-components"; +import { ClassesQuery } from "@/lib/graphql"; +import type { Class } from "@/lib/graphql"; export default function ClassesPage() { - const [classes, setClasses] = useState([]); - const [loading, setLoading] = useState(false); - const [name, setName] = useState(""); - const [gradeId, setGradeId] = useState( - "550e8400-e29b-41d4-a716-446655440000", - ); - const [description, setDescription] = useState(""); - const [error, setError] = useState(null); - - const authHeaders = (): Record => { - const token = getToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; - }; - - const fetchClasses = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch("/api/v1/classes", { - headers: authHeaders(), - }); - const json: ApiResponse = await res.json(); - if (json.success && json.data) { - setClasses(json.data); - } else { - setError(json.error?.message || "Failed to load"); - } - } catch (e) { - setError(e instanceof Error ? e.message : "Network error"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchClasses(); - }, [fetchClasses]); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim()) return; - try { - const res = await fetch("/api/v1/classes", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeaders(), - }, - body: JSON.stringify({ name, gradeId, description }), - }); - const json = await res.json(); - if (!json.success) { - setError(json.error?.message || "Create failed"); - return; - } - setName(""); - setDescription(""); - await fetchClasses(); - } catch (e) { - setError(e instanceof Error ? e.message : "Network error"); - } - }; - - const handleDelete = async (id: string) => { - try { - const res = await fetch(`/api/v1/classes/${id}`, { - method: "DELETE", - headers: authHeaders(), - }); - const json = await res.json(); - if (!json.success) { - setError(json.error?.message || "Delete failed"); - return; - } - await fetchClasses(); - } catch (e) { - setError(e instanceof Error ? e.message : "Network error"); - } - }; + const [result] = useQuery({ query: ClassesQuery }); return (
-

- 班级管理 -

-

- classes 域 CRUD · JWT 鉴权 +

班级管理

+

+ GraphQL ClassesQuery · 按教师 dataScope 过滤

-
-