diff --git a/src/app/kid/page.tsx b/src/app/kid/page.tsx index 76a0260..eb75c37 100644 --- a/src/app/kid/page.tsx +++ b/src/app/kid/page.tsx @@ -7,7 +7,7 @@ import { SUBJECT_ORDER, subjectMeta } from "@/lib/subjects"; import { todayStr } from "@/lib/dates"; import { getSessionUser } from "@/lib/auth"; import { streakOf } from "@/lib/streak"; -import { dayTasks } from "@/lib/tasks"; +import { dayTasks, childCheckStats } from "@/lib/tasks"; import { listPoints, passedByPoint } from "@/lib/grammar"; import { TaskCard } from "./task-card"; @@ -17,9 +17,10 @@ export default async function KidPage() { if (user.role !== "child") redirect("/parent"); const today = todayStr(); - const [tasks, streak] = await Promise.all([ + const [tasks, streak, stats] = await Promise.all([ dayTasks(user.id, today), streakOf(user.id), + childCheckStats(user.id, 7), ]); const doneCount = tasks.filter((t) => t.status === "done").length; const allDone = tasks.length > 0 && doneCount === tasks.length; @@ -128,6 +129,27 @@ export default async function KidPage() { )} )} + +
+
+

我的记录

+ 累计打卡 {stats.totalDays} 天 +
+ {stats.recent.length === 0 ? ( +

还没有完成记录,从今天开始吧!

+ ) : ( +
+ {stats.recent.map((d) => ( +
+

+ {d.date === today ? "今天" : d.date} · 完成 {d.tasks.length} 项 +

+

{d.tasks.join("、")}

+
+ ))} +
+ )} +
); } diff --git a/src/app/kid/task-card.tsx b/src/app/kid/task-card.tsx index 84e5b2f..9e9e610 100644 --- a/src/app/kid/task-card.tsx +++ b/src/app/kid/task-card.tsx @@ -87,13 +87,13 @@ export function TaskCard({ onClick={onToggle} disabled={busy} aria-label={done ? "取消完成" : "标记完成"} - className={`flex size-12 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${ + className={`flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${ done - ? "border-transparent bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-[0_0_16px_rgba(16,185,129,0.4)]" + ? "border-transparent bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-[0_0_12px_rgba(16,185,129,0.35)]" : "border-slate-300 hover:border-indigo-400" }`} > - {done && } + {done && }

k.id === kidId) ?? data.kids[0]; if (!kid) return null; @@ -70,10 +72,11 @@ export function OverviewTab({ data }: { data: OverviewData }) { return (

setSelected(d)} + title={`${d} 打卡${c?.done ?? 0}/${c?.total ?? 0}`} className={cn( - "flex aspect-square flex-col items-center justify-center rounded-lg border text-sm", - isToday ? "border-indigo-400 ring-1 ring-indigo-300" : "border-slate-100", + "flex aspect-square cursor-pointer flex-col items-center justify-center rounded-lg border text-sm transition-colors", + selected === d ? "border-indigo-400 ring-1 ring-indigo-300" : "border-slate-100 hover:border-indigo-300", "bg-white", )} > @@ -96,6 +99,38 @@ export function OverviewTab({ data }: { data: OverviewData }) { 语法点亮 周检测

+ +
+

+ {selected} {selected === today ? "(今天)" : ""} 的具体任务 +

+ {(() => { + const cell = kid.days[selected]; + if (!cell || cell.tasks.length === 0) + return

这一天没有打卡任务。

; + return ( +
+ {cell.tasks.map((t, i) => { + const sm = subjectMeta(t.subject); + return ( +
+ + {t.title} + + {t.status === "done" ? "已完成" : "未完成"} + +
+ ); + })} +
+ ); + })()} +
); diff --git a/src/app/parent/page.tsx b/src/app/parent/page.tsx index 4735fa8..87d8516 100644 --- a/src/app/parent/page.tsx +++ b/src/app/parent/page.tsx @@ -49,7 +49,12 @@ export default async function ParentPage() { for (const kid of kids) { const [instRows, gRows, wRows, wrongRow, gDone] = await Promise.all([ db - .select({ date: taskInstances.date, status: taskInstances.status }) + .select({ + date: taskInstances.date, + status: taskInstances.status, + title: taskInstances.title, + subject: taskInstances.subject, + }) .from(taskInstances) .where(and(eq(taskInstances.childUserId, kid.id), gte(taskInstances.date, mStart), lte(taskInstances.date, mEnd))), db @@ -75,6 +80,7 @@ export default async function ParentPage() { total: inst.length, grammar, weekly: w ? w.score : null, + tasks: inst.map((r) => ({ title: r.title, status: r.status, subject: r.subject })), }; } overview.kids.push({ diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index 462839d..03ce709 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -2,7 +2,7 @@ import { and, asc, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { photos, taskInstances, taskTemplates } from "@/db/schema"; -import { dowOf, mondayOf } from "@/lib/dates"; +import { dowOf, mondayOf, todayStr, daysAgo } from "@/lib/dates"; import { viewUrl } from "@/lib/oss"; // 模板是否覆盖某一天(weekly/date/range/week 四种生效方式) @@ -18,6 +18,83 @@ function coversDate( return false; } +// 生成/对账某天任务快照。历史日:只生成一次(保历史);今天:实时对账, +// 模板删了→移除未完成快照,模板加了→补上,保证孩子端与家长模板一致。 +export async function ensureDay(childId: number, date: string) { + const isToday = date === todayStr(); + await db.transaction(async (tx) => { + const all = await tx + .select() + .from(taskTemplates) + .where(eq(taskTemplates.childUserId, childId)) + .orderBy(asc(taskTemplates.sortOrder)); + const templates = all.filter((t) => coversDate(t, date)); + const instances = await tx + .select() + .from(taskInstances) + .where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date))); + + if (!isToday) { + if (instances.length > 0) return; + if (templates.length > 0) + await tx.insert(taskInstances).values( + templates.map((t) => ({ + childUserId: childId, + date, + title: t.title, + subject: t.subject, + requiresPhoto: t.requiresPhoto, + sortOrder: t.sortOrder, + sourceTemplateId: t.id, + })), + ); + return; + } + + // 今天:对账 + const tIds = new Set(templates.map((t) => t.id)); + for (const inst of instances) { + if ( + inst.status === "pending" && + inst.sourceTemplateId != null && + !tIds.has(inst.sourceTemplateId) + ) { + await tx.delete(taskInstances).where(eq(taskInstances.id, inst.id)); + } + } + const haveIds = new Set(instances.map((i) => i.sourceTemplateId)); + for (const t of templates) { + if (!haveIds.has(t.id)) { + await tx.insert(taskInstances).values({ + childUserId: childId, + date, + title: t.title, + subject: t.subject, + requiresPhoto: t.requiresPhoto, + sortOrder: t.sortOrder, + sourceTemplateId: t.id, + }); + } + } + }); +} + +// 孩子打卡统计:累计打卡天数 + 最近 n 天已完成任务 +export async function childCheckStats(childId: number, n = 7) { + const rows = await db + .select({ date: taskInstances.date, status: taskInstances.status, title: taskInstances.title }) + .from(taskInstances) + .where(eq(taskInstances.childUserId, childId)); + const doneDates = new Set(rows.filter((r) => r.status === "done").map((r) => r.date)); + const recent: { date: string; tasks: string[] }[] = []; + for (let i = 0; i < n; i++) { + const d = daysAgo(i); + const done = rows.filter((r) => r.date === d && r.status === "done").map((r) => r.title); + if (done.length > 0) recent.push({ date: d, tasks: done }); + } + return { totalDays: doneDates.size, recent }; +} + export type TaskView = { id: number; title: string; @@ -28,39 +105,6 @@ export type TaskView = { photoUrls: string[]; }; -// 确保某天的任务快照存在:第一次访问当天时由模板生成,之后读快照 -export async function ensureDay(childId: number, date: string) { - await db.transaction(async (tx) => { - const existing = await tx - .select({ id: taskInstances.id }) - .from(taskInstances) - .where(and(eq(taskInstances.childUserId, childId), eq(taskInstances.date, date))) - .limit(1); - if (existing.length > 0) return; - - const all = await tx - .select() - .from(taskTemplates) - .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( - templates.map((t) => ({ - childUserId: childId, - date, - title: t.title, - subject: t.subject, - requiresPhoto: t.requiresPhoto, - sortOrder: t.sortOrder, - sourceTemplateId: t.id, - })), - ); - } - }); -} - // 孩子端当日任务视图(含照片签名地址) export async function dayTasks(childId: number, date: string): Promise { await ensureDay(childId, date);