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:
@@ -8,5 +8,23 @@
|
||||
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { handlers } from "./handlers";
|
||||
import { p4Handlers } from "./handlers-p4";
|
||||
import { p5Handlers } from "./handlers-p5";
|
||||
import { p7Handlers } from "./handlers-p7-grades";
|
||||
import { p7ExamsHandlers } from "./handlers-p7-exams";
|
||||
import { p7AdminHandlers } from "./handlers-p7-admin";
|
||||
import { p7InsightsHandlers } from "./handlers-p7-insights";
|
||||
import { p7AdvancedHandlers } from "./handlers-p7-advanced";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
// 顺序:先 P7(最新扩展)→ P4/P5 → 主 handlers(默认兜底)
|
||||
// 每个 handler 内部未命中时返回 undefined 触发 MSW fallthrough 至下一个
|
||||
export const worker = setupWorker(
|
||||
...p7AdvancedHandlers,
|
||||
...p7InsightsHandlers,
|
||||
...p7AdminHandlers,
|
||||
...p7ExamsHandlers,
|
||||
...p7Handlers,
|
||||
...p4Handlers,
|
||||
...p5Handlers,
|
||||
...handlers,
|
||||
);
|
||||
|
||||
94
apps/teacher-portal/src/mocks/fixtures/ai.ts
Normal file
94
apps/teacher-portal/src/mocks/fixtures/ai.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Mock AI 生成数据(ai 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §6 AI 辅助
|
||||
*/
|
||||
|
||||
import type {
|
||||
GeneratedQuestion,
|
||||
GeneratedLessonPlan,
|
||||
GeneratedReport,
|
||||
} from "@/lib/graphql-p5";
|
||||
|
||||
export const mockGeneratedQuestion: GeneratedQuestion = {
|
||||
id: "ai-q-001",
|
||||
question:
|
||||
"已知函数 f(x) = x³ - 3x² + 2,求 f(x) 在区间 [-1, 3] 上的极大值与极小值。",
|
||||
options: [
|
||||
"A. 极大值 2,极小值 -2",
|
||||
"B. 极大值 2,极小值 0",
|
||||
"C. 极大值 3,极小值 -2",
|
||||
"D. 极大值 0,极小值 -2",
|
||||
],
|
||||
answer: "A",
|
||||
explanation:
|
||||
"对 f(x) 求导得 f'(x) = 3x² - 6x = 3x(x - 2)。令 f'(x)=0 解得 x=0 或 x=2。" +
|
||||
"f(0)=2 为极大值,f(2)=8-12+2=-2 为极小值,端点 f(-1)=-2、f(3)=2。故极大值 2,极小值 -2。",
|
||||
};
|
||||
|
||||
export const mockLessonPlan: GeneratedLessonPlan = {
|
||||
id: "ai-lp-001",
|
||||
content: [
|
||||
"# 教案:函数的单调性与极值",
|
||||
"",
|
||||
"## 一、教学目标",
|
||||
"1. 理解函数单调性的概念,能利用导数判断函数的单调性。",
|
||||
"2. 掌握求函数极值的步骤,理解极值点与导数的关系。",
|
||||
"3. 能运用单调性与极值解决简单的实际应用问题。",
|
||||
"",
|
||||
"## 二、教学重点与难点",
|
||||
"- **重点**:利用导数判断单调性、求极值。",
|
||||
"- **难点**:极值点的判定条件(导数变号)。",
|
||||
"",
|
||||
"## 三、教学过程",
|
||||
"### 1. 情境导入(5 分钟)",
|
||||
"通过山峰高低变化的图象引入:函数值何时升高、何时降低?",
|
||||
"",
|
||||
"### 2. 新知探究(20 分钟)",
|
||||
"- 引导学生回顾导数的几何意义。",
|
||||
"- 推导单调性与导数符号的关系:f'(x)>0 ⇒ 单调递增;f'(x)<0 ⇒ 单调递减。",
|
||||
"- 结合例题 f(x)=x³-3x²+2 演示求极值的完整步骤。",
|
||||
"",
|
||||
"### 3. 巩固练习(10 分钟)",
|
||||
"学生独立完成 2 道练习题,教师巡视指导。",
|
||||
"",
|
||||
"### 4. 课堂小结(5 分钟)",
|
||||
"梳理(求导 → 找驻点 → 判断变号 → 定极值)四步法。",
|
||||
"",
|
||||
"## 四、作业布置",
|
||||
"课本 P78 习题 1、2、3 题。",
|
||||
].join("\n"),
|
||||
summary:
|
||||
"本节课以导数为工具,引导学生建立单调性与极值的判定方法,重点突破极值点的变号条件。",
|
||||
};
|
||||
|
||||
export const mockReport: GeneratedReport = {
|
||||
id: "ai-rpt-001",
|
||||
content: [
|
||||
"# 班级学情报告(周报)",
|
||||
"",
|
||||
"## 一、总体概况",
|
||||
"本周高三(1)班数学共完成 3 次作业、1 次单元测试,整体表现稳中有升。",
|
||||
"作业平均完成率 94%,单元测试平均分 108.5(满分 150),较上周提升 3.2 分。",
|
||||
"",
|
||||
"## 二、薄弱知识点分析",
|
||||
"1. **导数应用**:失分集中在于极值判定,约 35% 学生混淆极值点与驻点。",
|
||||
"2. **三角函数**:周期与最值的综合应用错误率 28%。",
|
||||
"3. **立体几何**:体积计算公式记忆不牢,错误率 22%。",
|
||||
"",
|
||||
"## 三、优秀表现",
|
||||
"- 李明、王芳连续两周作业满分。",
|
||||
"- 陈强本次单元测试进步 18 分,进入班级前 10。",
|
||||
"",
|
||||
"## 四、后续关注",
|
||||
"重点关注 3 名连续两次测试低于平均线的学生,建议安排课后辅导。",
|
||||
].join("\n"),
|
||||
summary:
|
||||
"班级整体稳中有升,导数应用与三角函数综合为本周薄弱点,需在下周教学中重点强化。",
|
||||
recommendations: [
|
||||
"下周安排 1 课时专项练习(导数极值判定),配合错题精讲。",
|
||||
"对 3 名后进学生安排周二/周四课后 30 分钟辅导。",
|
||||
"引入分层作业,为前 10 名学生布置拓展题,保持学习动力。",
|
||||
],
|
||||
};
|
||||
105
apps/teacher-portal/src/mocks/fixtures/analytics.ts
Normal file
105
apps/teacher-portal/src/mocks/fixtures/analytics.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mock 学情分析数据(data-ana / ai 域,P4 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClassAnalytics,
|
||||
StudentAnalytics,
|
||||
} from "@/lib/graphql-p4";
|
||||
|
||||
/** Top 学生 mock(5 名,含趋势 / 弱项 / 强项) */
|
||||
const topStudents: StudentAnalytics[] = [
|
||||
{
|
||||
studentId: "stu-001",
|
||||
studentName: "学生01",
|
||||
avgScore: 92,
|
||||
trend: [88, 90, 85, 91, 94],
|
||||
weakPoints: ["导数", "书面表达"],
|
||||
strongPoints: ["实数与运算", "词汇基础"],
|
||||
masteryRate: 90,
|
||||
},
|
||||
{
|
||||
studentId: "stu-002",
|
||||
studentName: "学生02",
|
||||
avgScore: 89,
|
||||
trend: [85, 87, 86, 90, 92],
|
||||
weakPoints: ["函数", "议论文写作"],
|
||||
strongPoints: ["代数式", "语法结构"],
|
||||
masteryRate: 84,
|
||||
},
|
||||
{
|
||||
studentId: "stu-003",
|
||||
studentName: "学生03",
|
||||
avgScore: 86,
|
||||
trend: [80, 83, 85, 88, 90],
|
||||
weakPoints: ["方程与不等式", "阅读理解"],
|
||||
strongPoints: ["文言文基础", "词汇基础"],
|
||||
masteryRate: 78,
|
||||
},
|
||||
{
|
||||
studentId: "stu-004",
|
||||
studentName: "学生04",
|
||||
avgScore: 84,
|
||||
trend: [78, 82, 80, 86, 88],
|
||||
weakPoints: ["函数", "现代文阅读"],
|
||||
strongPoints: ["实数与运算", "古诗词鉴赏"],
|
||||
masteryRate: 72,
|
||||
},
|
||||
{
|
||||
studentId: "stu-005",
|
||||
studentName: "学生05",
|
||||
avgScore: 81,
|
||||
trend: [75, 79, 82, 84, 85],
|
||||
weakPoints: ["导数", "书面表达", "阅读理解"],
|
||||
strongPoints: ["词汇基础", "代数式"],
|
||||
masteryRate: 68,
|
||||
},
|
||||
];
|
||||
|
||||
/** 班级学情分析 mock */
|
||||
export const mockClassAnalytics: ClassAnalytics = {
|
||||
classId: "550e8400-e29b-41d4-a716-446655440010",
|
||||
className: "高三(1)班",
|
||||
avgScore: 78.5,
|
||||
passRate: 87.5,
|
||||
avgTrend: [72, 74, 76, 77, 79],
|
||||
topStudents,
|
||||
weakPoints: ["导数", "函数", "书面表达", "阅读理解"],
|
||||
};
|
||||
|
||||
/** 单生学情分析 mock(默认返回 Top1 学生数据) */
|
||||
export const mockStudentAnalytics: StudentAnalytics = {
|
||||
studentId: "stu-001",
|
||||
studentName: "学生01",
|
||||
avgScore: 92,
|
||||
trend: [88, 90, 85, 91, 94],
|
||||
weakPoints: ["导数", "书面表达"],
|
||||
strongPoints: ["实数与运算", "词汇基础"],
|
||||
masteryRate: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* 按学生 ID 查找学情数据
|
||||
* 未命中时返回 mockStudentAnalytics 兜底
|
||||
*/
|
||||
export function findStudentAnalytics(
|
||||
studentId: string,
|
||||
): StudentAnalytics {
|
||||
const found = topStudents.find((s) => s.studentId === studentId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
// 未命中时基于 studentId 构造一份变化数据,避免页面空态
|
||||
return {
|
||||
...mockStudentAnalytics,
|
||||
studentId,
|
||||
studentName: `学生${studentId.slice(-2)}`,
|
||||
avgScore: 75,
|
||||
trend: [70, 72, 74, 73, 76],
|
||||
weakPoints: ["函数", "现代文阅读"],
|
||||
strongPoints: ["词汇基础"],
|
||||
masteryRate: 65,
|
||||
};
|
||||
}
|
||||
501
apps/teacher-portal/src/mocks/fixtures/attendance.ts
Normal file
501
apps/teacher-portal/src/mocks/fixtures/attendance.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Mock 考勤数据(P7-admin:考勤子模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个班级 × 30 学生 × 30 天 = 4500 条考勤记录(确定性伪随机)
|
||||
* - 班级考勤统计(出勤率/迟到率/早退率/请假率)
|
||||
* - 30 天每日趋势数据
|
||||
* - 学生考勤预警(出勤率 < 90%)
|
||||
* - 考勤报告数据(周报/月报)
|
||||
*/
|
||||
|
||||
import type {
|
||||
AttendanceRecord,
|
||||
AttendanceStatus,
|
||||
AttendanceListFilter,
|
||||
AttendanceListResult,
|
||||
AttendanceSheetItem,
|
||||
SaveAttendanceSheetInput,
|
||||
SaveAttendanceSheetResult,
|
||||
AttendanceStats,
|
||||
AttendanceTrendPoint,
|
||||
AttendanceClassComparison,
|
||||
AttendanceWarning,
|
||||
AttendanceReport,
|
||||
AttendanceReportType,
|
||||
AttendanceReportDetail,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import { EXTENDED_CLASSES } from "./grades-extended";
|
||||
|
||||
/** 5 个班级 ID 列表 */
|
||||
const CLASS_IDS = EXTENDED_CLASSES.map((c) => c.id);
|
||||
|
||||
/** 30 天日期列表(从今天往前回溯 29 天) */
|
||||
function buildDateRange(days: number): string[] {
|
||||
const dates: string[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
dates.push(formatDate(d));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** 格式化日期 YYYY-MM-DD */
|
||||
function formatDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** 30 天日期列表 */
|
||||
export const ATTENDANCE_DATES = buildDateRange(30);
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/** 基于字符串生成 seed */
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 生成单生学号 */
|
||||
function studentNoFor(classIdx: number, studentIdx: number): string {
|
||||
return `2026${String(classIdx + 1).padStart(2, "0")}${String(studentIdx).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** 生成单生 ID */
|
||||
function studentIdFor(classId: string, studentIdx: number): string {
|
||||
return `stu-${classId.slice(-4)}-${String(studentIdx).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** 学生姓名(确定性) */
|
||||
function studentNameFor(studentIdx: number): string {
|
||||
return `学生${String(studentIdx).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 班级出勤率基准(与 grades-extended 对齐) */
|
||||
const CLASS_PRESENT_RATE_BASE: Record<string, number> = {
|
||||
"550e8400-e29b-41d4-a716-446655440010": 95,
|
||||
"550e8400-e29b-41d4-a716-446655440011": 92,
|
||||
"550e8400-e29b-41d4-a716-446655440012": 90,
|
||||
"550e8400-e29b-41d4-a716-446655440013": 93,
|
||||
"550e8400-e29b-41d4-a716-446655440014": 96,
|
||||
};
|
||||
|
||||
/** 根据班级 + 学生 + 日期生成确定性考勤状态 */
|
||||
function pickStatus(
|
||||
classId: string,
|
||||
studentIdx: number,
|
||||
dateStr: string,
|
||||
): AttendanceStatus {
|
||||
const base = CLASS_PRESENT_RATE_BASE[classId] ?? 92;
|
||||
const seed = strToSeed(`${classId}-${studentIdx}-${dateStr}`);
|
||||
const r = seededRandom(seed);
|
||||
// 调整:班级越强,出勤率越高
|
||||
const presentThreshold = base / 100;
|
||||
const lateThreshold = presentThreshold + 0.03;
|
||||
const leaveThreshold = lateThreshold + 0.02;
|
||||
const earlyLeaveThreshold = leaveThreshold + 0.015;
|
||||
if (r < presentThreshold) return "PRESENT";
|
||||
if (r < lateThreshold) return "LATE";
|
||||
if (r < leaveThreshold) return "LEAVE";
|
||||
if (r < earlyLeaveThreshold) return "EARLY_LEAVE";
|
||||
return "ABSENT";
|
||||
}
|
||||
|
||||
/** 生成全量考勤记录(4500 条) */
|
||||
function generateAllRecords(): AttendanceRecord[] {
|
||||
const records: AttendanceRecord[] = [];
|
||||
CLASS_IDS.forEach((classId, classIdx) => {
|
||||
const cls = EXTENDED_CLASSES[classIdx];
|
||||
if (!cls) return;
|
||||
for (const date of ATTENDANCE_DATES) {
|
||||
// 周末(周六/周日)跳过:基于日期字符串判断
|
||||
const dayOfWeek = new Date(date).getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const status = pickStatus(classId, s, date);
|
||||
const note =
|
||||
status === "ABSENT"
|
||||
? "未到校,需跟进"
|
||||
: status === "LEAVE"
|
||||
? "已提交请假条"
|
||||
: status === "LATE"
|
||||
? "迟到 5 分钟"
|
||||
: null;
|
||||
records.push({
|
||||
id: `att-${cls.id.slice(-4)}-${date}-${String(s).padStart(3, "0")}`,
|
||||
classId,
|
||||
className: cls.name,
|
||||
studentId: studentIdFor(classId, s),
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
date,
|
||||
status,
|
||||
note,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return records;
|
||||
}
|
||||
|
||||
/** 全量考勤记录缓存 */
|
||||
const ALL_RECORDS: AttendanceRecord[] = generateAllRecords();
|
||||
|
||||
/** 保存的考勤覆盖(key = `${classId}|${date}|${studentId}`),用于 SaveAttendanceSheet 后即时反映 */
|
||||
const SAVED_OVERRIDES = new Map<string, AttendanceStatus>();
|
||||
|
||||
function makeOverrideKey(
|
||||
classId: string,
|
||||
date: string,
|
||||
studentId: string,
|
||||
): string {
|
||||
return `${classId}|${date}|${studentId}`;
|
||||
}
|
||||
|
||||
/** 应用覆盖后的状态读取 */
|
||||
function resolveStatus(
|
||||
classId: string,
|
||||
date: string,
|
||||
studentId: string,
|
||||
fallback: AttendanceStatus,
|
||||
): AttendanceStatus {
|
||||
const override = SAVED_OVERRIDES.get(
|
||||
makeOverrideKey(classId, date, studentId),
|
||||
);
|
||||
return override ?? fallback;
|
||||
}
|
||||
|
||||
/** 考勤列表查询(按筛选 + 分页) */
|
||||
export function queryAttendanceList(
|
||||
filter: AttendanceListFilter,
|
||||
): AttendanceListResult {
|
||||
const { classId, startDate, endDate, statuses, page, pageSize } = filter;
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 30;
|
||||
|
||||
const statusSet = statuses && statuses.length > 0 ? new Set(statuses) : null;
|
||||
|
||||
const filtered = ALL_RECORDS.filter((r) => {
|
||||
if (classId && r.classId !== classId) return false;
|
||||
if (startDate && r.date < startDate) return false;
|
||||
if (endDate && r.date > endDate) return false;
|
||||
if (statusSet && !statusSet.has(r.status)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
|
||||
const currentPage = Math.min(Math.max(1, safePage), totalPages);
|
||||
const start = (currentPage - 1) * safePageSize;
|
||||
const items = filtered.slice(start, start + safePageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: safePageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/** 考勤录入查询(按 classId + date 拉取学生 + 当天状态) */
|
||||
export function queryAttendanceSheet(
|
||||
classId: string,
|
||||
date: string,
|
||||
): AttendanceSheetItem[] {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return [];
|
||||
const classIdx = EXTENDED_CLASSES.indexOf(cls);
|
||||
const items: AttendanceSheetItem[] = [];
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const studentId = studentIdFor(classId, s);
|
||||
const baseStatus = pickStatus(classId, s, date);
|
||||
const status = resolveStatus(classId, date, studentId, baseStatus);
|
||||
items.push({
|
||||
studentId,
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
date,
|
||||
status,
|
||||
note:
|
||||
status === "ABSENT"
|
||||
? "未到校,需跟进"
|
||||
: status === "LEAVE"
|
||||
? "已提交请假条"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 批量保存考勤录入 */
|
||||
export function saveAttendanceSheet(
|
||||
input: SaveAttendanceSheetInput,
|
||||
): SaveAttendanceSheetResult {
|
||||
for (const rec of input.records) {
|
||||
SAVED_OVERRIDES.set(
|
||||
makeOverrideKey(input.classId, input.date, rec.studentId),
|
||||
rec.status,
|
||||
);
|
||||
}
|
||||
return {
|
||||
classId: input.classId,
|
||||
date: input.date,
|
||||
savedCount: input.records.length,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算班级考勤统计 */
|
||||
export function queryAttendanceStats(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceStats {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
const className = cls?.name ?? "未知班级";
|
||||
const classRecords = ALL_RECORDS.filter((r) => {
|
||||
if (r.classId !== classId) return false;
|
||||
if (r.date < startDate || r.date > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = classRecords.length;
|
||||
const counts = { present: 0, absent: 0, late: 0, earlyLeave: 0, leave: 0 };
|
||||
for (const r of classRecords) {
|
||||
if (r.status === "PRESENT") counts.present++;
|
||||
else if (r.status === "ABSENT") counts.absent++;
|
||||
else if (r.status === "LATE") counts.late++;
|
||||
else if (r.status === "EARLY_LEAVE") counts.earlyLeave++;
|
||||
else if (r.status === "LEAVE") counts.leave++;
|
||||
}
|
||||
|
||||
const safeDiv = (n: number): number =>
|
||||
total === 0 ? 0 : Math.round((n / total) * 1000) / 10;
|
||||
|
||||
const trend = buildTrend(classId, startDate, endDate);
|
||||
const warningCount = computeWarnings(classId, startDate, endDate).length;
|
||||
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
startDate,
|
||||
endDate,
|
||||
totalRecords: total,
|
||||
presentCount: counts.present,
|
||||
absentCount: counts.absent,
|
||||
lateCount: counts.late,
|
||||
earlyLeaveCount: counts.earlyLeave,
|
||||
leaveCount: counts.leave,
|
||||
presentRate: safeDiv(counts.present),
|
||||
lateRate: safeDiv(counts.late),
|
||||
earlyLeaveRate: safeDiv(counts.earlyLeave),
|
||||
leaveRate: safeDiv(counts.leave),
|
||||
warningCount,
|
||||
trend,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建趋势(按日聚合) */
|
||||
function buildTrend(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceTrendPoint[] {
|
||||
const dates = ATTENDANCE_DATES.filter((d) => d >= startDate && d <= endDate);
|
||||
return dates.map((date) => {
|
||||
const dayRecords = ALL_RECORDS.filter(
|
||||
(r) => r.classId === classId && r.date === date,
|
||||
);
|
||||
const total = dayRecords.length;
|
||||
const safeDiv = (n: number): number =>
|
||||
total === 0 ? 0 : Math.round((n / total) * 1000) / 10;
|
||||
const present = dayRecords.filter((r) => r.status === "PRESENT").length;
|
||||
const late = dayRecords.filter((r) => r.status === "LATE").length;
|
||||
const absent = dayRecords.filter((r) => r.status === "ABSENT").length;
|
||||
return {
|
||||
date,
|
||||
presentRate: safeDiv(present),
|
||||
lateRate: safeDiv(late),
|
||||
absentRate: safeDiv(absent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 班级考勤对比 */
|
||||
export function queryAttendanceClassComparison(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceClassComparison[] {
|
||||
return CLASS_IDS.map((classId) => {
|
||||
const stats = queryAttendanceStats(classId, startDate, endDate);
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
return {
|
||||
classId,
|
||||
className: cls?.name ?? "未知班级",
|
||||
presentRate: stats.presentRate,
|
||||
lateRate: stats.lateRate,
|
||||
absentRate: Math.round(
|
||||
(stats.absentCount / Math.max(1, stats.totalRecords)) * 1000,
|
||||
) / 10,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 学生考勤预警(出勤率 < 90%) */
|
||||
function computeWarnings(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceWarning[] {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return [];
|
||||
const classIdx = EXTENDED_CLASSES.indexOf(cls);
|
||||
const warnings: AttendanceWarning[] = [];
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const studentId = studentIdFor(classId, s);
|
||||
const studentRecords = ALL_RECORDS.filter((r) => {
|
||||
if (r.classId !== classId) return false;
|
||||
if (r.studentId !== studentId) return false;
|
||||
if (r.date < startDate || r.date > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
const total = studentRecords.length;
|
||||
if (total === 0) continue;
|
||||
const present = studentRecords.filter((r) => r.status === "PRESENT").length;
|
||||
const rate = Math.round((present / total) * 1000) / 10;
|
||||
if (rate < 90) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
className: cls.name,
|
||||
presentRate: rate,
|
||||
absentCount: studentRecords.filter((r) => r.status === "ABSENT").length,
|
||||
lateCount: studentRecords.filter((r) => r.status === "LATE").length,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 按出勤率升序
|
||||
warnings.sort((a, b) => a.presentRate - b.presentRate);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** 学生考勤预警查询入口 */
|
||||
export function queryAttendanceWarnings(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceWarning[] {
|
||||
return computeWarnings(classId, startDate, endDate);
|
||||
}
|
||||
|
||||
/** 考勤报告查询 */
|
||||
export function queryAttendanceReport(
|
||||
classId: string,
|
||||
reportType: AttendanceReportType,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceReport {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
const className = cls?.name ?? "未知班级";
|
||||
const stats = queryAttendanceStats(classId, startDate, endDate);
|
||||
const details = buildReportDetails(classId, startDate, endDate);
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
reportType,
|
||||
startDate,
|
||||
endDate,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
totalRecords: stats.totalRecords,
|
||||
presentRate: stats.presentRate,
|
||||
lateRate: stats.lateRate,
|
||||
earlyLeaveRate: stats.earlyLeaveRate,
|
||||
leaveRate: stats.leaveRate,
|
||||
warningCount: stats.warningCount,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建报告明细(按学生聚合) */
|
||||
function buildReportDetails(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceReportDetail[] {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return [];
|
||||
const classIdx = EXTENDED_CLASSES.indexOf(cls);
|
||||
const details: AttendanceReportDetail[] = [];
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const studentId = studentIdFor(classId, s);
|
||||
const studentRecords = ALL_RECORDS.filter((r) => {
|
||||
if (r.classId !== classId) return false;
|
||||
if (r.studentId !== studentId) return false;
|
||||
if (r.date < startDate || r.date > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
const total = studentRecords.length;
|
||||
if (total === 0) continue;
|
||||
const present = studentRecords.filter((r) => r.status === "PRESENT").length;
|
||||
const absent = studentRecords.filter((r) => r.status === "ABSENT").length;
|
||||
const late = studentRecords.filter((r) => r.status === "LATE").length;
|
||||
const earlyLeave = studentRecords.filter(
|
||||
(r) => r.status === "EARLY_LEAVE",
|
||||
).length;
|
||||
const leave = studentRecords.filter((r) => r.status === "LEAVE").length;
|
||||
details.push({
|
||||
studentId,
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
presentCount: present,
|
||||
absentCount: absent,
|
||||
lateCount: late,
|
||||
earlyLeaveCount: earlyLeave,
|
||||
leaveCount: leave,
|
||||
presentRate: Math.round((present / total) * 1000) / 10,
|
||||
});
|
||||
}
|
||||
// 按出勤率降序
|
||||
details.sort((a, b) => b.presentRate - a.presentRate);
|
||||
return details;
|
||||
}
|
||||
|
||||
/** 考勤列表分页类型重导出(供 handlers 使用) */
|
||||
export type { AttendanceListResult };
|
||||
|
||||
/** 默认日期范围(近 7 天) */
|
||||
export function defaultDateRange(): { startDate: string; endDate: string } {
|
||||
const dates = ATTENDANCE_DATES;
|
||||
const end = dates[dates.length - 1] ?? formatDate(new Date());
|
||||
const startIdx = Math.max(0, dates.length - 7);
|
||||
const start = dates[startIdx] ?? end;
|
||||
return { startDate: start, endDate: end };
|
||||
}
|
||||
|
||||
/** 近 30 天日期范围 */
|
||||
export function fullDateRange(): { startDate: string; endDate: string } {
|
||||
const dates = ATTENDANCE_DATES;
|
||||
return {
|
||||
startDate: dates[0] ?? formatDate(new Date()),
|
||||
endDate: dates[dates.length - 1] ?? formatDate(new Date()),
|
||||
};
|
||||
}
|
||||
265
apps/teacher-portal/src/mocks/fixtures/classes-extended.ts
Normal file
265
apps/teacher-portal/src/mocks/fixtures/classes-extended.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Mock 班级详情 + 课表数据(P7-admin:班级详情聚合)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个班级详情(基本信息 + 学生 + 课表 + 近期作业 + 概览统计 + 成绩趋势)
|
||||
* - 课表数据(5 班 × 周一到周五 × 8 节 = 200 节)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClassDetail,
|
||||
ClassDetailAggregate,
|
||||
ClassDetailStudent,
|
||||
ClassDetailHomework,
|
||||
ClassOverviewStats,
|
||||
ClassDetailTrendPoint,
|
||||
ClassScheduleItem,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import { EXTENDED_CLASSES, SUBJECTS } from "./grades-extended";
|
||||
|
||||
/** 班级任课教师(确定性生成) */
|
||||
const CLASS_TEACHERS = [
|
||||
"王老师",
|
||||
"刘老师",
|
||||
"陈老师",
|
||||
"杨老师",
|
||||
"周老师",
|
||||
] as const;
|
||||
|
||||
/** 班主任名单 */
|
||||
const HOMEROOM_TEACHERS = [
|
||||
"王主任",
|
||||
"李主任",
|
||||
"张主任",
|
||||
"赵主任",
|
||||
"钱主任",
|
||||
] as const;
|
||||
|
||||
/** 教室编号 */
|
||||
const ROOMS = ["A101", "A102", "A103", "A104", "A105"] as const;
|
||||
|
||||
/** 学校名 */
|
||||
const SCHOOL_NAME = "示范中学";
|
||||
|
||||
/** 8 节课的起止时间 */
|
||||
const PERIOD_TIMES: Array<{ start: string; end: string }> = [
|
||||
{ start: "08:00", end: "08:45" },
|
||||
{ start: "08:55", end: "09:40" },
|
||||
{ start: "10:00", end: "10:45" },
|
||||
{ start: "10:55", end: "11:40" },
|
||||
{ start: "13:30", end: "14:15" },
|
||||
{ start: "14:25", end: "15:10" },
|
||||
{ start: "15:25", end: "16:10" },
|
||||
{ start: "16:20", end: "17:05" },
|
||||
];
|
||||
|
||||
/** 班级学科(班主任学科) */
|
||||
const CLASS_SUBJECTS = ["数学", "语文", "英语", "物理", "化学"] as const;
|
||||
|
||||
/** 学生姓名(确定性) */
|
||||
function studentNameFor(studentIdx: number): string {
|
||||
return `学生${String(studentIdx).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 学生学号 */
|
||||
function studentNoFor(classIdx: number, studentIdx: number): string {
|
||||
return `2026${String(classIdx + 1).padStart(2, "0")}${String(studentIdx).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** 学生邮箱 */
|
||||
function emailFor(classIdx: number, studentIdx: number): string {
|
||||
return `student${classIdx + 1}_${studentIdx}@edu.test`;
|
||||
}
|
||||
|
||||
/** 班级学生列表生成 */
|
||||
function buildStudents(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
count: number,
|
||||
): ClassDetailStudent[] {
|
||||
const items: ClassDetailStudent[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
items.push({
|
||||
id: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
name: studentNameFor(i),
|
||||
studentNo: studentNoFor(classIdx, i),
|
||||
email: emailFor(classIdx, i),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 班级详情生成 */
|
||||
function buildClassDetail(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
): ClassDetail {
|
||||
const cls = EXTENDED_CLASSES[classIdx];
|
||||
const subject = CLASS_SUBJECTS[classIdx] ?? "数学";
|
||||
return {
|
||||
id: classId,
|
||||
name: cls?.name ?? `班级${classIdx + 1}`,
|
||||
gradeId: cls?.gradeId ?? "grade-12",
|
||||
subject,
|
||||
studentCount: 30,
|
||||
homeroomTeacher: HOMEROOM_TEACHERS[classIdx] ?? "王主任",
|
||||
room: ROOMS[classIdx] ?? "A101",
|
||||
schoolName: SCHOOL_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
/** 课表生成(5 班 × 周一到周五 × 8 节 = 200 节) */
|
||||
function buildSchedule(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
): ClassScheduleItem[] {
|
||||
const cls = EXTENDED_CLASSES[classIdx];
|
||||
const items: ClassScheduleItem[] = [];
|
||||
// 每天最多 6 节(最后两节为自习或空)
|
||||
const subjectRotation = SUBJECTS;
|
||||
for (let day = 1; day <= 5; day++) {
|
||||
for (let period = 1; period <= 8; period++) {
|
||||
// 第 7、8 节多数为自习(保留少量科目)
|
||||
const isSelfStudy = period >= 7 && (day + period) % 2 === 0;
|
||||
const subjectIdx = (day + period + classIdx) % subjectRotation.length;
|
||||
const subject = isSelfStudy
|
||||
? "自习"
|
||||
: (subjectRotation[subjectIdx] ?? "数学");
|
||||
const teacherIdx = (day + period + classIdx) % CLASS_TEACHERS.length;
|
||||
const times = PERIOD_TIMES[period - 1];
|
||||
if (!times) continue;
|
||||
items.push({
|
||||
id: `sch-${classId.slice(-4)}-${day}-${period}`,
|
||||
classId,
|
||||
className: cls?.name ?? `班级${classIdx + 1}`,
|
||||
dayOfWeek: day,
|
||||
period,
|
||||
subject,
|
||||
teacherName: isSelfStudy
|
||||
? "—"
|
||||
: (CLASS_TEACHERS[teacherIdx] ?? "未知"),
|
||||
room: ROOMS[classIdx] ?? "A101",
|
||||
startTime: times.start,
|
||||
endTime: times.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 近期作业生成(5 个) */
|
||||
function buildRecentHomework(classId: string): ClassDetailHomework[] {
|
||||
const items: ClassDetailHomework[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const due = new Date(today);
|
||||
due.setDate(due.getDate() + i - 1);
|
||||
const dueStr = `${due.getFullYear()}-${String(due.getMonth() + 1).padStart(2, "0")}-${String(due.getDate()).padStart(2, "0")}`;
|
||||
const subject = SUBJECTS[i % SUBJECTS.length] ?? "数学";
|
||||
items.push({
|
||||
id: `hw-${classId.slice(-4)}-${i}`,
|
||||
title: `${subject} - 作业 ${i + 1}`,
|
||||
dueDate: dueStr,
|
||||
submissionRate: 70 + ((i * 7) % 25),
|
||||
avgScore: i === 4 ? null : 75 + ((i * 5) % 18),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 概览统计 */
|
||||
function buildOverview(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
): ClassOverviewStats {
|
||||
const base = 78 + classIdx * 2;
|
||||
return {
|
||||
attendanceRate: 90 + (classIdx % 5),
|
||||
averageScore: base,
|
||||
homeworkCompletionRate: 80 + (classIdx % 10),
|
||||
recentExamTitle: `高三第${classIdx + 1}次月考`,
|
||||
recentExamAvg: base - 4,
|
||||
};
|
||||
}
|
||||
|
||||
/** 成绩趋势(6 个月) */
|
||||
function buildTrend(classId: string): ClassDetailTrendPoint[] {
|
||||
const months = ["1月", "2月", "3月", "4月", "5月", "6月"];
|
||||
const base = 75 + (classId.charCodeAt(classId.length - 1) % 8);
|
||||
return months.map((m, i) => ({
|
||||
month: m,
|
||||
avgScore: base + ((i * 3) % 8) - 2,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 班级详情缓存(按 id 索引) */
|
||||
const CLASS_DETAIL_CACHE: Record<string, ClassDetailAggregate> = {};
|
||||
|
||||
/** 初始化所有班级详情 */
|
||||
function initClassDetails(): void {
|
||||
EXTENDED_CLASSES.forEach((cls, idx) => {
|
||||
const classId = cls.id;
|
||||
CLASS_DETAIL_CACHE[classId] = {
|
||||
class: buildClassDetail(classId, idx),
|
||||
students: buildStudents(classId, idx, 30),
|
||||
schedule: buildSchedule(classId, idx),
|
||||
recentHomework: buildRecentHomework(classId),
|
||||
overview: buildOverview(classId, idx),
|
||||
trend: buildTrend(classId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
initClassDetails();
|
||||
|
||||
/** 查询班级详情聚合 */
|
||||
export function queryClassDetail(
|
||||
classId: string,
|
||||
): ClassDetailAggregate | null {
|
||||
return CLASS_DETAIL_CACHE[classId] ?? null;
|
||||
}
|
||||
|
||||
/** 查询班级课表(支持 classId 过滤) */
|
||||
export function queryClassSchedule(classId?: string): ClassScheduleItem[] {
|
||||
const allItems: ClassScheduleItem[] = [];
|
||||
for (const cls of EXTENDED_CLASSES) {
|
||||
const detail = CLASS_DETAIL_CACHE[cls.id];
|
||||
if (!detail) continue;
|
||||
if (classId && cls.id !== classId) continue;
|
||||
allItems.push(...detail.schedule);
|
||||
}
|
||||
// 按 dayOfWeek 升序,再按 period 升序
|
||||
allItems.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||
return a.period - b.period;
|
||||
});
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/** 班级详情基本信息(独立访问) */
|
||||
export function queryClassBasicInfo(classId: string): ClassDetail | null {
|
||||
return CLASS_DETAIL_CACHE[classId]?.class ?? null;
|
||||
}
|
||||
|
||||
/** 班级课表按 dayOfWeek 分组 */
|
||||
export function groupScheduleByDay(
|
||||
schedule: ClassScheduleItem[],
|
||||
): Record<number, ClassScheduleItem[]> {
|
||||
const grouped: Record<number, ClassScheduleItem[]> = {
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
5: [],
|
||||
6: [],
|
||||
7: [],
|
||||
};
|
||||
for (const item of schedule) {
|
||||
const list = grouped[item.dayOfWeek];
|
||||
if (list) list.push(item);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
218
apps/teacher-portal/src/mocks/fixtures/course-plans.ts
Normal file
218
apps/teacher-portal/src/mocks/fixtures/course-plans.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Mock 课程计划数据(course-plans 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个课程计划(draft/published/archived)
|
||||
* - 每个含 4-6 章节(含学时/目标/重难点/活动)
|
||||
* - 关联教材 + 作业入口
|
||||
*/
|
||||
|
||||
import type {
|
||||
CoursePlanItem,
|
||||
CoursePlanDetail,
|
||||
CoursePlanChapter,
|
||||
CreateCoursePlanInput,
|
||||
UpdateCoursePlanInput,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
interface CoursePlanDef {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
totalHours: number;
|
||||
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
homeworkCount: number;
|
||||
chapterCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const COURSE_PLAN_DEFS: CoursePlanDef[] = [
|
||||
{
|
||||
id: "cp-001",
|
||||
title: "高三数学上学期课程计划",
|
||||
subject: "数学",
|
||||
grade: "高中三年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "上学期",
|
||||
totalHours: 64,
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
homeworkCount: 12,
|
||||
chapterCount: 6,
|
||||
updatedAt: "2026-07-01T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-002",
|
||||
title: "高一语文上学期课程计划",
|
||||
subject: "语文",
|
||||
grade: "高中一年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "上学期",
|
||||
totalHours: 48,
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-001",
|
||||
textbookTitle: "小学语文三年级上册",
|
||||
homeworkCount: 8,
|
||||
chapterCount: 4,
|
||||
updatedAt: "2026-07-02T14:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-003",
|
||||
title: "初中英语七年级下学期课程计划",
|
||||
subject: "英语",
|
||||
grade: "初中一年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "下学期",
|
||||
totalHours: 36,
|
||||
status: "DRAFT",
|
||||
textbookId: "tb-003",
|
||||
textbookTitle: "初中英语七年级上册",
|
||||
homeworkCount: 6,
|
||||
chapterCount: 5,
|
||||
updatedAt: "2026-07-03T10:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-004",
|
||||
title: "小学数学六年级复习课程计划",
|
||||
subject: "数学",
|
||||
grade: "小学六年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "下学期",
|
||||
totalHours: 24,
|
||||
status: "ARCHIVED",
|
||||
textbookId: "tb-002",
|
||||
textbookTitle: "小学数学六年级上册",
|
||||
homeworkCount: 4,
|
||||
chapterCount: 4,
|
||||
updatedAt: "2026-06-28T09:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-005",
|
||||
title: "高三数学下学期复习冲刺",
|
||||
subject: "数学",
|
||||
grade: "高中三年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "下学期",
|
||||
totalHours: 48,
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
homeworkCount: 10,
|
||||
chapterCount: 5,
|
||||
updatedAt: "2026-07-08T13:45:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** 课程计划列表项 */
|
||||
export const mockCoursePlans: CoursePlanItem[] = COURSE_PLAN_DEFS.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
subject: p.subject,
|
||||
grade: p.grade,
|
||||
academicYear: p.academicYear,
|
||||
semester: p.semester,
|
||||
totalHours: p.totalHours,
|
||||
status: p.status,
|
||||
textbookId: p.textbookId,
|
||||
updatedAt: p.updatedAt,
|
||||
}));
|
||||
|
||||
/** 按状态筛选 */
|
||||
export function filterCoursePlans(
|
||||
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | "ALL" | null,
|
||||
): CoursePlanItem[] {
|
||||
if (!status || status === "ALL") return mockCoursePlans;
|
||||
return mockCoursePlans.filter((p) => p.status === status);
|
||||
}
|
||||
|
||||
/** 生成章节(按章节模板) */
|
||||
function generateChapters(planId: string, count: number): CoursePlanChapter[] {
|
||||
const template = [
|
||||
{ title: "第一章 导入与基础", obj: "建立学科认知框架", kp: "基础概念与运算规则" },
|
||||
{ title: "第二章 核心知识", obj: "掌握核心知识与方法", kp: "核心定理与推导" },
|
||||
{ title: "第三章 综合应用", obj: "能够解决综合问题", kp: "应用场景与解题策略" },
|
||||
{ title: "第四章 拓展提升", obj: "拓展学科思维", kp: "高阶方法与变式" },
|
||||
{ title: "第五章 复习巩固", obj: "系统化复习", kp: "知识网络与方法对比" },
|
||||
{ title: "第六章 评估反馈", obj: "阶段性评估", kp: "易错点与补强" },
|
||||
];
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const t = template[i] ?? template[0]!;
|
||||
return {
|
||||
id: `${planId}-ch-${String(i + 1).padStart(2, "0")}`,
|
||||
title: t.title,
|
||||
hours: 8 + i * 2,
|
||||
objectives: t.obj,
|
||||
keyPoints: t.kp,
|
||||
activities: `讲授 + 小组讨论 + 课堂练习 + 课后作业`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询课程计划详情 */
|
||||
export function findCoursePlanDetail(id: string): CoursePlanDetail | null {
|
||||
const found = COURSE_PLAN_DEFS.find((p) => p.id === id);
|
||||
if (!found) return null;
|
||||
return {
|
||||
id: found.id,
|
||||
title: found.title,
|
||||
subject: found.subject,
|
||||
grade: found.grade,
|
||||
academicYear: found.academicYear,
|
||||
semester: found.semester,
|
||||
totalHours: found.totalHours,
|
||||
status: found.status,
|
||||
textbookId: found.textbookId,
|
||||
textbookTitle: found.textbookTitle,
|
||||
homeworkCount: found.homeworkCount,
|
||||
chapters: generateChapters(found.id, found.chapterCount),
|
||||
updatedAt: found.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建课程计划 */
|
||||
export function createMockCoursePlan(
|
||||
input: CreateCoursePlanInput,
|
||||
): CoursePlanItem {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `cp-${String(Date.now()).slice(-6)}`,
|
||||
title: input.title,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
academicYear: input.academicYear,
|
||||
semester: input.semester,
|
||||
totalHours: input.totalHours,
|
||||
status: "DRAFT",
|
||||
textbookId: input.textbookId ?? null,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新课程计划 */
|
||||
export function updateMockCoursePlan(
|
||||
input: UpdateCoursePlanInput,
|
||||
): CoursePlanItem | null {
|
||||
const found = COURSE_PLAN_DEFS.find((p) => p.id === input.id);
|
||||
if (!found) return null;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: found.id,
|
||||
title: input.title ?? found.title,
|
||||
subject: found.subject,
|
||||
grade: found.grade,
|
||||
academicYear: found.academicYear,
|
||||
semester: found.semester,
|
||||
totalHours: found.totalHours,
|
||||
status: input.status ?? found.status,
|
||||
textbookId: found.textbookId,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
233
apps/teacher-portal/src/mocks/fixtures/diagnostic.ts
Normal file
233
apps/teacher-portal/src/mocks/fixtures/diagnostic.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Mock 诊断数据(diagnostic 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个班级诊断(每班 10 知识点掌握度 + 学生排名)
|
||||
* - 5 个学生诊断(雷达图 + 班级对比 + 报告列表)
|
||||
* - 诊断报告列表(按 type + status 筛选)
|
||||
*/
|
||||
|
||||
import type {
|
||||
DiagnosticReportItem,
|
||||
DiagnosticReportType,
|
||||
DiagnosticReportStatus,
|
||||
ClassDiagnostic,
|
||||
StudentDiagnostic,
|
||||
DiagnosticKpMastery,
|
||||
DiagnosticStudentRank,
|
||||
DiagnosticRadarPoint,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 5 个班级(复用 grades-extended 的 class id 保持一致) */
|
||||
export const DIAGNOSTIC_CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
|
||||
] as const;
|
||||
|
||||
/** 10 个知识点(数学/语文混合) */
|
||||
const KNOWLEDGE_POINTS = [
|
||||
"函数与导数",
|
||||
"数列",
|
||||
"立体几何",
|
||||
"解析几何",
|
||||
"概率统计",
|
||||
"实数运算",
|
||||
"文言文基础",
|
||||
"现代文阅读",
|
||||
"古诗词鉴赏",
|
||||
"议论文写作",
|
||||
] as const;
|
||||
|
||||
/** 班级平均基准(用于确定性 mock) */
|
||||
const CLASS_BASE: Record<string, number> = {
|
||||
"550e8400-e29b-41d4-a716-446655440010": 78,
|
||||
"550e8400-e29b-41d4-a716-446655440011": 82,
|
||||
"550e8400-e29b-41d4-a716-446655440012": 75,
|
||||
"550e8400-e29b-41d4-a716-446655440013": 80,
|
||||
"550e8400-e29b-41d4-a716-446655440014": 85,
|
||||
};
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? (sorted[mid] ?? 0)
|
||||
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
||||
}
|
||||
|
||||
/** 诊断报告列表 mock(12 条) */
|
||||
const REPORT_DEFS: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: DiagnosticReportType;
|
||||
targetType: string;
|
||||
status: DiagnosticReportStatus;
|
||||
createdAt: string;
|
||||
}> = [
|
||||
{ id: "dr-001", title: "高三(1)班 期中诊断报告", type: "CLASS", targetType: "高三(1)班", status: "PUBLISHED", createdAt: "2026-07-01T09:00:00Z" },
|
||||
{ id: "dr-002", title: "学生01 个人诊断报告", type: "INDIVIDUAL", targetType: "学生01", status: "PUBLISHED", createdAt: "2026-07-02T10:30:00Z" },
|
||||
{ id: "dr-003", title: "高三年级 期中诊断", type: "GRADE", targetType: "高三年级", status: "PUBLISHED", createdAt: "2026-07-03T14:15:00Z" },
|
||||
{ id: "dr-004", title: "高三(2)班 月考诊断", type: "CLASS", targetType: "高三(2)班", status: "DRAFT", createdAt: "2026-07-04T11:00:00Z" },
|
||||
{ id: "dr-005", title: "学生02 个人诊断", type: "INDIVIDUAL", targetType: "学生02", status: "DRAFT", createdAt: "2026-07-05T08:45:00Z" },
|
||||
{ id: "dr-006", title: "高三(3)班 单元诊断", type: "CLASS", targetType: "高三(3)班", status: "ARCHIVED", createdAt: "2026-06-28T16:20:00Z" },
|
||||
{ id: "dr-007", title: "学生03 个人诊断", type: "INDIVIDUAL", targetType: "学生03", status: "PUBLISHED", createdAt: "2026-07-06T09:00:00Z" },
|
||||
{ id: "dr-008", title: "高三年级 期末诊断", type: "GRADE", targetType: "高三年级", status: "DRAFT", createdAt: "2026-07-07T13:30:00Z" },
|
||||
{ id: "dr-009", title: "高三(4)班 月考诊断", type: "CLASS", targetType: "高三(4)班", status: "PUBLISHED", createdAt: "2026-07-08T10:15:00Z" },
|
||||
{ id: "dr-010", title: "学生04 个人诊断", type: "INDIVIDUAL", targetType: "学生04", status: "ARCHIVED", createdAt: "2026-06-30T15:00:00Z" },
|
||||
{ id: "dr-011", title: "高三(5)班 期中诊断", type: "CLASS", targetType: "高三(5)班", status: "PUBLISHED", createdAt: "2026-07-09T11:30:00Z" },
|
||||
{ id: "dr-012", title: "学生05 个人诊断", type: "INDIVIDUAL", targetType: "学生05", status: "DRAFT", createdAt: "2026-07-10T14:00:00Z" },
|
||||
];
|
||||
|
||||
/** 按类型 + 状态筛选诊断报告 */
|
||||
export function filterDiagnosticReports(
|
||||
type?: DiagnosticReportType | null,
|
||||
status?: DiagnosticReportStatus | null,
|
||||
): DiagnosticReportItem[] {
|
||||
return REPORT_DEFS.filter((r) => {
|
||||
if (type && r.type !== type) return false;
|
||||
if (status && r.status !== status) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级知识点掌握度(10 知识点) */
|
||||
function generateKpMastery(classId: string): DiagnosticKpMastery[] {
|
||||
const base = CLASS_BASE[classId] ?? 78;
|
||||
return KNOWLEDGE_POINTS.map((kp, i) => {
|
||||
const seed = strToSeed(`${classId}-kp-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const avg = Math.max(30, Math.min(98, base + delta));
|
||||
// 模拟 20 学生掌握度分布
|
||||
const studentCount = 20;
|
||||
const mastered = Math.round(studentCount * (avg / 100));
|
||||
const weak = Math.max(0, Math.round(studentCount * ((100 - avg) / 100) * 0.4));
|
||||
const partial = Math.max(0, studentCount - mastered - weak);
|
||||
// 中位数:基于 avg 的稳定 mock
|
||||
const med = Math.max(30, Math.min(98, avg - 2 + (i % 3) - 1));
|
||||
return {
|
||||
knowledgePoint: kp,
|
||||
avg,
|
||||
median: med,
|
||||
studentCount,
|
||||
distribution: { mastered, partial, weak },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级学生排名(20 学生) */
|
||||
function generateRankings(classId: string): DiagnosticStudentRank[] {
|
||||
const base = CLASS_BASE[classId] ?? 78;
|
||||
const ranks: DiagnosticStudentRank[] = [];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const seed = strToSeed(`${classId}-rank-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const masteryAvg = Math.max(30, Math.min(98, base + delta));
|
||||
ranks.push({
|
||||
rank: i,
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
masteryAvg,
|
||||
});
|
||||
}
|
||||
ranks.sort((a, b) => b.masteryAvg - a.masteryAvg);
|
||||
return ranks.map((r, i) => ({ ...r, rank: i + 1 }));
|
||||
}
|
||||
|
||||
/** 按班级 ID 查询班级诊断 */
|
||||
export function findClassDiagnostic(classId: string): ClassDiagnostic | null {
|
||||
const cls = DIAGNOSTIC_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return null;
|
||||
const kpMastery = generateKpMastery(classId);
|
||||
// 取前 5 知识点作为顶部摘要
|
||||
const summary = kpMastery.slice(0, 5).map((k) => ({
|
||||
knowledgePoint: k.knowledgePoint,
|
||||
avg: k.avg,
|
||||
}));
|
||||
return {
|
||||
classId,
|
||||
className: cls.name,
|
||||
studentCount: 20,
|
||||
summary,
|
||||
kpMastery,
|
||||
rankings: generateRankings(classId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 5 个学生诊断 */
|
||||
const STUDENT_DIAGNOSTIC_DEFS: Array<{ id: string; name: string }> = [
|
||||
{ id: "stu-001", name: "学生01" },
|
||||
{ id: "stu-002", name: "学生02" },
|
||||
{ id: "stu-003", name: "学生03" },
|
||||
{ id: "stu-004", name: "学生04" },
|
||||
{ id: "stu-005", name: "学生05" },
|
||||
];
|
||||
|
||||
/** 生成学生雷达图(6 轴) */
|
||||
function generateRadar(studentId: string): DiagnosticRadarPoint[] {
|
||||
const axes = ["函数", "数列", "几何", "概率", "统计", "导数"];
|
||||
const classId = "550e8400-e29b-41d4-a716-446655440010";
|
||||
const classBase = CLASS_BASE[classId] ?? 78;
|
||||
return axes.map((axis, i) => {
|
||||
const seed = strToSeed(`${studentId}-radar-${i}`);
|
||||
const studentDelta = Math.round((seededRandom(seed) - 0.5) * 40);
|
||||
const classDelta = Math.round((seededRandom(seed + 1) - 0.5) * 10);
|
||||
return {
|
||||
axis,
|
||||
value: Math.max(20, Math.min(98, classBase + studentDelta)),
|
||||
classAvg: Math.max(40, Math.min(95, classBase + classDelta)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按学生 ID 查询学生诊断 */
|
||||
export function findStudentDiagnostic(
|
||||
studentId: string,
|
||||
): StudentDiagnostic | null {
|
||||
const found = STUDENT_DIAGNOSTIC_DEFS.find((s) => s.id === studentId);
|
||||
const name = found?.name ?? `学生${studentId.slice(-2)}`;
|
||||
return {
|
||||
studentId,
|
||||
studentName: name,
|
||||
radar: generateRadar(studentId),
|
||||
reports: [
|
||||
{ id: `dr-${studentId}-1`, title: `${name} 期中诊断`, createdAt: "2026-07-01T09:00:00Z", status: "PUBLISHED" },
|
||||
{ id: `dr-${studentId}-2`, title: `${name} 月考诊断`, createdAt: "2026-06-15T10:30:00Z", status: "ARCHIVED" },
|
||||
{ id: `dr-${studentId}-3`, title: `${name} 单元诊断`, createdAt: "2026-05-20T14:00:00Z", status: "ARCHIVED" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 班级对比(5 班级 × 平均掌握度) */
|
||||
export function getClassDiagnosticComparison(): Array<{
|
||||
classId: string;
|
||||
className: string;
|
||||
avgMastery: number;
|
||||
}> {
|
||||
return DIAGNOSTIC_CLASSES.map((c) => {
|
||||
const base = CLASS_BASE[c.id] ?? 78;
|
||||
return { classId: c.id, className: c.name, avgMastery: base };
|
||||
});
|
||||
}
|
||||
|
||||
/** 中位数工具导出(便于 handler 使用) */
|
||||
export { median };
|
||||
298
apps/teacher-portal/src/mocks/fixtures/elective.ts
Normal file
298
apps/teacher-portal/src/mocks/fixtures/elective.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Mock 选修课数据(P7-advanced:选修课模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 10 个选修课(覆盖 draft/open/closed/cancelled 状态)
|
||||
* - 每个含学生选课列表
|
||||
* - 按 ID 查找详情 + CRUD mock 函数
|
||||
*/
|
||||
|
||||
import type {
|
||||
ElectiveCourseItem,
|
||||
ElectiveCourseDetail,
|
||||
ElectiveStudent,
|
||||
ElectiveCourseStatus,
|
||||
CreateElectiveCourseInput,
|
||||
UpdateElectiveCourseInput,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 当前教师 ID(mock:登录用户) */
|
||||
export const CURRENT_TEACHER_ID = "tch-001";
|
||||
export const CURRENT_TEACHER_NAME = "王老师";
|
||||
|
||||
/** 10 个选修课定义(覆盖 4 种状态) */
|
||||
const ELECTIVE_COURSE_DEFS: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
capacity: number;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
semester: string;
|
||||
status: ElectiveCourseStatus;
|
||||
description: string | null;
|
||||
enrolledCount: number;
|
||||
}> = [
|
||||
{
|
||||
id: "ele-001",
|
||||
title: "趣味数学建模",
|
||||
subject: "数学",
|
||||
grade: "高一",
|
||||
capacity: 40,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: "通过实际案例学习数学建模方法",
|
||||
enrolledCount: 32,
|
||||
},
|
||||
{
|
||||
id: "ele-002",
|
||||
title: "现代文学赏析",
|
||||
subject: "语文",
|
||||
grade: "高二",
|
||||
capacity: 35,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: "阅读分析当代经典文学作品",
|
||||
enrolledCount: 28,
|
||||
},
|
||||
{
|
||||
id: "ele-003",
|
||||
title: "物理实验探究",
|
||||
subject: "物理",
|
||||
grade: "高一",
|
||||
capacity: 30,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "closed",
|
||||
description: "动手实验,探究物理规律",
|
||||
enrolledCount: 30,
|
||||
},
|
||||
{
|
||||
id: "ele-004",
|
||||
title: "英语口语训练",
|
||||
subject: "英语",
|
||||
grade: "高二",
|
||||
capacity: 25,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: null,
|
||||
enrolledCount: 20,
|
||||
},
|
||||
{
|
||||
id: "ele-005",
|
||||
title: "化学与生活",
|
||||
subject: "化学",
|
||||
grade: "高一",
|
||||
capacity: 40,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "draft",
|
||||
description: "了解化学在日常生活中的应用",
|
||||
enrolledCount: 0,
|
||||
},
|
||||
{
|
||||
id: "ele-006",
|
||||
title: "生物多样性",
|
||||
subject: "生物",
|
||||
grade: "高二",
|
||||
capacity: 35,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "draft",
|
||||
description: "探索生态系统与物种保护",
|
||||
enrolledCount: 0,
|
||||
},
|
||||
{
|
||||
id: "ele-007",
|
||||
title: "历史人物评说",
|
||||
subject: "历史",
|
||||
grade: "高二",
|
||||
capacity: 50,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2025秋季",
|
||||
status: "closed",
|
||||
description: "评析历史人物的功过是非",
|
||||
enrolledCount: 48,
|
||||
},
|
||||
{
|
||||
id: "ele-008",
|
||||
title: "地理与环境",
|
||||
subject: "地理",
|
||||
grade: "高一",
|
||||
capacity: 40,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2025秋季",
|
||||
status: "cancelled",
|
||||
description: "已取消:教师调动",
|
||||
enrolledCount: 0,
|
||||
},
|
||||
{
|
||||
id: "ele-009",
|
||||
title: "信息技术基础",
|
||||
subject: "信息技术",
|
||||
grade: "高一",
|
||||
capacity: 60,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: "学习编程基础与算法思维",
|
||||
enrolledCount: 55,
|
||||
},
|
||||
{
|
||||
id: "ele-010",
|
||||
title: "美术鉴赏",
|
||||
subject: "美术",
|
||||
grade: "高二",
|
||||
capacity: 30,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "closed",
|
||||
description: "鉴赏中外美术名作",
|
||||
enrolledCount: 30,
|
||||
},
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 生成单个选修课的学生列表(按 enrolledCount) */
|
||||
function generateStudents(
|
||||
courseId: string,
|
||||
enrolledCount: number,
|
||||
): ElectiveStudent[] {
|
||||
const list: ElectiveStudent[] = [];
|
||||
const now = Date.now();
|
||||
for (let i = 1; i <= enrolledCount; i++) {
|
||||
const seed = strToSeed(`${courseId}-stu-${i}`);
|
||||
const isCancelled = seededRandom(seed) > 0.9;
|
||||
list.push({
|
||||
studentId: `stu-${courseId}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(4, "0")}`,
|
||||
enrolledAt: new Date(now - i * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: isCancelled ? "cancelled" : "enrolled",
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 生成选修课列表(可按 status / subject 过滤) */
|
||||
export function generateElectiveCourses(
|
||||
status?: string,
|
||||
subject?: string,
|
||||
): ElectiveCourseItem[] {
|
||||
return ELECTIVE_COURSE_DEFS.filter((c) => {
|
||||
if (status && c.status !== status) return false;
|
||||
if (subject && c.subject !== subject) return false;
|
||||
return true;
|
||||
}).map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
subject: c.subject,
|
||||
grade: c.grade,
|
||||
capacity: c.capacity,
|
||||
enrolledCount: c.enrolledCount,
|
||||
teacherId: c.teacherId,
|
||||
teacherName: c.teacherName,
|
||||
semester: c.semester,
|
||||
status: c.status,
|
||||
description: c.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按 ID 查找选修课详情(含学生列表) */
|
||||
export function findElectiveCourseDetail(
|
||||
id: string,
|
||||
): ElectiveCourseDetail | null {
|
||||
const def = ELECTIVE_COURSE_DEFS.find((c) => c.id === id);
|
||||
if (!def) return null;
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: def.id,
|
||||
title: def.title,
|
||||
subject: def.subject,
|
||||
grade: def.grade,
|
||||
capacity: def.capacity,
|
||||
enrolledCount: def.enrolledCount,
|
||||
teacherId: def.teacherId,
|
||||
teacherName: def.teacherName,
|
||||
semester: def.semester,
|
||||
status: def.status,
|
||||
description: def.description,
|
||||
students: generateStudents(def.id, def.enrolledCount),
|
||||
createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建选修课(mock:返回新 ID + draft 状态) */
|
||||
export function createMockElectiveCourse(
|
||||
_input: CreateElectiveCourseInput,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
const randomId = `ele-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return {
|
||||
id: randomId,
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新选修课(mock:返回更新时间) */
|
||||
export function updateMockElectiveCourse(
|
||||
input: UpdateElectiveCourseInput,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
return {
|
||||
id: input.id,
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 发布选修课(mock:返回 open 状态) */
|
||||
export function publishMockElectiveCourse(
|
||||
id: string,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
return {
|
||||
id,
|
||||
status: "open",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 取消选修课(mock:返回 cancelled 状态) */
|
||||
export function cancelMockElectiveCourse(
|
||||
id: string,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
return {
|
||||
id,
|
||||
status: "cancelled",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
182
apps/teacher-portal/src/mocks/fixtures/error-book.ts
Normal file
182
apps/teacher-portal/src/mocks/fixtures/error-book.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Mock 错题本数据(error-book 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 班级 × 6 学科 错题统计
|
||||
* - 章节薄弱度(5 学科 × 10 章节)
|
||||
* - 知识点薄弱度(5 学科 × 20 知识点)
|
||||
* - Top10 高频错题
|
||||
* - 学生错题分组(前 5 学生)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ErrorBook,
|
||||
ErrorBookSummary,
|
||||
ErrorBookClassComparison,
|
||||
ErrorBookChapterWeakness,
|
||||
ErrorBookKpWeakness,
|
||||
ErrorBookStudentGroup,
|
||||
ErrorBookTopQuestion,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 5 个班级 */
|
||||
const CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", base: 120 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", base: 110 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", base: 135 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", base: 105 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", base: 95 },
|
||||
] as const;
|
||||
|
||||
/** 6 学科(错题本用) */
|
||||
export const ERROR_BOOK_SUBJECTS = [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
] as const;
|
||||
|
||||
/** 章节模板(每学科 10 章节) */
|
||||
const CHAPTER_TEMPLATES: Record<string, string[]> = {
|
||||
语文: ["现代文阅读", "古诗词鉴赏", "文言文阅读", "成语运用", "病句辨析", "论述类文本", "实用类文本", "语言运用", "议论文写作", "记叙文写作"],
|
||||
数学: ["集合与逻辑", "函数与导数", "三角函数", "数列", "立体几何", "解析几何", "概率统计", "不等式", "复数", "向量"],
|
||||
英语: ["听力", "阅读理解", "完形填空", "语法填空", "短文改错", "书面表达", "七选五", "词汇运用", "句型转换", "翻译"],
|
||||
物理: ["力学", "运动学", "动力学", "能量守恒", "动量", "电学", "磁学", "光学", "热学", "近代物理"],
|
||||
化学: ["化学键", "化学反应", "化学平衡", "电离", "水解", "氧化还原", "元素周期", "有机化学", "实验化学", "计算化学"],
|
||||
生物: ["细胞", "遗传", "变异", "进化", "生态", "植物激素", "人体生理", "免疫", "微生物", "生物技术"],
|
||||
};
|
||||
|
||||
/** 知识点薄弱度模板(每学科 10 知识点,雷达图用) */
|
||||
const KP_TEMPLATES: Record<string, string[]> = {
|
||||
语文: ["主旨概括", "细节理解", "推理判断", "词义猜测", "论证分析", "情感把握", "手法鉴赏", "结构梳理", "语言品味", "文化常识"],
|
||||
数学: ["函数定义域", "单调性", "奇偶性", "导数运算", "极值最值", "数列求和", "向量运算", "概率计算", "几何证明", "参数方程"],
|
||||
英语: ["主旨题", "细节题", "推理题", "词义题", "作者态度", "完形填空", "语法结构", "词汇辨析", "句型转换", "书面表达"],
|
||||
物理: ["受力分析", "运动方程", "能量守恒", "动量守恒", "电场", "磁场", "电磁感应", "光学成像", "热学定律", "原子结构"],
|
||||
化学: ["化学键类型", "反应速率", "化学平衡", "电离平衡", "水解平衡", "氧化剂", "还原剂", "元素周期", "官能团", "实验操作"],
|
||||
生物: ["细胞结构", "光合作用", "细胞呼吸", "有丝分裂", "减数分裂", "遗传规律", "基因突变", "生态系统", "激素调节", "免疫机制"],
|
||||
};
|
||||
|
||||
/** 班级对比 */
|
||||
export function getClassErrorComparison(
|
||||
subject: string,
|
||||
): ErrorBookClassComparison[] {
|
||||
return CLASSES.map((c) => {
|
||||
// 学科偏移:理科偏多错题
|
||||
const subjOffset = subject === "数学" || subject === "物理" ? 20 : 0;
|
||||
const subjOffset2 = subject === "语文" || subject === "英语" ? -10 : 0;
|
||||
return {
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
errorCount: c.base + subjOffset + subjOffset2,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 章节薄弱度(10 章节) */
|
||||
export function getChapterWeakness(
|
||||
subject: string,
|
||||
): ErrorBookChapterWeakness[] {
|
||||
const chapters = CHAPTER_TEMPLATES[subject] ?? CHAPTER_TEMPLATES["数学"]!;
|
||||
return chapters.map((ch, i) => ({
|
||||
chapter: ch,
|
||||
errorCount: 20 + i * 5 + ((subject.length + i) % 7) * 3,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 知识点薄弱度(10 知识点,雷达图用) */
|
||||
export function getKpWeakness(subject: string): ErrorBookKpWeakness[] {
|
||||
const kps = KP_TEMPLATES[subject] ?? KP_TEMPLATES["数学"]!;
|
||||
return kps.map((kp, i) => ({
|
||||
knowledgePoint: kp,
|
||||
errorRate: Math.max(20, Math.min(85, 30 + i * 4 + ((subject.length + i) % 5) * 3)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 学生错题分组(前 5 学生) */
|
||||
export function getStudentGroups(
|
||||
subject: string,
|
||||
): ErrorBookStudentGroup[] {
|
||||
const students = [
|
||||
{ id: "stu-001", name: "学生01", base: 28 },
|
||||
{ id: "stu-002", name: "学生02", base: 25 },
|
||||
{ id: "stu-003", name: "学生03", base: 22 },
|
||||
{ id: "stu-004", name: "学生04", base: 18 },
|
||||
{ id: "stu-005", name: "学生05", base: 15 },
|
||||
];
|
||||
return students.map((s) => {
|
||||
const subjOffset = subject === "数学" ? 5 : 0;
|
||||
return {
|
||||
studentId: s.id,
|
||||
studentName: s.name,
|
||||
errorCount: s.base + subjOffset,
|
||||
topErrors: [
|
||||
{
|
||||
questionId: `q-${s.id}-1`,
|
||||
content: `${subject}高频错题示例:${subject}基础知识应用题(题干较长时显示前 100 字)`,
|
||||
errorRate: 0.75,
|
||||
},
|
||||
{
|
||||
questionId: `q-${s.id}-2`,
|
||||
content: `${subject}中档题:${subject}综合题分析题(含图形或长文本描述)`,
|
||||
errorRate: 0.62,
|
||||
},
|
||||
{
|
||||
questionId: `q-${s.id}-3`,
|
||||
content: `${subject}概念辨析题:${subject}易错概念对比题`,
|
||||
errorRate: 0.55,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Top10 高频错题 */
|
||||
export function getTopQuestions(subject: string): ErrorBookTopQuestion[] {
|
||||
const items: ErrorBookTopQuestion[] = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
items.push({
|
||||
questionId: `q-top-${subject}-${i}`,
|
||||
content: `【${subject}·高频】第 ${i} 题:${subject}综合应用题示例(题干内容较长,此处显示前 100 字以便预览,完整题干在详情页展示)`,
|
||||
errorRate: 0.85 - i * 0.05,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 完整错题本(聚合查询) */
|
||||
export function getErrorBook(
|
||||
subject: string,
|
||||
classId?: string | null,
|
||||
): ErrorBook {
|
||||
const classComparison = getClassErrorComparison(subject);
|
||||
const targetClass = classId
|
||||
? classComparison.find((c) => c.classId === classId)
|
||||
: null;
|
||||
const totalErrors = targetClass
|
||||
? targetClass.errorCount
|
||||
: classComparison.reduce((acc, c) => acc + c.errorCount, 0);
|
||||
|
||||
const summary: ErrorBookSummary = {
|
||||
totalErrors,
|
||||
highFreqErrors: Math.floor(totalErrors * 0.15),
|
||||
weakKpCount: 6,
|
||||
avgErrorRate: 0.42,
|
||||
trendUp: subject === "数学" || subject === "物理",
|
||||
};
|
||||
|
||||
return {
|
||||
subject,
|
||||
classId: classId ?? "",
|
||||
summary,
|
||||
classComparison,
|
||||
chapterWeakness: getChapterWeakness(subject),
|
||||
kpWeakness: getKpWeakness(subject),
|
||||
studentGroups: getStudentGroups(subject),
|
||||
topQuestions: getTopQuestions(subject),
|
||||
};
|
||||
}
|
||||
353
apps/teacher-portal/src/mocks/fixtures/exam-analytics.ts
Normal file
353
apps/teacher-portal/src/mocks/fixtures/exam-analytics.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Mock 考试分析数据(core-edu 域,P7 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 场考试 × 30 学生 × 20 题
|
||||
* - 分数分布、及格率、每题正确率、知识点掌握度、班级对比、历次趋势、学生排名
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExamAnalytics,
|
||||
ExamAnalyticsSummary,
|
||||
ExamScoreBand,
|
||||
ExamQuestionAccuracy,
|
||||
ExamKpMastery,
|
||||
ExamClassComparison,
|
||||
ExamHistoryTrend,
|
||||
ExamStudentRank,
|
||||
ExamBuildStructure,
|
||||
ExamBuildNode,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import type { QuestionItem } from "@/lib/graphql-p7-exams";
|
||||
import type { ExamDetail } from "@/lib/graphql";
|
||||
import { MOCK_QUESTIONS } from "./questions";
|
||||
import { EXTENDED_CLASSES } from "./grades-extended";
|
||||
|
||||
/** 5 场考试定义 */
|
||||
interface ExamDef {
|
||||
examId: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
examDate: string;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
const NOW = Date.now();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const EXAM_DEFS: ExamDef[] = [
|
||||
{
|
||||
examId: "exam-ana-001",
|
||||
title: "2026 春季期中考试",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "c-001",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "高三(1)班",
|
||||
examDate: new Date(NOW - 7 * DAY).toISOString(),
|
||||
totalScore: 150,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-002",
|
||||
title: "单元测试 - 函数与导数",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "c-001",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "高三(1)班",
|
||||
examDate: new Date(NOW - 14 * DAY).toISOString(),
|
||||
totalScore: 100,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-003",
|
||||
title: "月考 - 数学综合",
|
||||
classId: EXTENDED_CLASSES[1]?.id ?? "c-002",
|
||||
className: EXTENDED_CLASSES[1]?.name ?? "高三(2)班",
|
||||
examDate: new Date(NOW - 21 * DAY).toISOString(),
|
||||
totalScore: 120,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-004",
|
||||
title: "周测 - 解析几何",
|
||||
classId: EXTENDED_CLASSES[2]?.id ?? "c-003",
|
||||
className: EXTENDED_CLASSES[2]?.name ?? "高三(3)班",
|
||||
examDate: new Date(NOW - 3 * DAY).toISOString(),
|
||||
totalScore: 80,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-005",
|
||||
title: "阶段测验 - 概率统计",
|
||||
classId: EXTENDED_CLASSES[3]?.id ?? "c-004",
|
||||
className: EXTENDED_CLASSES[3]?.name ?? "高三(4)班",
|
||||
examDate: new Date(NOW - 1 * DAY).toISOString(),
|
||||
totalScore: 100,
|
||||
},
|
||||
];
|
||||
|
||||
/** 确定性伪随机(与 grades-extended 一致) */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 计算等级 */
|
||||
function scoreToLevel(score: number, total: number): ExamStudentRank["level"] {
|
||||
const ratio = total > 0 ? score / total : 0;
|
||||
if (ratio >= 0.9) return "A";
|
||||
if (ratio >= 0.8) return "B";
|
||||
if (ratio >= 0.7) return "C";
|
||||
if (ratio >= 0.6) return "D";
|
||||
return "E";
|
||||
}
|
||||
|
||||
/** 生成 30 个学生成绩(按考试 ID 稳定) */
|
||||
function generateStudentScores(
|
||||
examId: string,
|
||||
totalScore: number,
|
||||
): Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
}> {
|
||||
const base = totalScore * 0.7; // 均分约 70%
|
||||
const result: Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
}> = [];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const seed = strToSeed(`${examId}-stu-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.4) * totalScore * 0.4);
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.min(totalScore, Math.round(base + delta)),
|
||||
);
|
||||
result.push({
|
||||
studentId: `stu-${examId.slice(-3)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
totalScore: score,
|
||||
});
|
||||
}
|
||||
result.sort((a, b) => b.totalScore - a.totalScore);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 生成 5 段分数分布 */
|
||||
function generateDistribution(
|
||||
scores: number[],
|
||||
total: number,
|
||||
): ExamScoreBand[] {
|
||||
const bands: ExamScoreBand[] = [
|
||||
{ label: "0-59", min: 0, max: 59, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
];
|
||||
// 按 0-100 百分比归一化(total != 100 时换算)
|
||||
scores.forEach((s) => {
|
||||
const pct = total > 0 ? (s / total) * 100 : 0;
|
||||
if (pct < 60) bands[0]!.count += 1;
|
||||
else if (pct < 70) bands[1]!.count += 1;
|
||||
else if (pct < 80) bands[2]!.count += 1;
|
||||
else if (pct < 90) bands[3]!.count += 1;
|
||||
else bands[4]!.count += 1;
|
||||
});
|
||||
return bands;
|
||||
}
|
||||
|
||||
/** 选取前 20 题(不足 20 则循环补齐) */
|
||||
function pick20Questions(): QuestionItem[] {
|
||||
const items: QuestionItem[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const q = MOCK_QUESTIONS[i % MOCK_QUESTIONS.length];
|
||||
if (q) items.push(q);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const EXAM_QUESTIONS = pick20Questions();
|
||||
|
||||
/** 生成每题正确率 */
|
||||
function generateQuestionAccuracy(
|
||||
examId: string,
|
||||
): ExamQuestionAccuracy[] {
|
||||
return EXAM_QUESTIONS.map((q, i) => {
|
||||
const seed = strToSeed(`${examId}-qa-${i}`);
|
||||
const correctRate = Math.round(40 + seededRandom(seed) * 55); // 40-95
|
||||
const avgScore = Math.round((q.score * correctRate) / 100);
|
||||
return {
|
||||
questionId: q.questionId,
|
||||
questionTitle: q.content.slice(0, 40),
|
||||
order: i + 1,
|
||||
correctRate,
|
||||
avgScore,
|
||||
maxScore: q.score,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 5 个知识点(用于雷达图) */
|
||||
const KP_NAMES = [
|
||||
"函数与导数",
|
||||
"数列",
|
||||
"立体几何",
|
||||
"解析几何",
|
||||
"概率统计",
|
||||
];
|
||||
|
||||
/** 生成知识点掌握度 */
|
||||
function generateKpMastery(examId: string): ExamKpMastery[] {
|
||||
return KP_NAMES.map((name, i) => {
|
||||
const seed = strToSeed(`${examId}-kp-${i}`);
|
||||
const mastery = Math.round(50 + seededRandom(seed) * 45); // 50-95
|
||||
return { knowledgePoint: name, mastery };
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级对比 */
|
||||
function generateClassComparison(
|
||||
targetClassId: string,
|
||||
avg: number,
|
||||
): ExamClassComparison[] {
|
||||
return EXTENDED_CLASSES.map((c) => {
|
||||
const seed = strToSeed(`cc-${c.id}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 16);
|
||||
const classAvg = Math.max(0, Math.round(avg + delta));
|
||||
return {
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
avg: classAvg,
|
||||
significant: c.id === targetClassId ? false : Math.abs(delta) >= 7,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成历次考试趋势 */
|
||||
function generateHistoryTrend(): ExamHistoryTrend[] {
|
||||
return EXAM_DEFS.map((e) => {
|
||||
const seed = strToSeed(`ht-${e.examId}`);
|
||||
const avg = Math.round(60 + seededRandom(seed) * 25); // 60-85
|
||||
return {
|
||||
examTitle: e.title,
|
||||
examDate: e.examDate,
|
||||
avg,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成考试分析完整数据 */
|
||||
export function generateExamAnalytics(examId: string): ExamAnalytics | null {
|
||||
const def = EXAM_DEFS.find((e) => e.examId === examId);
|
||||
if (!def) return null;
|
||||
const studentScores = generateStudentScores(examId, def.totalScore);
|
||||
const scores = studentScores.map((s) => s.totalScore);
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0);
|
||||
const avg = Math.round((sum / scores.length) * 10) / 10;
|
||||
const max = Math.max(...scores);
|
||||
const min = Math.min(...scores);
|
||||
const passCount = scores.filter(
|
||||
(s) => s / def.totalScore >= 0.6,
|
||||
).length;
|
||||
const passRate = Math.round((passCount / scores.length) * 1000) / 10;
|
||||
|
||||
const summary: ExamAnalyticsSummary = {
|
||||
expectedCount: 30,
|
||||
attendedCount: 30,
|
||||
avgScore: avg,
|
||||
maxScore: max,
|
||||
minScore: min,
|
||||
passRate,
|
||||
};
|
||||
|
||||
const distribution = generateDistribution(scores, def.totalScore);
|
||||
const questionAccuracy = generateQuestionAccuracy(examId);
|
||||
const knowledgeMastery = generateKpMastery(examId);
|
||||
const classComparison = generateClassComparison(def.classId, avg);
|
||||
const historyTrend = generateHistoryTrend();
|
||||
|
||||
const rankings: ExamStudentRank[] = studentScores.map((s, i) => ({
|
||||
rank: i + 1,
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName,
|
||||
studentNo: s.studentNo,
|
||||
totalScore: s.totalScore,
|
||||
level: scoreToLevel(s.totalScore, def.totalScore),
|
||||
}));
|
||||
|
||||
return {
|
||||
examId,
|
||||
examTitle: def.title,
|
||||
summary,
|
||||
distribution,
|
||||
questionAccuracy,
|
||||
knowledgeMastery,
|
||||
classComparison,
|
||||
historyTrend,
|
||||
rankings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成试卷结构(组卷页用)
|
||||
*
|
||||
* - 从题库取前 20 题作为候选分页
|
||||
* - 已选题目:前 4 题(默认排序)
|
||||
*/
|
||||
export function generateExamBuildStructure(
|
||||
examId: string,
|
||||
examDetail: ExamDetail | null,
|
||||
candidatePage: number,
|
||||
candidatePageSize: number,
|
||||
): ExamBuildStructure | null {
|
||||
// 默认 4 题已选,分数按 examDetail.totalScore 平均分摊
|
||||
const selectedCount = 4;
|
||||
const totalScore = examDetail?.totalScore ?? 100;
|
||||
const passScore = Math.round(totalScore * 0.6);
|
||||
const duration = examDetail?.duration ?? 120;
|
||||
const title = examDetail?.title ?? "未命名考试";
|
||||
|
||||
// 取前 4 道题作为已选
|
||||
const picked = EXAM_QUESTIONS.slice(0, selectedCount);
|
||||
const perScore = Math.floor(totalScore / selectedCount);
|
||||
const remainder = totalScore - perScore * selectedCount;
|
||||
const selected: ExamBuildNode[] = picked.map((q, i) => ({
|
||||
questionId: q.questionId,
|
||||
score: perScore + (i === 0 ? remainder : 0),
|
||||
sortOrder: i + 1,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
}));
|
||||
|
||||
// 题库候选分页(前 20 题)
|
||||
const start = (candidatePage - 1) * candidatePageSize;
|
||||
const candidatesItems = EXAM_QUESTIONS.slice(start, start + candidatePageSize);
|
||||
const candidates = {
|
||||
items: candidatesItems,
|
||||
total: EXAM_QUESTIONS.length,
|
||||
page: candidatePage,
|
||||
pageSize: candidatePageSize,
|
||||
};
|
||||
|
||||
return {
|
||||
examId,
|
||||
title,
|
||||
totalScore,
|
||||
passScore,
|
||||
duration,
|
||||
selected,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有 mock 考试定义(外部可消费) */
|
||||
export const MOCK_EXAM_ANALYTICS_DEFS = EXAM_DEFS;
|
||||
@@ -4,7 +4,7 @@
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { ExamItem } from "@/lib/graphql";
|
||||
import type { ExamItem, ExamDetail, ExamQuestion, CreateExamInput } from "@/lib/graphql";
|
||||
|
||||
export function generateMockExams(classId: string): ExamItem[] {
|
||||
const now = Date.now();
|
||||
@@ -41,3 +41,70 @@ export function generateMockExams(classId: string): ExamItem[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 examId 反查 classId(mock 数据 ID 规约:${classId}-exam-XXX)
|
||||
* 兜底:未匹配时返回 "mock-class"。
|
||||
*/
|
||||
function extractClassIdFromExamId(examId: string): string {
|
||||
const match = examId.match(/^(.+)-exam-\d+$/);
|
||||
return match?.[1] ?? "mock-class";
|
||||
}
|
||||
|
||||
/** 生成 mock 题目列表(固定 4 题) */
|
||||
function generateMockQuestions(examId: string): ExamQuestion[] {
|
||||
const defs: Array<{
|
||||
type: ExamQuestion["type"];
|
||||
title: string;
|
||||
score: number;
|
||||
}> = [
|
||||
{ type: "SINGLE_CHOICE", title: "单项选择:函数的定义域", score: 20 },
|
||||
{ type: "MULTIPLE_CHOICE", title: "多项选择:导数的几何意义", score: 20 },
|
||||
{ type: "SHORT_ANSWER", title: "简答:求解方程的实根", score: 30 },
|
||||
{ type: "ESSAY", title: "论述:函数与不等式的综合应用", score: 50 },
|
||||
];
|
||||
return defs.map((d, i) => ({
|
||||
id: `${examId}-q-${String(i + 1).padStart(3, "0")}`,
|
||||
examId,
|
||||
title: d.title,
|
||||
type: d.type,
|
||||
score: d.score,
|
||||
order: i + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按考试 ID 查询详情(含题目列表,MSW mock) */
|
||||
export function findMockExamDetail(examId: string): ExamDetail {
|
||||
const classId = extractClassIdFromExamId(examId);
|
||||
const all = generateMockExams(classId);
|
||||
const matched = all.find((e) => e.id === examId);
|
||||
const base = matched ?? {
|
||||
id: examId,
|
||||
classId,
|
||||
title: "考试详情(mock 兜底)",
|
||||
description: "Mock 数据兜底:未匹配到列表中的考试",
|
||||
examDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
duration: 120,
|
||||
totalScore: 150,
|
||||
status: "DRAFT" as const,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
questions: generateMockQuestions(examId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建新考试:基于 input 生成 ExamItem(含随机 ID) */
|
||||
export function createMockExam(input: CreateExamInput): ExamItem {
|
||||
const randomId = Math.random().toString(36).slice(2, 10);
|
||||
return {
|
||||
id: `${input.classId}-exam-${randomId}`,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
examDate: input.examDate,
|
||||
duration: input.duration,
|
||||
totalScore: input.totalScore,
|
||||
status: "DRAFT",
|
||||
};
|
||||
}
|
||||
|
||||
403
apps/teacher-portal/src/mocks/fixtures/grades-extended.ts
Normal file
403
apps/teacher-portal/src/mocks/fixtures/grades-extended.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Mock 扩展成绩数据(P7:成绩完整子模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 班级成绩统计(5 个班级 × 6 学科)
|
||||
* - 学生成绩趋势(6 个月,3 个学生)
|
||||
* - 学科对比、班级对比、知识点掌握度
|
||||
* - 报告卡数据(5 学生 × 4 学期 × 6 学科)
|
||||
* - 成绩录入列表生成器
|
||||
*/
|
||||
|
||||
import type {
|
||||
GradeEntryItem,
|
||||
GradeStats,
|
||||
GradeRanking,
|
||||
GradeAnalytics,
|
||||
GradeTrendPoint,
|
||||
GradeDistributionBand,
|
||||
SubjectComparisonItem,
|
||||
ClassComparisonItem,
|
||||
KnowledgeMasteryItem,
|
||||
ReportCard,
|
||||
ReportCardSubject,
|
||||
SaveGradeEntriesInput,
|
||||
SaveGradeEntriesResult,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 6 个学科 */
|
||||
export const SUBJECTS = [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
] as const;
|
||||
|
||||
/** 5 个班级(含 classes.ts 现有 3 个 + 扩展 2 个) */
|
||||
export const EXTENDED_CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", gradeId: "grade-12" },
|
||||
] as const;
|
||||
|
||||
/** 6 个月标签(趋势 X 轴) */
|
||||
const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月"] as const;
|
||||
|
||||
/** 5 个知识点 */
|
||||
const KNOWLEDGE_POINTS = [
|
||||
"函数与导数",
|
||||
"数列",
|
||||
"立体几何",
|
||||
"解析几何",
|
||||
"概率统计",
|
||||
] as const;
|
||||
|
||||
/** 班级平均分基准(用于生成稳定 mock 数据) */
|
||||
const CLASS_AVG_BASE: Record<string, number> = {
|
||||
"550e8400-e29b-41d4-a716-446655440010": 78,
|
||||
"550e8400-e29b-41d4-a716-446655440011": 82,
|
||||
"550e8400-e29b-41d4-a716-446655440012": 75,
|
||||
"550e8400-e29b-41d4-a716-446655440013": 80,
|
||||
"550e8400-e29b-41d4-a716-446655440014": 85,
|
||||
};
|
||||
|
||||
/** 学科难度系数(用于在班级平均基础上调整学科均分) */
|
||||
const SUBJECT_DIFFICULTY: Record<string, number> = {
|
||||
语文: 0,
|
||||
数学: -5,
|
||||
英语: 2,
|
||||
物理: -8,
|
||||
化学: -3,
|
||||
生物: 3,
|
||||
};
|
||||
|
||||
/** 确定性伪随机(基于 seed,保证同输入同输出) */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/** 基于字符串生成 seed */
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 计算等级 */
|
||||
function scoreToLevel(score: number): GradeRanking["level"] {
|
||||
if (score >= 90) return "A";
|
||||
if (score >= 80) return "B";
|
||||
if (score >= 70) return "C";
|
||||
if (score >= 60) return "D";
|
||||
return "E";
|
||||
}
|
||||
|
||||
/** 计算中位数 */
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? (sorted[mid] ?? 0)
|
||||
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
||||
}
|
||||
|
||||
/** 计算标准差 */
|
||||
function stdDev(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const avg = values.reduce((acc, v) => acc + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length;
|
||||
return Math.round(Math.sqrt(variance) * 10) / 10;
|
||||
}
|
||||
|
||||
/** 生成班级学生成绩排名(30 名学生) */
|
||||
function generateClassRankings(
|
||||
classId: string,
|
||||
subject: string,
|
||||
): Array<{ studentId: string; studentName: string; studentNo: string; totalScore: number }> {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
const adj = SUBJECT_DIFFICULTY[subject] ?? 0;
|
||||
const target = base + adj;
|
||||
const students: Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
}> = [];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const seed = strToSeed(`${classId}-${subject}-${i}`);
|
||||
// 围绕 target 上下波动 ±15
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const score = Math.max(0, Math.min(100, target + delta));
|
||||
const studentNo = `2026${String(i).padStart(3, "0")}`;
|
||||
students.push({
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo,
|
||||
totalScore: score,
|
||||
});
|
||||
}
|
||||
students.sort((a, b) => b.totalScore - a.totalScore);
|
||||
return students;
|
||||
}
|
||||
|
||||
/** 按班级 + 学科生成成绩统计 */
|
||||
export function generateGradeStats(
|
||||
classId: string,
|
||||
subject: string,
|
||||
): GradeStats {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
const className = cls?.name ?? "未知班级";
|
||||
const ranked = generateClassRankings(classId, subject);
|
||||
const scores = ranked.map((s) => s.totalScore);
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0);
|
||||
const avg = Math.round((sum / scores.length) * 10) / 10;
|
||||
const passCount = scores.filter((s) => s >= 60).length;
|
||||
const rankings: GradeRanking[] = ranked.map((s, i) => ({
|
||||
rank: i + 1,
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName,
|
||||
studentNo: s.studentNo,
|
||||
totalScore: s.totalScore,
|
||||
level: scoreToLevel(s.totalScore),
|
||||
}));
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
subject,
|
||||
avg,
|
||||
median: Math.round(median(scores) * 10) / 10,
|
||||
max: Math.max(...scores),
|
||||
min: Math.min(...scores),
|
||||
passRate: Math.round((passCount / scores.length) * 1000) / 10,
|
||||
stdDev: stdDev(scores),
|
||||
totalCount: scores.length,
|
||||
rankings,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成成绩录入列表(按 examId + classId) */
|
||||
export function generateGradeEntries(
|
||||
examId: string,
|
||||
classId: string,
|
||||
): GradeEntryItem[] {
|
||||
const studentCount = 30;
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
const items: GradeEntryItem[] = [];
|
||||
for (let i = 1; i <= studentCount; i++) {
|
||||
const seed = strToSeed(`${examId}-${classId}-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const score = Math.max(0, Math.min(100, base + delta));
|
||||
const feedback =
|
||||
score < 60 ? "需加强基础" : score >= 90 ? "表现优秀" : null;
|
||||
items.push({
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
score: i <= 20 ? score : null, // 后 10 名未录入
|
||||
feedback: i <= 20 ? feedback : null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 批量保存成绩录入(mock:返回 savedCount) */
|
||||
export function saveGradeEntries(
|
||||
input: SaveGradeEntriesInput,
|
||||
): SaveGradeEntriesResult {
|
||||
return {
|
||||
examId: input.examId,
|
||||
classId: input.classId,
|
||||
savedCount: input.entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成趋势数据(6 个月,含 avg/max/min) */
|
||||
function generateTrend(classId: string): GradeTrendPoint[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return MONTHS.map((m, i) => {
|
||||
const seed = strToSeed(`${classId}-trend-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 6);
|
||||
const avg = Math.max(0, Math.min(100, base + delta));
|
||||
return {
|
||||
month: m,
|
||||
avg,
|
||||
max: Math.min(100, avg + 12),
|
||||
min: Math.max(0, avg - 15),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成分数分布(5 段) */
|
||||
function generateDistribution(classId: string): GradeDistributionBand[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
const bands = [
|
||||
{ label: "0-59", min: 0, max: 59 },
|
||||
{ label: "60-69", min: 60, max: 69 },
|
||||
{ label: "70-79", min: 70, max: 79 },
|
||||
{ label: "80-89", min: 80, max: 89 },
|
||||
{ label: "90-100", min: 90, max: 100 },
|
||||
];
|
||||
// 班级越强,高分段越多
|
||||
const skew = (base - 75) / 10;
|
||||
return bands.map((b, i) => {
|
||||
const seed = strToSeed(`${classId}-dist-${i}`);
|
||||
const baseCount = [4, 6, 9, 8, 3][i] ?? 5;
|
||||
const adj = Math.round(skew * (i - 2) * 1.5);
|
||||
const count = Math.max(0, baseCount + adj + Math.round((seededRandom(seed) - 0.5) * 2));
|
||||
return { ...b, count };
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成学科对比 */
|
||||
function generateSubjectComparison(classId: string): SubjectComparisonItem[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return SUBJECTS.map((s) => {
|
||||
const adj = SUBJECT_DIFFICULTY[s] ?? 0;
|
||||
const seed = strToSeed(`${classId}-subj-${s}`);
|
||||
const noise = Math.round((seededRandom(seed) - 0.5) * 4);
|
||||
return { subject: s, avg: Math.max(0, Math.min(100, base + adj + noise)) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级对比(含显著性) */
|
||||
function generateClassComparison(classId: string): ClassComparisonItem[] {
|
||||
const targetAvg = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return EXTENDED_CLASSES.map((c) => {
|
||||
const clsAvg = CLASS_AVG_BASE[c.id] ?? 78;
|
||||
const diff = Math.abs(clsAvg - targetAvg);
|
||||
// 差距 ≥ 6 视为显著
|
||||
const significant = diff >= 6;
|
||||
return {
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
avg: clsAvg,
|
||||
significant,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成知识点掌握度 */
|
||||
function generateKnowledgeMastery(classId: string): KnowledgeMasteryItem[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return KNOWLEDGE_POINTS.map((kp) => {
|
||||
const seed = strToSeed(`${classId}-kp-${kp}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 20);
|
||||
return {
|
||||
knowledgePoint: kp,
|
||||
mastery: Math.max(30, Math.min(98, base + delta)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按班级生成成绩分析聚合 */
|
||||
export function generateGradeAnalytics(classId: string): GradeAnalytics {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
return {
|
||||
classId,
|
||||
className: cls?.name ?? "未知班级",
|
||||
trend: generateTrend(classId),
|
||||
distribution: generateDistribution(classId),
|
||||
subjectComparison: generateSubjectComparison(classId),
|
||||
classComparison: generateClassComparison(classId),
|
||||
knowledgeMastery: generateKnowledgeMastery(classId),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 报告卡数据(5 学生 × 4 学期 × 6 学科) ============
|
||||
|
||||
/** 5 名学生(默认从高三(1)班抽取) */
|
||||
const REPORT_STUDENTS = [
|
||||
{ id: "stu-0010-001", name: "张明", no: "2026001" },
|
||||
{ id: "stu-0010-002", name: "李华", no: "2026002" },
|
||||
{ id: "stu-0010-003", name: "王芳", no: "2026003" },
|
||||
{ id: "stu-0010-004", name: "赵磊", no: "2026004" },
|
||||
{ id: "stu-0010-005", name: "陈静", no: "2026005" },
|
||||
] as const;
|
||||
|
||||
/** 4 个学期 */
|
||||
const SEMESTERS = [
|
||||
{ year: "2025-2026", semester: "1" },
|
||||
{ year: "2025-2026", semester: "2" },
|
||||
{ year: "2026-2027", semester: "1" },
|
||||
{ year: "2026-2027", semester: "2" },
|
||||
] as const;
|
||||
|
||||
const TEACHER_NAMES = ["王老师", "刘老师", "陈老师", "杨老师", "周老师", "吴老师"];
|
||||
|
||||
const COMMENTS = [
|
||||
"学习态度端正,基础扎实,望继续保持。",
|
||||
"本学期进步明显,望戒骄戒躁,再接再厉。",
|
||||
"理科较强,语文需加强阅读积累。",
|
||||
"全面发展,各科均衡,表现优异。",
|
||||
"数理偏弱,建议加强逻辑训练。",
|
||||
];
|
||||
|
||||
/** 按学生 ID + 学年 + 学期生成报告卡 */
|
||||
export function generateReportCard(
|
||||
studentId: string,
|
||||
academicYear?: string,
|
||||
semester?: string,
|
||||
): ReportCard | null {
|
||||
const stu = REPORT_STUDENTS.find((s) => s.id === studentId);
|
||||
if (!stu) {
|
||||
// 未匹配:用兜底学生数据
|
||||
return null;
|
||||
}
|
||||
const term = SEMESTERS.find(
|
||||
(t) =>
|
||||
(academicYear ? t.year === academicYear : true) &&
|
||||
(semester ? t.semester === semester : true),
|
||||
) ?? SEMESTERS[0];
|
||||
if (!term) return null;
|
||||
|
||||
const baseSeed = strToSeed(`${stu.id}-${term.year}-${term.semester}`);
|
||||
const baseScore = 70 + Math.round(seededRandom(baseSeed) * 20);
|
||||
|
||||
const subjects: ReportCardSubject[] = SUBJECTS.map((s, i) => {
|
||||
const seed = strToSeed(`${stu.id}-${s}-${term.year}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 16);
|
||||
const score = Math.max(0, Math.min(100, baseScore + delta));
|
||||
return {
|
||||
subject: s,
|
||||
score,
|
||||
rank: 1 + ((i + Math.floor(seededRandom(seed) * 10)) % 30),
|
||||
teacherName: TEACHER_NAMES[i] ?? "未知",
|
||||
};
|
||||
});
|
||||
|
||||
const totalScore = subjects.reduce((acc, s) => acc + s.score, 0);
|
||||
const commentIdx = Math.floor(seededRandom(baseSeed) * COMMENTS.length);
|
||||
|
||||
return {
|
||||
studentId: stu.id,
|
||||
studentName: stu.name,
|
||||
studentNo: stu.no,
|
||||
className: "高三(1)班",
|
||||
academicYear: term.year,
|
||||
semester: term.semester,
|
||||
subjects,
|
||||
totalScore,
|
||||
classRank: 1 + (Math.floor(seededRandom(baseSeed + 1) * 30)),
|
||||
classSize: 32,
|
||||
gradeRank: 1 + (Math.floor(seededRandom(baseSeed + 2) * 150)),
|
||||
gradeSize: 150,
|
||||
teacherComment: COMMENTS[commentIdx] ?? COMMENTS[0] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 报告卡可用学生列表 */
|
||||
export const REPORT_CARD_STUDENTS = REPORT_STUDENTS;
|
||||
|
||||
/** 报告卡可用学期列表 */
|
||||
export const REPORT_CARD_SEMESTERS = SEMESTERS;
|
||||
@@ -4,7 +4,7 @@
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { GradeItem } from "@/lib/graphql";
|
||||
import type { GradeItem, RecordGradeInput } from "@/lib/graphql";
|
||||
|
||||
export function generateMockGrades(examId: string): GradeItem[] {
|
||||
const grades: GradeItem[] = [];
|
||||
@@ -24,3 +24,18 @@ export function generateMockGrades(examId: string): GradeItem[] {
|
||||
}
|
||||
return grades;
|
||||
}
|
||||
|
||||
/** 录入成绩:基于 input 生成 GradeItem(含随机 ID + 学生名兜底) */
|
||||
export function recordMockGrade(input: RecordGradeInput): GradeItem {
|
||||
const randomId = Math.random().toString(36).slice(2, 10);
|
||||
const idx = Math.floor(Math.random() * 30) + 1;
|
||||
return {
|
||||
id: `grade-${randomId}`,
|
||||
studentId: input.studentId,
|
||||
studentName: `学生${String(idx).padStart(2, "0")}`,
|
||||
examId: input.examId ?? null,
|
||||
homeworkId: input.homeworkId ?? null,
|
||||
score: input.score,
|
||||
feedback: input.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
344
apps/teacher-portal/src/mocks/fixtures/homework-submissions.ts
Normal file
344
apps/teacher-portal/src/mocks/fixtures/homework-submissions.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Mock 作业提交数据(P7:作业批改链路)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 10 个作业 × 30 学生提交记录(统计维度)
|
||||
* - 单份提交含题目作答 + AI 评分建议
|
||||
* - 按 submissionId 查找详情(含上一份/下一份导航)
|
||||
*/
|
||||
|
||||
import type {
|
||||
HomeworkSubmissionStat,
|
||||
HomeworkSubmissionDetail,
|
||||
SubmissionAnswer,
|
||||
GradeSubmissionInput,
|
||||
GradeSubmissionResult,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
import { EXTENDED_CLASSES } from "./grades-extended";
|
||||
|
||||
/** 10 个作业定义(跨 5 个班级) */
|
||||
const HOMEWORK_DEFS: Array<{
|
||||
homeworkId: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
status: HomeworkSubmissionStat["status"];
|
||||
totalCount: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
avgScore: number | null;
|
||||
}> = [
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440010-hw-001",
|
||||
title: "函数练习题",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 32,
|
||||
submittedCount: 28,
|
||||
gradedCount: 20,
|
||||
avgScore: 82,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440010-hw-002",
|
||||
title: "导数应用题",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "",
|
||||
status: "graded",
|
||||
totalCount: 32,
|
||||
submittedCount: 30,
|
||||
gradedCount: 30,
|
||||
avgScore: 78,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440011-hw-001",
|
||||
title: "数列综合练习",
|
||||
classId: EXTENDED_CLASSES[1]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[1]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 30,
|
||||
submittedCount: 25,
|
||||
gradedCount: 10,
|
||||
avgScore: 85,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440011-hw-002",
|
||||
title: "立体几何作业",
|
||||
classId: EXTENDED_CLASSES[1]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[1]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 30,
|
||||
submittedCount: 22,
|
||||
gradedCount: 5,
|
||||
avgScore: null,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440012-hw-001",
|
||||
title: "解析几何专题",
|
||||
classId: EXTENDED_CLASSES[2]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[2]?.name ?? "",
|
||||
status: "draft",
|
||||
totalCount: 28,
|
||||
submittedCount: 0,
|
||||
gradedCount: 0,
|
||||
avgScore: null,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440012-hw-002",
|
||||
title: "概率统计练习",
|
||||
classId: EXTENDED_CLASSES[2]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[2]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 28,
|
||||
submittedCount: 26,
|
||||
gradedCount: 26,
|
||||
avgScore: 80,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440013-hw-001",
|
||||
title: "三角函数复习",
|
||||
classId: EXTENDED_CLASSES[3]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[3]?.name ?? "",
|
||||
status: "graded",
|
||||
totalCount: 30,
|
||||
submittedCount: 30,
|
||||
gradedCount: 30,
|
||||
avgScore: 88,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440013-hw-002",
|
||||
title: "向量与复数",
|
||||
classId: EXTENDED_CLASSES[3]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[3]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 30,
|
||||
submittedCount: 18,
|
||||
gradedCount: 8,
|
||||
avgScore: 75,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440014-hw-001",
|
||||
title: "不等式练习",
|
||||
classId: EXTENDED_CLASSES[4]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[4]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 32,
|
||||
submittedCount: 30,
|
||||
gradedCount: 12,
|
||||
avgScore: 90,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440014-hw-002",
|
||||
title: "圆锥曲线综合",
|
||||
classId: EXTENDED_CLASSES[4]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[4]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 32,
|
||||
submittedCount: 27,
|
||||
gradedCount: 3,
|
||||
avgScore: null,
|
||||
},
|
||||
];
|
||||
|
||||
/** 按作业维度统计提交(可按 classId / status 过滤) */
|
||||
export function generateHomeworkSubmissionStats(
|
||||
classId?: string,
|
||||
status?: string,
|
||||
): HomeworkSubmissionStat[] {
|
||||
const now = Date.now();
|
||||
return HOMEWORK_DEFS.filter((h) => {
|
||||
if (classId && h.classId !== classId) return false;
|
||||
if (status && h.status !== status) return false;
|
||||
return true;
|
||||
}).map((h) => ({
|
||||
homeworkId: h.homeworkId,
|
||||
title: h.title,
|
||||
classId: h.classId,
|
||||
className: h.className,
|
||||
status: h.status,
|
||||
dueDate: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
totalCount: h.totalCount,
|
||||
submittedCount: h.submittedCount,
|
||||
gradedCount: h.gradedCount,
|
||||
avgScore: h.avgScore,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 4 道题目模板(用于生成作答详情) */
|
||||
const QUESTION_DEFS: Array<{
|
||||
title: string;
|
||||
type: SubmissionAnswer["questionType"];
|
||||
maxScore: number;
|
||||
correctAnswer: string;
|
||||
}> = [
|
||||
{
|
||||
title: "求函数 f(x) = x² - 2x + 1 在 [0, 2] 上的极值",
|
||||
type: "SHORT_ANSWER",
|
||||
maxScore: 25,
|
||||
correctAnswer: "极小值 f(1) = 0,极大值 f(0) = f(2) = 1",
|
||||
},
|
||||
{
|
||||
title: "选择题:函数 y = ln(x) 的导数是",
|
||||
type: "SINGLE_CHOICE",
|
||||
maxScore: 20,
|
||||
correctAnswer: "B. y = 1/x",
|
||||
},
|
||||
{
|
||||
title: "计算定积分 ∫₀¹ (2x + 1) dx",
|
||||
type: "SHORT_ANSWER",
|
||||
maxScore: 25,
|
||||
correctAnswer: "∫₀¹ (2x + 1) dx = [x² + x]₀¹ = 1 + 1 = 2",
|
||||
},
|
||||
{
|
||||
title: "论述:导数在实际问题中的应用",
|
||||
type: "ESSAY",
|
||||
maxScore: 30,
|
||||
correctAnswer: "(参考答案)导数可用于求极值、最值、变化率分析等……",
|
||||
},
|
||||
];
|
||||
|
||||
const STUDENT_ANSWERS = [
|
||||
"f(1) = 0 为极小值",
|
||||
"B",
|
||||
"积分结果为 2",
|
||||
"导数可应用于最优化问题、物理学中的瞬时速度等",
|
||||
];
|
||||
|
||||
const AI_SUGGESTIONS = [
|
||||
"答案正确,步骤清晰。建议补充极值点验证过程。",
|
||||
"选择题正确。",
|
||||
"计算正确,但缺少积分上下限代入步骤。",
|
||||
"论述较为简略,建议补充具体实例说明。",
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按作业 ID 生成所有提交详情列表
|
||||
*
|
||||
* 每份提交包含 4 道题作答 + AI 评分建议。
|
||||
* 同一作业下的提交列表共享 prevSubmissionId / nextSubmissionId 链。
|
||||
*/
|
||||
export function generateAssignmentSubmissions(
|
||||
homeworkId: string,
|
||||
): HomeworkSubmissionDetail[] {
|
||||
const def = HOMEWORK_DEFS.find((h) => h.homeworkId === homeworkId);
|
||||
const totalCount = def?.submittedCount ?? 5;
|
||||
const now = Date.now();
|
||||
const list: HomeworkSubmissionDetail[] = [];
|
||||
|
||||
for (let i = 1; i <= totalCount; i++) {
|
||||
const studentId = `stu-${homeworkId.slice(-4)}-${String(i).padStart(3, "0")}`;
|
||||
const submissionId = `${homeworkId}-sub-${String(i).padStart(3, "0")}`;
|
||||
const isGraded = def ? i <= def.gradedCount : false;
|
||||
|
||||
const answers: SubmissionAnswer[] = QUESTION_DEFS.map((q, qi) => {
|
||||
const seed = strToSeed(`${submissionId}-${qi}`);
|
||||
const isCorrect = seededRandom(seed) > 0.3;
|
||||
const score = isCorrect ? q.maxScore : Math.floor(q.maxScore * 0.5);
|
||||
return {
|
||||
questionId: `${submissionId}-q-${String(qi + 1).padStart(3, "0")}`,
|
||||
questionTitle: q.title,
|
||||
questionType: q.type,
|
||||
maxScore: q.maxScore,
|
||||
studentAnswer: STUDENT_ANSWERS[qi] ?? "(未作答)",
|
||||
correctAnswer: q.correctAnswer,
|
||||
score: isGraded ? score : null,
|
||||
feedback: isGraded ? AI_SUGGESTIONS[qi] ?? null : null,
|
||||
aiSuggestion: AI_SUGGESTIONS[qi] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const totalScore = isGraded
|
||||
? answers.reduce((acc, a) => acc + (a.score ?? 0), 0)
|
||||
: null;
|
||||
|
||||
list.push({
|
||||
submissionId,
|
||||
homeworkId,
|
||||
homeworkTitle: def?.title ?? "作业详情",
|
||||
classId: def?.classId ?? "",
|
||||
className: def?.className ?? "",
|
||||
studentId,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
status: isGraded ? "GRADED" : "SUBMITTED",
|
||||
submittedAt: new Date(now - i * 60 * 60 * 1000).toISOString(),
|
||||
totalScore,
|
||||
answers,
|
||||
prevSubmissionId: null,
|
||||
nextSubmissionId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 建立 prev / next 链
|
||||
list.forEach((item, idx) => {
|
||||
item.prevSubmissionId = idx > 0 ? (list[idx - 1]?.submissionId ?? null) : null;
|
||||
item.nextSubmissionId =
|
||||
idx < list.length - 1 ? (list[idx + 1]?.submissionId ?? null) : null;
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 按 submissionId 查找单份提交详情 */
|
||||
export function findSubmissionDetail(
|
||||
submissionId: string,
|
||||
): HomeworkSubmissionDetail | null {
|
||||
// submissionId 规约:${homeworkId}-sub-XXX
|
||||
const match = submissionId.match(/^(.+)-sub-\d+$/);
|
||||
if (!match) return null;
|
||||
const homeworkId = match[1] ?? "";
|
||||
const list = generateAssignmentSubmissions(homeworkId);
|
||||
return list.find((s) => s.submissionId === submissionId) ?? null;
|
||||
}
|
||||
|
||||
/** 批改单份提交(mock:返回 GRADED 状态 + totalScore) */
|
||||
export function gradeSubmission(
|
||||
input: GradeSubmissionInput,
|
||||
): GradeSubmissionResult {
|
||||
// 若提供 questionScores,汇总;否则用 input.score
|
||||
const totalScore = input.questionScores
|
||||
? input.questionScores.reduce((acc, q) => acc + q.score, 0)
|
||||
: input.score;
|
||||
return {
|
||||
submissionId: input.submissionId,
|
||||
status: "GRADED",
|
||||
totalScore,
|
||||
feedback: input.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** AI 批量评分建议(mock:对每份提交返回 AI 建议分数) */
|
||||
export function generateAiBatchGrading(
|
||||
homeworkId: string,
|
||||
): Array<{ submissionId: string; suggestedScore: number; suggestion: string }> {
|
||||
const list = generateAssignmentSubmissions(homeworkId);
|
||||
return list
|
||||
.filter((s) => s.status === "SUBMITTED")
|
||||
.map((s) => {
|
||||
const suggestedScore = s.answers.reduce(
|
||||
(acc, a) => acc + Math.floor(a.maxScore * 0.8),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
submissionId: s.submissionId,
|
||||
suggestedScore,
|
||||
suggestion: "AI 建议分数基于作答完整度评估,请教师复核。",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { HomeworkItem } from "@/lib/graphql";
|
||||
import type { HomeworkItem, HomeworkDetail, HomeworkSubmission, AssignHomeworkInput } from "@/lib/graphql";
|
||||
|
||||
export function generateMockHomework(classId: string): HomeworkItem[] {
|
||||
const now = Date.now();
|
||||
@@ -35,3 +35,84 @@ export function generateMockHomework(classId: string): HomeworkItem[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 homeworkId 反查 classId(mock 数据 ID 规约:${classId}-hw-XXX)
|
||||
* 兜底:未匹配时返回 "mock-class"。
|
||||
*/
|
||||
function extractClassIdFromHomeworkId(homeworkId: string): string {
|
||||
const match = homeworkId.match(/^(.+)-hw-\d+$/);
|
||||
return match?.[1] ?? "mock-class";
|
||||
}
|
||||
|
||||
/** 生成 mock 提交列表(固定 5 个学生提交,混合状态) */
|
||||
function generateMockSubmissions(homeworkId: string): HomeworkSubmission[] {
|
||||
const now = Date.now();
|
||||
const defs: Array<{
|
||||
status: HomeworkSubmission["status"];
|
||||
score: number | null;
|
||||
feedback: string | null;
|
||||
}> = [
|
||||
{
|
||||
status: "GRADED",
|
||||
score: Math.floor(Math.random() * 20) + 80,
|
||||
feedback: "思路清晰,过程完整",
|
||||
},
|
||||
{
|
||||
status: "GRADED",
|
||||
score: Math.floor(Math.random() * 20) + 80,
|
||||
feedback: "注意步骤书写规范",
|
||||
},
|
||||
{ status: "SUBMITTED", score: null, feedback: null },
|
||||
{ status: "SUBMITTED", score: null, feedback: null },
|
||||
{ status: "NOT_SUBMITTED", score: null, feedback: null },
|
||||
];
|
||||
return defs.map((d, i) => {
|
||||
const studentId = `${homeworkId.slice(0, -3)}${String(i + 1).padStart(3, "0")}`;
|
||||
return {
|
||||
id: `${homeworkId}-sub-${String(i + 1).padStart(3, "0")}`,
|
||||
homeworkId,
|
||||
studentId,
|
||||
studentName: `学生${String(i + 1).padStart(2, "0")}`,
|
||||
status: d.status,
|
||||
score: d.score,
|
||||
feedback: d.feedback,
|
||||
submittedAt:
|
||||
d.status !== "NOT_SUBMITTED"
|
||||
? new Date(now - i * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按作业 ID 查询详情(含提交列表,MSW mock) */
|
||||
export function findMockHomeworkDetail(homeworkId: string): HomeworkDetail {
|
||||
const classId = extractClassIdFromHomeworkId(homeworkId);
|
||||
const all = generateMockHomework(classId);
|
||||
const matched = all.find((h) => h.id === homeworkId);
|
||||
const base = matched ?? {
|
||||
id: homeworkId,
|
||||
classId,
|
||||
title: "作业详情(mock 兜底)",
|
||||
description: "Mock 数据兜底:未匹配到列表中的作业",
|
||||
dueDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "SUBMITTED" as const,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
submissions: generateMockSubmissions(homeworkId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建新作业:基于 input 生成 HomeworkItem(含随机 ID) */
|
||||
export function createMockHomework(input: AssignHomeworkInput): HomeworkItem {
|
||||
const randomId = Math.random().toString(36).slice(2, 10);
|
||||
return {
|
||||
id: `${input.classId}-hw-${randomId}`,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
dueDate: input.dueDate,
|
||||
status: "NOT_SUBMITTED",
|
||||
};
|
||||
}
|
||||
|
||||
146
apps/teacher-portal/src/mocks/fixtures/knowledge-graph.ts
Normal file
146
apps/teacher-portal/src/mocks/fixtures/knowledge-graph.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Mock 知识图谱数据(core-edu / ai 域,P4 扩展)
|
||||
*
|
||||
* 包含数学、语文、英语三科知识点节点 + 前置依赖边。
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { KnowledgeGraphData } from "@/lib/graphql-p4";
|
||||
|
||||
/** 全量知识图谱 mock(按科目分布,13 节点 + 10 边) */
|
||||
export const mockKnowledgeGraph: KnowledgeGraphData = {
|
||||
nodes: [
|
||||
// ---- 数学(5 节点) ----
|
||||
{
|
||||
id: "math-01",
|
||||
name: "实数与运算",
|
||||
subject: "数学",
|
||||
description: "有理数、无理数、实数的基本运算",
|
||||
masteryLevel: 88,
|
||||
},
|
||||
{
|
||||
id: "math-02",
|
||||
name: "代数式",
|
||||
subject: "数学",
|
||||
description: "整式、分式、根式的化简与求值",
|
||||
masteryLevel: 76,
|
||||
},
|
||||
{
|
||||
id: "math-03",
|
||||
name: "方程与不等式",
|
||||
subject: "数学",
|
||||
description: "一元二次方程、不等式组求解",
|
||||
masteryLevel: 62,
|
||||
},
|
||||
{
|
||||
id: "math-04",
|
||||
name: "函数",
|
||||
subject: "数学",
|
||||
description: "一次函数、二次函数、指数对数函数",
|
||||
masteryLevel: 45,
|
||||
},
|
||||
{
|
||||
id: "math-05",
|
||||
name: "导数",
|
||||
subject: "数学",
|
||||
description: "导数的几何意义与运算应用",
|
||||
masteryLevel: 32,
|
||||
},
|
||||
// ---- 语文(4 节点) ----
|
||||
{
|
||||
id: "chinese-01",
|
||||
name: "文言文基础",
|
||||
subject: "语文",
|
||||
description: "常见实词虚词、句式辨析",
|
||||
masteryLevel: 81,
|
||||
},
|
||||
{
|
||||
id: "chinese-02",
|
||||
name: "古诗词鉴赏",
|
||||
subject: "语文",
|
||||
description: "意象、手法、情感分析",
|
||||
masteryLevel: 68,
|
||||
},
|
||||
{
|
||||
id: "chinese-03",
|
||||
name: "现代文阅读",
|
||||
subject: "语文",
|
||||
description: "论述类与文学类文本阅读策略",
|
||||
masteryLevel: 55,
|
||||
},
|
||||
{
|
||||
id: "chinese-04",
|
||||
name: "议论文写作",
|
||||
subject: "语文",
|
||||
description: "论点提炼、论据组织、论证结构",
|
||||
masteryLevel: 48,
|
||||
},
|
||||
// ---- 英语(4 节点) ----
|
||||
{
|
||||
id: "english-01",
|
||||
name: "词汇基础",
|
||||
subject: "英语",
|
||||
description: "核心 3500 词汇与词组搭配",
|
||||
masteryLevel: 90,
|
||||
},
|
||||
{
|
||||
id: "english-02",
|
||||
name: "语法结构",
|
||||
subject: "英语",
|
||||
description: "从句、非谓语动词、虚拟语气",
|
||||
masteryLevel: 72,
|
||||
},
|
||||
{
|
||||
id: "english-03",
|
||||
name: "阅读理解",
|
||||
subject: "英语",
|
||||
description: "主旨、细节、推理题型策略",
|
||||
masteryLevel: 58,
|
||||
},
|
||||
{
|
||||
id: "english-04",
|
||||
name: "书面表达",
|
||||
subject: "英语",
|
||||
description: "应用文与议论文写作模板",
|
||||
masteryLevel: 40,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
// 数学依赖链:实数 → 代数式 → 方程 → 函数 → 导数
|
||||
{ from: "math-01", to: "math-02", type: "PREREQUISITE" },
|
||||
{ from: "math-02", to: "math-03", type: "PREREQUISITE" },
|
||||
{ from: "math-03", to: "math-04", type: "PREREQUISITE" },
|
||||
{ from: "math-04", to: "math-05", type: "PREREQUISITE" },
|
||||
// 语文依赖链:文言文 → 古诗词 → 现代文 → 写作
|
||||
{ from: "chinese-01", to: "chinese-02", type: "PREREQUISITE" },
|
||||
{ from: "chinese-02", to: "chinese-03", type: "RELATED" },
|
||||
{ from: "chinese-03", to: "chinese-04", type: "PREREQUISITE" },
|
||||
// 英语依赖链:词汇 → 语法 → 阅读 → 写作
|
||||
{ from: "english-01", to: "english-02", type: "PREREQUISITE" },
|
||||
{ from: "english-02", to: "english-03", type: "RELATED" },
|
||||
{ from: "english-03", to: "english-04", type: "PREREQUISITE" },
|
||||
],
|
||||
};
|
||||
|
||||
/** 可选科目列表 */
|
||||
export const KNOWLEDGE_SUBJECTS = ["全部", "数学", "语文", "英语"] as const;
|
||||
|
||||
/**
|
||||
* 按科目过滤知识图谱
|
||||
* subject 为空、"全部" 或 undefined 时返回全量
|
||||
*/
|
||||
export function filterKnowledgeGraph(
|
||||
subject: string | null | undefined,
|
||||
): KnowledgeGraphData {
|
||||
if (!subject || subject === "全部") {
|
||||
return mockKnowledgeGraph;
|
||||
}
|
||||
const nodes = mockKnowledgeGraph.nodes.filter(
|
||||
(n) => n.subject === subject,
|
||||
);
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const edges = mockKnowledgeGraph.edges.filter(
|
||||
(e) => nodeIds.has(e.from) && nodeIds.has(e.to),
|
||||
);
|
||||
return { nodes, edges };
|
||||
}
|
||||
458
apps/teacher-portal/src/mocks/fixtures/leave-requests.ts
Normal file
458
apps/teacher-portal/src/mocks/fixtures/leave-requests.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Mock 请假申请 + 调课申请数据(P7-admin:请假/调课子模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 15 条请假申请(含 pending/approved/rejected 三种状态)
|
||||
* - 10 条调课申请(含 ADJUST/SUBSTITUTE 两种类型)
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeaveRequest,
|
||||
LeaveRequestsFilter,
|
||||
LeaveRequestsResult,
|
||||
LeaveStatus,
|
||||
LeaveType,
|
||||
ApproveLeaveRequestInput,
|
||||
ApproveLeaveRequestResult,
|
||||
SubmitLeaveRequestInput,
|
||||
SubmitLeaveRequestResult,
|
||||
ScheduleChange,
|
||||
ScheduleChangeType,
|
||||
ScheduleChangeStatus,
|
||||
ScheduleChangesResult,
|
||||
SubmitScheduleChangeInput,
|
||||
SubmitScheduleChangeResult,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type { DataScope } from "@/lib/graphql";
|
||||
import { EXTENDED_CLASSES, SUBJECTS } from "./grades-extended";
|
||||
|
||||
/** 教师 ID 列表 */
|
||||
const TEACHERS = [
|
||||
{ id: "u-001", name: "王老师", role: "teacher" },
|
||||
{ id: "u-002", name: "刘老师", role: "teacher" },
|
||||
{ id: "u-003", name: "陈老师", role: "teacher" },
|
||||
{ id: "u-004", name: "杨老师", role: "teacher" },
|
||||
{ id: "u-005", name: "周老师", role: "teacher" },
|
||||
{ id: "u-006", name: "吴老师", role: "head_teacher" },
|
||||
{ id: "u-007", name: "郑老师", role: "teacher" },
|
||||
{ id: "u-008", name: "孙老师", role: "teacher" },
|
||||
] as const;
|
||||
|
||||
/** 审批人 ID(教务主任) */
|
||||
const REVIEWER = { id: "u-admin-01", name: "教务主任" };
|
||||
|
||||
/** 请假原因模板 */
|
||||
const LEAVE_REASONS = [
|
||||
"感冒发烧,需在家休养",
|
||||
"家中突发急事",
|
||||
"年度体检",
|
||||
"陪同家人就医",
|
||||
"参加校外培训",
|
||||
"处理个人事务",
|
||||
"感冒咳嗽",
|
||||
"事务处理",
|
||||
"婚假",
|
||||
"事假",
|
||||
];
|
||||
|
||||
/** 生成日期 YYYY-MM-DD */
|
||||
function dateOffset(daysAgo: number): string {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 请假类型轮转 */
|
||||
const LEAVE_TYPES: LeaveType[] = [
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
"ANNUAL",
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
"OTHER",
|
||||
"SICK",
|
||||
"ANNUAL",
|
||||
"PERSONAL",
|
||||
"OTHER",
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
"ANNUAL",
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
];
|
||||
|
||||
/** 请假状态轮转 */
|
||||
const LEAVE_STATUSES: LeaveStatus[] = [
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
];
|
||||
|
||||
/** 生成 15 条请假申请 */
|
||||
function generateLeaveRequests(): LeaveRequest[] {
|
||||
const list: LeaveRequest[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const teacher = TEACHERS[i % TEACHERS.length];
|
||||
if (!teacher) continue;
|
||||
const type = LEAVE_TYPES[i] ?? "PERSONAL";
|
||||
const status = LEAVE_STATUSES[i] ?? "PENDING";
|
||||
const submittedAt = dateOffset(15 - i);
|
||||
const startDate = dateOffset(13 - i);
|
||||
const endDate = dateOffset(12 - i);
|
||||
const reason = LEAVE_REASONS[i % LEAVE_REASONS.length] ?? "个人事务";
|
||||
const reviewed =
|
||||
status === "PENDING"
|
||||
? null
|
||||
: {
|
||||
reviewedAt: dateOffset(14 - i),
|
||||
reviewerId: REVIEWER.id,
|
||||
reviewerName: REVIEWER.name,
|
||||
reviewComment:
|
||||
status === "APPROVED" ? "已批准" : "理由不充分,请补充材料",
|
||||
};
|
||||
list.push({
|
||||
id: `lr-2026-${String(i + 1).padStart(3, "0")}`,
|
||||
applicantId: teacher.id,
|
||||
applicantName: teacher.name,
|
||||
applicantRole: teacher.role,
|
||||
type,
|
||||
startDate,
|
||||
endDate,
|
||||
reason,
|
||||
status,
|
||||
submittedAt,
|
||||
reviewedAt: reviewed?.reviewedAt ?? null,
|
||||
reviewerId: reviewed?.reviewerId ?? null,
|
||||
reviewerName: reviewed?.reviewerName ?? null,
|
||||
reviewComment: reviewed?.reviewComment ?? null,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量请假申请缓存 */
|
||||
const ALL_LEAVE_REQUESTS: LeaveRequest[] = generateLeaveRequests();
|
||||
|
||||
/** 保存的请假覆盖(按 requestId 索引) */
|
||||
const LEAVE_OVERRIDES = new Map<string, LeaveRequest>();
|
||||
|
||||
/** 应用覆盖后的请假申请读取 */
|
||||
function resolveLeave(id: string): LeaveRequest | undefined {
|
||||
const override = LEAVE_OVERRIDES.get(id);
|
||||
if (override) return override;
|
||||
return ALL_LEAVE_REQUESTS.find((r) => r.id === id);
|
||||
}
|
||||
|
||||
/** 请假列表查询 */
|
||||
export function queryLeaveRequests(
|
||||
filter: LeaveRequestsFilter,
|
||||
): LeaveRequestsResult {
|
||||
const { dataScope, status, page, pageSize } = filter;
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 50;
|
||||
// 应用覆盖后的列表
|
||||
const merged = ALL_LEAVE_REQUESTS.map(
|
||||
(r) => LEAVE_OVERRIDES.get(r.id) ?? r,
|
||||
);
|
||||
const filtered = merged.filter((r) => {
|
||||
if (status && r.status !== status) return false;
|
||||
// dataScope 过滤:SELF 只看自己的(mock 里全部 teacher 角色,模拟当前教师 = u-001)
|
||||
if (dataScope === "SELF" && r.applicantId !== "u-001") return false;
|
||||
return true;
|
||||
});
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
|
||||
const currentPage = Math.min(Math.max(1, safePage), totalPages);
|
||||
const start = (currentPage - 1) * safePageSize;
|
||||
const items = filtered.slice(start, start + safePageSize);
|
||||
return { items, total, page: currentPage, pageSize: safePageSize };
|
||||
}
|
||||
|
||||
/** 审批请假 */
|
||||
export function approveLeaveRequest(
|
||||
input: ApproveLeaveRequestInput,
|
||||
): ApproveLeaveRequestResult {
|
||||
const original = resolveLeave(input.requestId);
|
||||
const newStatus: LeaveStatus =
|
||||
input.action === "APPROVE" ? "APPROVED" : "REJECTED";
|
||||
const now = new Date().toISOString();
|
||||
const updated: LeaveRequest = {
|
||||
...(original ?? {
|
||||
id: input.requestId,
|
||||
applicantId: "unknown",
|
||||
applicantName: "未知",
|
||||
applicantRole: "teacher",
|
||||
type: "PERSONAL" as LeaveType,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
reason: "",
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
reviewComment: null,
|
||||
}),
|
||||
status: newStatus,
|
||||
reviewedAt: now,
|
||||
reviewerId: REVIEWER.id,
|
||||
reviewerName: REVIEWER.name,
|
||||
reviewComment: input.comment ?? null,
|
||||
};
|
||||
LEAVE_OVERRIDES.set(input.requestId, updated);
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
status: newStatus,
|
||||
reviewedAt: now,
|
||||
reviewerId: REVIEWER.id,
|
||||
reviewerName: REVIEWER.name,
|
||||
reviewComment: input.comment ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 提交请假申请 */
|
||||
export function submitLeaveRequest(
|
||||
input: SubmitLeaveRequestInput,
|
||||
): SubmitLeaveRequestResult {
|
||||
const now = new Date().toISOString();
|
||||
const seq = String(ALL_LEAVE_REQUESTS.length + LEAVE_OVERRIDES.size + 1).padStart(
|
||||
3,
|
||||
"0",
|
||||
);
|
||||
const requestId = `lr-2026-${seq}`;
|
||||
const newRequest: LeaveRequest = {
|
||||
id: requestId,
|
||||
applicantId: "u-001",
|
||||
applicantName: "王老师",
|
||||
applicantRole: "teacher",
|
||||
type: input.type,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
reason: input.reason,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
reviewComment: null,
|
||||
};
|
||||
LEAVE_OVERRIDES.set(requestId, newRequest);
|
||||
return {
|
||||
requestId,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 调课申请 ============
|
||||
|
||||
/** 调课原因模板 */
|
||||
const CHANGE_REASONS = [
|
||||
"出差需调课",
|
||||
"请同事代课",
|
||||
"教务临时调整",
|
||||
"学科组活动",
|
||||
"家中有事",
|
||||
"培训冲突",
|
||||
"会议冲突",
|
||||
"外出学习",
|
||||
"代同事上课",
|
||||
"校际教研",
|
||||
];
|
||||
|
||||
/** 调课类型轮转 */
|
||||
const CHANGE_TYPES: ScheduleChangeType[] = [
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
];
|
||||
|
||||
/** 调课状态轮转 */
|
||||
const CHANGE_STATUSES: ScheduleChangeStatus[] = [
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PENDING",
|
||||
];
|
||||
|
||||
/** 生成 10 条调课申请 */
|
||||
function generateScheduleChanges(): ScheduleChange[] {
|
||||
const list: ScheduleChange[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const teacher = TEACHERS[i % TEACHERS.length];
|
||||
if (!teacher) continue;
|
||||
const cls = EXTENDED_CLASSES[i % EXTENDED_CLASSES.length];
|
||||
if (!cls) continue;
|
||||
const subject = SUBJECTS[i % SUBJECTS.length] ?? "数学";
|
||||
const type = CHANGE_TYPES[i] ?? "ADJUST";
|
||||
const status = CHANGE_STATUSES[i] ?? "PENDING";
|
||||
const submittedAt = dateOffset(10 - i);
|
||||
const originalDate = dateOffset(8 - i);
|
||||
const adjustedDate = dateOffset(6 - i);
|
||||
const reason = CHANGE_REASONS[i % CHANGE_REASONS.length] ?? "调课";
|
||||
const substitute =
|
||||
type === "SUBSTITUTE"
|
||||
? {
|
||||
substituteTeacherId: TEACHERS[(i + 1) % TEACHERS.length]?.id ?? null,
|
||||
substituteTeacherName:
|
||||
TEACHERS[(i + 1) % TEACHERS.length]?.name ?? null,
|
||||
}
|
||||
: { substituteTeacherId: null, substituteTeacherName: null };
|
||||
const reviewed =
|
||||
status === "PENDING"
|
||||
? null
|
||||
: {
|
||||
reviewedAt: dateOffset(9 - i),
|
||||
reviewComment:
|
||||
status === "APPROVED" ? "已批准" : "原因不充分,请重新提交",
|
||||
};
|
||||
list.push({
|
||||
id: `sc-2026-${String(i + 1).padStart(3, "0")}`,
|
||||
requesterId: teacher.id,
|
||||
requesterName: teacher.name,
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
originalDate,
|
||||
originalPeriod: (i % 8) + 1,
|
||||
originalSubject: subject,
|
||||
adjustedDate,
|
||||
adjustedPeriod: ((i + 2) % 8) + 1,
|
||||
adjustedSubject: subject,
|
||||
substituteTeacherId: substitute.substituteTeacherId,
|
||||
substituteTeacherName: substitute.substituteTeacherName,
|
||||
type,
|
||||
reason,
|
||||
status,
|
||||
submittedAt,
|
||||
reviewedAt: reviewed?.reviewedAt ?? null,
|
||||
reviewComment: reviewed?.reviewComment ?? null,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量调课申请缓存 */
|
||||
const ALL_SCHEDULE_CHANGES: ScheduleChange[] = generateScheduleChanges();
|
||||
|
||||
/** 保存的调课覆盖(按 requestId 索引) */
|
||||
const CHANGE_OVERRIDES = new Map<string, ScheduleChange>();
|
||||
|
||||
/** 调课列表查询 */
|
||||
export function queryScheduleChanges(
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
): ScheduleChangesResult {
|
||||
const merged = ALL_SCHEDULE_CHANGES.map(
|
||||
(r) => CHANGE_OVERRIDES.get(r.id) ?? r,
|
||||
);
|
||||
const total = merged.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(Math.max(1, page), totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const items = merged.slice(start, start + pageSize);
|
||||
return { items, total, page: currentPage, pageSize };
|
||||
}
|
||||
|
||||
/** 提交调课申请 */
|
||||
export function submitScheduleChange(
|
||||
input: SubmitScheduleChangeInput,
|
||||
): SubmitScheduleChangeResult {
|
||||
const now = new Date().toISOString();
|
||||
const seq = String(
|
||||
ALL_SCHEDULE_CHANGES.length + CHANGE_OVERRIDES.size + 1,
|
||||
).padStart(3, "0");
|
||||
const requestId = `sc-2026-${seq}`;
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === input.classId);
|
||||
const newRequest: ScheduleChange = {
|
||||
id: requestId,
|
||||
requesterId: "u-001",
|
||||
requesterName: "王老师",
|
||||
classId: input.classId,
|
||||
className: cls?.name ?? "未知班级",
|
||||
originalDate: input.originalDate,
|
||||
originalPeriod: input.originalPeriod,
|
||||
originalSubject: input.originalSubject,
|
||||
adjustedDate: input.adjustedDate,
|
||||
adjustedPeriod: input.adjustedPeriod,
|
||||
adjustedSubject: input.adjustedSubject,
|
||||
substituteTeacherId: input.substituteTeacherId ?? null,
|
||||
substituteTeacherName: null,
|
||||
type: input.type,
|
||||
reason: input.reason,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
};
|
||||
CHANGE_OVERRIDES.set(requestId, newRequest);
|
||||
return {
|
||||
requestId,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前教师可访问的 dataScope 列表(用于 UI 下拉) */
|
||||
export const DATA_SCOPES: Array<{ value: DataScope; label: string }> = [
|
||||
{ value: "SELF", label: "我的申请" },
|
||||
{ value: "CLASS", label: "本班" },
|
||||
{ value: "GRADE", label: "本年级" },
|
||||
{ value: "SCHOOL", label: "全校" },
|
||||
];
|
||||
|
||||
/** 请假类型映射 */
|
||||
export const LEAVE_TYPE_LABEL: Record<LeaveType, string> = {
|
||||
SICK: "病假",
|
||||
PERSONAL: "事假",
|
||||
ANNUAL: "年假",
|
||||
OTHER: "其他",
|
||||
};
|
||||
|
||||
/** 请假状态映射 */
|
||||
export const LEAVE_STATUS_LABEL: Record<LeaveStatus, string> = {
|
||||
PENDING: "待审批",
|
||||
APPROVED: "已批准",
|
||||
REJECTED: "已拒绝",
|
||||
};
|
||||
|
||||
/** 调课类型映射 */
|
||||
export const SCHEDULE_CHANGE_TYPE_LABEL: Record<ScheduleChangeType, string> = {
|
||||
ADJUST: "调课",
|
||||
SUBSTITUTE: "代课",
|
||||
};
|
||||
|
||||
/** 调课状态映射 */
|
||||
export const SCHEDULE_CHANGE_STATUS_LABEL: Record<ScheduleChangeStatus, string> =
|
||||
{
|
||||
PENDING: "待审批",
|
||||
APPROVED: "已批准",
|
||||
REJECTED: "已拒绝",
|
||||
};
|
||||
157
apps/teacher-portal/src/mocks/fixtures/lesson-plan-heatmap.ts
Normal file
157
apps/teacher-portal/src/mocks/fixtures/lesson-plan-heatmap.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Mock 课标覆盖热力图数据(P7-advanced:热力图模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 教材 × 50 知识点 × 20 课案覆盖矩阵
|
||||
* - 按 textbookId 查询热力图
|
||||
*/
|
||||
|
||||
import type {
|
||||
LessonPlanHeatmap,
|
||||
HeatmapKnowledgePoint,
|
||||
HeatmapLessonPlan,
|
||||
HeatmapCell,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 5 个教材定义 */
|
||||
const TEXTBOOK_DEFS: Array<{
|
||||
textbookId: string;
|
||||
textbookTitle: string;
|
||||
subject: string;
|
||||
}> = [
|
||||
{ textbookId: "tb-001", textbookTitle: "高中数学必修一", subject: "数学" },
|
||||
{ textbookId: "tb-002", textbookTitle: "高中数学必修二", subject: "数学" },
|
||||
{ textbookId: "tb-003", textbookTitle: "高中物理必修一", subject: "物理" },
|
||||
{ textbookId: "tb-004", textbookTitle: "高中化学必修一", subject: "化学" },
|
||||
{ textbookId: "tb-005", textbookTitle: "高中生物必修一", subject: "生物" },
|
||||
];
|
||||
|
||||
/** 章节名池 */
|
||||
const CHAPTER_NAMES = [
|
||||
"第一章 集合与函数",
|
||||
"第二章 基本初等函数",
|
||||
"第三章 函数的应用",
|
||||
"第四章 空间几何",
|
||||
"第五章 点线面位置关系",
|
||||
];
|
||||
|
||||
/** 课案标题池(20 个) */
|
||||
const LESSON_PLAN_TITLES = [
|
||||
"集合的概念",
|
||||
"集合间的基本关系",
|
||||
"函数及其表示",
|
||||
"函数的单调性",
|
||||
"函数的奇偶性",
|
||||
"指数函数",
|
||||
"对数函数",
|
||||
"幂函数",
|
||||
"函数与方程",
|
||||
"函数模型及应用",
|
||||
"空间几何体",
|
||||
"空间点线面位置关系",
|
||||
"直线方程",
|
||||
"圆的方程",
|
||||
"空间直角坐标系",
|
||||
"函数综合复习",
|
||||
"三角函数初步",
|
||||
"数列初步",
|
||||
"不等式",
|
||||
"导数初步",
|
||||
];
|
||||
|
||||
/** 知识点名池(50 个,简化生成) */
|
||||
const KP_NAME_POOL = [
|
||||
"集合概念", "集合运算", "并集交集", "补集", "全集",
|
||||
"函数定义", "定义域值域", "单调性", "奇偶性", "周期性",
|
||||
"指数运算", "指数函数图象", "对数运算", "对数函数图象", "换底公式",
|
||||
"幂函数", "幂函数性质", "函数零点", "二分法", "函数模型",
|
||||
"几何体结构", "三视图", "直观图", "表面积", "体积",
|
||||
"点线面关系", "线线平行", "线面平行", "面面平行", "线线垂直",
|
||||
"线面垂直", "面面垂直", "直线倾斜角", "直线斜率", "直线方程形式",
|
||||
"圆的定义", "圆的标准方程", "圆的一般方程", "直线与圆位置", "圆与圆位置",
|
||||
"空间坐标", "距离公式", "三角函数定义", "正弦定理", "余弦定理",
|
||||
"数列概念", "等差数列", "等比数列", "不等式性质", "导数定义",
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 生成 50 个知识点 */
|
||||
function generateKnowledgePoints(textbookId: string): HeatmapKnowledgePoint[] {
|
||||
return KP_NAME_POOL.map((name, i) => ({
|
||||
kpId: `kp-${textbookId}-${String(i + 1).padStart(3, "0")}`,
|
||||
name,
|
||||
chapterName: CHAPTER_NAMES[i % CHAPTER_NAMES.length] ?? "未分类",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 生成 20 个课案 */
|
||||
function generateLessonPlans(textbookId: string): HeatmapLessonPlan[] {
|
||||
return LESSON_PLAN_TITLES.map((title, i) => ({
|
||||
planId: `plan-${textbookId}-${String(i + 1).padStart(3, "0")}`,
|
||||
title,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 生成覆盖矩阵单元格(50 × 20 = 1000 个) */
|
||||
function generateCells(
|
||||
textbookId: string,
|
||||
knowledgePoints: HeatmapKnowledgePoint[],
|
||||
lessonPlans: HeatmapLessonPlan[],
|
||||
): HeatmapCell[] {
|
||||
const cells: HeatmapCell[] = [];
|
||||
for (const kp of knowledgePoints) {
|
||||
for (const plan of lessonPlans) {
|
||||
const seed = strToSeed(`${kp.kpId}-${plan.planId}`);
|
||||
const r = seededRandom(seed);
|
||||
// 0-3 覆盖数:约 40% 为 0,25% 为 1,20% 为 2,15% 为 3
|
||||
let coverage: number;
|
||||
if (r > 0.85) coverage = 3;
|
||||
else if (r > 0.65) coverage = 2;
|
||||
else if (r > 0.4) coverage = 1;
|
||||
else coverage = 0;
|
||||
cells.push({ kpId: kp.kpId, planId: plan.planId, coverage });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 按 textbookId 查询热力图 */
|
||||
export function findLessonPlanHeatmap(
|
||||
textbookId: string,
|
||||
): LessonPlanHeatmap | null {
|
||||
const def = TEXTBOOK_DEFS.find((t) => t.textbookId === textbookId);
|
||||
if (!def) return null;
|
||||
const knowledgePoints = generateKnowledgePoints(textbookId);
|
||||
const lessonPlans = generateLessonPlans(textbookId);
|
||||
const cells = generateCells(textbookId, knowledgePoints, lessonPlans);
|
||||
return {
|
||||
textbookId: def.textbookId,
|
||||
textbookTitle: def.textbookTitle,
|
||||
knowledgePoints,
|
||||
lessonPlans,
|
||||
cells,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取所有教材列表(供页面 select 使用) */
|
||||
export function listHeatmapTextbooks(): Array<{
|
||||
textbookId: string;
|
||||
textbookTitle: string;
|
||||
subject: string;
|
||||
}> {
|
||||
return TEXTBOOK_DEFS;
|
||||
}
|
||||
448
apps/teacher-portal/src/mocks/fixtures/lesson-plans.ts
Normal file
448
apps/teacher-portal/src/mocks/fixtures/lesson-plans.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Mock 课案数据(lesson-preparation 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 10 个我自己的课案(语文/数学/英语,覆盖小学/初中/高中)
|
||||
* - 20 个校内库课案(他人发布)
|
||||
* - 5 课案模板
|
||||
* - 课案日历 / 热力图
|
||||
* - CRUD:创建 / 更新 / Fork
|
||||
*/
|
||||
|
||||
import type {
|
||||
LessonPlanItem,
|
||||
LessonPlanDetail,
|
||||
LessonPlanLibraryItem,
|
||||
LessonPlanCalendarMonth,
|
||||
LessonPlanCalendarDay,
|
||||
LessonPlanHeatmap,
|
||||
LessonPlanTemplate,
|
||||
CreateLessonPlanInput,
|
||||
UpdateLessonPlanInput,
|
||||
AIGenerateLessonPlanInput,
|
||||
AIGenerateLessonPlanResult,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 学科 */
|
||||
const SUBJECTS = ["语文", "数学", "英语"] as const;
|
||||
|
||||
/** 年级 */
|
||||
const GRADES = ["小学三年级", "小学六年级", "初中一年级", "高中三年级"] as const;
|
||||
|
||||
/** 模板元信息 */
|
||||
export const LESSON_PLAN_TEMPLATES: {
|
||||
id: LessonPlanTemplate;
|
||||
name: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ id: "REGULAR", name: "常规教案", description: "标准课时教案:教学目标→重难点→教学过程→作业布置" },
|
||||
{ id: "UNIT", name: "单元教案", description: "单元整体设计:单元目标→课时分配→阶段评估" },
|
||||
{ id: "REVIEW", name: "复习教案", description: "复习课教案:知识梳理→典型例题→巩固练习" },
|
||||
{ id: "ACTIVITY", name: "活动教案", description: "活动课教案:情境导入→合作探究→成果展示" },
|
||||
{ id: "PERSONALIZED", name: "个性化教案", description: "分层教学:基础目标→进阶目标→差异化辅导" },
|
||||
];
|
||||
|
||||
/** 课案内容模板(按学科生成 markdown) */
|
||||
function planContent(
|
||||
title: string,
|
||||
subject: string,
|
||||
grade: string,
|
||||
chapter: string,
|
||||
): string {
|
||||
return `# ${title}
|
||||
|
||||
## 基本信息
|
||||
- 学科:${subject}
|
||||
- 年级:${grade}
|
||||
- 章节:${chapter}
|
||||
- 课时:1 课时(40 分钟)
|
||||
|
||||
## 教学目标
|
||||
1. 知识与技能:掌握 ${chapter} 的核心概念与基本运算
|
||||
2. 过程与方法:通过情境导入与例题分析,培养逻辑推理能力
|
||||
3. 情感态度价值观:激发学习兴趣,建立学科自信
|
||||
|
||||
## 教学重难点
|
||||
- 重点:${chapter} 的定义与基本性质
|
||||
- 难点:综合应用与变式训练
|
||||
|
||||
## 教学过程
|
||||
### 一、情境导入(5 分钟)
|
||||
通过生活实例引出 ${chapter} 的学习必要性。
|
||||
|
||||
### 二、新知讲解(15 分钟)
|
||||
1. 概念引入:给出 ${chapter} 的规范定义
|
||||
2. 性质剖析:列表对比关键性质
|
||||
3. 典型例题:精选 3 道分层例题
|
||||
|
||||
### 三、巩固练习(12 分钟)
|
||||
- 基础题:5 道
|
||||
- 提升题:3 道
|
||||
- 拓展题:1 道
|
||||
|
||||
### 四、课堂小结(5 分钟)
|
||||
学生口述本节收获,教师补充完善。
|
||||
|
||||
### 五、作业布置(3 分钟)
|
||||
- 必做:教材 P12 第 1-5 题
|
||||
- 选做:探究题 1 道
|
||||
|
||||
## 板书设计
|
||||
左侧:${chapter} 定义 | 中间:例题区 | 右侧:学生板演区
|
||||
|
||||
## 教学反思
|
||||
课后填写:达成度 / 改进点 / 后续衔接`;
|
||||
}
|
||||
|
||||
/** 10 个我的课案(基础定义) */
|
||||
const MY_PLANS: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
classIds: string[];
|
||||
updatedAt: string;
|
||||
}> = [
|
||||
{
|
||||
id: "lp-001",
|
||||
title: "《岳阳楼记》第一课时",
|
||||
subject: "语文",
|
||||
grade: "初中一年级",
|
||||
chapter: "第二单元·岳阳楼记",
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-001",
|
||||
textbookTitle: "小学语文三年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
|
||||
updatedAt: "2026-07-01T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-002",
|
||||
title: "分数的初步认识",
|
||||
subject: "数学",
|
||||
grade: "小学三年级",
|
||||
chapter: "第七单元·分数的初步认识",
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-002",
|
||||
textbookTitle: "小学数学六年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
|
||||
updatedAt: "2026-07-02T14:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-003",
|
||||
title: "My Family 单元导入",
|
||||
subject: "英语",
|
||||
grade: "初中一年级",
|
||||
chapter: "Unit 1·My Family",
|
||||
status: "DRAFT",
|
||||
textbookId: "tb-003",
|
||||
textbookTitle: "初中英语七年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440011"],
|
||||
updatedAt: "2026-07-03T10:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-004",
|
||||
title: "函数的单调性与极值",
|
||||
subject: "数学",
|
||||
grade: "高中三年级",
|
||||
chapter: "选修 2-2·导数应用",
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-05T08:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-005",
|
||||
title: "现代文阅读·论述类文本",
|
||||
subject: "语文",
|
||||
grade: "高中三年级",
|
||||
chapter: "第一轮复习·现代文阅读",
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-06T16:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-006",
|
||||
title: "Reading Strategies for Main Idea",
|
||||
subject: "英语",
|
||||
grade: "高中三年级",
|
||||
chapter: "高三复习·阅读理解",
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-07T11:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-007",
|
||||
title: "古诗词鉴赏·意象分析",
|
||||
subject: "语文",
|
||||
grade: "初中一年级",
|
||||
chapter: "第三单元·古诗词",
|
||||
status: "ARCHIVED",
|
||||
textbookId: "tb-001",
|
||||
textbookTitle: "小学语文三年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
|
||||
updatedAt: "2026-06-28T09:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-008",
|
||||
title: "方程与不等式复习课",
|
||||
subject: "数学",
|
||||
grade: "小学六年级",
|
||||
chapter: "总复习·方程与不等式",
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-002",
|
||||
textbookTitle: "小学数学六年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440013"],
|
||||
updatedAt: "2026-07-08T13:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-009",
|
||||
title: "书面表达·议论文结构",
|
||||
subject: "英语",
|
||||
grade: "高中三年级",
|
||||
chapter: "高三复习·书面表达",
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-09T15:10:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-010",
|
||||
title: "实数与运算·总复习",
|
||||
subject: "数学",
|
||||
grade: "初中一年级",
|
||||
chapter: "第一章·实数",
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440011"],
|
||||
updatedAt: "2026-07-10T10:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** 转 List item */
|
||||
function toItem(p: typeof MY_PLANS[number]): LessonPlanItem {
|
||||
return { ...p };
|
||||
}
|
||||
|
||||
/** 我的课案列表 */
|
||||
export const mockLessonPlans: LessonPlanItem[] = MY_PLANS.map(toItem);
|
||||
|
||||
/** 按 ID 查询课案详情 */
|
||||
export function findLessonPlanDetail(planId: string): LessonPlanDetail | null {
|
||||
const found = MY_PLANS.find((p) => p.id === planId);
|
||||
if (!found) return null;
|
||||
return {
|
||||
...found,
|
||||
content: planContent(found.title, found.subject, found.grade, found.chapter),
|
||||
aiHistory: [
|
||||
{
|
||||
id: `ai-${found.id}-1`,
|
||||
prompt: `为「${found.title}」生成教学过程`,
|
||||
generatedAt: found.updatedAt,
|
||||
model: "edu-llm-p7-v1",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-06-20T08:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
/** 按学科筛选我的课案 */
|
||||
export function filterLessonPlans(subject?: string | null): LessonPlanItem[] {
|
||||
if (!subject || subject === "全部") return mockLessonPlans;
|
||||
return mockLessonPlans.filter((p) => p.subject === subject);
|
||||
}
|
||||
|
||||
/** 创建课案 */
|
||||
export function createMockLessonPlan(
|
||||
input: CreateLessonPlanInput,
|
||||
): LessonPlanItem {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `lp-${String(Date.now()).slice(-6)}`,
|
||||
title: input.title,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
chapter: input.chapter,
|
||||
status: "DRAFT",
|
||||
textbookId: input.textbookId ?? null,
|
||||
textbookTitle: null,
|
||||
classIds: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新课案(mock:返回更新后的最小信息) */
|
||||
export function updateMockLessonPlan(
|
||||
input: UpdateLessonPlanInput,
|
||||
): LessonPlanItem | null {
|
||||
const found = MY_PLANS.find((p) => p.id === input.planId);
|
||||
if (!found) return null;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...toItem(found),
|
||||
title: input.title ?? found.title,
|
||||
status: input.status ?? found.status,
|
||||
textbookId: input.textbookId !== undefined ? input.textbookId : found.textbookId,
|
||||
chapter: input.chapter ?? found.chapter,
|
||||
classIds: input.classIds ?? found.classIds,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** AI 生成课案(mock:返回一段 markdown) */
|
||||
export function generateMockLessonPlan(
|
||||
input: AIGenerateLessonPlanInput,
|
||||
): AIGenerateLessonPlanResult {
|
||||
const now = new Date().toISOString();
|
||||
const seq = String(Date.now()).slice(-6);
|
||||
const content = `# AI 生成教案:${input.chapter}
|
||||
|
||||
## 学科 ${input.subject} · 年级 ${input.grade}
|
||||
|
||||
## 教学目标
|
||||
1. 掌握 ${input.chapter} 的核心概念
|
||||
2. 能够运用相关知识解决典型问题
|
||||
3. 培养学科思维与迁移能力
|
||||
|
||||
## 教学过程
|
||||
### 一、导入(5 分钟)
|
||||
${input.prompt}
|
||||
|
||||
### 二、新知讲解(15 分钟)
|
||||
- 概念定义与示例
|
||||
- 关键性质剖析
|
||||
- 典型例题分层讲解
|
||||
|
||||
### 三、巩固练习(12 分钟)
|
||||
- 基础题 / 提升题 / 拓展题
|
||||
|
||||
### 四、课堂小结(5 分钟)
|
||||
### 五、作业布置(3 分钟)`;
|
||||
return {
|
||||
id: `ai-lp-${seq}`,
|
||||
content,
|
||||
summary: `围绕「${input.chapter}」生成的 ${input.subject} 教案,含教学目标、教学过程、作业布置。`,
|
||||
model: "edu-llm-p7-v1",
|
||||
generatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 20 个校内库课案(他人发布) */
|
||||
const AUTHORS = [
|
||||
"王老师", "李老师", "张老师", "陈老师", "刘老师",
|
||||
"赵老师", "黄老师", "周老师", "吴老师", "徐老师",
|
||||
];
|
||||
|
||||
function buildLibraryItem(idx: number): LessonPlanLibraryItem {
|
||||
const subject = SUBJECTS[idx % SUBJECTS.length] ?? "数学";
|
||||
const grade = GRADES[idx % GRADES.length] ?? "初中一年级";
|
||||
const author = AUTHORS[idx % AUTHORS.length] ?? "佚名";
|
||||
const titles: Record<string, string[]> = {
|
||||
语文: ["《荷塘月色》赏析", "《背影》情感分析", "议论文写作入门", "古诗词意象专题"],
|
||||
数学: ["二次函数图像", "概率统计初步", "立体几何证明", "导数综合应用"],
|
||||
英语: ["Reading for Theme", "Writing a Narrative", "Grammar in Context", "Listening Strategies"],
|
||||
};
|
||||
const subjectTitles = titles[subject] ?? ["通用教案"];
|
||||
const title = subjectTitles[idx % subjectTitles.length] ?? `${subject} 教案 ${idx}`;
|
||||
return {
|
||||
id: `lp-lib-${String(idx).padStart(3, "0")}`,
|
||||
title,
|
||||
author,
|
||||
subject,
|
||||
grade,
|
||||
chapter: `第 ${idx + 1} 章`,
|
||||
rating: Math.round((3.5 + (idx % 1.5)) * 10) / 10,
|
||||
forkCount: Math.floor(((idx * 7) % 50) + 5),
|
||||
updatedAt: `2026-06-${String(28 - (idx % 27)).padStart(2, "0")}T09:00:00Z`,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockLessonPlanLibrary: LessonPlanLibraryItem[] = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => buildLibraryItem(i),
|
||||
);
|
||||
|
||||
/** 按学科 + 年级筛选校内库 */
|
||||
export function filterLessonPlanLibrary(
|
||||
subject?: string | null,
|
||||
grade?: string | null,
|
||||
): LessonPlanLibraryItem[] {
|
||||
return mockLessonPlanLibrary.filter((p) => {
|
||||
if (subject && subject !== "全部" && p.subject !== subject) return false;
|
||||
if (grade && grade !== "全部" && p.grade !== grade) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fork 课案(mock:复制为新课案) */
|
||||
export function forkMockLessonPlan(planId: string): LessonPlanItem | null {
|
||||
const lib = mockLessonPlanLibrary.find((p) => p.id === planId);
|
||||
if (!lib) return null;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `lp-${String(Date.now()).slice(-6)}`,
|
||||
title: `${lib.title}(Fork)`,
|
||||
subject: lib.subject,
|
||||
grade: lib.grade,
|
||||
chapter: lib.chapter,
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 课案日历视图(按月份) */
|
||||
export function getLessonPlanCalendar(
|
||||
year: number,
|
||||
month: number,
|
||||
): LessonPlanCalendarMonth {
|
||||
// 当月每天有 0-3 个课案(确定性 mock)
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const days: LessonPlanCalendarDay[] = [];
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const seed = (year * 100 + month) * 31 + d;
|
||||
const planCount = seed % 4 === 0 ? 2 : seed % 3 === 0 ? 1 : 0;
|
||||
if (planCount === 0) {
|
||||
days.push({ date: `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`, planCount: 0, planTitles: [] });
|
||||
} else {
|
||||
const titles: string[] = [];
|
||||
for (let i = 0; i < planCount; i++) {
|
||||
titles.push(`课案 ${d}-${i + 1}`);
|
||||
}
|
||||
days.push({
|
||||
date: `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
|
||||
planCount,
|
||||
planTitles: titles,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { year, month, days };
|
||||
}
|
||||
|
||||
/** 课标覆盖热力图(教材知识点 × 课案覆盖矩阵) */
|
||||
export function getLessonPlanHeatmap(): LessonPlanHeatmap {
|
||||
const rows = ["函数", "方程", "几何", "概率", "统计", "数列", "导数", "实数"];
|
||||
const cols = ["lp-001", "lp-002", "lp-004", "lp-008", "lp-010"];
|
||||
const cells = rows.map((r) =>
|
||||
cols.map((c) => ({
|
||||
textbookKp: r,
|
||||
planIds: (r.charCodeAt(0) + c.charCodeAt(3)) % 3 === 0 ? [c] : [],
|
||||
covered: (r.charCodeAt(0) + c.charCodeAt(3)) % 3 === 0,
|
||||
})),
|
||||
).flat();
|
||||
return { rows, cols, cells };
|
||||
}
|
||||
77
apps/teacher-portal/src/mocks/fixtures/notifications.ts
Normal file
77
apps/teacher-portal/src/mocks/fixtures/notifications.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Mock 通知数据(push-gateway + msg 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送
|
||||
*/
|
||||
|
||||
import type { NotificationItem } from "@/lib/graphql-p5";
|
||||
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
export const mockNotifications: NotificationItem[] = [
|
||||
{
|
||||
id: "ntf-001",
|
||||
type: "HOMEWORK_SUBMITTED",
|
||||
title: "新作业提交",
|
||||
message: "高三(1)班 李明 提交了《函数与导数练习》",
|
||||
read: false,
|
||||
createdAt: new Date(now - 5 * minute).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-002",
|
||||
type: "EXAM_GRADED",
|
||||
title: "考试已评分",
|
||||
message: "高三(2)班《2026 春季期中考试》成绩已发布,平均分 112.5",
|
||||
read: false,
|
||||
createdAt: new Date(now - 35 * minute).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-003",
|
||||
type: "BROADCAST",
|
||||
title: "学校公告",
|
||||
message: "本周五下午 14:00 召开教研组会议,请准时参加",
|
||||
read: false,
|
||||
createdAt: new Date(now - 2 * hour).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-004",
|
||||
type: "HOMEWORK_SUBMITTED",
|
||||
title: "新作业提交",
|
||||
message: "高三(1)班 王芳 提交了《三角函数复习》",
|
||||
read: true,
|
||||
createdAt: new Date(now - 5 * hour).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-005",
|
||||
type: "ROLE_CHANGED",
|
||||
title: "角色变更",
|
||||
message: "您的角色已更新为:年级组长(GRADE 数据范围)",
|
||||
read: true,
|
||||
createdAt: new Date(now - 1 * day).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-006",
|
||||
type: "EXAM_GRADED",
|
||||
title: "考试已评分",
|
||||
message: "高三(3)班《月考 - 数学综合》成绩已发布,平均分 98.2",
|
||||
read: false,
|
||||
createdAt: new Date(now - 1 * day - 3 * hour).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-007",
|
||||
type: "BROADCAST",
|
||||
title: "系统通知",
|
||||
message: "成绩录入截止日期为本周日 23:59,请及时完成",
|
||||
read: true,
|
||||
createdAt: new Date(now - 2 * day).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
/** 按 id 查找 mock 通知 */
|
||||
export function findNotification(id: string): NotificationItem | null {
|
||||
return mockNotifications.find((n) => n.id === id) ?? null;
|
||||
}
|
||||
152
apps/teacher-portal/src/mocks/fixtures/practice.ts
Normal file
152
apps/teacher-portal/src/mocks/fixtures/practice.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Mock 自适应练习数据(practice 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 班级练习统计(含 5 指标)
|
||||
* - 类型分布(5 类型:单选/多选/判断/填空/简答)
|
||||
* - 知识点薄弱度(10 知识点)
|
||||
* - 学生排名(20 学生)
|
||||
* - 未参与提醒(最近 7 天未练习)
|
||||
*/
|
||||
|
||||
import type {
|
||||
PracticeAnalytics,
|
||||
PracticeSummary,
|
||||
PracticeClassComparison,
|
||||
PracticeTypeBreakdown,
|
||||
PracticeKpWeakness,
|
||||
PracticeStudentRank,
|
||||
PracticeInactiveStudent,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 5 个班级 */
|
||||
const CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", sessions: 240, accuracy: 0.78, completion: 0.85, participation: 0.92 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", sessions: 220, accuracy: 0.82, completion: 0.88, participation: 0.94 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", sessions: 260, accuracy: 0.75, completion: 0.82, participation: 0.89 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", sessions: 200, accuracy: 0.80, completion: 0.86, participation: 0.91 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", sessions: 280, accuracy: 0.85, completion: 0.90, participation: 0.95 },
|
||||
] as const;
|
||||
|
||||
/** 5 类型分布 */
|
||||
const TYPE_DEFS = [
|
||||
{ type: "单选题", count: 380, ratio: 0.42 },
|
||||
{ type: "多选题", count: 180, ratio: 0.20 },
|
||||
{ type: "判断题", count: 120, ratio: 0.13 },
|
||||
{ type: "填空题", count: 140, ratio: 0.16 },
|
||||
{ type: "简答题", count: 80, ratio: 0.09 },
|
||||
] as const;
|
||||
|
||||
/** 10 知识点(薄弱度) */
|
||||
const KP_DEFS = [
|
||||
{ knowledgePoint: "函数与导数", accuracy: 0.62 },
|
||||
{ knowledgePoint: "数列", accuracy: 0.68 },
|
||||
{ knowledgePoint: "立体几何", accuracy: 0.72 },
|
||||
{ knowledgePoint: "解析几何", accuracy: 0.58 },
|
||||
{ knowledgePoint: "概率统计", accuracy: 0.75 },
|
||||
{ knowledgePoint: "实数运算", accuracy: 0.85 },
|
||||
{ knowledgePoint: "三角函数", accuracy: 0.70 },
|
||||
{ knowledgePoint: "不等式", accuracy: 0.66 },
|
||||
{ knowledgePoint: "复数", accuracy: 0.80 },
|
||||
{ knowledgePoint: "向量", accuracy: 0.74 },
|
||||
] as const;
|
||||
|
||||
/** 生成学生排名(20 学生) */
|
||||
function generateRankings(classId: string): PracticeStudentRank[] {
|
||||
const cls = CLASSES.find((c) => c.id === classId) ?? CLASSES[0]!;
|
||||
const baseAccuracy = cls.accuracy;
|
||||
const ranks: PracticeStudentRank[] = [];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const delta = ((i - 10) * 0.02);
|
||||
const accuracy = Math.max(0.4, Math.min(0.98, baseAccuracy - delta));
|
||||
ranks.push({
|
||||
rank: i,
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
totalSessions: Math.round(cls.sessions / 20) + (i % 3),
|
||||
accuracy: Math.round(accuracy * 100) / 100,
|
||||
weakKp: i % 3 === 0 ? "函数与导数" : i % 3 === 1 ? "解析几何" : "概率统计",
|
||||
});
|
||||
}
|
||||
ranks.sort((a, b) => b.accuracy - a.accuracy);
|
||||
return ranks.map((r, i) => ({ ...r, rank: i + 1 }));
|
||||
}
|
||||
|
||||
/** 生成未参与学生(5 名) */
|
||||
function generateInactiveStudents(classId: string): PracticeInactiveStudent[] {
|
||||
return [
|
||||
{ studentId: `stu-${classId.slice(-4)}-007`, studentName: "学生07", lastActiveDays: 7 },
|
||||
{ studentId: `stu-${classId.slice(-4)}-011`, studentName: "学生11", lastActiveDays: 8 },
|
||||
{ studentId: `stu-${classId.slice(-4)}-014`, studentName: "学生14", lastActiveDays: 10 },
|
||||
{ studentId: `stu-${classId.slice(-4)}-018`, studentName: "学生18", lastActiveDays: 9 },
|
||||
{ studentId: `stu-${classId.slice(-4)}-020`, studentName: "学生20", lastActiveDays: 12 },
|
||||
];
|
||||
}
|
||||
|
||||
/** 班级对比表 */
|
||||
export function getClassComparison(): PracticeClassComparison[] {
|
||||
return CLASSES.map((c) => ({
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
totalSessions: c.sessions,
|
||||
completionRate: c.completion,
|
||||
avgAccuracy: c.accuracy,
|
||||
participationRate: c.participation,
|
||||
weakKpCount: Math.floor((1 - c.accuracy) * 20),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 类型分布 */
|
||||
export function getTypeBreakdown(): PracticeTypeBreakdown[] {
|
||||
return TYPE_DEFS.map((t) => ({ ...t }));
|
||||
}
|
||||
|
||||
/** 知识点薄弱度 */
|
||||
export function getKpWeakness(): PracticeKpWeakness[] {
|
||||
return KP_DEFS.map((k) => ({ ...k }));
|
||||
}
|
||||
|
||||
/** 聚合查询 */
|
||||
export function getPracticeAnalytics(
|
||||
classId?: string | null,
|
||||
): PracticeAnalytics {
|
||||
const allClasses = getClassComparison();
|
||||
const targetClass = classId
|
||||
? allClasses.find((c) => c.classId === classId)
|
||||
: null;
|
||||
|
||||
// 单班级视图 vs 全部视图
|
||||
const targetSessions = targetClass ? targetClass.totalSessions : allClasses.reduce((acc, c) => acc + c.totalSessions, 0);
|
||||
const targetAccuracy = targetClass
|
||||
? targetClass.avgAccuracy
|
||||
: allClasses.reduce((acc, c) => acc + c.avgAccuracy, 0) / allClasses.length;
|
||||
const targetCompletion = targetClass
|
||||
? targetClass.completionRate
|
||||
: allClasses.reduce((acc, c) => acc + c.completionRate, 0) / allClasses.length;
|
||||
const targetParticipation = targetClass
|
||||
? targetClass.participationRate
|
||||
: allClasses.reduce((acc, c) => acc + c.participationRate, 0) / allClasses.length;
|
||||
|
||||
const summary: PracticeSummary = {
|
||||
totalSessions: targetSessions,
|
||||
completionRate: Math.round(targetCompletion * 100) / 100,
|
||||
avgAccuracy: Math.round(targetAccuracy * 100) / 100,
|
||||
weakKpCount: 6,
|
||||
participationRate: Math.round(targetParticipation * 100) / 100,
|
||||
};
|
||||
|
||||
const targetClassId = classId ?? CLASSES[0]!.id;
|
||||
|
||||
return {
|
||||
classId: classId ?? "",
|
||||
summary,
|
||||
classComparison: allClasses,
|
||||
typeBreakdown: getTypeBreakdown(),
|
||||
kpWeakness: getKpWeakness(),
|
||||
rankings: generateRankings(targetClassId),
|
||||
inactiveStudents: generateInactiveStudents(targetClassId),
|
||||
};
|
||||
}
|
||||
154
apps/teacher-portal/src/mocks/fixtures/proctoring.ts
Normal file
154
apps/teacher-portal/src/mocks/fixtures/proctoring.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Mock 监考数据(P7-advanced:监考模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 场考试 × 30 学生监考状态
|
||||
* - 监考事件流(20 个事件)
|
||||
* - 按 examId 查询监考状态 + 提交事件
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProctoringStatus,
|
||||
ProctoringSummary,
|
||||
ProctoringStudent,
|
||||
ProctoringEvent,
|
||||
ProctoringStudentStatus,
|
||||
ProctoringEventInput,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 5 场考试定义 */
|
||||
const EXAM_DEFS: Array<{
|
||||
examId: string;
|
||||
title: string;
|
||||
expectedCount: number;
|
||||
isProctoring: boolean;
|
||||
}> = [
|
||||
{ examId: "proctor-exam-001", title: "2026 春季期中考试", expectedCount: 30, isProctoring: true },
|
||||
{ examId: "proctor-exam-002", title: "单元测试 - 函数与导数", expectedCount: 28, isProctoring: true },
|
||||
{ examId: "proctor-exam-003", title: "月考 - 数学综合", expectedCount: 32, isProctoring: false },
|
||||
{ examId: "proctor-exam-004", title: "期末模拟考", expectedCount: 30, isProctoring: true },
|
||||
{ examId: "proctor-exam-005", title: "随堂测验 - 解析几何", expectedCount: 25, isProctoring: false },
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 学生姓名池(30 个) */
|
||||
const STUDENT_NAMES = [
|
||||
"张明", "李华", "王芳", "刘洋", "陈静", "杨帆", "赵磊", "孙丽", "周强", "吴敏",
|
||||
"郑浩", "钱莹", "冯涛", "陈雪", "褚伟", "卫国", "蒋雯", "沈鹏", "韩雪", "杨光",
|
||||
"朱琳", "秦宇", "尤佳", "许诺", "何欢", "吕娜", "施磊", "张超", "孔慧", "曹阳",
|
||||
];
|
||||
|
||||
/** 生成单场考试的学生监考状态列表 */
|
||||
function generateProctoringStudents(examId: string, count: number): ProctoringStudent[] {
|
||||
const list: ProctoringStudent[] = [];
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const seed = strToSeed(`${examId}-stu-${i}`);
|
||||
const r = seededRandom(seed);
|
||||
let status: ProctoringStudentStatus;
|
||||
if (r > 0.85) status = "flagged";
|
||||
else if (r > 0.7) status = "submitted";
|
||||
else if (r > 0.1) status = "online";
|
||||
else status = "offline";
|
||||
|
||||
list.push({
|
||||
studentId: `stu-${examId}-${String(i + 1).padStart(3, "0")}`,
|
||||
studentName: STUDENT_NAMES[i] ?? `学生${i + 1}`,
|
||||
studentNo: `2026${String(i + 1).padStart(4, "0")}`,
|
||||
status,
|
||||
lastEventAt: status === "offline" ? null : new Date(now - i * 60 * 1000).toISOString(),
|
||||
ipAddress: status === "offline" ? null : `192.168.1.${100 + i}`,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 生成监考事件流(按时间倒序) */
|
||||
function generateProctoringEvents(examId: string): ProctoringEvent[] {
|
||||
const EVENT_TYPES: Array<{ type: ProctoringEvent["eventType"]; note: string }> = [
|
||||
{ type: "WARNING", note: "切屏到其他应用" },
|
||||
{ type: "FLAG", note: "多次切屏,疑似作弊" },
|
||||
{ type: "NOTE", note: "教师手动备注:已提醒" },
|
||||
{ type: "WARNING", note: "浏览器失去焦点" },
|
||||
{ type: "FLAG", note: "检测到复制粘贴行为" },
|
||||
{ type: "NOTE", note: "学生请求上厕所" },
|
||||
{ type: "WARNING", note: "网络连接不稳定" },
|
||||
{ type: "FLAG", note: "全屏模式退出" },
|
||||
{ type: "NOTE", note: "已重新连接监考" },
|
||||
{ type: "WARNING", note: "麦克风静音" },
|
||||
];
|
||||
const now = Date.now();
|
||||
const events: ProctoringEvent[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const seed = strToSeed(`${examId}-evt-${i}`);
|
||||
const def = EVENT_TYPES[i % EVENT_TYPES.length] ?? EVENT_TYPES[0]!;
|
||||
const studentIdx = Math.floor(seededRandom(seed + 1) * 30);
|
||||
events.push({
|
||||
eventId: `evt-${examId}-${String(i + 1).padStart(3, "0")}`,
|
||||
examId,
|
||||
studentId: `stu-${examId}-${String(studentIdx + 1).padStart(3, "0")}`,
|
||||
studentName: STUDENT_NAMES[studentIdx] ?? `学生${studentIdx + 1}`,
|
||||
studentNo: `2026${String(studentIdx + 1).padStart(4, "0")}`,
|
||||
eventType: def.type,
|
||||
note: def.note,
|
||||
createdAt: new Date(now - i * 3 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** 生成监考摘要 */
|
||||
function generateSummary(examId: string): ProctoringSummary {
|
||||
const def = EXAM_DEFS.find((e) => e.examId === examId) ?? EXAM_DEFS[0]!;
|
||||
const students = generateProctoringStudents(examId, def.expectedCount);
|
||||
const onlineCount = students.filter((s) => s.status === "online").length;
|
||||
const submittedCount = students.filter((s) => s.status === "submitted").length;
|
||||
const flaggedCount = students.filter((s) => s.status === "flagged").length;
|
||||
const now = Date.now();
|
||||
return {
|
||||
examId,
|
||||
expectedCount: def.expectedCount,
|
||||
onlineCount,
|
||||
submittedCount,
|
||||
flaggedCount,
|
||||
isProctoring: def.isProctoring,
|
||||
startedAt: def.isProctoring ? new Date(now - 30 * 60 * 1000).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 按考试 ID 查询监考状态(摘要 + 学生列表 + 近期事件) */
|
||||
export function findProctoringStatus(examId: string): ProctoringStatus {
|
||||
const summary = generateSummary(examId);
|
||||
const students = generateProctoringStudents(examId, summary.expectedCount);
|
||||
const recentEvents = generateProctoringEvents(examId);
|
||||
return {
|
||||
summary,
|
||||
students,
|
||||
recentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/** 提交监考事件(mock:返回事件 ID + 创建时间) */
|
||||
export function createProctoringEvent(
|
||||
input: ProctoringEventInput,
|
||||
): { eventId: string; createdAt: string } {
|
||||
return {
|
||||
eventId: `evt-${input.examId}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
391
apps/teacher-portal/src/mocks/fixtures/questions.ts
Normal file
391
apps/teacher-portal/src/mocks/fixtures/questions.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Mock 题库数据(content 域,P7 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 30 道题(5 类型 × 6 题,难度均匀分布 EASY/MEDIUM/HARD)
|
||||
* - 含 questionId/content/type/difficulty/textbookId/chapterId/kpId/score/answer/analysis
|
||||
* - 引用 textbooks.ts 中的前 5 本教材的章节与知识点
|
||||
*/
|
||||
|
||||
import type {
|
||||
QuestionItem,
|
||||
QuestionType,
|
||||
QuestionDifficulty,
|
||||
QuestionsLibraryFilter,
|
||||
QuestionsLibraryPage,
|
||||
QuestionCreateInput,
|
||||
QuestionUpdateInput,
|
||||
QuestionBatchImportInput,
|
||||
QuestionBatchImportResult,
|
||||
QuestionBatchExportResult,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import { MOCK_TEXTBOOK_DETAILS } from "./textbooks";
|
||||
|
||||
/** 题目类型枚举顺序 */
|
||||
const QUESTION_TYPES: QuestionType[] = [
|
||||
"SINGLE_CHOICE",
|
||||
"MULTIPLE_CHOICE",
|
||||
"TRUE_FALSE",
|
||||
"FILL_BLANK",
|
||||
"SHORT_ANSWER",
|
||||
];
|
||||
|
||||
/** 难度枚举 */
|
||||
const DIFFICULTIES: QuestionDifficulty[] = ["EASY", "MEDIUM", "HARD"];
|
||||
|
||||
/** 中文类型标签(用于题目内容模板) */
|
||||
const TYPE_LABEL: Record<QuestionType, string> = {
|
||||
SINGLE_CHOICE: "单项选择",
|
||||
MULTIPLE_CHOICE: "多项选择",
|
||||
TRUE_FALSE: "判断",
|
||||
FILL_BLANK: "填空",
|
||||
SHORT_ANSWER: "简答",
|
||||
};
|
||||
|
||||
/** 题目内容模板 */
|
||||
function questionContent(
|
||||
type: QuestionType,
|
||||
subject: string,
|
||||
kp: string,
|
||||
idx: number,
|
||||
): string {
|
||||
const labels = TYPE_LABEL[type];
|
||||
if (type === "SINGLE_CHOICE" || type === "MULTIPLE_CHOICE") {
|
||||
return `【${labels}】${subject}·${kp}(题 ${idx}):下列关于${kp}的描述,正确的是?\nA. 选项 A:基本概念表述\nB. 选项 B:进阶应用举例\nC. 选项 C:常见错误理解\nD. 选项 D:综合分析结论`;
|
||||
}
|
||||
if (type === "TRUE_FALSE") {
|
||||
return `【判断题】${subject}·${kp}(题 ${idx}):${kp}的核心定义成立时,可以推出相关结论。(请判断对错)`;
|
||||
}
|
||||
if (type === "FILL_BLANK") {
|
||||
return `【填空题】${subject}·${kp}(题 ${idx}):根据${kp}的基本原理,可得 ______,进而推出 ______。`;
|
||||
}
|
||||
return `【简答题】${subject}·${kp}(题 ${idx}):请简述${kp}的定义、性质与典型应用场景。`;
|
||||
}
|
||||
|
||||
/** 标准答案模板 */
|
||||
function answerTemplate(type: QuestionType): string {
|
||||
switch (type) {
|
||||
case "SINGLE_CHOICE":
|
||||
return "B";
|
||||
case "MULTIPLE_CHOICE":
|
||||
return "A, C, D";
|
||||
case "TRUE_FALSE":
|
||||
return "正确";
|
||||
case "FILL_BLANK":
|
||||
return "对应公式结论;迁移应用结果";
|
||||
case "SHORT_ANSWER":
|
||||
return `${TYPE_LABEL[type]}标准答案:先阐述定义,再说明性质,最后给出两个典型应用示例。`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析模板 */
|
||||
function analysisTemplate(type: QuestionType, kp: string): string {
|
||||
return `解析要点:本题考查${kp}。需先回顾基本定义,再结合选项/空缺判断正误;若涉及简答,应分层作答,做到逻辑清晰、有理有据。题型:${TYPE_LABEL[type]}。`;
|
||||
}
|
||||
|
||||
/** 从前 5 本教材中收集可用 textbook/chapter/kp 三元组 */
|
||||
interface TbkRef {
|
||||
textbookId: string;
|
||||
textbookName: string;
|
||||
chapterId: string;
|
||||
chapterName: string;
|
||||
kpId: string;
|
||||
kpName: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
function collectTextbookRefs(): TbkRef[] {
|
||||
const refs: TbkRef[] = [];
|
||||
const targetTextbooks = MOCK_TEXTBOOK_DETAILS.slice(0, 5);
|
||||
for (const tb of targetTextbooks) {
|
||||
for (const ch of tb.chapters) {
|
||||
for (const kp of ch.knowledgePoints) {
|
||||
refs.push({
|
||||
textbookId: tb.id,
|
||||
textbookName: tb.title,
|
||||
chapterId: ch.id,
|
||||
chapterName: ch.title,
|
||||
kpId: kp.id,
|
||||
kpName: kp.name,
|
||||
subject: tb.subject,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
const TEXTBOOK_REFS = collectTextbookRefs();
|
||||
|
||||
/** 按 (类型索引 * 6 + 题内序号) 选取 textbook ref,保证 30 题覆盖前 5 本教材 */
|
||||
function pickRef(typeIdx: number, qIdx: number): TbkRef {
|
||||
const seed = (typeIdx * 6 + qIdx) % TEXTBOOK_REFS.length;
|
||||
// 题目均匀分布在所有可用 ref 上
|
||||
const ref = TEXTBOOK_REFS[seed];
|
||||
if (!ref) {
|
||||
// 兜底(理论上不会进入,因为 TEXTBOOK_REFS 至少有数十条)
|
||||
const fallback = TEXTBOOK_REFS[0];
|
||||
if (!fallback) {
|
||||
throw new Error("textbook refs empty");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** 生成 30 道题 */
|
||||
function generateQuestions(): QuestionItem[] {
|
||||
const now = Date.now();
|
||||
const items: QuestionItem[] = [];
|
||||
// 5 类型 × 6 题 = 30
|
||||
QUESTION_TYPES.forEach((type, typeIdx) => {
|
||||
for (let q = 0; q < 6; q++) {
|
||||
const ref = pickRef(typeIdx, q);
|
||||
const difficulty = DIFFICULTIES[q % DIFFICULTIES.length] ?? "MEDIUM";
|
||||
const idx = typeIdx * 6 + q + 1;
|
||||
const questionId = `q-${String(idx).padStart(3, "0")}`;
|
||||
items.push({
|
||||
questionId,
|
||||
content: questionContent(type, ref.subject, ref.kpName, idx),
|
||||
type,
|
||||
difficulty,
|
||||
textbookId: ref.textbookId,
|
||||
textbookName: ref.textbookName,
|
||||
chapterId: ref.chapterId,
|
||||
chapterName: ref.chapterName,
|
||||
kpId: ref.kpId,
|
||||
kpName: ref.kpName,
|
||||
score: type === "SHORT_ANSWER" ? 10 : type === "FILL_BLANK" ? 5 : 4,
|
||||
answer: answerTemplate(type),
|
||||
analysis: analysisTemplate(type, ref.kpName),
|
||||
createdAt: new Date(now - idx * 60_000).toISOString(),
|
||||
updatedAt: new Date(now - idx * 30_000).toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 内存中题库(支持运行时插入/更新/删除),导出供 exam-analytics 等模块消费初始快照 */
|
||||
export let MOCK_QUESTIONS: QuestionItem[] = generateQuestions();
|
||||
|
||||
/** 计数器(用于新建题目 ID) */
|
||||
let questionCounter = MOCK_QUESTIONS.length;
|
||||
|
||||
/** 按筛选条件过滤题库(分页) */
|
||||
export function filterQuestions(
|
||||
filter: QuestionsLibraryFilter,
|
||||
): QuestionsLibraryPage {
|
||||
const q = (filter.q ?? "").trim().toLowerCase();
|
||||
const type = filter.type ?? null;
|
||||
const difficulty = filter.difficulty ?? null;
|
||||
const kp = filter.kp ?? null;
|
||||
const textbook = filter.textbook ?? null;
|
||||
const chapter = filter.chapter ?? null;
|
||||
const page = Math.max(1, filter.page ?? 1);
|
||||
const pageSize = Math.max(1, filter.pageSize ?? 20);
|
||||
|
||||
let items = MOCK_QUESTIONS;
|
||||
if (q) {
|
||||
items = items.filter(
|
||||
(it) =>
|
||||
it.content.toLowerCase().includes(q) ||
|
||||
it.kpName?.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (type) {
|
||||
items = items.filter((it) => it.type === type);
|
||||
}
|
||||
if (difficulty) {
|
||||
items = items.filter((it) => it.difficulty === difficulty);
|
||||
}
|
||||
if (kp && kp !== "all") {
|
||||
items = items.filter((it) => it.kpId === kp);
|
||||
}
|
||||
if (textbook && textbook !== "all") {
|
||||
items = items.filter((it) => it.textbookId === textbook);
|
||||
}
|
||||
if (chapter && chapter !== "all") {
|
||||
items = items.filter((it) => it.chapterId === chapter);
|
||||
}
|
||||
|
||||
// 按 createdAt 倒序
|
||||
const sorted = [...items].sort((a, b) =>
|
||||
b.createdAt.localeCompare(a.createdAt),
|
||||
);
|
||||
const total = sorted.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paged = sorted.slice(start, start + pageSize);
|
||||
return { items: paged, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** 按 ID 查找题目 */
|
||||
export function findQuestion(questionId: string): QuestionItem | null {
|
||||
return MOCK_QUESTIONS.find((q) => q.questionId === questionId) ?? null;
|
||||
}
|
||||
|
||||
/** 创建题目(持久化到内存) */
|
||||
export function createMockQuestion(
|
||||
input: QuestionCreateInput,
|
||||
): QuestionItem {
|
||||
questionCounter += 1;
|
||||
const now = new Date().toISOString();
|
||||
const questionId = `q-${String(questionCounter).padStart(3, "0")}`;
|
||||
// 查找教材/章节/知识点名称(如引用存在)
|
||||
let textbookName: string | null = null;
|
||||
let chapterName: string | null = null;
|
||||
let kpName: string | null = null;
|
||||
if (input.textbookId) {
|
||||
const tb = MOCK_TEXTBOOK_DETAILS.find((t) => t.id === input.textbookId);
|
||||
textbookName = tb?.title ?? null;
|
||||
if (tb && input.chapterId) {
|
||||
const ch = tb.chapters.find((c) => c.id === input.chapterId);
|
||||
chapterName = ch?.title ?? null;
|
||||
if (ch && input.kpId) {
|
||||
const kp = ch.knowledgePoints.find((k) => k.id === input.kpId);
|
||||
kpName = kp?.name ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const item: QuestionItem = {
|
||||
questionId,
|
||||
content: input.content,
|
||||
type: input.type,
|
||||
difficulty: input.difficulty,
|
||||
textbookId: input.textbookId ?? null,
|
||||
textbookName,
|
||||
chapterId: input.chapterId ?? null,
|
||||
chapterName,
|
||||
kpId: input.kpId ?? null,
|
||||
kpName,
|
||||
score: input.score,
|
||||
answer: input.answer,
|
||||
analysis: input.analysis ?? "",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
MOCK_QUESTIONS = [item, ...MOCK_QUESTIONS];
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 更新题目 */
|
||||
export function updateMockQuestion(
|
||||
input: QuestionUpdateInput,
|
||||
): QuestionItem | null {
|
||||
const idx = MOCK_QUESTIONS.findIndex((q) => q.questionId === input.questionId);
|
||||
if (idx === -1) return null;
|
||||
const existing = MOCK_QUESTIONS[idx];
|
||||
if (!existing) return null;
|
||||
// 解析新的 textbook/chapter/kp 名称
|
||||
let textbookName = existing.textbookName;
|
||||
let chapterName = existing.chapterName;
|
||||
let kpName = existing.kpName;
|
||||
if (input.textbookId !== undefined) {
|
||||
const tb = MOCK_TEXTBOOK_DETAILS.find((t) => t.id === input.textbookId);
|
||||
textbookName = tb?.title ?? null;
|
||||
}
|
||||
if (input.chapterId !== undefined) {
|
||||
const tb = MOCK_TEXTBOOK_DETAILS.find(
|
||||
(t) => t.id === (input.textbookId ?? existing.textbookId),
|
||||
);
|
||||
const ch = tb?.chapters.find((c) => c.id === input.chapterId);
|
||||
chapterName = ch?.title ?? null;
|
||||
}
|
||||
if (input.kpId !== undefined) {
|
||||
const tb = MOCK_TEXTBOOK_DETAILS.find(
|
||||
(t) => t.id === (input.textbookId ?? existing.textbookId),
|
||||
);
|
||||
const ch = tb?.chapters.find(
|
||||
(c) => c.id === (input.chapterId ?? existing.chapterId),
|
||||
);
|
||||
const kp = ch?.knowledgePoints.find((k) => k.id === input.kpId);
|
||||
kpName = kp?.name ?? null;
|
||||
}
|
||||
const updated: QuestionItem = {
|
||||
...existing,
|
||||
content: input.content ?? existing.content,
|
||||
type: input.type ?? existing.type,
|
||||
difficulty: input.difficulty ?? existing.difficulty,
|
||||
textbookId: input.textbookId !== undefined ? input.textbookId : existing.textbookId,
|
||||
textbookName,
|
||||
chapterId: input.chapterId !== undefined ? input.chapterId : existing.chapterId,
|
||||
chapterName,
|
||||
kpId: input.kpId !== undefined ? input.kpId : existing.kpId,
|
||||
kpName,
|
||||
score: input.score ?? existing.score,
|
||||
answer: input.answer ?? existing.answer,
|
||||
analysis: input.analysis ?? existing.analysis,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// 替换 store 中的元素
|
||||
const next = [...MOCK_QUESTIONS];
|
||||
next[idx] = updated;
|
||||
MOCK_QUESTIONS = next;
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 删除题目 */
|
||||
export function deleteMockQuestion(questionId: string): boolean {
|
||||
const idx = MOCK_QUESTIONS.findIndex((q) => q.questionId === questionId);
|
||||
if (idx === -1) return false;
|
||||
MOCK_QUESTIONS = MOCK_QUESTIONS.filter((q) => q.questionId !== questionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 批量导入 */
|
||||
export function batchImportQuestions(
|
||||
input: QuestionBatchImportInput,
|
||||
): QuestionBatchImportResult {
|
||||
let importedCount = 0;
|
||||
let failedCount = 0;
|
||||
const errors: string[] = [];
|
||||
input.items.forEach((item, i) => {
|
||||
if (!item.content || !item.type || !item.difficulty) {
|
||||
failedCount += 1;
|
||||
errors.push(`第 ${i + 1} 题缺少必填字段(content/type/difficulty)`);
|
||||
return;
|
||||
}
|
||||
createMockQuestion(item);
|
||||
importedCount += 1;
|
||||
});
|
||||
return { importedCount, failedCount, errors };
|
||||
}
|
||||
|
||||
/** 批量导出 */
|
||||
export function batchExportQuestions(
|
||||
filter: QuestionsLibraryFilter,
|
||||
): QuestionBatchExportResult {
|
||||
const page = filterQuestions({ ...filter, page: 1, pageSize: 10_000 });
|
||||
return {
|
||||
items: page.items,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 题目类型选项(用于筛选/创建下拉) */
|
||||
export const QUESTION_TYPE_OPTIONS: ReadonlyArray<{
|
||||
value: QuestionType;
|
||||
label: string;
|
||||
}> = QUESTION_TYPES.map((t) => ({ value: t, label: TYPE_LABEL[t] }));
|
||||
|
||||
/** 难度选项 */
|
||||
export const QUESTION_DIFFICULTY_OPTIONS: ReadonlyArray<{
|
||||
value: QuestionDifficulty;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "EASY", label: "简单" },
|
||||
{ value: "MEDIUM", label: "中等" },
|
||||
{ value: "HARD", label: "困难" },
|
||||
];
|
||||
|
||||
/** 题目类型中文映射(外部消费) */
|
||||
export const QUESTION_TYPE_LABEL: Record<QuestionType, string> = TYPE_LABEL;
|
||||
|
||||
/** 难度中文映射 */
|
||||
export const QUESTION_DIFFICULTY_LABEL: Record<QuestionDifficulty, string> = {
|
||||
EASY: "简单",
|
||||
MEDIUM: "中等",
|
||||
HARD: "困难",
|
||||
};
|
||||
163
apps/teacher-portal/src/mocks/fixtures/scan-grading.ts
Normal file
163
apps/teacher-portal/src/mocks/fixtures/scan-grading.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Mock 扫描批改数据(P7-advanced:扫描批改模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个提交的扫描图片 URL(placeholder)
|
||||
* - 已识别作答 + AI 评分建议
|
||||
* - 按 submissionId 查询扫描批改视图 + 保存批改
|
||||
*/
|
||||
|
||||
import type {
|
||||
SubmissionScanGrading,
|
||||
ScanImage,
|
||||
ScanGradingItem,
|
||||
SaveScanGradingInput,
|
||||
SaveScanGradingResult,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 5 个提交定义 */
|
||||
const SUBMISSION_DEFS: Array<{
|
||||
submissionId: string;
|
||||
homeworkTitle: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
}> = [
|
||||
{
|
||||
submissionId: "scan-sub-001",
|
||||
homeworkTitle: "函数练习题",
|
||||
studentName: "张明",
|
||||
studentNo: "20260001",
|
||||
},
|
||||
{
|
||||
submissionId: "scan-sub-002",
|
||||
homeworkTitle: "导数应用题",
|
||||
studentName: "李华",
|
||||
studentNo: "20260002",
|
||||
},
|
||||
{
|
||||
submissionId: "scan-sub-003",
|
||||
homeworkTitle: "数列综合练习",
|
||||
studentName: "王芳",
|
||||
studentNo: "20260003",
|
||||
},
|
||||
{
|
||||
submissionId: "scan-sub-004",
|
||||
homeworkTitle: "立体几何作业",
|
||||
studentName: "刘洋",
|
||||
studentNo: "20260004",
|
||||
},
|
||||
{
|
||||
submissionId: "scan-sub-005",
|
||||
homeworkTitle: "解析几何专题",
|
||||
studentName: "陈静",
|
||||
studentNo: "20260005",
|
||||
},
|
||||
];
|
||||
|
||||
/** 生成扫描图片列表(每份提交 2-3 页,用 placeholder) */
|
||||
function generateScanImages(submissionId: string): ScanImage[] {
|
||||
const pageCount = 2 + (submissionId.charCodeAt(submissionId.length - 1) % 2);
|
||||
const images: ScanImage[] = [];
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
images.push({
|
||||
imageId: `img-${submissionId}-${i}`,
|
||||
// placeholder 图片:用 picsum 占位(项目规则不禁止 URL)
|
||||
url: `https://picsum.photos/seed/${submissionId}-${i}/800/1100`,
|
||||
page: i,
|
||||
});
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/** 题目模板(4 题) */
|
||||
const QUESTION_TEMPLATES: Array<{
|
||||
title: string;
|
||||
maxScore: number;
|
||||
recognizedAnswer: string;
|
||||
aiSuggestedScore: number;
|
||||
aiSuggestion: string;
|
||||
}> = [
|
||||
{
|
||||
title: "求函数 f(x) = x² - 2x + 1 在 [0, 2] 上的极值",
|
||||
maxScore: 25,
|
||||
recognizedAnswer: "f(1) = 0 为极小值",
|
||||
aiSuggestedScore: 22,
|
||||
aiSuggestion: "答案正确,步骤基本完整。建议补充极值点二阶导数验证。",
|
||||
},
|
||||
{
|
||||
title: "选择题:函数 y = ln(x) 的导数是",
|
||||
maxScore: 20,
|
||||
recognizedAnswer: "B. y = 1/x",
|
||||
aiSuggestedScore: 20,
|
||||
aiSuggestion: "选择题答案正确。",
|
||||
},
|
||||
{
|
||||
title: "计算定积分 ∫₀¹ (2x + 1) dx",
|
||||
maxScore: 25,
|
||||
recognizedAnswer: "积分结果为 2",
|
||||
aiSuggestedScore: 18,
|
||||
aiSuggestion: "结果正确,但缺少积分上下限代入的中间步骤。",
|
||||
},
|
||||
{
|
||||
title: "论述:导数在实际问题中的应用",
|
||||
maxScore: 30,
|
||||
recognizedAnswer: "导数可应用于最优化问题、物理学中的瞬时速度等",
|
||||
aiSuggestedScore: 24,
|
||||
aiSuggestion: "论述方向正确,但较为简略。建议补充至少一个具体实例。",
|
||||
},
|
||||
];
|
||||
|
||||
/** 按 submissionId 查询扫描批改视图 */
|
||||
export function findScanGrading(
|
||||
submissionId: string,
|
||||
): SubmissionScanGrading | null {
|
||||
const def = SUBMISSION_DEFS.find((s) => s.submissionId === submissionId);
|
||||
if (!def) return null;
|
||||
|
||||
// 确定 prev/next 导航链
|
||||
const idx = SUBMISSION_DEFS.findIndex((s) => s.submissionId === submissionId);
|
||||
const prevSubmissionId = idx > 0 ? (SUBMISSION_DEFS[idx - 1]?.submissionId ?? null) : null;
|
||||
const nextSubmissionId =
|
||||
idx >= 0 && idx < SUBMISSION_DEFS.length - 1
|
||||
? (SUBMISSION_DEFS[idx + 1]?.submissionId ?? null)
|
||||
: null;
|
||||
|
||||
const items: ScanGradingItem[] = QUESTION_TEMPLATES.map((q, i) => ({
|
||||
questionId: `${submissionId}-q-${String(i + 1).padStart(3, "0")}`,
|
||||
questionTitle: q.title,
|
||||
questionOrder: i + 1,
|
||||
maxScore: q.maxScore,
|
||||
recognizedAnswer: q.recognizedAnswer,
|
||||
aiSuggestedScore: q.aiSuggestedScore,
|
||||
aiSuggestion: q.aiSuggestion,
|
||||
score: null,
|
||||
feedback: null,
|
||||
}));
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
submissionId: def.submissionId,
|
||||
homeworkTitle: def.homeworkTitle,
|
||||
studentName: def.studentName,
|
||||
studentNo: def.studentNo,
|
||||
submittedAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
|
||||
images: generateScanImages(def.submissionId),
|
||||
items,
|
||||
prevSubmissionId,
|
||||
nextSubmissionId,
|
||||
};
|
||||
}
|
||||
|
||||
/** 保存扫描批改(mock:返回 totalScore + 时间戳) */
|
||||
export function saveScanGrading(
|
||||
input: SaveScanGradingInput,
|
||||
): SaveScanGradingResult {
|
||||
const totalScore = input.items.reduce((acc, item) => acc + (item.score || 0), 0);
|
||||
return {
|
||||
submissionId: input.submissionId,
|
||||
totalScore,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
249
apps/teacher-portal/src/mocks/fixtures/textbooks.ts
Normal file
249
apps/teacher-portal/src/mocks/fixtures/textbooks.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Mock 教材数据(content 域,P7 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 8 本教材(语文/数学/英语/物理/化学/生物/历史/地理,覆盖小学/初中/高中)
|
||||
* - 每本教材含 5-10 章节,每章节含 3-8 知识点
|
||||
* - 章节支持嵌套(parentId),mock 数据保持单层便于阅读器渲染
|
||||
*/
|
||||
|
||||
import type {
|
||||
TextbookItem,
|
||||
TextbookDetail,
|
||||
TextbookChapter,
|
||||
KnowledgePoint,
|
||||
TextbookCreateInput,
|
||||
TextbooksFilter,
|
||||
TextbooksPage,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
|
||||
/** 学科 */
|
||||
const SUBJECTS = [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
"历史",
|
||||
"地理",
|
||||
] as const;
|
||||
|
||||
/** 年级 */
|
||||
const GRADES = [
|
||||
"小学三年级",
|
||||
"小学六年级",
|
||||
"初中一年级",
|
||||
"初中三年级",
|
||||
"高中一年级",
|
||||
"高中三年级",
|
||||
] as const;
|
||||
|
||||
/** 版本 */
|
||||
const VERSIONS = ["人教版", "北师大版", "苏教版", "鲁科版"] as const;
|
||||
|
||||
/** 教材定义(8 本) */
|
||||
interface TextbookDef {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: (typeof SUBJECTS)[number];
|
||||
grade: (typeof GRADES)[number];
|
||||
version: (typeof VERSIONS)[number];
|
||||
chapterCount: number;
|
||||
}
|
||||
|
||||
const TEXTBOOK_DEFS: TextbookDef[] = [
|
||||
{
|
||||
id: "tb-001",
|
||||
title: "小学语文三年级上册",
|
||||
subject: "语文",
|
||||
grade: "小学三年级",
|
||||
version: "人教版",
|
||||
chapterCount: 8,
|
||||
},
|
||||
{
|
||||
id: "tb-002",
|
||||
title: "小学数学六年级上册",
|
||||
subject: "数学",
|
||||
grade: "小学六年级",
|
||||
version: "北师大版",
|
||||
chapterCount: 10,
|
||||
},
|
||||
{
|
||||
id: "tb-003",
|
||||
title: "初中英语七年级上册",
|
||||
subject: "英语",
|
||||
grade: "初中一年级",
|
||||
version: "人教版",
|
||||
chapterCount: 9,
|
||||
},
|
||||
{
|
||||
id: "tb-004",
|
||||
title: "初中物理九年级全册",
|
||||
subject: "物理",
|
||||
grade: "初中三年级",
|
||||
version: "苏教版",
|
||||
chapterCount: 7,
|
||||
},
|
||||
{
|
||||
id: "tb-005",
|
||||
title: "高中化学必修一",
|
||||
subject: "化学",
|
||||
grade: "高中一年级",
|
||||
version: "鲁科版",
|
||||
chapterCount: 5,
|
||||
},
|
||||
{
|
||||
id: "tb-006",
|
||||
title: "高中生物必修一·分子与细胞",
|
||||
subject: "生物",
|
||||
grade: "高中一年级",
|
||||
version: "人教版",
|
||||
chapterCount: 6,
|
||||
},
|
||||
{
|
||||
id: "tb-007",
|
||||
title: "高中历史必修·中外历史纲要",
|
||||
subject: "历史",
|
||||
grade: "高中一年级",
|
||||
version: "人教版",
|
||||
chapterCount: 8,
|
||||
},
|
||||
{
|
||||
id: "tb-008",
|
||||
title: "高中地理必修一·自然地理",
|
||||
subject: "地理",
|
||||
grade: "高中一年级",
|
||||
version: "人教版",
|
||||
chapterCount: 6,
|
||||
},
|
||||
];
|
||||
|
||||
/** 教材列表(含章节数) */
|
||||
export const MOCK_TEXTBOOKS: TextbookItem[] = TEXTBOOK_DEFS.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
subject: d.subject,
|
||||
grade: d.grade,
|
||||
version: d.version,
|
||||
coverUrl: null,
|
||||
chapterCount: d.chapterCount,
|
||||
createdAt: "2026-06-01T08:00:00.000Z",
|
||||
}));
|
||||
|
||||
/** 章节模板(按学科生成不同内容) */
|
||||
function generateChapter(
|
||||
textbookId: string,
|
||||
order: number,
|
||||
subject: string,
|
||||
): TextbookChapter {
|
||||
const chapterId = `${textbookId}-ch-${String(order).padStart(2, "0")}`;
|
||||
const titlePrefix = `第${order}章`;
|
||||
const titles: Record<string, string[]> = {
|
||||
语文: ["春天的故事", "古诗词赏析", "现代文阅读", "写作训练", "文言文入门"],
|
||||
数学: ["数与代数", "图形与几何", "统计与概率", "函数初步", "方程与不等式"],
|
||||
英语: ["Greetings", "Family", "School Life", "Hobbies", "Festivals"],
|
||||
物理: ["运动与力", "声与光", "热现象", "电与磁", "能量守恒"],
|
||||
化学: ["物质分类", "原子结构", "化学键", "氧化还原", "元素周期律"],
|
||||
生物: ["细胞结构", "细胞代谢", "细胞增殖", "遗传基础", "生物进化"],
|
||||
历史: ["古代政治", "近代变革", "世界大战", "冷战格局", "改革开放"],
|
||||
地理: ["地球运动", "大气环流", "水循环", "地貌演化", "自然环境"],
|
||||
};
|
||||
const subjectTitles = titles[subject] ?? ["导论", "正文", "拓展", "总结", "复习"];
|
||||
const titleIndex = (order - 1) % subjectTitles.length;
|
||||
const titleSuffix = subjectTitles[titleIndex] ?? "导论";
|
||||
// 知识点数量 3-8(按章节顺序周期性变化)
|
||||
const kpCount = 3 + (order % 6); // 3..8
|
||||
const knowledgePoints: KnowledgePoint[] = [];
|
||||
for (let k = 1; k <= kpCount; k++) {
|
||||
knowledgePoints.push({
|
||||
id: `${chapterId}-kp-${String(k).padStart(2, "0")}`,
|
||||
name: `${titleSuffix}·知识点${k}`,
|
||||
description: `本节涉及${titleSuffix}的第 ${k} 个核心知识点,建议结合实例理解。`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: chapterId,
|
||||
textbookId,
|
||||
title: `${titlePrefix} ${titleSuffix}`,
|
||||
order,
|
||||
content: `# ${titlePrefix} ${titleSuffix}\n\n## 学习目标\n\n掌握${titleSuffix}的核心概念与基本方法。\n\n## 知识概览\n\n本章节将系统介绍${titleSuffix}的主要内容,分为 ${kpCount} 个知识点逐一讲解。\n\n## 正文\n\n${titleSuffix}是${subject}学科的重要组成部分,理解其基本原理对后续学习至关重要。本节通过实例分析与练习巩固,帮助学生建立完整知识体系。\n\n## 小结\n\n通过本章学习,应能够独立完成相关习题,并能将${titleSuffix}的方法迁移到实际问题中。`,
|
||||
parentId: null,
|
||||
knowledgePoints,
|
||||
};
|
||||
}
|
||||
|
||||
/** 教材详情(含章节树 + 知识点) */
|
||||
export const MOCK_TEXTBOOK_DETAILS: TextbookDetail[] = TEXTBOOK_DEFS.map(
|
||||
(d) => {
|
||||
const chapters: TextbookChapter[] = [];
|
||||
for (let i = 1; i <= d.chapterCount; i++) {
|
||||
chapters.push(generateChapter(d.id, i, d.subject));
|
||||
}
|
||||
return {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
subject: d.subject,
|
||||
grade: d.grade,
|
||||
version: d.version,
|
||||
coverUrl: null,
|
||||
chapters,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** 按教材 ID 查询详情 */
|
||||
export function findTextbookDetail(id: string): TextbookDetail | null {
|
||||
return MOCK_TEXTBOOK_DETAILS.find((t) => t.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** 按筛选条件过滤教材(分页) */
|
||||
export function filterTextbooks(filter: TextbooksFilter): TextbooksPage {
|
||||
const q = (filter.q ?? "").trim().toLowerCase();
|
||||
const subject = filter.subject ?? null;
|
||||
const grade = filter.grade ?? null;
|
||||
const page = Math.max(1, filter.page ?? 1);
|
||||
const pageSize = Math.max(1, filter.pageSize ?? 20);
|
||||
|
||||
let items = MOCK_TEXTBOOKS;
|
||||
if (q) {
|
||||
items = items.filter((t) => t.title.toLowerCase().includes(q));
|
||||
}
|
||||
if (subject && subject !== "all") {
|
||||
items = items.filter((t) => t.subject === subject);
|
||||
}
|
||||
if (grade && grade !== "all") {
|
||||
items = items.filter((t) => t.grade === grade);
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paged = items.slice(start, start + pageSize);
|
||||
return { items: paged, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** 创建新教材(基于 input 生成 TextbookItem,章节数为 0) */
|
||||
export function createMockTextbook(input: TextbookCreateInput): TextbookItem {
|
||||
const randomId = `tb-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return {
|
||||
id: randomId,
|
||||
title: input.title,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
version: input.version,
|
||||
coverUrl: input.coverUrl ?? null,
|
||||
chapterCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 学科列表(用于筛选下拉) */
|
||||
export const TEXTBOOK_SUBJECTS: readonly string[] = SUBJECTS;
|
||||
|
||||
/** 年级列表(用于筛选下拉) */
|
||||
export const TEXTBOOK_GRADES: readonly string[] = GRADES;
|
||||
|
||||
/** 版本列表(用于筛选下拉) */
|
||||
export const TEXTBOOK_VERSIONS: readonly string[] = VERSIONS;
|
||||
@@ -47,6 +47,142 @@ export const mockViewports: ViewportItem[] = [
|
||||
sortOrder: "05",
|
||||
requiredPermission: "GRADE_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
label: "学情分析",
|
||||
route: "/analytics",
|
||||
icon: null,
|
||||
sortOrder: "06",
|
||||
requiredPermission: "ANALYTICS_READ",
|
||||
},
|
||||
{
|
||||
key: "knowledge-graph",
|
||||
label: "知识图谱",
|
||||
route: "/knowledge-graph",
|
||||
icon: null,
|
||||
sortOrder: "07",
|
||||
requiredPermission: "CONTENT_READ",
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: "通知中心",
|
||||
route: "/notifications",
|
||||
icon: null,
|
||||
sortOrder: "08",
|
||||
requiredPermission: "MSG_READ",
|
||||
},
|
||||
{
|
||||
key: "ai-assist",
|
||||
label: "AI 辅助",
|
||||
route: "/ai-assist",
|
||||
icon: null,
|
||||
sortOrder: "09",
|
||||
requiredPermission: "AI_GENERATE",
|
||||
},
|
||||
{
|
||||
key: "ai-lesson-plan",
|
||||
label: "AI 教案",
|
||||
route: "/ai-lesson-plan",
|
||||
icon: null,
|
||||
sortOrder: "10",
|
||||
requiredPermission: "AI_GENERATE",
|
||||
},
|
||||
{
|
||||
key: "ai-report",
|
||||
label: "AI 学情报告",
|
||||
route: "/ai-report",
|
||||
icon: null,
|
||||
sortOrder: "11",
|
||||
requiredPermission: "AI_GENERATE",
|
||||
},
|
||||
{
|
||||
key: "attendance",
|
||||
label: "考勤管理",
|
||||
route: "/attendance",
|
||||
icon: null,
|
||||
sortOrder: "12",
|
||||
requiredPermission: "ATTENDANCE_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "questions",
|
||||
label: "题库",
|
||||
route: "/questions",
|
||||
icon: null,
|
||||
sortOrder: "13",
|
||||
requiredPermission: "QUESTION_READ",
|
||||
},
|
||||
{
|
||||
key: "textbooks",
|
||||
label: "教材",
|
||||
route: "/textbooks",
|
||||
icon: null,
|
||||
sortOrder: "14",
|
||||
requiredPermission: "TEXTBOOK_READ",
|
||||
},
|
||||
{
|
||||
key: "lesson-plans",
|
||||
label: "备课",
|
||||
route: "/lesson-plans",
|
||||
icon: null,
|
||||
sortOrder: "15",
|
||||
requiredPermission: "LESSON_PLAN_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "course-plans",
|
||||
label: "课程计划",
|
||||
route: "/course-plans",
|
||||
icon: null,
|
||||
sortOrder: "16",
|
||||
requiredPermission: "COURSE_PLAN_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "diagnostic",
|
||||
label: "诊断报告",
|
||||
route: "/diagnostic",
|
||||
icon: null,
|
||||
sortOrder: "17",
|
||||
requiredPermission: "DIAGNOSTIC_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "error-book",
|
||||
label: "错题本",
|
||||
route: "/error-book",
|
||||
icon: null,
|
||||
sortOrder: "18",
|
||||
requiredPermission: "ERROR_BOOK_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "practice",
|
||||
label: "练习分析",
|
||||
route: "/practice",
|
||||
icon: null,
|
||||
sortOrder: "19",
|
||||
requiredPermission: "PRACTICE_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "elective",
|
||||
label: "选修课",
|
||||
route: "/elective",
|
||||
icon: null,
|
||||
sortOrder: "20",
|
||||
requiredPermission: "ELECTIVE_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "leave",
|
||||
label: "请假审批",
|
||||
route: "/leave",
|
||||
icon: null,
|
||||
sortOrder: "21",
|
||||
requiredPermission: "LEAVE_REQUEST_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "schedule-changes",
|
||||
label: "调课申请",
|
||||
route: "/schedule-changes",
|
||||
icon: null,
|
||||
sortOrder: "22",
|
||||
requiredPermission: "SCHEDULE_CHANGE_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: "个人设置",
|
||||
|
||||
108
apps/teacher-portal/src/mocks/handlers-p4.ts
Normal file
108
apps/teacher-portal/src/mocks/handlers-p4.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* MSW P4 Handlers - 知识图谱 + 学情分析 mock 拦截
|
||||
*
|
||||
* 独立于 handlers.ts 维护,合并时将 p4Handlers 追加到主 handlers 数组,
|
||||
* 并在主 handleGraphQL 的 default 之前插入以下 case。
|
||||
*
|
||||
* 覆盖 operationName(对应 graphql-p4.ts):
|
||||
* - KnowledgeGraph → 知识图谱节点 + 边
|
||||
* - ClassAnalytics → 班级学情分析
|
||||
* - StudentAnalytics → 单生学情分析
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { filterKnowledgeGraph } from "./fixtures/knowledge-graph";
|
||||
import {
|
||||
mockClassAnalytics,
|
||||
findStudentAnalytics,
|
||||
} from "./fixtures/analytics";
|
||||
|
||||
/** GraphQL 响应体(urql 期望 { data: ... }) */
|
||||
function graphqlData<T>(data: T) {
|
||||
return HttpResponse.json({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 operationName 路由 P4 GraphQL mock 响应
|
||||
*
|
||||
* 返回 null 表示当前 operationName 不属于 P4,交回主 handler 处理。
|
||||
*/
|
||||
export function handleP4GraphQL(
|
||||
operationName: string,
|
||||
variables: Record<string, unknown>,
|
||||
) {
|
||||
switch (operationName) {
|
||||
case "KnowledgeGraph": {
|
||||
const subject = (variables.subject as string | undefined) ?? null;
|
||||
return graphqlData({
|
||||
knowledgeGraph: filterKnowledgeGraph(subject),
|
||||
});
|
||||
}
|
||||
|
||||
case "ClassAnalytics": {
|
||||
// classId 传入但 mock 数据固定,返回时回填以保证字段一致
|
||||
const classId =
|
||||
(variables.classId as string | undefined) ?? mockClassAnalytics.classId;
|
||||
return graphqlData({
|
||||
classAnalytics: { ...mockClassAnalytics, classId },
|
||||
});
|
||||
}
|
||||
|
||||
case "StudentAnalytics": {
|
||||
const studentId =
|
||||
(variables.studentId as string | undefined) ?? "stu-001";
|
||||
return graphqlData({
|
||||
studentAnalytics: findStudentAnalytics(studentId),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P4 独立 handler(POST /api/v1/teacher/graphql)
|
||||
*
|
||||
* 合并策略:将本数组追加到 handlers.ts 的 handlers 数组之前(MSW 按顺序匹配,
|
||||
* 先于主 handler 的 default case 命中 P4 operation)。
|
||||
*/
|
||||
export const p4Handlers = [
|
||||
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 ?? {};
|
||||
|
||||
const p4Result = handleP4GraphQL(operationName, variables);
|
||||
if (p4Result) {
|
||||
return p4Result;
|
||||
}
|
||||
// 非 P4 operation:交回后续 handler(返回 passthrough 让 MSW 继续匹配)
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `P4 handler 未覆盖的 GraphQL operation: ${operationName}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_IMPLEMENTED" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
/** 从 query 字符串提取 operationName(兜底) */
|
||||
function extractOperationName(query?: string): string {
|
||||
if (!query) return "Unknown";
|
||||
const match = query.match(/(?:query|mutation)\s+(\w+)/);
|
||||
return match?.[1] ?? "Unknown";
|
||||
}
|
||||
137
apps/teacher-portal/src/mocks/handlers-p5.ts
Normal file
137
apps/teacher-portal/src/mocks/handlers-p5.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* MSW Handlers - P5 扩展(通知中心 + AI 辅助)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送、§6 AI 辅助
|
||||
*
|
||||
* 设计:本文件独立于 handlers.ts,不修改共享文件,最后统一合并。
|
||||
* - 拦截 POST /api/v1/teacher/graphql,按 operationName 分发 P5 操作。
|
||||
* - 未覆盖的 operation(P2/P3)返回 undefined → MSW fallthrough 至 handlers.ts。
|
||||
* (MSW v2:resolver 返回 undefined 时继续匹配下一个 handler,见 executeHandlers.js)
|
||||
*
|
||||
* 注册顺序:p5Handlers 必须在 handlers 之前注册(见 browser.ts / server.ts)。
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { mockNotifications, findNotification } from "./fixtures/notifications";
|
||||
import {
|
||||
mockGeneratedQuestion,
|
||||
mockLessonPlan,
|
||||
mockReport,
|
||||
} from "./fixtures/ai";
|
||||
|
||||
/** 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 p5Handlers = [
|
||||
// POST /api/v1/teacher/graphql → P5 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 "MyNotifications": {
|
||||
const unreadOnly = variables.unreadOnly as boolean | undefined;
|
||||
const list = unreadOnly
|
||||
? mockNotifications.filter((n) => !n.read)
|
||||
: mockNotifications;
|
||||
return graphqlData({ myNotifications: list });
|
||||
}
|
||||
|
||||
case "MarkAsRead": {
|
||||
const id = variables.id as string;
|
||||
const found = findNotification(id);
|
||||
if (!found) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `通知不存在: ${id}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({
|
||||
markAsRead: { id, read: true },
|
||||
});
|
||||
}
|
||||
|
||||
case "GenerateQuestion": {
|
||||
const input = variables.input as {
|
||||
subject?: string;
|
||||
questionType?: string;
|
||||
difficulty?: string;
|
||||
knowledgePoints?: string;
|
||||
count?: number;
|
||||
};
|
||||
const seq = String(Date.now()).slice(-6);
|
||||
return graphqlData({
|
||||
generateQuestion: {
|
||||
...mockGeneratedQuestion,
|
||||
id: `ai-q-${seq}`,
|
||||
question: `【${input.subject ?? "数学"}·${input.difficulty ?? "中等"}】${mockGeneratedQuestion.question}`,
|
||||
explanation: `知识点:${input.knowledgePoints ?? "导数应用"}\n${mockGeneratedQuestion.explanation}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "GenerateLessonPlan": {
|
||||
const input = variables.input as {
|
||||
classId?: string;
|
||||
subject?: string;
|
||||
topic?: string;
|
||||
};
|
||||
const seq = String(Date.now()).slice(-6);
|
||||
return graphqlData({
|
||||
generateLessonPlan: {
|
||||
...mockLessonPlan,
|
||||
id: `ai-lp-${seq}`,
|
||||
content: `# 教案:${input.topic ?? "函数的单调性与极值"}\n\n班级:${input.classId ?? "-"} 科目:${input.subject ?? "数学"}\n${mockLessonPlan.content}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "GenerateReport": {
|
||||
const input = variables.input as {
|
||||
classId?: string;
|
||||
reportType?: string;
|
||||
studentId?: string;
|
||||
};
|
||||
const seq = String(Date.now()).slice(-6);
|
||||
const scope =
|
||||
input.reportType === "单生报告" && input.studentId
|
||||
? `学生 ${input.studentId}`
|
||||
: `班级 ${input.classId ?? "-"}`;
|
||||
return graphqlData({
|
||||
generateReport: {
|
||||
...mockReport,
|
||||
id: `ai-rpt-${seq}`,
|
||||
content: `# 学情报告(${input.reportType ?? "班级周报"})- ${scope}\n${mockReport.content}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
// 未覆盖的 P2/P3 operation:返回 undefined → fallthrough 至 handlers.ts
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
];
|
||||
249
apps/teacher-portal/src/mocks/handlers-p7-admin.ts
Normal file
249
apps/teacher-portal/src/mocks/handlers-p7-admin.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* MSW Handlers - P7 扩展(行政管理子模块:考勤 + 班级详情 + 课表 + 请假 + 调课)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql-p7-admin.ts、workline.md §3 P7 交付物
|
||||
*
|
||||
* 设计:本文件独立于 handlers.ts,不修改共享文件,最后统一合并。
|
||||
* - 拦截 POST /api/v1/teacher/graphql,按 operationName 分发 P7-admin 操作。
|
||||
* - 未覆盖的 operation(P2/P3/P4/P5/P7-grades/P7-exams)返回 undefined → MSW fallthrough 至下游 handler。
|
||||
*
|
||||
* 注册顺序:p7AdminHandlers 必须在 handlers 之前注册(见 browser.ts / server.ts)。
|
||||
*
|
||||
* 覆盖 operationName(对应 graphql-p7-admin.ts):
|
||||
* - AttendanceList → 考勤记录列表(按筛选 + 分页)
|
||||
* - AttendanceSheet → 考勤录入用学生 + 当天状态
|
||||
* - SaveAttendanceSheet → 批量保存考勤
|
||||
* - AttendanceStats → 班级考勤统计
|
||||
* - AttendanceClassComparison → 班级考勤对比
|
||||
* - AttendanceWarnings → 学生考勤预警
|
||||
* - AttendanceReport → 周报/月报数据
|
||||
* - ClassDetail → 班级详情聚合
|
||||
* - ClassSchedule → 班级课表
|
||||
* - LeaveRequests → 教师请假审批列表
|
||||
* - ApproveLeaveRequest → 批准/拒绝请假
|
||||
* - SubmitLeaveRequest → 教师提交请假
|
||||
* - ScheduleChanges → 我的调课申请列表
|
||||
* - SubmitScheduleChange → 提交调课申请
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
queryAttendanceList,
|
||||
queryAttendanceSheet,
|
||||
saveAttendanceSheet,
|
||||
queryAttendanceStats,
|
||||
queryAttendanceClassComparison,
|
||||
queryAttendanceWarnings,
|
||||
queryAttendanceReport,
|
||||
defaultDateRange,
|
||||
} from "./fixtures/attendance";
|
||||
import {
|
||||
queryClassDetail,
|
||||
queryClassSchedule,
|
||||
} from "./fixtures/classes-extended";
|
||||
import {
|
||||
queryLeaveRequests,
|
||||
approveLeaveRequest,
|
||||
submitLeaveRequest,
|
||||
queryScheduleChanges,
|
||||
submitScheduleChange,
|
||||
} from "./fixtures/leave-requests";
|
||||
import { EXTENDED_CLASSES } from "./fixtures/grades-extended";
|
||||
import type {
|
||||
AttendanceListFilter,
|
||||
SaveAttendanceSheetInput,
|
||||
AttendanceReportType,
|
||||
ApproveLeaveRequestInput,
|
||||
SubmitLeaveRequestInput,
|
||||
SubmitScheduleChangeInput,
|
||||
LeaveRequestsFilter,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
|
||||
/** 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 p7AdminHandlers = [
|
||||
// POST /api/v1/teacher/graphql → P7-admin 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 "AttendanceList": {
|
||||
const filterRaw = (variables.filter as Partial<AttendanceListFilter> | undefined) ?? {};
|
||||
const filter: AttendanceListFilter = {
|
||||
classId: filterRaw.classId ?? null,
|
||||
startDate: filterRaw.startDate ?? null,
|
||||
endDate: filterRaw.endDate ?? null,
|
||||
statuses: filterRaw.statuses ?? null,
|
||||
page: filterRaw.page ?? 1,
|
||||
pageSize: filterRaw.pageSize ?? 30,
|
||||
};
|
||||
return graphqlData({
|
||||
attendanceList: queryAttendanceList(filter),
|
||||
});
|
||||
}
|
||||
|
||||
case "AttendanceSheet": {
|
||||
const classId =
|
||||
(variables.classId as string | undefined) ??
|
||||
(EXTENDED_CLASSES[0]?.id ?? "");
|
||||
const date =
|
||||
(variables.date as string | undefined) ??
|
||||
defaultDateRange().endDate;
|
||||
return graphqlData({
|
||||
attendanceSheet: queryAttendanceSheet(classId, date),
|
||||
});
|
||||
}
|
||||
|
||||
case "SaveAttendanceSheet": {
|
||||
const input = variables.input as SaveAttendanceSheetInput;
|
||||
return graphqlData({
|
||||
saveAttendanceSheet: saveAttendanceSheet(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "AttendanceStats": {
|
||||
const classId =
|
||||
(variables.classId as string) ?? (EXTENDED_CLASSES[0]?.id ?? "");
|
||||
const { startDate, endDate } = defaultDateRange();
|
||||
return graphqlData({
|
||||
attendanceStats: queryAttendanceStats(
|
||||
classId,
|
||||
(variables.startDate as string) ?? startDate,
|
||||
(variables.endDate as string) ?? endDate,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
case "AttendanceClassComparison": {
|
||||
const { startDate, endDate } = defaultDateRange();
|
||||
return graphqlData({
|
||||
attendanceClassComparison: queryAttendanceClassComparison(
|
||||
(variables.startDate as string) ?? startDate,
|
||||
(variables.endDate as string) ?? endDate,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
case "AttendanceWarnings": {
|
||||
const classId =
|
||||
(variables.classId as string) ?? (EXTENDED_CLASSES[0]?.id ?? "");
|
||||
const { startDate, endDate } = defaultDateRange();
|
||||
return graphqlData({
|
||||
attendanceWarnings: queryAttendanceWarnings(
|
||||
classId,
|
||||
(variables.startDate as string) ?? startDate,
|
||||
(variables.endDate as string) ?? endDate,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
case "AttendanceReport": {
|
||||
const classId =
|
||||
(variables.classId as string) ?? (EXTENDED_CLASSES[0]?.id ?? "");
|
||||
const reportType =
|
||||
(variables.reportType as AttendanceReportType | undefined) ??
|
||||
"WEEKLY";
|
||||
const { startDate, endDate } = defaultDateRange();
|
||||
return graphqlData({
|
||||
attendanceReport: queryAttendanceReport(
|
||||
classId,
|
||||
reportType,
|
||||
(variables.startDate as string) ?? startDate,
|
||||
(variables.endDate as string) ?? endDate,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
case "ClassDetail": {
|
||||
const id =
|
||||
(variables.id as string) ?? (EXTENDED_CLASSES[0]?.id ?? "");
|
||||
const detail = queryClassDetail(id);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `班级详情不存在: ${id}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ classDetail: detail });
|
||||
}
|
||||
|
||||
case "ClassSchedule": {
|
||||
const classId = (variables.classId as string | undefined) ?? undefined;
|
||||
return graphqlData({
|
||||
classSchedule: queryClassSchedule(classId),
|
||||
});
|
||||
}
|
||||
|
||||
case "LeaveRequests": {
|
||||
const filterRaw = (variables.filter as Partial<LeaveRequestsFilter> | undefined) ?? {};
|
||||
const filter: LeaveRequestsFilter = {
|
||||
dataScope: filterRaw.dataScope ?? null,
|
||||
status: filterRaw.status ?? null,
|
||||
page: filterRaw.page ?? 1,
|
||||
pageSize: filterRaw.pageSize ?? 50,
|
||||
};
|
||||
return graphqlData({
|
||||
leaveRequests: queryLeaveRequests(filter),
|
||||
});
|
||||
}
|
||||
|
||||
case "ApproveLeaveRequest": {
|
||||
const input = variables.input as ApproveLeaveRequestInput;
|
||||
return graphqlData({
|
||||
approveLeaveRequest: approveLeaveRequest(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "SubmitLeaveRequest": {
|
||||
const input = variables.input as SubmitLeaveRequestInput;
|
||||
return graphqlData({
|
||||
submitLeaveRequest: submitLeaveRequest(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "ScheduleChanges": {
|
||||
const page = (variables.page as number | undefined) ?? 1;
|
||||
const pageSize = (variables.pageSize as number | undefined) ?? 50;
|
||||
return graphqlData({
|
||||
scheduleChanges: queryScheduleChanges(page, pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
case "SubmitScheduleChange": {
|
||||
const input = variables.input as SubmitScheduleChangeInput;
|
||||
return graphqlData({
|
||||
submitScheduleChange: submitScheduleChange(input),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
// 未覆盖的 P2/P3/P4/P5/P7-grades/P7-exams operation:返回 undefined → fallthrough 至 handlers.ts
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
];
|
||||
241
apps/teacher-portal/src/mocks/handlers-p7-advanced.ts
Normal file
241
apps/teacher-portal/src/mocks/handlers-p7-advanced.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* MSW Handlers - P7 扩展(选修课 + 富文本编辑 + 监考 + 扫描批改 + 课标热力图)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql-p7-advanced.ts、workline.md §3 P7 交付物
|
||||
*
|
||||
* 设计:本文件独立于 handlers.ts,不修改共享文件,最后统一合并。
|
||||
* - 拦截 POST /api/v1/teacher/graphql,按 operationName 分发 P7-advanced 操作。
|
||||
* - 未覆盖的 operation(P2/P3/P4/P5/P7-grades/exams/admin/insights)返回 undefined → MSW fallthrough 至下游 handler。
|
||||
*
|
||||
* 注册顺序:p7AdvancedHandlers 必须在 handlers 之前注册(见 browser.ts / server.ts)。
|
||||
*
|
||||
* 覆盖 operationName(对应 graphql-p7-advanced.ts):
|
||||
* - ElectiveCourses / ElectiveCourseDetail → 选修课列表 + 详情
|
||||
* - CreateElectiveCourse / UpdateElectiveCourse / PublishElectiveCourse / CancelElectiveCourse
|
||||
* - ExamRichEditor / SaveExamRichContent → 富文本试卷内容
|
||||
* - ProctoringStatus / ProctoringEvent → 监考状态 + 事件
|
||||
* - SubmissionScanGrading / SaveScanGrading → 扫描批改
|
||||
* - LessonPlanHeatmap → 课标覆盖热力图
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
generateElectiveCourses,
|
||||
findElectiveCourseDetail,
|
||||
createMockElectiveCourse,
|
||||
updateMockElectiveCourse,
|
||||
publishMockElectiveCourse,
|
||||
cancelMockElectiveCourse,
|
||||
} from "./fixtures/elective";
|
||||
import {
|
||||
findProctoringStatus,
|
||||
createProctoringEvent,
|
||||
} from "./fixtures/proctoring";
|
||||
import {
|
||||
findScanGrading,
|
||||
saveScanGrading,
|
||||
} from "./fixtures/scan-grading";
|
||||
import {
|
||||
findLessonPlanHeatmap,
|
||||
} from "./fixtures/lesson-plan-heatmap";
|
||||
import type {
|
||||
CreateElectiveCourseInput,
|
||||
UpdateElectiveCourseInput,
|
||||
ProctoringEventInput,
|
||||
SaveScanGradingInput,
|
||||
SaveExamRichContentInput,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 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 p7AdvancedHandlers = [
|
||||
// POST /api/v1/teacher/graphql → P7-advanced 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 "ElectiveCourses": {
|
||||
const status = variables.status as string | undefined;
|
||||
const subject = variables.subject as string | undefined;
|
||||
return graphqlData({
|
||||
electiveCourses: generateElectiveCourses(status, subject),
|
||||
});
|
||||
}
|
||||
|
||||
case "ElectiveCourseDetail": {
|
||||
const id = (variables.id as string) ?? "";
|
||||
const detail = findElectiveCourseDetail(id);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `选修课不存在: ${id}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ electiveCourseDetail: detail });
|
||||
}
|
||||
|
||||
case "CreateElectiveCourse": {
|
||||
const input = variables.input as CreateElectiveCourseInput;
|
||||
return graphqlData({
|
||||
createElectiveCourse: createMockElectiveCourse(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "UpdateElectiveCourse": {
|
||||
const input = variables.input as UpdateElectiveCourseInput;
|
||||
return graphqlData({
|
||||
updateElectiveCourse: updateMockElectiveCourse(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "PublishElectiveCourse": {
|
||||
const id = (variables.id as string) ?? "";
|
||||
return graphqlData({
|
||||
publishElectiveCourse: publishMockElectiveCourse(id),
|
||||
});
|
||||
}
|
||||
|
||||
case "CancelElectiveCourse": {
|
||||
const id = (variables.id as string) ?? "";
|
||||
return graphqlData({
|
||||
cancelElectiveCourse: cancelMockElectiveCourse(id),
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 富文本编辑 =====
|
||||
case "ExamRichEditor": {
|
||||
const examId = (variables.examId as string) ?? "";
|
||||
// mock:返回默认富文本结构
|
||||
const now = new Date().toISOString();
|
||||
return graphqlData({
|
||||
examRichEditor: {
|
||||
examId,
|
||||
title: "富文本试卷(mock)",
|
||||
totalScore: 100,
|
||||
questionCount: 4,
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "2026 春季期中考试" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "请考生认真作答,满分 100 分。" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "SaveExamRichContent": {
|
||||
const input = variables.input as SaveExamRichContentInput;
|
||||
const now = new Date().toISOString();
|
||||
return graphqlData({
|
||||
saveExamRichContent: {
|
||||
examId: input.examId,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 监考 =====
|
||||
case "ProctoringStatus": {
|
||||
const examId = (variables.examId as string) ?? "";
|
||||
return graphqlData({
|
||||
proctoringStatus: findProctoringStatus(examId),
|
||||
});
|
||||
}
|
||||
|
||||
case "ProctoringEvent": {
|
||||
const input = variables.input as ProctoringEventInput;
|
||||
return graphqlData({
|
||||
proctoringEvent: createProctoringEvent(input),
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 扫描批改 =====
|
||||
case "SubmissionScanGrading": {
|
||||
const submissionId = (variables.submissionId as string) ?? "";
|
||||
const detail = findScanGrading(submissionId);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `扫描批改视图不存在: ${submissionId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ submissionScanGrading: detail });
|
||||
}
|
||||
|
||||
case "SaveScanGrading": {
|
||||
const input = variables.input as SaveScanGradingInput;
|
||||
return graphqlData({
|
||||
saveScanGrading: saveScanGrading(input),
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 课标热力图 =====
|
||||
case "LessonPlanHeatmap": {
|
||||
const textbookId = (variables.textbookId as string) ?? "";
|
||||
const heatmap = findLessonPlanHeatmap(textbookId);
|
||||
if (!heatmap) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `教材热力图不存在: ${textbookId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ lessonPlanHeatmap: heatmap });
|
||||
}
|
||||
|
||||
default:
|
||||
// 未覆盖的 P2/P3/P4/P5/P7-grades/exams/admin/insights operation:返回 undefined → fallthrough
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
];
|
||||
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;
|
||||
}
|
||||
}),
|
||||
];
|
||||
163
apps/teacher-portal/src/mocks/handlers-p7-grades.ts
Normal file
163
apps/teacher-portal/src/mocks/handlers-p7-grades.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* MSW Handlers - P7 扩展(成绩完整子模块 + 作业批改链路)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql-p7-grades.ts、workline.md §3 P7 交付物
|
||||
*
|
||||
* 设计:本文件独立于 handlers.ts,不修改共享文件,最后统一合并。
|
||||
* - 拦截 POST /api/v1/teacher/graphql,按 operationName 分发 P7 操作。
|
||||
* - 未覆盖的 operation(P2/P3/P4/P5)返回 undefined → MSW fallthrough 至下游 handler。
|
||||
*
|
||||
* 注册顺序:p7Handlers 必须在 handlers 之前注册(见 browser.ts / server.ts)。
|
||||
*
|
||||
* 覆盖 operationName(对应 graphql-p7-grades.ts):
|
||||
* - GradeEntry → 成绩录入列表(按 examId + classId)
|
||||
* - SaveGradeEntries → 批量保存成绩录入
|
||||
* - GradeStats → 班级成绩统计
|
||||
* - GradeAnalytics → 成绩分析
|
||||
* - ReportCard → 学生成绩报告卡
|
||||
* - HomeworkSubmissions → 作业提交统计列表
|
||||
* - HomeworkAssignmentSubmissions → 按作业 ID 拉取所有提交
|
||||
* - GradeSubmission → 单份提交批改
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
generateGradeEntries,
|
||||
saveGradeEntries,
|
||||
generateGradeStats,
|
||||
generateGradeAnalytics,
|
||||
generateReportCard,
|
||||
EXTENDED_CLASSES,
|
||||
} from "./fixtures/grades-extended";
|
||||
import {
|
||||
generateHomeworkSubmissionStats,
|
||||
generateAssignmentSubmissions,
|
||||
gradeSubmission,
|
||||
generateAiBatchGrading,
|
||||
} from "./fixtures/homework-submissions";
|
||||
import type { GradeSubmissionInput, SaveGradeEntriesInput } from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 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 p7Handlers = [
|
||||
// POST /api/v1/teacher/graphql → P7 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 "GradeEntry": {
|
||||
const examId = variables.examId as string;
|
||||
const classId = variables.classId as string;
|
||||
// classId 兜底为第一个班级
|
||||
const targetClassId =
|
||||
classId || (EXTENDED_CLASSES[0]?.id ?? "");
|
||||
return graphqlData({
|
||||
gradeEntry: generateGradeEntries(examId, targetClassId),
|
||||
});
|
||||
}
|
||||
|
||||
case "SaveGradeEntries": {
|
||||
const input = variables.input as SaveGradeEntriesInput;
|
||||
return graphqlData({
|
||||
saveGradeEntries: saveGradeEntries(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "GradeStats": {
|
||||
const classId =
|
||||
(variables.classId as string) ||
|
||||
(EXTENDED_CLASSES[0]?.id ?? "");
|
||||
const subject = (variables.subject as string) ?? "数学";
|
||||
return graphqlData({
|
||||
gradeStats: generateGradeStats(classId, subject),
|
||||
});
|
||||
}
|
||||
|
||||
case "GradeAnalytics": {
|
||||
const classId =
|
||||
(variables.classId as string) ||
|
||||
(EXTENDED_CLASSES[0]?.id ?? "");
|
||||
return graphqlData({
|
||||
gradeAnalytics: generateGradeAnalytics(classId),
|
||||
});
|
||||
}
|
||||
|
||||
case "ReportCard": {
|
||||
const studentId = variables.studentId as string;
|
||||
const academicYear = variables.academicYear as string | undefined;
|
||||
const semester = variables.semester as string | undefined;
|
||||
const card = generateReportCard(studentId, academicYear, semester);
|
||||
if (!card) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `报告卡不存在: ${studentId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ reportCard: card });
|
||||
}
|
||||
|
||||
case "HomeworkSubmissions": {
|
||||
const classId = variables.classId as string | undefined;
|
||||
const status = variables.status as string | undefined;
|
||||
return graphqlData({
|
||||
homeworkSubmissions: generateHomeworkSubmissionStats(
|
||||
classId,
|
||||
status,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
case "HomeworkAssignmentSubmissions": {
|
||||
const homeworkId = variables.homeworkId as string;
|
||||
return graphqlData({
|
||||
homeworkAssignmentSubmissions:
|
||||
generateAssignmentSubmissions(homeworkId),
|
||||
});
|
||||
}
|
||||
|
||||
case "GradeSubmission": {
|
||||
const input = variables.input as GradeSubmissionInput;
|
||||
return graphqlData({
|
||||
gradeSubmission: gradeSubmission(input),
|
||||
});
|
||||
}
|
||||
|
||||
// 批量 AI 评分建议(自定义 operation,非 graphql-p7-grades.ts 定义)
|
||||
case "AiBatchGrading": {
|
||||
const homeworkId = variables.homeworkId as string;
|
||||
return graphqlData({
|
||||
aiBatchGrading: generateAiBatchGrading(homeworkId),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
// 未覆盖的 P2/P3/P4/P5 operation:返回 undefined → fallthrough 至 handlers.ts
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
];
|
||||
299
apps/teacher-portal/src/mocks/handlers-p7-insights.ts
Normal file
299
apps/teacher-portal/src/mocks/handlers-p7-insights.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* MSW Handlers - P7 扩展(备课 + 课程计划 + 诊断 + 错题本 + 自适应练习)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql-p7-insights.ts、workline.md §3 P7 交付物
|
||||
*
|
||||
* 设计:本文件独立于 handlers.ts,不修改共享文件,最后统一合并。
|
||||
* - 拦截 POST /api/v1/teacher/graphql,按 operationName 分发 P7-insights 操作。
|
||||
* - 未覆盖的 operation(P2/P3/P4/P5/P7-grades/P7-exams/P7-admin)返回 undefined
|
||||
* → MSW fallthrough 至下游 handler。
|
||||
*
|
||||
* 注册顺序:p7InsightsHandlers 必须在 handlers 之前注册(见 browser.ts / server.ts)。
|
||||
*
|
||||
* 覆盖 operationName(对应 graphql-p7-insights.ts):
|
||||
* - LessonPlans / LessonPlanDetail / LessonPlanLibrary
|
||||
* - LessonPlanCalendar / LessonPlanHeatmap
|
||||
* - CreateLessonPlan / UpdateLessonPlan / AIGenerateLessonPlan / ForkLessonPlan
|
||||
* - CoursePlans / CoursePlanDetail / CreateCoursePlan / UpdateCoursePlan
|
||||
* - DiagnosticReports / ClassDiagnostic / StudentDiagnostic
|
||||
* - ErrorBook
|
||||
* - PracticeAnalytics
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
filterLessonPlans,
|
||||
findLessonPlanDetail,
|
||||
createMockLessonPlan,
|
||||
updateMockLessonPlan,
|
||||
generateMockLessonPlan,
|
||||
filterLessonPlanLibrary,
|
||||
forkMockLessonPlan,
|
||||
getLessonPlanCalendar,
|
||||
getLessonPlanHeatmap,
|
||||
} from "./fixtures/lesson-plans";
|
||||
import {
|
||||
filterCoursePlans,
|
||||
findCoursePlanDetail,
|
||||
createMockCoursePlan,
|
||||
updateMockCoursePlan,
|
||||
} from "./fixtures/course-plans";
|
||||
import {
|
||||
filterDiagnosticReports,
|
||||
findClassDiagnostic,
|
||||
findStudentDiagnostic,
|
||||
} from "./fixtures/diagnostic";
|
||||
import { getErrorBook } from "./fixtures/error-book";
|
||||
import { getPracticeAnalytics } from "./fixtures/practice";
|
||||
import type {
|
||||
CreateLessonPlanInput,
|
||||
UpdateLessonPlanInput,
|
||||
AIGenerateLessonPlanInput,
|
||||
CreateCoursePlanInput,
|
||||
UpdateCoursePlanInput,
|
||||
DiagnosticReportType,
|
||||
DiagnosticReportStatus,
|
||||
CoursePlanStatus,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 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 p7InsightsHandlers = [
|
||||
// POST /api/v1/teacher/graphql → P7-insights 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 "LessonPlans": {
|
||||
const subject = (variables.subject as string | undefined) ?? null;
|
||||
return graphqlData({ lessonPlans: filterLessonPlans(subject) });
|
||||
}
|
||||
|
||||
case "LessonPlanDetail": {
|
||||
const planId = (variables.planId as string) ?? "";
|
||||
const detail = findLessonPlanDetail(planId);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `课案不存在: ${planId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ lessonPlanDetail: detail });
|
||||
}
|
||||
|
||||
case "CreateLessonPlan": {
|
||||
const input = variables.input as CreateLessonPlanInput;
|
||||
return graphqlData({ createLessonPlan: createMockLessonPlan(input) });
|
||||
}
|
||||
|
||||
case "UpdateLessonPlan": {
|
||||
const input = variables.input as UpdateLessonPlanInput;
|
||||
const updated = updateMockLessonPlan(input);
|
||||
if (!updated) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `课案不存在: ${input.planId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ updateLessonPlan: updated });
|
||||
}
|
||||
|
||||
case "AIGenerateLessonPlan": {
|
||||
const input = variables.input as AIGenerateLessonPlanInput;
|
||||
return graphqlData({
|
||||
aiGenerateLessonPlan: generateMockLessonPlan(input),
|
||||
});
|
||||
}
|
||||
|
||||
case "LessonPlanLibrary": {
|
||||
const subject = (variables.subject as string | undefined) ?? null;
|
||||
const grade = (variables.grade as string | undefined) ?? null;
|
||||
return graphqlData({
|
||||
lessonPlanLibrary: filterLessonPlanLibrary(subject, grade),
|
||||
});
|
||||
}
|
||||
|
||||
case "ForkLessonPlan": {
|
||||
const planId = (variables.planId as string) ?? "";
|
||||
const forked = forkMockLessonPlan(planId);
|
||||
if (!forked) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `课案库条目不存在: ${planId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ forkLessonPlan: forked });
|
||||
}
|
||||
|
||||
case "LessonPlanCalendar": {
|
||||
const year = (variables.year as number) ?? 2026;
|
||||
const month = (variables.month as number) ?? 7;
|
||||
return graphqlData({
|
||||
lessonPlanCalendar: getLessonPlanCalendar(year, month),
|
||||
});
|
||||
}
|
||||
|
||||
case "LessonPlanHeatmap": {
|
||||
return graphqlData({
|
||||
lessonPlanHeatmap: getLessonPlanHeatmap(),
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 课程计划 ============
|
||||
case "CoursePlans": {
|
||||
const status = (variables.status as CoursePlanStatus | undefined) ?? null;
|
||||
return graphqlData({ coursePlans: filterCoursePlans(status) });
|
||||
}
|
||||
|
||||
case "CoursePlanDetail": {
|
||||
const id = (variables.id as string) ?? "";
|
||||
const detail = findCoursePlanDetail(id);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `课程计划不存在: ${id}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ coursePlanDetail: detail });
|
||||
}
|
||||
|
||||
case "CreateCoursePlan": {
|
||||
const input = variables.input as CreateCoursePlanInput;
|
||||
return graphqlData({ createCoursePlan: createMockCoursePlan(input) });
|
||||
}
|
||||
|
||||
case "UpdateCoursePlan": {
|
||||
const input = variables.input as UpdateCoursePlanInput;
|
||||
const updated = updateMockCoursePlan(input);
|
||||
if (!updated) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `课程计划不存在: ${input.id}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ updateCoursePlan: updated });
|
||||
}
|
||||
|
||||
// ============ 诊断 ============
|
||||
case "DiagnosticReports": {
|
||||
const type = (variables.type as DiagnosticReportType | undefined) ?? null;
|
||||
const status = (variables.status as DiagnosticReportStatus | undefined) ?? null;
|
||||
return graphqlData({
|
||||
diagnosticReports: filterDiagnosticReports(type, status),
|
||||
});
|
||||
}
|
||||
|
||||
case "ClassDiagnostic": {
|
||||
const classId = (variables.classId as string) ?? "";
|
||||
const detail = findClassDiagnostic(classId);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `班级诊断不存在: ${classId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ classDiagnostic: detail });
|
||||
}
|
||||
|
||||
case "StudentDiagnostic": {
|
||||
const studentId = (variables.studentId as string) ?? "";
|
||||
const detail = findStudentDiagnostic(studentId);
|
||||
if (!detail) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `学生诊断不存在: ${studentId}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_FOUND" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return graphqlData({ studentDiagnostic: detail });
|
||||
}
|
||||
|
||||
// ============ 错题本 ============
|
||||
case "ErrorBook": {
|
||||
const subject = (variables.subject as string) ?? "数学";
|
||||
const classId = (variables.classId as string | undefined) ?? null;
|
||||
return graphqlData({ errorBook: getErrorBook(subject, classId) });
|
||||
}
|
||||
|
||||
// ============ 练习 ============
|
||||
case "PracticeAnalytics": {
|
||||
const classId = (variables.classId as string | undefined) ?? null;
|
||||
return graphqlData({
|
||||
practiceAnalytics: getPracticeAnalytics(classId),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
// 未覆盖的 P2/P3/P4/P5/P7-grades/P7-exams/P7-admin operation:
|
||||
// 返回 undefined → fallthrough 至 handlers.ts
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -15,9 +15,9 @@ import { mockUser, mockUserInfo, mockToken } from "./fixtures/user";
|
||||
import { mockViewports } from "./fixtures/viewports";
|
||||
import { mockClasses, findClass } from "./fixtures/classes";
|
||||
import { generateMockStudents } from "./fixtures/students";
|
||||
import { generateMockExams } from "./fixtures/exams";
|
||||
import { generateMockHomework } from "./fixtures/homework";
|
||||
import { generateMockGrades } from "./fixtures/grades";
|
||||
import { generateMockExams, findMockExamDetail, createMockExam } from "./fixtures/exams";
|
||||
import { generateMockHomework, findMockHomeworkDetail, createMockHomework } from "./fixtures/homework";
|
||||
import { generateMockGrades, recordMockGrade } from "./fixtures/grades";
|
||||
|
||||
/** ActionState 信封(coord-final-decisions F9 + 总裁 §3.4 方案 B) */
|
||||
function envelope<T>(data: T) {
|
||||
@@ -32,7 +32,7 @@ function graphqlData<T>(data: T) {
|
||||
/**
|
||||
* 根据 operationName 路由 GraphQL mock 响应
|
||||
*
|
||||
* 对应 graphql.ts 的 5 个 P2 Query + 4 个 P3 Query + 1 个 Mutation。
|
||||
* 对应 graphql.ts 的 5 个 P2 Query + 4 个 P3 Query + 5 个 P3 Mutation/Query。
|
||||
*/
|
||||
function handleGraphQL(
|
||||
operationName: string,
|
||||
@@ -94,6 +94,49 @@ function handleGraphQL(
|
||||
});
|
||||
}
|
||||
|
||||
case "CreateExam": {
|
||||
const input = variables.input as {
|
||||
classId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
examDate: string;
|
||||
duration: number;
|
||||
totalScore: number;
|
||||
};
|
||||
return graphqlData({ createExam: createMockExam(input) });
|
||||
}
|
||||
|
||||
case "AssignHomework": {
|
||||
const input = variables.input as {
|
||||
classId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate: string;
|
||||
};
|
||||
return graphqlData({ assignHomework: createMockHomework(input) });
|
||||
}
|
||||
|
||||
case "RecordGrade": {
|
||||
const input = variables.input as {
|
||||
studentId: string;
|
||||
examId?: string | null;
|
||||
homeworkId?: string | null;
|
||||
score: number;
|
||||
feedback?: string | null;
|
||||
};
|
||||
return graphqlData({ recordGrade: recordMockGrade(input) });
|
||||
}
|
||||
|
||||
case "ExamDetail": {
|
||||
const id = variables.id as string;
|
||||
return graphqlData({ examDetail: findMockExamDetail(id) });
|
||||
}
|
||||
|
||||
case "HomeworkDetail": {
|
||||
const id = variables.id as string;
|
||||
return graphqlData({ homeworkDetail: findMockHomeworkDetail(id) });
|
||||
}
|
||||
|
||||
default:
|
||||
return HttpResponse.json(
|
||||
{
|
||||
|
||||
@@ -9,5 +9,23 @@
|
||||
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
import { p4Handlers } from "./handlers-p4";
|
||||
import { p5Handlers } from "./handlers-p5";
|
||||
import { p7Handlers } from "./handlers-p7-grades";
|
||||
import { p7ExamsHandlers } from "./handlers-p7-exams";
|
||||
import { p7AdminHandlers } from "./handlers-p7-admin";
|
||||
import { p7InsightsHandlers } from "./handlers-p7-insights";
|
||||
import { p7AdvancedHandlers } from "./handlers-p7-advanced";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
// 顺序:先 P7(最新扩展)→ P4/P5 → 主 handlers(默认兜底)
|
||||
// 每个 handler 内部未命中时返回 undefined 触发 MSW fallthrough 至下一个
|
||||
export const server = setupServer(
|
||||
...p7AdvancedHandlers,
|
||||
...p7InsightsHandlers,
|
||||
...p7AdminHandlers,
|
||||
...p7ExamsHandlers,
|
||||
...p7Handlers,
|
||||
...p4Handlers,
|
||||
...p5Handlers,
|
||||
...handlers,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user