"use client"; import {useEffect, useMemo, useRef, useState} from "react"; import Link from "next/link"; import {toast} from "sonner"; import { Download, Eye, FileSpreadsheet, Gamepad2, Mic2, Pencil, Play, Plus, RefreshCw, Save, School, Search, Settings2, SlidersHorizontal, Trash2, Upload, } from "lucide-react"; import {Badge} from "@/components/ui/badge"; import {Button} from "@/components/ui/button"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import {Input} from "@/components/ui/input"; import {Switch} from "@/components/ui/switch"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; type Grade = "小班" | "中班" | "大班"; type ClassType = { id: number; grade: Grade; name: string; enabled: boolean; showCurrentWord: boolean; }; type WordRecord = { id: number; grade: Grade; classTypeId: number; classTypeName: string; english: string; chinese: string; image: string; audio: string; enabled: boolean; }; type WordForm = { id?: number; grade: Grade; classTypeId: number; english: string; chinese: string; image: string; audio?: string; enabled: boolean; }; type AppSettings = { ttsVoice: string; ttsRate: string; scorePerCorrect: number; scorePerWrong: number; autoSpeak: boolean; wordDisplayMode: "uppercase" | "capitalize" | "input"; pkWordCount: number; pkOptionCount: number; }; type BatchAudioProgress = { running: boolean; total: number; done: number; success: number; failed: number; current: string; }; type SettingsSection = "voice" | "rules" | "classTypes" | "wordImport" | "preview"; const grades: Grade[] = ["小班", "中班", "大班"]; const defaultSettings: AppSettings = { ttsVoice: "en-US-EmmaMultilingualNeural", ttsRate: "-10%", scorePerCorrect: 10, scorePerWrong: 0, autoSpeak: true, wordDisplayMode: "uppercase", pkWordCount: 0, pkOptionCount: 4, }; const ttsVoiceOptions = [ {value: "en-US-EmmaMultilingualNeural", label: "Emma - 美式英语 女声"}, {value: "en-US-JennyNeural", label: "Jenny - 美式英语 女声"}, {value: "en-US-GuyNeural", label: "Guy - 美式英语 男声"}, {value: "en-GB-SoniaNeural", label: "Sonia - 英式英语 女声"}, {value: "en-GB-RyanNeural", label: "Ryan - 英式英语 男声"}, {value: "en-AU-NatashaNeural", label: "Natasha - 澳洲英语 女声"}, {value: "en-AU-WilliamNeural", label: "William - 澳洲英语 男声"}, ]; const ttsRateOptions = [ {value: "-30%", label: "较慢 (-30%)"}, {value: "-20%", label: "慢速 (-20%)"}, {value: "-10%", label: "稍慢 (-10%)"}, {value: "+0%", label: "正常"}, {value: "+10%", label: "稍快 (+10%)"}, ]; const wordDisplayModeOptions: Array<{ value: AppSettings["wordDisplayMode"]; label: string }> = [ { value: "uppercase", label: "全部大写:APPLE" }, { value: "capitalize", label: "首字母大写:Apple" }, { value: "input", label: "按输入显示:apple / Apple" }, ]; const settingsSections: Array<{ id: SettingsSection; label: string; description: string; icon: typeof Mic2 }> = [ {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, grade: Grade = "中班"): WordForm { return { grade, classTypeId, english: "", chinese: "", image: "", audio: "", enabled: true, }; } export default function AdminPage() { const [wordList, setWordList] = useState([]); const [classTypes, setClassTypes] = useState([]); const [settings, setSettings] = useState(defaultSettings); const [query, setQuery] = useState(""); const [gradeFilter, setGradeFilter] = useState<"all" | Grade>("all"); const [classTypeFilter, setClassTypeFilter] = useState<"all" | number>("all"); const [wordForm, setWordForm] = useState(createEmptyWordForm()); const [wordDialogOpen, setWordDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsSection, setSettingsSection] = useState("voice"); const [classTypeGrade, setClassTypeGrade] = useState("中班"); const [importGrade, setImportGrade] = useState("中班"); const [importClassTypeId, setImportClassTypeId] = useState(0); const [importingWords, setImportingWords] = useState(false); const [classTypeName, setClassTypeName] = useState(""); const [editingClassType, setEditingClassType] = useState(null); const [loading, setLoading] = useState(true); const importFileInputRef = useRef(null); const [batchAudioProgress, setBatchAudioProgress] = useState({ running: false, total: 0, done: 0, success: 0, failed: 0, current: "", }); useEffect(() => { void loadData(); }, []); async function loadData() { setLoading(true); const [wordsResponse, classTypesResponse, settingsResponse] = await Promise.all([ fetch("/api/words", {cache: "no-store"}), fetch("/api/class-types", {cache: "no-store"}), fetch("/api/settings", {cache: "no-store"}), ]); const wordsData = (await wordsResponse.json()) as { words: WordRecord[] }; const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] }; const settingsData = (await settingsResponse.json()) as { settings: Partial }; setWordList(wordsData.words); setClassTypes(classTypesData.classTypes); setSettings(mergeSettings(settingsData.settings)); setWordForm((form) => ({ ...form, classTypeId: getNextClassTypeId(classTypesData.classTypes, form.grade, form.classTypeId), })); setImportClassTypeId((current) => getNextClassTypeId(classTypesData.classTypes, importGrade, current)); setLoading(false); } const enabledCount = wordList.filter((word) => word.enabled).length; const audioCount = wordList.filter((word) => word.audio).length; const filteredWords = useMemo(() => { return wordList.filter((word) => { const matchQuery = word.english.toLowerCase().includes(query.toLowerCase()); const matchGrade = gradeFilter === "all" || word.grade === gradeFilter; const matchClassType = classTypeFilter === "all" || word.classTypeId === classTypeFilter; 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() { 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, grade: word.grade, classTypeId: word.classTypeId, english: word.english, chinese: word.chinese, image: word.image, audio: word.audio, enabled: word.enabled, }); setWordDialogOpen(true); } async function saveWord() { if (!wordForm.english.trim()) { toast.error("题库英语不能为空"); return null; } if (!wordForm.classTypeId) { toast.error("请先创建班级类型"); return null; } const response = await fetch(wordForm.id ? `/api/words/${wordForm.id}` : "/api/words", { method: wordForm.id ? "PUT" : "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(wordForm), }); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "保存题库失败"); return null; } toast.success(wordForm.id ? "题库已更新,语音需要重新生成" : "题库已添加"); setWordDialogOpen(false); await loadData(); return data.word as WordRecord; } async function saveWordAndGenerateAudio() { const savedWord = await saveWord(); if (!savedWord) return; await generateAudio(savedWord); } async function deleteWord(id: number) { const response = await fetch(`/api/words/${id}`, {method: "DELETE"}); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "删除题库失败"); return; } toast.success("题库已删除"); await loadData(); } async function toggleWord(word: WordRecord) { const response = await fetch(`/api/words/${word.id}`, { method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({...word, enabled: !word.enabled}), }); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "更新题库失败"); return; } await loadData(); } async function generateAudio(word: WordRecord) { const toastId = toast.loading(`正在生成 ${word.english} 的语音`); const response = await fetch(`/api/words/${word.id}/generate-audio`, {method: "POST"}); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "生成语音失败", {id: toastId}); return; } toast.success(`${word.english} 的语音已生成`, {id: toastId}); await loadData(); } async function generateFilteredAudios() { if (filteredWords.length === 0 || batchAudioProgress.running) return; setBatchAudioProgress({ running: true, total: filteredWords.length, done: 0, success: 0, failed: 0, current: "", }); let success = 0; let failed = 0; for (let index = 0; index < filteredWords.length; index += 1) { const word = filteredWords[index]; setBatchAudioProgress((progress) => ({ ...progress, current: word.english, })); try { const response = await fetch(`/api/words/${word.id}/generate-audio`, {method: "POST"}); if (response.ok) success += 1; else failed += 1; } catch { failed += 1; } setBatchAudioProgress({ running: true, total: filteredWords.length, done: index + 1, success, failed, current: word.english, }); } toast.success("当前词库语音生成完成", { description: `成功 ${success} 个,失败 ${failed} 个`, }); setBatchAudioProgress({ running: false, total: 0, done: 0, success: 0, failed: 0, current: "", }); await loadData(); } async function saveClassType() { const name = editingClassType ? editingClassType.name : classTypeName; const response = await fetch(editingClassType ? `/api/class-types/${editingClassType.id}` : "/api/class-types", { 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(); if (!response.ok) { toast.error(data.message ?? "保存班级类型失败"); return; } toast.success(editingClassType ? "班级类型已更新" : "班级类型已添加"); setClassTypeName(""); setEditingClassType(null); await loadData(); } async function deleteClassType(id: number) { const response = await fetch(`/api/class-types/${id}`, {method: "DELETE"}); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "删除班级类型失败"); return; } toast.success("班级类型已删除"); await loadData(); } async function toggleClassType(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 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", headers: {"Content-Type": "application/json"}, body: JSON.stringify(settings), }); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "保存设置失败"); return; } setSettings(mergeSettings(data.settings)); setSettingsDialogOpen(false); toast.success("设置已保存"); } async function previewTtsVoice() { const toastId = toast.loading("正在生成试听语音"); const response = await fetch("/api/tts-preview", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ voice: settings.ttsVoice, rate: settings.ttsRate, text: "Hello, welcome to English PK.", }), }); const data = await response.json(); if (!response.ok) { toast.error(data.message ?? "试听语音失败", {id: toastId}); return; } await new Audio(data.audio).play(); 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 (
后台管理

English PK 管理台

启用词库 {enabledCount} PK 页面会从启用词库中随机出题。 总词库 {wordList.length} {grades.map((grade) => ( {grade}: {wordList.filter((word) => word.grade === grade).length} ))} 已生成语音 {audioCount} 编辑单词后语音会清空,需要重新生成。
词库管理
{loading && 加载中}
{batchAudioProgress.running && (
正在生成:{batchAudioProgress.current || "准备中"}
{batchAudioProgress.done}/{batchAudioProgress.total} 成功 {batchAudioProgress.success} 失败 {batchAudioProgress.failed}
)}
setQuery(event.target.value)} placeholder="搜索题库英语" className="pl-9" />
年级 班级类型 图标/图片 题库英语 中文 语音 状态 操作 {filteredWords.map((word) => ( {word.grade} {word.classTypeName} {word.english} {word.chinese || 未设置} {word.audio ? ( ) : ( )} toggleWord(word)}/>
))}
{wordForm.id ? "编辑词库" : "添加词库"} 编辑英语内容后,原语音会清空并需要重新生成。
是否启用
停用后不会进入 PK 题库。
setWordForm((form) => ({...form, enabled: !form.enabled}))} />
设置 配置语音、课堂规则、班级类型和 PK 页面入口。
{settingsSections.map((section) => { const Icon = section.icon; const active = settingsSection === section.id; return ( ); })}
{settingsSection === "voice" && (

语音设置

用于生成单词音频,也会用于 PK 页面缺少音频时的生成。

Edge TTS 声音
)} {settingsSection === "rules" && (

课堂规则

控制 PK 页面计分规则和自动播放行为。

自动朗读
每题开始时播放单词。
setSettings((value) => ({ ...value, autoSpeak: !value.autoSpeak }))} />
)} {settingsSection === "classTypes" && (

班级类型

这里可以按年级定制班级类型,PK 页面会读取当前年级下的启用项。

editingClassType ? setEditingClassType({ ...editingClassType, name: event.target.value }) : setClassTypeName(event.target.value) } placeholder="班级类型名称" /> {editingClassType && ( )}
{classTypesForSettings.map((classType) => (
{classType.name}
{classType.grade} · {classType.enabled ? "已启用" : "已停用"} · 当前单词{classType.showCurrentWord ? "显示" : "隐藏"}
))} {classTypesForSettings.length === 0 && (
当前年级还没有班级类型。
)}
)} {settingsSection === "wordImport" && (

一键导入单词

表格表头固定为:词库英语、中文、图标/图片。导入会写入当前选择的年级和班级类型。

导出导入表格

会导出当前年级和班级类型下的词库;没有词条时会导出一行示例,可直接改完再导入。

导入表格

同一年级和班级类型中已存在的英语会被排除;新增英语会自动加入当前词库。

void importWordExcel(event.target.files?.[0])} />
)} {settingsSection === "preview" && (

页面预览

打开比赛页面检查课堂展示效果。

)}
); } function WordImagePreview({value}: { value: string }) { if (!value) { return 未设置; } if (isImageSource(value)) { return ; } return {value}; } 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 { return { ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice, ttsRate: settings.ttsRate || defaultSettings.ttsRate, scorePerCorrect: Number(settings.scorePerCorrect ?? defaultSettings.scorePerCorrect), scorePerWrong: Number(settings.scorePerWrong ?? defaultSettings.scorePerWrong), autoSpeak: settings.autoSpeak ?? defaultSettings.autoSpeak, wordDisplayMode: settings.wordDisplayMode === "capitalize" || settings.wordDisplayMode === "input" || settings.wordDisplayMode === "uppercase" ? settings.wordDisplayMode : defaultSettings.wordDisplayMode, pkWordCount: Math.max(0, Math.floor(Number(settings.pkWordCount ?? defaultSettings.pkWordCount))), pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount ?? defaultSettings.pkOptionCount)), }; } function normalizeOptionCount(value: number) { const count = Math.floor(value); if (!Number.isFinite(count)) return defaultSettings.pkOptionCount; if (count < 2) return 2; if (count > 8) return 8; return count % 2 === 0 ? count : count + 1; }