1066 lines
50 KiB
TypeScript
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">
|
|
建议使用双数,如 2、4、6,保存时会自动修正。
|
|
</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;
|
|
}
|