课程管理新增单词与数学思维内容编辑:单词词库落库(word_entry+0007迁移,表空回退words.json),家长端按天增删改单词;数学思维编辑器对标语法编辑器;课程管理加入两张入口卡片
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ab84bec1b4
commit
5f207e4a57
@@ -113,7 +113,7 @@ export function ParentDashboard({
|
||||
<TemplatesTab data={templates} />
|
||||
</Section>
|
||||
|
||||
<Section title="课程管理" desc="英语语法课程编辑与每周检测成绩">
|
||||
<Section title="课程管理" desc="英语语法/单词、数学思维课程编辑与每周检测成绩">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Link href="/parent/grammar" className="block">
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
@@ -121,6 +121,18 @@ export function ParentDashboard({
|
||||
<p className="mt-1 text-xs text-muted-foreground">改标题/目标/课文,保存即生效</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/parent/words" className="block">
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<p className="font-bold text-slate-900">📖 编辑英语单词</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">按天增删改单词与释义,保存即生效</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/parent/math" className="block">
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<p className="font-bold text-slate-900">🧮 编辑数学思维课程</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">改标题/目标/课文,保存即生效</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||
<p className="font-bold text-slate-900">📝 每周检测成绩</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { getMathPoint } from "@/lib/math";
|
||||
import { EditorForm } from "../editor-form";
|
||||
|
||||
// xuchao: 单个数学知识点编辑页
|
||||
export default async function MathEditPage({ params }: { params: Promise<{ pointId: string }> }) {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent) redirect("/login");
|
||||
if (parent.role !== "parent") redirect("/kid");
|
||||
|
||||
const pointId = (await params).pointId;
|
||||
const point = await getMathPoint(pointId);
|
||||
if (!point) redirect("/parent/math");
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-3xl space-y-5 p-4 pb-10">
|
||||
<BackgroundBlobs />
|
||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link href="/parent/math" className="text-sm font-medium text-indigo-600">
|
||||
← 课程列表
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">{point.moduleName}</Badge>
|
||||
<h1 className="text-lg font-extrabold text-slate-900">{point.title}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
|
||||
<EditorForm
|
||||
pointId={point.pointId}
|
||||
initialTitle={point.title}
|
||||
initialSummary={point.summary}
|
||||
initialMd={point.md}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { mathPoints } from "@/db/schema";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// xuchao: 家长编辑数学知识点(标题/摘要/课文 markdown)
|
||||
export async function updateMathPoint(
|
||||
pointId: string,
|
||||
data: { title: string; summary: string; md: string },
|
||||
): Promise<{ ok?: true; error?: string }> {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
|
||||
if (!data.title.trim()) return { error: "标题不能为空" };
|
||||
if (!data.md.trim()) return { error: "课文内容不能为空" };
|
||||
|
||||
await db
|
||||
.update(mathPoints)
|
||||
.set({
|
||||
title: data.title.trim(),
|
||||
summary: data.summary,
|
||||
md: data.md,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(mathPoints.pointId, pointId));
|
||||
|
||||
revalidatePath("/math");
|
||||
revalidatePath(`/math/${pointId}`);
|
||||
revalidatePath("/parent/math");
|
||||
revalidatePath(`/parent/math/${pointId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Eye, EyeOff, Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Md } from "@/app/learn/md";
|
||||
import { extractQuiz } from "@/lib/quiz-parse";
|
||||
import { updateMathPoint } from "./actions";
|
||||
|
||||
// xuchao: 数学课程编辑器——结构同语法编辑器:改标题/摘要/课文 markdown,可预览,保存即生效
|
||||
export function EditorForm({
|
||||
pointId,
|
||||
initialTitle,
|
||||
initialSummary,
|
||||
initialMd,
|
||||
}: {
|
||||
pointId: string;
|
||||
initialTitle: string;
|
||||
initialSummary: string | null;
|
||||
initialMd: string;
|
||||
}) {
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [summary, setSummary] = useState(initialSummary ?? "");
|
||||
const [md, setMd] = useState(initialMd);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const quizCount = extractQuiz(md).length;
|
||||
|
||||
const save = () => {
|
||||
setMsg(null);
|
||||
startTransition(async () => {
|
||||
const r = await updateMathPoint(pointId, { title, summary, md });
|
||||
if (r.error) setMsg({ text: r.error });
|
||||
else setMsg({ ok: true, text: "已保存,孩子端立即生效" });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>标题</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>学习目标(一句话)</Label>
|
||||
<Input value={summary} onChange={(e) => setSummary(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>课文内容(Markdown,含 ```quiz 练习块)</Label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>检测到练习题 {quizCount} 道</span>
|
||||
<Button variant="outline" size="sm" type="button" onClick={() => setPreview((p) => !p)}>
|
||||
{preview ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
{preview ? "编辑" : "预览"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{preview ? (
|
||||
<div className="max-h-[480px] overflow-y-auto rounded-xl border border-slate-100 bg-white p-4">
|
||||
<Md text={md} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={md}
|
||||
onChange={(e) => setMd(e.target.value)}
|
||||
rows={18}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white p-3 font-mono text-xs leading-relaxed focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="lg" disabled={pending} onClick={save}>
|
||||
<Save className="size-4" /> 保存
|
||||
</Button>
|
||||
{msg && (
|
||||
<p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { listMathPoints, MODULES } from "@/lib/math";
|
||||
|
||||
// xuchao: 数学思维课程编辑列表——按七大模块分组,点击进入单课编辑
|
||||
export default async function MathAdmin() {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent) redirect("/login");
|
||||
if (parent.role !== "parent") redirect("/kid");
|
||||
|
||||
const points = await listMathPoints();
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
|
||||
<BackgroundBlobs />
|
||||
<header className="flex items-center justify-between">
|
||||
<Link href="/parent" className="text-sm font-medium text-indigo-600">
|
||||
← 返回家长中心
|
||||
</Link>
|
||||
<h1 className="text-xl font-extrabold text-slate-900">数学思维课程编辑</h1>
|
||||
</header>
|
||||
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
|
||||
点击任意课程即可修改标题、目标与课文内容,保存后孩子端立即生效。
|
||||
</p>
|
||||
|
||||
{MODULES.map((mod) => {
|
||||
const group = points.filter((p) => p.module === mod.id);
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<section key={mod.id} className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||
<h2 className="mb-3 text-lg font-extrabold text-slate-900">
|
||||
{mod.name}
|
||||
<span className="ml-1 text-amber-500">{"★".repeat(mod.stars)}</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{group.map((p) => (
|
||||
<Link
|
||||
key={p.pointId}
|
||||
href={`/parent/math/${p.pointId}`}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-300"
|
||||
>
|
||||
<Pencil className="size-3.5 shrink-0 text-indigo-400" />
|
||||
<span className="truncate">{p.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { wordsByDay } from "@/lib/words";
|
||||
import { DayForm } from "../day-form";
|
||||
|
||||
// xuchao: 某一天的单词编辑页
|
||||
export default async function WordsDayAdmin({ params }: { params: Promise<{ day: string }> }) {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent) redirect("/login");
|
||||
if (parent.role !== "parent") redirect("/kid");
|
||||
|
||||
const day = Number((await params).day);
|
||||
if (!Number.isInteger(day) || day < 1) redirect("/parent/words");
|
||||
const words = await wordsByDay(day);
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
|
||||
<BackgroundBlobs />
|
||||
<header className="flex items-center justify-between">
|
||||
<Link href="/parent/words" className="text-sm font-medium text-indigo-600">
|
||||
← 天数列表
|
||||
</Link>
|
||||
<h1 className="text-xl font-extrabold text-slate-900">第 {day} 天单词</h1>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-5 shadow-soft">
|
||||
<DayForm day={day} initial={words.map((w) => ({ word: w.word, meaning: w.meaning }))} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import raw from "@/content/words/words.json";
|
||||
import { db } from "@/db";
|
||||
import { wordEntry } from "@/db/schema";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
|
||||
// xuchao: 整日保存单词——事务内删旧插新;保留词沿用原 seq,新词接续全库最大 seq
|
||||
export async function saveDayWords(
|
||||
day: number,
|
||||
words: { word: string; meaning: string }[],
|
||||
): Promise<{ ok?: true; error?: string }> {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" };
|
||||
if (!Number.isInteger(day) || day < 1) return { error: "天数无效" };
|
||||
const cleaned = words
|
||||
.map((w) => ({ word: w.word.trim(), meaning: w.meaning.trim() }))
|
||||
.filter((w) => w.word);
|
||||
if (cleaned.length === 0) return { error: "至少保留一个单词" };
|
||||
if (new Set(cleaned.map((w) => w.word.toLowerCase())).size !== cleaned.length)
|
||||
return { error: "同一天内单词不能重复" };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// 首次编辑(表还没灌种子):先全量灌入打包词库,否则只存一天会丢失其余天
|
||||
const [cnt] = await tx.select({ n: sql<number>`COUNT(*)` }).from(wordEntry);
|
||||
if (cnt.n === 0) {
|
||||
const rows = raw as { word: string; meaning: string; day: number; seq: number }[];
|
||||
for (let i = 0; i < rows.length; i += 500) await tx.insert(wordEntry).values(rows.slice(i, i + 500));
|
||||
}
|
||||
|
||||
const oldRows = await tx.select().from(wordEntry).where(eq(wordEntry.day, day));
|
||||
const seqByWord = new Map(oldRows.map((r) => [r.word, r.seq]));
|
||||
await tx.delete(wordEntry).where(eq(wordEntry.day, day));
|
||||
const [mx] = await tx.select({ m: sql<number>`COALESCE(MAX(seq),0)` }).from(wordEntry);
|
||||
let seq = mx.m;
|
||||
|
||||
await tx.insert(wordEntry).values(
|
||||
cleaned.map((w) => ({
|
||||
word: w.word,
|
||||
meaning: w.meaning,
|
||||
day,
|
||||
seq: seqByWord.get(w.word) ?? ++seq,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
revalidatePath("/words");
|
||||
revalidatePath(`/words/${day}`);
|
||||
revalidatePath("/parent/words");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Save, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { saveDayWords } from "./actions";
|
||||
|
||||
// xuchao: 按天编辑单词——逐词行编辑,可增删行,整日保存
|
||||
export function DayForm({
|
||||
day,
|
||||
initial,
|
||||
}: {
|
||||
day: number;
|
||||
initial: { word: string; meaning: string }[];
|
||||
}) {
|
||||
const [rows, setRows] = useState(initial);
|
||||
const [msg, setMsg] = useState<{ ok?: boolean; text: string } | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const set = (i: number, key: "word" | "meaning", v: string) => {
|
||||
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, [key]: v } : r)));
|
||||
setMsg(null);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
setMsg(null);
|
||||
startTransition(async () => {
|
||||
const r = await saveDayWords(day, rows);
|
||||
if (r.error) setMsg({ text: r.error });
|
||||
else setMsg({ ok: true, text: "已保存,孩子端立即生效" });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={r.word}
|
||||
onChange={(e) => set(i, "word", e.target.value)}
|
||||
placeholder="单词"
|
||||
className="w-36 shrink-0 font-medium"
|
||||
/>
|
||||
<Input value={r.meaning} onChange={(e) => set(i, "meaning", e.target.value)} placeholder="中文释义" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除该词"
|
||||
onClick={() => {
|
||||
setRows((rs) => rs.filter((_, j) => j !== i));
|
||||
setMsg(null);
|
||||
}}
|
||||
className="shrink-0 rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRows((rs) => [...rs, { word: "", meaning: "" }]);
|
||||
setMsg(null);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" /> 添加单词
|
||||
</Button>
|
||||
<Button size="lg" disabled={pending} onClick={save}>
|
||||
<Save className="size-4" /> 保存第 {day} 天
|
||||
</Button>
|
||||
{msg && (
|
||||
<p className={msg.ok ? "text-sm font-medium text-emerald-600" : "text-sm font-medium text-destructive"}>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { allWords } from "@/lib/words";
|
||||
|
||||
// xuchao: 单词词库编辑列表——按天网格,格内显示当天词数,点击进入当天编辑
|
||||
export default async function WordsAdmin() {
|
||||
const parent = await getSessionUser();
|
||||
if (!parent) redirect("/login");
|
||||
if (parent.role !== "parent") redirect("/kid");
|
||||
|
||||
const ws = await allWords();
|
||||
const counts = new Map<number, number>();
|
||||
let totalDays = 0;
|
||||
for (const w of ws) {
|
||||
counts.set(w.day, (counts.get(w.day) ?? 0) + 1);
|
||||
totalDays = Math.max(totalDays, w.day);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto max-w-2xl space-y-5 p-4 pb-10">
|
||||
<BackgroundBlobs />
|
||||
<header className="flex items-center justify-between">
|
||||
<Link href="/parent" className="text-sm font-medium text-indigo-600">
|
||||
← 返回家长中心
|
||||
</Link>
|
||||
<h1 className="text-xl font-extrabold text-slate-900">英语单词编辑</h1>
|
||||
</header>
|
||||
<p className="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-3 py-2 text-sm text-indigo-800">
|
||||
共 {ws.length} 词 / {totalDays} 天。点击某一天可增删改当天单词,保存后孩子端立即生效。
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-7">
|
||||
{Array.from({ length: totalDays }, (_, i) => i + 1).map((d) => (
|
||||
<Link
|
||||
key={d}
|
||||
href={`/parent/words/${d}`}
|
||||
className="flex flex-col items-center justify-center rounded-lg border border-slate-200 bg-white py-2 text-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-300"
|
||||
>
|
||||
<span className="font-bold text-slate-800">第{d}天</span>
|
||||
<span className="text-xs text-muted-foreground">{counts.get(d) ?? 0} 词</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export default async function WordDayPage({ params }: { params: Promise<{ day: s
|
||||
if (user.role !== "child") redirect("/parent");
|
||||
|
||||
const day = Number((await params).day);
|
||||
const words = wordsByDay(day);
|
||||
const words = await wordsByDay(day);
|
||||
if (words.length === 0) redirect("/words");
|
||||
|
||||
const [prog] = await db
|
||||
@@ -22,18 +22,20 @@ export default async function WordDayPage({ params }: { params: Promise<{ day: s
|
||||
.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 })),
|
||||
}));
|
||||
const items = await Promise.all(
|
||||
words.map(async (w) => ({
|
||||
word: w.word,
|
||||
meaning: w.meaning,
|
||||
similar: (await similarWords(w.word, 3)).map((s) => ({ word: s.word, meaning: s.meaning })),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<WordDayClient
|
||||
day={day}
|
||||
items={items}
|
||||
learned={learned}
|
||||
test={buildWordTest(day)}
|
||||
test={await buildWordTest(day)}
|
||||
passed={prog?.passed ?? false}
|
||||
score={prog?.score ?? null}
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function submitWordTest(
|
||||
): 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);
|
||||
const qs = await buildWordTest(day);
|
||||
if (qs.length === 0) return { error: "当天暂无单词" };
|
||||
let correct = 0;
|
||||
const detail = qs.map((q, i) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { totalWordDays } from "@/lib/words";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default async function WordsHome() {
|
||||
@@ -18,6 +18,7 @@ export default async function WordsHome() {
|
||||
.from(wordProgress)
|
||||
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true)));
|
||||
const passedSet = new Set(passed.map((p) => p.day));
|
||||
const TOTAL_WORD_DAYS = await totalWordDays();
|
||||
|
||||
return (
|
||||
<main className="relative mx-auto min-h-dvh max-w-2xl space-y-5 p-4 pb-10">
|
||||
|
||||
@@ -181,6 +181,19 @@ export const wrongBook = mysqlTable(
|
||||
(t) => [index("idx_wrong_child").on(t.childUserId, t.mastered)],
|
||||
);
|
||||
|
||||
// ===== 单词词库(内容存库,家长端可按天编辑;初始由 words.json 灌入)=====
|
||||
export const wordEntry = mysqlTable(
|
||||
"word_entry",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
word: varchar("word", { length: 60 }).notNull(),
|
||||
meaning: varchar("meaning", { length: 300 }).notNull().default(""),
|
||||
day: int("day").notNull(), // 第几天学习
|
||||
seq: int("seq").notNull(), // 全局顺序(组卷干扰项取材用)
|
||||
},
|
||||
(t) => [index("idx_word_entry_day").on(t.day)],
|
||||
);
|
||||
|
||||
// ===== 单词每日进度(学会的词+检测成绩)=====
|
||||
export const wordProgress = mysqlTable(
|
||||
"word_progress",
|
||||
|
||||
+35
-12
@@ -1,13 +1,27 @@
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
import raw from "@/content/words/words.json";
|
||||
import { db } from "@/db";
|
||||
import { wordEntry } from "@/db/schema";
|
||||
|
||||
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);
|
||||
// xuchao: 词库以 word_entry 表为准(家长端可编辑);表空回退打包的 words.json,
|
||||
// 保证未灌种子数据的部署孩子端照常可用
|
||||
export async function allWords(): Promise<Word[]> {
|
||||
const rows = await db.select().from(wordEntry).orderBy(asc(wordEntry.seq));
|
||||
if (rows.length > 0) return rows.map((r) => ({ word: r.word, meaning: r.meaning, day: r.day, seq: r.seq }));
|
||||
return raw as Word[];
|
||||
}
|
||||
|
||||
export async function totalWordDays(): Promise<number> {
|
||||
const ws = await allWords();
|
||||
return ws.reduce((m, w) => Math.max(m, w.day), 0);
|
||||
}
|
||||
|
||||
// 某一天的单词
|
||||
export function wordsByDay(day: number): Word[] {
|
||||
return WORDS.filter((w) => w.day === day);
|
||||
export async function wordsByDay(day: number): Promise<Word[]> {
|
||||
const ws = await allWords();
|
||||
return ws.filter((w) => w.day === day);
|
||||
}
|
||||
|
||||
// 标准发音(有道公共语音,美式 type=2 / 英式 type=1)
|
||||
@@ -21,21 +35,22 @@ function lev(a: string, b: string): number {
|
||||
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 词,选中文释义,干扰项用相邻词的释义
|
||||
// 每日检测组卷(确定性):选最多 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);
|
||||
export async function buildWordTest(day: number): Promise<WordTestQ[]> {
|
||||
const ws = await allWords();
|
||||
const dayWords = ws.filter((w) => w.day === day);
|
||||
const pick = dayWords.slice(0, 10);
|
||||
return pick.map((w) => {
|
||||
const idx = ws.indexOf(w);
|
||||
const distractors = [1, 2, 3]
|
||||
.map((off) => WORDS[(w.seq - 1 + off * 37) % WORDS.length].meaning)
|
||||
.map((off) => ws[(idx + off * 37) % ws.length].meaning)
|
||||
.filter((m) => m && m !== w.meaning)
|
||||
.slice(0, 3);
|
||||
const options = [w.meaning || w.word, ...distractors];
|
||||
@@ -46,10 +61,18 @@ export function buildWordTest(day: number): WordTestQ[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function similarWords(word: string, n = 3): Word[] {
|
||||
return WORDS.filter((w) => w.word !== word)
|
||||
export async function similarWords(word: string, n = 3): Promise<Word[]> {
|
||||
const ws = await allWords();
|
||||
return ws
|
||||
.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);
|
||||
}
|
||||
|
||||
// 词库总数(家长端展示用)
|
||||
export async function countWordEntries(): Promise<number> {
|
||||
const [r] = await db.select({ n: sql<number>`COUNT(*)` }).from(wordEntry);
|
||||
return r.n;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user