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() {
+
🎉
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]}
+
+ );
+ })}
+
+
+
+
+
{selected} 的任务
+
+ {dayTasks(selected).length === 0 &&
这一天没有任务(休息日)
}
+ {dayTasks(selected).map((t) => {
+ const sm = subjectMeta(t.subject);
+ return (
+
+
+ {sm.label}
+ {t.title}
+ ({modeLabel[t.mode] ?? t.mode})
+ startTransition(() => deleteTemplate(t.id))}
+ >
+
+
);
})}
-