Files
englishPk/app/admin/page.tsx
T
hanhan 17fc24edc7
Build Docker Package / docker-package (push) Successful in 6m34s
feat: add word import and class type display controls
2026-07-02 14:34:00 +08:00

1320 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<WordRecord[]>([]);
const [classTypes, setClassTypes] = useState<ClassType[]>([]);
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
const [query, setQuery] = useState("");
const [gradeFilter, setGradeFilter] = useState<"all" | Grade>("all");
const [classTypeFilter, setClassTypeFilter] = useState<"all" | number>("all");
const [wordForm, setWordForm] = useState<WordForm>(createEmptyWordForm());
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,
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<AppSettings> };
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 (
<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>
<div
className="mb-2 inline-flex items-center gap-2 rounded-md bg-white px-3 py-1 text-sm font-medium text-slate-600 shadow-sm">
<Settings2 className="size-4"/>
后台管理
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-950 sm:text-4xl">English PK 管理台</h1>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href="/pk">
<Gamepad2 className="size-4"/>
打开PK页面
</Link>
</Button>
<Button variant="outline" onClick={() => setSettingsDialogOpen(true)}>
<Settings2 className="size-4"/>
设置
</Button>
<Button variant="outline" onClick={openWordImportSection}>
<FileSpreadsheet className="size-4"/>
一键导入单词
</Button>
<Button onClick={loadData}>
<RefreshCw className="size-4"/>
刷新
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardHeader className="p-4 pb-2">
<CardDescription>启用词库</CardDescription>
<CardTitle className="text-2xl">{enabledCount}</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 text-xs text-muted-foreground">PK
页面会从启用词库中随机出题。</CardContent>
</Card>
<Card>
<CardHeader className="p-4 pb-2">
<CardDescription>总词库</CardDescription>
<CardTitle className="text-2xl">{wordList.length}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-1 px-4 pb-4">
{grades.map((grade) => (
<Badge key={grade} variant="secondary">
{grade}: {wordList.filter((word) => word.grade === grade).length}
</Badge>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 pb-2">
<CardDescription>已生成语音</CardDescription>
<CardTitle className="text-2xl">{audioCount}</CardTitle>
</CardHeader>
<CardContent
className="px-4 pb-4 text-xs text-muted-foreground">编辑单词后语音会清空,需要重新生成。</CardContent>
</Card>
</div>
<div className="mt-5">
<Card>
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<CardTitle>词库管理</CardTitle>
<div className="flex flex-wrap items-center gap-2">
{loading && <Badge variant="secondary">加载中</Badge>}
<Button variant="outline" onClick={openCreateWordDialog}>
<Plus className="size-4"/>
添加词库
</Button>
<Button
variant="outline"
onClick={generateFilteredAudios}
disabled={batchAudioProgress.running || filteredWords.length === 0}
>
<Mic2 className="size-4"/>
一键生成当前词库语音
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{batchAudioProgress.running && (
<div className="mb-5 rounded-lg border bg-white p-4">
<div
className="mb-2 flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="font-medium text-slate-800">
正在生成:{batchAudioProgress.current || "准备中"}
</div>
<div className="text-slate-500">
{batchAudioProgress.done}/{batchAudioProgress.total}
<span className="ml-3 text-emerald-600">成功 {batchAudioProgress.success}</span>
<span className="ml-3 text-red-500">失败 {batchAudioProgress.failed}</span>
</div>
</div>
<div className="h-3 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{
width:
batchAudioProgress.total === 0
? "0%"
: `${Math.round((batchAudioProgress.done / batchAudioProgress.total) * 100)}%`,
}}
/>
</div>
</div>
)}
<div className="mb-4 flex flex-col gap-3 lg:flex-row">
<div className="relative flex-1">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索题库英语"
className="pl-9"
/>
</div>
<select
value={gradeFilter}
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>
{grades.map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
<select
value={classTypeFilter}
onChange={(event) =>
setClassTypeFilter(event.target.value === "all" ? "all" : Number(event.target.value))
}
className="h-10 rounded-md border bg-white px-3 text-sm"
>
<option value="all">全部班型</option>
{classTypesForFilter.map((classType) => (
<option key={classType.id} value={classType.id}>
{gradeFilter === "all" ? `${classType.grade} - ${classType.name}` : classType.name}
</option>
))}
</select>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>年级</TableHead>
<TableHead>班级类型</TableHead>
<TableHead>图标/图片</TableHead>
<TableHead>题库英语</TableHead>
<TableHead>中文</TableHead>
<TableHead>语音</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredWords.map((word) => (
<TableRow key={word.id}>
<TableCell>
<Badge variant="outline">{word.grade}</Badge>
</TableCell>
<TableCell>{word.classTypeName}</TableCell>
<TableCell>
<WordImagePreview value={word.image}/>
</TableCell>
<TableCell className="font-semibold">{word.english}</TableCell>
<TableCell>{word.chinese ||
<span className="text-muted-foreground">未设置</span>}</TableCell>
<TableCell>
{word.audio ? (
<Button variant="outline" size="sm"
onClick={() => new Audio(word.audio).play()}>
<Play className="size-4"/>
试听
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => generateAudio(word)}>
<Mic2 className="size-4"/>
生成
</Button>
)}
</TableCell>
<TableCell>
<Switch checked={word.enabled} onClick={() => toggleWord(word)}/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="icon"
onClick={() => openEditWordDialog(word)}>
<Pencil className="size-4"/>
</Button>
<Button variant="outline" size="icon"
onClick={() => generateAudio(word)}>
<Mic2 className="size-4"/>
</Button>
<Button variant="destructive" size="icon"
onClick={() => deleteWord(word.id)}>
<Trash2 className="size-4"/>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Dialog open={wordDialogOpen} onOpenChange={setWordDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{wordForm.id ? "编辑词库" : "添加词库"}</DialogTitle>
<DialogDescription>编辑英语内容后,原语音会清空并需要重新生成。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium">
年级
<select
value={wordForm.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) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm font-medium">
班级类型
<select
value={wordForm.classTypeId}
onChange={(event) => setWordForm((form) => ({
...form,
classTypeId: Number(event.target.value)
}))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{classTypesForWordForm.length === 0 && <option value={0}>请先创建该年级班型</option>}
{classTypesForWordForm.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm font-medium">
题库英语
<Input
value={wordForm.english}
onChange={(event) => setWordForm((form) => ({...form, english: event.target.value}))}
placeholder="apple"
/>
</label>
<label className="space-y-2 text-sm font-medium">
中文
<Input
value={wordForm.chinese}
onChange={(event) => setWordForm((form) => ({...form, chinese: event.target.value}))}
placeholder="苹果"
/>
</label>
<label className="space-y-2 text-sm font-medium">
图标/图片
<Input
value={wordForm.image}
onChange={(event) => setWordForm((form) => ({...form, image: event.target.value}))}
placeholder="🍎 或 /apple.png"
/>
</label>
<div className="flex items-center justify-between rounded-md border p-3 sm:col-span-2">
<div>
<div className="font-medium">是否启用</div>
<div className="text-sm text-muted-foreground">停用后不会进入 PK 题库。</div>
</div>
<Switch
checked={wordForm.enabled}
onClick={() => setWordForm((form) => ({...form, enabled: !form.enabled}))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={saveWordAndGenerateAudio}>
<Mic2 className="size-4"/>
保存并生成语音
</Button>
<Button onClick={saveWord}>
<Save className="size-4"/>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>设置</DialogTitle>
<DialogDescription>配置语音、课堂规则、班级类型和 PK 页面入口。</DialogDescription>
</DialogHeader>
<div className="grid min-h-[460px] gap-4 md:grid-cols-[220px_1fr]">
<div
className="flex gap-2 overflow-x-auto rounded-lg border bg-slate-50 p-2 md:flex-col md:overflow-visible">
{settingsSections.map((section) => {
const Icon = section.icon;
const active = settingsSection === section.id;
return (
<button
key={section.id}
type="button"
onClick={() => setSettingsSection(section.id)}
className={`flex min-w-[160px] items-start gap-3 rounded-md px-3 py-3 text-left text-sm transition md:min-w-0 ${
active ? "bg-white text-blue-700 shadow-sm" : "text-slate-600 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Icon className="mt-0.5 size-4 shrink-0"/>
<span>
<span className="block font-semibold">{section.label}</span>
<span className="mt-1 block text-xs text-muted-foreground">{section.description}</span>
</span>
</button>
);
})}
</div>
<div className="rounded-lg border bg-white p-5">
{settingsSection === "voice" && (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold">语音设置</h3>
<p className="mt-1 text-sm text-muted-foreground">用于生成单词音频,也会用于 PK
页面缺少音频时的生成。</p>
</div>
<div className="space-y-2 text-sm font-medium">
<div>Edge TTS 声音</div>
<div className="flex gap-2">
<select
value={settings.ttsVoice}
onChange={(event) => setSettings((value) => ({
...value,
ttsVoice: event.target.value
}))}
className="h-10 flex-1 rounded-md border bg-white px-3 text-sm"
>
{ttsVoiceOptions.map((voice) => (
<option key={voice.value} value={voice.value}>
{voice.label}
</option>
))}
</select>
<Button variant="outline" onClick={previewTtsVoice}>
<Play className="size-4"/>
试听
</Button>
</div>
</div>
<label className="block space-y-2 text-sm font-medium">
语速
<select
value={settings.ttsRate}
onChange={(event) => setSettings((value) => ({
...value,
ttsRate: event.target.value
}))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{ttsRateOptions.map((rate) => (
<option key={rate.value} value={rate.value}>
{rate.label}
</option>
))}
</select>
</label>
</div>
)}
{settingsSection === "rules" && (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold">课堂规则</h3>
<p className="mt-1 text-sm text-muted-foreground">控制 PK
页面计分规则和自动播放行为。</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium">
答对加分
<Input
type="number"
min={1}
value={settings.scorePerCorrect}
onChange={(event) =>
setSettings((value) => ({
...value,
scorePerCorrect: Number(event.target.value)
}))
}
/>
</label>
<label className="space-y-2 text-sm font-medium">
答错扣分
<Input
type="number"
min={0}
value={settings.scorePerWrong}
onChange={(event) =>
setSettings((value) => ({
...value,
scorePerWrong: Number(event.target.value)
}))
}
/>
</label>
<label className="space-y-2 text-sm font-medium">
PK 显示单词数
<Input
type="number"
min={0}
value={settings.pkWordCount}
onChange={(event) =>
setSettings((value) => ({
...value,
pkWordCount: Number(event.target.value)
}))
}
/>
<span className="block text-xs font-normal text-muted-foreground">
0 表示使用当前题库全部单词。
</span>
</label>
<label className="space-y-2 text-sm font-medium">
PK 选项数量
<Input
type="number"
min={2}
max={8}
step={2}
value={settings.pkOptionCount}
onChange={(event) =>
setSettings((value) => ({
...value,
pkOptionCount: Number(event.target.value)
}))
}
/>
<span className="block text-xs font-normal text-muted-foreground">
建议使用双数,如 246,保存时会自动修正。
</span>
</label>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="font-medium">自动朗读</div>
<div className="text-sm text-muted-foreground">每题开始时播放单词。</div>
</div>
<Switch
checked={settings.autoSpeak}
onClick={() => setSettings((value) => ({
...value,
autoSpeak: !value.autoSpeak
}))}
/>
</div>
<label className="block space-y-2 text-sm font-medium">
PK 单词显示
<select
value={settings.wordDisplayMode}
onChange={(event) =>
setSettings((value) => ({
...value,
wordDisplayMode: event.target.value as AppSettings["wordDisplayMode"],
}))
}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{wordDisplayModeOptions.map((mode) => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
</label>
</div>
)}
{settingsSection === "classTypes" && (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold">班级类型</h3>
<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}
onChange={(event) =>
editingClassType
? setEditingClassType({
...editingClassType,
name: event.target.value
})
: setClassTypeName(event.target.value)
}
placeholder="班级类型名称"
/>
<Button onClick={saveClassType}>{editingClassType ? "保存" : "添加"}</Button>
{editingClassType && (
<Button variant="outline" onClick={() => setEditingClassType(null)}>
取消
</Button>
)}
</div>
<div className="space-y-2">
{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.grade} · {classType.enabled ? "已启用" : "已停用"} · 当前单词{classType.showCurrentWord ? "显示" : "隐藏"}
</div>
</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}
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"/>
</Button>
<Button variant="destructive" size="icon"
onClick={() => deleteClassType(classType.id)}>
<Trash2 className="size-4"/>
</Button>
</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>
)}
{settingsSection === "preview" && (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold">页面预览</h3>
<p className="mt-1 text-sm text-muted-foreground">打开比赛页面检查课堂展示效果。</p>
</div>
<Button asChild className="w-full sm:w-auto">
<Link href="/pk">
<Eye className="size-4"/>
预览PK页面
</Link>
</Button>
</div>
)}
</div>
</div>
<DialogFooter>
<Button onClick={saveSettings}>
<Save className="size-4"/>
保存设置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
function WordImagePreview({value}: { value: string }) {
if (!value) {
return <span className="text-sm text-muted-foreground">未设置</span>;
}
if (isImageSource(value)) {
return <img src={value} alt="" className="size-12 rounded-md border bg-white object-contain p-1"/>;
}
return <span className="text-3xl leading-none">{value}</span>;
}
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,
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;
}