课程管理新增单词与数学思维内容编辑:单词词库落库(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
+12
View File
@@ -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`)
);
+7
View File
@@ -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
} }
] ]
} }
+33
View File
@@ -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();
+13 -1
View File
@@ -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">
+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"); 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}
/> />
+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 }> { ): 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) => {
+2 -1
View File
@@ -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">
+13
View File
@@ -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
View File
@@ -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;
}