课程管理新增单词与数学思维内容编辑:单词词库落库(word_entry+0007迁移,表空回退words.json),家长端按天增删改单词;数学思维编辑器对标语法编辑器;课程管理加入两张入口卡片

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-17 17:25:04 +08:00
co-authored by Claude Opus 4.8
parent ab84bec1b4
commit 5f207e4a57
17 changed files with 565 additions and 22 deletions
+13 -1
View File
@@ -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">
+42
View File
@@ -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>
);
}
+34
View File
@@ -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 };
}
+92
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+53
View File
@@ -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 };
}
+84
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+9 -7
View File
@@ -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}
/>
+1 -1
View File
@@ -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) => {
+2 -1
View File
@@ -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">