From d84d82720237f368e4221b745242e4539e3c23ff Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Mon, 3 Aug 2026 23:39:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A6=E4=B9=A0=E9=97=AD=E7=8E=AF=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA:=E9=94=99=E9=A2=98=E6=9C=AC+=E6=AF=8F=E5=91=A8?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=A3=80=E6=B5=8B+=E5=AD=A6=E4=B9=A0?= =?UTF-8?q?=E6=A6=82=E8=A7=88=E6=97=A5=E5=8E=86+=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=97=A5=E5=8E=86+=E8=AF=BE=E7=A8=8B=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 错题本:所有检测错题自动入库,带详细讲解,可标记掌握,孩子端可查 - 每周能力检测:从已学知识点确定性组卷10题,计分记录,达标关联当日 英语打卡,错题进错题本,家长可见成绩 - 家长"今日概览"改"学习概览",月日历展示每日打卡/语法点亮/周检测 - 任务模板改日历展示,灵活添加:某一天/每周X/本周/日期区间批量,可删 - 家长中心新增"课程管理"栏目(语法课程编辑+周测成绩),设备管理弱化为 栏目内二级区块 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/kid/page.tsx | 21 +++ src/app/learn/actions.ts | 6 + src/app/learn/quiz-block.tsx | 14 +- src/app/parent/actions.ts | 18 +- src/app/parent/dashboard.tsx | 94 ++++++----- src/app/parent/overview-tab.tsx | 184 ++++++++++----------- src/app/parent/page.tsx | 126 +++++++++----- src/app/parent/templates-tab.tsx | 273 ++++++++++++++++++------------- src/app/weekly/actions.ts | 76 +++++++++ src/app/weekly/page.tsx | 50 ++++++ src/app/wrongbook/actions.ts | 13 ++ src/app/wrongbook/page.tsx | 58 +++++++ src/app/wrongbook/wrong-row.tsx | 64 ++++++++ src/db/schema.ts | 46 +++++- src/lib/dates.ts | 29 +++- src/lib/tasks.ts | 20 ++- src/lib/weekly.ts | 60 +++++++ src/lib/wrongbook.ts | 65 ++++++++ 18 files changed, 905 insertions(+), 312 deletions(-) create mode 100644 src/app/weekly/actions.ts create mode 100644 src/app/weekly/page.tsx create mode 100644 src/app/wrongbook/actions.ts create mode 100644 src/app/wrongbook/page.tsx create mode 100644 src/app/wrongbook/wrong-row.tsx create mode 100644 src/lib/weekly.ts create mode 100644 src/lib/wrongbook.ts diff --git a/src/app/kid/page.tsx b/src/app/kid/page.tsx index ec50438..76a0260 100644 --- a/src/app/kid/page.tsx +++ b/src/app/kid/page.tsx @@ -62,6 +62,27 @@ export default async function KidPage() { +
+ +
+ 📝 +
+

每周能力检测

+

看看这周掌握得怎样

+
+
+ + +
+ 📒 +
+

我的错题本

+

错题都有详细讲解

+
+
+ +
+ {tasks.length === 0 ? (

🎉

diff --git a/src/app/learn/actions.ts b/src/app/learn/actions.ts index 546318d..fb8edd3 100644 --- a/src/app/learn/actions.ts +++ b/src/app/learn/actions.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { getSessionUser } from "@/lib/auth"; import { completePoint, extractQuiz, getPoint, gradeAnswers, type AnswerValue } from "@/lib/grammar"; +import { recordWrong } from "@/lib/wrongbook"; export type QuizResult = | { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } @@ -20,11 +21,16 @@ export async function submitQuiz(pointId: string, answers: AnswerValue[]): Promi if (questions.length === 0) return { error: "本课暂无练习题" }; const g = gradeAnswers(questions, answers); + // 错题进错题本(带详细讲解) + questions.forEach((q, i) => { + if (!g.detail[i]) void recordWrong(user.id, "lesson", pointId, point.title, q, answers[i]); + }); await completePoint(user.id, pointId, point.title, g.score, g.passed); revalidatePath("/learn"); revalidatePath(`/learn/${pointId}`); revalidatePath("/kid"); revalidatePath("/parent"); + revalidatePath("/wrongbook"); return g; } diff --git a/src/app/learn/quiz-block.tsx b/src/app/learn/quiz-block.tsx index 575bd89..a51dfb9 100644 --- a/src/app/learn/quiz-block.tsx +++ b/src/app/learn/quiz-block.tsx @@ -12,7 +12,15 @@ import { submitQuiz, type QuizResult } from "./actions"; type AV = AnswerValue | null; // 结课检验:选择/判断/填空/连词成句/连线,提交后服务端判分,达标点亮并联动打卡 -export function QuizBlock({ pointId, questions }: { pointId: string; questions: QuizQ[] }) { +export function QuizBlock({ + pointId, + questions, + submitter, +}: { + pointId: string; + questions: QuizQ[]; + submitter?: (a: AnswerValue[]) => Promise; +}) { const [answers, setAnswers] = useState(questions.map(() => null)); const [result, setResult] = useState | null>(null); const [error, setError] = useState(""); @@ -35,7 +43,9 @@ export function QuizBlock({ pointId, questions }: { pointId: string; questions: const submit = () => { setError(""); startTransition(async () => { - const r = await submitQuiz(pointId, answers.map((a) => a as AnswerValue)); + const r = submitter + ? await submitter(answers.map((a) => a as AnswerValue)) + : await submitQuiz(pointId, answers.map((a) => a as AnswerValue)); if ("error" in r) setError(r.error); else setResult(r); }); diff --git a/src/app/parent/actions.ts b/src/app/parent/actions.ts index 631e61d..b76f166 100644 --- a/src/app/parent/actions.ts +++ b/src/app/parent/actions.ts @@ -76,8 +76,11 @@ export async function makeupTask(instanceId: number, done: boolean) { const templateSchema = z.object({ childId: z.coerce.number().int().positive(), - dow: z.coerce.number().int().min(0).max(6), + dow: z.coerce.number().int().min(0).max(6).catch(0), subject: z.enum(SUBJECT_VALUES).catch("other"), + mode: z.enum(["weekly", "date", "range", "week"]).catch("weekly"), + dateStart: z.string().optional(), + dateEnd: z.string().optional(), title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"), requiresPhoto: z.boolean(), }); @@ -100,6 +103,9 @@ export async function addTemplate(formData: FormData) { childId: formData.get("childId"), dow: formData.get("dow"), subject: formData.get("subject"), + mode: formData.get("mode"), + dateStart: formData.get("dateStart") || null, + dateEnd: formData.get("dateEnd") || null, title: formData.get("title"), requiresPhoto: formData.get("requiresPhoto") === "on", }); @@ -109,17 +115,15 @@ export async function addTemplate(formData: FormData) { const [maxRow] = await db .select({ maxSort: sql`COALESCE(MAX(${taskTemplates.sortOrder}), -1)` }) .from(taskTemplates) - .where( - and( - eq(taskTemplates.childUserId, parsed.data.childId), - eq(taskTemplates.dow, parsed.data.dow), - ), - ); + .where(eq(taskTemplates.childUserId, parsed.data.childId)); await db.insert(taskTemplates).values({ childUserId: parsed.data.childId, dow: parsed.data.dow, subject: parsed.data.subject, + mode: parsed.data.mode, + dateStart: parsed.data.dateStart || null, + dateEnd: parsed.data.dateEnd || null, title: parsed.data.title, requiresPhoto: parsed.data.requiresPhoto, sortOrder: maxRow.maxSort + 1, diff --git a/src/app/parent/dashboard.tsx b/src/app/parent/dashboard.tsx index d395147..ca2d76a 100644 --- a/src/app/parent/dashboard.tsx +++ b/src/app/parent/dashboard.tsx @@ -9,18 +9,15 @@ import { DevicesTab } from "./devices-tab"; import { OverviewTab } from "./overview-tab"; import { TemplatesTab } from "./templates-tab"; +export type DayCell = { done: number; total: number; grammar: number; weekly: number | null }; export type OverviewData = { kids: { id: number; name: string; - streak: number; - today: { done: number; total: number }; grammar: { done: number; total: number }; - week: { - date: string; - isToday: boolean; - tasks: { id: number; title: string; subject: string; status: "pending" | "done"; isMakeup: boolean }[]; - }[]; + wrongOpen: number; + weeklyLatest: { weekKey: string; score: number; passed: boolean } | null; + days: Record; }[]; }; @@ -28,7 +25,16 @@ export type TemplatesData = { kids: { id: number; name: string; - items: { id: number; dow: number; title: string; subject: string; requiresPhoto: boolean }[]; + items: { + id: number; + dow: number; + title: string; + subject: string; + requiresPhoto: boolean; + mode: string; + dateStart: string | null; + dateEnd: string | null; + }[]; }[]; }; @@ -43,67 +49,77 @@ export type DevicesData = { }[]; }; +export type WeeklyRow = { childName: string; weekKey: string; score: number; passed: boolean }; + +const tabCls = "data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft"; + export function ParentDashboard({ overview, templates, devices, + weekly, }: { overview: OverviewData; templates: TemplatesData; devices: DevicesData; + weekly: WeeklyRow[]; }) { - const kids = overview.kids.map((k) => ({ id: k.id, name: k.name })); - return ( -
+

家长中心

-
- - - -
- -
-
+
+ +
- - 今日概览 + + 学习概览 - + 任务模板 - - 设备管理 + + 课程管理 + - - + +
+
+ +
+

✏️ 编辑英语语法课程

+

改标题/目标/课文,保存即生效

+
+ +
+

📝 每周检测成绩

+
+ {weekly.length === 0 &&

暂无检测记录

} + {weekly.map((w, i) => ( +

+ {w.childName} · {w.weekKey} · {w.score} 分 {w.passed ? "✅" : "❌"} +

+ ))} +
+
+
+ ({ id: k.id, name: k.name }))} /> +
diff --git a/src/app/parent/overview-tab.tsx b/src/app/parent/overview-tab.tsx index 8333a45..d9b8c7b 100644 --- a/src/app/parent/overview-tab.tsx +++ b/src/app/parent/overview-tab.tsx @@ -1,108 +1,102 @@ "use client"; -import { useTransition } from "react"; -import { Flame } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useState } from "react"; import { cn } from "@/lib/utils"; -import { subjectMeta } from "@/lib/subjects"; -import { makeupTask } from "./actions"; +import { addDays, dowOf, todayStr } from "@/lib/dates"; +import { Button } from "@/components/ui/button"; import type { OverviewData } from "./dashboard"; -const DOW_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; - -function dateLabel(date: string) { - const [y, m, d] = date.split("-").map(Number); - const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); - return `${m}月${d}日 ${DOW_LABELS[dow]}`; -} - +// 学习概览:按孩子的月日历,展示每日 打卡/语法/周检测 情况 export function OverviewTab({ data }: { data: OverviewData }) { - const [, startTransition] = useTransition(); + const [kidId, setKidId] = useState(data.kids[0]?.id); + const kid = data.kids.find((k) => k.id === kidId) ?? data.kids[0]; + if (!kid) return null; + + const today = todayStr(); + const ym = today.slice(0, 7); + const first = `${ym}-01`; + const daysInMonth = new Date(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)), 0).getDate(); + const lead = (dowOf(first) + 6) % 7; // 周一开头的前导空位数 + const dates = Array.from({ length: daysInMonth }, (_, i) => `${ym}-${String(i + 1).padStart(2, "0")}`); return (
- {data.kids.map((kid) => ( - - - {kid.name} -
- - {kid.streak} - 天连击 -
-
- -

- 今日完成 {kid.today.done} / {kid.today.total} 项 -

-
- 英语语法 -
-
+
+ {data.kids.map((k) => ( + + ))} +
+ +
+
+

语法进度

+

+ {kid.grammar.done}/{kid.grammar.total} +

+
+
+

待掌握错题

+

{kid.wrongOpen}

+
+
+

最近周测

+

+ {kid.weeklyLatest ? `${kid.weeklyLatest.score}分` : "—"} +

+
+
+ +
+
+ {["一", "二", "三", "四", "五", "六", "日"].map((w) => ( +
周{w}
+ ))} +
+
+ {Array.from({ length: lead }).map((_, i) => ( +
+ ))} + {dates.map((d) => { + const c = kid.days[d]; + const isToday = d === today; + const check = c && c.total > 0 ? (c.done >= c.total && c.done > 0 ? 2 : c.done > 0 ? 1 : 0) : 0; + return ( +
+ + {Number(d.slice(8))} + + + {check === 2 && } + {check === 1 && } + {(c?.grammar ?? 0) > 0 && } + {c?.weekly != null && } +
- - {kid.grammar.done}/{kid.grammar.total} - -
-
- {kid.week.map((day) => ( -
-

- {dateLabel(day.date)} - {day.isToday && " · 今天"} -

- {day.tasks.length === 0 ? ( -

无任务(自动保持连击)

- ) : ( -
- {day.tasks.map((t) => ( - - - - {t.title} - {t.isMakeup && " · 补"} - - {!day.isToday && t.status === "pending" && ( - - )} - {!day.isToday && t.isMakeup && ( - - )} - - ))} -
- )} -
- ))} -
- - - ))} + ); + })} +
+

