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

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