feat(portal-shell): add MSW mock layer with production bundle exclusion (P1-5)

MSW v2.7.0 fallback layer covering dashboard/users/exams/grades domains.
NEXT_PUBLIC_MSW=1 enables browser Service Worker + SSR route handler mock
responses without backend. Production build excludes all mock data via
Turbopack resolveAlias redirecting @/mocks to empty stub.

Acceptance: build bundle (client+server) verified clean of mock strings;
typecheck/lint/vitest (231 tests) all pass.
This commit is contained in:
SpecialX
2026-07-22 14:48:30 +08:00
parent da05c9107a
commit 9358372657
16 changed files with 1292 additions and 67 deletions

View File

@@ -757,7 +757,7 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- **P0-7**`eslint .` → 0 errors, 2 warnings均在 `__generated__/types.ts` 生成文件);[eslint.config.js](file:///e:/Desktop/Edu/apps/portal-shell/eslint.config.js) 含 `no-restricted-imports`
- **P0-8**`git status` 干净commit `cfb7b00``.env.local``tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore)
### P1 · 框架与数据源接通12 周)— 进行中P1-1/P1-2/P1-3/P1-4 ✅ 2026-07-22 验收)
### P1 · 框架与数据源接通12 周)— 进行中P1-1/P1-2/P1-3/P1-4/P1-5 ✅ 2026-07-22 验收)
**目标**AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n页面迁移的"流水线"建成。
@@ -767,7 +767,7 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈4 角色仪表盘显示真实聚合数据截图widget 假契约查询grades/homeworks/schedule/attendance/exams/announcements全部下线或改造 | ✅ |
| P1-3 | 页面模板四件套list/detail/form/workbench+ 三态规范 | Storybook 或示例页 4 张(/shell/dev/templates/*,仅 dev 可见Next.js 私有文件夹 `_xxx` 不参与路由,故使用 `dev` 而非 `_dev` | ✅ |
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ✅ |
| P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | |
| P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | |
| P1-6 | 31 widget 令牌清债271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0`pnpm lint:tokens` 通过 | ⏳ |
| P1-7 | codegen 恢复 typescript-operationsconfig + data-ana 两域先行关闭 skipDocumentsValidation | 生成操作级类型lib/api 对应域删除手写 interfacetypecheck 通过 | ⏳ |
| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) | ⏳ |
@@ -844,6 +844,25 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- `GET /login``Cookie: NEXT_LOCALE=en`)→ `<html lang="en">` + `NextIntlClientProvider locale="en"` + 英文文案("Save"/"Cancel"/"Toggle sidebar"
- **质量校验**`tsc --noEmit` 通过;`eslint src` → 0 errors, 2 warnings`__generated__/types.ts` 生成文件);`vitest run` 全量 21 test files / 231 tests 全部通过
**P1-5 验收证据2026-07-22**
- **MSW v2.7.0 兜底层(覆盖 dashboard/users/exams/grades 四域起步)**
- [src/mocks/index.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/mocks/index.ts)`initMocks()` 入口,`NEXT_PUBLIC_MSW=1` 时按环境动态启动 browser worker 或 node server
- [src/mocks/handlers.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/mocks/handlers.ts):拦截 `POST /api/graphql``POST {APOLLO_ROUTER_URL}`,按 `operationName` 路由
- [src/mocks/graphql-data.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/mocks/graphql-data.ts)mock 数据与 `graphqlResponse()` 函数,供 handlers.tsMSW browser和 route.tsSSR共用
- [src/mocks/browser.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/mocks/browser.ts) / [server.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/mocks/server.ts)`setupWorker` / `setupServer`
- [public/mockServiceWorker.js](file:///e:/Desktop/Edu/apps/portal-shell/public/mockServiceWorker.js)MSW Service Worker`pnpm exec msw init public/ --save`
- **SSR 端 mock 数据Apollo Client 走同域代理)**
- [src/lib/apollo-client.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/lib/apollo-client.ts)`MSW_ENABLED` 时 SSR 端 HttpLink 也指向 `/api/graphql`(不走直连 router
- [src/app/api/graphql/route.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/app/api/graphql/route.ts)`MSW_ENABLED` 时直接返回 mock 数据,不连接后端
- **生产构建安全bundle 无 mocks**
- [src/mocks/empty.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/mocks/empty.ts):空 stub导出与 `index.ts`/`graphql-data.ts` 同签名的 no-op 函数
- [next.config.js](file:///e:/Desktop/Edu/apps/portal-shell/next.config.js)`NEXT_PUBLIC_MSW!=1` 时 Turbopack `resolveAlias` + webpack `resolve.alias``@/mocks``@/mocks/graphql-data` 重定向到 `@/mocks/empty`Turbopack 不支持 Windows 绝对路径,故使用 `@/` 说明符)
- [src/providers/MswProvider.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/providers/MswProvider.tsx):静态 `import { initMocks } from "@/mocks"`alias 生效后指向 empty.ts`MSW_ENABLED=false` 时 useEffect 分支被 dead-code 消除
- **构建验证**`NEXT_PUBLIC_MSW=0 next build` 成功20 路由生成);`Select-String -Path ".next/static/chunks/**/*.js",".next/server/**/*.js" -Pattern "张老师","stu-001","setupWorker","dev-teacher-001","二次函数"`**CLEAN客户端 + 服务端 bundle 均无 mock 字符串**
- **dev server 验证MSW=1 无后端)**`GET /api/graphql` 返回 `{msw:true}``POST /api/graphql` 返回 mock 数据teacherDashboard: total_classes=5/total_students=142users: 5 条记录questions: 3 条grades: 3 条)
- **质量校验**`tsc --noEmit` 通过;`eslint src` → 0 errors, 2 warnings`__generated__/types.ts``vitest run` 全量 21 test files / 231 tests 全部通过
### P2 · 教师域页面23 周,可与 P3 部分并行)
- 范围§9.1 全表(~50 页。顺序建议exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,5 +1,5 @@
/**
* Next.js 配置v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack + P1-4 next-intl
* Next.js 配置v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack + P1-4 next-intl + P1-5 MSW
*
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker
* - output:standalone单容器部署
@@ -7,17 +7,32 @@
* - 反向代理:/api/v1/* → api-gateway :8080JWT 校验 + 注入 x-user-id/x-user-role
* - GraphQL 查询走 apollo-router :3000M8 验收点,由 Apollo Client 直连)
* - next-intl无 i18n 路由模式locale 由 cookie 决定ARCHITECTURE.md §3.4 V3-A6
* - MSW 兜底层P1-5NEXT_PUBLIC_MSW!=1 时把 @/mocks 与 @/mocks/graphql-data
* 重定向到空 stub确保生产 bundle 不含 mock 数据ARCHITECTURE.md §3.4 V3-A7
*
* Next 16 默认 Turbopack
* - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射
* - webpack 配置保留作为 fallback--webpack flag 时生效)
*
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A6、spec §2、project_rules §3.2
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A6/V3-A7、spec §2、project_rules §3.2
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// P1-5MSW 启用开关。生产构建NEXT_PUBLIC_MSW 非等于 "1")时把
// @/mocks 与 @/mocks/graphql-data 重定向到空 stub
// 确保 bundle 不含真实 mock 数据与 msw 包。
// 注意Turbopack resolveAlias 不支持 Windows 绝对路径("windows imports
// are not implemented yet"),故使用 @/mocks/empty 说明符(由 tsconfig
// paths 解析为 src/mocks/empty.ts而非绝对路径。
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
const MOCKS_STUB_SPECIFIER = "@/mocks/empty";
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
@@ -43,6 +58,17 @@ const nextConfig = {
".cjs",
".json",
],
// P1-5MSW 关闭时把 @/mocksMswProvider 静态 import
// @/mocks/graphql-dataroute.ts 静态 import重定向到空 stub。
// 使用 @/mocks/empty 说明符作为目标Turbopack 不支持 Windows 绝对路径)。
...(MSW_ENABLED
? {}
: {
resolveAlias: {
"@/mocks": MOCKS_STUB_SPECIFIER,
"@/mocks/graphql-data": MOCKS_STUB_SPECIFIER,
},
}),
},
// Webpack 配置fallback使用 --webpack flag 时生效)
webpack(config) {
@@ -51,6 +77,16 @@ const nextConfig = {
...config.resolve.extensionAlias,
".js": [".ts", ".tsx", ".js"],
};
// P1-5MSW 关闭时把 @/mocks 与 @/mocks/graphql-data 重定向到空 stub。
// webpack resolve.alias 支持 exact 匹配($ 后缀)与前缀匹配,
// 这里用 exact 匹配避免误伤其他 @/mocks/xxx 路径(实际只有这两个 import 点)。
if (!MSW_ENABLED) {
config.resolve.alias = {
...config.resolve.alias,
"@/mocks$": MOCKS_STUB_SPECIFIER,
"@/mocks/graphql-data$": MOCKS_STUB_SPECIFIER,
};
}
return config;
},
async rewrites() {

View File

@@ -38,6 +38,7 @@
"graphql": "^16.8.0",
"jose": "^5.9.6",
"lucide-react": "^0.562.0",
"msw": "2.7.0",
"next": "^16.0.10",
"next-intl": "^4.13.2",
"next-themes": "^0.4.6",
@@ -69,5 +70,10 @@
"tsx": "^4.0.0",
"typescript": "^5.6.0",
"vitest": "^2.0.0"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
}

View File

@@ -0,0 +1,307 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.7.0'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = 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 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
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
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && 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 deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// 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)) {
;(async function () {
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[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.
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)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = 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 requestBuffer = await request.arrayBuffer()
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
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: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
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].concat(transferrables.filter(Boolean)),
)
})
}
async 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
}

View File

@@ -1,9 +1,15 @@
/**
* GraphQL 同域代理P0-3ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2
* GraphQL 同域代理P0-3 + P1-5ARCHITECTURE.md §3.4 V3-A2/V3-A7 / §4 / §5.2 / §10 P1-5
*
* 浏览器 Apollo Client 一律走同域 `/api/graphql`
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
*
* MSW 兜底层P1-5
* - NEXT_PUBLIC_MSW=1 时SSR 端 Apollo Client 也走 /api/graphql见 apollo-client.ts
* - 本 Route Handler 检测 MSW 开关,开启时直接返回 mock 数据(不连接后端)
* - Mock 数据来自 mocks/graphql-data.ts与 MSW browser worker 共用
* - 生产构建 NEXT_PUBLIC_MSW 不为 "1",此分支被 tree-shake 移除
*
* 职责:
* 1. 从 httpOnly cookie `edu_session` 取 JWT注入 `Authorization: Bearer`
* 2. 透传 bodyAPQ hash 或 query与 Apollo 相关头
@@ -16,10 +22,13 @@
*
* 验收命令:
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
* NEXT_PUBLIC_MSW=1 pnpm dev → 仪表盘/users 有数据,无需后端
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { graphqlResponse } from "@/mocks/graphql-data";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -30,6 +39,8 @@ const UPSTREAM_URL =
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
/**
* 从 Cookie 头解析指定 cookie 值。
*/
@@ -44,6 +55,16 @@ function readCookie(cookieHeader: string | null, name: string): string | null {
}
export async function POST(req: NextRequest): Promise<NextResponse> {
// P1-5 MSW 兜底层:开启时直接返回 mock 数据,不连接后端
if (MSW_ENABLED) {
const body = (await req.json().catch(() => ({}))) as {
operationName?: string;
};
return NextResponse.json(graphqlResponse(body.operationName), {
headers: { "Cache-Control": "no-store" },
});
}
const cookieHeader = req.headers.get("cookie");
const token = readCookie(cookieHeader, SESSION_COOKIE);
@@ -117,6 +138,7 @@ export async function GET(): Promise<NextResponse> {
ok: true,
proxy: "/api/graphql",
upstream: UPSTREAM_URL,
msw: MSW_ENABLED,
},
{ status: 200 },
);

View File

@@ -39,6 +39,9 @@ const CLIENT_PROXY_URL = "/api/graphql";
// 生产环境默认启用(未设置或设置为 true 均启用)
const APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false";
// MSW 启用时跳过 SSR 查询P1-5浏览器端由 MSW SW 拦截 /api/graphql
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
/**
* 创建 Apollo Client 实例。
*
@@ -54,7 +57,8 @@ export function createApolloClient(
const isServer = options.serverSide ?? typeof window === "undefined";
const httpLink = new HttpLink({
uri: isServer ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
// MSW 启用时P1-5SSR 端也走同域 /api/graphql由 Route Handler 返回 mock 数据
uri: isServer && !MSW_ENABLED ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
// 客户端同域请求cookie 自动随行;服务端:直连 router 不需要 cookie
credentials: isServer ? "omit" : "include",
});

View File

@@ -0,0 +1,13 @@
/**
* MSW Browser WorkerP1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* 浏览器端 setupWorker拦截 /api/graphql 与 /api/auth/* 请求。
* 仅在 NEXT_PUBLIC_MSW=1 时由 index.ts 动态导入。
*
* 关联ARCHITECTURE.md §3.4 V3-A7、§10 P1-5
*/
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);

View File

@@ -0,0 +1,32 @@
/**
* Mocks 空 stubP1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* 生产构建时由 next.config.js 的 resolveAlias/resolve.alias 把
* `@/mocks`MswProvider 的 dynamic import和 `@/mocks/graphql-data`
* route.ts 的 dynamic import都重定向到本文件
* 确保生产 bundle 完全不含真实 mock 数据。
*
* 本文件导出与 mocks/index.ts、mocks/graphql-data.ts 相同签名的函数,
* 但都是空实现(不会被实际调用,因 MSW_ENABLED=false 时
* MswProvider 立即返回route.ts 不会进入 MSW 分支)。
*
* 关联ARCHITECTURE.md §3.4 V3-A7、§10 P1-5
*/
/**
* initMocks 空 stub对应 mocks/index.ts
* MswProvider 中 `if (!MSW_ENABLED) return;` 守护MSW=false 时不会调用。
*/
export async function initMocks(): Promise<void> {
// no-op
}
/**
* graphqlResponse 空 stub对应 mocks/graphql-data.ts
* route.ts 中 `if (MSW_ENABLED)` 守护MSW=false 时不会调用。
*/
export function graphqlResponse(
_operationName: string | undefined,
): Record<string, unknown> {
return { data: null };
}

View File

@@ -0,0 +1,437 @@
/**
* GraphQL Mock 数据P1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* 被 handlers.tsMSW browser worker和 route.ts/api/graphql Route Handler共用。
* SSR 端走 /api/graphql → Route Handler → 返回 mock 数据(不连接后端)。
* 客户端走 /api/graphql → MSW browser worker 拦截 → 返回 mock 数据。
*
* 关联ARCHITECTURE.md §3.4 V3-A7、§5.1、§10 P1-5
*/
// ── Mock Data ──────────────────────────────────────────────────
const mockTeacherDashboard = {
user_id: "dev-teacher-001",
total_classes: 5,
total_students: 142,
class_avg_score: 82.5,
pending_homework_count: 12,
classes: {
class_id: "cls-001",
class_name: "高三(1)班",
student_count: 38,
average_score: 85.2,
},
top_students: [
{
student_id: "stu-001",
student_name: "张明",
score: 98,
rank_in_class: 1,
},
{
student_id: "stu-002",
student_name: "李华",
score: 95,
rank_in_class: 2,
},
{
student_id: "stu-003",
student_name: "王芳",
score: 93,
rank_in_class: 3,
},
],
recent_warnings: {
warning_id: "warn-001",
warning_type: "score_drop",
target_id: "stu-005",
target_name: "赵六",
threshold: 60,
current_value: 45,
severity: "high",
occurred_at: "2026-07-20T10:00:00Z",
},
};
const mockStudentDashboard = {
user_id: "dev-student-001",
avg_score: 88.5,
class_rank: 5,
total_students: 42,
weak_points: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
mastery: 0.45,
error_count: 8,
},
{
knowledge_point_id: "kp-002",
title: "概率统计",
mastery: 0.62,
error_count: 5,
},
],
recent_trends: [
{ date: "2026-07-15", score: 85 },
{ date: "2026-07-18", score: 90 },
{ date: "2026-07-21", score: 88 },
],
pending_homework: 3,
};
const mockParentDashboard = {
user_id: "dev-parent-001",
student_id: "dev-student-001",
child_avg_score: 88.5,
child_class_rank: 5,
total_class_students: 42,
child_weak_points: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
mastery: 0.45,
error_count: 8,
},
],
child_warnings: {
warning_id: "warn-002",
warning_type: "score_drop",
target_id: "dev-student-001",
target_name: "张小明",
threshold: 60,
current_value: 45,
severity: "medium",
occurred_at: "2026-07-19T14:00:00Z",
},
};
const mockAdminDashboard = {
user_id: "dev-admin-001",
total_teachers: 28,
total_students: 1200,
total_classes: 36,
school_avg_score: 79.8,
recent_warnings: {
warning_id: "warn-003",
warning_type: "attendance",
target_id: "cls-005",
target_name: "高二(5)班",
threshold: 0.9,
current_value: 0.72,
severity: "high",
occurred_at: "2026-07-22T08:00:00Z",
},
ai_usage: {
total_requests: 1520,
total_tokens: 480000,
total_cost_cents: 9600,
by_provider: [
{
provider: "doubao",
request_count: 800,
total_tokens: 250000,
cost_cents: 5000,
},
{
provider: "deepseek",
request_count: 720,
total_tokens: 230000,
cost_cents: 4600,
},
],
},
};
const mockUsers = {
items: [
{
id: "usr-001",
name: "张老师",
email: "zhang@edu.cn",
role: "teacher",
status: "active",
createdAt: "2026-06-01T00:00:00Z",
},
{
id: "usr-002",
name: "李老师",
email: "li@edu.cn",
role: "teacher",
status: "active",
createdAt: "2026-06-15T00:00:00Z",
},
{
id: "usr-003",
name: "管理员",
email: "admin@edu.cn",
role: "admin",
status: "active",
createdAt: "2026-05-01T00:00:00Z",
},
{
id: "usr-004",
name: "王同学",
email: "wang@edu.cn",
role: "student",
status: "active",
createdAt: "2026-07-01T00:00:00Z",
},
{
id: "usr-005",
name: "赵家长",
email: "zhao@edu.cn",
role: "parent",
status: "suspended",
createdAt: "2026-07-10T00:00:00Z",
},
],
total: 5,
};
const mockQuestions = [
{
id: "q-001",
type: "single_choice",
difficulty: "easy",
content: "下列哪个是质数?",
options: ["4", "7", "9", "15"],
answer: "B",
tags: ["数论", "质数"],
},
{
id: "q-002",
type: "multiple_choice",
difficulty: "medium",
content: "下列哪些是偶数?",
options: ["2", "3", "4", "5"],
answer: "AC",
tags: ["数论", "偶数"],
},
{
id: "q-003",
type: "fill_blank",
difficulty: "hard",
content: "sin(30°) = ?",
options: null,
answer: "0.5",
tags: ["三角函数"],
},
];
const mockTextbooks = [
{
id: "tb-001",
title: "高中数学必修一",
author: "人民教育出版社",
publisher: "人教版",
isbn: "978-7-107-000001",
chapters: [
{ id: "ch-001", title: "第一章 集合与函数" },
{ id: "ch-002", title: "第二章 基本初等函数" },
],
},
{
id: "tb-002",
title: "高中物理必修一",
author: "人民教育出版社",
publisher: "人教版",
isbn: "978-7-107-000002",
chapters: [
{ id: "ch-003", title: "第一章 运动的描述" },
{ id: "ch-004", title: "第二章 匀变速直线运动" },
],
},
];
const mockGrades = [
{
student_id: "stu-001",
student_name: "张明",
exam_id: "exam-001",
exam_name: "期中考试",
score: 95,
rank: 1,
},
{
student_id: "stu-002",
student_name: "李华",
exam_id: "exam-001",
exam_name: "期中考试",
score: 88,
rank: 2,
},
{
student_id: "stu-003",
student_name: "王芳",
exam_id: "exam-001",
exam_name: "期中考试",
score: 76,
rank: 3,
},
];
// ── GraphQL Response ───────────────────────────────────────────
/**
* 根据 operationName 返回 mock GraphQL 响应。
*/
export function graphqlResponse(
operationName: string | undefined,
): Record<string, unknown> {
switch (operationName) {
// ── Dashboard 域 ──
case "GetTeacherDashboard":
return { data: { teacherDashboard: mockTeacherDashboard } };
case "GetStudentDashboard":
return { data: { studentDashboard: mockStudentDashboard } };
case "GetParentDashboard":
return { data: { parentDashboard: mockParentDashboard } };
case "GetAdminDashboard":
return { data: { adminDashboard: mockAdminDashboard } };
case "GetWarnings":
return {
data: {
warnings: {
warnings: [mockTeacherDashboard.recent_warnings],
total: 1,
},
},
};
case "GetErrorBookStats":
return {
data: {
errorBookStats: {
student_id: "dev-student-001",
total_error_questions: 15,
total_error_count: 42,
by_knowledge_point: [
{
knowledge_point_id: "kp-001",
title: "二次函数",
error_count: 12,
question_count: 20,
error_rate: 0.6,
},
],
recent_7d_errors: 5,
},
},
};
// ── Users 域 ──
case "GetUsers":
return { data: { users: mockUsers } };
case "UpdateUserStatus":
return {
data: { updateUserStatus: { id: "usr-005", status: "active" } },
};
case "UpdateUserRole":
return { data: { updateUserRole: { id: "usr-001", role: "admin" } } };
case "GetRoles":
return {
data: {
roles: [
{
id: "role-001",
name: "admin",
permissions: [
{
id: "perm-001",
name: "user.read",
resource: "user",
action: "read",
},
{
id: "perm-002",
name: "user.write",
resource: "user",
action: "write",
},
],
},
{
id: "role-002",
name: "teacher",
permissions: [
{
id: "perm-003",
name: "class.read",
resource: "class",
action: "read",
},
],
},
],
},
};
case "GetPermissions":
return {
data: {
permissions: [
{
id: "perm-001",
name: "user.read",
resource: "user",
action: "read",
description: "查看用户",
},
{
id: "perm-002",
name: "user.write",
resource: "user",
action: "write",
description: "编辑用户",
},
],
},
};
// ── Exams 域 ──
case "GetQuestions":
return { data: { questions: mockQuestions } };
case "GetTextbooks":
return { data: { textbooks: mockTextbooks } };
case "GetLessonPlans":
return {
data: {
lessonPlans: [
{
id: "lp-001",
title: "集合的概念",
objectives: "理解集合的定义与表示方法",
content: "集合是数学中最基本的概念之一...",
resources: ["教材P1-10", "练习册P1-5"],
},
],
},
};
// ── Grades 域(预留) ──
case "GetGrades":
return { data: { grades: mockGrades } };
// ── 通用 ──
case "GetNotificationsList":
return {
data: {
notifications: {
items: [
{
id: "notif-001",
title: "欢迎使用 Edu Portal",
body: "系统已就绪,开始您的教学之旅吧!",
createdAt: "2026-07-22T08:00:00Z",
type: "system",
},
],
total: 1,
},
},
};
default:
return { data: null };
}
}

View File

@@ -0,0 +1,63 @@
/**
* MSW HandlersP1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* 覆盖四域起步dashboard / users / exams / grades
* 拦截 POST /api/graphqlApollo Client 同域代理路径)
* 按 operationName 路由,未命中返回 HttpResponse.json({ data: null })
*
* Mock 数据与 graphqlResponse 函数提取到 graphql-data.ts
* 供 handlers.tsMSW browser和 route.ts/api/graphql Route Handler共用
* 确保 SSR 端和客户端返回一致的 mock 数据。
*
* 启用条件NEXT_PUBLIC_MSW=1生产构建永不包含
* 关联ARCHITECTURE.md §3.4 V3-A7、§5.1、§10 P1-5
*/
import { http, HttpResponse } from "msw";
import { graphqlResponse } from "./graphql-data";
// ── Handlers 导出 ──────────────────────────────────────────────
// Apollo Router URLSSR 端直连兜底MSW server 拦截用)
const APOLLO_ROUTER_GRAPHQL =
process.env.APOLLO_ROUTER_URL || "http://localhost:3000/graphql";
export const handlers = [
// GraphQL 同域代理(客户端 Apollo Client
http.post("/api/graphql", async ({ request }) => {
const body = (await request.json()) as { operationName?: string };
return HttpResponse.json(graphqlResponse(body.operationName));
}),
// GraphQL SSR 直连兜底msw/node server 拦截 RSC 端 fetch
http.post(APOLLO_ROUTER_GRAPHQL, async ({ request }) => {
const body = (await request.json()) as { operationName?: string };
return HttpResponse.json(graphqlResponse(body.operationName));
}),
// 登录兜底DEV_MODE 使用)
http.post("/api/auth/login", async ({ request }) => {
const body = (await request.json()) as {
email?: string;
password?: string;
};
return HttpResponse.json({
success: true,
user: {
id: "dev-user-001",
email: body.email ?? "dev@edu.local",
name: "开发用户",
role: "teacher",
permissions: ["*"],
dataScope: "all",
},
});
}),
// 健康检查兜底
http.get("/api/health", () =>
HttpResponse.json({ status: "ok", timestamp: new Date().toISOString() }),
),
http.get("/api/ready", () =>
HttpResponse.json({ status: "ready", timestamp: new Date().toISOString() }),
),
];

View File

@@ -0,0 +1,30 @@
/**
* MSW 启用入口P1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* NEXT_PUBLIC_MSW=1 时启用:
* - 浏览器端:动态导入 ./browser启动 Service Worker
* - 服务端:动态导入 ./server启动 Node.js 拦截
*
* 生产构建安全:
* - NEXT_PUBLIC_MSW 不设或非 "1" 时,此函数立即返回(无副作用)
* - webpack/turbopack 会将 mocks/* 代码分割到独立 chunk
* - 生产环境 NEXT_PUBLIC_MSW 编译时为 undefineddynamic import 被 tree-shake
*
* 关联ARCHITECTURE.md §3.4 V3-A7、§5.1、§10 P1-5
*/
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
export async function initMocks(): Promise<void> {
if (!MSW_ENABLED) return;
if (typeof window === "undefined") {
const { server } = await import("./server");
server.listen({ onUnhandledRequest: "bypass" });
} else {
const { worker } = await import("./browser");
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: { url: "/mockServiceWorker.js" },
});
}
}

View File

@@ -0,0 +1,13 @@
/**
* MSW ServerP1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* Node.js 端 setupServer用于 SSR + 测试环境。
* 仅在 NEXT_PUBLIC_MSW=1 时由 index.ts 动态导入。
*
* 关联ARCHITECTURE.md §3.4 V3-A7、§10 P1-5
*/
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);

View File

@@ -0,0 +1,42 @@
"use client";
/**
* MswProviderP1-5ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5
*
* NEXT_PUBLIC_MSW=1 时在客户端启动 MSW Service Worker。
* - MSW 启用时阻塞子组件渲染直到 worker ready避免 mock 未就绪时请求穿透)
* - MSW 未启用时直接渲染子组件(零开销,生产构建安全)
*
* 生产构建安全(关键):
* - 静态 `import { initMocks } from "@/mocks"` 配合 next.config.js resolveAlias
* - MSW 关闭时 `@/mocks` 被重定向到 `src/mocks/empty.ts`no-op 实现)
* - Next.js 在客户端 bundle 内联 NEXT_PUBLIC_MSW 为字符串字面量,
* `MSW_ENABLED === "1"` 编译期求值为 false整个 useEffect 分支被 dead-code 消除
*
* 关联ARCHITECTURE.md §3.4 V3-A7、§5.1、§10 P1-5
*/
import { useEffect, useState, type ReactNode } from "react";
import { initMocks } from "@/mocks";
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
export function MswProvider({ children }: { children: ReactNode }): ReactNode {
const [mswReady, setMswReady] = useState(!MSW_ENABLED);
useEffect(() => {
if (!MSW_ENABLED) return;
let mounted = true;
initMocks().finally(() => {
if (mounted) setMswReady(true);
});
return () => {
mounted = false;
};
}, []);
// MSW 启用时阻塞渲染,避免 mock 未就绪时请求穿透到后端
if (!mswReady) return null;
return <>{children}</>;
}

View File

@@ -17,6 +17,7 @@
import { use, useState, type ReactNode } from "react";
import { ApolloProvider } from "@/providers/ApolloProvider";
import { AuthProvider, type AuthUser } from "@/providers/AuthProvider";
import { MswProvider } from "@/providers/MswProvider";
import { ThemeProvider } from "@/providers/ThemeProvider";
import { Shell } from "./Shell";
import { usePluginConfig } from "@/lib/usePluginConfig";
@@ -103,30 +104,32 @@ export function ClientShell({
const useStreaming = Boolean(configPromise);
return (
<ApolloProvider>
<AuthProvider user={user}>
<ThemeProvider>
{useStreaming && configPromise ? (
<ShellContent
configPromise={configPromise}
user={user}
role={role}
userId={userId}
/>
) : (
<LegacyShell
config={fallbackConfig}
user={user}
role={role}
userId={userId}
onConfigChange={() => setConfigChanged(true)}
configChanged={configChanged}
onDismiss={() => setConfigChanged(false)}
/>
)}
</ThemeProvider>
</AuthProvider>
</ApolloProvider>
<MswProvider>
<ApolloProvider>
<AuthProvider user={user}>
<ThemeProvider>
{useStreaming && configPromise ? (
<ShellContent
configPromise={configPromise}
user={user}
role={role}
userId={userId}
/>
) : (
<LegacyShell
config={fallbackConfig}
user={user}
role={role}
userId={userId}
onConfigChange={() => setConfigChanged(true)}
configChanged={configChanged}
onDismiss={() => setConfigChanged(false)}
/>
)}
</ThemeProvider>
</AuthProvider>
</ApolloProvider>
</MswProvider>
);
}