+ 打卡完成 + 打卡部分 + 语法点亮 + 周检测 +

+
); } diff --git a/src/app/parent/page.tsx b/src/app/parent/page.tsx index b115846..4735fa8 100644 --- a/src/app/parent/page.tsx +++ b/src/app/parent/page.tsx @@ -1,19 +1,30 @@ import crypto from "node:crypto"; -import { and, asc, desc, eq, inArray } from "drizzle-orm"; +import { and, asc, desc, eq, gte, lte, inArray } from "drizzle-orm"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { db } from "@/db"; -import { sessions, taskInstances, taskTemplates, users } from "@/db/schema"; +import { + sessions, + taskInstances, + taskTemplates, + users, + grammarProgress, + weeklyTest, + wrongBook, +} from "@/db/schema"; import { SESSION_COOKIE, getSessionUser } from "@/lib/auth"; -import { daysAgo, todayStr } from "@/lib/dates"; -import { ensureDay } from "@/lib/tasks"; -import { streakOf } from "@/lib/streak"; +import { addDays, dowOf, todayStr } from "@/lib/dates"; import { passedCount, totalPoints } from "@/lib/grammar"; -import { ParentDashboard, type DevicesData, type OverviewData, type TemplatesData } from "./dashboard"; +import { + ParentDashboard, + type DevicesData, + type OverviewData, + type TemplatesData, + type WeeklyRow, +} from "./dashboard"; -const SHANGHAI = { timeZone: "Asia/Shanghai" } as const; -const fmtDateTime = (d: Date) => - d.toLocaleString("zh-CN", { ...SHANGHAI, hour12: false }).replace(/\//g, "-"); +const SH = { timeZone: "Asia/Shanghai" } as const; +const dstr = (d: Date) => d.toLocaleDateString("en-CA", SH); export default async function ParentPage() { const parent = await getSessionUser(); @@ -27,58 +38,62 @@ export default async function ParentPage() { .orderBy(asc(users.sortOrder)); const today = todayStr(); - const weekDates = Array.from({ length: 7 }, (_, i) => daysAgo(i)); // 今天在前 + const ym = today.slice(0, 7); + const daysInMonth = new Date(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)), 0).getDate(); + const mStart = `${ym}-01`; + const mEnd = `${ym}-${String(daysInMonth).padStart(2, "0")}`; const gTotal = await totalPoints(); - // ===== 今日概览 ===== + // ===== 学习概览(日历) ===== const overview: OverviewData = { kids: [] }; for (const kid of kids) { - await ensureDay(kid.id, today); // 家长先看时也要生成今天的快照 - const [streak, gDone, rows] = await Promise.all([ - streakOf(kid.id), - passedCount(kid.id), + const [instRows, gRows, wRows, wrongRow, gDone] = await Promise.all([ db - .select({ - id: taskInstances.id, - date: taskInstances.date, - title: taskInstances.title, - subject: taskInstances.subject, - status: taskInstances.status, - isMakeup: taskInstances.isMakeup, - sortOrder: taskInstances.sortOrder, - }) + .select({ date: taskInstances.date, status: taskInstances.status }) .from(taskInstances) - .where(and(eq(taskInstances.childUserId, kid.id), inArray(taskInstances.date, weekDates))) - .orderBy(desc(taskInstances.date), asc(taskInstances.sortOrder)), + .where(and(eq(taskInstances.childUserId, kid.id), gte(taskInstances.date, mStart), lte(taskInstances.date, mEnd))), + db + .select({ completedAt: grammarProgress.completedAt, passed: grammarProgress.passed }) + .from(grammarProgress) + .where(and(eq(grammarProgress.childUserId, kid.id), eq(grammarProgress.passed, true))), + db.select().from(weeklyTest).where(eq(weeklyTest.childUserId, kid.id)).orderBy(desc(weeklyTest.takenAt)), + db + .select({ id: wrongBook.id }) + .from(wrongBook) + .where(and(eq(wrongBook.childUserId, kid.id), eq(wrongBook.mastered, false))), + passedCount(kid.id), ]); - const week = weekDates.map((date) => ({ - date, - isToday: date === today, - tasks: rows.filter((r) => r.date === date), - })); - const todayTasks = week.find((w) => w.isToday)?.tasks ?? []; - + const days: OverviewData["kids"][number]["days"] = {}; + for (let i = 1; i <= daysInMonth; i++) { + const d = `${ym}-${String(i).padStart(2, "0")}`; + const inst = instRows.filter((r) => r.date === d); + const grammar = gRows.filter((r) => dstr(r.completedAt) === d).length; + const w = wRows.find((r) => dstr(r.takenAt) === d); + days[d] = { + done: inst.filter((r) => r.status === "done").length, + total: inst.length, + grammar, + weekly: w ? w.score : null, + }; + } overview.kids.push({ id: kid.id, name: kid.name, - streak, - today: { - total: todayTasks.length, - done: todayTasks.filter((t) => t.status === "done").length, - }, grammar: { done: gDone, total: gTotal }, - week, + wrongOpen: wrongRow.length, + weeklyLatest: wRows[0] ? { weekKey: wRows[0].weekKey, score: wRows[0].score ?? 0, passed: wRows[0].passed } : null, + days, }); } - // ===== 周模板 ===== + // ===== 任务模板 ===== const templateRows = kids.length ? await db .select() .from(taskTemplates) .where(inArray(taskTemplates.childUserId, kids.map((k) => k.id))) - .orderBy(asc(taskTemplates.childUserId), asc(taskTemplates.dow), asc(taskTemplates.sortOrder)) + .orderBy(asc(taskTemplates.childUserId), asc(taskTemplates.sortOrder)) : []; const templates: TemplatesData = { kids: kids.map((kid) => ({ @@ -92,6 +107,9 @@ export default async function ParentPage() { title: t.title, subject: t.subject, requiresPhoto: t.requiresPhoto, + mode: t.mode, + dateStart: t.dateStart, + dateEnd: t.dateEnd, })), })), }; @@ -118,11 +136,31 @@ export default async function ParentPage() { id: s.id, owner: s.role === "parent" ? `${s.owner}(家长)` : s.owner, deviceLabel: s.deviceLabel, - createdAt: fmtDateTime(s.createdAt), - expiresAt: fmtDateTime(s.expiresAt), + createdAt: dstr(s.createdAt), + expiresAt: dstr(s.expiresAt), isCurrent: s.tokenHash === tokenHash, })), }; - return ; + // ===== 周测成绩(课程管理) ===== + const weeklyRows = await db + .select({ + childName: users.displayName, + weekKey: weeklyTest.weekKey, + score: weeklyTest.score, + passed: weeklyTest.passed, + }) + .from(weeklyTest) + .innerJoin(users, eq(users.id, weeklyTest.childUserId)) + .where(eq(users.familyId, parent.familyId)) + .orderBy(desc(weeklyTest.takenAt)) + .limit(10); + const weekly: WeeklyRow[] = weeklyRows.map((w) => ({ + childName: w.childName, + weekKey: w.weekKey, + score: w.score ?? 0, + passed: w.passed, + })); + + return ; } diff --git a/src/app/parent/templates-tab.tsx b/src/app/parent/templates-tab.tsx index c4eca0d..ce576de 100644 --- a/src/app/parent/templates-tab.tsx +++ b/src/app/parent/templates-tab.tsx @@ -3,165 +3,202 @@ import { useState, useTransition } from "react"; import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; +import { addDays, dowOf, mondayOf, todayStr } from "@/lib/dates"; import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects"; import { addTemplate, deleteTemplate } from "./actions"; import type { TemplatesData } from "./dashboard"; -const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0]; // 展示顺序:周一到周日 -const DOW_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; +type Item = TemplatesData["kids"][number]["items"][number]; -export function TemplatesTab({ data }: { data: TemplatesData }) { - return ( -
-

- 模板改动从第二天起生效,不影响已生成的历史任务。 -

- {data.kids.map((kid) => ( - - ))} -
- ); +function covers(t: Item, 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; } -function KidTemplateCard({ - kid, -}: { - kid: TemplatesData["kids"][number]; -}) { - const [dow, setDow] = useState(1); +// 任务模板:日历展示 + 灵活添加(某一天/每周X/本周/日期区间) +export function TemplatesTab({ data }: { data: TemplatesData }) { + const [kidId, setKidId] = useState(data.kids[0]?.id); + const kid = data.kids.find((k) => k.id === kidId) ?? data.kids[0]; + const [selected, setSelected] = useState(todayStr()); + const [mode, setMode] = useState<"date" | "weekly" | "week" | "range">("weekly"); const [subject, setSubject] = useState("other"); const [title, setTitle] = useState(""); - const [requiresPhoto, setRequiresPhoto] = useState(false); + const [photo, setPhoto] = useState(false); + const [rangeStart, setRangeStart] = useState(todayStr()); + const [rangeEnd, setRangeEnd] = useState(addDays(todayStr(), 6)); const [error, setError] = useState(""); const [pending, startTransition] = useTransition(); + if (!kid) return null; - function onAdd(e: React.FormEvent) { + const today = todayStr(); + const ym = today.slice(0, 7); + const daysInMonth = new Date(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)), 0).getDate(); + const lead = (dowOf(`${ym}-01`) + 6) % 7; + const dates = Array.from({ length: daysInMonth }, (_, i) => `${ym}-${String(i + 1).padStart(2, "0")}`); + const dayTasks = (d: string) => kid.items.filter((t) => covers(t, d)); + + const submit = (e: React.FormEvent) => { e.preventDefault(); setError(""); const fd = new FormData(); fd.set("childId", String(kid.id)); - fd.set("dow", String(dow)); fd.set("subject", subject); fd.set("title", title); - if (requiresPhoto) fd.set("requiresPhoto", "on"); + fd.set("mode", mode); + fd.set("dow", String(dowOf(selected))); + if (mode === "date" || mode === "week") fd.set("dateStart", selected); + if (mode === "range") { + fd.set("dateStart", rangeStart); + fd.set("dateEnd", rangeEnd); + } + if (photo) fd.set("requiresPhoto", "on"); startTransition(async () => { try { await addTemplate(fd); setTitle(""); - setRequiresPhoto(false); + setPhoto(false); } catch (err) { setError(err instanceof Error ? err.message : "添加失败"); } }); - } + }; + + const modeLabel: Record = { + date: "某一天", + weekly: "每周X", + week: "某一周", + range: "日期区间", + }; return ( - - - {kid.name} - - -
- {WEEK_ORDER.map((d) => { - const items = kid.items.filter((i) => i.dow === d); +
+
+ {data.kids.map((k) => ( + + ))} +
+ +
+
+ {["一", "二", "三", "四", "五", "六", "日"].map((w) => ( +
周{w}
+ ))} +
+
+ {Array.from({ length: lead }).map((_, i) => ( +
+ ))} + {dates.map((d) => { + const ts = dayTasks(d); return ( -
- - {DOW_LABELS[d]} + - - ); - })} -
+ + {ts.slice(0, 4).map((t) => ( + + ))} + + + ); + })} +
+
+ +
+

