英语语法模块 v2:接入参考语法体系,中文为主,家长可编辑,全部可学

- 内容底座改为你提供的语法体系(入门/基础/进阶 69 知识点,中文讲解 +
  中英对照例句 + 六步教学法),整体更适合小学生
- 内容存库(grammar_point),家长中心新增"编辑英语课程"可改标题/目标/
  课文,保存即时生效
- 取消顺序解锁,所有课程可直接学习,通过测验即点亮
- 测验引擎支持 选择/判断/填空/连词成句/连线 五种题型,达标自动联动
  当日英语打卡

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-03 22:53:10 +08:00
co-authored by Claude Opus 4.8
parent 6ed4d5a61e
commit f98a21672d
36 changed files with 2379 additions and 4002 deletions
-10
View File
@@ -1,10 +0,0 @@
import type { Cefr } from "@/content/grammar/types";
// CEFR 级别配色(徽章与进度用)
export const CEFR_STYLE: Record<Cefr, { label: string; cls: string }> = {
A1: { label: "A1 入门", cls: "bg-emerald-100 text-emerald-700" },
A2: { label: "A2 基础", cls: "bg-sky-100 text-sky-700" },
B1: { label: "B1 进阶", cls: "bg-indigo-100 text-indigo-700" },
B2: { label: "B2 中高", cls: "bg-violet-100 text-violet-700" },
C1: { label: "C1 高级", cls: "bg-amber-100 text-amber-700" },
};
+81 -44
View File
@@ -1,73 +1,122 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { grammarProgress, taskInstances } from "@/db/schema";
import { grammarPoints, grammarProgress, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { SYLLABUS, TOTAL, entryBySeq } from "@/content/grammar";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
// 已通过的课程序列集合
export async function passedSet(childId: number): Promise<Set<number>> {
export { extractQuiz, type QuizQ };
// 三档级别元信息(展示用)
export const LEVELS = [
{ id: "starter", name: "入门", stars: 1, desc: "打地基:词与句子、四种句类、名词、代词、冠词、be 动词等。" },
{ id: "base", name: "基础", stars: 2, desc: "时态、情态、比较级、介词、连词等核心用法。" },
{ id: "pro", name: "进阶", stars: 3, desc: "复合句、非谓语、被动、虚拟等综合提升。" },
] as const;
export type PointRow = {
pointId: string;
level: string;
levelName: string;
title: string;
difficulty: number;
seq: number;
};
// 全部知识点(按展示顺序)
export async function listPoints(): Promise<PointRow[]> {
return db
.select({
pointId: grammarPoints.pointId,
level: grammarPoints.level,
levelName: grammarPoints.levelName,
title: grammarPoints.title,
difficulty: grammarPoints.difficulty,
seq: grammarPoints.seq,
})
.from(grammarPoints)
.orderBy(grammarPoints.seq);
}
export async function totalPoints(): Promise<number> {
const [r] = await db.select({ n: sql<number>`COUNT(*)` }).from(grammarPoints);
return r.n;
}
// 单个知识点全文
export async function getPoint(pointId: string) {
const [row] = await db.select().from(grammarPoints).where(eq(grammarPoints.pointId, pointId)).limit(1);
return row ?? null;
}
// 已通过知识点集合
export async function passedByPoint(childId: number): Promise<Set<string>> {
const rows = await db
.select({ seq: grammarProgress.seq })
.select({ pointId: grammarProgress.pointId })
.from(grammarProgress)
.where(and(eq(grammarProgress.childUserId, childId), eq(grammarProgress.passed, true)));
return new Set(rows.map((r) => r.seq));
return new Set(rows.map((r) => r.pointId));
}
// 下一个该学的序列(第一个未通过的);全部完成返回 TOTAL+1
export async function nextSeq(childId: number): Promise<number> {
const passed = await passedSet(childId);
for (const e of SYLLABUS) if (!passed.has(e.seq)) return e.seq;
return TOTAL + 1;
export async function passedCount(childId: number): Promise<number> {
return (await passedByPoint(childId)).size;
}
// 解锁规则:第 1 课永远开放;其余需前一课已通过
export function isUnlocked(seq: number, passed: Set<number>): boolean {
return seq === 1 || passed.has(seq - 1);
const norm = (s: unknown) => String(s ?? "").trim().toLowerCase();
// 服务端判分:五种题型 choice/judge/fill/order/match
export type AnswerValue = number | string | string[];
export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) {
let correct = 0;
const detail = questions.map((q, i) => {
const a = answers[i];
let ok = false;
if (q.type === "fill") ok = (q.answers ?? []).some((x) => norm(x) === norm(a));
else if (q.type === "order")
ok = Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
else if (q.type === "match")
ok = Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
else ok = Number(a) === (q.answer as number);
if (ok) correct++;
return ok;
});
const score = questions.length ? Math.round((correct / questions.length) * 100) : 0;
return { score, passed: score >= 80, correct, total: questions.length, detail };
}
// 记录某课结果;通过则同步完成当天的英语打卡
export async function completeSeq(
// 记录结果;通过则同步完成当天英语打卡
export async function completePoint(
childId: number,
seq: number,
pointId: string,
title: string,
score: number,
passed: boolean,
) {
await db
.insert(grammarProgress)
.values({ childUserId: childId, seq, score, passed })
.values({ childUserId: childId, pointId, score, passed })
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
if (!passed) return;
const entry = entryBySeq(seq);
if (!entry) return;
const title = entry.type === "lesson" ? `英语语法 · ${entry.title}` : entry.title;
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, title),
),
and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, today), eq(taskInstances.title, ckTitle)),
)
.limit(1);
if (existing) {
if (existing.status !== "done") {
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,
title: ckTitle,
subject: "english",
status: "done",
doneAt: new Date(),
@@ -75,15 +124,3 @@ export async function completeSeq(
});
}
}
// 服务端计分:对比提交的答案,返回得分与每题对错,防作弊
export function grade(entryQuiz: { answer: number }[], answers: number[]) {
let correct = 0;
const detail = entryQuiz.map((q, i) => {
const right = answers[i] === q.answer;
if (right) correct++;
return right;
});
const score = entryQuiz.length ? Math.round((correct / entryQuiz.length) * 100) : 0;
return { score, passed: score >= 80, correct, total: entryQuiz.length, detail };
}
+28
View File
@@ -0,0 +1,28 @@
// 纯解析工具:不依赖数据库,可安全在客户端组件中使用
export type QuizQ = {
type: "choice" | "fill" | "judge" | "order" | "match";
stem: string;
options?: string[];
answer?: number | string[];
answers?: string[];
words?: string[]; // order:打乱的词
pairs?: [string, string][]; // match:左右配对
explain: string;
};
// 从课文 markdown 中提取所有 ```quiz 块的问题
export function extractQuiz(md: string): QuizQ[] {
const out: QuizQ[] = [];
const re = /```quiz\s*\n([\s\S]*?)```/g;
let m: RegExpExecArray | null;
while ((m = re.exec(md))) {
try {
const parsed = JSON.parse(m[1]);
if (Array.isArray(parsed.questions)) out.push(...parsed.questions);
} catch {
/* 忽略坏块 */
}
}
return out;
}