From 7a11a890317be0283b317f10a82bebdd470c147e Mon Sep 17 00:00:00 2001
From: jacobxu666
Date: Tue, 18 Aug 2026 17:58:53 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=A7=AF=E5=88=86=E7=B3=BB?=
=?UTF-8?q?=E7=BB=9F:=E6=89=93=E5=8D=A1/=E6=B5=8B=E8=AF=95=E8=BE=BE?=
=?UTF-8?q?=E6=A0=87=E8=B5=9A=E7=A7=AF=E5=88=86,=E7=BA=AF=E7=B4=AF?=
=?UTF-8?q?=E8=AE=A1=E5=8F=AF=E9=85=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
drizzle/0008_points.sql | 42 ++++++++++++
drizzle/meta/_journal.json | 7 ++
src/app/exam/actions.ts | 19 ++++--
src/app/kid/actions.ts | 12 +++-
src/app/kid/page.tsx | 22 ++++--
src/app/kid/task-card.tsx | 9 ++-
src/app/learn/actions.ts | 7 +-
src/app/learn/quiz-block.tsx | 3 +
src/app/math/actions.ts | 7 +-
src/app/math/weekly/actions.ts | 10 ++-
src/app/math/weekly/page.tsx | 4 ++
src/app/parent/actions.ts | 43 ++++++++++++
src/app/parent/dashboard.tsx | 18 +++++
src/app/parent/page.tsx | 29 ++++++++
src/app/parent/points-manage.tsx | 90 ++++++++++++++++++++++++
src/app/parent/templates-tab.tsx | 36 +++++++++-
src/app/points/page.tsx | 59 ++++++++++++++++
src/app/weekly/actions.ts | 10 ++-
src/app/weekly/page.tsx | 4 ++
src/app/words/actions.ts | 13 +++-
src/app/words/word-day.tsx | 7 +-
src/db/schema.ts | 39 +++++++++++
src/lib/exam.ts | 9 ++-
src/lib/grammar.ts | 9 ++-
src/lib/math.ts | 9 ++-
src/lib/points.ts | 114 +++++++++++++++++++++++++++++++
src/lib/tasks.ts | 2 +
27 files changed, 592 insertions(+), 41 deletions(-)
create mode 100644 drizzle/0008_points.sql
create mode 100644 src/app/parent/points-manage.tsx
create mode 100644 src/app/points/page.tsx
create mode 100644 src/lib/points.ts
diff --git a/drizzle/0008_points.sql b/drizzle/0008_points.sql
new file mode 100644
index 0000000..6f192dd
--- /dev/null
+++ b/drizzle/0008_points.sql
@@ -0,0 +1,42 @@
+-- xuchao: 积分系统——规则表 + 流水表(唯一键防刷) + 任务表加 points 列。全部幂等。
+CREATE TABLE IF NOT EXISTS `point_rule` (
+ `id` bigint AUTO_INCREMENT NOT NULL,
+ `event_type` varchar(30) NOT NULL,
+ `points` int NOT NULL DEFAULT 0,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT `point_rule_id` PRIMARY KEY(`id`),
+ UNIQUE KEY `uq_point_rule_event` (`event_type`)
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS `point_log` (
+ `id` bigint AUTO_INCREMENT NOT NULL,
+ `child_user_id` bigint NOT NULL,
+ `event_type` varchar(30) NOT NULL,
+ `ref_key` varchar(100) NOT NULL,
+ `points` int NOT NULL,
+ `remark` varchar(200) NOT NULL DEFAULT '',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT `point_log_id` PRIMARY KEY(`id`),
+ UNIQUE KEY `uq_point_log_dedup` (`child_user_id`, `event_type`, `ref_key`),
+ KEY `idx_point_log_child` (`child_user_id`)
+);
+--> statement-breakpoint
+SET @has_tpl_points = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'task_template' AND column_name = 'points');
+--> statement-breakpoint
+SET @ddl = IF(@has_tpl_points = 0, 'ALTER TABLE `task_template` ADD COLUMN `points` int NOT NULL DEFAULT 10', 'SELECT 1');
+--> statement-breakpoint
+PREPARE stmt FROM @ddl;
+--> statement-breakpoint
+EXECUTE stmt;
+--> statement-breakpoint
+DEALLOCATE PREPARE stmt;
+--> statement-breakpoint
+SET @has_inst_points = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'task_instance' AND column_name = 'points');
+--> statement-breakpoint
+SET @ddl2 = IF(@has_inst_points = 0, 'ALTER TABLE `task_instance` ADD COLUMN `points` int NOT NULL DEFAULT 10', 'SELECT 1');
+--> statement-breakpoint
+PREPARE stmt2 FROM @ddl2;
+--> statement-breakpoint
+EXECUTE stmt2;
+--> statement-breakpoint
+DEALLOCATE PREPARE stmt2;
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index f5d4a87..83dce88 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1786147200000,
"tag": "0007_word-entry",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "5",
+ "when": 1786233600000,
+ "tag": "0008_points",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/app/exam/actions.ts b/src/app/exam/actions.ts
index 6bebb7d..3f4d720 100644
--- a/src/app/exam/actions.ts
+++ b/src/app/exam/actions.ts
@@ -25,7 +25,15 @@ export type SolveDetail = {
};
export type ExamResult =
- | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; solves?: SolveDetail[] }
+ | {
+ score: number;
+ passed: boolean;
+ correct: number;
+ total: number;
+ detail: boolean[];
+ solves?: SolveDetail[];
+ pointsEarned: number;
+ }
| { error: string };
// xuchao: 签发解答题照片直传地址(或本地开发回退)
@@ -109,9 +117,9 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
});
- await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
+ const pointsEarned = await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
revalidateAll(paperId);
- return g;
+ return { ...g, pointsEarned };
}
// xuchao: 混合卷加权判分——解答题只信服务端最近一次判定,不信任客户端上报
@@ -167,9 +175,9 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
const score = pointSum ? Math.round((earnedSum / pointSum) * 100) : 0;
const passed = score >= 80;
- await recordExam(user.id, paperId, paper.title, score, correct, questions.length, passed, storedAnswers);
+ const pointsEarned = await recordExam(user.id, paperId, paper.title, score, correct, questions.length, passed, storedAnswers);
revalidateAll(paperId);
- return { score, passed, correct, total: questions.length, detail, solves };
+ return { score, passed, correct, total: questions.length, detail, solves, pointsEarned };
}
function revalidateAll(paperId: string) {
@@ -178,4 +186,5 @@ function revalidateAll(paperId: string) {
revalidatePath("/kid");
revalidatePath("/parent");
revalidatePath("/wrongbook");
+ revalidatePath("/points");
}
diff --git a/src/app/kid/actions.ts b/src/app/kid/actions.ts
index b539a46..cd4a6d6 100644
--- a/src/app/kid/actions.ts
+++ b/src/app/kid/actions.ts
@@ -7,6 +7,7 @@ import { photos, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import { photoKey, uploadUrl } from "@/lib/oss";
import { getSessionUser } from "@/lib/auth";
+import { awardPoints } from "@/lib/points";
// 取当前孩子会话,非法则抛错(客户端会显示)
async function requireChild() {
@@ -25,8 +26,8 @@ async function ownedTask(childId: number, instanceId: number) {
return task;
}
-// 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾)
-export async function toggleTask(instanceId: number, done: boolean) {
+// 勾选 / 取消勾选(仅限今天;需凭证的任务必须有照片才能勾);首次勾选发积分
+export async function toggleTask(instanceId: number, done: boolean): Promise<{ pointsEarned: number }> {
const child = await requireChild();
const task = await ownedTask(child.id, instanceId);
if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡");
@@ -45,7 +46,14 @@ export async function toggleTask(instanceId: number, done: boolean) {
.set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null })
.where(eq(taskInstances.id, instanceId));
+ // xuchao: 首次完成才发分(唯一键防刷),分值取任务快照上的设置
+ const pointsEarned = done
+ ? await awardPoints(child.id, "checkin", `inst-${instanceId}`, task.points, task.title)
+ : 0;
+
revalidatePath("/kid");
+ revalidatePath("/points");
+ return { pointsEarned };
}
// 签发照片直传地址(浏览器 PUT 到对象存储)
diff --git a/src/app/kid/page.tsx b/src/app/kid/page.tsx
index 3d43527..e177212 100644
--- a/src/app/kid/page.tsx
+++ b/src/app/kid/page.tsx
@@ -14,6 +14,7 @@ import { dayTasks, childCheckStats } from "@/lib/tasks";
import { listPoints, passedByPoint } from "@/lib/grammar";
import { listMathPoints, mathPassedByPoint } from "@/lib/math";
import { recordsByPaper } from "@/lib/exam";
+import { totalPoints } from "@/lib/points";
import { TaskCard } from "./task-card";
export default async function KidPage() {
@@ -22,10 +23,11 @@ export default async function KidPage() {
if (user.role !== "child") redirect("/parent");
const today = todayStr();
- const [tasks, streak, stats] = await Promise.all([
+ const [tasks, streak, stats, myPoints] = await Promise.all([
dayTasks(user.id, today),
streakOf(user.id),
childCheckStats(user.id, 7),
+ totalPoints(user.id),
]);
const doneCount = tasks.filter((t) => t.status === "done").length;
const allDone = tasks.length > 0 && doneCount === tasks.length;
@@ -63,10 +65,20 @@ export default async function KidPage() {
{today}
-
-
-
{streak}
-
天
+
+
+
🪙
+
{myPoints}
+
+
+
+ {streak}
+ 天
+
diff --git a/src/app/kid/task-card.tsx b/src/app/kid/task-card.tsx
index 9e9e610..c39849f 100644
--- a/src/app/kid/task-card.tsx
+++ b/src/app/kid/task-card.tsx
@@ -35,6 +35,7 @@ export function TaskCard({
const fileRef = useRef
(null);
const [busy, startTransition] = useTransition();
const [error, setError] = useState("");
+ const [earned, setEarned] = useState(null);
const done = task.status === "done";
const canCheck = !task.requiresPhoto || task.photoUrls.length > 0;
@@ -46,7 +47,8 @@ export function TaskCard({
}
startTransition(async () => {
try {
- await toggleTask(task.id, !done);
+ const r = await toggleTask(task.id, !done);
+ setEarned(!done && r.pointsEarned > 0 ? r.pointsEarned : null);
} catch (e) {
setError(e instanceof Error ? e.message : "操作失败");
}
@@ -111,6 +113,11 @@ export function TaskCard({
补卡
)}
+ {earned !== null && (
+
+ 🪙 +{earned} 积分
+
+ )}
{task.requiresPhoto && task.photoUrls.length < 3 && (
diff --git a/src/app/learn/actions.ts b/src/app/learn/actions.ts
index fb8edd3..6b29831 100644
--- a/src/app/learn/actions.ts
+++ b/src/app/learn/actions.ts
@@ -6,7 +6,7 @@ import { completePoint, extractQuiz, getPoint, gradeAnswers, type AnswerValue }
import { recordWrong } from "@/lib/wrongbook";
export type QuizResult =
- | { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
+ | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
| { error: string };
// 提交结课练习:服务端按课文 quiz 判分,达标记进度并联动英语打卡
@@ -25,12 +25,13 @@ export async function submitQuiz(pointId: string, answers: AnswerValue[]): Promi
questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "lesson", pointId, point.title, q, answers[i]);
});
- await completePoint(user.id, pointId, point.title, g.score, g.passed);
+ const pointsEarned = await completePoint(user.id, pointId, point.title, g.score, g.passed);
revalidatePath("/learn");
revalidatePath(`/learn/${pointId}`);
revalidatePath("/kid");
revalidatePath("/parent");
revalidatePath("/wrongbook");
- return g;
+ revalidatePath("/points");
+ return { ...g, pointsEarned };
}
diff --git a/src/app/learn/quiz-block.tsx b/src/app/learn/quiz-block.tsx
index 820020a..13de46a 100644
--- a/src/app/learn/quiz-block.tsx
+++ b/src/app/learn/quiz-block.tsx
@@ -198,6 +198,9 @@ export function QuizBlock({
答对 {result.correct} / {result.total}
{result.passed ? ` · ${passText}` : " · 看看解说,再试一次"}
+ {result.passed && result.pointsEarned > 0 && (
+ 🪙 +{result.pointsEarned} 积分
+ )}
{!result.passed && (
) : (
@@ -113,6 +127,10 @@ export function ParentDashboard({
+
+
diff --git a/src/app/parent/page.tsx b/src/app/parent/page.tsx
index 0359192..863e4d5 100644
--- a/src/app/parent/page.tsx
+++ b/src/app/parent/page.tsx
@@ -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 (
);
}
diff --git a/src/app/parent/points-manage.tsx b/src/app/parent/points-manage.tsx
new file mode 100644
index 0000000..2fa4a43
--- /dev/null
+++ b/src/app/parent/points-manage.tsx
@@ -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
>(
+ 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 (
+
+
+
测试类达标分值
+
+ {data.rules.map((r) => (
+
+ ))}
+
+
+
+ {saved && 已保存,下次达标生效}
+ {error && {error}}
+
+
+
+
+ {data.kids.length === 0 &&
还没有孩子,先去孩子管理添加。
}
+ {data.kids.map((k) => (
+
+
+ {k.name}
+ 🪙 {k.total} 积分
+
+ {k.recent.length === 0 ? (
+
暂无积分记录
+ ) : (
+
+ {k.recent.map((r) => (
+
+
+ {r.label} · {r.remark} · {r.date}
+
+ +{r.points}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/parent/templates-tab.tsx b/src/app/parent/templates-tab.tsx
index ce576de..2bd061b 100644
--- a/src/app/parent/templates-tab.tsx
+++ b/src/app/parent/templates-tab.tsx
@@ -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("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 }) {
{sm.label}
{t.title}
({modeLabel[t.mode] ?? t.mode})
+
+ 🪙
+ {
+ 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"
+ />
+ 分
+
+
+
+ setPoints(Number(e.target.value))}
+ className="h-9 w-20 text-right"
+ title="完成该任务可得的积分"
+ />
+
diff --git a/src/app/points/page.tsx b/src/app/points/page.tsx
new file mode 100644
index 0000000..1b7db25
--- /dev/null
+++ b/src/app/points/page.tsx
@@ -0,0 +1,59 @@
+// xuchao: 孩子积分页——总积分 + 流水明细(只增不减)
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { BackgroundBlobs } from "@/components/background-blobs";
+import { getSessionUser } from "@/lib/auth";
+import { pointHistory, totalPoints, POINT_EVENT_LABELS } from "@/lib/points";
+
+const SH = { timeZone: "Asia/Shanghai" } as const;
+
+// checkin 的分值在任务上,不属测试类规则,单独给个名字
+const eventLabel = (eventType: string) =>
+ eventType === "checkin" ? "打卡" : POINT_EVENT_LABELS[eventType] ?? eventType;
+
+export default async function PointsPage() {
+ const user = await getSessionUser();
+ if (!user) redirect("/join");
+ if (user.role !== "child") redirect("/parent");
+
+ const [total, rows] = await Promise.all([totalPoints(user.id), pointHistory(user.id, 50)]);
+
+ return (
+
+
+
+
+
+
🪙 {total}
+
完成任务和达标检测都会攒积分,继续加油!
+
+
+
+ 积分明细
+ {rows.length === 0 ? (
+ 还没有积分记录,去完成今天的任务吧!
+ ) : (
+
+ {rows.map((r) => (
+
+
+
{r.remark || eventLabel(r.eventType)}
+
+ {eventLabel(r.eventType)} · {r.createdAt.toLocaleDateString("zh-CN", SH)}
+
+
+
+{r.points}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/weekly/actions.ts b/src/app/weekly/actions.ts
index 2e9e73b..6f20a39 100644
--- a/src/app/weekly/actions.ts
+++ b/src/app/weekly/actions.ts
@@ -9,9 +9,10 @@ import { recordWrong } from "@/lib/wrongbook";
import { db } from "@/db";
import { taskInstances, weeklyTest } from "@/db/schema";
import { todayStr } from "@/lib/dates";
+import { awardRulePoints } from "@/lib/points";
export type WeeklyResult =
- | { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
+ | { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
| { error: string };
// 提交每周能力检测:判分、记录、错题入库、达标关联当日英语打卡
@@ -43,8 +44,10 @@ export async function takeWeekly(answers: AnswerValue[]): Promise
})
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
- // 达标关联当日英语打卡
+ // 达标关联当日英语打卡,并发积分(首次达标才发)
+ let pointsEarned = 0;
if (g.passed) {
+ pointsEarned = await awardRulePoints(user.id, "grammar_weekly", `week-${wk}`, `英语周检测 ${wk}`);
const today = todayStr();
const title = `英语周检测 · ${wk}`;
const [ex] = await db
@@ -72,5 +75,6 @@ export async function takeWeekly(answers: AnswerValue[]): Promise
revalidatePath("/kid");
revalidatePath("/parent");
revalidatePath("/wrongbook");
- return g;
+ revalidatePath("/points");
+ return { ...g, pointsEarned };
}
diff --git a/src/app/weekly/page.tsx b/src/app/weekly/page.tsx
index 63aa4e9..6186723 100644
--- a/src/app/weekly/page.tsx
+++ b/src/app/weekly/page.tsx
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { BackgroundBlobs } from "@/components/background-blobs";
import { getSessionUser } from "@/lib/auth";
import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly";
+import { getAwardedPoints } from "@/lib/points";
import { QuizBlock } from "@/app/learn/quiz-block";
import { takeWeekly } from "./actions";
@@ -14,6 +15,8 @@ export default async function WeeklyPage() {
const wk = currentWeekKey();
const record = await getWeeklyRecord(user.id, wk);
const questions = record ? [] : await buildWeeklyQuestions(user.id, wk);
+ // xuchao: 已有记录时从流水取回本周一达标的积分,结果页刷新后仍显示
+ const awarded = record?.passed ? await getAwardedPoints(user.id, "grammar_weekly", `week-${wk}`) : 0;
return (
@@ -41,6 +44,7 @@ export default async function WeeklyPage() {
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
+ {record.passed && awarded > 0 && 🪙 +{awarded} 积分
}
) : (
diff --git a/src/app/words/actions.ts b/src/app/words/actions.ts
index 33ad0d8..7c56308 100644
--- a/src/app/words/actions.ts
+++ b/src/app/words/actions.ts
@@ -8,6 +8,7 @@ import { getSessionUser } from "@/lib/auth";
import { buildWordTest } from "@/lib/words";
import { uploadUrl } from "@/lib/oss";
import { todayStr } from "@/lib/dates";
+import { awardRulePoints } from "@/lib/points";
// 跟读录音直传签名(按实际录音类型签名,避免签名不匹配)
export async function getRecUploadUrl(word: string, contentType: string): Promise<{ url: string; key: string }> {
@@ -39,11 +40,13 @@ export async function markLearned(day: number, word: string): Promise {
revalidatePath("/words/[day]", "layout");
}
-// 提交每日单词检测:服务端组卷判分,达标关联当日英语打卡
+// 提交每日单词检测:服务端组卷判分,达标关联当日英语打卡并发积分
export async function submitWordTest(
day: number,
answers: number[],
-): Promise<{ score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { error: string }> {
+): Promise<
+ { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number } | { error: string }
+> {
const u = await child();
if (!u) return { error: "会话无效" };
const qs = await buildWordTest(day);
@@ -61,7 +64,10 @@ export async function submitWordTest(
.values({ childUserId: u.id, day, score, passed, completedAt: new Date() })
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
+ // xuchao: 首次达标发积分(唯一键防刷,重考不再发)
+ let pointsEarned = 0;
if (passed) {
+ pointsEarned = await awardRulePoints(u.id, "word_test", `day-${day}`, `英语单词 · 第${day}天`);
const today = todayStr();
const title = `英语单词 · 第${day}天`;
const [ex] = await db
@@ -88,7 +94,8 @@ export async function submitWordTest(
revalidatePath("/words/[day]", "layout");
revalidatePath("/kid");
revalidatePath("/parent");
- return { score, passed, correct, total: qs.length, detail };
+ revalidatePath("/points");
+ return { score, passed, correct, total: qs.length, detail, pointsEarned };
}
// 保存跟读录音(已直传 OSS,这里登记)
diff --git a/src/app/words/word-day.tsx b/src/app/words/word-day.tsx
index fa1633d..ea46416 100644
--- a/src/app/words/word-day.tsx
+++ b/src/app/words/word-day.tsx
@@ -94,7 +94,9 @@ export function WordDayClient({
const [example, setExample] = useState>({});
const [testOpen, setTestOpen] = useState(false);
const [answers, setAnswers] = useState<(number | null)[]>(test.map(() => null));
- const [result, setResult] = useState<{ score: number; passed: boolean } | null>(passed ? { score: score ?? 0, passed } : null);
+ const [result, setResult] = useState<{ score: number; passed: boolean; pointsEarned: number } | null>(
+ passed ? { score: score ?? 0, passed, pointsEarned: 0 } : null,
+ );
const [pending, startTransition] = useTransition();
const toggleLearn = (word: string) => {
@@ -126,7 +128,7 @@ export function WordDayClient({
const submit = () => {
startTransition(async () => {
const r = await submitWordTest(day, answers.map((a) => a as number));
- if (!("error" in r)) setResult({ score: r.score, passed: r.passed });
+ if (!("error" in r)) setResult({ score: r.score, passed: r.passed, pointsEarned: r.pointsEarned });
});
};
@@ -196,6 +198,7 @@ export function WordDayClient({
{result && (
{result.score} 分 {result.passed ? "· 已点亮" : ""}
+ {result.passed && result.pointsEarned > 0 ? ` · 🪙 +${result.pointsEarned} 积分` : ""}
)}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 82f847f..29e623a 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -91,6 +91,8 @@ export const taskTemplates = mysqlTable(
title: varchar("title", { length: 100 }).notNull(),
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
requiresPhoto: boolean("requires_photo").notNull().default(false),
+ // xuchao: 该任务完成可得积分(家长按任务难度单独设置)
+ points: int("points").notNull().default(10),
// 模板生效方式:weekly=每周某weekday / date=某一天 / range=日期区间 / week=某一周
mode: varchar("mode", { length: 10 }).notNull().default("weekly"),
dateStart: date("date_start", { mode: "string" }),
@@ -114,6 +116,8 @@ export const taskInstances = mysqlTable(
title: varchar("title", { length: 100 }).notNull(),
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
requiresPhoto: boolean("requires_photo").notNull().default(false),
+ // xuchao: 该任务完成可得积分(生成快照时从模板复制)
+ points: int("points").notNull().default(10),
status: mysqlEnum("status", ["pending", "done"]).notNull().default("pending"),
// 家长补卡标记,永久保留
isMakeup: boolean("is_makeup").notNull().default(false),
@@ -386,3 +390,38 @@ export const aiGradeLog = mysqlTable(
index("idx_ai_grade_paper_q").on(t.childUserId, t.paperId, t.questionIndex),
],
);
+
+// ===== 积分规则(测试类事件分值,家长可调;打卡分值随任务本身设置)=====
+export const pointRule = mysqlTable(
+ "point_rule",
+ {
+ id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
+ // word_test/math_lesson/grammar_point/grammar_weekly/math_weekly/exam_pass
+ eventType: varchar("event_type", { length: 30 }).notNull(),
+ points: int("points").notNull().default(0),
+ updatedAt: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
+ },
+ (t) => [uniqueIndex("uq_point_rule_event").on(t.eventType)],
+);
+
+// ===== 积分流水(只增不减;唯一键即防刷:同一事件实例只发一次)=====
+export const pointLog = mysqlTable(
+ "point_log",
+ {
+ id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
+ childUserId: bigint("child_user_id", { mode: "number" })
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ // checkin/word_test/math_lesson/grammar_point/grammar_weekly/math_weekly/exam_pass
+ eventType: varchar("event_type", { length: 30 }).notNull(),
+ // 事件实例标识,如 inst-123 / day-5 / point-calc-01 / week-2026-W33 / paper-xxx
+ refKey: varchar("ref_key", { length: 100 }).notNull(),
+ points: int("points").notNull(),
+ remark: varchar("remark", { length: 200 }).notNull().default(""),
+ createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
+ },
+ (t) => [
+ uniqueIndex("uq_point_log_dedup").on(t.childUserId, t.eventType, t.refKey),
+ index("idx_point_log_child").on(t.childUserId),
+ ],
+);
diff --git a/src/lib/exam.ts b/src/lib/exam.ts
index 7ceeae3..eff8a04 100644
--- a/src/lib/exam.ts
+++ b/src/lib/exam.ts
@@ -4,6 +4,7 @@ import { db } from "@/db";
import { aiGradeLog, examPapers, examRecords, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import type { QuizQ } from "@/lib/quiz-parse";
+import { awardRulePoints } from "@/lib/points";
// xuchao: 试卷题型标签(展示用)
export const EXAM_TYPE_LABEL: Record = {
@@ -86,7 +87,7 @@ export async function recordsByPaper(childId: number): Promise