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:
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* GraphQL 同域代理(P0-3,ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2)
|
||||
* GraphQL 同域代理(P0-3 + P1-5,ARCHITECTURE.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. 透传 body(APQ 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 },
|
||||
);
|
||||
|
||||
@@ -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-5):SSR 端也走同域 /api/graphql,由 Route Handler 返回 mock 数据
|
||||
uri: isServer && !MSW_ENABLED ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
|
||||
// 客户端:同域请求,cookie 自动随行;服务端:直连 router 不需要 cookie
|
||||
credentials: isServer ? "omit" : "include",
|
||||
});
|
||||
|
||||
13
apps/portal-shell/src/mocks/browser.ts
Normal file
13
apps/portal-shell/src/mocks/browser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* MSW Browser Worker(P1-5,ARCHITECTURE.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);
|
||||
32
apps/portal-shell/src/mocks/empty.ts
Normal file
32
apps/portal-shell/src/mocks/empty.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Mocks 空 stub(P1-5,ARCHITECTURE.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 };
|
||||
}
|
||||
437
apps/portal-shell/src/mocks/graphql-data.ts
Normal file
437
apps/portal-shell/src/mocks/graphql-data.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* GraphQL Mock 数据(P1-5,ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5)
|
||||
*
|
||||
* 被 handlers.ts(MSW 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 };
|
||||
}
|
||||
}
|
||||
63
apps/portal-shell/src/mocks/handlers.ts
Normal file
63
apps/portal-shell/src/mocks/handlers.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* MSW Handlers(P1-5,ARCHITECTURE.md §3.4 V3-A7 / §10 P1-5)
|
||||
*
|
||||
* 覆盖四域起步:dashboard / users / exams / grades
|
||||
* 拦截 POST /api/graphql(Apollo Client 同域代理路径)
|
||||
* 按 operationName 路由,未命中返回 HttpResponse.json({ data: null })
|
||||
*
|
||||
* Mock 数据与 graphqlResponse 函数提取到 graphql-data.ts,
|
||||
* 供 handlers.ts(MSW 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 URL(SSR 端直连兜底,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() }),
|
||||
),
|
||||
];
|
||||
30
apps/portal-shell/src/mocks/index.ts
Normal file
30
apps/portal-shell/src/mocks/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* MSW 启用入口(P1-5,ARCHITECTURE.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 编译时为 undefined,dynamic 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/portal-shell/src/mocks/server.ts
Normal file
13
apps/portal-shell/src/mocks/server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* MSW Server(P1-5,ARCHITECTURE.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);
|
||||
42
apps/portal-shell/src/providers/MswProvider.tsx
Normal file
42
apps/portal-shell/src/providers/MswProvider.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MswProvider(P1-5,ARCHITECTURE.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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user