- 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>
249 lines
9.6 KiB
TypeScript
249 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import Link from "next/link";
|
|
import { BookOpen, Check, Mic, Square, Volume2 } from "lucide-react";
|
|
import { BackgroundBlobs } from "@/components/background-blobs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { getRecUploadUrl, markLearned, saveRecording, submitWordTest } from "./actions";
|
|
|
|
type Item = { word: string; meaning: string; similar: { word: string; meaning: string }[] };
|
|
type TestQ = { word: string; options: string[]; answer: number };
|
|
|
|
const speak = (word: string) => {
|
|
const a = new Audio(`https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(word)}&type=2`);
|
|
a.play();
|
|
};
|
|
|
|
// 跟读录音按钮:录音→直传 OSS→登记
|
|
function RecordBtn({ day, word }: { day: number; word: string }) {
|
|
const [rec, setRec] = useState<MediaRecorder | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const stop = () => {
|
|
rec?.stop();
|
|
setRec(null);
|
|
};
|
|
const start = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
// 优先选 Safari/Chrome 都能播放的 mp4,家长端 iOS 才能回放
|
|
const mime =
|
|
["audio/mp4", "audio/mp4;codecs=mp4a.40.2", "audio/webm;codecs=opus", "audio/webm"].find(
|
|
(t) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t),
|
|
) || "";
|
|
const mr = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
|
const chunks: Blob[] = [];
|
|
mr.ondataavailable = (e) => chunks.push(e.data);
|
|
mr.onstop = async () => {
|
|
setBusy(true);
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
const blob = new Blob(chunks, { type: mr.mimeType || "audio/webm" });
|
|
const ct = blob.type || "audio/webm";
|
|
const { url, key } = await getRecUploadUrl(word, ct);
|
|
await fetch(url, { method: "PUT", headers: { "Content-Type": ct }, body: blob });
|
|
await saveRecording(day, word, key);
|
|
setBusy(false);
|
|
setSaved(true);
|
|
};
|
|
mr.start();
|
|
setRec(mr);
|
|
} catch {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (rec)
|
|
return (
|
|
<button type="button" onClick={stop} className="flex items-center gap-1 rounded-full bg-rose-500 px-2 py-1 text-xs text-white">
|
|
<Square className="size-3" /> 停止
|
|
</button>
|
|
);
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={start}
|
|
className={cn("flex items-center gap-1 rounded-full px-2 py-1 text-xs", saved ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-600 hover:bg-indigo-100")}
|
|
title="跟读录音"
|
|
>
|
|
<Mic className="size-3" /> {busy ? "上传中" : saved ? "已跟读" : "跟读"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function WordDayClient({
|
|
day,
|
|
items,
|
|
learned,
|
|
test,
|
|
passed,
|
|
score,
|
|
}: {
|
|
day: number;
|
|
items: Item[];
|
|
learned: string[];
|
|
test: TestQ[];
|
|
passed: boolean;
|
|
score: number | null;
|
|
}) {
|
|
const [learnedSet, setLearnedSet] = useState(new Set(learned));
|
|
const [open, setOpen] = useState<string | null>(null);
|
|
const [example, setExample] = useState<Record<string, string>>({});
|
|
const [testOpen, setTestOpen] = useState(false);
|
|
const [answers, setAnswers] = useState<(number | null)[]>(test.map(() => 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) => {
|
|
startTransition(async () => {
|
|
await markLearned(day, word);
|
|
setLearnedSet((s) => {
|
|
const n = new Set(s);
|
|
n.has(word) ? n.delete(word) : n.add(word);
|
|
return n;
|
|
});
|
|
});
|
|
};
|
|
|
|
const openDetail = async (word: string) => {
|
|
setOpen(open === word ? null : word);
|
|
if (open !== word && !example[word]) {
|
|
try {
|
|
const r = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${word}`);
|
|
const j = await r.json();
|
|
const def = j?.[0]?.meanings?.[0]?.definitions?.[0];
|
|
const ex = def?.example || j?.[0]?.meanings?.[0]?.definitions?.[1]?.example || def?.definition || "";
|
|
setExample((e) => ({ ...e, [word]: ex }));
|
|
} catch {
|
|
setExample((e) => ({ ...e, [word]: "" }));
|
|
}
|
|
}
|
|
};
|
|
|
|
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, pointsEarned: r.pointsEarned });
|
|
});
|
|
};
|
|
|
|
return (
|
|
<main className="relative mx-auto min-h-dvh max-w-2xl space-y-4 p-4 pb-12">
|
|
<BackgroundBlobs />
|
|
<header className="flex items-center justify-between">
|
|
<Link href="/words" className="text-sm font-medium text-indigo-600">
|
|
← 单词地图
|
|
</Link>
|
|
<h1 className="text-lg font-extrabold text-slate-900">第 {day} 天 · {items.length} 词</h1>
|
|
<span className="text-xs text-muted-foreground">已学 {learnedSet.size}/{items.length}</span>
|
|
</header>
|
|
|
|
<div className="space-y-2">
|
|
{items.map((it) => {
|
|
const done = learnedSet.has(it.word);
|
|
return (
|
|
<div key={it.word} className="rounded-xl border border-slate-100 bg-white p-3 shadow-soft">
|
|
<div className="flex items-center gap-2">
|
|
<button type="button" onClick={() => speak(it.word)} className="text-indigo-600" title="发音">
|
|
<Volume2 className="size-5" />
|
|
</button>
|
|
<button type="button" onClick={() => openDetail(it.word)} className="text-left">
|
|
<span className="text-base font-bold text-slate-900">{it.word}</span>
|
|
</button>
|
|
<span className="ml-1 flex-1 truncate text-sm text-muted-foreground">{it.meaning}</span>
|
|
<RecordBtn day={day} word={it.word} />
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleLearn(it.word)}
|
|
className={cn(
|
|
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
|
|
done ? "border-transparent bg-gradient-to-br from-emerald-400 to-emerald-600 text-white" : "border-slate-300",
|
|
)}
|
|
title="学会"
|
|
>
|
|
{done && <Check className="size-4" strokeWidth={3} />}
|
|
</button>
|
|
</div>
|
|
{open === it.word && (
|
|
<div className="mt-2 space-y-1 rounded-lg bg-slate-50 p-2 text-sm">
|
|
<p className="text-slate-800">
|
|
<span className="font-medium text-indigo-600">释义:</span>
|
|
{it.meaning || "见例句"}
|
|
</p>
|
|
<p className="text-slate-700">
|
|
<span className="font-medium text-indigo-600">例句:</span>
|
|
{example[it.word] === undefined ? "加载中…" : example[it.word] || "暂无例句"}
|
|
</p>
|
|
<p className="text-slate-700">
|
|
<span className="font-medium text-indigo-600">形近词:</span>
|
|
{it.similar.map((s) => `${s.word}(${s.meaning})`).join("、") || "无"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="flex items-center gap-1 font-extrabold text-slate-900">
|
|
<BookOpen className="size-4" /> 每日检测
|
|
</h2>
|
|
{result && (
|
|
<span className={cn("text-sm font-bold", result.passed ? "text-emerald-600" : "text-rose-600")}>
|
|
{result.score} 分 {result.passed ? "· 已点亮" : ""}
|
|
{result.passed && result.pointsEarned > 0 ? ` · 🪙 +${result.pointsEarned} 积分` : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!testOpen ? (
|
|
<Button className="mt-3 w-full" onClick={() => setTestOpen(true)}>
|
|
开始检测
|
|
</Button>
|
|
) : (
|
|
<div className="mt-3 space-y-3">
|
|
{test.map((q, qi) => (
|
|
<div key={q.word} className="rounded-lg bg-slate-50 p-2">
|
|
<p className="mb-1 text-sm font-medium text-slate-900">
|
|
{qi + 1}. 「{q.word}」的意思是?
|
|
<button type="button" onClick={() => speak(q.word)} className="ml-1 text-indigo-600">
|
|
<Volume2 className="inline size-4" />
|
|
</button>
|
|
</p>
|
|
<div className="grid gap-1">
|
|
{q.options.map((opt, oi) => (
|
|
<button
|
|
key={oi}
|
|
type="button"
|
|
onClick={() => {
|
|
const n = [...answers];
|
|
n[qi] = oi;
|
|
setAnswers(n);
|
|
}}
|
|
className={cn(
|
|
"rounded-md border px-2 py-1 text-left text-sm",
|
|
answers[qi] === oi ? "border-indigo-400 bg-indigo-50" : "border-slate-200 bg-white",
|
|
)}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button className="w-full" disabled={pending || answers.some((a) => a === null)} onClick={submit}>
|
|
提交
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|