新增积分系统:打卡/测试达标赚积分,纯累计可配置
- 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:
co-authored by
Claude Opus 4.8
parent
841f937b8e
commit
7a11a89031
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user