学习闭环增强:错题本+每周能力检测+学习概览日历+模板日历+课程管理

- 错题本:所有检测错题自动入库,带详细讲解,可标记掌握,孩子端可查
- 每周能力检测:从已学知识点确定性组卷10题,计分记录,达标关联当日
  英语打卡,错题进错题本,家长可见成绩
- 家长"今日概览"改"学习概览",月日历展示每日打卡/语法点亮/周检测
- 任务模板改日历展示,灵活添加:某一天/每周X/本周/日期区间批量,可删
- 家长中心新增"课程管理"栏目(语法课程编辑+周测成绩),设备管理弱化为
  栏目内二级区块

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-03 23:39:52 +08:00
co-authored by Claude Opus 4.8
parent f98a21672d
commit d84d827202
18 changed files with 905 additions and 312 deletions
+26 -3
View File
@@ -13,12 +13,35 @@ export function dowOf(dateStr: string): number {
// 今天往前推 n 天的日期字符串
export function daysAgo(n: number): string {
const now = new Date();
// 先定位上海时区的今天零点,再回退 n 天
const today = new Date(
new Date().toLocaleDateString("en-US", { timeZone: "Asia/Shanghai" }),
);
today.setDate(today.getDate() - n);
void now;
return today.toLocaleDateString("en-CA");
}
function parse(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
// 日期加减 n 天
export function addDays(dateStr: string, n: number): string {
const d = parse(dateStr);
d.setUTCDate(d.getUTCDate() + n);
return fmt(d);
}
// 该日期所在周的周一(用于"某一周"与周键)
export function mondayOf(dateStr: string): string {
const dow = dowOf(dateStr); // 0=周日..6=周六
const offset = (dow + 6) % 7; // 距周一的天数
return addDays(dateStr, -offset);
}
// 周键:用该周周一的日期字符串,唯一且直观
export function weekKey(dateStr: string): string {
return mondayOf(dateStr);
}
+17 -3
View File
@@ -2,9 +2,22 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { photos, taskInstances, taskTemplates } from "@/db/schema";
import { dowOf } from "@/lib/dates";
import { dowOf, mondayOf } from "@/lib/dates";
import { viewUrl } from "@/lib/oss";
// 模板是否覆盖某一天(weekly/date/range/week 四种生效方式)
function coversDate(
t: { mode: string; dow: number; dateStart: string | null; dateEnd: string | null },
date: string,
): boolean {
if (t.mode === "weekly") return t.dow === dowOf(date);
if (t.mode === "date") return t.dateStart === date;
if (t.mode === "range")
return !!t.dateStart && !!t.dateEnd && date >= t.dateStart && date <= t.dateEnd;
if (t.mode === "week") return !!t.dateStart && mondayOf(t.dateStart) === mondayOf(date);
return false;
}
export type TaskView = {
id: number;
title: string;
@@ -25,11 +38,12 @@ export async function ensureDay(childId: number, date: string) {
.limit(1);
if (existing.length > 0) return;
const templates = await tx
const all = await tx
.select()
.from(taskTemplates)
.where(and(eq(taskTemplates.childUserId, childId), eq(taskTemplates.dow, dowOf(date))))
.where(eq(taskTemplates.childUserId, childId))
.orderBy(asc(taskTemplates.sortOrder));
const templates = all.filter((t) => coversDate(t, date));
if (templates.length > 0) {
await tx.insert(taskInstances).values(
+60
View File
@@ -0,0 +1,60 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { grammarPoints, grammarProgress, weeklyTest } from "@/db/schema";
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
import { todayStr, weekKey } from "@/lib/dates";
export const currentWeekKey = () => weekKey(todayStr());
// 确定性随机:同 (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);
}
// 从已点亮知识点组卷(不足则补入门题),每周固定 10 题
export async function buildWeeklyQuestions(childId: number, wk: string): Promise<QuizQ[]> {
const lit = await db
.select({ pointId: grammarProgress.pointId })
.from(grammarProgress)
.where(and(eq(grammarProgress.childUserId, childId), eq(grammarProgress.passed, true)));
const litIds = new Set(lit.map((r) => r.pointId));
const all = await db.select().from(grammarPoints);
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.level === "starter") pool.push(...extractQuiz(p.md));
}
return seededSample(pool, `${childId}:${wk}`, 10);
}
export async function getWeeklyRecord(childId: number, wk: string) {
const [row] = await db
.select()
.from(weeklyTest)
.where(and(eq(weeklyTest.childUserId, childId), eq(weeklyTest.weekKey, wk)))
.limit(1);
return row ?? null;
}
+65
View File
@@ -0,0 +1,65 @@
import { and, eq, desc } from "drizzle-orm";
import { db } from "@/db";
import { wrongBook } from "@/db/schema";
import type { QuizQ } from "@/lib/quiz-parse";
import type { AnswerValue } from "@/lib/grammar";
// 各类题型的"用户作答"与"标准答案"可读文本
export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string {
if (a == null) return "未作答";
if (q.type === "fill") return String(a);
if (q.type === "order") return Array.isArray(a) ? a.join(" ") : String(a);
if (q.type === "match") return Array.isArray(a) ? (a as string[]).join("、") : String(a);
const opts = q.type === "judge" ? ["对", "错"] : q.options ?? [];
return opts[Number(a)] ?? String(a);
}
export function correctText(q: QuizQ): string {
if (q.type === "fill") return (q.answers ?? []).join(" / ");
if (q.type === "order") return (q.answer as string[]).join(" ");
if (q.type === "match") return (q.pairs ?? []).map((p) => `${p[0]}→${p[1]}`).join("、");
const opts = q.type === "judge" ? ["对", "错"] : q.options ?? [];
return opts[q.answer as number] ?? "";
}
// 错题入库(同一孩子同一来源同一题干去重)
export async function recordWrong(
childId: number,
sourceType: "lesson" | "weekly",
sourceId: string,
sourceTitle: string,
q: QuizQ,
user: AnswerValue | null | undefined,
) {
const rows = await db
.select({ question: wrongBook.question })
.from(wrongBook)
.where(and(eq(wrongBook.childUserId, childId), eq(wrongBook.sourceId, sourceId)));
const dup = rows.some((r) => {
try {
return (JSON.parse(r.question) as QuizQ).stem === q.stem;
} catch {
return false;
}
});
if (dup) return;
await db.insert(wrongBook).values({
childUserId: childId,
sourceType,
sourceId,
sourceTitle,
question: JSON.stringify(q),
userAnswer: answerText(q, user),
correctAnswer: correctText(q),
});
}
export async function listWrong(childId: number) {
return db.select().from(wrongBook).where(eq(wrongBook.childUserId, childId)).orderBy(desc(wrongBook.createdAt));
}
export async function setMastered(id: number, childId: number, mastered: boolean) {
await db
.update(wrongBook)
.set({ mastered, updatedAt: new Date() })
.where(and(eq(wrongBook.id, id), eq(wrongBook.childUserId, childId)));
}