第二期:企业信任风 UI + 打卡科目分类 + 英语语法学习模块

- 全站按 Corporate Trust 设计规范重做:靛蓝/紫罗兰渐变、Plus Jakarta Sans
  自托管字体、带色阴影、卡片悬浮、氛围光斑
- 打卡任务增加科目分类(语文/数学/英语/运动/其他),孩子端按科目分组
- 英语语法自学模块:线性解锁课程 + 课后/周/月测验引擎,测验达标
  自动联动当日英语打卡;家长概览显示语法进度
- 课程内容:A1→B1 完整大纲,9 周 54 课 + 9 周测 + 2 月测,讲解/例句/
  练习/测验均原创,仅参考两份语法书的主题编排

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-03 01:20:04 +08:00
co-authored by Claude Opus 4.8
parent b08f6649be
commit 6ed4d5a61e
45 changed files with 5824 additions and 92 deletions
+89
View File
@@ -0,0 +1,89 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { grammarProgress, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { SYLLABUS, TOTAL, entryBySeq } from "@/content/grammar";
// 已通过的课程序列集合
export async function passedSet(childId: number): Promise<Set<number>> {
const rows = await db
.select({ seq: grammarProgress.seq })
.from(grammarProgress)
.where(and(eq(grammarProgress.childUserId, childId), eq(grammarProgress.passed, true)));
return new Set(rows.map((r) => r.seq));
}
// 下一个该学的序列(第一个未通过的);全部完成返回 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;
}
// 解锁规则:第 1 课永远开放;其余需前一课已通过
export function isUnlocked(seq: number, passed: Set<number>): boolean {
return seq === 1 || passed.has(seq - 1);
}
// 记录某课结果;通过则同步完成当天的英语打卡
export async function completeSeq(
childId: number,
seq: number,
score: number,
passed: boolean,
) {
await db
.insert(grammarProgress)
.values({ childUserId: childId, seq, 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 [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),
),
)
.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,
subject: "english",
status: "done",
doneAt: new Date(),
sortOrder: 99,
});
}
}
// 服务端计分:对比提交的答案,返回得分与每题对错,防作弊
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 };
}