feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现

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

View File

@@ -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,
);

View File

@@ -0,0 +1,94 @@
/**
* Mock AI 生成数据ai 域)
*
* 维护者ai13teacher-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 名学生布置拓展题,保持学习动力。",
],
};

View File

@@ -0,0 +1,105 @@
/**
* Mock 学情分析数据data-ana / ai 域P4 扩展)
*
* 维护者ai13teacher-portal
*/
import type {
ClassAnalytics,
StudentAnalytics,
} from "@/lib/graphql-p4";
/** Top 学生 mock5 名,含趋势 / 弱项 / 强项) */
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,
};
}

View File

@@ -0,0 +1,501 @@
/**
* Mock 考勤数据P7-admin考勤子模块
*
* 维护者ai13teacher-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()),
};
}

View File

@@ -0,0 +1,265 @@
/**
* Mock 班级详情 + 课表数据P7-admin班级详情聚合
*
* 维护者ai13teacher-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;
}

View File

@@ -0,0 +1,218 @@
/**
* Mock 课程计划数据course-plans 域P7-insights 扩展)
*
* 维护者ai13teacher-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,
};
}

View File

@@ -0,0 +1,233 @@
/**
* Mock 诊断数据diagnostic 域P7-insights 扩展)
*
* 维护者ai13teacher-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;
}
/** 诊断报告列表 mock12 条) */
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 };

View File

@@ -0,0 +1,298 @@
/**
* Mock 选修课数据P7-advanced选修课模块
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 10 个选修课(覆盖 draft/open/closed/cancelled 状态)
* - 每个含学生选课列表
* - 按 ID 查找详情 + CRUD mock 函数
*/
import type {
ElectiveCourseItem,
ElectiveCourseDetail,
ElectiveStudent,
ElectiveCourseStatus,
CreateElectiveCourseInput,
UpdateElectiveCourseInput,
} from "@/lib/graphql-p7-advanced";
/** 当前教师 IDmock登录用户 */
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(),
};
}

View File

@@ -0,0 +1,182 @@
/**
* Mock 错题本数据error-book 域P7-insights 扩展)
*
* 维护者ai13teacher-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),
};
}

View File

@@ -0,0 +1,353 @@
/**
* Mock 考试分析数据core-edu 域P7 扩展)
*
* 维护者ai13teacher-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;

View File

@@ -4,7 +4,7 @@
* 维护者ai13teacher-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 反查 classIdmock 数据 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",
};
}

View File

@@ -0,0 +1,403 @@
/**
* Mock 扩展成绩数据P7成绩完整子模块
*
* 维护者ai13teacher-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;

View File

@@ -4,7 +4,7 @@
* 维护者ai13teacher-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,
};
}

View File

@@ -0,0 +1,344 @@
/**
* Mock 作业提交数据P7作业批改链路
*
* 维护者ai13teacher-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 建议分数基于作答完整度评估,请教师复核。",
};
});
}

View File

@@ -4,7 +4,7 @@
* 维护者ai13teacher-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 反查 classIdmock 数据 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",
};
}

View File

@@ -0,0 +1,146 @@
/**
* Mock 知识图谱数据core-edu / ai 域P4 扩展)
*
* 包含数学、语文、英语三科知识点节点 + 前置依赖边。
* 维护者ai13teacher-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 };
}

View File

@@ -0,0 +1,458 @@
/**
* Mock 请假申请 + 调课申请数据P7-admin请假/调课子模块)
*
* 维护者ai13teacher-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: "已拒绝",
};

View File

@@ -0,0 +1,157 @@
/**
* Mock 课标覆盖热力图数据P7-advanced热力图模块
*
* 维护者ai13teacher-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% 为 025% 为 120% 为 215% 为 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;
}

View File

@@ -0,0 +1,448 @@
/**
* Mock 课案数据lesson-preparation 域P7-insights 扩展)
*
* 维护者ai13teacher-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 };
}

View File

@@ -0,0 +1,77 @@
/**
* Mock 通知数据push-gateway + msg 域)
*
* 维护者ai13teacher-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;
}

View File

@@ -0,0 +1,152 @@
/**
* Mock 自适应练习数据practice 域P7-insights 扩展)
*
* 维护者ai13teacher-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),
};
}

View File

@@ -0,0 +1,154 @@
/**
* Mock 监考数据P7-advanced监考模块
*
* 维护者ai13teacher-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(),
};
}

View File

@@ -0,0 +1,391 @@
/**
* Mock 题库数据content 域P7 扩展)
*
* 维护者ai13teacher-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: "困难",
};

View File

@@ -0,0 +1,163 @@
/**
* Mock 扫描批改数据P7-advanced扫描批改模块
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 5 个提交的扫描图片 URLplaceholder
* - 已识别作答 + 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(),
};
}

View File

@@ -0,0 +1,249 @@
/**
* Mock 教材数据content 域P7 扩展)
*
* 维护者ai13teacher-portal
*
* 覆盖:
* - 8 本教材(语文/数学/英语/物理/化学/生物/历史/地理,覆盖小学/初中/高中)
* - 每本教材含 5-10 章节,每章节含 3-8 知识点
* - 章节支持嵌套parentIdmock 数据保持单层便于阅读器渲染
*/
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;

