课程管理新增单词与数学思维内容编辑:单词词库落库(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
@@ -0,0 +1,12 @@
|
|||||||
|
-- xuchao: 单词词库落库,供家长端按天编辑。幂等:已存在则跳过。
|
||||||
|
-- 初始数据由 scripts/seed-words.ts 从 src/content/words/words.json 灌入;
|
||||||
|
-- 表空时 lib/words.ts 自动回退读 JSON,部署顺序不影响孩子端使用。
|
||||||
|
CREATE TABLE IF NOT EXISTS `word_entry` (
|
||||||
|
`id` bigint AUTO_INCREMENT NOT NULL,
|
||||||
|
`word` varchar(60) NOT NULL,
|
||||||
|
`meaning` varchar(300) NOT NULL DEFAULT '',
|
||||||
|
`day` int NOT NULL,
|
||||||
|
`seq` int NOT NULL,
|
||||||
|
CONSTRAINT `word_entry_id` PRIMARY KEY(`id`),
|
||||||
|
KEY `idx_word_entry_day` (`day`)
|
||||||
|
);
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1786064400000,
|
"when": 1786064400000,
|
||||||
"tag": "0006_fill-missing-tables",
|
"tag": "0006_fill-missing-tables",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1786147200000,
|
||||||
|
"tag": "0007_word-entry",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// xuchao: 单词词库种子——words.json 灌入 word_entry;仅表空时全量灌入,已有数据视为家长已编辑,跳过
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import raw from "../src/content/words/words.json";
|
||||||
|
import { wordEntry } from "../src/db/schema";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const conn = await mysql.createConnection(process.env.DATABASE_URL!);
|
||||||
|
const db = drizzle(conn);
|
||||||
|
|
||||||
|
const [cnt] = await db.select({ n: sql<number>`COUNT(*)` }).from(wordEntry);
|
||||||
|
if (cnt.n > 0) {
|
||||||
|
console.log("word_entry 已有", cnt.n, "条,跳过灌入(视为已编辑数据)");
|
||||||
|
await conn.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (raw as { word: string; meaning: string; day: number; seq: number }[]).map((w) => ({
|
||||||
|
word: w.word,
|
||||||
|
meaning: w.meaning,
|
||||||
|
day: w.day,
|
||||||
|
seq: w.seq,
|
||||||
|
}));
|
||||||
|
// 分批插入,避免单条 SQL 过大
|
||||||
|
for (let i = 0; i < rows.length; i += 500) {
|
||||||
|
await db.insert(wordEntry).values(rows.slice(i, i + 500));
|
||||||
|
}
|
||||||
|
console.log("word_entry 灌入完成:", rows.length, "条");
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -113,7 +113,7 @@ export function ParentDashboard({
|
|||||||
<TemplatesTab data={templates} />
|
<TemplatesTab data={templates} />
|
||||||
</Section>
|
</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">
|
||||||
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft transition-transform duration-200 hover:-translate-y-0.5">
|
<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>
|
<p className="mt-1 text-xs text-muted-foreground">改标题/目标/课文,保存即生效</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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">
|
<div className="rounded-xl border border-slate-100 bg-white p-4 shadow-soft">
|
||||||
<p className="font-bold text-slate-900">📝 每周检测成绩</p>
|
<p className="font-bold text-slate-900">📝 每周检测成绩</p>
|
||||||
<div className="mt-2 space-y-1">
|
<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");
|
if (user.role !== "child") redirect("/parent");
|
||||||
|
|
||||||
const day = Number((await params).day);
|
const day = Number((await params).day);
|
||||||
const words = wordsByDay(day);
|
const words = await wordsByDay(day);
|
||||||
if (words.length === 0) redirect("/words");
|
if (words.length === 0) redirect("/words");
|
||||||
|
|
||||||
const [prog] = await db
|
const [prog] = await db
|
||||||
@@ -22,18 +22,20 @@ export default async function WordDayPage({ params }: { params: Promise<{ day: s
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
const learned: string[] = prog?.learned ? JSON.parse(prog.learned) : [];
|
const learned: string[] = prog?.learned ? JSON.parse(prog.learned) : [];
|
||||||
|
|
||||||
const items = words.map((w) => ({
|
const items = await Promise.all(
|
||||||
word: w.word,
|
words.map(async (w) => ({
|
||||||
meaning: w.meaning,
|
word: w.word,
|
||||||
similar: similarWords(w.word, 3).map((s) => ({ word: s.word, meaning: s.meaning })),
|
meaning: w.meaning,
|
||||||
}));
|
similar: (await similarWords(w.word, 3)).map((s) => ({ word: s.word, meaning: s.meaning })),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WordDayClient
|
<WordDayClient
|
||||||
day={day}
|
day={day}
|
||||||
items={items}
|
items={items}
|
||||||
learned={learned}
|
learned={learned}
|
||||||
test={buildWordTest(day)}
|
test={await buildWordTest(day)}
|
||||||
passed={prog?.passed ?? false}
|
passed={prog?.passed ?? false}
|
||||||
score={prog?.score ?? null}
|
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 }> {
|
): Promise<{ score: number; passed: boolean; correct: number; total: number; detail: boolean[] } | { error: string }> {
|
||||||
const u = await child();
|
const u = await child();
|
||||||
if (!u) return { error: "会话无效" };
|
if (!u) return { error: "会话无效" };
|
||||||
const qs = buildWordTest(day);
|
const qs = await buildWordTest(day);
|
||||||
if (qs.length === 0) return { error: "当天暂无单词" };
|
if (qs.length === 0) return { error: "当天暂无单词" };
|
||||||
let correct = 0;
|
let correct = 0;
|
||||||
const detail = qs.map((q, i) => {
|
const detail = qs.map((q, i) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BackgroundBlobs } from "@/components/background-blobs";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { wordProgress } from "@/db/schema";
|
import { wordProgress } from "@/db/schema";
|
||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { TOTAL_WORD_DAYS } from "@/lib/words";
|
import { totalWordDays } from "@/lib/words";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default async function WordsHome() {
|
export default async function WordsHome() {
|
||||||
@@ -18,6 +18,7 @@ export default async function WordsHome() {
|
|||||||
.from(wordProgress)
|
.from(wordProgress)
|
||||||
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true)));
|
.where(and(eq(wordProgress.childUserId, user.id), eq(wordProgress.passed, true)));
|
||||||
const passedSet = new Set(passed.map((p) => p.day));
|
const passedSet = new Set(passed.map((p) => p.day));
|
||||||
|
const TOTAL_WORD_DAYS = await totalWordDays();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative mx-auto min-h-dvh max-w-2xl space-y-5 p-4 pb-10">
|
<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)],
|
(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(
|
export const wordProgress = mysqlTable(
|
||||||
"word_progress",
|
"word_progress",
|
||||||
|
|||||||
+35
-12
@@ -1,13 +1,27 @@
|
|||||||
|
import { asc, sql } from "drizzle-orm";
|
||||||
import raw from "@/content/words/words.json";
|
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 type Word = { word: string; meaning: string; day: number; seq: number };
|
||||||
|
|
||||||
export const WORDS = raw as Word[];
|
// xuchao: 词库以 word_entry 表为准(家长端可编辑);表空回退打包的 words.json,
|
||||||
export const TOTAL_WORD_DAYS = WORDS.reduce((m, w) => Math.max(m, w.day), 0);
|
// 保证未灌种子数据的部署孩子端照常可用
|
||||||
|
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[] {
|
export async function wordsByDay(day: number): Promise<Word[]> {
|
||||||
return WORDS.filter((w) => w.day === day);
|
const ws = await allWords();
|
||||||
|
return ws.filter((w) => w.day === day);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标准发音(有道公共语音,美式 type=2 / 英式 type=1)
|
// 标准发音(有道公共语音,美式 type=2 / 英式 type=1)
|
||||||
@@ -21,21 +35,22 @@ function lev(a: string, b: string): number {
|
|||||||
n = b.length;
|
n = b.length;
|
||||||
if (Math.abs(m - n) > 2) return 99;
|
if (Math.abs(m - n) > 2) return 99;
|
||||||
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
|
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 i = 1; i <= m; i++)
|
||||||
for (let j = 1; j <= n; j++)
|
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));
|
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];
|
return dp[m][n];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每日检测组卷(确定性):选最多 10 词,选中文释义,干扰项用相邻词的释义
|
// 每日检测组卷(确定性):选最多 10 词,选中文释义,干扰项按全库位置偏移取释义
|
||||||
export type WordTestQ = { word: string; options: string[]; answer: number };
|
export type WordTestQ = { word: string; options: string[]; answer: number };
|
||||||
export function buildWordTest(day: number): WordTestQ[] {
|
export async function buildWordTest(day: number): Promise<WordTestQ[]> {
|
||||||
const ws = wordsByDay(day);
|
const ws = await allWords();
|
||||||
const pick = ws.slice(0, 10);
|
const dayWords = ws.filter((w) => w.day === day);
|
||||||
|
const pick = dayWords.slice(0, 10);
|
||||||
return pick.map((w) => {
|
return pick.map((w) => {
|
||||||
|
const idx = ws.indexOf(w);
|
||||||
const distractors = [1, 2, 3]
|
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)
|
.filter((m) => m && m !== w.meaning)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const options = [w.meaning || w.word, ...distractors];
|
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[] {
|
export async function similarWords(word: string, n = 3): Promise<Word[]> {
|
||||||
return WORDS.filter((w) => w.word !== word)
|
const ws = await allWords();
|
||||||
|
return ws
|
||||||
|
.filter((w) => w.word !== word)
|
||||||
.map((w) => ({ w, d: lev(word, w.word) }))
|
.map((w) => ({ w, d: lev(word, w.word) }))
|
||||||
.sort((a, b) => a.d - b.d)
|
.sort((a, b) => a.d - b.d)
|
||||||
.slice(0, n)
|
.slice(0, n)
|
||||||
.map((x) => x.w);
|
.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