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

618 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { RotateCcw, Trophy, Volume2 } from "lucide-react";
import { cn } from "@/lib/utils";
type Team = "red" | "blue";
type AnswerState = "idle" | "correct" | "wrong";
type Grade = "小班" | "中班" | "大班";
type ClassType = {
id: number;
name: string;
enabled: boolean;
};
type WordItem = {
id: number;
grade: Grade;
classTypeId: number;
classTypeName: string;
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;
};
const grades: Grade[] = ["小班", "中班", "大班"];
function shuffleArray<T>(array: T[]) {
const next = [...array];
for (let i = next.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[next[i], next[j]] = [next[j], next[i]];
}
return next;
}
function TeamCard({
team,
active,
score,
}: {
team: Team;
active: boolean;
score: number;
}) {
const red = team === "red";
return (
<div
className={cn(
"flex-1 rounded-lg border-4 p-[clamp(10px,1.6vw,16px)] text-center transition",
red ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
active && (red ? "border-red-400 shadow-lg" : "border-blue-400 shadow-lg"),
!active && "border-transparent",
)}
>
<div className="text-[clamp(16px,1.8vw,22px)] font-bold">{red ? "🔴 红队" : "🔵 蓝队"}</div>
<div className="text-[clamp(30px,4vw,48px)] font-black leading-tight">{score}</div>
</div>
);
}
export default function PkPage() {
const [selectedGrade, setSelectedGrade] = useState<Grade>("中班");
const [classTypes, setClassTypes] = useState<ClassType[]>([]);
const [selectedClassTypeId, setSelectedClassTypeId] = useState<number>(0);
const [availableWords, setAvailableWords] = useState<WordItem[]>([]);
const [loadingSetup, setLoadingSetup] = useState(true);
const [started, setStarted] = useState(false);
const [ended, setEnded] = useState(false);
const [roundWords, setRoundWords] = useState<WordItem[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
const [currentTeam, setCurrentTeam] = useState<Team>("red");
const [scoreRed, setScoreRed] = useState(0);
const [scoreBlue, setScoreBlue] = useState(0);
const [answerState, setAnswerState] = useState<AnswerState>("idle");
const [selectedWord, setSelectedWord] = useState<number | null>(null);
const [speechTip, setSpeechTip] = useState("点击开始后可朗读单词。");
const [settings, setSettings] = useState<AppSettings>({
ttsVoice: "en-US-EmmaMultilingualNeural",
ttsRate: "-10%",
scorePerCorrect: 10,
scorePerWrong: 0,
autoSpeak: true,
wordDisplayMode: "uppercase",
pkWordCount: 0,
pkOptionCount: 4,
});
const audioContextRef = useRef<AudioContext | null>(null);
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);
setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
void loadInitialSetup();
}, []);
useEffect(() => {
function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
}
window.addEventListener("focus", refreshSettingsOnFocus);
return () => window.removeEventListener("focus", refreshSettingsOnFocus);
}, []);
useEffect(() => {
async function loadWords() {
if (!selectedClassTypeId) {
setAvailableWords([]);
return;
}
const params = new URLSearchParams({
grade: selectedGrade,
classTypeId: String(selectedClassTypeId),
enabledOnly: "true",
});
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] };
setAvailableWords(data.words);
}
void loadWords();
}, [selectedGrade, selectedClassTypeId]);
const selectedClassType = classTypes.find((classType) => classType.id === selectedClassTypeId);
const currentWord = roundWords[questionIndex] ?? availableWords[0];
const optionCount = normalizeOptionCount(settings.pkOptionCount);
const options = useMemo(() => {
if (!currentWord) return [];
const wrongOptions = availableWords.filter((word) => word.id !== currentWord.id);
return shuffleArray([currentWord, ...shuffleArray(wrongOptions).slice(0, optionCount - 1)]);
}, [currentWord, availableWords, optionCount]);
const progress = roundWords.length > 0 ? (questionIndex / roundWords.length) * 100 : 0;
const roundWordLimit = Math.max(0, Math.floor(settings.pkWordCount || 0));
const plannedRoundCount = getFairRoundCount(availableWords.length, roundWordLimit);
const winner =
scoreRed > scoreBlue ? "red" : scoreBlue > scoreRed ? "blue" : "draw";
function ensureAudioContext() {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
}
return audioContextRef.current;
}
async function playTone(correct: boolean) {
const audioContext = ensureAudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
const duration = correct ? 0.4 : 0.3;
if (correct) {
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
} else {
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
}
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + duration);
}
async function speakWord(word: WordItem) {
if (word.audio) {
const audio = new Audio(word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
return;
}
try {
setSpeechTip(`正在生成语音:${word.english}`);
const response = await fetch(`/api/words/${word.id}/generate-audio`, { method: "POST" });
if (!response.ok) throw new Error("generate failed");
const data = (await response.json()) as { word: WordItem };
const audio = new Audio(data.word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
setRoundWords((items) => items.map((item) => (item.id === data.word.id ? data.word : item)));
setAvailableWords((items) => items.map((item) => (item.id === data.word.id ? data.word : item)));
} catch {
speakSystemWord(word.english);
}
}
function speakSystemWord(word: string) {
if (!("speechSynthesis" in window)) {
setSpeechTip("当前浏览器不支持英文朗读,请老师带读。");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
const voices = window.speechSynthesis.getVoices();
utterance.voice =
voices.find((voice) => /^en[-_]?US/i.test(voice.lang)) ??
voices.find((voice) => /^en/i.test(voice.lang)) ??
null;
utterance.lang = utterance.voice?.lang ?? "en-US";
utterance.rate = 0.85;
utterance.pitch = 1;
window.speechSynthesis.speak(utterance);
setSpeechTip(`正在朗读:${word}`);
}
function startGame() {
const nextRound = shuffleArray(availableWords).slice(0, plannedRoundCount);
if (nextRound.length === 0) return;
setRoundWords(nextRound);
setQuestionIndex(0);
setCurrentTeam("red");
setScoreRed(0);
setScoreBlue(0);
setAnswerState("idle");
setSelectedWord(null);
setStarted(true);
setEnded(false);
setSpeechTip(settings.autoSpeak ? "准备朗读中..." : "自动朗读已关闭。");
if (settings.autoSpeak) window.setTimeout(() => void speakWord(nextRound[0]), 350);
}
function checkAnswer(option: WordItem) {
if (answerState !== "idle") return;
const correct = option.id === currentWord.id;
setSelectedWord(option.id);
setAnswerState(correct ? "correct" : "wrong");
playTone(correct);
if (correct) {
if (currentTeam === "red") setScoreRed((score) => score + settings.scorePerCorrect);
else setScoreBlue((score) => score + settings.scorePerCorrect);
} else if (settings.scorePerWrong > 0) {
if (currentTeam === "red") setScoreRed((score) => Math.max(0, score - settings.scorePerWrong));
else setScoreBlue((score) => Math.max(0, score - settings.scorePerWrong));
}
}
function nextQuestion() {
window.speechSynthesis?.cancel();
const nextIndex = questionIndex + 1;
const nextTeam = currentTeam === "red" ? "blue" : "red";
setCurrentTeam(nextTeam);
setAnswerState("idle");
setSelectedWord(null);
if (nextIndex >= roundWords.length) {
setEnded(true);
return;
}
setQuestionIndex(nextIndex);
setSpeechTip(settings.autoSpeak ? "准备朗读中..." : "自动朗读已关闭。");
if (settings.autoSpeak) window.setTimeout(() => void speakWord(roundWords[nextIndex]), 350);
}
if (!started) {
return (
<main className="mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-8">
<section className="w-full rounded-[28px] bg-white p-[clamp(24px,5vw,64px)] text-center shadow-soft">
<div className="mb-5 text-[clamp(72px,11vw,126px)] leading-none">🦊 VS 🦓</div>
<h1 className="text-balance text-[clamp(32px,5vw,56px)] font-black text-rose-500">字母单词PK赛</h1>
<p className="mt-4 text-[clamp(17px,2vw,22px)] text-slate-600">
红队蓝队轮流答题,答对加{settings.scorePerCorrect}
{settings.scorePerWrong > 0 ? `,答错扣${settings.scorePerWrong}分。` : ",答错不扣分。"}
</p>
<div className="mx-auto mt-8 grid max-w-3xl gap-5 rounded-2xl border bg-slate-50 p-5 text-left sm:p-6">
<div>
<div className="mb-3 text-sm font-semibold text-slate-600">选择年级</div>
<div className="grid grid-cols-3 gap-3">
{grades.map((grade) => (
<button
key={grade}
onClick={() => setSelectedGrade(grade)}
className={cn(
"rounded-lg border bg-white px-4 py-3 text-center text-base font-bold transition hover:border-rose-300",
selectedGrade === grade && "border-rose-500 bg-rose-50 text-rose-700 shadow-sm",
)}
>
{grade}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-sm font-semibold text-slate-600">选择班级类型</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{classTypes.map((classType) => (
<button
key={classType.id}
onClick={() => setSelectedClassTypeId(classType.id)}
className={cn(
"rounded-lg border bg-white px-4 py-3 text-center text-base font-bold transition hover:border-blue-300",
selectedClassTypeId === classType.id && "border-blue-500 bg-blue-50 text-blue-700 shadow-sm",
)}
>
{classType.name}
</button>
))}
</div>
</div>
<div className="rounded-lg bg-white px-4 py-3 text-center text-sm text-slate-600">
当前选择:<span className="font-bold text-slate-900">{selectedGrade}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{selectedClassType?.name ?? "未选择班型"}</span>
<span className="mx-2 text-slate-300">/</span>
本轮题目 <span className="font-bold text-emerald-600">{plannedRoundCount}</span>
<span className="mx-2 text-slate-300">/</span>
题库可用 <span className="font-bold text-slate-900">{availableWords.length}</span>
</div>
</div>
{!loadingSetup && availableWords.length === 0 && (
<p className="mt-4 text-sm font-medium text-red-500">当前年级和班级类型没有启用题目,请先到后台管理启用词库。</p>
)}
<button
onClick={startGame}
disabled={loadingSetup || availableWords.length === 0}
className="mt-8 rounded-full bg-rose-500 px-12 py-4 text-[clamp(18px,2vw,24px)] font-bold text-white shadow-lg transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
>
{loadingSetup ? "加载中..." : "开始PK"}
</button>
</section>
</main>
);
}
if (ended) {
return (
<main className="mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-8">
<section className="w-full rounded-[28px] bg-white p-[clamp(28px,6vw,72px)] text-center shadow-soft">
<div className="mb-5 text-[clamp(72px,10vw,118px)] leading-none">{winner === "draw" ? "🤝" : "🏆"}</div>
<h1 className="text-[clamp(32px,5vw,54px)] font-black text-rose-500">PK结束</h1>
<p
className={cn(
"mt-4 text-[clamp(26px,4vw,44px)] font-black",
winner === "red" && "text-red-600",
winner === "blue" && "text-blue-600",
winner === "draw" && "text-amber-500",
)}
>
{winner === "red" ? "🔴 红队获胜!" : winner === "blue" ? "🔵 蓝队获胜!" : "平局!势均力敌!"}
</p>
<div className="mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4">
<div className="rounded-lg bg-red-50 p-5 text-red-700">
<div className="font-bold">红队</div>
<div className="text-4xl font-black">{scoreRed}</div>
</div>
<div className="rounded-lg bg-blue-50 p-5 text-blue-700">
<div className="font-bold">蓝队</div>
<div className="text-4xl font-black">{scoreBlue}</div>
</div>
</div>
<button
onClick={startGame}
className="mt-8 inline-flex items-center gap-2 rounded-full bg-rose-500 px-10 py-4 text-lg font-bold text-white shadow-lg transition hover:bg-rose-600"
>
<RotateCcw className="size-5" />
再来一局
</button>
</section>
</main>
);
}
return (
<main className="mx-auto min-h-[calc(100vh-56px)] max-w-7xl px-4 py-5 sm:px-6 sm:py-8">
<section className="relative overflow-hidden rounded-[28px] bg-white p-[clamp(18px,3vw,34px)] shadow-soft">
{answerState !== "idle" && (
<div
className={cn(
"z-10 mb-4 flex flex-col items-center gap-3 rounded-lg border bg-white/95 px-4 py-3 text-center text-[clamp(16px,1.8vw,22px)] font-black shadow-lg backdrop-blur md:absolute md:right-[clamp(18px,3vw,34px)] md:top-[clamp(18px,3vw,34px)] md:mb-0 md:max-w-[360px]",
answerState === "correct" && "border-emerald-100 text-emerald-600",
answerState === "wrong" && "border-red-100 text-red-500",
)}
>
<div>
{answerState === "correct" && `🎉 太棒了!答对啦!+${settings.scorePerCorrect}分`}
{answerState === "wrong" &&
(settings.scorePerWrong > 0
? `答错啦,扣${settings.scorePerWrong}分,轮到下一队!`
: "答错啦,轮到下一队!")}
</div>
<button
onClick={nextQuestion}
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-7 py-2.5 text-base font-bold text-white shadow-lg transition hover:bg-slate-700"
>
下一题
<Trophy className="size-5" />
</button>
</div>
)}
<div className="mb-[clamp(12px,2vw,22px)] text-center">
<h1 className="text-[clamp(26px,3vw,38px)] font-black text-rose-500">单词PK大作战</h1>
<p className="mt-2 text-sm font-medium text-slate-500">
{selectedGrade} / {selectedClassType?.name ?? "班型"} / {roundWords.length} 道题
</p>
</div>
<div className="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="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>
<TeamCard team="blue" active={currentTeam === "blue"} score={scoreBlue} />
</div>
<div
className={cn(
"mt-4 rounded-lg px-4 py-3 text-center text-[clamp(18px,2vw,23px)] font-black",
currentTeam === "red" ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
)}
>
{currentTeam === "red" ? "🔴 红队请作答" : "🔵 蓝队请作答"}
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center">
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600">听一听,这是哪个单词?</p>
<button
onClick={() => void speakWord(currentWord)}
className="mt-4 grid size-[clamp(62px,6vw,80px)] place-items-center rounded-full bg-rose-400 text-white shadow-lg transition hover:scale-105 hover:bg-rose-500"
aria-label="重新朗读当前单词"
>
<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>
</div>
</div>
<div className="flex min-w-0 flex-col justify-center">
<div
className={cn(
"grid grid-cols-1 gap-[clamp(14px,2vw,22px)] sm:grid-cols-2",
optionCount >= 6 && "xl:grid-cols-3",
)}
>
{options.map((option) => {
const correct = option.id === currentWord.id;
const selected = selectedWord === option.id;
const revealCorrect = answerState !== "idle" && correct;
return (
<button
key={option.id}
onClick={() => checkAnswer(option)}
className={cn(
"flex min-h-[clamp(104px,18vh,188px)] flex-col items-center justify-center rounded-[20px] border-4 bg-white p-4 text-[clamp(22px,3vw,34px)] font-black transition hover:-translate-y-1 hover:border-sky-300 hover:shadow-lg",
answerState === "idle" && "border-slate-200",
revealCorrect && "border-emerald-400 bg-emerald-50 text-emerald-700",
selected && answerState === "wrong" && "border-red-400 bg-red-50 text-red-700",
)}
>
<WordImage value={option.image} label={option.english} />
<AdaptiveWordText text={formatWordDisplay(option.english, settings.wordDisplayMode)} variant="option" />
</button>
);
})}
</div>
</div>
</div>
</section>
</main>
);
}
function AdaptiveWordText({ text, variant }: { text: string; variant: "hero" | "option" }) {
const displayText = text.trim().replace(/\s+/g, "\n");
const compactLength = text.replace(/\s+/g, "").length;
const isVeryLongSingleWord = compactLength > 16 && !/\s/.test(text.trim());
const size =
compactLength <= 6
? "large"
: compactLength <= 10
? "medium"
: "small";
const sizeClass = {
hero: {
large: "text-[clamp(52px,7vw,82px)]",
medium: "text-[clamp(42px,5.6vw,64px)]",
small: "text-[clamp(28px,4.2vw,46px)]",
},
option: {
large: "text-[clamp(30px,4vw,48px)]",
medium: "text-[clamp(25px,3.2vw,38px)]",
small: "text-[clamp(20px,2.6vw,30px)]",
},
}[variant][size];
return (
<span
className={cn(
"mx-auto block max-w-full whitespace-pre-line text-center font-black leading-[1.05] tracking-normal",
variant === "hero" && "mt-4 text-blue-500 [text-shadow:3px_3px_0_#dbeafe]",
variant === "option" && "mt-3 text-slate-900",
sizeClass,
isVeryLongSingleWord && "max-w-[min(100%,14ch)] [overflow-wrap:anywhere]",
)}
>
{displayText}
</span>
);
}
async function loadSettings() {
const response = await fetch("/api/settings", { cache: "no-store" });
const data = (await response.json()) as { settings: Partial<AppSettings> };
return data.settings;
}
function mergeSettings(settings: Partial<AppSettings>): AppSettings {
return {
ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural",
ttsRate: settings.ttsRate || "-10%",
scorePerCorrect: Number(settings.scorePerCorrect ?? 10),
scorePerWrong: Number(settings.scorePerWrong ?? 0),
autoSpeak: settings.autoSpeak ?? true,
wordDisplayMode:
settings.wordDisplayMode === "capitalize" || settings.wordDisplayMode === "input" || settings.wordDisplayMode === "uppercase"
? settings.wordDisplayMode
: "uppercase",
pkWordCount: Math.max(0, Math.floor(Number(settings.pkWordCount ?? 0))),
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount ?? 4)),
};
}
function getFairRoundCount(availableCount: number, limit: number) {
const requestedCount = limit > 0 ? Math.min(limit, availableCount) : availableCount;
if (requestedCount < 2) return requestedCount;
return requestedCount % 2 === 0 ? requestedCount : requestedCount - 1;
}
function formatWordDisplay(text: string, mode: AppSettings["wordDisplayMode"]) {
if (mode === "input") return text;
if (mode === "capitalize") {
return text
.split(/(\s+)/)
.map((part) => {
if (/^\s+$/.test(part) || !part) return part;
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join("");
}
return text.toUpperCase();
}
function normalizeOptionCount(value: number) {
const count = Math.floor(value);
if (!Number.isFinite(count)) return 4;
if (count < 2) return 2;
if (count > 8) return 8;
return count % 2 === 0 ? count : count + 1;
}
function WordImage({ value, label }: { value: string; label: string }) {
if (!value) {
return null;
}
if (/^(https?:\/\/|\/|data:image\/)/i.test(value)) {
return (
<img
src={value}
alt={label}
className="size-[clamp(54px,7vw,92px)] rounded-lg object-contain"
/>
);
}
return <span className="text-[clamp(44px,6vw,76px)] leading-none">{value}</span>;
}