feat(student-portal): 完整实现 student-portal 微前端

包含 src 全部实现、Dockerfile、配置文件、contracts 包等
This commit is contained in:
SpecialX
2026-07-10 19:10:36 +08:00
parent 24c2860b41
commit 74474a2d04
80 changed files with 17226 additions and 70 deletions

View File

@@ -0,0 +1,86 @@
/**
* MSW 浏览器端 setupai14
*
* 开发环境下在浏览器中启动 MSW Service Worker 拦截 GraphQL 请求。
* 通过 NEXT_PUBLIC_API_MOCKING=enabled 控制是否启用。
*
* 重要setupWorker 仅在浏览器环境调用SSR 时不执行。
* worker 实例延迟初始化,避免 Next.js prerender 时报错。
*/
import { handlers } from "./handlers";
/** MSW worker 类型(延迟初始化)*/
type MSWWorker = Awaited<
ReturnType<(typeof import("msw/browser"))["setupWorker"]>
>;
/** MSW worker 单例(延迟初始化,仅在浏览器环境)*/
let workerInstance: MSWWorker | null = null;
/** MSW worker 是否已启动 */
let isStarted = false;
/**
* 获取或创建 MSW worker 实例
*
* 仅在浏览器环境调用SSR 时返回 null。
* 使用动态 import 避免 Next.js prerender 时加载 msw/browser。
*/
async function getOrCreateWorker(): Promise<MSWWorker | null> {
if (typeof window === "undefined") return null;
if (workerInstance) return workerInstance;
const { setupWorker } = await import("msw/browser");
workerInstance = setupWorker(...handlers);
return workerInstance;
}
/**
* 初始化浏览器 MSW mock
*
* 仅在 NEXT_PUBLIC_API_MOCKING=enabled 时启动。
* 启动后拦截 /api/v1/student/graphql 的请求并返回 mock 响应。
*
* @returns Promise<void>,启动完成后 resolve
*/
export async function initMocks(): Promise<void> {
const isMockingEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
if (!isMockingEnabled || isStarted) {
return;
}
const worker = await getOrCreateWorker();
if (!worker) return;
await worker.start({
onUnhandledRequest: "bypass",
quiet: false,
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
isStarted = true;
}
/**
* 获取 MSW worker 实例(测试用)
* @returns worker 实例或 null非浏览器环境
*/
export function getWorker(): MSWWorker | null {
return workerInstance;
}
/**
* 停止 MSW worker
* @returns Promise<void>
*/
export async function stopMocks(): Promise<void> {
if (!isStarted || !workerInstance) {
return;
}
await workerInstance.stop();
isStarted = false;
}

View File

@@ -0,0 +1,88 @@
[
{
"id": "ch-001",
"textbookId": "tb-001",
"name": "第一章 函数与导数",
"level": 1,
"parentId": null,
"order": 1,
"children": [
{
"id": "ch-001-01",
"textbookId": "tb-001",
"name": "1.1 函数的概念与性质",
"level": 2,
"parentId": "ch-001",
"order": 1,
"children": [
{
"id": "ch-001-01-01",
"textbookId": "tb-001",
"name": "1.1.1 函数的定义",
"level": 3,
"parentId": "ch-001-01",
"order": 1,
"children": []
},
{
"id": "ch-001-01-02",
"textbookId": "tb-001",
"name": "1.1.2 函数的单调性",
"level": 3,
"parentId": "ch-001-01",
"order": 2,
"children": []
}
]
},
{
"id": "ch-001-02",
"textbookId": "tb-001",
"name": "1.2 导数的概念",
"level": 2,
"parentId": "ch-001",
"order": 2,
"children": [
{
"id": "ch-001-02-01",
"textbookId": "tb-001",
"name": "1.2.1 导数的定义",
"level": 3,
"parentId": "ch-001-02",
"order": 1,
"children": []
}
]
}
]
},
{
"id": "ch-002",
"textbookId": "tb-001",
"name": "第二章 三角函数",
"level": 1,
"parentId": null,
"order": 2,
"children": [
{
"id": "ch-002-01",
"textbookId": "tb-001",
"name": "2.1 任意角的三角函数",
"level": 2,
"parentId": "ch-002",
"order": 1,
"children": [
{
"id": "ch-002-01-01",
"textbookId": "tb-001",
"name": "2.1.1 三角函数定义",
"level": 3,
"parentId": "ch-002-01",
"order": 1,
"children": []
}
]
}
]
}
]

View File

@@ -0,0 +1,20 @@
{
"id": "student-001",
"name": "张小明",
"studentNo": "2024001",
"grade": "高三(2)班",
"avatar": null,
"roles": ["student"],
"permissions": [
"STUDENT_DASHBOARD_VIEW",
"HOMEWORK_READ_OWN",
"HOMEWORK_SUBMIT",
"EXAMS_READ_OWN",
"EXAMS_TAKE",
"EXAMS_RESULT_VIEW",
"GRADES_READ_OWN",
"ATTENDANCE_READ_OWN",
"LEARNING_PATH_VIEW"
],
"dataScope": "L0"
}

View File

@@ -0,0 +1,68 @@
{
"id": "exam-001",
"name": "高三数学期中考试",
"subject": { "id": "subject-math", "name": "数学" },
"status": "not_started",
"startsAt": "2026-07-15T09:00:00.000Z",
"expiresAt": "2026-07-15T11:00:00.000Z",
"durationSeconds": 7200,
"totalScore": 100,
"questionCount": 5,
"questions": [
{
"id": "q-001",
"type": "single-choice",
"content": "已知函数 f(x) = x² + 2x求 f(3) 的值。",
"options": [
{ "id": "opt-a", "text": "9" },
{ "id": "opt-b", "text": "15" },
{ "id": "opt-c", "text": "12" },
{ "id": "opt-d", "text": "18" }
],
"score": 0,
"maxScore": 20
},
{
"id": "q-002",
"type": "single-choice",
"content": "若 sin θ = 1/2且 θ 为锐角,则 cos θ 的值为?",
"options": [
{ "id": "opt-a", "text": "1/2" },
{ "id": "opt-b", "text": "√3/2" },
{ "id": "opt-c", "text": "√2/2" },
{ "id": "opt-d", "text": "1" }
],
"score": 0,
"maxScore": 20
},
{
"id": "q-003",
"type": "multiple-choice",
"content": "下列哪些是偶函数?(多选)",
"options": [
{ "id": "opt-a", "text": "f(x) = x²" },
{ "id": "opt-b", "text": "f(x) = x³" },
{ "id": "opt-c", "text": "f(x) = |x|" },
{ "id": "opt-d", "text": "f(x) = cos x" }
],
"score": 0,
"maxScore": 20
},
{
"id": "q-004",
"type": "short-answer",
"content": "简述导数的几何意义。",
"options": [],
"score": 0,
"maxScore": 20
},
{
"id": "q-005",
"type": "short-answer",
"content": "求不等式 x² - 5x + 6 < 0 的解集。",
"options": [],
"score": 0,
"maxScore": 20
}
]
}

View File

@@ -0,0 +1,38 @@
{
"id": "hw-001",
"title": "数学作业第五章",
"subject": { "id": "subject-math", "name": "数学" },
"status": "pending",
"dueAt": "2026-07-12T23:59:59.000Z",
"assignedAt": "2026-07-08T08:00:00.000Z",
"questionCount": 3,
"totalScore": 100,
"questions": [
{
"id": "hw-q-001",
"type": "single-choice",
"content": "函数 f(x) = 2x + 3 在 x = 2 处的函数值为?",
"options": [
{ "id": "opt-a", "text": "5" },
{ "id": "opt-b", "text": "7" },
{ "id": "opt-c", "text": "9" },
{ "id": "opt-d", "text": "11" }
],
"maxScore": 30
},
{
"id": "hw-q-002",
"type": "short-answer",
"content": "解方程x² - 4 = 0",
"options": [],
"maxScore": 40
},
{
"id": "hw-q-003",
"type": "short-answer",
"content": "证明:三角形内角和为 180 度。",
"options": [],
"maxScore": 30
}
]
}

View File

@@ -0,0 +1,10 @@
[
{ "id": "kp-001", "name": "函数概念", "knowledgePointId": "kp-001", "status": "completed", "mastery": 0.9, "dependencies": [], "recommendedOrder": 1 },
{ "id": "kp-002", "name": "函数性质", "knowledgePointId": "kp-002", "status": "completed", "mastery": 0.85, "dependencies": ["kp-001"], "recommendedOrder": 2 },
{ "id": "kp-003", "name": "导数概念", "knowledgePointId": "kp-003", "status": "in-progress", "mastery": 0.6, "dependencies": ["kp-001", "kp-002"], "recommendedOrder": 3 },
{ "id": "kp-004", "name": "导数运算", "knowledgePointId": "kp-004", "status": "available", "mastery": 0.3, "dependencies": ["kp-003"], "recommendedOrder": 4 },
{ "id": "kp-005", "name": "三角函数定义", "knowledgePointId": "kp-005", "status": "completed", "mastery": 0.95, "dependencies": [], "recommendedOrder": 5 },
{ "id": "kp-006", "name": "三角恒等变换", "knowledgePointId": "kp-006", "status": "in-progress", "mastery": 0.55, "dependencies": ["kp-005"], "recommendedOrder": 6 },
{ "id": "kp-007", "name": "不等式", "knowledgePointId": "kp-007", "status": "available", "mastery": 0.2, "dependencies": ["kp-001"], "recommendedOrder": 7 },
{ "id": "kp-008", "name": "数列", "knowledgePointId": "kp-008", "status": "locked", "mastery": 0, "dependencies": ["kp-002"], "recommendedOrder": 8 }
]

View File

@@ -0,0 +1,12 @@
[
{ "id": "att-001", "date": "2026-07-01", "status": "present", "subject": { "id": "subject-math", "name": "数学" } },
{ "id": "att-002", "date": "2026-07-02", "status": "present", "subject": { "id": "subject-chinese", "name": "语文" } },
{ "id": "att-003", "date": "2026-07-03", "status": "late", "subject": { "id": "subject-english", "name": "英语" }, "lateMinutes": 10 },
{ "id": "att-004", "date": "2026-07-04", "status": "present", "subject": { "id": "subject-physics", "name": "物理" } },
{ "id": "att-005", "date": "2026-07-07", "status": "absent", "subject": { "id": "subject-chemistry", "name": "化学" }, "reason": "病假" },
{ "id": "att-006", "date": "2026-07-08", "status": "present", "subject": { "id": "subject-math", "name": "数学" } },
{ "id": "att-007", "date": "2026-07-09", "status": "present", "subject": { "id": "subject-chinese", "name": "语文" } },
{ "id": "att-008", "date": "2026-07-10", "status": "late", "subject": { "id": "subject-english", "name": "英语" }, "lateMinutes": 5 },
{ "id": "att-009", "date": "2026-07-11", "status": "present", "subject": { "id": "subject-physics", "name": "物理" } },
{ "id": "att-010", "date": "2026-07-12", "status": "present", "subject": { "id": "subject-chemistry", "name": "化学" } }
]

View File

@@ -0,0 +1,10 @@
[
{
"id": "class-001",
"name": "高三(2)班",
"homeroomTeacher": "李老师",
"studentCount": 45,
"grade": "高三",
"year": 2026
}
]

View File

@@ -0,0 +1,26 @@
[
{
"id": "exam-001",
"name": "高三数学期中考试",
"subject": { "id": "subject-math", "name": "数学" },
"status": "not_started",
"startsAt": "2026-07-15T09:00:00.000Z",
"expiresAt": "2026-07-15T11:00:00.000Z",
"durationSeconds": 7200,
"questionCount": 5,
"totalScore": 100
},
{
"id": "exam-002",
"name": "高三语文月考",
"subject": { "id": "subject-chinese", "name": "语文" },
"status": "submitted",
"startsAt": "2026-07-05T09:00:00.000Z",
"expiresAt": "2026-07-05T11:00:00.000Z",
"durationSeconds": 7200,
"questionCount": 4,
"totalScore": 100,
"submittedAt": "2026-07-05T10:45:00.000Z",
"submissionId": "submission-002"
}
]

View File

@@ -0,0 +1,62 @@
[
{
"id": "grade-001",
"examId": "exam-002",
"examName": "高三语文月考",
"subject": { "id": "subject-chinese", "name": "语文" },
"score": 85,
"maxScore": 100,
"grade": "良好",
"rank": 8,
"submittedAt": "2026-07-05T10:45:00.000Z",
"gradedAt": "2026-07-07T14:00:00.000Z"
},
{
"id": "grade-002",
"examId": "exam-003",
"examName": "高三英语模拟考",
"subject": { "id": "subject-english", "name": "英语" },
"score": 92,
"maxScore": 100,
"grade": "优秀",
"rank": 3,
"submittedAt": "2026-06-28T10:00:00.000Z",
"gradedAt": "2026-06-30T09:00:00.000Z"
},
{
"id": "grade-003",
"examId": "exam-004",
"examName": "高三物理单元测验",
"subject": { "id": "subject-physics", "name": "物理" },
"score": 78,
"maxScore": 100,
"grade": "及格",
"rank": 15,
"submittedAt": "2026-06-20T10:00:00.000Z",
"gradedAt": "2026-06-22T09:00:00.000Z"
},
{
"id": "grade-004",
"examId": "exam-005",
"examName": "高三化学月考",
"subject": { "id": "subject-chemistry", "name": "化学" },
"score": 88,
"maxScore": 100,
"grade": "良好",
"rank": 6,
"submittedAt": "2026-06-10T10:00:00.000Z",
"gradedAt": "2026-06-12T09:00:00.000Z"
},
{
"id": "grade-005",
"examId": "exam-006",
"examName": "高三数学月考",
"subject": { "id": "subject-math", "name": "数学" },
"score": 95,
"maxScore": 100,
"grade": "优秀",
"rank": 2,
"submittedAt": "2026-05-28T10:00:00.000Z",
"gradedAt": "2026-05-30T09:00:00.000Z"
}
]

View File

@@ -0,0 +1,36 @@
[
{
"id": "hw-001",
"title": "数学作业第五章",
"subject": { "id": "subject-math", "name": "数学" },
"status": "pending",
"dueAt": "2026-07-12T23:59:59.000Z",
"assignedAt": "2026-07-08T08:00:00.000Z",
"questionCount": 10,
"totalScore": 100
},
{
"id": "hw-002",
"title": "语文阅读理解练习",
"subject": { "id": "subject-chinese", "name": "语文" },
"status": "submitted",
"dueAt": "2026-07-10T23:59:59.000Z",
"assignedAt": "2026-07-06T08:00:00.000Z",
"submittedAt": "2026-07-09T20:30:00.000Z",
"questionCount": 8,
"totalScore": 100
},
{
"id": "hw-003",
"title": "英语单元测试卷",
"subject": { "id": "subject-english", "name": "英语" },
"status": "graded",
"dueAt": "2026-07-05T23:59:59.000Z",
"assignedAt": "2026-07-01T08:00:00.000Z",
"submittedAt": "2026-07-04T21:00:00.000Z",
"gradedAt": "2026-07-06T10:00:00.000Z",
"score": 88,
"totalScore": 100,
"questionCount": 12
}
]

View File

@@ -0,0 +1,12 @@
[
{ "id": "ntf-001", "type": "homework", "title": "新作业发布", "content": "数学作业第五章已发布,截止日期 7月12日", "read": false, "createdAt": "2026-07-08T08:00:00.000Z" },
{ "id": "ntf-002", "type": "exam", "title": "考试即将开始", "content": "高三数学期中考试将于7月15日开始", "read": false, "createdAt": "2026-07-09T10:00:00.000Z" },
{ "id": "ntf-003", "type": "grade", "title": "成绩已发布", "content": "您的语文月考成绩为85分", "read": false, "createdAt": "2026-07-07T14:00:00.000Z" },
{ "id": "ntf-004", "type": "system", "title": "系统维护通知", "content": "系统将于本周日 2:00-4:00 进行维护", "read": true, "createdAt": "2026-07-06T16:00:00.000Z" },
{ "id": "ntf-005", "type": "homework", "title": "作业即将截止", "content": "语文阅读理解练习将于明天截止", "read": false, "createdAt": "2026-07-09T20:00:00.000Z" },
{ "id": "ntf-006", "type": "grade", "title": "成绩已发布", "content": "您的英语模拟考成绩为92分", "read": true, "createdAt": "2026-06-30T09:00:00.000Z" },
{ "id": "ntf-007", "type": "exam", "title": "考试结果发布", "content": "高三语文月考已结束,查看详情", "read": true, "createdAt": "2026-07-05T11:00:00.000Z" },
{ "id": "ntf-008", "type": "system", "title": "隐私政策更新", "content": "平台隐私政策已更新,请查阅", "read": true, "createdAt": "2026-07-01T10:00:00.000Z" },
{ "id": "ntf-009", "type": "homework", "title": "作业已批改", "content": "英语单元测试卷已批改得分88分", "read": true, "createdAt": "2026-07-06T10:00:00.000Z" },
{ "id": "ntf-010", "type": "system", "title": "学期注册提醒", "content": "请于7月20日前完成新学期注册", "read": false, "createdAt": "2026-07-10T08:00:00.000Z" }
]

View File

@@ -0,0 +1,9 @@
[
{ "date": "2026-07-04", "score": 88, "examName": "英语单元测试" },
{ "date": "2026-07-05", "score": 85, "examName": "语文月考" },
{ "date": "2026-06-20", "score": 78, "examName": "物理单元测验" },
{ "date": "2026-06-10", "score": 88, "examName": "化学月考" },
{ "date": "2026-05-28", "score": 95, "examName": "数学月考" },
{ "date": "2026-05-15", "score": 82, "examName": "语文模拟考" },
{ "date": "2026-05-01", "score": 80, "examName": "英语模拟考" }
]

View File

@@ -0,0 +1,38 @@
[
{
"id": "wp-001",
"questionId": "q-weak-001",
"questionContent": "求复合函数的导数",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointId": "kp-004",
"knowledgePointName": "导数运算",
"mastery": 0.3,
"wrongCount": 5,
"lastWrongAt": "2026-07-08T10:00:00.000Z",
"recommendedAction": "practice"
},
{
"id": "wp-002",
"questionId": "q-weak-002",
"questionContent": "三角恒等变换应用",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointId": "kp-006",
"knowledgePointName": "三角恒等变换",
"mastery": 0.4,
"wrongCount": 3,
"lastWrongAt": "2026-07-06T14:00:00.000Z",
"recommendedAction": "review"
},
{
"id": "wp-003",
"questionId": "q-weak-003",
"questionContent": "不等式解集求解",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointId": "kp-007",
"knowledgePointName": "不等式",
"mastery": 0.2,
"wrongCount": 4,
"lastWrongAt": "2026-07-09T09:00:00.000Z",
"recommendedAction": "practice"
}
]

View File

@@ -0,0 +1,4 @@
{
"timestamp": "2026-07-10T12:00:00.000Z",
"offset": 0
}

View File

@@ -0,0 +1,20 @@
{
"upcomingHomework": [
{ "id": "hw-001", "title": "数学作业第五章", "dueAt": "2026-07-12T23:59:59.000Z", "subject": { "id": "subject-math", "name": "数学" }, "status": "pending" },
{ "id": "hw-004", "title": "物理实验报告", "dueAt": "2026-07-13T23:59:59.000Z", "subject": { "id": "subject-physics", "name": "物理" }, "status": "pending" },
{ "id": "hw-005", "title": "英语听力练习", "dueAt": "2026-07-14T23:59:59.000Z", "subject": { "id": "subject-english", "name": "英语" }, "status": "pending" }
],
"upcomingExams": [
{ "id": "exam-001", "name": "高三数学期中考试", "startsAt": "2026-07-15T09:00:00.000Z", "expiresAt": "2026-07-15T11:00:00.000Z", "durationSeconds": 7200, "subject": { "id": "subject-math", "name": "数学" }, "status": "not_started" },
{ "id": "exam-007", "name": "高三英语期末考", "startsAt": "2026-07-18T09:00:00.000Z", "expiresAt": "2026-07-18T11:00:00.000Z", "durationSeconds": 7200, "subject": { "id": "subject-english", "name": "英语" }, "status": "not_started" }
],
"recentGrades": [
{ "id": "grade-001", "examName": "高三语文月考", "score": 85, "maxScore": 100, "grade": "良好", "submittedAt": "2026-07-05T10:45:00.000Z" },
{ "id": "grade-002", "examName": "高三英语模拟考", "score": 92, "maxScore": 100, "grade": "优秀", "submittedAt": "2026-06-28T10:00:00.000Z" },
{ "id": "grade-003", "examName": "高三物理单元测验", "score": 78, "maxScore": 100, "grade": "及格", "submittedAt": "2026-06-20T10:00:00.000Z" }
],
"attendanceRate": 0.95,
"learningStreakDays": 12,
"avgScore": 85.0,
"classRank": 5
}

View File

@@ -0,0 +1,7 @@
[
{ "id": "tb-001", "name": "高三数学(上)", "subject": { "id": "subject-math", "name": "数学" }, "version": "人教版 2024", "author": "人民教育出版社" },
{ "id": "tb-002", "name": "高三语文", "subject": { "id": "subject-chinese", "name": "语文" }, "version": "人教版 2024", "author": "人民教育出版社" },
{ "id": "tb-003", "name": "高三英语", "subject": { "id": "subject-english", "name": "英语" }, "version": "外研版 2024", "author": "外语教学与研究出版社" },
{ "id": "tb-004", "name": "高三物理", "subject": { "id": "subject-physics", "name": "物理" }, "version": "人教版 2024", "author": "人民教育出版社" },
{ "id": "tb-005", "name": "高三化学", "subject": { "id": "subject-chemistry", "name": "化学" }, "version": "鲁科版 2024", "author": "山东科学技术出版社" }
]

View File

@@ -0,0 +1,484 @@
/**
* MSW 请求处理器ai14
*
* 拦截 POST /api/v1/student/graphql按 operationName 分发到对应的 mock 响应。
* 所有响应包裹在 ActionState 信封中:
* { success: true, errors: [], data: <fixture>, extensions: { degraded: false } }
*
* GraphQL 响应结构:
* { data: { <operationName>: ActionState<...> } }
*
* 支持的 24 个操作16 查询 + 8 变更)详见 types.ts OperationName。
*/
import { http, HttpResponse } from "msw";
import type {
ActionState,
MutationOperationName,
QueryOperationName,
} from "../lib/graphql/types";
import { ERROR_CODES } from "../lib/graphql/types";
// 查询 fixture 数据
import currentUserFixture from "./fixtures/currentUser.json";
import myClassesFixture from "./fixtures/myClasses.json";
import myExamsFixture from "./fixtures/myExams.json";
import examDetailFixture from "./fixtures/examDetail.json";
import myHomeworkFixture from "./fixtures/myHomework.json";
import homeworkDetailFixture from "./fixtures/homeworkDetail.json";
import myGradesFixture from "./fixtures/myGrades.json";
import myAttendanceFixture from "./fixtures/myAttendance.json";
import textbooksFixture from "./fixtures/textbooks.json";
import chaptersFixture from "./fixtures/chapters.json";
import learningPathFixture from "./fixtures/learningPath.json";
import studentDashboardFixture from "./fixtures/studentDashboard.json";
import myWeaknessFixture from "./fixtures/myWeakness.json";
import myTrendFixture from "./fixtures/myTrend.json";
import myNotificationsFixture from "./fixtures/myNotifications.json";
import serverTimeFixture from "./fixtures/serverTime.json";
// ---------------------------------------------------------------------------
// GraphQL 请求/响应类型
// ---------------------------------------------------------------------------
/** GraphQL 请求体 */
interface GraphQLRequest {
query: string;
variables?: Record<string, unknown>;
operationName?: string;
}
/** GraphQL 顶层响应 */
interface GraphQLResponseData {
data: Record<string, unknown> | null;
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>;
}
// ---------------------------------------------------------------------------
// ActionState 信封构造工具
// ---------------------------------------------------------------------------
/**
* 构造成功的 ActionState 响应
* @param data 业务数据
* @returns ActionState 信封
*/
function ok<T>(data: T): ActionState<T> {
return {
success: true,
errors: [],
data,
extensions: { degraded: false },
};
}
/**
* 构造失败的 ActionState 响应
* @param code 错误码
* @param message 错误消息
* @returns ActionState 信封data 为 null
*/
function fail<T>(code: string, message: string): ActionState<T> {
return {
success: false,
errors: [{ code, message }],
data: null,
extensions: { degraded: false },
};
}
/**
* 构造降级的 ActionState 响应(部分上游不可用)
* @param data 部分业务数据
* @param reason 降级原因
* @returns ActionState 信封degraded: true
*/
function degraded<T>(data: T, reason: string): ActionState<T> {
return {
success: true,
errors: [],
data,
extensions: { degraded: true, degradedReason: reason },
};
}
// ---------------------------------------------------------------------------
// GraphQL 响应构造
// ---------------------------------------------------------------------------
/**
* 构造 GraphQL 成功响应(单操作)
* @param operationName 操作名
* @param actionState ActionState 信封
* @returns HttpResponse JSON
*/
function graphqlOk(
operationName: string,
actionState: ActionState<unknown>,
): Response {
const body: GraphQLResponseData = {
data: { [operationName]: actionState },
};
return HttpResponse.json(body);
}
/**
* 构造 GraphQL 错误响应(顶层错误,如认证失败)
* @param message 错误消息
* @param code 错误码
* @returns HttpResponse JSON
*/
function graphqlError(message: string, code: string): Response {
const body: GraphQLResponseData = {
data: null,
errors: [{ message, extensions: { code } }],
};
return HttpResponse.json(body, { status: 200 });
}
// ---------------------------------------------------------------------------
// operationName 提取
// ---------------------------------------------------------------------------
/**
* 从 GraphQL 请求中提取 operationName
* 优先使用 body.operationName缺失时从 query 字符串解析
* @param body GraphQL 请求体
* @returns 操作名或 null
*/
function extractOperationName(body: GraphQLRequest): string | null {
if (body.operationName) {
return body.operationName;
}
// 从 query 字符串解析query/mutation OperationName( ...
const match = body.query.match(/(?:query|mutation)\s+(\w+)/);
return match?.[1] ?? null;
}
// ---------------------------------------------------------------------------
// 查询处理器16 个)
// ---------------------------------------------------------------------------
/**
* 处理查询操作
* @param operationName 操作名
* @param variables 查询变量
* @returns HttpResponse
*/
function handleQuery(
operationName: QueryOperationName,
variables: Record<string, unknown>,
): Response {
switch (operationName) {
case "currentUser":
return graphqlOk("currentUser", ok(currentUserFixture));
case "myClasses":
return graphqlOk("myClasses", ok(myClassesFixture));
case "myExams":
return graphqlOk("myExams", ok(myExamsFixture));
case "examDetail": {
const examId = variables["examId"] ?? "exam-001";
// 根据请求的 examId 返回对应数据,默认返回 examDetail fixture
const detail =
examId === "exam-001"
? examDetailFixture
: { ...examDetailFixture, id: examId };
return graphqlOk("examDetail", ok(detail));
}
case "myHomework":
return graphqlOk("myHomework", ok(myHomeworkFixture));
case "homeworkDetail": {
const homeworkId = variables["homeworkId"] ?? "hw-001";
const detail =
homeworkId === "hw-001"
? homeworkDetailFixture
: { ...homeworkDetailFixture, id: homeworkId };
return graphqlOk("homeworkDetail", ok(detail));
}
case "myGrades":
return graphqlOk("myGrades", ok(myGradesFixture));
case "myAttendance":
return graphqlOk("myAttendance", ok(myAttendanceFixture));
case "textbooks":
return graphqlOk("textbooks", ok(textbooksFixture));
case "chapters": {
const textbookId = variables["textbookId"] ?? "tb-001";
const chapters =
textbookId === "tb-001"
? chaptersFixture
: chaptersFixture.filter((c) => c.textbookId === textbookId);
return graphqlOk("chapters", ok(chapters));
}
case "learningPath":
return graphqlOk("learningPath", ok(learningPathFixture));
case "studentDashboard":
return graphqlOk("studentDashboard", ok(studentDashboardFixture));
case "myWeakness":
return graphqlOk("myWeakness", ok(myWeaknessFixture));
case "myTrend":
return graphqlOk("myTrend", ok(myTrendFixture));
case "myNotifications": {
const first = (variables["first"] as number | undefined) ?? 10;
const items = myNotificationsFixture.slice(0, first);
const unreadCount = items.filter((n) => !n.read).length;
const result = {
items,
totalCount: myNotificationsFixture.length,
unreadCount,
};
return graphqlOk("myNotifications", ok(result));
}
case "serverTime":
// 返回当前时间以支持倒计时校正
return graphqlOk(
"serverTime",
ok({
timestamp: new Date().toISOString(),
offset: 0,
}),
);
default: {
// 未覆盖的查询操作名,返回错误
return graphqlError(
`未实现的查询: ${operationName}`,
ERROR_CODES.BFF_STUDENT_NOT_FOUND,
);
}
}
}
// ---------------------------------------------------------------------------
// 变更处理器8 个)
// ---------------------------------------------------------------------------
/**
* 处理变更操作
* @param operationName 操作名
* @param variables 变更变量
* @returns HttpResponse
*/
function handleMutation(
operationName: MutationOperationName,
variables: Record<string, unknown>,
): Response {
switch (operationName) {
case "submitHomework": {
const homeworkId = variables["homeworkId"] ?? "hw-001";
const result = {
submissionId: `submission-${Date.now()}`,
submittedAt: new Date().toISOString(),
};
void homeworkId;
return graphqlOk("submitHomework", ok(result));
}
case "submitExam": {
const examId = variables["examId"] ?? "exam-001";
const result = {
submissionId: `submission-${Date.now()}`,
submittedAt: new Date().toISOString(),
score: 85,
maxScore: 100,
};
void examId;
return graphqlOk("submitExam", ok(result));
}
case "saveExamDraft": {
const result = {
savedAt: new Date().toISOString(),
ok: true,
};
return graphqlOk("saveExamDraft", ok(result));
}
case "markAsRead": {
const notificationId = variables["notificationId"] ?? "";
const result = {
notificationId: notificationId as string,
read: true,
};
return graphqlOk("markAsRead", ok(result));
}
case "markAllAsRead": {
const result = {
updatedCount: myNotificationsFixture.filter((n) => !n.read).length,
};
return graphqlOk("markAllAsRead", ok(result));
}
case "recordExamViolation": {
const result = {
recorded: true,
violationCount: 1,
};
return graphqlOk("recordExamViolation", ok(result));
}
case "recordPasteEvent": {
const result = {
recorded: true,
};
return graphqlOk("recordPasteEvent", ok(result));
}
case "updateNotificationPreference": {
const type = variables["type"] ?? "system";
const enabled = variables["enabled"] ?? true;
const result = {
type: type as string,
enabled: enabled as boolean,
updatedAt: new Date().toISOString(),
};
return graphqlOk("updateNotificationPreference", ok(result));
}
default: {
// 未覆盖的变更操作名,返回错误
return graphqlError(
`未实现的变更: ${operationName}`,
ERROR_CODES.BFF_STUDENT_NOT_FOUND,
);
}
}
}
// ---------------------------------------------------------------------------
// 主处理器
// ---------------------------------------------------------------------------
/** 查询操作名集合 */
const QUERY_NAMES = new Set<QueryOperationName>([
"currentUser",
"myClasses",
"myExams",
"examDetail",
"myHomework",
"homeworkDetail",
"myGrades",
"myAttendance",
"textbooks",
"chapters",
"learningPath",
"studentDashboard",
"myWeakness",
"myTrend",
"myNotifications",
"serverTime",
]);
/** 变更操作名集合 */
const MUTATION_NAMES = new Set<MutationOperationName>([
"submitHomework",
"submitExam",
"saveExamDraft",
"markAsRead",
"markAllAsRead",
"recordExamViolation",
"recordPasteEvent",
"updateNotificationPreference",
]);
/**
* 判断是否为查询操作名
* @param name 操作名
* @returns 是否查询
*/
function isQueryName(name: string): name is QueryOperationName {
return (QUERY_NAMES as Set<string>).has(name);
}
/**
* 判断是否为变更操作名
* @param name 操作名
* @returns 是否变更
*/
function isMutationName(name: string): name is MutationOperationName {
return (MUTATION_NAMES as Set<string>).has(name);
}
/** MSW 请求处理器列表 */
export const handlers = [
http.post(
"*/api/v1/student/graphql",
async ({ request }): Promise<Response> => {
let body: GraphQLRequest;
try {
body = (await request.json()) as GraphQLRequest;
} catch {
return graphqlError(
"请求体解析失败",
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
);
}
const operationName = extractOperationName(body);
if (!operationName) {
return graphqlError(
"无法识别 operationName",
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
);
}
const variables = body.variables ?? {};
// 按操作类型分发
if (isQueryName(operationName)) {
return handleQuery(operationName, variables);
}
if (isMutationName(operationName)) {
return handleMutation(operationName, variables);
}
// 未知操作名
return graphqlError(
`未知操作: ${operationName}`,
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
);
},
),
];
/** 导出 fixture 数据供测试使用 */
export const fixtures = {
currentUser: currentUserFixture,
myClasses: myClassesFixture,
myExams: myExamsFixture,
examDetail: examDetailFixture,
myHomework: myHomeworkFixture,
homeworkDetail: homeworkDetailFixture,
myGrades: myGradesFixture,
myAttendance: myAttendanceFixture,
textbooks: textbooksFixture,
chapters: chaptersFixture,
learningPath: learningPathFixture,
studentDashboard: studentDashboardFixture,
myWeakness: myWeaknessFixture,
myTrend: myTrendFixture,
myNotifications: myNotificationsFixture,
serverTime: serverTimeFixture,
};
/** 导出工具函数供测试使用 */
export { ok, fail, degraded, graphqlOk, graphqlError };
/** 导出操作名集合供测试断言 */
export { QUERY_NAMES, MUTATION_NAMES };

View File

@@ -0,0 +1,20 @@
/**
* MSW 服务端 setupai14
*
* 用于 Node.js 环境Vitest 集成测试 / SSR拦截 GraphQL 请求。
* 通过 setupServer 注册 handler在测试 beforeEach 中启动、afterEach 中停止。
*
* 用法:
* import { server } from "@/mocks/server";
* beforeAll(() => server.listen());
* afterEach(() => server.resetHandlers());
* afterAll(() => server.close());
*/
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
/** MSW Node.js server 单例 */
export const server = setupServer(...handlers);
export { handlers };