"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(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 (
{red ? "🔴 红队" : "🔵 蓝队"}
{score}
); } export default function PkPage() { const [selectedGrade, setSelectedGrade] = useState("中班"); const [classTypes, setClassTypes] = useState([]); const [selectedClassTypeId, setSelectedClassTypeId] = useState(0); const [availableWords, setAvailableWords] = useState([]); const [loadingSetup, setLoadingSetup] = useState(true); const [started, setStarted] = useState(false); const [ended, setEnded] = useState(false); const [roundWords, setRoundWords] = useState([]); const [questionIndex, setQuestionIndex] = useState(0); const [currentTeam, setCurrentTeam] = useState("red"); const [scoreRed, setScoreRed] = useState(0); const [scoreBlue, setScoreBlue] = useState(0); const [answerState, setAnswerState] = useState("idle"); const [selectedWord, setSelectedWord] = useState(null); const [speechTip, setSpeechTip] = useState("点击开始后可朗读单词。"); const [settings, setSettings] = useState({ ttsVoice: "en-US-EmmaMultilingualNeural", ttsRate: "-10%", scorePerCorrect: 10, scorePerWrong: 0, autoSpeak: true, wordDisplayMode: "uppercase", pkWordCount: 0, pkOptionCount: 4, }); const audioContextRef = useRef(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 (
🦊 VS 🦓

字母单词PK赛

红队蓝队轮流答题,答对加{settings.scorePerCorrect}分 {settings.scorePerWrong > 0 ? `,答错扣${settings.scorePerWrong}分。` : ",答错不扣分。"}

选择年级
{grades.map((grade) => ( ))}
选择班级类型
{classTypes.map((classType) => ( ))}
当前选择:{selectedGrade} / {selectedClassType?.name ?? "未选择班型"} / 本轮题目 {plannedRoundCount}/ 题库可用 {availableWords.length}
{!loadingSetup && availableWords.length === 0 && (

当前年级和班级类型没有启用题目,请先到后台管理启用词库。

)}
); } if (ended) { return (
{winner === "draw" ? "🤝" : "🏆"}

PK结束!

{winner === "red" ? "🔴 红队获胜!" : winner === "blue" ? "🔵 蓝队获胜!" : "平局!势均力敌!"}

红队
{scoreRed}
蓝队
{scoreBlue}
); } return (
{answerState !== "idle" && (
{answerState === "correct" && `🎉 太棒了!答对啦!+${settings.scorePerCorrect}分`} {answerState === "wrong" && (settings.scorePerWrong > 0 ? `答错啦,扣${settings.scorePerWrong}分,轮到下一队!` : "答错啦,轮到下一队!")}
)}

单词PK大作战

{selectedGrade} / {selectedClassType?.name ?? "班型"} / {roundWords.length} 道题

VS
{currentTeam === "red" ? "🔴 红队请作答" : "🔵 蓝队请作答"}

听一听,这是哪个单词?

{speechTip}

= 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 ( ); })}
); } 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 ( {displayText} ); } async function loadSettings() { const response = await fetch("/api/settings", { cache: "no-store" }); const data = (await response.json()) as { settings: Partial }; return data.settings; } function mergeSettings(settings: Partial): 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 ( {label} ); } return {value}; }