{selected} 的任务

+
+ {dayTasks(selected).length === 0 &&

这一天没有任务(休息日)

} + {dayTasks(selected).map((t) => { + const sm = subjectMeta(t.subject); + return ( +
+ + {sm.label} + {t.title} + ({modeLabel[t.mode] ?? t.mode}) +
); })}
-
-
- - + +
+
+ + +
+ {mode === "range" && ( + <> +
+ + setRangeStart(e.target.value)} className="h-9" /> +
+
+ + setRangeEnd(e.target.value)} className="h-9" /> +
+ + )} +
+ + +
-
- - +
+
+ + setTitle(e.target.value)} placeholder="如:看书 30 分钟" maxLength={100} /> +
+
+ setPhoto(v === true)} /> + +
+
-
- - setTitle(e.target.value)} - placeholder="如:看书 30 分钟" - maxLength={100} - /> -
-
- setRequiresPhoto(v === true)} - /> - -
- + {error &&

{error}

} - {error &&

{error}

} - - +
+
); } diff --git a/src/app/weekly/actions.ts b/src/app/weekly/actions.ts new file mode 100644 index 0000000..2e9e73b --- /dev/null +++ b/src/app/weekly/actions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { and, eq, sql } from "drizzle-orm"; +import { getSessionUser } from "@/lib/auth"; +import { gradeAnswers, type AnswerValue } from "@/lib/grammar"; +import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly"; +import { recordWrong } from "@/lib/wrongbook"; +import { db } from "@/db"; +import { taskInstances, weeklyTest } from "@/db/schema"; +import { todayStr } from "@/lib/dates"; + +export type WeeklyResult = + | { score: number; passed: boolean; correct: number; total: number; detail: boolean[] } + | { error: string }; + +// 提交每周能力检测:判分、记录、错题入库、达标关联当日英语打卡 +export async function takeWeekly(answers: AnswerValue[]): Promise { + const user = await getSessionUser(); + if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" }; + const wk = currentWeekKey(); + const existing = await getWeeklyRecord(user.id, wk); + if (existing) return { error: "本周已完成检测,下周再来。" }; + + const questions = await buildWeeklyQuestions(user.id, wk); + if (questions.length === 0) return { error: "暂无可组卷的题目,先学几课吧。" }; + + const g = gradeAnswers(questions, answers); + questions.forEach((q, i) => { + if (!g.detail[i]) void recordWrong(user.id, "weekly", wk, `每周检测 ${wk}`, q, answers[i]); + }); + + await db + .insert(weeklyTest) + .values({ + childUserId: user.id, + weekKey: wk, + score: g.score, + correct: g.correct, + total: g.total, + passed: g.passed, + questions: JSON.stringify(questions), + }) + .onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } }); + + // 达标关联当日英语打卡 + if (g.passed) { + const today = todayStr(); + const title = `英语周检测 · ${wk}`; + const [ex] = await db + .select({ id: taskInstances.id, status: taskInstances.status }) + .from(taskInstances) + .where(and(eq(taskInstances.childUserId, user.id), eq(taskInstances.date, today), eq(taskInstances.title, title))) + .limit(1); + if (ex) { + if (ex.status !== "done") + await db.update(taskInstances).set({ status: "done", doneAt: sql`CURRENT_TIMESTAMP` }).where(eq(taskInstances.id, ex.id)); + } else { + await db.insert(taskInstances).values({ + childUserId: user.id, + date: today, + title, + subject: "english", + status: "done", + doneAt: new Date(), + sortOrder: 98, + }); + } + } + + revalidatePath("/weekly"); + revalidatePath("/kid"); + revalidatePath("/parent"); + revalidatePath("/wrongbook"); + return g; +} diff --git a/src/app/weekly/page.tsx b/src/app/weekly/page.tsx new file mode 100644 index 0000000..63aa4e9 --- /dev/null +++ b/src/app/weekly/page.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { BackgroundBlobs } from "@/components/background-blobs"; +import { getSessionUser } from "@/lib/auth"; +import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly"; +import { QuizBlock } from "@/app/learn/quiz-block"; +import { takeWeekly } from "./actions"; + +export default async function WeeklyPage() { + const user = await getSessionUser(); + if (!user) redirect("/join"); + if (user.role !== "child") redirect("/parent"); + + const wk = currentWeekKey(); + const record = await getWeeklyRecord(user.id, wk); + const questions = record ? [] : await buildWeeklyQuestions(user.id, wk); + + return ( +
+ +
+ + ← 返回 + +