View File

@@ -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: "个人设置",

View File

@@ -0,0 +1,108 @@
/**
* MSW P4 Handlers - 知识图谱 + 学情分析 mock 拦截
*
* 独立于 handlers.ts 维护,合并时将 p4Handlers 追加到主 handlers 数组,
* 并在主 handleGraphQL 的 default 之前插入以下 case。
*
* 覆盖 operationName对应 graphql-p4.ts
* - KnowledgeGraph → 知识图谱节点 + 边
* - ClassAnalytics → 班级学情分析
* - StudentAnalytics → 单生学情分析
*
* 维护者ai13teacher-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 独立 handlerPOST /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";
}

View File

@@ -0,0 +1,137 @@
/**
* MSW Handlers - P5 扩展(通知中心 + AI 辅助)
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §5 实时推送、§6 AI 辅助
*
* 设计:本文件独立于 handlers.ts不修改共享文件最后统一合并。
* - 拦截 POST /api/v1/teacher/graphql按 operationName 分发 P5 操作。
* - 未覆盖的 operationP2/P3返回 undefined → MSW fallthrough 至 handlers.ts。
* MSW v2resolver 返回 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;
}
}),
];

View File

@@ -0,0 +1,249 @@
/**
* MSW Handlers - P7 扩展(行政管理子模块:考勤 + 班级详情 + 课表 + 请假 + 调课)
*
* 维护者ai13teacher-portal
* 关联graphql-p7-admin.ts、workline.md §3 P7 交付物
*
* 设计:本文件独立于 handlers.ts不修改共享文件最后统一合并。
* - 拦截 POST /api/v1/teacher/graphql按 operationName 分发 P7-admin 操作。
* - 未覆盖的 operationP2/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;
}
}),
];

View File

@@ -0,0 +1,241 @@
/**
* MSW Handlers - P7 扩展(选修课 + 富文本编辑 + 监考 + 扫描批改 + 课标热力图)
*
* 维护者ai13teacher-portal
* 关联graphql-p7-advanced.ts、workline.md §3 P7 交付物
*
* 设计:本文件独立于 handlers.ts不修改共享文件最后统一合并。
* - 拦截 POST /api/v1/teacher/graphql按 operationName 分发 P7-advanced 操作。
* - 未覆盖的 operationP2/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;
}
}),
];

View File

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

View File

@@ -0,0 +1,163 @@
/**
* MSW Handlers - P7 扩展(成绩完整子模块 + 作业批改链路)
*
* 维护者ai13teacher-portal
* 关联graphql-p7-grades.ts、workline.md §3 P7 交付物
*
* 设计:本文件独立于 handlers.ts不修改共享文件最后统一合并。
* - 拦截 POST /api/v1/teacher/graphql按 operationName 分发 P7 操作。
* - 未覆盖的 operationP2/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;
}
}),
];

View File

@@ -0,0 +1,299 @@
/**
* MSW Handlers - P7 扩展(备课 + 课程计划 + 诊断 + 错题本 + 自适应练习)
*
* 维护者ai13teacher-portal
* 关联graphql-p7-insights.ts、workline.md §3 P7 交付物
*
* 设计:本文件独立于 handlers.ts不修改共享文件最后统一合并。
* - 拦截 POST /api/v1/teacher/graphql按 operationName 分发 P7-insights 操作。
* - 未覆盖的 operationP2/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;
}
}),
];

View File

@@ -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(
{

View File

@@ -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,
);