feat: add word import and class type display controls
Build Docker Package / docker-package (push) Successful in 6m34s

This commit is contained in:
2026-07-02 14:34:00 +08:00
parent 0378504be0
commit 17fc24edc7
16 changed files with 844 additions and 66 deletions
+276 -22
View File
@@ -1,10 +1,12 @@
"use client";
import {useEffect, useMemo, useState} from "react";
import {useEffect, useMemo, useRef, useState} from "react";
import Link from "next/link";
import {toast} from "sonner";
import {
Download,
Eye,
FileSpreadsheet,
Gamepad2,
Mic2,
Pencil,
@@ -17,6 +19,7 @@ import {
Settings2,
SlidersHorizontal,
Trash2,
Upload,
} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -37,8 +40,10 @@ type Grade = "小班" | "中班" | "大班";
type ClassType = {
id: number;
grade: Grade;
name: string;
enabled: boolean;
showCurrentWord: boolean;
};
type WordRecord = {
@@ -84,7 +89,7 @@ type BatchAudioProgress = {
current: string;
};
type SettingsSection = "voice" | "rules" | "classTypes" | "preview";
type SettingsSection = "voice" | "rules" | "classTypes" | "wordImport" | "preview";
const grades: Grade[] = ["小班", "中班", "大班"];
const defaultSettings: AppSettings = {
@@ -126,12 +131,13 @@ const settingsSections: Array<{ id: SettingsSection; label: string; description:
{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): WordForm {
function createEmptyWordForm(classTypeId = 0, grade: Grade = "中班"): WordForm {
return {
grade: "中班",
grade,
classTypeId,
english: "",
chinese: "",
@@ -152,9 +158,14 @@ export default function AdminPage() {
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,
@@ -183,8 +194,9 @@ export default function AdminPage() {
setSettings(mergeSettings(settingsData.settings));
setWordForm((form) => ({
...form,
classTypeId: form.classTypeId || classTypesData.classTypes[0]?.id || 0,
classTypeId: getNextClassTypeId(classTypesData.classTypes, form.grade, form.classTypeId),
}));
setImportClassTypeId((current) => getNextClassTypeId(classTypesData.classTypes, importGrade, current));
setLoading(false);
}
@@ -198,12 +210,61 @@ export default function AdminPage() {
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() {
setWordForm(createEmptyWordForm(classTypes[0]?.id || 0));
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,
@@ -357,8 +418,10 @@ export default function AdminPage() {
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();
@@ -391,7 +454,11 @@ export default function AdminPage() {
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}),
body: JSON.stringify({
name: classType.name,
enabled: !classType.enabled,
showCurrentWord: classType.showCurrentWord,
}),
});
const data = await response.json();
@@ -403,6 +470,26 @@ export default function AdminPage() {
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",
@@ -443,6 +530,62 @@ export default function AdminPage() {
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">
@@ -465,6 +608,10 @@ export default function AdminPage() {
<Settings2 className="size-4"/>
</Button>
<Button variant="outline" onClick={openWordImportSection}>
<FileSpreadsheet className="size-4"/>
</Button>
<Button onClick={loadData}>
<RefreshCw className="size-4"/>
@@ -567,7 +714,7 @@ export default function AdminPage() {
</div>
<select
value={gradeFilter}
onChange={(event) => setGradeFilter(event.target.value as "all" | Grade)}
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>
@@ -585,9 +732,9 @@ export default function AdminPage() {
className="h-10 rounded-md border bg-white px-3 text-sm"
>
<option value="all"></option>
{classTypes.map((classType) => (
{classTypesForFilter.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
{gradeFilter === "all" ? `${classType.grade} - ${classType.name}` : classType.name}
</option>
))}
</select>
@@ -671,10 +818,7 @@ export default function AdminPage() {
<select
value={wordForm.grade}
onChange={(event) => setWordForm((form) => ({
...form,
grade: event.target.value as 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) => (
@@ -694,7 +838,8 @@ export default function AdminPage() {
}))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{classTypes.map((classType) => (
{classTypesForWordForm.length === 0 && <option value={0}></option>}
{classTypesForWordForm.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
</option>
@@ -943,9 +1088,23 @@ export default function AdminPage() {
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">PK
</p>
<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}
@@ -968,17 +1127,29 @@ export default function AdminPage() {
</div>
<div className="space-y-2">
{classTypes.map((classType) => (
{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.enabled ? "已启用" : "已停用"}</div>
className="text-xs text-muted-foreground">
{classType.grade} · {classType.enabled ? "已启用" : "已停用"} · {classType.showCurrentWord ? "显示" : "隐藏"}
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={classType.enabled}
onClick={() => toggleClassType(classType)}/>
<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"/>
@@ -990,6 +1161,84 @@ export default function AdminPage() {
</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>
)}
@@ -1038,6 +1287,11 @@ 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,
+2 -1
View File
@@ -15,6 +15,7 @@ export async function PUT(request: NextRequest, context: RouteContext) {
const classType = updateClassType(Number(id), {
name: String(body.name ?? ""),
enabled: Boolean(body.enabled),
showCurrentWord: body.showCurrentWord !== false,
});
return NextResponse.json({ classType });
@@ -35,7 +36,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "班级类型已存在";
if (message.includes("UNIQUE")) return "该年级下班级类型已存在";
if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除";
return message;
}
+16 -4
View File
@@ -1,18 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { createClassType, listClassTypes } from "@/lib/db";
import { createClassType, listClassTypes, type Grade } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const includeDisabled = request.nextUrl.searchParams.get("includeDisabled") !== "false";
return NextResponse.json({ classTypes: listClassTypes(includeDisabled) });
const grade = request.nextUrl.searchParams.get("grade") ?? undefined;
return NextResponse.json({
classTypes: listClassTypes({
grade: parseGrade(grade),
includeDisabled,
}),
});
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const classType = createClassType(String(body.name ?? ""));
const classType = createClassType(body.grade as Grade, String(body.name ?? ""), {
showCurrentWord: body.showCurrentWord !== false,
});
return NextResponse.json({ classType }, { status: 201 });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
@@ -21,6 +29,10 @@ export async function POST(request: NextRequest) {
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "班级类型已存在";
if (message.includes("UNIQUE")) return "该年级下班级类型已存在";
return message;
}
function parseGrade(value: string | undefined) {
return value === "小班" || value === "中班" || value === "大班" ? value : undefined;
}
+1 -1
View File
@@ -30,7 +30,7 @@ export async function POST(_request: NextRequest, context: RouteContext) {
const safeEnglish = word.english.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
const fileName = `${word.id}-${safeEnglish || "word"}.mp3`;
const filePath = getAudioFilePath(fileName);
const publicPath = getAudioPublicPath(fileName);
const publicPath = `${getAudioPublicPath(fileName)}?v=${Date.now()}`;
const settings = getSettings();
const tts = new EdgeTTS(word.english, settings.ttsVoice, {
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest } from "next/server";
import { listWords } from "@/lib/db";
import { buildWordExcel } from "@/lib/word-excel";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const grade = searchParams.get("grade") ?? undefined;
const classTypeId = Number(searchParams.get("classTypeId"));
const words = listWords({
grade,
classTypeId: Number.isFinite(classTypeId) && classTypeId > 0 ? classTypeId : undefined,
});
const workbook = buildWordExcel(words);
return new Response(workbook, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${encodeURIComponent("词库导入表格")}.xlsx"`,
"Cache-Control": "no-store",
},
});
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { importWords, type Grade } from "@/lib/db";
import { parseWordExcel } from "@/lib/word-excel";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const grade = String(formData.get("grade") ?? "") as Grade;
const classTypeId = Number(formData.get("classTypeId"));
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ message: "请选择要导入的 Excel 表格" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const rows = parseWordExcel(buffer);
const result = importWords(grade, classTypeId, rows);
return NextResponse.json({ result });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "导入失败";
if (message.includes("FOREIGN KEY")) return "班级类型不存在";
if (message.includes("Unsupported file")) return "无法读取该表格,请使用 .xlsx 文件";
return message;
}
+6 -1
View File
@@ -60,10 +60,15 @@
.pk-game-card {
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
min-height: min(720px, calc(100svh - 88px));
width: 100%;
}
.pk-game-grid {
flex: 1;
}
.cursor-hammer {
--hammer-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(-36 16 16)'%3E%3Crect x='14' y='9' width='4' height='20' rx='2' fill='%23815a2f'/%3E%3Crect x='13' y='8' width='6' height='3' rx='1.5' fill='%236b4220'/%3E%3Cpath d='M8 5h14c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23f2b84b'/%3E%3Cpath d='M8 5h5v10H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23d78a2a'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3h-3V5Z' fill='%23999'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v1h-6V5Z' fill='%23c9c9c9'/%3E%3C/g%3E%3C/svg%3E") 10 26, auto;
cursor: var(--hammer-cursor);
+23 -5
View File
@@ -10,6 +10,7 @@ import { WordImage } from "./WordImage";
type GameScreenProps = {
selectedGrade: Grade;
selectedClassTypeName?: string;
showCurrentWord: boolean;
roundWords: WordItem[];
currentWord: WordItem;
options: WordItem[];
@@ -30,6 +31,7 @@ type GameScreenProps = {
export function GameScreen({
selectedGrade,
selectedClassTypeName,
showCurrentWord,
roundWords,
currentWord,
options,
@@ -83,8 +85,13 @@ export function GameScreen({
</p>
</div>
<div className="pk-game-grid grid gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]">
<div className="flex min-w-0 flex-col">
<div
className={cn(
"pk-game-grid grid items-center gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]",
!showCurrentWord && "pk-game-grid-word-hidden",
)}
>
<div className="flex min-w-0 flex-col justify-center">
<div className="flex items-center gap-[clamp(10px,1.8vw,18px)]">
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
<div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div>
@@ -104,7 +111,14 @@ export function GameScreen({
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="pk-listen-area flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center">
<div
className={cn(
"pk-listen-area flex flex-col items-center justify-center text-center",
showCurrentWord
? "py-[clamp(24px,5vw,70px)]"
: "min-h-[clamp(150px,24vh,230px)] py-[clamp(16px,3vw,34px)]",
)}
>
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p>
<button
onClick={() => onSpeakWord(currentWord)}
@@ -113,8 +127,12 @@ export function GameScreen({
>
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
</button>
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
{showCurrentWord && (
<>
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
</>
)}
</div>
</div>
+37 -8
View File
@@ -17,20 +17,41 @@ export function usePkSetup() {
useEffect(() => {
async function loadInitialSetup() {
setLoadingSetup(true);
const [classTypesResponse, nextSettings] = await Promise.all([
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
loadSettings(),
]);
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
setClassTypes(classTypesData.classTypes);
const nextSettings = await loadSettings();
setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
void loadInitialSetup();
}, []);
useEffect(() => {
let ignore = false;
async function loadClassTypes() {
setLoadingSetup(true);
setSelectedClassTypeId(0);
const params = new URLSearchParams({
includeDisabled: "false",
grade: selectedGrade,
});
const response = await fetch(`/api/class-types?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { classTypes: ClassType[] };
if (!ignore) {
setClassTypes(data.classTypes);
setSelectedClassTypeId(data.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
}
void loadClassTypes();
return () => {
ignore = true;
};
}, [selectedGrade]);
useEffect(() => {
function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
@@ -41,6 +62,8 @@ export function usePkSetup() {
}, []);
useEffect(() => {
let ignore = false;
async function loadWords() {
if (!selectedClassTypeId) {
setAvailableWords([]);
@@ -54,10 +77,16 @@ export function usePkSetup() {
});
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] };
setAvailableWords(data.words);
if (!ignore) {
setAvailableWords(data.words);
}
}
void loadWords();
return () => {
ignore = true;
};
}, [selectedGrade, selectedClassTypeId]);
return {
+1
View File
@@ -128,6 +128,7 @@ export default function PkPage() {
<GameScreen
selectedGrade={selectedGrade}
selectedClassTypeName={selectedClassType?.name}
showCurrentWord={selectedClassType?.showCurrentWord ?? true}
roundWords={roundWords}
currentWord={currentWord}
options={options}
+2
View File
@@ -4,8 +4,10 @@ export type Grade = "小班" | "中班" | "大班";
export type ClassType = {
id: number;
grade: Grade;
name: string;
enabled: boolean;
showCurrentWord: boolean;
};
export type WordItem = {