feat: add word import and class type display controls
Build Docker Package / docker-package (push) Successful in 6m34s

This commit is contained in:
2026-07-02 14:34:00 +08:00
parent 0378504be0
commit 17fc24edc7
16 changed files with 844 additions and 66 deletions
+274 -20
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import {useEffect, useMemo, useState} from "react"; import {useEffect, useMemo, useRef, useState} from "react";
import Link from "next/link"; import Link from "next/link";
import {toast} from "sonner"; import {toast} from "sonner";
import { import {
Download,
Eye, Eye,
FileSpreadsheet,
Gamepad2, Gamepad2,
Mic2, Mic2,
Pencil, Pencil,
@@ -17,6 +19,7 @@ import {
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
Trash2, Trash2,
Upload,
} from "lucide-react"; } from "lucide-react";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
@@ -37,8 +40,10 @@ type Grade = "小班" | "中班" | "大班";
type ClassType = { type ClassType = {
id: number; id: number;
grade: Grade;
name: string; name: string;
enabled: boolean; enabled: boolean;
showCurrentWord: boolean;
}; };
type WordRecord = { type WordRecord = {
@@ -84,7 +89,7 @@ type BatchAudioProgress = {
current: string; current: string;
}; };
type SettingsSection = "voice" | "rules" | "classTypes" | "preview"; type SettingsSection = "voice" | "rules" | "classTypes" | "wordImport" | "preview";
const grades: Grade[] = ["小班", "中班", "大班"]; const grades: Grade[] = ["小班", "中班", "大班"];
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
@@ -126,12 +131,13 @@ const settingsSections: Array<{ id: SettingsSection; label: string; description:
{id: "voice", label: "语音设置", description: "Edge TTS 声音与语速", icon: Mic2}, {id: "voice", label: "语音设置", description: "Edge TTS 声音与语速", icon: Mic2},
{id: "rules", label: "课堂规则", description: "得分与自动朗读", icon: SlidersHorizontal}, {id: "rules", label: "课堂规则", description: "得分与自动朗读", icon: SlidersHorizontal},
{id: "classTypes", label: "班级类型", description: "定制 PK 入口班型", icon: School}, {id: "classTypes", label: "班级类型", description: "定制 PK 入口班型", icon: School},
{id: "wordImport", label: "一键导入", description: "Excel 导入导出词库", icon: FileSpreadsheet},
{id: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye}, {id: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye},
]; ];
function createEmptyWordForm(classTypeId = 0): WordForm { function createEmptyWordForm(classTypeId = 0, grade: Grade = "中班"): WordForm {
return { return {
grade: "中班", grade,
classTypeId, classTypeId,
english: "", english: "",
chinese: "", chinese: "",
@@ -152,9 +158,14 @@ export default function AdminPage() {
const [wordDialogOpen, setWordDialogOpen] = useState(false); const [wordDialogOpen, setWordDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>("voice"); const [settingsSection, setSettingsSection] = useState<SettingsSection>("voice");
const [classTypeGrade, setClassTypeGrade] = useState<Grade>("中班");
const [importGrade, setImportGrade] = useState<Grade>("中班");
const [importClassTypeId, setImportClassTypeId] = useState(0);
const [importingWords, setImportingWords] = useState(false);
const [classTypeName, setClassTypeName] = useState(""); const [classTypeName, setClassTypeName] = useState("");
const [editingClassType, setEditingClassType] = useState<ClassType | null>(null); const [editingClassType, setEditingClassType] = useState<ClassType | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [batchAudioProgress, setBatchAudioProgress] = useState<BatchAudioProgress>({ const [batchAudioProgress, setBatchAudioProgress] = useState<BatchAudioProgress>({
running: false, running: false,
total: 0, total: 0,
@@ -183,8 +194,9 @@ export default function AdminPage() {
setSettings(mergeSettings(settingsData.settings)); setSettings(mergeSettings(settingsData.settings));
setWordForm((form) => ({ setWordForm((form) => ({
...form, ...form,
classTypeId: form.classTypeId || classTypesData.classTypes[0]?.id || 0, classTypeId: getNextClassTypeId(classTypesData.classTypes, form.grade, form.classTypeId),
})); }));
setImportClassTypeId((current) => getNextClassTypeId(classTypesData.classTypes, importGrade, current));
setLoading(false); setLoading(false);
} }
@@ -198,12 +210,61 @@ export default function AdminPage() {
return matchQuery && matchGrade && matchClassType; return matchQuery && matchGrade && matchClassType;
}); });
}, [wordList, query, gradeFilter, classTypeFilter]); }, [wordList, query, gradeFilter, classTypeFilter]);
const classTypesForFilter = useMemo(() => {
return gradeFilter === "all" ? classTypes : classTypes.filter((classType) => classType.grade === gradeFilter);
}, [classTypes, gradeFilter]);
const classTypesForWordForm = useMemo(() => {
return classTypes.filter((classType) => classType.grade === wordForm.grade);
}, [classTypes, wordForm.grade]);
const classTypesForImport = useMemo(() => {
return classTypes.filter((classType) => classType.grade === importGrade);
}, [classTypes, importGrade]);
const classTypesForSettings = useMemo(() => {
return classTypes.filter((classType) => classType.grade === classTypeGrade);
}, [classTypes, classTypeGrade]);
function openCreateWordDialog() { function openCreateWordDialog() {
setWordForm(createEmptyWordForm(classTypes[0]?.id || 0)); const nextGrade = gradeFilter === "all" ? "中班" : gradeFilter;
setWordForm(createEmptyWordForm(getNextClassTypeId(classTypes, nextGrade, 0), nextGrade));
setWordDialogOpen(true); setWordDialogOpen(true);
} }
function openWordImportSection() {
const selectedClassType = classTypeFilter === "all" ? null : classTypes.find((classType) => classType.id === classTypeFilter);
const nextGrade = gradeFilter === "all" ? selectedClassType?.grade ?? "中班" : gradeFilter;
setImportGrade(nextGrade);
setImportClassTypeId(getNextClassTypeId(classTypes, nextGrade, classTypeFilter === "all" ? 0 : classTypeFilter));
setSettingsSection("wordImport");
setSettingsDialogOpen(true);
}
function changeGradeFilter(nextGrade: "all" | Grade) {
setGradeFilter(nextGrade);
setClassTypeFilter((current) => {
if (current === "all" || nextGrade === "all") return current;
const currentClassType = classTypes.find((classType) => classType.id === current);
return currentClassType?.grade === nextGrade ? current : "all";
});
}
function changeWordFormGrade(nextGrade: Grade) {
setWordForm((form) => ({
...form,
grade: nextGrade,
classTypeId: getNextClassTypeId(classTypes, nextGrade, 0),
}));
}
function changeImportGrade(nextGrade: Grade) {
setImportGrade(nextGrade);
setImportClassTypeId(getNextClassTypeId(classTypes, nextGrade, 0));
}
function changeClassTypeGrade(nextGrade: Grade) {
setClassTypeGrade(nextGrade);
setEditingClassType(null);
}
function openEditWordDialog(word: WordRecord) { function openEditWordDialog(word: WordRecord) {
setWordForm({ setWordForm({
id: word.id, id: word.id,
@@ -357,8 +418,10 @@ export default function AdminPage() {
method: editingClassType ? "PUT" : "POST", method: editingClassType ? "PUT" : "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({ body: JSON.stringify({
grade: classTypeGrade,
name, name,
enabled: editingClassType?.enabled ?? true, enabled: editingClassType?.enabled ?? true,
showCurrentWord: editingClassType?.showCurrentWord ?? true,
}), }),
}); });
const data = await response.json(); const data = await response.json();
@@ -391,7 +454,11 @@ export default function AdminPage() {
const response = await fetch(`/api/class-types/${classType.id}`, { const response = await fetch(`/api/class-types/${classType.id}`, {
method: "PUT", method: "PUT",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({name: classType.name, enabled: !classType.enabled}), body: JSON.stringify({
name: classType.name,
enabled: !classType.enabled,
showCurrentWord: classType.showCurrentWord,
}),
}); });
const data = await response.json(); const data = await response.json();
@@ -403,6 +470,26 @@ export default function AdminPage() {
await loadData(); await loadData();
} }
async function toggleClassTypeCurrentWord(classType: ClassType) {
const response = await fetch(`/api/class-types/${classType.id}`, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
name: classType.name,
enabled: classType.enabled,
showCurrentWord: !classType.showCurrentWord,
}),
});
const data = await response.json();
if (!response.ok) {
toast.error(data.message ?? "更新当前单词展示失败");
return;
}
await loadData();
}
async function saveSettings() { async function saveSettings() {
const response = await fetch("/api/settings", { const response = await fetch("/api/settings", {
method: "PUT", method: "PUT",
@@ -443,6 +530,62 @@ export default function AdminPage() {
toast.success("正在播放试听语音", {id: toastId}); toast.success("正在播放试听语音", {id: toastId});
} }
function exportWordExcel() {
if (!importClassTypeId) {
toast.error("请先选择班级类型");
return;
}
const params = new URLSearchParams({
grade: importGrade,
classTypeId: String(importClassTypeId),
});
window.location.href = `/api/words/export?${params.toString()}`;
}
async function importWordExcel(file: File | undefined) {
if (!file || importingWords) return;
if (!importClassTypeId) {
toast.error("请先选择班级类型");
return;
}
const formData = new FormData();
formData.append("grade", importGrade);
formData.append("classTypeId", String(importClassTypeId));
formData.append("file", file);
setImportingWords(true);
const toastId = toast.loading("正在导入词库表格");
try {
const response = await fetch("/api/words/import", {
method: "POST",
body: formData,
});
const data = (await response.json()) as {
result?: { created: number; excluded: number; skipped: number; errors: string[] };
message?: string;
};
if (!response.ok || !data.result) {
toast.error(data.message ?? "导入失败", {id: toastId});
return;
}
const {created, excluded, skipped, errors} = data.result;
toast.success("词库导入完成", {
id: toastId,
description: `新增 ${created} 个,排除已存在 ${excluded} 个,跳过 ${skipped}${errors.length ? `${errors[0]}` : ""}`,
});
await loadData();
} finally {
setImportingWords(false);
if (importFileInputRef.current) {
importFileInputRef.current.value = "";
}
}
}
return ( return (
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8"> <main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@@ -465,6 +608,10 @@ export default function AdminPage() {
<Settings2 className="size-4"/> <Settings2 className="size-4"/>
</Button> </Button>
<Button variant="outline" onClick={openWordImportSection}>
<FileSpreadsheet className="size-4"/>
</Button>
<Button onClick={loadData}> <Button onClick={loadData}>
<RefreshCw className="size-4"/> <RefreshCw className="size-4"/>
@@ -567,7 +714,7 @@ export default function AdminPage() {
</div> </div>
<select <select
value={gradeFilter} value={gradeFilter}
onChange={(event) => setGradeFilter(event.target.value as "all" | Grade)} onChange={(event) => changeGradeFilter(event.target.value as "all" | Grade)}
className="h-10 rounded-md border bg-white px-3 text-sm" className="h-10 rounded-md border bg-white px-3 text-sm"
> >
<option value="all"></option> <option value="all"></option>
@@ -585,9 +732,9 @@ export default function AdminPage() {
className="h-10 rounded-md border bg-white px-3 text-sm" className="h-10 rounded-md border bg-white px-3 text-sm"
> >
<option value="all"></option> <option value="all"></option>
{classTypes.map((classType) => ( {classTypesForFilter.map((classType) => (
<option key={classType.id} value={classType.id}> <option key={classType.id} value={classType.id}>
{classType.name} {gradeFilter === "all" ? `${classType.grade} - ${classType.name}` : classType.name}
</option> </option>
))} ))}
</select> </select>
@@ -671,10 +818,7 @@ export default function AdminPage() {
<select <select
value={wordForm.grade} value={wordForm.grade}
onChange={(event) => setWordForm((form) => ({ onChange={(event) => changeWordFormGrade(event.target.value as Grade)}
...form,
grade: event.target.value as Grade
}))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm" className="h-10 w-full rounded-md border bg-white px-3 text-sm"
> >
{grades.map((grade) => ( {grades.map((grade) => (
@@ -694,7 +838,8 @@ export default function AdminPage() {
}))} }))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm" className="h-10 w-full rounded-md border bg-white px-3 text-sm"
> >
{classTypes.map((classType) => ( {classTypesForWordForm.length === 0 && <option value={0}></option>}
{classTypesForWordForm.map((classType) => (
<option key={classType.id} value={classType.id}> <option key={classType.id} value={classType.id}>
{classType.name} {classType.name}
</option> </option>
@@ -943,9 +1088,23 @@ export default function AdminPage() {
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">PK <p className="mt-1 text-sm text-muted-foreground">PK
</p> </p>
</div> </div>
<label className="block space-y-2 text-sm font-medium">
<select
value={classTypeGrade}
onChange={(event) => changeClassTypeGrade(event.target.value as Grade)}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{grades.map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={editingClassType ? editingClassType.name : classTypeName} value={editingClassType ? editingClassType.name : classTypeName}
@@ -968,17 +1127,29 @@ export default function AdminPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{classTypes.map((classType) => ( {classTypesForSettings.map((classType) => (
<div key={classType.id} <div key={classType.id}
className="flex items-center justify-between rounded-md border p-3"> className="flex items-center justify-between rounded-md border p-3">
<div> <div>
<div className="font-medium">{classType.name}</div> <div className="font-medium">{classType.name}</div>
<div <div
className="text-xs text-muted-foreground">{classType.enabled ? "已启用" : "已停用"}</div> className="text-xs text-muted-foreground">
{classType.grade} · {classType.enabled ? "已启用" : "已停用"} · {classType.showCurrentWord ? "显示" : "隐藏"}
</div> </div>
<div className="flex items-center gap-2"> </div>
<div className="flex flex-wrap items-center justify-end gap-3">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={classType.enabled} <Switch checked={classType.enabled}
onClick={() => toggleClassType(classType)}/> onClick={() => toggleClassType(classType)}/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={classType.showCurrentWord}
onClick={() => toggleClassTypeCurrentWord(classType)}
/>
</label>
<Button variant="outline" size="icon" <Button variant="outline" size="icon"
onClick={() => setEditingClassType(classType)}> onClick={() => setEditingClassType(classType)}>
<Pencil className="size-4"/> <Pencil className="size-4"/>
@@ -990,6 +1161,84 @@ export default function AdminPage() {
</div> </div>
</div> </div>
))} ))}
{classTypesForSettings.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
</div>
)}
</div>
</div>
)}
{settingsSection === "wordImport" && (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">
/
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium">
<select
value={importGrade}
onChange={(event) => changeImportGrade(event.target.value as Grade)}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{grades.map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm font-medium">
<select
value={importClassTypeId}
onChange={(event) => setImportClassTypeId(Number(event.target.value))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{classTypesForImport.length === 0 && <option value={0}></option>}
{classTypesForImport.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
</option>
))}
</select>
</label>
</div>
<div className="rounded-md border p-4">
<div className="font-medium"></div>
<p className="mt-1 text-sm text-muted-foreground">
</p>
<Button variant="outline" className="mt-3" onClick={exportWordExcel}>
<Download className="size-4"/>
</Button>
</div>
<div className="rounded-md border p-4">
<div className="font-medium"></div>
<p className="mt-1 text-sm text-muted-foreground">
</p>
<input
ref={importFileInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(event) => void importWordExcel(event.target.files?.[0])}
/>
<Button
className="mt-3"
onClick={() => importFileInputRef.current?.click()}
disabled={importingWords}
>
<Upload className="size-4"/>
{importingWords ? "导入中" : "选择表格并导入"}
</Button>
</div> </div>
</div> </div>
)} )}
@@ -1038,6 +1287,11 @@ function isImageSource(value: string) {
return /^(https?:\/\/|\/|data:image\/)/i.test(value); return /^(https?:\/\/|\/|data:image\/)/i.test(value);
} }
function getNextClassTypeId(classTypes: ClassType[], grade: Grade, currentId: number) {
const gradeClassTypes = classTypes.filter((classType) => classType.grade === grade);
return gradeClassTypes.some((classType) => classType.id === currentId) ? currentId : gradeClassTypes[0]?.id || 0;
}
function mergeSettings(settings: Partial<AppSettings>): AppSettings { function mergeSettings(settings: Partial<AppSettings>): AppSettings {
return { return {
ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice, ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice,
+2 -1
View File
@@ -15,6 +15,7 @@ export async function PUT(request: NextRequest, context: RouteContext) {
const classType = updateClassType(Number(id), { const classType = updateClassType(Number(id), {
name: String(body.name ?? ""), name: String(body.name ?? ""),
enabled: Boolean(body.enabled), enabled: Boolean(body.enabled),
showCurrentWord: body.showCurrentWord !== false,
}); });
return NextResponse.json({ classType }); return NextResponse.json({ classType });
@@ -35,7 +36,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败"; const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "班级类型已存在"; if (message.includes("UNIQUE")) return "该年级下班级类型已存在";
if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除"; if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除";
return message; return message;
} }
+16 -4
View File
@@ -1,18 +1,26 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createClassType, listClassTypes } from "@/lib/db"; import { createClassType, listClassTypes, type Grade } from "@/lib/db";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const includeDisabled = request.nextUrl.searchParams.get("includeDisabled") !== "false"; const includeDisabled = request.nextUrl.searchParams.get("includeDisabled") !== "false";
return NextResponse.json({ classTypes: listClassTypes(includeDisabled) }); const grade = request.nextUrl.searchParams.get("grade") ?? undefined;
return NextResponse.json({
classTypes: listClassTypes({
grade: parseGrade(grade),
includeDisabled,
}),
});
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const classType = createClassType(String(body.name ?? "")); const classType = createClassType(body.grade as Grade, String(body.name ?? ""), {
showCurrentWord: body.showCurrentWord !== false,
});
return NextResponse.json({ classType }, { status: 201 }); return NextResponse.json({ classType }, { status: 201 });
} catch (error) { } catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 }); return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
@@ -21,6 +29,10 @@ export async function POST(request: NextRequest) {
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败"; const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "班级类型已存在"; if (message.includes("UNIQUE")) return "该年级下班级类型已存在";
return message; return message;
} }
function parseGrade(value: string | undefined) {
return value === "小班" || value === "中班" || value === "大班" ? value : undefined;
}
+1 -1
View File
@@ -30,7 +30,7 @@ export async function POST(_request: NextRequest, context: RouteContext) {
const safeEnglish = word.english.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); const safeEnglish = word.english.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
const fileName = `${word.id}-${safeEnglish || "word"}.mp3`; const fileName = `${word.id}-${safeEnglish || "word"}.mp3`;
const filePath = getAudioFilePath(fileName); const filePath = getAudioFilePath(fileName);
const publicPath = getAudioPublicPath(fileName); const publicPath = `${getAudioPublicPath(fileName)}?v=${Date.now()}`;
const settings = getSettings(); const settings = getSettings();
const tts = new EdgeTTS(word.english, settings.ttsVoice, { const tts = new EdgeTTS(word.english, settings.ttsVoice, {
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest } from "next/server";
import { listWords } from "@/lib/db";
import { buildWordExcel } from "@/lib/word-excel";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const grade = searchParams.get("grade") ?? undefined;
const classTypeId = Number(searchParams.get("classTypeId"));
const words = listWords({
grade,
classTypeId: Number.isFinite(classTypeId) && classTypeId > 0 ? classTypeId : undefined,
});
const workbook = buildWordExcel(words);
return new Response(workbook, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${encodeURIComponent("词库导入表格")}.xlsx"`,
"Cache-Control": "no-store",
},
});
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { importWords, type Grade } from "@/lib/db";
import { parseWordExcel } from "@/lib/word-excel";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const grade = String(formData.get("grade") ?? "") as Grade;
const classTypeId = Number(formData.get("classTypeId"));
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ message: "请选择要导入的 Excel 表格" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const rows = parseWordExcel(buffer);
const result = importWords(grade, classTypeId, rows);
return NextResponse.json({ result });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "导入失败";
if (message.includes("FOREIGN KEY")) return "班级类型不存在";
if (message.includes("Unsupported file")) return "无法读取该表格,请使用 .xlsx 文件";
return message;
}
+6 -1
View File
@@ -60,10 +60,15 @@
.pk-game-card { .pk-game-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
min-height: min(720px, calc(100svh - 88px));
width: 100%; width: 100%;
} }
.pk-game-grid {
flex: 1;
}
.cursor-hammer { .cursor-hammer {
--hammer-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(-36 16 16)'%3E%3Crect x='14' y='9' width='4' height='20' rx='2' fill='%23815a2f'/%3E%3Crect x='13' y='8' width='6' height='3' rx='1.5' fill='%236b4220'/%3E%3Cpath d='M8 5h14c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23f2b84b'/%3E%3Cpath d='M8 5h5v10H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23d78a2a'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3h-3V5Z' fill='%23999'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v1h-6V5Z' fill='%23c9c9c9'/%3E%3C/g%3E%3C/svg%3E") 10 26, auto; --hammer-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(-36 16 16)'%3E%3Crect x='14' y='9' width='4' height='20' rx='2' fill='%23815a2f'/%3E%3Crect x='13' y='8' width='6' height='3' rx='1.5' fill='%236b4220'/%3E%3Cpath d='M8 5h14c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23f2b84b'/%3E%3Cpath d='M8 5h5v10H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23d78a2a'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3h-3V5Z' fill='%23999'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v1h-6V5Z' fill='%23c9c9c9'/%3E%3C/g%3E%3C/svg%3E") 10 26, auto;
cursor: var(--hammer-cursor); cursor: var(--hammer-cursor);
+21 -3
View File
@@ -10,6 +10,7 @@ import { WordImage } from "./WordImage";
type GameScreenProps = { type GameScreenProps = {
selectedGrade: Grade; selectedGrade: Grade;
selectedClassTypeName?: string; selectedClassTypeName?: string;
showCurrentWord: boolean;
roundWords: WordItem[]; roundWords: WordItem[];
currentWord: WordItem; currentWord: WordItem;
options: WordItem[]; options: WordItem[];
@@ -30,6 +31,7 @@ type GameScreenProps = {
export function GameScreen({ export function GameScreen({
selectedGrade, selectedGrade,
selectedClassTypeName, selectedClassTypeName,
showCurrentWord,
roundWords, roundWords,
currentWord, currentWord,
options, options,
@@ -83,8 +85,13 @@ export function GameScreen({
</p> </p>
</div> </div>
<div className="pk-game-grid grid gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]"> <div
<div className="flex min-w-0 flex-col"> className={cn(
"pk-game-grid grid items-center gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]",
!showCurrentWord && "pk-game-grid-word-hidden",
)}
>
<div className="flex min-w-0 flex-col justify-center">
<div className="flex items-center gap-[clamp(10px,1.8vw,18px)]"> <div className="flex items-center gap-[clamp(10px,1.8vw,18px)]">
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} /> <TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
<div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div> <div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div>
@@ -104,7 +111,14 @@ export function GameScreen({
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} /> <div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div> </div>
<div className="pk-listen-area flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center"> <div
className={cn(
"pk-listen-area flex flex-col items-center justify-center text-center",
showCurrentWord
? "py-[clamp(24px,5vw,70px)]"
: "min-h-[clamp(150px,24vh,230px)] py-[clamp(16px,3vw,34px)]",
)}
>
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p> <p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p>
<button <button
onClick={() => onSpeakWord(currentWord)} onClick={() => onSpeakWord(currentWord)}
@@ -113,8 +127,12 @@ export function GameScreen({
> >
<Volume2 className="size-[clamp(28px,3vw,36px)]" /> <Volume2 className="size-[clamp(28px,3vw,36px)]" />
</button> </button>
{showCurrentWord && (
<>
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" /> <AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p> <p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
</>
)}
</div> </div>
</div> </div>
+36 -7
View File
@@ -17,20 +17,41 @@ export function usePkSetup() {
useEffect(() => { useEffect(() => {
async function loadInitialSetup() { async function loadInitialSetup() {
setLoadingSetup(true); setLoadingSetup(true);
const [classTypesResponse, nextSettings] = await Promise.all([ const nextSettings = await loadSettings();
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
loadSettings(),
]);
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
setClassTypes(classTypesData.classTypes);
setSettings(mergeSettings(nextSettings)); setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false); setLoadingSetup(false);
} }
void loadInitialSetup(); void loadInitialSetup();
}, []); }, []);
useEffect(() => {
let ignore = false;
async function loadClassTypes() {
setLoadingSetup(true);
setSelectedClassTypeId(0);
const params = new URLSearchParams({
includeDisabled: "false",
grade: selectedGrade,
});
const response = await fetch(`/api/class-types?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { classTypes: ClassType[] };
if (!ignore) {
setClassTypes(data.classTypes);
setSelectedClassTypeId(data.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
}
void loadClassTypes();
return () => {
ignore = true;
};
}, [selectedGrade]);
useEffect(() => { useEffect(() => {
function refreshSettingsOnFocus() { function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings))); void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
@@ -41,6 +62,8 @@ export function usePkSetup() {
}, []); }, []);
useEffect(() => { useEffect(() => {
let ignore = false;
async function loadWords() { async function loadWords() {
if (!selectedClassTypeId) { if (!selectedClassTypeId) {
setAvailableWords([]); setAvailableWords([]);
@@ -54,10 +77,16 @@ export function usePkSetup() {
}); });
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" }); const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] }; const data = (await response.json()) as { words: WordItem[] };
if (!ignore) {
setAvailableWords(data.words); setAvailableWords(data.words);
} }
}
void loadWords(); void loadWords();
return () => {
ignore = true;
};
}, [selectedGrade, selectedClassTypeId]); }, [selectedGrade, selectedClassTypeId]);
return { return {
+1
View File
@@ -128,6 +128,7 @@ export default function PkPage() {
<GameScreen <GameScreen
selectedGrade={selectedGrade} selectedGrade={selectedGrade}
selectedClassTypeName={selectedClassType?.name} selectedClassTypeName={selectedClassType?.name}
showCurrentWord={selectedClassType?.showCurrentWord ?? true}
roundWords={roundWords} roundWords={roundWords}
currentWord={currentWord} currentWord={currentWord}
options={options} options={options}
+2
View File
@@ -4,8 +4,10 @@ export type Grade = "小班" | "中班" | "大班";
export type ClassType = { export type ClassType = {
id: number; id: number;
grade: Grade;
name: string; name: string;
enabled: boolean; enabled: boolean;
showCurrentWord: boolean;
}; };
export type WordItem = { export type WordItem = {
+288 -21
View File
@@ -6,8 +6,10 @@ export type Grade = "小班" | "中班" | "大班";
export type ClassType = { export type ClassType = {
id: number; id: number;
grade: Grade;
name: string; name: string;
enabled: boolean; enabled: boolean;
showCurrentWord: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -47,6 +49,19 @@ export type WordInput = {
enabled: boolean; enabled: boolean;
}; };
export type WordImportInput = {
english: string;
chinese: string;
image: string;
};
export type WordImportResult = {
created: number;
excluded: number;
skipped: number;
errors: string[];
};
type DbWordRow = { type DbWordRow = {
id: number; id: number;
grade: Grade; grade: Grade;
@@ -63,8 +78,10 @@ type DbWordRow = {
type DbClassTypeRow = { type DbClassTypeRow = {
id: number; id: number;
grade: Grade;
name: string; name: string;
enabled: 0 | 1; enabled: 0 | 1;
show_current_word: 0 | 1;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -93,8 +110,10 @@ const defaultWords = [
function mapClassType(row: DbClassTypeRow): ClassType { function mapClassType(row: DbClassTypeRow): ClassType {
return { return {
id: row.id, id: row.id,
grade: row.grade,
name: row.name, name: row.name,
enabled: Boolean(row.enabled), enabled: Boolean(row.enabled),
showCurrentWord: Boolean(row.show_current_word),
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -134,10 +153,13 @@ function initializeDatabase(database: DatabaseSync) {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS class_types ( CREATE TABLE IF NOT EXISTS class_types (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, grade TEXT NOT NULL DEFAULT '中班',
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
show_current_word INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (grade, name)
); );
CREATE TABLE IF NOT EXISTS words ( CREATE TABLE IF NOT EXISTS words (
@@ -162,6 +184,11 @@ function initializeDatabase(database: DatabaseSync) {
); );
`); `);
migrateClassTypesByGrade(database);
migrateClassTypeDisplayOptions(database);
ensureWordClassTypeGradeConsistency(database);
cleanupUnreferencedDuplicatedCustomClassTypes(database);
const wordColumns = database.prepare("PRAGMA table_info(words)").all() as Array<{ name: string }>; const wordColumns = database.prepare("PRAGMA table_info(words)").all() as Array<{ name: string }>;
if (!wordColumns.some((column) => column.name === "image")) { if (!wordColumns.some((column) => column.name === "image")) {
database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';"); database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';");
@@ -175,21 +202,23 @@ function initializeDatabase(database: DatabaseSync) {
const classTypeCount = database.prepare("SELECT COUNT(*) AS count FROM class_types").get() as { count: number }; const classTypeCount = database.prepare("SELECT COUNT(*) AS count FROM class_types").get() as { count: number };
if (classTypeCount.count === 0) { if (classTypeCount.count === 0) {
const insertClassType = database.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)"); const insertClassType = database.prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, 1)");
for (const grade of ["小班", "中班", "大班"] satisfies Grade[]) {
for (const name of defaultClassTypes) { for (const name of defaultClassTypes) {
insertClassType.run(name); insertClassType.run(grade, name);
}
} }
} }
const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number }; const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number };
if (wordCount.count === 0) { if (wordCount.count === 0) {
const findClassType = database.prepare("SELECT id FROM class_types WHERE name = ?"); const findClassType = database.prepare("SELECT id FROM class_types WHERE grade = ? AND name = ?");
const insertWord = database.prepare( const insertWord = database.prepare(
"INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)", "INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)",
); );
for (const [grade, classTypeName, english, chinese, image] of defaultWords) { for (const [grade, classTypeName, english, chinese, image] of defaultWords) {
const classType = findClassType.get(classTypeName) as { id: number } | undefined; const classType = findClassType.get(grade, classTypeName) as { id: number } | undefined;
if (classType) { if (classType) {
insertWord.run(grade, classType.id, english, chinese, image); insertWord.run(grade, classType.id, english, chinese, image);
} }
@@ -225,28 +254,194 @@ function initializeDatabase(database: DatabaseSync) {
} }
} }
export function listClassTypes(includeDisabled = true) { function migrateClassTypesByGrade(database: DatabaseSync) {
const database = getDatabase(); const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>;
const sql = includeDisabled if (classTypeColumns.some((column) => column.name === "grade")) {
? "SELECT * FROM class_types ORDER BY id ASC" return;
: "SELECT * FROM class_types WHERE enabled = 1 ORDER BY id ASC"; }
return (database.prepare(sql).all() as DbClassTypeRow[]).map(mapClassType);
const oldClassTypes = database.prepare("SELECT * FROM class_types ORDER BY id ASC").all() as Array<{
id: number;
name: string;
enabled: 0 | 1;
created_at: string;
updated_at: string;
}>;
const wordGradeRows = database
.prepare("SELECT class_type_id, grade FROM words GROUP BY class_type_id, grade")
.all() as Array<{ class_type_id: number; grade: Grade }>;
const gradesByClassType = new Map<number, Grade[]>();
for (const row of wordGradeRows) {
const grades = gradesByClassType.get(row.class_type_id) ?? [];
if (!grades.includes(row.grade)) {
grades.push(row.grade);
}
gradesByClassType.set(row.class_type_id, grades);
}
database.exec(`
PRAGMA foreign_keys = OFF;
CREATE TABLE class_types_next (
id INTEGER PRIMARY KEY AUTOINCREMENT,
grade TEXT NOT NULL DEFAULT '中班',
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
show_current_word INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (grade, name)
);
`);
const insertWithId = database.prepare(
"INSERT INTO class_types_next (id, grade, name, enabled, show_current_word, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
);
const insertCopy = database.prepare(
"INSERT INTO class_types_next (grade, name, enabled, show_current_word, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)",
);
const updateWords = database.prepare("UPDATE words SET class_type_id = ? WHERE class_type_id = ? AND grade = ?");
const allGrades = ["小班", "中班", "大班"] satisfies Grade[];
for (const classType of oldClassTypes) {
const usedGrades = gradesByClassType.get(classType.id) ?? [];
const targetGrades = defaultClassTypes.includes(classType.name)
? allGrades
: usedGrades.length > 0
? usedGrades
: (["中班"] satisfies Grade[]);
const primaryGrade = targetGrades.includes("中班") ? "中班" : targetGrades[0];
insertWithId.run(
classType.id,
primaryGrade,
classType.name,
classType.enabled,
classType.created_at,
classType.updated_at,
);
updateWords.run(classType.id, classType.id, primaryGrade);
for (const grade of targetGrades) {
if (grade === primaryGrade) continue;
const result = insertCopy.run(grade, classType.name, classType.enabled, classType.created_at, classType.updated_at);
updateWords.run(Number(result.lastInsertRowid), classType.id, grade);
}
}
database.exec(`
DROP TABLE class_types;
ALTER TABLE class_types_next RENAME TO class_types;
PRAGMA foreign_keys = ON;
`);
} }
export function createClassType(name: string) { function migrateClassTypeDisplayOptions(database: DatabaseSync) {
const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>;
if (!classTypeColumns.some((column) => column.name === "show_current_word")) {
database.exec("ALTER TABLE class_types ADD COLUMN show_current_word INTEGER NOT NULL DEFAULT 1;");
}
}
function ensureWordClassTypeGradeConsistency(database: DatabaseSync) {
const mismatches = database
.prepare(
`
SELECT DISTINCT
words.grade,
words.class_type_id AS source_class_type_id,
class_types.name,
class_types.enabled
FROM words
JOIN class_types ON class_types.id = words.class_type_id
WHERE words.grade <> class_types.grade
`,
)
.all() as Array<{
grade: Grade;
source_class_type_id: number;
name: string;
enabled: 0 | 1;
}>;
if (mismatches.length === 0) return;
const findClassType = database.prepare("SELECT id FROM class_types WHERE grade = ? AND name = ?");
const insertClassType = database.prepare("INSERT INTO class_types (grade, name, enabled) VALUES (?, ?, ?)");
const updateWords = database.prepare("UPDATE words SET class_type_id = ? WHERE grade = ? AND class_type_id = ?");
for (const mismatch of mismatches) {
const existing = findClassType.get(mismatch.grade, mismatch.name) as { id: number } | undefined;
const classTypeId =
existing?.id ??
Number(insertClassType.run(mismatch.grade, mismatch.name, mismatch.enabled).lastInsertRowid);
updateWords.run(classTypeId, mismatch.grade, mismatch.source_class_type_id);
}
}
function cleanupUnreferencedDuplicatedCustomClassTypes(database: DatabaseSync) {
const duplicates = database
.prepare(
`
SELECT class_types.id, class_types.name, COUNT(words.id) AS word_count
FROM class_types
LEFT JOIN words ON words.class_type_id = class_types.id
WHERE class_types.name NOT IN (${defaultClassTypes.map(() => "?").join(", ")})
AND class_types.name IN (
SELECT name
FROM class_types
WHERE name NOT IN (${defaultClassTypes.map(() => "?").join(", ")})
GROUP BY name
HAVING COUNT(*) > 1
)
GROUP BY class_types.id
`,
)
.all(...defaultClassTypes, ...defaultClassTypes) as Array<{ id: number; name: string; word_count: number }>;
const namesWithWords = new Set(duplicates.filter((row) => row.word_count > 0).map((row) => row.name));
if (namesWithWords.size === 0) return;
const deleteClassType = database.prepare("DELETE FROM class_types WHERE id = ?");
for (const row of duplicates) {
if (row.word_count === 0 && namesWithWords.has(row.name)) {
deleteClassType.run(row.id);
}
}
}
export function listClassTypes(filters?: { grade?: Grade; includeDisabled?: boolean }) {
const database = getDatabase(); const database = getDatabase();
const clauses: string[] = [];
const values: Array<string | number> = [];
if (filters?.grade) {
clauses.push("grade = ?");
values.push(filters.grade);
}
if (filters?.includeDisabled === false) {
clauses.push("enabled = 1");
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
return (database.prepare(`SELECT * FROM class_types ${where} ORDER BY grade ASC, id ASC`).all(...values) as DbClassTypeRow[]).map(mapClassType);
}
export function createClassType(grade: Grade, name: string, options?: { showCurrentWord?: boolean }) {
const database = getDatabase();
validateGrade(grade);
const trimmedName = name.trim(); const trimmedName = name.trim();
if (!trimmedName) { if (!trimmedName) {
throw new Error("班级类型不能为空"); throw new Error("班级类型不能为空");
} }
const result = database const result = database
.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)") .prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, ?)")
.run(trimmedName); .run(grade, trimmedName, options?.showCurrentWord === false ? 0 : 1);
return getClassType(Number(result.lastInsertRowid)); return getClassType(Number(result.lastInsertRowid));
} }
export function updateClassType(id: number, input: { name: string; enabled: boolean }) { export function updateClassType(id: number, input: { name: string; enabled: boolean; showCurrentWord?: boolean }) {
const database = getDatabase(); const database = getDatabase();
const trimmedName = input.name.trim(); const trimmedName = input.name.trim();
if (!trimmedName) { if (!trimmedName) {
@@ -254,8 +449,8 @@ export function updateClassType(id: number, input: { name: string; enabled: bool
} }
database database
.prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .prepare("UPDATE class_types SET name = ?, enabled = ?, show_current_word = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(trimmedName, input.enabled ? 1 : 0, id); .run(trimmedName, input.enabled ? 1 : 0, input.showCurrentWord === false ? 0 : 1, id);
return getClassType(id); return getClassType(id);
} }
@@ -340,6 +535,65 @@ export function createWord(input: WordInput) {
return getWord(Number(result.lastInsertRowid)); return getWord(Number(result.lastInsertRowid));
} }
export function importWords(grade: Grade, classTypeId: number, rows: WordImportInput[]): WordImportResult {
const database = getDatabase();
validateWordInput({
grade,
classTypeId,
english: "placeholder",
chinese: "",
image: "",
enabled: true,
});
if (!getClassType(classTypeId)) {
throw new Error("班级类型不存在");
}
const findExisting = database.prepare(
"SELECT id FROM words WHERE grade = ? AND class_type_id = ? AND english = ?",
);
const insertWord = database.prepare(
"INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, '', 1)",
);
const result: WordImportResult = {
created: 0,
excluded: 0,
skipped: 0,
errors: [],
};
database.exec("BEGIN IMMEDIATE");
try {
rows.forEach((row, index) => {
const rowNumber = index + 2;
const english = row.english.trim();
if (!english) {
result.skipped += 1;
result.errors.push(`${rowNumber} 行缺少词库英语`);
return;
}
const chinese = row.chinese.trim();
const image = row.image.trim();
const existing = findExisting.get(grade, classTypeId, english) as { id: number } | undefined;
if (existing) {
result.excluded += 1;
} else {
insertWord.run(grade, classTypeId, english, chinese, image);
result.created += 1;
}
});
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return result;
}
export function updateWord(id: number, input: WordInput) { export function updateWord(id: number, input: WordInput) {
const database = getDatabase(); const database = getDatabase();
validateWordInput(input); validateWordInput(input);
@@ -385,19 +639,32 @@ function getWord(id: number) {
} }
function validateWordInput(input: WordInput) { function validateWordInput(input: WordInput) {
if (!["小班", "中班", "大班"].includes(input.grade)) { validateGrade(input.grade);
throw new Error("年级不正确");
}
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) { if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
throw new Error("班级类型不正确"); throw new Error("班级类型不正确");
} }
const classType = getClassType(input.classTypeId);
if (!classType) {
throw new Error("班级类型不存在");
}
if (classType.grade !== input.grade) {
throw new Error("班级类型不属于当前年级");
}
if (!input.english.trim()) { if (!input.english.trim()) {
throw new Error("题库英语不能为空"); throw new Error("题库英语不能为空");
} }
} }
function validateGrade(grade: Grade) {
if (!["小班", "中班", "大班"].includes(grade)) {
throw new Error("年级不正确");
}
}
export function getSettings(): AppSettings { export function getSettings(): AppSettings {
const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{ const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{
key: string; key: string;
+57
View File
@@ -0,0 +1,57 @@
import * as XLSX from "xlsx";
import type { WordImportInput, WordRecord } from "@/lib/db";
export const wordExcelHeaders = ["词库英语", "中文", "图标/图片"] as const;
type WordExcelHeader = (typeof wordExcelHeaders)[number];
type WordExcelRow = Record<WordExcelHeader, string>;
export function parseWordExcel(buffer: Buffer): WordImportInput[] {
const workbook = XLSX.read(buffer, { type: "buffer" });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error("表格中没有可导入的工作表");
}
const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, {
defval: "",
raw: false,
});
return rows.map((row) => ({
english: readCell(row, "词库英语"),
chinese: readCell(row, "中文"),
image: readCell(row, "图标/图片"),
}));
}
export function buildWordExcel(words: WordRecord[]) {
const rows: WordExcelRow[] =
words.length > 0
? words.map((word) => ({
词库英语: word.english,
中文: word.chinese,
"图标/图片": word.image,
}))
: [
{
: "apple",
: "苹果",
"图标/图片": "🍎",
},
];
const worksheet = XLSX.utils.json_to_sheet(rows, { header: [...wordExcelHeaders] });
worksheet["!cols"] = [{ wch: 24 }, { wch: 18 }, { wch: 28 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "词库导入");
return XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }) as Buffer;
}
function readCell(row: Record<string, unknown>, header: string) {
const value = row[header];
return value == null ? "" : String(value).trim();
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -1
View File
@@ -22,7 +22,8 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
+72
View File
@@ -44,6 +44,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19) version: 1.0.7(tailwindcss@3.4.19)
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.10.2 specifier: ^22.10.2
@@ -742,6 +745,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@6.0.0: agent-base@6.0.0:
resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==} resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
@@ -912,6 +919,10 @@ packages:
caniuse-lite@1.0.30001799: caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -930,6 +941,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -951,6 +966,11 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cross-fetch@4.1.0: cross-fetch@4.1.0:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
@@ -1289,6 +1309,10 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -2102,6 +2126,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2338,10 +2366,18 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2362,6 +2398,11 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-escape@1.1.0: xml-escape@1.1.0:
resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
@@ -3008,6 +3049,8 @@ snapshots:
acorn@8.17.0: {} acorn@8.17.0: {}
adler-32@1.3.1: {}
agent-base@6.0.0: agent-base@6.0.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -3210,6 +3253,11 @@ snapshots:
caniuse-lite@1.0.30001799: {} caniuse-lite@1.0.30001799: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -3235,6 +3283,8 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
codepage@1.15.0: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -3251,6 +3301,8 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
crc-32@1.2.2: {}
cross-fetch@4.1.0: cross-fetch@4.1.0:
dependencies: dependencies:
node-fetch: 2.7.0 node-fetch: 2.7.0
@@ -3753,6 +3805,8 @@ snapshots:
hasown: 2.0.4 hasown: 2.0.4
mime-types: 2.1.35 mime-types: 2.1.35
frac@1.1.2: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fsevents@2.3.3: fsevents@2.3.3:
@@ -4589,6 +4643,10 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -4912,8 +4970,12 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -4928,6 +4990,16 @@ snapshots:
ws@8.21.0: {} ws@8.21.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-escape@1.1.0: {} xml-escape@1.1.0: {}
yallist@3.1.1: {} yallist@3.1.1: {}