- 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 参考项目差距闭环(含完整文件清单)
269 lines
8.3 KiB
TypeScript
269 lines
8.3 KiB
TypeScript
/**
|
||
* MSW Handlers - P7 扩展(组卷 + 题库 + 教材 + 考试分析)
|
||
*
|
||
* 维护者:ai13(teacher-portal)
|
||
* 关联:graphql-p7-exams.ts、workline.md §3 P7 交付物
|
||
*
|
||
* 设计:本文件独立于 handlers.ts,不修改共享文件,最后统一合并。
|
||
* - 拦截 POST /api/v1/teacher/graphql,按 operationName 分发 P7-exams 操作。
|
||
* - 未覆盖的 operation(P2/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;
|
||
}
|
||
}),
|
||
];
|