Files
englishPk/app/admin/page.tsx
T
2026-06-29 23:56:38 +08:00

1066 lines
50 KiB
TypeScript

"use client";
import {useEffect, useMemo, useState} from "react";
import Link from "next/link";
import {toast} from "sonner";
import {
Eye,
Gamepad2,
Mic2,
Pencil,
Play,
Plus,
RefreshCw,
Save,
School,
Search,
Settings2,
SlidersHorizontal,
Trash2,
} 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;
name: string;
enabled: 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" | "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: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye},
];
function createEmptyWordForm(classTypeId = 0): 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 [classTypeName, setClassTypeName] = useState("");
const [editingClassType, setEditingClassType] = useState<ClassType | null>(null);
const [loading, setLoading] = useState(true);
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: form.classTypeId || classTypesData.classTypes[0]?.id || 0,
}));
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]);
function openCreateWordDialog() {
setWordForm(createEmptyWordForm(classTypes[0]?.id || 0));
setWordDialogOpen(true);
}
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({
name,
enabled: editingClassType?.enabled ?? 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}),
});
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});
}
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 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) => setGradeFilter(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>
{classTypes.map((classType) => (
<option key={classType.id} value={classType.id}>
{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) => setWordForm((form) => ({
...form,
grade: 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"
>
{classTypes.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>
<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">
{classTypes.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>
</div>
<div className="flex items-center gap-2">
<Switch checked={classType.enabled}
onClick={() => toggleClassType(classType)}/>
<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>
))}
</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 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;
}