新增英语单词打卡模块:3694词/247天,发音/详情/跟读录音/每日检测
- 数据源:解析 3500 词 PDF 得 3694 词,按每天 15 词分 247 天,覆盖全部单词 - 孩子端 /words 地图 + 当天学习页:点词标准发音(有道语音)、详情(释义/ 形近词/例句)、学会标记、跟读录音(MediaRecorder 直传 OSS)、每日检测 (选释义,达标点亮并计入英语打卡) - 家长端课程管理可回放孩子跟读录音 - 形近词运行时编辑距离计算;例句按需取词典 API Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
38a64b4ec1
commit
2bbf349e0c
@@ -64,6 +64,15 @@ export default async function KidPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<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="/words" className="block sm:col-span-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||||
|
<span className="text-xl">🔤</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-slate-900">单词打卡</p>
|
||||||
|
<p className="text-xs text-muted-foreground">每天 15 词,发音/详情/跟读/检测</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
<Link href="/weekly" className="block">
|
<Link href="/weekly" className="block">
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
<div className="flex items-center gap-2 rounded-xl border border-slate-100 bg-white p-3 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||||
<span className="text-xl">📝</span>
|
<span className="text-xl">📝</span>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export type DevicesData = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
export type WeeklyRow = { childName: string; weekKey: string; score: number; passed: boolean };
|
export type WeeklyRow = { childName: string; weekKey: string; score: number; passed: boolean };
|
||||||
|
export type RecordingRow = { childName: string; word: string; url: string; at: string };
|
||||||
|
|
||||||
const tabCls = "data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft";
|
const tabCls = "data-[state=active]:bg-white data-[state=active]:text-indigo-600 data-[state=active]:shadow-soft";
|
||||||
|
|
||||||
@@ -72,11 +73,13 @@ export function ParentDashboard({
|
|||||||
templates,
|
templates,
|
||||||
devices,
|
devices,
|
||||||
weekly,
|
weekly,
|
||||||
|
recordings,
|
||||||
}: {
|
}: {
|
||||||
overview: OverviewData;
|
overview: OverviewData;
|
||||||
templates: TemplatesData;
|
templates: TemplatesData;
|
||||||
devices: DevicesData;
|
devices: DevicesData;
|
||||||
weekly: WeeklyRow[];
|
weekly: WeeklyRow[];
|
||||||
|
recordings: RecordingRow[];
|
||||||
}) {
|
}) {
|
||||||
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">
|
||||||
@@ -130,6 +133,19 @@ export function ParentDashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft sm:col-span-2">
|
||||||
|
<p className="font-bold text-slate-900">🎙️ 单词跟读录音</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{recordings.length === 0 && <p className="text-xs text-muted-foreground">暂无跟读录音</p>}
|
||||||
|
{recordings.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-16 shrink-0 text-xs text-muted-foreground">{r.childName}</span>
|
||||||
|
<span className="w-20 shrink-0 font-medium text-slate-800">{r.word}</span>
|
||||||
|
<audio controls src={r.url} className="h-8 flex-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -11,10 +11,12 @@ import {
|
|||||||
grammarProgress,
|
grammarProgress,
|
||||||
weeklyTest,
|
weeklyTest,
|
||||||
wrongBook,
|
wrongBook,
|
||||||
|
wordRecording,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
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 { viewUrl } from "@/lib/oss";
|
||||||
import {
|
import {
|
||||||
ParentDashboard,
|
ParentDashboard,
|
||||||
type DevicesData,
|
type DevicesData,
|
||||||
@@ -168,5 +170,27 @@ export default async function ParentPage() {
|
|||||||
passed: w.passed,
|
passed: w.passed,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <ParentDashboard overview={overview} templates={templates} devices={devices} weekly={weekly} />;
|
// ===== 单词跟读录音(课程管理回放) =====
|
||||||
|
const recRows = await db
|
||||||
|
.select({
|
||||||
|
childName: users.displayName,
|
||||||
|
word: wordRecording.word,
|
||||||
|
ossKey: wordRecording.ossKey,
|
||||||
|
createdAt: wordRecording.createdAt,
|
||||||
|
})
|
||||||
|
.from(wordRecording)
|
||||||
|
.innerJoin(users, eq(users.id, wordRecording.childUserId))
|
||||||
|
.where(eq(users.familyId, parent.familyId))
|
||||||
|
.orderBy(desc(wordRecording.createdAt))
|
||||||
|
.limit(20);
|
||||||
|
const recordings = recRows.map((r) => ({
|
||||||
|
childName: r.childName,
|
||||||
|
word: r.word,
|
||||||
|
url: viewUrl(r.ossKey),
|
||||||
|
at: dstr(r.createdAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParentDashboard overview={overview} templates={templates} devices={devices} weekly={weekly} recordings={recordings} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { wordProgress } from "@/db/schema";
|
||||||
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
import { buildWordTest, similarWords, wordsByDay } from "@/lib/words";
|
||||||
|
import { WordDayClient } from "../word-day";
|
||||||
|
|
||||||
|
export default async function WordDayPage({ params }: { params: Promise<{ day: string }> }) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/join");
|
||||||
|
if (user.role !== "child") redirect("/parent");
|
||||||
|
|
||||||
|
const day = Number((await params).day);
|
||||||
|
const words = wordsByDay(day);
|
||||||
|
if (words.length === 0) redirect("/words");
|
||||||
|
|
||||||
|
const [prog] = await db
|
||||||
|
.select()
|
||||||
|
.from(wordProgress)
|
||||||
|
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.day, day)))
|
||||||
|
.limit(1);
|
||||||
|
const learned: string[] = prog?.learned ? JSON.parse(prog.learned) : [];
|
||||||
|
|
||||||
|
const items = words.map((w) => ({
|
||||||
|
word: w.word,
|
||||||
|
meaning: w.meaning,
|
||||||
|
similar: similarWords(w.word, 3).map((s) => ({ word: s.word, meaning: s.meaning })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WordDayClient
|
||||||
|
day={day}
|
||||||
|
items={items}
|
||||||
|
learned={learned}
|
||||||
|
test={buildWordTest(day)}
|
||||||
|
passed={prog?.passed ?? false}
|
||||||
|
score={prog?.score ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { taskInstances, wordProgress, wordRecording } from "@/db/schema";
|
||||||
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
import { buildWordTest } from "@/lib/words";
|
||||||
|
import { uploadUrl } from "@/lib/oss";
|
||||||
|
import { todayStr } from "@/lib/dates";
|
||||||
|
|
||||||
|
// 跟读录音直传签名(浏览器直接 PUT 到对象存储)
|
||||||
|
export async function getRecUploadUrl(word: string): Promise<{ url: string; key: string }> {
|
||||||
|
const key = `recordings/${word}-${Date.now()}.webm`;
|
||||||
|
return { url: uploadUrl(key, "audio/webm"), key };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function child() {
|
||||||
|
const u = await getSessionUser();
|
||||||
|
if (!u || u.role !== "child") return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记某词已学会
|
||||||
|
export async function markLearned(day: number, word: string): Promise<void> {
|
||||||
|
const u = await child();
|
||||||
|
if (!u) return;
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(wordProgress)
|
||||||
|
.where(and(eq(wordProgress.childUserId, u.id), eq(wordProgress.day, day)))
|
||||||
|
.limit(1);
|
||||||
|
const learned: string[] = row?.learned ? JSON.parse(row.learned) : [];
|
||||||
|
if (!learned.includes(word)) learned.push(word);
|
||||||
|
if (row)
|
||||||
|
await db.update(wordProgress).set({ learned: JSON.stringify(learned) }).where(eq(wordProgress.id, row.id));
|
||||||
|
else await db.insert(wordProgress).values({ childUserId: u.id, day, learned: JSON.stringify(learned) });
|
||||||
|
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 }> {
|
||||||
|
const u = await child();
|
||||||
|
if (!u) return { error: "会话无效" };
|
||||||
|
const qs = buildWordTest(day);
|
||||||
|
if (qs.length === 0) return { error: "当天暂无单词" };
|
||||||
|
let correct = 0;
|
||||||
|
const detail = qs.map((q, i) => {
|
||||||
|
const ok = answers[i] === q.answer;
|
||||||
|
if (ok) correct++;
|
||||||
|
return ok;
|
||||||
|
});
|
||||||
|
const score = Math.round((correct / qs.length) * 100);
|
||||||
|
const passed = score >= 80;
|
||||||
|
await db
|
||||||
|
.insert(wordProgress)
|
||||||
|
.values({ childUserId: u.id, day, score, passed, completedAt: new Date() })
|
||||||
|
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
|
||||||
|
|
||||||
|
if (passed) {
|
||||||
|
const today = todayStr();
|
||||||
|
const title = `英语单词 · 第${day}天`;
|
||||||
|
const [ex] = await db
|
||||||
|
.select({ id: taskInstances.id, status: taskInstances.status })
|
||||||
|
.from(taskInstances)
|
||||||
|
.where(and(eq(taskInstances.childUserId, u.id), eq(taskInstances.date, today), eq(taskInstances.title, title)))
|
||||||
|
.limit(1);
|
||||||
|
if (ex) {
|
||||||
|
if (ex.status !== "done")
|
||||||
|
await db.update(taskInstances).set({ status: "done", doneAt: sql`CURRENT_TIMESTAMP` }).where(eq(taskInstances.id, ex.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(taskInstances).values({
|
||||||
|
childUserId: u.id,
|
||||||
|
date: today,
|
||||||
|
title,
|
||||||
|
subject: "english",
|
||||||
|
status: "done",
|
||||||
|
doneAt: new Date(),
|
||||||
|
sortOrder: 97,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revalidatePath("/words");
|
||||||
|
revalidatePath("/words/[day]", "layout");
|
||||||
|
revalidatePath("/kid");
|
||||||
|
revalidatePath("/parent");
|
||||||
|
return { score, passed, correct, total: qs.length, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存跟读录音(已直传 OSS,这里登记)
|
||||||
|
export async function saveRecording(day: number, word: string, ossKey: string): Promise<void> {
|
||||||
|
const u = await child();
|
||||||
|
if (!u) return;
|
||||||
|
await db.insert(wordRecording).values({ childUserId: u.id, word, day, ossKey });
|
||||||
|
revalidatePath("/parent");
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { wordProgress } from "@/db/schema";
|
||||||
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
import { TOTAL_WORD_DAYS } from "@/lib/words";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default async function WordsHome() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/join");
|
||||||
|
if (user.role !== "child") redirect("/parent");
|
||||||
|
|
||||||
|
const passed = await db
|
||||||
|
.select({ day: wordProgress.day })
|
||||||
|
.from(wordProgress)
|
||||||
|
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true)));
|
||||||
|
const passedSet = new Set(passed.map((p) => p.day));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative mx-auto min-h-dvh max-w-2xl space-y-5 p-4 pb-10">
|
||||||
|
<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="text-xs text-muted-foreground">
|
||||||
|
已点亮 {passedSet.size}/{TOTAL_WORD_DAYS} 天
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
|
||||||
|
每天 15 个单词,点词可发音、看详情;学会后做检测,达标点亮并计入打卡。
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-5 gap-1.5 sm:grid-cols-8">
|
||||||
|
{Array.from({ length: TOTAL_WORD_DAYS }, (_, i) => i + 1).map((d) => (
|
||||||
|
<Link
|
||||||
|
key={d}
|
||||||
|
href={`/words/${d}`}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-square items-center justify-center rounded-lg border text-sm transition-colors",
|
||||||
|
passedSet.has(d)
|
||||||
|
? "border-transparent bg-gradient-to-br from-emerald-400 to-emerald-600 font-bold text-white"
|
||||||
|
: "border-slate-200 bg-white text-slate-600 hover:border-indigo-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"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 });
|
||||||
|
const mr = new MediaRecorder(stream);
|
||||||
|
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 { url, key } = await getRecUploadUrl(word);
|
||||||
|
await fetch(url, { method: "PUT", headers: { "Content-Type": blob.type || "audio/webm" }, 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 } | null>(passed ? { score: score ?? 0, passed } : 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 });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ? "· 已点亮" : ""}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -181,6 +181,39 @@ export const wrongBook = mysqlTable(
|
|||||||
(t) => [index("idx_wrong_child").on(t.childUserId, t.mastered)],
|
(t) => [index("idx_wrong_child").on(t.childUserId, t.mastered)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===== 单词每日进度(学会的词+检测成绩)=====
|
||||||
|
export const wordProgress = mysqlTable(
|
||||||
|
"word_progress",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||||
|
childUserId: bigint("child_user_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
day: int("day").notNull(),
|
||||||
|
learned: text("learned"), // JSON 数组:已学会的单词
|
||||||
|
score: int("score"),
|
||||||
|
passed: boolean("passed").notNull().default(false),
|
||||||
|
completedAt: datetime("completed_at"),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("uq_word_child_day").on(t.childUserId, t.day)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 单词跟读录音(存对象存储,供家长回放)=====
|
||||||
|
export const wordRecording = mysqlTable(
|
||||||
|
"word_recording",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||||
|
childUserId: bigint("child_user_id", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
word: varchar("word", { length: 60 }).notNull(),
|
||||||
|
day: int("day"),
|
||||||
|
ossKey: varchar("oss_key", { length: 300 }).notNull(),
|
||||||
|
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_rec_child").on(t.childUserId)],
|
||||||
|
);
|
||||||
|
|
||||||
// ===== 每周能力检测记录 =====
|
// ===== 每周能力检测记录 =====
|
||||||
export const weeklyTest = mysqlTable(
|
export const weeklyTest = mysqlTable(
|
||||||
"weekly_test",
|
"weekly_test",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import raw from "@/content/words/words.json";
|
||||||
|
|
||||||
|
export type Word = { word: string; meaning: string; day: number; seq: number };
|
||||||
|
|
||||||
|
export const WORDS = raw as Word[];
|
||||||
|
export const TOTAL_WORD_DAYS = WORDS.reduce((m, w) => Math.max(m, w.day), 0);
|
||||||
|
|
||||||
|
// 某一天的单词
|
||||||
|
export function wordsByDay(day: number): Word[] {
|
||||||
|
return WORDS.filter((w) => w.day === day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准发音(有道公共语音,美式 type=2 / 英式 type=1)
|
||||||
|
export function audioUrl(word: string, type: 1 | 2 = 2): string {
|
||||||
|
return `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(word)}&type=${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑距离(形近词计算)
|
||||||
|
function lev(a: string, b: string): number {
|
||||||
|
const m = a.length,
|
||||||
|
n = b.length;
|
||||||
|
if (Math.abs(m - n) > 2) return 99;
|
||||||
|
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
|
||||||
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||||
|
for (let i = 1; i <= m; i++)
|
||||||
|
for (let j = 1; j <= n; j++)
|
||||||
|
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||||
|
return dp[m][n];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每日检测组卷(确定性):选最多 10 词,选中文释义,干扰项用相邻词的释义
|
||||||
|
export type WordTestQ = { word: string; options: string[]; answer: number };
|
||||||
|
export function buildWordTest(day: number): WordTestQ[] {
|
||||||
|
const ws = wordsByDay(day);
|
||||||
|
const pick = ws.slice(0, 10);
|
||||||
|
return pick.map((w) => {
|
||||||
|
const distractors = [1, 2, 3]
|
||||||
|
.map((off) => WORDS[(w.seq - 1 + off * 37) % WORDS.length].meaning)
|
||||||
|
.filter((m) => m && m !== w.meaning)
|
||||||
|
.slice(0, 3);
|
||||||
|
const options = [w.meaning || w.word, ...distractors];
|
||||||
|
// 打乱(确定性:按 seq)
|
||||||
|
const rnd = (w.seq * 2654435761) % 4;
|
||||||
|
const rotated = [...options.slice(rnd), ...options.slice(0, rnd)];
|
||||||
|
return { word: w.word, options: rotated, answer: rotated.indexOf(w.meaning || w.word) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function similarWords(word: string, n = 3): Word[] {
|
||||||
|
return WORDS.filter((w) => w.word !== word)
|
||||||
|
.map((w) => ({ w, d: lev(word, w.word) }))
|
||||||
|
.sort((a, b) => a.d - b.d)
|
||||||
|
.slice(0, n)
|
||||||
|
.map((x) => x.w);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user