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 [r.paperId, { score: r.score ?? 0, passed: r.passed }])); } -// xuchao: 记录作答;达标联动当天数学打卡(允许重考覆盖成绩) +// xuchao: 记录作答;达标联动当天数学打卡并发积分(首次达标才发);返回实发积分 export async function recordExam( childId: number, paperId: string, @@ -96,7 +97,7 @@ export async function recordExam( total: number, passed: boolean, answers: unknown[], -) { +): Promise { await db .insert(examRecords) .values({ @@ -112,7 +113,8 @@ export async function recordExam( set: { score, correct, total, passed, answers: JSON.stringify(answers), takenAt: sql`CURRENT_TIMESTAMP` }, }); - if (!passed) return; + if (!passed) return 0; + const pointsEarned = await awardRulePoints(childId, "exam_pass", `paper-${paperId}`, paperTitle); const today = todayStr(); const ckTitle = `考试 · ${paperTitle.length > 24 ? paperTitle.slice(0, 24) + "…" : paperTitle}`; const [existing] = await db @@ -139,6 +141,7 @@ export async function recordExam( sortOrder: 96, }); } + return pointsEarned; } // xuchao: 当日 AI 判分次数(限频用,含全部状态) diff --git a/src/lib/grammar.ts b/src/lib/grammar.ts index a456d39..7efd53d 100644 --- a/src/lib/grammar.ts +++ b/src/lib/grammar.ts @@ -3,6 +3,7 @@ import { db } from "@/db"; import { grammarPoints, grammarProgress, taskInstances } from "@/db/schema"; import { todayStr } from "@/lib/dates"; import { extractQuiz, type QuizQ } from "@/lib/quiz-parse"; +import { awardRulePoints } from "@/lib/points"; export { extractQuiz, type QuizQ }; @@ -86,20 +87,21 @@ export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) { return { score, passed: score >= 80, correct, total: questions.length, detail }; } -// 记录结果;通过则同步完成当天英语打卡 +// 记录结果;通过则同步完成当天英语打卡;返回首次达标所发积分 export async function completePoint( childId: number, pointId: string, title: string, score: number, passed: boolean, -) { +): Promise { await db .insert(grammarProgress) .values({ childUserId: childId, pointId, score, passed }) .onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } }); - if (!passed) return; + if (!passed) return 0; + const pointsEarned = await awardRulePoints(childId, "grammar_point", `point-${pointId}`, title); const today = todayStr(); const ckTitle = `英语语法 · ${title}`; const [existing] = await db @@ -126,4 +128,5 @@ export async function completePoint( sortOrder: 99, }); } + return pointsEarned; } diff --git a/src/lib/math.ts b/src/lib/math.ts index c85c8bc..cdb7cfd 100644 --- a/src/lib/math.ts +++ b/src/lib/math.ts @@ -5,6 +5,7 @@ import { mathPoints, mathProgress, taskInstances } from "@/db/schema"; import { todayStr } from "@/lib/dates"; import { extractQuiz, type QuizQ } from "@/lib/quiz-parse"; import { gradeAnswers, type AnswerValue } from "@/lib/grammar"; +import { awardRulePoints } from "@/lib/points"; export { extractQuiz, type QuizQ }; export { gradeAnswers, type AnswerValue }; @@ -64,20 +65,21 @@ export async function mathPassedByPoint(childId: number): Promise> { return new Set(rows.map((r) => r.pointId)); } -// xuchao: 记录结果;通过则同步完成当天数学打卡 +// xuchao: 记录结果;通过则同步完成当天数学打卡;返回首次达标所发积分 export async function completeMathPoint( childId: number, pointId: string, title: string, score: number, passed: boolean, -) { +): Promise { await db .insert(mathProgress) .values({ childUserId: childId, pointId, score, passed }) .onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } }); - if (!passed) return; + if (!passed) return 0; + const pointsEarned = await awardRulePoints(childId, "math_lesson", `point-${pointId}`, title); const today = todayStr(); const ckTitle = `数学思维 · ${title}`; const [existing] = await db @@ -104,4 +106,5 @@ export async function completeMathPoint( sortOrder: 97, }); } + return pointsEarned; } diff --git a/src/lib/points.ts b/src/lib/points.ts new file mode 100644 index 0000000..14d78a2 --- /dev/null +++ b/src/lib/points.ts @@ -0,0 +1,114 @@ +// xuchao: 积分系统——纯累计,首次达标才发(唯一键防刷);打卡分值随任务,测试类分值在 point_rule 可调 +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { pointLog, pointRule } from "@/db/schema"; + +// 测试类事件中文名(家长规则页展示用);checkin 分值在任务上,不进规则表 +export const POINT_EVENT_LABELS: Record = { + word_test: "单词检测达标", + math_lesson: "数学课达标", + grammar_point: "语法知识点达标", + grammar_weekly: "英语周检测达标", + math_weekly: "数学周检测达标", + exam_pass: "考试通过", +}; + +// 规则表无记录时的默认分值(家长改过即落库覆盖) +export const DEFAULT_RULE_POINTS: Record = { + word_test: 20, + math_lesson: 15, + grammar_point: 15, + grammar_weekly: 50, + math_weekly: 50, + exam_pass: 100, +}; + +// xuchao: drizzle 会把 mysql2 错误包进 cause,沿错误链识别重复键 +function isDupEntry(e: unknown): boolean { + let cur: unknown = e; + for (let i = 0; cur && i < 5; i++) { + const c = cur as { code?: string; errno?: number }; + if (c.code === "ER_DUP_ENTRY" || c.errno === 1062) return true; + cur = (cur as { cause?: unknown }).cause; + } + return false; +} + +// 发放积分;同一 (child,eventType,refKey) 重复插入命中唯一键→静默跳过返回 0 +export async function awardPoints( + childId: number, + eventType: string, + refKey: string, + points: number, + remark: string, +): Promise { + if (!points || points <= 0) return 0; + try { + await db.insert(pointLog).values({ childUserId: childId, eventType, refKey, points, remark }); + return points; + } catch (e: unknown) { + if (isDupEntry(e)) return 0; + throw e; + } +} + +// 测试类事件发放:按规则表取分值(缺省用默认值),返回实发分值(0=已发过) +export async function awardRulePoints( + childId: number, + eventType: string, + refKey: string, + remark: string, +): Promise { + const pts = await getRulePoints(eventType); + return awardPoints(childId, eventType, refKey, pts, remark); +} + +export async function getRulePoints(eventType: string): Promise { + const [row] = await db.select().from(pointRule).where(eq(pointRule.eventType, eventType)).limit(1); + return row?.points ?? DEFAULT_RULE_POINTS[eventType] ?? 0; +} + +// 家长规则页:DB 值优先,缺的用默认值补齐 +export async function listPointRules(): Promise<{ eventType: string; label: string; points: number }[]> { + const rows = await db.select().from(pointRule); + const map = new Map(rows.map((r) => [r.eventType, r.points])); + return Object.keys(DEFAULT_RULE_POINTS).map((eventType) => ({ + eventType, + label: POINT_EVENT_LABELS[eventType], + points: map.get(eventType) ?? DEFAULT_RULE_POINTS[eventType], + })); +} + +export async function upsertPointRule(eventType: string, points: number): Promise { + await db + .insert(pointRule) + .values({ eventType, points }) + .onDuplicateKeyUpdate({ set: { points, updatedAt: new Date() } }); +} + +export async function totalPoints(childId: number): Promise { + const [r] = await db + .select({ n: sql`COALESCE(SUM(points),0)` }) + .from(pointLog) + .where(eq(pointLog.childUserId, childId)); + return r.n; +} + +// xuchao: 查询某事件已发积分(周测结果页刷新后仍要显示「+N 积分」) +export async function getAwardedPoints(childId: number, eventType: string, refKey: string): Promise { + const [row] = await db + .select({ points: pointLog.points }) + .from(pointLog) + .where(and(eq(pointLog.childUserId, childId), eq(pointLog.eventType, eventType), eq(pointLog.refKey, refKey))) + .limit(1); + return row?.points ?? 0; +} + +export async function pointHistory(childId: number, limit = 50) { + return db + .select() + .from(pointLog) + .where(eq(pointLog.childUserId, childId)) + .orderBy(desc(pointLog.id)) + .limit(limit); +} diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index 03ce709..e7c821c 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -44,6 +44,7 @@ export async function ensureDay(childId: number, date: string) { title: t.title, subject: t.subject, requiresPhoto: t.requiresPhoto, + points: t.points, sortOrder: t.sortOrder, sourceTemplateId: t.id, })), @@ -71,6 +72,7 @@ export async function ensureDay(childId: number, date: string) { title: t.title, subject: t.subject, requiresPhoto: t.requiresPhoto, + points: t.points, sortOrder: t.sortOrder, sourceTemplateId: t.id, });