新增数学思维自学板块与考试板块(9模块154课+初一上5套真实试卷客观题)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-06 08:00:46 +08:00
co-authored by Claude Opus 4.8
parent 3473ecbf2b
commit bc53e2b2a5
38 changed files with 3393 additions and 10 deletions
+141
View File
@@ -0,0 +1,141 @@
// xuchao: 考试板块——试卷查询与作答记录
import { and, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { examPapers, examRecords, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import type { QuizQ } from "@/lib/quiz-parse";
// xuchao: 试卷题型标签(展示用)
export const EXAM_TYPE_LABEL: Record<string, string> = {
monthly: "月考",
unit: "单元卷",
midterm: "期中",
final: "期末",
};
export type ExamPaperRow = {
paperId: string;
subject: string;
grade: string;
semester: string;
month: number;
examType: string;
title: string;
region: string | null;
year: number | null;
duration: number | null;
fullScore: number | null;
seq: number;
};
// xuchao: 某年级某学期的全部试卷(按月份+顺序)
export async function listPapers(grade: string, semester: string, subject = "math"): Promise<ExamPaperRow[]> {
return db
.select({
paperId: examPapers.paperId,
subject: examPapers.subject,
grade: examPapers.grade,
semester: examPapers.semester,
month: examPapers.month,
examType: examPapers.examType,
title: examPapers.title,
region: examPapers.region,
year: examPapers.year,
duration: examPapers.duration,
fullScore: examPapers.fullScore,
seq: examPapers.seq,
})
.from(examPapers)
.where(and(eq(examPapers.subject, subject), eq(examPapers.grade, grade), eq(examPapers.semester, semester)))
.orderBy(examPapers.month, examPapers.seq);
}
// xuchao: 单张试卷全文(含题目 JSON)
export async function getPaper(paperId: string) {
const [row] = await db.select().from(examPapers).where(eq(examPapers.paperId, paperId)).limit(1);
return row ?? null;
}
// xuchao: 解析试卷题目 JSON
export function paperQuestions(paper: { questions: string }): QuizQ[] {
try {
const arr = JSON.parse(paper.questions);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
// xuchao: 某孩子某卷的最近成绩
export async function getExamRecord(childId: number, paperId: string) {
const [row] = await db
.select()
.from(examRecords)
.where(and(eq(examRecords.childUserId, childId), eq(examRecords.paperId, paperId)))
.limit(1);
return row ?? null;
}
// xuchao: 某孩子全部考试记录(列表页显示已考分数)
export async function recordsByPaper(childId: number): Promise<Map<string, { score: number; passed: boolean }>> {
const rows = await db
.select({ paperId: examRecords.paperId, score: examRecords.score, passed: examRecords.passed })
.from(examRecords)
.where(eq(examRecords.childUserId, childId));
return new Map(rows.map((r) => [r.paperId, { score: r.score ?? 0, passed: r.passed }]));
}
// xuchao: 记录作答;达标联动当天数学打卡(允许重考覆盖成绩)
export async function recordExam(
childId: number,
paperId: string,
paperTitle: string,
score: number,
correct: number,
total: number,
passed: boolean,
answers: unknown[],
) {
await db
.insert(examRecords)
.values({
childUserId: childId,
paperId,
score,
correct,
total,
passed,
answers: JSON.stringify(answers),
})
.onDuplicateKeyUpdate({
set: { score, correct, total, passed, answers: JSON.stringify(answers), takenAt: sql`CURRENT_TIMESTAMP` },
});
if (!passed) return;
const today = todayStr();
const ckTitle = `考试 · ${paperTitle.length > 24 ? paperTitle.slice(0, 24) + "…" : paperTitle}`;
const [existing] = await db
.select({ id: taskInstances.id, status: taskInstances.status })
.from(taskInstances)
.where(
and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, today), eq(taskInstances.title, ckTitle)),
)
.limit(1);
if (existing) {
if (existing.status !== "done")
await db
.update(taskInstances)
.set({ status: "done", doneAt: sql`CURRENT_TIMESTAMP` })
.where(eq(taskInstances.id, existing.id));
} else {
await db.insert(taskInstances).values({
childUserId: childId,
date: today,
title: ckTitle,
subject: "math",
status: "done",
doneAt: new Date(),
sortOrder: 96,
});
}
}
+61
View File
@@ -0,0 +1,61 @@
// xuchao: 数学每周检测——从已点亮数学课的题池 seeded 抽 10 题
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { mathPoints, mathProgress, mathWeeklyTest } from "@/db/schema";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
import { todayStr, weekKey } from "@/lib/dates";
export const currentMathWeekKey = () => weekKey(todayStr());
// xuchao: 确定性随机(同 seed 每次得到相同组卷,渲染与判分一致)
function hashStr(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h >>> 0;
}
function mulberry32(a: number) {
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seededSample<T>(arr: T[], seed: string, n: number): T[] {
const rnd = mulberry32(hashStr(seed));
const pool = [...arr];
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, n);
}
// xuchao: 从已点亮数学课组卷;题池不足 5 题时用计算模块兜底
export async function buildMathWeeklyQuestions(childId: number, wk: string): Promise<QuizQ[]> {
const lit = await db
.select({ pointId: mathProgress.pointId })
.from(mathProgress)
.where(and(eq(mathProgress.childUserId, childId), eq(mathProgress.passed, true)));
const litIds = new Set(lit.map((r) => r.pointId));
const all = await db.select().from(mathPoints);
let pool: QuizQ[] = [];
for (const p of all) {
if (litIds.has(p.pointId)) pool.push(...extractQuiz(p.md));
}
if (pool.length < 5) {
for (const p of all) if (p.module === "calc") pool.push(...extractQuiz(p.md));
}
return seededSample(pool, `${childId}:math:${wk}`, 10);
}
export async function getMathWeeklyRecord(childId: number, wk: string) {
const [row] = await db
.select()
.from(mathWeeklyTest)
.where(and(eq(mathWeeklyTest.childUserId, childId), eq(mathWeeklyTest.weekKey, wk)))
.limit(1);
return row ?? null;
}
+107
View File
@@ -0,0 +1,107 @@
// xuchao: 数学思维自学板块——结构对标英语语法模块(知识点+进度+打卡联动)
import { and, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { mathPoints, mathProgress, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
export { extractQuiz, type QuizQ };
export { gradeAnswers, type AnswerValue };
// xuchao: 七大模块元信息(展示用),顺序即学习顺序
export const MODULES = [
{ id: "calc", name: "计算与计数", stars: 1, desc: "整数/分数/小数运算、运算律、巧算与计数原理,一切数学的地基。" },
{ id: "apply", name: "应用题", stars: 2, desc: "和差倍、鸡兔同笼、植树盈亏、工程比例等经典应用题模型。" },
{ id: "geo", name: "几何", stars: 2, desc: "平面图形周长面积、立体图形表面积体积、割补与等积变换。" },
{ id: "num", name: "数论", stars: 3, desc: "整除特征、质数合数、位值原理、余数与同余。" },
{ id: "travel", name: "行程", stars: 3, desc: "相遇追及、流水行船、火车过桥、环形跑道。" },
{ id: "eq", name: "方程", stars: 3, desc: "用字母表示数、解方程、列方程解应用题。" },
{ id: "combo", name: "组合", stars: 3, desc: "抽屉原理、最值问题、逻辑推理与策略游戏。" },
] as const;
export type MathPointRow = {
pointId: string;
module: string;
moduleName: string;
title: string;
difficulty: number;
seq: number;
};
// xuchao: 全部数学知识点(按展示顺序)
export async function listMathPoints(): Promise<MathPointRow[]> {
return db
.select({
pointId: mathPoints.pointId,
module: mathPoints.module,
moduleName: mathPoints.moduleName,
title: mathPoints.title,
difficulty: mathPoints.difficulty,
seq: mathPoints.seq,
})
.from(mathPoints)
.orderBy(mathPoints.seq);
}
export async function totalMathPoints(): Promise<number> {
const [r] = await db.select({ n: sql<number>`COUNT(*)` }).from(mathPoints);
return r.n;
}
// xuchao: 单个数学知识点全文
export async function getMathPoint(pointId: string) {
const [row] = await db.select().from(mathPoints).where(eq(mathPoints.pointId, pointId)).limit(1);
return row ?? null;
}
// xuchao: 已通过的数学知识点集合
export async function mathPassedByPoint(childId: number): Promise<Set<string>> {
const rows = await db
.select({ pointId: mathProgress.pointId })
.from(mathProgress)
.where(and(eq(mathProgress.childUserId, childId), eq(mathProgress.passed, true)));
return new Set(rows.map((r) => r.pointId));
}
// xuchao: 记录结果;通过则同步完成当天数学打卡
export async function completeMathPoint(
childId: number,
pointId: string,
title: string,
score: number,
passed: boolean,
) {
await db
.insert(mathProgress)
.values({ childUserId: childId, pointId, score, passed })
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
if (!passed) return;
const today = todayStr();
const ckTitle = `数学思维 · ${title}`;
const [existing] = await db
.select({ id: taskInstances.id, status: taskInstances.status })
.from(taskInstances)
.where(
and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, today), eq(taskInstances.title, ckTitle)),
)
.limit(1);
if (existing) {
if (existing.status !== "done")
await db
.update(taskInstances)
.set({ status: "done", doneAt: sql`CURRENT_TIMESTAMP` })
.where(eq(taskInstances.id, existing.id));
} else {
await db.insert(taskInstances).values({
childUserId: childId,
date: today,
title: ckTitle,
subject: "math",
status: "done",
doneAt: new Date(),
sortOrder: 97,
});
}
}
+2 -1
View File
@@ -22,9 +22,10 @@ export function correctText(q: QuizQ): string {
}
// 错题入库(同一孩子同一来源同一题干去重)
// sourceType:lesson/weekly=英语,mlesson/mweekly=数学,exam=考试卷
export async function recordWrong(
childId: number,
sourceType: "lesson" | "weekly",
sourceType: "lesson" | "weekly" | "mlesson" | "mweekly" | "exam",
sourceId: string,
sourceTitle: string,
q: QuizQ,