新增积分系统:打卡/测试达标赚积分,纯累计可配置

- point_rule 规则表 + point_log 流水表(0008 迁移),唯一键防刷:同一事件首次达标才发放,重复静默返回 0
- 打卡分值随任务设置(模板/实例 points 字段,默认 10);测试类 6 种事件按规则表取值,各有默认分(单词 20/数学课 15/语法点 15/周测 50×2/考试通过 100)
- 发放埋点 6 处:打卡、单词检测、数学课、语法知识点、英语/数学周测、考试通过;周测达标联动打卡实例不重复计分
- 孩子端:/kid 总积分徽章,新增 /points 积分页(总积分+流水),测验结果页即时显示「+N 积分」,周测结果页刷新后从流水取回已发积分
- 家长端:功能管理新增「积分管理」Section(规则分值配置+每孩子总积分/最近流水);任务管理内联编辑任务分值
- 修复:drizzle 将 mysql2 重复键错误包进 cause,沿错误链识别 ER_DUP_ENTRY

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-18 17:58:53 +08:00
co-authored by Claude Opus 4.8
parent 841f937b8e
commit 7a11a89031
27 changed files with 592 additions and 41 deletions
+43
View File
@@ -10,6 +10,7 @@ import { SUBJECT_VALUES } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { ensureDay } from "@/lib/tasks";
import { getSessionUser } from "@/lib/auth";
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("hex");
@@ -84,6 +85,8 @@ const templateSchema = z.object({
dateEnd: z.string().nullish(),
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
requiresPhoto: z.boolean(),
// xuchao: 该任务完成可得积分,0~9999
points: z.coerce.number().int().min(0).max(9999).catch(10),
});
async function requireOwnerOfChild(childId: number) {
@@ -109,6 +112,7 @@ export async function addTemplate(formData: FormData) {
dateEnd: formData.get("dateEnd") || null,
title: formData.get("title"),
requiresPhoto: formData.get("requiresPhoto") === "on",
points: formData.get("points") || 10,
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await requireOwnerOfChild(parsed.data.childId);
@@ -127,12 +131,51 @@ export async function addTemplate(formData: FormData) {
dateEnd: parsed.data.dateEnd || null,
title: parsed.data.title,
requiresPhoto: parsed.data.requiresPhoto,
points: parsed.data.points,
sortOrder: maxRow.maxSort + 1,
});
await ensureDay(parsed.data.childId, todayStr());
revalidatePath("/parent");
}
// xuchao: 修改模板任务分值;当天未完成的快照同步更新,设置立即生效
export async function updateTemplatePoints(templateId: number, points: number) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
const p = Math.max(0, Math.min(9999, Math.round(points)));
const [row] = await db
.select({ id: taskTemplates.id, childUserId: taskTemplates.childUserId })
.from(taskTemplates)
.innerJoin(users, eq(users.id, taskTemplates.childUserId))
.where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId)))
.limit(1);
if (!row) throw new Error("任务不存在");
await db.update(taskTemplates).set({ points: p }).where(eq(taskTemplates.id, templateId));
await db
.update(taskInstances)
.set({ points: p })
.where(
and(
eq(taskInstances.sourceTemplateId, templateId),
eq(taskInstances.date, todayStr()),
eq(taskInstances.status, "pending"),
),
);
revalidatePath("/parent");
revalidatePath("/kid");
}
// xuchao: 保存测试类积分规则(只接受已知事件,分值 0~9999)
export async function savePointRules(values: { eventType: string; points: number }[]) {
const parent = await getSessionUser();
if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录");
for (const v of values) {
if (!(v.eventType in DEFAULT_RULE_POINTS)) continue;
await upsertPointRule(v.eventType, Math.max(0, Math.min(9999, Math.round(v.points))));
}
revalidatePath("/parent");
}
// 删除模板任务:删除后立即对账当天,孩子端未完成的对应任务会同步移除
export async function deleteTemplate(templateId: number) {
const parent = await getSessionUser();
+18
View File
@@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChildManage } from "./child-manage";
import { DevicesTab } from "./devices-tab";
import { OverviewTab } from "./overview-tab";
import { PointsManage } from "./points-manage";
import { TemplatesTab } from "./templates-tab";
export type DayCell = {
@@ -39,12 +40,23 @@ export type TemplatesData = {
title: string;
subject: string;
requiresPhoto: boolean;
points: number;
mode: string;
dateStart: string | null;
dateEnd: string | null;
}[];
}[];
};
// xuchao: 积分管理数据——测试类规则 + 每个孩子总积分/最近流水
export type PointsData = {
rules: { eventType: string; label: string; points: number }[];
kids: {
id: number;
name: string;
total: number;
recent: { id: number; label: string; remark: string; points: number; date: string }[];
}[];
};
export type DevicesData = {
rows: {
id: number;
@@ -74,11 +86,13 @@ export function ParentDashboard({
templates,
devices,
weekly,
points,
}: {
overview: OverviewData;
templates: TemplatesData;
devices: DevicesData;
weekly: WeeklyRow[];
points: PointsData;
}) {
return (
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
@@ -113,6 +127,10 @@ export function ParentDashboard({
<TemplatesTab data={templates} />
</Section>
<Section title="积分管理" desc="打卡分值在任务管理里按任务设置;测试类达标分值在下面调整,孩子首次达标才计分">
<PointsManage data={points} />
</Section>
<Section title="课程管理" desc="英语语法/单词、数学思维课程编辑与每周检测成绩">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Link href="/parent/grammar" className="block">
+29
View File
@@ -17,11 +17,18 @@ import {
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
import { addDays, dowOf, todayStr } from "@/lib/dates";
import { passedCount, totalPoints } from "@/lib/grammar";
import {
POINT_EVENT_LABELS,
listPointRules,
pointHistory,
totalPoints as childPointsTotal,
} from "@/lib/points";
import { viewUrl } from "@/lib/oss";
import {
ParentDashboard,
type DevicesData,
type OverviewData,
type PointsData,
type TemplatesData,
type WeeklyRow,
} from "./dashboard";
@@ -136,6 +143,7 @@ export default async function ParentPage() {
title: t.title,
subject: t.subject,
requiresPhoto: t.requiresPhoto,
points: t.points,
mode: t.mode,
dateStart: t.dateStart,
dateEnd: t.dateEnd,
@@ -191,12 +199,33 @@ export default async function ParentPage() {
passed: w.passed,
}));
// ===== 积分管理(规则 + 每孩子总积分/最近流水) =====
const rules = await listPointRules();
const pointsData: PointsData = { rules, kids: [] };
const eventLabel = (t: string) => (t === "checkin" ? "打卡" : POINT_EVENT_LABELS[t] ?? t);
for (const kid of kids) {
const [total, recent] = await Promise.all([childPointsTotal(kid.id), pointHistory(kid.id, 5)]);
pointsData.kids.push({
id: kid.id,
name: kid.name,
total,
recent: recent.map((r) => ({
id: r.id,
label: eventLabel(r.eventType),
remark: r.remark,
points: r.points,
date: r.createdAt.toLocaleDateString("zh-CN", SH),
})),
});
}
return (
<ParentDashboard
overview={overview}
templates={templates}
devices={devices}
weekly={weekly}
points={pointsData}
/>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
// xuchao: 积分管理——测试类规则分值配置 + 每个孩子总积分/最近流水
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { savePointRules } from "./actions";
import type { PointsData } from "./dashboard";
export function PointsManage({ data }: { data: PointsData }) {
const [values, setValues] = useState<Record<string, number>>(
Object.fromEntries(data.rules.map((r) => [r.eventType, r.points])),
);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
const save = () => {
setError("");
setSaved(false);
startTransition(async () => {
try {
await savePointRules(Object.entries(values).map(([eventType, points]) => ({ eventType, points })));
setSaved(true);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
}
});
};
return (
<div className="space-y-4">
<div className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
<h4 className="text-sm font-bold text-slate-900">测试类达标分值</h4>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
{data.rules.map((r) => (
<label key={r.eventType} className="flex items-center gap-2 text-sm text-slate-700">
<span className="flex-1">{r.label}</span>
<Input
type="number"
min={0}
max={9999}
value={values[r.eventType]}
onChange={(e) => {
setValues((v) => ({ ...v, [r.eventType]: Number(e.target.value) }));
setSaved(false);
}}
className="h-8 w-20 text-right"
/>
<span className="text-xs text-muted-foreground">分</span>
</label>
))}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" disabled={pending} onClick={save}>
保存分值
</Button>
{saved && <span className="text-xs font-medium text-emerald-600">已保存,下次达标生效</span>}
{error && <span className="text-xs font-medium text-destructive">{error}</span>}
</div>
</div>
<div className="space-y-3">
{data.kids.length === 0 && <p className="text-sm text-muted-foreground">还没有孩子,先去孩子管理添加。</p>}
{data.kids.map((k) => (
<div key={k.id} className="rounded-xl border border-slate-100 bg-white p-3">
<div className="flex items-center justify-between">
<span className="font-bold text-slate-900">{k.name}</span>
<span className="text-sm font-extrabold text-amber-600">🪙 {k.total} 积分</span>
</div>
{k.recent.length === 0 ? (
<p className="mt-1.5 text-xs text-muted-foreground">暂无积分记录</p>
) : (
<div className="mt-1.5 space-y-1">
{k.recent.map((r) => (
<p key={r.id} className="flex items-center gap-2 text-xs text-slate-600">
<span className="flex-1 truncate">
{r.label} · {r.remark} · {r.date}
</span>
<span className="shrink-0 font-bold text-amber-600">+{r.points}</span>
</p>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
+34 -2
View File
@@ -9,7 +9,7 @@ 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 { addTemplate, deleteTemplate, updateTemplatePoints } from "./actions";
import type { TemplatesData } from "./dashboard";
type Item = TemplatesData["kids"][number]["items"][number];
@@ -31,6 +31,7 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
const [subject, setSubject] = useState<Subject>("other");
const [title, setTitle] = useState("");
const [photo, setPhoto] = useState(false);
const [points, setPoints] = useState(10);
const [rangeStart, setRangeStart] = useState(todayStr());
const [rangeEnd, setRangeEnd] = useState(addDays(todayStr(), 6));
const [error, setError] = useState("");
@@ -59,11 +60,13 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
fd.set("dateEnd", rangeEnd);
}
if (photo) fd.set("requiresPhoto", "on");
fd.set("points", String(points));
startTransition(async () => {
try {
await addTemplate(fd);
setTitle("");
setPhoto(false);
setPoints(10);
} catch (err) {
setError(err instanceof Error ? err.message : "添加失败");
}
@@ -136,10 +139,27 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
<span className="font-medium">{sm.label}</span>
<span className="text-slate-600">{t.title}</span>
<span className="text-xs text-muted-foreground">({modeLabel[t.mode] ?? t.mode})</span>
<span className="ml-auto flex items-center gap-1 text-xs text-amber-600" title="完成该任务可得的积分,改后当天未完成任务立即生效">
🪙
<input
type="number"
min={0}
max={9999}
defaultValue={t.points}
aria-label={`${t.title} 积分`}
onBlur={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v) && Math.round(v) !== t.points)
startTransition(() => updateTemplatePoints(t.id, Math.round(v)));
}}
className="h-6 w-12 rounded-md border border-amber-200 bg-white px-1 text-right"
/>
分
</span>
<button
type="button"
aria-label={`删除 ${t.title}`}
className="ml-auto text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-destructive"
onClick={() => startTransition(() => deleteTemplate(t.id))}
>
<Trash2 className="size-3.5" />
@@ -192,6 +212,18 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
<Checkbox id="tpl-photo" checked={photo} onCheckedChange={(v) => setPhoto(v === true)} />
<Label htmlFor="tpl-photo">需拍照</Label>
</div>
<div className="space-y-1">
<Label>积分</Label>
<Input
type="number"
min={0}
max={9999}
value={points}
onChange={(e) => setPoints(Number(e.target.value))}
className="h-9 w-20 text-right"
title="完成该任务可得的积分"
/>
</div>
<Button type="submit" disabled={pending || title.trim() === ""}>
添加
</Button>