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:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View File

@@ -0,0 +1,268 @@
/**
* 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;
}
}),
];