1320 lines
64 KiB
TypeScript
1320 lines
64 KiB
TypeScript
"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">
|
||
建议使用双数,如 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>
|
||
<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;
|
||
}
|