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 参考项目差距闭环(含完整文件清单)
This commit is contained in:
268
apps/teacher-portal/src/mocks/handlers-p7-exams.ts
Normal file
268
apps/teacher-portal/src/mocks/handlers-p7-exams.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}),
|
||||
];
|
||||
Reference in New Issue
Block a user