新增积分系统:打卡/测试达标赚积分,纯累计可配置
- 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
@@ -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;
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1786147200000,
|
"when": 1786147200000,
|
||||||
"tag": "0007_word-entry",
|
"tag": "0007_word-entry",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1786233600000,
|
||||||
|
"tag": "0008_points",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+14
-5
@@ -25,7 +25,15 @@ export type SolveDetail = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExamResult =
|
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 };
|
| { error: string };
|
||||||
|
|
||||||
// xuchao: 签发解答题照片直传地址(或本地开发回退)
|
// xuchao: 签发解答题照片直传地址(或本地开发回退)
|
||||||
@@ -109,9 +117,9 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
|
|||||||
questions.forEach((q, i) => {
|
questions.forEach((q, i) => {
|
||||||
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[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);
|
revalidateAll(paperId);
|
||||||
return g;
|
return { ...g, pointsEarned };
|
||||||
}
|
}
|
||||||
|
|
||||||
// xuchao: 混合卷加权判分——解答题只信服务端最近一次判定,不信任客户端上报
|
// xuchao: 混合卷加权判分——解答题只信服务端最近一次判定,不信任客户端上报
|
||||||
@@ -167,9 +175,9 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
|
|||||||
|
|
||||||
const score = pointSum ? Math.round((earnedSum / pointSum) * 100) : 0;
|
const score = pointSum ? Math.round((earnedSum / pointSum) * 100) : 0;
|
||||||
const passed = score >= 80;
|
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);
|
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) {
|
function revalidateAll(paperId: string) {
|
||||||
@@ -178,4 +186,5 @@ function revalidateAll(paperId: string) {
|
|||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
revalidatePath("/wrongbook");
|
revalidatePath("/wrongbook");
|
||||||
|
revalidatePath("/points");
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -7,6 +7,7 @@ import { photos, taskInstances } from "@/db/schema";
|
|||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
import { photoKey, uploadUrl } from "@/lib/oss";
|
import { photoKey, uploadUrl } from "@/lib/oss";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
import { awardPoints } from "@/lib/points";
|
||||||
|
|
||||||
// 取当前孩子会话,非法则抛错(客户端会显示)
|
// 取当前孩子会话,非法则抛错(客户端会显示)
|
||||||
async function requireChild() {
|
async function requireChild() {
|
||||||
@@ -25,8 +26,8 @@ async function ownedTask(childId: number, instanceId: number) {
|
|||||||
return task;
|
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 child = await requireChild();
|
||||||
const task = await ownedTask(child.id, instanceId);
|
const task = await ownedTask(child.id, instanceId);
|
||||||
if (task.date !== todayStr()) throw new Error("只能给今天的任务打卡");
|
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 })
|
.set({ status: done ? "done" : "pending", doneAt: done ? new Date() : null })
|
||||||
.where(eq(taskInstances.id, instanceId));
|
.where(eq(taskInstances.id, instanceId));
|
||||||
|
|
||||||
|
// xuchao: 首次完成才发分(唯一键防刷),分值取任务快照上的设置
|
||||||
|
const pointsEarned = done
|
||||||
|
? await awardPoints(child.id, "checkin", `inst-${instanceId}`, task.points, task.title)
|
||||||
|
: 0;
|
||||||
|
|
||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
|
revalidatePath("/points");
|
||||||
|
return { pointsEarned };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 签发照片直传地址(浏览器 PUT 到对象存储)
|
// 签发照片直传地址(浏览器 PUT 到对象存储)
|
||||||
|
|||||||
+17
-5
@@ -14,6 +14,7 @@ import { dayTasks, childCheckStats } from "@/lib/tasks";
|
|||||||
import { listPoints, passedByPoint } from "@/lib/grammar";
|
import { listPoints, passedByPoint } from "@/lib/grammar";
|
||||||
import { listMathPoints, mathPassedByPoint } from "@/lib/math";
|
import { listMathPoints, mathPassedByPoint } from "@/lib/math";
|
||||||
import { recordsByPaper } from "@/lib/exam";
|
import { recordsByPaper } from "@/lib/exam";
|
||||||
|
import { totalPoints } from "@/lib/points";
|
||||||
import { TaskCard } from "./task-card";
|
import { TaskCard } from "./task-card";
|
||||||
|
|
||||||
export default async function KidPage() {
|
export default async function KidPage() {
|
||||||
@@ -22,10 +23,11 @@ export default async function KidPage() {
|
|||||||
if (user.role !== "child") redirect("/parent");
|
if (user.role !== "child") redirect("/parent");
|
||||||
|
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
const [tasks, streak, stats] = await Promise.all([
|
const [tasks, streak, stats, myPoints] = await Promise.all([
|
||||||
dayTasks(user.id, today),
|
dayTasks(user.id, today),
|
||||||
streakOf(user.id),
|
streakOf(user.id),
|
||||||
childCheckStats(user.id, 7),
|
childCheckStats(user.id, 7),
|
||||||
|
totalPoints(user.id),
|
||||||
]);
|
]);
|
||||||
const doneCount = tasks.filter((t) => t.status === "done").length;
|
const doneCount = tasks.filter((t) => t.status === "done").length;
|
||||||
const allDone = tasks.length > 0 && doneCount === tasks.length;
|
const allDone = tasks.length > 0 && doneCount === tasks.length;
|
||||||
@@ -63,10 +65,20 @@ export default async function KidPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">{today}</p>
|
<p className="text-sm text-muted-foreground">{today}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-4 py-2 text-white shadow-[0_4px_14px_0_rgba(249,115,22,0.35)]">
|
<div className="flex items-center gap-2">
|
||||||
<Flame className="size-5" fill="currentColor" />
|
<Link
|
||||||
<span className="text-xl font-extrabold">{streak}</span>
|
href="/points"
|
||||||
<span className="text-xs font-medium">天</span>
|
className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-yellow-400 to-amber-500 px-3.5 py-2 text-white shadow-[0_4px_14px_0_rgba(245,158,11,0.35)]"
|
||||||
|
title="我的积分"
|
||||||
|
>
|
||||||
|
<span className="text-base">🪙</span>
|
||||||
|
<span className="text-xl font-extrabold">{myPoints}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-4 py-2 text-white shadow-[0_4px_14px_0_rgba(249,115,22,0.35)]">
|
||||||
|
<Flame className="size-5" fill="currentColor" />
|
||||||
|
<span className="text-xl font-extrabold">{streak}</span>
|
||||||
|
<span className="text-xs font-medium">天</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function TaskCard({
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [busy, startTransition] = useTransition();
|
const [busy, startTransition] = useTransition();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [earned, setEarned] = useState<number | null>(null);
|
||||||
const done = task.status === "done";
|
const done = task.status === "done";
|
||||||
const canCheck = !task.requiresPhoto || task.photoUrls.length > 0;
|
const canCheck = !task.requiresPhoto || task.photoUrls.length > 0;
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ export function TaskCard({
|
|||||||
}
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await toggleTask(task.id, !done);
|
const r = await toggleTask(task.id, !done);
|
||||||
|
setEarned(!done && r.pointsEarned > 0 ? r.pointsEarned : null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "操作失败");
|
setError(e instanceof Error ? e.message : "操作失败");
|
||||||
}
|
}
|
||||||
@@ -111,6 +113,11 @@ export function TaskCard({
|
|||||||
补卡
|
补卡
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{earned !== null && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-600">
|
||||||
|
🪙 +{earned} 积分
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{task.requiresPhoto && task.photoUrls.length < 3 && (
|
{task.requiresPhoto && task.photoUrls.length < 3 && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { completePoint, extractQuiz, getPoint, gradeAnswers, type AnswerValue }
|
|||||||
import { recordWrong } from "@/lib/wrongbook";
|
import { recordWrong } from "@/lib/wrongbook";
|
||||||
|
|
||||||
export type QuizResult =
|
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 };
|
| { error: string };
|
||||||
|
|
||||||
// 提交结课练习:服务端按课文 quiz 判分,达标记进度并联动英语打卡
|
// 提交结课练习:服务端按课文 quiz 判分,达标记进度并联动英语打卡
|
||||||
@@ -25,12 +25,13 @@ export async function submitQuiz(pointId: string, answers: AnswerValue[]): Promi
|
|||||||
questions.forEach((q, i) => {
|
questions.forEach((q, i) => {
|
||||||
if (!g.detail[i]) void recordWrong(user.id, "lesson", pointId, point.title, q, answers[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");
|
||||||
revalidatePath(`/learn/${pointId}`);
|
revalidatePath(`/learn/${pointId}`);
|
||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
revalidatePath("/wrongbook");
|
revalidatePath("/wrongbook");
|
||||||
return g;
|
revalidatePath("/points");
|
||||||
|
return { ...g, pointsEarned };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ export function QuizBlock({
|
|||||||
答对 {result.correct} / {result.total}
|
答对 {result.correct} / {result.total}
|
||||||
{result.passed ? ` · ${passText}` : " · 看看解说,再试一次"}
|
{result.passed ? ` · ${passText}` : " · 看看解说,再试一次"}
|
||||||
</p>
|
</p>
|
||||||
|
{result.passed && result.pointsEarned > 0 && (
|
||||||
|
<p className="mt-1 text-base font-extrabold">🪙 +{result.pointsEarned} 积分</p>
|
||||||
|
)}
|
||||||
<div className="mt-4 flex justify-center gap-3">
|
<div className="mt-4 flex justify-center gap-3">
|
||||||
{!result.passed && (
|
{!result.passed && (
|
||||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { completeMathPoint, extractQuiz, getMathPoint, gradeAnswers, type Answer
|
|||||||
import { recordWrong } from "@/lib/wrongbook";
|
import { recordWrong } from "@/lib/wrongbook";
|
||||||
|
|
||||||
export type QuizResult =
|
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 };
|
| { error: string };
|
||||||
|
|
||||||
// 提交数学课练习:服务端判分,达标记进度并联动数学打卡
|
// 提交数学课练习:服务端判分,达标记进度并联动数学打卡
|
||||||
@@ -26,12 +26,13 @@ export async function submitMathQuiz(pointId: string, answers: AnswerValue[]): P
|
|||||||
questions.forEach((q, i) => {
|
questions.forEach((q, i) => {
|
||||||
if (!g.detail[i]) void recordWrong(user.id, "mlesson", pointId, point.title, q, answers[i]);
|
if (!g.detail[i]) void recordWrong(user.id, "mlesson", pointId, point.title, q, answers[i]);
|
||||||
});
|
});
|
||||||
await completeMathPoint(user.id, pointId, point.title, g.score, g.passed);
|
const pointsEarned = await completeMathPoint(user.id, pointId, point.title, g.score, g.passed);
|
||||||
|
|
||||||
revalidatePath("/math");
|
revalidatePath("/math");
|
||||||
revalidatePath(`/math/${pointId}`);
|
revalidatePath(`/math/${pointId}`);
|
||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
revalidatePath("/wrongbook");
|
revalidatePath("/wrongbook");
|
||||||
return g;
|
revalidatePath("/points");
|
||||||
|
return { ...g, pointsEarned };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { recordWrong } from "@/lib/wrongbook";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { taskInstances, mathWeeklyTest } from "@/db/schema";
|
import { taskInstances, mathWeeklyTest } from "@/db/schema";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
|
import { awardRulePoints } from "@/lib/points";
|
||||||
|
|
||||||
export type MathWeeklyResult =
|
export type MathWeeklyResult =
|
||||||
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
|
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; pointsEarned: number }
|
||||||
| { error: string };
|
| { error: string };
|
||||||
|
|
||||||
// 提交数学每周检测:判分、记录、错题入库、达标关联当日数学打卡
|
// 提交数学每周检测:判分、记录、错题入库、达标关联当日数学打卡
|
||||||
@@ -44,8 +45,10 @@ export async function takeMathWeekly(answers: AnswerValue[]): Promise<MathWeekly
|
|||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
|
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
|
||||||
|
|
||||||
// 达标关联当日数学打卡
|
// 达标关联当日数学打卡,并发积分(首次达标才发)
|
||||||
|
let pointsEarned = 0;
|
||||||
if (g.passed) {
|
if (g.passed) {
|
||||||
|
pointsEarned = await awardRulePoints(user.id, "math_weekly", `week-${wk}`, `数学周检测 ${wk}`);
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
const title = `数学周检测 · ${wk}`;
|
const title = `数学周检测 · ${wk}`;
|
||||||
const [ex] = await db
|
const [ex] = await db
|
||||||
@@ -73,5 +76,6 @@ export async function takeMathWeekly(answers: AnswerValue[]): Promise<MathWeekly
|
|||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
revalidatePath("/wrongbook");
|
revalidatePath("/wrongbook");
|
||||||
return g;
|
revalidatePath("/points");
|
||||||
|
return { ...g, pointsEarned };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { buildMathWeeklyQuestions, currentMathWeekKey, getMathWeeklyRecord } from "@/lib/math-weekly";
|
import { buildMathWeeklyQuestions, currentMathWeekKey, getMathWeeklyRecord } from "@/lib/math-weekly";
|
||||||
|
import { getAwardedPoints } from "@/lib/points";
|
||||||
import { QuizBlock } from "@/app/learn/quiz-block";
|
import { QuizBlock } from "@/app/learn/quiz-block";
|
||||||
import { takeMathWeekly } from "./actions";
|
import { takeMathWeekly } from "./actions";
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ export default async function MathWeeklyPage() {
|
|||||||
const wk = currentMathWeekKey();
|
const wk = currentMathWeekKey();
|
||||||
const record = await getMathWeeklyRecord(user.id, wk);
|
const record = await getMathWeeklyRecord(user.id, wk);
|
||||||
const questions = record ? [] : await buildMathWeeklyQuestions(user.id, wk);
|
const questions = record ? [] : await buildMathWeeklyQuestions(user.id, wk);
|
||||||
|
// xuchao: 已有记录时从流水取回本周达标的积分,结果页刷新后仍显示
|
||||||
|
const awarded = record?.passed ? await getAwardedPoints(user.id, "math_weekly", `week-${wk}`) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
||||||
@@ -42,6 +45,7 @@ export default async function MathWeeklyPage() {
|
|||||||
<p className="mt-1 text-sm opacity-90">
|
<p className="mt-1 text-sm opacity-90">
|
||||||
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
|
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
|
||||||
</p>
|
</p>
|
||||||
|
{record.passed && awarded > 0 && <p className="mt-1 text-base font-extrabold">🪙 +{awarded} 积分</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<QuizBlock
|
<QuizBlock
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { SUBJECT_VALUES } from "@/db/schema";
|
|||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
import { ensureDay } from "@/lib/tasks";
|
import { ensureDay } from "@/lib/tasks";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
import { DEFAULT_RULE_POINTS, upsertPointRule } from "@/lib/points";
|
||||||
|
|
||||||
function hashCode(code: string) {
|
function hashCode(code: string) {
|
||||||
return crypto.createHash("sha256").update(code).digest("hex");
|
return crypto.createHash("sha256").update(code).digest("hex");
|
||||||
@@ -84,6 +85,8 @@ const templateSchema = z.object({
|
|||||||
dateEnd: z.string().nullish(),
|
dateEnd: z.string().nullish(),
|
||||||
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
|
title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"),
|
||||||
requiresPhoto: z.boolean(),
|
requiresPhoto: z.boolean(),
|
||||||
|
// xuchao: 该任务完成可得积分,0~9999
|
||||||
|
points: z.coerce.number().int().min(0).max(9999).catch(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function requireOwnerOfChild(childId: number) {
|
async function requireOwnerOfChild(childId: number) {
|
||||||
@@ -109,6 +112,7 @@ export async function addTemplate(formData: FormData) {
|
|||||||
dateEnd: formData.get("dateEnd") || null,
|
dateEnd: formData.get("dateEnd") || null,
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
requiresPhoto: formData.get("requiresPhoto") === "on",
|
requiresPhoto: formData.get("requiresPhoto") === "on",
|
||||||
|
points: formData.get("points") || 10,
|
||||||
});
|
});
|
||||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
await requireOwnerOfChild(parsed.data.childId);
|
await requireOwnerOfChild(parsed.data.childId);
|
||||||
@@ -127,12 +131,51 @@ export async function addTemplate(formData: FormData) {
|
|||||||
dateEnd: parsed.data.dateEnd || null,
|
dateEnd: parsed.data.dateEnd || null,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
requiresPhoto: parsed.data.requiresPhoto,
|
requiresPhoto: parsed.data.requiresPhoto,
|
||||||
|
points: parsed.data.points,
|
||||||
sortOrder: maxRow.maxSort + 1,
|
sortOrder: maxRow.maxSort + 1,
|
||||||
});
|
});
|
||||||
await ensureDay(parsed.data.childId, todayStr());
|
await ensureDay(parsed.data.childId, todayStr());
|
||||||
revalidatePath("/parent");
|
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) {
|
export async function deleteTemplate(templateId: number) {
|
||||||
const parent = await getSessionUser();
|
const parent = await getSessionUser();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { ChildManage } from "./child-manage";
|
import { ChildManage } from "./child-manage";
|
||||||
import { DevicesTab } from "./devices-tab";
|
import { DevicesTab } from "./devices-tab";
|
||||||
import { OverviewTab } from "./overview-tab";
|
import { OverviewTab } from "./overview-tab";
|
||||||
|
import { PointsManage } from "./points-manage";
|
||||||
import { TemplatesTab } from "./templates-tab";
|
import { TemplatesTab } from "./templates-tab";
|
||||||
|
|
||||||
export type DayCell = {
|
export type DayCell = {
|
||||||
@@ -39,12 +40,23 @@ export type TemplatesData = {
|
|||||||
title: string;
|
title: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
requiresPhoto: boolean;
|
requiresPhoto: boolean;
|
||||||
|
points: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
dateStart: string | null;
|
dateStart: string | null;
|
||||||
dateEnd: 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 = {
|
export type DevicesData = {
|
||||||
rows: {
|
rows: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -74,11 +86,13 @@ export function ParentDashboard({
|
|||||||
templates,
|
templates,
|
||||||
devices,
|
devices,
|
||||||
weekly,
|
weekly,
|
||||||
|
points,
|
||||||
}: {
|
}: {
|
||||||
overview: OverviewData;
|
overview: OverviewData;
|
||||||
templates: TemplatesData;
|
templates: TemplatesData;
|
||||||
devices: DevicesData;
|
devices: DevicesData;
|
||||||
weekly: WeeklyRow[];
|
weekly: WeeklyRow[];
|
||||||
|
points: PointsData;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
|
<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} />
|
<TemplatesTab data={templates} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="积分管理" desc="打卡分值在任务管理里按任务设置;测试类达标分值在下面调整,孩子首次达标才计分">
|
||||||
|
<PointsManage data={points} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="课程管理" desc="英语语法/单词、数学思维课程编辑与每周检测成绩">
|
<Section title="课程管理" desc="英语语法/单词、数学思维课程编辑与每周检测成绩">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Link href="/parent/grammar" className="block">
|
<Link href="/parent/grammar" className="block">
|
||||||
|
|||||||
@@ -17,11 +17,18 @@ import {
|
|||||||
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
import { SESSION_COOKIE, getSessionUser } from "@/lib/auth";
|
||||||
import { addDays, dowOf, todayStr } from "@/lib/dates";
|
import { addDays, dowOf, todayStr } from "@/lib/dates";
|
||||||
import { passedCount, totalPoints } from "@/lib/grammar";
|
import { passedCount, totalPoints } from "@/lib/grammar";
|
||||||
|
import {
|
||||||
|
POINT_EVENT_LABELS,
|
||||||
|
listPointRules,
|
||||||
|
pointHistory,
|
||||||
|
totalPoints as childPointsTotal,
|
||||||
|
} from "@/lib/points";
|
||||||
import { viewUrl } from "@/lib/oss";
|
import { viewUrl } from "@/lib/oss";
|
||||||
import {
|
import {
|
||||||
ParentDashboard,
|
ParentDashboard,
|
||||||
type DevicesData,
|
type DevicesData,
|
||||||
type OverviewData,
|
type OverviewData,
|
||||||
|
type PointsData,
|
||||||
type TemplatesData,
|
type TemplatesData,
|
||||||
type WeeklyRow,
|
type WeeklyRow,
|
||||||
} from "./dashboard";
|
} from "./dashboard";
|
||||||
@@ -136,6 +143,7 @@ export default async function ParentPage() {
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
requiresPhoto: t.requiresPhoto,
|
requiresPhoto: t.requiresPhoto,
|
||||||
|
points: t.points,
|
||||||
mode: t.mode,
|
mode: t.mode,
|
||||||
dateStart: t.dateStart,
|
dateStart: t.dateStart,
|
||||||
dateEnd: t.dateEnd,
|
dateEnd: t.dateEnd,
|
||||||
@@ -191,12 +199,33 @@ export default async function ParentPage() {
|
|||||||
passed: w.passed,
|
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 (
|
return (
|
||||||
<ParentDashboard
|
<ParentDashboard
|
||||||
overview={overview}
|
overview={overview}
|
||||||
templates={templates}
|
templates={templates}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
weekly={weekly}
|
weekly={weekly}
|
||||||
|
points={pointsData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { addDays, dowOf, mondayOf, todayStr } from "@/lib/dates";
|
import { addDays, dowOf, mondayOf, todayStr } from "@/lib/dates";
|
||||||
import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects";
|
import { SUBJECTS, type Subject, subjectMeta } from "@/lib/subjects";
|
||||||
import { addTemplate, deleteTemplate } from "./actions";
|
import { addTemplate, deleteTemplate, updateTemplatePoints } from "./actions";
|
||||||
import type { TemplatesData } from "./dashboard";
|
import type { TemplatesData } from "./dashboard";
|
||||||
|
|
||||||
type Item = TemplatesData["kids"][number]["items"][number];
|
type Item = TemplatesData["kids"][number]["items"][number];
|
||||||
@@ -31,6 +31,7 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
|
|||||||
const [subject, setSubject] = useState<Subject>("other");
|
const [subject, setSubject] = useState<Subject>("other");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [photo, setPhoto] = useState(false);
|
const [photo, setPhoto] = useState(false);
|
||||||
|
const [points, setPoints] = useState(10);
|
||||||
const [rangeStart, setRangeStart] = useState(todayStr());
|
const [rangeStart, setRangeStart] = useState(todayStr());
|
||||||
const [rangeEnd, setRangeEnd] = useState(addDays(todayStr(), 6));
|
const [rangeEnd, setRangeEnd] = useState(addDays(todayStr(), 6));
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -59,11 +60,13 @@ export function TemplatesTab({ data }: { data: TemplatesData }) {
|
|||||||
fd.set("dateEnd", rangeEnd);
|
fd.set("dateEnd", rangeEnd);
|
||||||
}
|
}
|
||||||
if (photo) fd.set("requiresPhoto", "on");
|
if (photo) fd.set("requiresPhoto", "on");
|
||||||
|
fd.set("points", String(points));
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await addTemplate(fd);
|
await addTemplate(fd);
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setPhoto(false);
|
setPhoto(false);
|
||||||
|
setPoints(10);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "添加失败");
|
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="font-medium">{sm.label}</span>
|
||||||
<span className="text-slate-600">{t.title}</span>
|
<span className="text-slate-600">{t.title}</span>
|
||||||
<span className="text-xs text-muted-foreground">({modeLabel[t.mode] ?? t.mode})</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`删除 ${t.title}`}
|
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))}
|
onClick={() => startTransition(() => deleteTemplate(t.id))}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<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)} />
|
<Checkbox id="tpl-photo" checked={photo} onCheckedChange={(v) => setPhoto(v === true)} />
|
||||||
<Label htmlFor="tpl-photo">需拍照</Label>
|
<Label htmlFor="tpl-photo">需拍照</Label>
|
||||||
</div>
|
</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 type="submit" disabled={pending || title.trim() === ""}>
|
||||||
添加
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<main className="relative mx-auto min-h-dvh max-w-lg space-y-5 p-4 pb-12">
|
||||||
|
<BackgroundBlobs />
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<Link href="/kid" className="text-sm font-medium text-indigo-600">
|
||||||
|
← 返回
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-extrabold text-slate-900">我的积分</h1>
|
||||||
|
<span className="w-10" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-gradient-to-r from-yellow-400 to-amber-500 p-6 text-center text-white shadow-btn">
|
||||||
|
<p className="text-5xl font-extrabold">🪙 {total}</p>
|
||||||
|
<p className="mt-2 text-sm opacity-90">完成任务和达标检测都会攒积分,继续加油!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||||
|
<h2 className="font-extrabold text-slate-900">积分明细</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">还没有积分记录,去完成今天的任务吧!</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 divide-y divide-slate-100">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-800">{r.remark || eventLabel(r.eventType)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{eventLabel(r.eventType)} · {r.createdAt.toLocaleDateString("zh-CN", SH)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-base font-extrabold text-amber-600">+{r.points}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ import { recordWrong } from "@/lib/wrongbook";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { taskInstances, weeklyTest } from "@/db/schema";
|
import { taskInstances, weeklyTest } from "@/db/schema";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
|
import { awardRulePoints } from "@/lib/points";
|
||||||
|
|
||||||
export type WeeklyResult =
|
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 };
|
| { error: string };
|
||||||
|
|
||||||
// 提交每周能力检测:判分、记录、错题入库、达标关联当日英语打卡
|
// 提交每周能力检测:判分、记录、错题入库、达标关联当日英语打卡
|
||||||
@@ -43,8 +44,10 @@ export async function takeWeekly(answers: AnswerValue[]): Promise<WeeklyResult>
|
|||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
|
.onDuplicateKeyUpdate({ set: { score: g.score, correct: g.correct, total: g.total, passed: g.passed } });
|
||||||
|
|
||||||
// 达标关联当日英语打卡
|
// 达标关联当日英语打卡,并发积分(首次达标才发)
|
||||||
|
let pointsEarned = 0;
|
||||||
if (g.passed) {
|
if (g.passed) {
|
||||||
|
pointsEarned = await awardRulePoints(user.id, "grammar_weekly", `week-${wk}`, `英语周检测 ${wk}`);
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
const title = `英语周检测 · ${wk}`;
|
const title = `英语周检测 · ${wk}`;
|
||||||
const [ex] = await db
|
const [ex] = await db
|
||||||
@@ -72,5 +75,6 @@ export async function takeWeekly(answers: AnswerValue[]): Promise<WeeklyResult>
|
|||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
revalidatePath("/wrongbook");
|
revalidatePath("/wrongbook");
|
||||||
return g;
|
revalidatePath("/points");
|
||||||
|
return { ...g, pointsEarned };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly";
|
import { buildWeeklyQuestions, currentWeekKey, getWeeklyRecord } from "@/lib/weekly";
|
||||||
|
import { getAwardedPoints } from "@/lib/points";
|
||||||
import { QuizBlock } from "@/app/learn/quiz-block";
|
import { QuizBlock } from "@/app/learn/quiz-block";
|
||||||
import { takeWeekly } from "./actions";
|
import { takeWeekly } from "./actions";
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ export default async function WeeklyPage() {
|
|||||||
const wk = currentWeekKey();
|
const wk = currentWeekKey();
|
||||||
const record = await getWeeklyRecord(user.id, wk);
|
const record = await getWeeklyRecord(user.id, wk);
|
||||||
const questions = record ? [] : await buildWeeklyQuestions(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 (
|
return (
|
||||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-12">
|
||||||
@@ -41,6 +44,7 @@ export default async function WeeklyPage() {
|
|||||||
<p className="mt-1 text-sm opacity-90">
|
<p className="mt-1 text-sm opacity-90">
|
||||||
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
|
答对 {record.correct} / {record.total} · {record.passed ? "本周掌握良好 🎉" : "看看错题本,下周再战"}
|
||||||
</p>
|
</p>
|
||||||
|
{record.passed && awarded > 0 && <p className="mt-1 text-base font-extrabold">🪙 +{awarded} 积分</p>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<QuizBlock pointId="" questions={questions} submitter={takeWeekly} />
|
<QuizBlock pointId="" questions={questions} submitter={takeWeekly} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getSessionUser } from "@/lib/auth";
|
|||||||
import { buildWordTest } from "@/lib/words";
|
import { buildWordTest } from "@/lib/words";
|
||||||
import { uploadUrl } from "@/lib/oss";
|
import { uploadUrl } from "@/lib/oss";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
|
import { awardRulePoints } from "@/lib/points";
|
||||||
|
|
||||||
// 跟读录音直传签名(按实际录音类型签名,避免签名不匹配)
|
// 跟读录音直传签名(按实际录音类型签名,避免签名不匹配)
|
||||||
export async function getRecUploadUrl(word: string, contentType: string): Promise<{ url: string; key: string }> {
|
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<void> {
|
|||||||
revalidatePath("/words/[day]", "layout");
|
revalidatePath("/words/[day]", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交每日单词检测:服务端组卷判分,达标关联当日英语打卡
|
// 提交每日单词检测:服务端组卷判分,达标关联当日英语打卡并发积分
|
||||||
export async function submitWordTest(
|
export async function submitWordTest(
|
||||||
day: number,
|
day: number,
|
||||||
answers: 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();
|
const u = await child();
|
||||||
if (!u) return { error: "会话无效" };
|
if (!u) return { error: "会话无效" };
|
||||||
const qs = await buildWordTest(day);
|
const qs = await buildWordTest(day);
|
||||||
@@ -61,7 +64,10 @@ export async function submitWordTest(
|
|||||||
.values({ childUserId: u.id, day, score, passed, completedAt: new Date() })
|
.values({ childUserId: u.id, day, score, passed, completedAt: new Date() })
|
||||||
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
|
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
|
||||||
|
|
||||||
|
// xuchao: 首次达标发积分(唯一键防刷,重考不再发)
|
||||||
|
let pointsEarned = 0;
|
||||||
if (passed) {
|
if (passed) {
|
||||||
|
pointsEarned = await awardRulePoints(u.id, "word_test", `day-${day}`, `英语单词 · 第${day}天`);
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
const title = `英语单词 · 第${day}天`;
|
const title = `英语单词 · 第${day}天`;
|
||||||
const [ex] = await db
|
const [ex] = await db
|
||||||
@@ -88,7 +94,8 @@ export async function submitWordTest(
|
|||||||
revalidatePath("/words/[day]", "layout");
|
revalidatePath("/words/[day]", "layout");
|
||||||
revalidatePath("/kid");
|
revalidatePath("/kid");
|
||||||
revalidatePath("/parent");
|
revalidatePath("/parent");
|
||||||
return { score, passed, correct, total: qs.length, detail };
|
revalidatePath("/points");
|
||||||
|
return { score, passed, correct, total: qs.length, detail, pointsEarned };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存跟读录音(已直传 OSS,这里登记)
|
// 保存跟读录音(已直传 OSS,这里登记)
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ export function WordDayClient({
|
|||||||
const [example, setExample] = useState<Record<string, string>>({});
|
const [example, setExample] = useState<Record<string, string>>({});
|
||||||
const [testOpen, setTestOpen] = useState(false);
|
const [testOpen, setTestOpen] = useState(false);
|
||||||
const [answers, setAnswers] = useState<(number | null)[]>(test.map(() => null));
|
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 [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
const toggleLearn = (word: string) => {
|
const toggleLearn = (word: string) => {
|
||||||
@@ -126,7 +128,7 @@ export function WordDayClient({
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const r = await submitWordTest(day, answers.map((a) => a as number));
|
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 && (
|
||||||
<span className={cn("text-sm font-bold", result.passed ? "text-emerald-600" : "text-rose-600")}>
|
<span className={cn("text-sm font-bold", result.passed ? "text-emerald-600" : "text-rose-600")}>
|
||||||
{result.score} 分 {result.passed ? "· 已点亮" : ""}
|
{result.score} 分 {result.passed ? "· 已点亮" : ""}
|
||||||
|
{result.passed && result.pointsEarned > 0 ? ` · 🪙 +${result.pointsEarned} 积分` : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export const taskTemplates = mysqlTable(
|
|||||||
title: varchar("title", { length: 100 }).notNull(),
|
title: varchar("title", { length: 100 }).notNull(),
|
||||||
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
|
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
|
||||||
requiresPhoto: boolean("requires_photo").notNull().default(false),
|
requiresPhoto: boolean("requires_photo").notNull().default(false),
|
||||||
|
// xuchao: 该任务完成可得积分(家长按任务难度单独设置)
|
||||||
|
points: int("points").notNull().default(10),
|
||||||
// 模板生效方式:weekly=每周某weekday / date=某一天 / range=日期区间 / week=某一周
|
// 模板生效方式:weekly=每周某weekday / date=某一天 / range=日期区间 / week=某一周
|
||||||
mode: varchar("mode", { length: 10 }).notNull().default("weekly"),
|
mode: varchar("mode", { length: 10 }).notNull().default("weekly"),
|
||||||
dateStart: date("date_start", { mode: "string" }),
|
dateStart: date("date_start", { mode: "string" }),
|
||||||
@@ -114,6 +116,8 @@ export const taskInstances = mysqlTable(
|
|||||||
title: varchar("title", { length: 100 }).notNull(),
|
title: varchar("title", { length: 100 }).notNull(),
|
||||||
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
|
subject: mysqlEnum("subject", SUBJECT_VALUES).notNull().default("other"),
|
||||||
requiresPhoto: boolean("requires_photo").notNull().default(false),
|
requiresPhoto: boolean("requires_photo").notNull().default(false),
|
||||||
|
// xuchao: 该任务完成可得积分(生成快照时从模板复制)
|
||||||
|
points: int("points").notNull().default(10),
|
||||||
status: mysqlEnum("status", ["pending", "done"]).notNull().default("pending"),
|
status: mysqlEnum("status", ["pending", "done"]).notNull().default("pending"),
|
||||||
// 家长补卡标记,永久保留
|
// 家长补卡标记,永久保留
|
||||||
isMakeup: boolean("is_makeup").notNull().default(false),
|
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),
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
+6
-3
@@ -4,6 +4,7 @@ import { db } from "@/db";
|
|||||||
import { aiGradeLog, examPapers, examRecords, taskInstances } from "@/db/schema";
|
import { aiGradeLog, examPapers, examRecords, taskInstances } from "@/db/schema";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
import type { QuizQ } from "@/lib/quiz-parse";
|
import type { QuizQ } from "@/lib/quiz-parse";
|
||||||
|
import { awardRulePoints } from "@/lib/points";
|
||||||
|
|
||||||
// xuchao: 试卷题型标签(展示用)
|
// xuchao: 试卷题型标签(展示用)
|
||||||
export const EXAM_TYPE_LABEL: Record<string, string> = {
|
export const EXAM_TYPE_LABEL: Record<string, string> = {
|
||||||
@@ -86,7 +87,7 @@ export async function recordsByPaper(childId: number): Promise<Map<string, { sco
|
|||||||
return new Map(rows.map((r) => [r.paperId, { score: r.score ?? 0, passed: r.passed }]));
|
return new Map(rows.map((r) => [r.paperId, { score: r.score ?? 0, passed: r.passed }]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// xuchao: 记录作答;达标联动当天数学打卡(允许重考覆盖成绩)
|
// xuchao: 记录作答;达标联动当天数学打卡并发积分(首次达标才发);返回实发积分
|
||||||
export async function recordExam(
|
export async function recordExam(
|
||||||
childId: number,
|
childId: number,
|
||||||
paperId: string,
|
paperId: string,
|
||||||
@@ -96,7 +97,7 @@ export async function recordExam(
|
|||||||
total: number,
|
total: number,
|
||||||
passed: boolean,
|
passed: boolean,
|
||||||
answers: unknown[],
|
answers: unknown[],
|
||||||
) {
|
): Promise<number> {
|
||||||
await db
|
await db
|
||||||
.insert(examRecords)
|
.insert(examRecords)
|
||||||
.values({
|
.values({
|
||||||
@@ -112,7 +113,8 @@ export async function recordExam(
|
|||||||
set: { score, correct, total, passed, answers: JSON.stringify(answers), takenAt: sql`CURRENT_TIMESTAMP` },
|
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 today = todayStr();
|
||||||
const ckTitle = `考试 · ${paperTitle.length > 24 ? paperTitle.slice(0, 24) + "…" : paperTitle}`;
|
const ckTitle = `考试 · ${paperTitle.length > 24 ? paperTitle.slice(0, 24) + "…" : paperTitle}`;
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -139,6 +141,7 @@ export async function recordExam(
|
|||||||
sortOrder: 96,
|
sortOrder: 96,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return pointsEarned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// xuchao: 当日 AI 判分次数(限频用,含全部状态)
|
// xuchao: 当日 AI 判分次数(限频用,含全部状态)
|
||||||
|
|||||||
+6
-3
@@ -3,6 +3,7 @@ import { db } from "@/db";
|
|||||||
import { grammarPoints, grammarProgress, taskInstances } from "@/db/schema";
|
import { grammarPoints, grammarProgress, taskInstances } from "@/db/schema";
|
||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
|
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
|
||||||
|
import { awardRulePoints } from "@/lib/points";
|
||||||
|
|
||||||
export { extractQuiz, type QuizQ };
|
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 };
|
return { score, passed: score >= 80, correct, total: questions.length, detail };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录结果;通过则同步完成当天英语打卡
|
// 记录结果;通过则同步完成当天英语打卡;返回首次达标所发积分
|
||||||
export async function completePoint(
|
export async function completePoint(
|
||||||
childId: number,
|
childId: number,
|
||||||
pointId: string,
|
pointId: string,
|
||||||
title: string,
|
title: string,
|
||||||
score: number,
|
score: number,
|
||||||
passed: boolean,
|
passed: boolean,
|
||||||
) {
|
): Promise<number> {
|
||||||
await db
|
await db
|
||||||
.insert(grammarProgress)
|
.insert(grammarProgress)
|
||||||
.values({ childUserId: childId, pointId, score, passed })
|
.values({ childUserId: childId, pointId, score, passed })
|
||||||
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
|
.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 today = todayStr();
|
||||||
const ckTitle = `英语语法 · ${title}`;
|
const ckTitle = `英语语法 · ${title}`;
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -126,4 +128,5 @@ export async function completePoint(
|
|||||||
sortOrder: 99,
|
sortOrder: 99,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return pointsEarned;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -5,6 +5,7 @@ import { mathPoints, mathProgress, taskInstances } from "@/db/schema";
|
|||||||
import { todayStr } from "@/lib/dates";
|
import { todayStr } from "@/lib/dates";
|
||||||
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
|
import { extractQuiz, type QuizQ } from "@/lib/quiz-parse";
|
||||||
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
|
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
|
||||||
|
import { awardRulePoints } from "@/lib/points";
|
||||||
|
|
||||||
export { extractQuiz, type QuizQ };
|
export { extractQuiz, type QuizQ };
|
||||||
export { gradeAnswers, type AnswerValue };
|
export { gradeAnswers, type AnswerValue };
|
||||||
@@ -64,20 +65,21 @@ export async function mathPassedByPoint(childId: number): Promise<Set<string>> {
|
|||||||
return new Set(rows.map((r) => r.pointId));
|
return new Set(rows.map((r) => r.pointId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// xuchao: 记录结果;通过则同步完成当天数学打卡
|
// xuchao: 记录结果;通过则同步完成当天数学打卡;返回首次达标所发积分
|
||||||
export async function completeMathPoint(
|
export async function completeMathPoint(
|
||||||
childId: number,
|
childId: number,
|
||||||
pointId: string,
|
pointId: string,
|
||||||
title: string,
|
title: string,
|
||||||
score: number,
|
score: number,
|
||||||
passed: boolean,
|
passed: boolean,
|
||||||
) {
|
): Promise<number> {
|
||||||
await db
|
await db
|
||||||
.insert(mathProgress)
|
.insert(mathProgress)
|
||||||
.values({ childUserId: childId, pointId, score, passed })
|
.values({ childUserId: childId, pointId, score, passed })
|
||||||
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
|
.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 today = todayStr();
|
||||||
const ckTitle = `数学思维 · ${title}`;
|
const ckTitle = `数学思维 · ${title}`;
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
@@ -104,4 +106,5 @@ export async function completeMathPoint(
|
|||||||
sortOrder: 97,
|
sortOrder: 97,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return pointsEarned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
word_test: "单词检测达标",
|
||||||
|
math_lesson: "数学课达标",
|
||||||
|
grammar_point: "语法知识点达标",
|
||||||
|
grammar_weekly: "英语周检测达标",
|
||||||
|
math_weekly: "数学周检测达标",
|
||||||
|
exam_pass: "考试通过",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 规则表无记录时的默认分值(家长改过即落库覆盖)
|
||||||
|
export const DEFAULT_RULE_POINTS: Record<string, number> = {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
const pts = await getRulePoints(eventType);
|
||||||
|
return awardPoints(childId, eventType, refKey, pts, remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRulePoints(eventType: string): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
await db
|
||||||
|
.insert(pointRule)
|
||||||
|
.values({ eventType, points })
|
||||||
|
.onDuplicateKeyUpdate({ set: { points, updatedAt: new Date() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function totalPoints(childId: number): Promise<number> {
|
||||||
|
const [r] = await db
|
||||||
|
.select({ n: sql<number>`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<number> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export async function ensureDay(childId: number, date: string) {
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
requiresPhoto: t.requiresPhoto,
|
requiresPhoto: t.requiresPhoto,
|
||||||
|
points: t.points,
|
||||||
sortOrder: t.sortOrder,
|
sortOrder: t.sortOrder,
|
||||||
sourceTemplateId: t.id,
|
sourceTemplateId: t.id,
|
||||||
})),
|
})),
|
||||||
@@ -71,6 +72,7 @@ export async function ensureDay(childId: number, date: string) {
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
requiresPhoto: t.requiresPhoto,
|
requiresPhoto: t.requiresPhoto,
|
||||||
|
points: t.points,
|
||||||
sortOrder: t.sortOrder,
|
sortOrder: t.sortOrder,
|
||||||
sourceTemplateId: t.id,
|
sourceTemplateId: t.id,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user