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
+276 -22
View File
@@ -1,10 +1,12 @@
"use client";
import {useEffect, useMemo, useState} from "react";
import {useEffect, useMemo, useRef, useState} from "react";
import Link from "next/link";
import {toast} from "sonner";
import {
Download,
Eye,
FileSpreadsheet,
Gamepad2,
Mic2,
Pencil,
@@ -17,6 +19,7 @@ import {
Settings2,
SlidersHorizontal,
Trash2,
Upload,
} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -37,8 +40,10 @@ type Grade = "小班" | "中班" | "大班";
type ClassType = {
id: number;
grade: Grade;
name: string;
enabled: boolean;
showCurrentWord: boolean;
};
type WordRecord = {
@@ -84,7 +89,7 @@ type BatchAudioProgress = {
current: string;
};
type SettingsSection = "voice" | "rules" | "classTypes" | "preview";
type SettingsSection = "voice" | "rules" | "classTypes" | "wordImport" | "preview";
const grades: Grade[] = ["小班", "中班", "大班"];
const defaultSettings: AppSettings = {
@@ -126,12 +131,13 @@ const settingsSections: Array<{ id: SettingsSection; label: string; description:
{id: "voice", label: "语音设置", description: "Edge TTS 声音与语速", icon: Mic2},
{id: "rules", label: "课堂规则", description: "得分与自动朗读", icon: SlidersHorizontal},
{id: "classTypes", label: "班级类型", description: "定制 PK 入口班型", icon: School},
{id: "wordImport", label: "一键导入", description: "Excel 导入导出词库", icon: FileSpreadsheet},
{id: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye},
];
function createEmptyWordForm(classTypeId = 0): WordForm {
function createEmptyWordForm(classTypeId = 0, grade: Grade = "中班"): WordForm {
return {
grade: "中班",
grade,
classTypeId,
english: "",
chinese: "",
@@ -152,9 +158,14 @@ export default function AdminPage() {
const [wordDialogOpen, setWordDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
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 [editingClassType, setEditingClassType] = useState<ClassType | null>(null);
const [loading, setLoading] = useState(true);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [batchAudioProgress, setBatchAudioProgress] = useState<BatchAudioProgress>({
running: false,
total: 0,
@@ -183,8 +194,9 @@ export default function AdminPage() {
setSettings(mergeSettings(settingsData.settings));
setWordForm((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);
}
@@ -198,12 +210,61 @@ export default function AdminPage() {
return matchQuery && matchGrade && matchClassType;
});
}, [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() {
setWordForm(createEmptyWordForm(classTypes[0]?.id || 0));
const nextGrade = gradeFilter === "all" ? "中班" : gradeFilter;
setWordForm(createEmptyWordForm(getNextClassTypeId(classTypes, nextGrade, 0), nextGrade));
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) {
setWordForm({
id: word.id,
@@ -357,8 +418,10 @@ export default function AdminPage() {
method: editingClassType ? "PUT" : "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
grade: classTypeGrade,
name,
enabled: editingClassType?.enabled ?? true,
showCurrentWord: editingClassType?.showCurrentWord ?? true,
}),
});
const data = await response.json();
@@ -391,7 +454,11 @@ export default function AdminPage() {
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}),
body: JSON.stringify({
name: classType.name,
enabled: !classType.enabled,
showCurrentWord: classType.showCurrentWord,
}),
});
const data = await response.json();
@@ -403,6 +470,26 @@ export default function AdminPage() {
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() {
const response = await fetch("/api/settings", {
method: "PUT",
@@ -443,6 +530,62 @@ export default function AdminPage() {
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 (
<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">
@@ -465,6 +608,10 @@ export default function AdminPage() {
<Settings2 className="size-4"/>
</Button>
<Button variant="outline" onClick={openWordImportSection}>
<FileSpreadsheet className="size-4"/>
</Button>
<Button onClick={loadData}>
<RefreshCw className="size-4"/>
@@ -567,7 +714,7 @@ export default function AdminPage() {
</div>
<select
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"
>
<option value="all"></option>
@@ -585,9 +732,9 @@ export default function AdminPage() {
className="h-10 rounded-md border bg-white px-3 text-sm"
>
<option value="all"></option>
{classTypes.map((classType) => (
{classTypesForFilter.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
{gradeFilter === "all" ? `${classType.grade} - ${classType.name}` : classType.name}
</option>
))}
</select>
@@ -671,10 +818,7 @@ export default function AdminPage() {
<select
value={wordForm.grade}
onChange={(event) => setWordForm((form) => ({
...form,
grade: event.target.value as Grade
}))}
onChange={(event) => changeWordFormGrade(event.target.value as Grade)}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{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"
>
{classTypes.map((classType) => (
{classTypesForWordForm.length === 0 && <option value={0}></option>}
{classTypesForWordForm.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
</option>
@@ -943,9 +1088,23 @@ export default function AdminPage() {
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">PK
</p>
<p className="mt-1 text-sm text-muted-foreground">PK
</p>
</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">
<Input
value={editingClassType ? editingClassType.name : classTypeName}
@@ -968,17 +1127,29 @@ export default function AdminPage() {
</div>
<div className="space-y-2">
{classTypes.map((classType) => (
{classTypesForSettings.map((classType) => (
<div key={classType.id}
className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="font-medium">{classType.name}</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">
<Switch checked={classType.enabled}
onClick={() => toggleClassType(classType)}/>
<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}
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"
onClick={() => setEditingClassType(classType)}>
<Pencil className="size-4"/>
@@ -990,6 +1161,84 @@ export default function AdminPage() {
</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>
)}
@@ -1038,6 +1287,11 @@ function isImageSource(value: string) {
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 {
return {
ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice,