Files
Edu/apps/teacher-portal/src/mocks/handlers-p7-exams.ts
SpecialX d49d211425 feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现
- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步
- P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote
- P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告
- P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移
- P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块
  - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页)
  - classes/[id] 详情 + classes/schedule 课表
  - course-plans(2 页)/diagnostic(2 页)/error-book/practice
  - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析
  - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考
  - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡
  - homework/submissions 列表 + assignments/[id]/submissions 批量批改
  - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改
  - lesson-plans 编辑器 + library + calendar + heatmap 5 页
  - elective 选修课 3 页 /leave 请假 /schedule-changes 调课
- P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports
- 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序)
- viewports.ts 扩展 11 个新导航项
- 验证:tsc --noEmit 零错误 + eslint 零错误
- 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
2026-07-13 14:27:04 +08:00

269 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MSW Handlers - P7 扩展(组卷 + 题库 + 教材 + 考试分析)
*
* 维护者ai13teacher-portal
* 关联graphql-p7-exams.ts、workline.md §3 P7 交付物
*
* 设计:本文件独立于 handlers.ts不修改共享文件最后统一合并。
* - 拦截 POST /api/v1/teacher/graphql按 operationName 分发 P7-exams 操作。
* - 未覆盖的 operationP2/P3/P4/P5/P7-grades返回 undefined → MSW fallthrough 至下游 handler。
*
* 注册顺序p7ExamsHandlers 必须在 handlers 之前注册(见 browser.ts / server.ts
*
* 覆盖 operationName对应 graphql-p7-exams.ts
* - ExamBuild → 试卷结构 + 题库候选分页
* - SaveExamBuild → 保存组卷
* - ExamAnalytics → 考后分析
* - QuestionsLibrary → 题库列表(六维筛选 + 分页)
* - QuestionCreate / QuestionUpdate / QuestionDelete → 题目 CRUD
* - QuestionBatchImport → 批量导入
* - QuestionBatchExport → 批量导出
* - Textbooks → 教材列表
* - TextbookDetail → 教材详情
* - TextbookCreate → 创建教材
*/
import { http, HttpResponse } from "msw";
import {
filterQuestions,
findQuestion,
createMockQuestion,
updateMockQuestion,
deleteMockQuestion,
batchImportQuestions,
batchExportQuestions,
} from "./fixtures/questions";
import {
filterTextbooks,
findTextbookDetail,
createMockTextbook,
} from "./fixtures/textbooks";
import {
generateExamAnalytics,
generateExamBuildStructure,
} from "./fixtures/exam-analytics";
import { findMockExamDetail } from "./fixtures/exams";
import type {
QuestionsLibraryFilter,
QuestionCreateInput,
QuestionUpdateInput,
QuestionBatchImportInput,
SaveExamBuildInput,
TextbooksFilter,
TextbookCreateInput,
} from "@/lib/graphql-p7-exams";
/** GraphQL 响应体urql 期望 { data: ... } */
function graphqlData<T>(data: T) {
return HttpResponse.json({ data });
}
function extractOperationName(query?: string): string {
if (!query) return "Unknown";
const match = query.match(/(?:query|mutation)\s+(\w+)/);
return match?.[1] ?? "Unknown";
}
export const p7ExamsHandlers = [
// POST /api/v1/teacher/graphql → P7-exams operations未覆盖的 fallthrough 至 handlers.ts
http.post("*/api/v1/teacher/graphql", async ({ request }) => {
const body = (await request.json()) as {
operationName?: string;
query?: string;
variables?: Record<string, unknown>;
};
const operationName =
body.operationName ?? extractOperationName(body.query);
const variables = body.variables ?? {};
switch (operationName) {
case "ExamBuild": {
const examId = (variables.examId as string) ?? "";
const candidatePage = (variables.candidatePage as number) ?? 1;
const candidatePageSize = (variables.candidatePageSize as number) ?? 10;
const filter =
(variables.filter as QuestionsLibraryFilter | undefined) ?? undefined;
// 调用 exams.ts 的 findMockExamDetail 拿基础信息
const examDetail = examId ? findMockExamDetail(examId) : null;
// 同时叠加题库筛选filter 影响 candidates
const structure = generateExamBuildStructure(
examId,
examDetail,
candidatePage,
candidatePageSize,
);
if (!structure) {
return HttpResponse.json(
{
errors: [
{
message: `试卷结构不存在: ${examId}`,
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
},
],
},
{ status: 200 },
);
}
// 叠加 filter 到 candidates
if (filter) {
const filtered = filterQuestions({
...filter,
page: candidatePage,
pageSize: candidatePageSize,
});
return graphqlData({
examBuild: {
...structure,
candidates: filtered,
},
});
}
return graphqlData({ examBuild: structure });
}
case "SaveExamBuild": {
const input = variables.input as SaveExamBuildInput;
const totalScore = input.questions.reduce(
(acc, q) => acc + (q.score || 0),
0,
);
const now = new Date().toISOString();
return graphqlData({
saveExamBuild: {
examId: input.examId,
totalScore,
questionCount: input.questions.length,
savedAt: now,
},
});
}
case "ExamAnalytics": {
const examId = (variables.examId as string) ?? "";
const analytics = generateExamAnalytics(examId);
if (!analytics) {
// 兜底:未匹配到 EXAM_DEFS 时按 examId 生成(使用第一场考试的 totalScore
return HttpResponse.json(
{
errors: [
{
message: `考试分析不存在: ${examId}`,
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
},
],
},
{ status: 200 },
);
}
return graphqlData({ examAnalytics: analytics });
}
case "QuestionsLibrary": {
const filter =
(variables.filter as QuestionsLibraryFilter | undefined) ?? {};
return graphqlData({
questionsLibrary: filterQuestions(filter),
});
}
case "QuestionCreate": {
const input = variables.input as QuestionCreateInput;
const created = createMockQuestion(input);
return graphqlData({ questionCreate: created });
}
case "QuestionUpdate": {
const input = variables.input as QuestionUpdateInput;
const updated = updateMockQuestion(input);
if (!updated) {
return HttpResponse.json(
{
errors: [
{
message: `题目不存在: ${input.questionId}`,
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
},
],
},
{ status: 200 },
);
}
return graphqlData({ questionUpdate: updated });
}
case "QuestionDelete": {
const questionId = variables.questionId as string;
// 删除前确认存在
const existed = findQuestion(questionId);
if (!existed) {
return HttpResponse.json(
{
errors: [
{
message: `题目不存在: ${questionId}`,
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
},
],
},
{ status: 200 },
);
}
deleteMockQuestion(questionId);
return graphqlData({
questionDelete: { questionId, deleted: true },
});
}
case "QuestionBatchImport": {
const input = variables.input as QuestionBatchImportInput;
const result = batchImportQuestions(input);
return graphqlData({ questionBatchImport: result });
}
case "QuestionBatchExport": {
const filter =
(variables.filter as QuestionsLibraryFilter | undefined) ?? {};
const result = batchExportQuestions(filter);
return graphqlData({ questionBatchExport: result });
}
case "Textbooks": {
const filter =
(variables.filter as TextbooksFilter | undefined) ?? {};
return graphqlData({ textbooks: filterTextbooks(filter) });
}
case "TextbookDetail": {
const id = (variables.id as string) ?? "";
const detail = findTextbookDetail(id);
if (!detail) {
return HttpResponse.json(
{
errors: [
{
message: `教材不存在: ${id}`,
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
},
],
},
{ status: 200 },
);
}
return graphqlData({ textbookDetail: detail });
}
case "TextbookCreate": {
const input = variables.input as TextbookCreateInput;
const created = createMockTextbook(input);
return graphqlData({ textbookCreate: created });
}
default:
// 未覆盖的 P2/P3/P4/P5/P7-grades operation返回 undefined → fallthrough 至 handlers.ts
return undefined;
}
}),
];