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);