每周能力检测

+ {wk} +
+

+ 从你已点亮的知识点里抽 10 题,判断这周的掌握情况;达标自动计入今日英语打卡,错题进错题本。 +

+ + {record ? ( +
+

{record.score} 分

+

+ 答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"} +

+
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/wrongbook/actions.ts b/src/app/wrongbook/actions.ts new file mode 100644 index 0000000..03bd2e3 --- /dev/null +++ b/src/app/wrongbook/actions.ts @@ -0,0 +1,13 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSessionUser } from "@/lib/auth"; +import { setMastered } from "@/lib/wrongbook"; + +export async function toggleMastered(id: number, mastered: boolean): Promise { + const user = await getSessionUser(); + if (!user || user.role !== "child") return; + await setMastered(id, user.id, mastered); + revalidatePath("/wrongbook"); + revalidatePath("/parent"); +} diff --git a/src/app/wrongbook/page.tsx b/src/app/wrongbook/page.tsx new file mode 100644 index 0000000..353b2ad --- /dev/null +++ b/src/app/wrongbook/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { BackgroundBlobs } from "@/components/background-blobs"; +import { getSessionUser } from "@/lib/auth"; +import { listWrong } from "@/lib/wrongbook"; +import type { QuizQ } from "@/lib/quiz-parse"; +import { WrongRow } from "./wrong-row"; + +export default async function WrongBookPage() { + const user = await getSessionUser(); + if (!user) redirect("/join"); + if (user.role !== "child") redirect("/parent"); + + const rows = await listWrong(user.id); + const open = rows.filter((r) => !r.mastered).length; + + return ( +
+ +
+ + ← 返回 + +

我的错题本

+ 待掌握 {open} 题 +
+ + {rows.length === 0 ? ( +
+

🎉

+

还没有错题,继续保持!

+
+ ) : ( +
+ {rows.map((r) => { + let q: QuizQ; + try { + q = JSON.parse(r.question) as QuizQ; + } catch { + return null; + } + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/wrongbook/wrong-row.tsx b/src/app/wrongbook/wrong-row.tsx new file mode 100644 index 0000000..6acd3e4 --- /dev/null +++ b/src/app/wrongbook/wrong-row.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useTransition } from "react"; +import { CheckCircle2, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { QuizQ } from "@/lib/quiz-parse"; +import { toggleMastered } from "./actions"; + +export function WrongRow({ + id, + sourceTitle, + q, + userAnswer, + correctAnswer, + mastered, +}: { + id: number; + sourceTitle: string | null; + q: QuizQ; + userAnswer: string | null; + correctAnswer: string | null; + mastered: boolean; +}) { + const [pending, startTransition] = useTransition(); + return ( +
+
+

{q.stem}

+ + {sourceTitle ?? "检测"} + +
+
+

你的答案:{userAnswer ?? "未作答"}

+

正确答案:{correctAnswer ?? ""}

+
+

💡 {q.explain}

+
+ +
+
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 9bac235..cb91229 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -87,10 +87,14 @@ export const taskTemplates = mysqlTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), // 0=周日,1=周一……6=周六(与 JS Date.getDay 一致) - dow: int("dow").notNull(), + dow: int("dow").notNull().default(0), title: varchar("title", { length: 100 }).notNull(), subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"), requiresPhoto: boolean("requires_photo").notNull().default(false), + // 模板生效方式:weekly=每周某weekday / date=某一天 / range=日期区间 / week=某一周 + mode: varchar("mode", { length: 10 }).notNull().default("weekly"), + dateStart: date("date_start", { mode: "string" }), + dateEnd: date("date_end", { mode: "string" }), sortOrder: int("sort_order").notNull().default(0), createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), }, @@ -156,6 +160,46 @@ export const grammarPoints = mysqlTable( (t) => [uniqueIndex("uq_grammar_point_id").on(t.pointId)], ); +// ===== 错题本:所有检测的错题,带详细讲解,可标记掌握 ===== +export const wrongBook = mysqlTable( + "wrong_book", + { + id: bigint("id", { mode: "number" }).primaryKey().autoincrement(), + childUserId: bigint("child_user_id", { mode: "number" }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + sourceType: varchar("source_type", { length: 10 }).notNull(), // lesson / weekly + sourceId: varchar("source_id", { length: 60 }).notNull(), // pointId 或 weekKey + sourceTitle: varchar("source_title", { length: 200 }), + question: text("question").notNull(), // 题目 JSON(含讲解) + userAnswer: text("user_answer"), + correctAnswer: text("correct_answer"), + mastered: boolean("mastered").notNull().default(false), + createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, + (t) => [index("idx_wrong_child").on(t.childUserId, t.mastered)], +); + +// ===== 每周能力检测记录 ===== +export const weeklyTest = mysqlTable( + "weekly_test", + { + id: bigint("id", { mode: "number" }).primaryKey().autoincrement(), + childUserId: bigint("child_user_id", { mode: "number" }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + weekKey: varchar("week_key", { length: 12 }).notNull(), // 2026-W32 + score: int("score"), + correct: int("correct"), + total: int("total"), + passed: boolean("passed").notNull().default(false), + questions: text("questions"), // 组卷 JSON,供回看 + takenAt: datetime("taken_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, + (t) => [uniqueIndex("uq_weekly_child_week").on(t.childUserId, t.weekKey)], +); + // ===== 英语语法学习进度(按知识点记录,达标即通过,不锁顺序)===== export const grammarProgress = mysqlTable( "grammar_progress", diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 80cf63d..3c3f457 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -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); +} + diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index d20cefe..462839d 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -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( diff --git a/src/lib/weekly.ts b/src/lib/weekly.ts new file mode 100644 index 0000000..7293c39 --- /dev/null +++ b/src/lib/weekly.ts @@ -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(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 { + 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; +} diff --git a/src/lib/wrongbook.ts b/src/lib/wrongbook.ts new file mode 100644 index 0000000..6db7014 --- /dev/null +++ b/src/lib/wrongbook.ts @@ -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))); +}