Files
englishPk/app/pk/page.tsx
T

621 lines
25 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[] = ["小班", "中班", "大班"];
const pkPageCursorClass = "cursor-hammer";
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(
"pk-team-card 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="pk-team-label text-[clamp(16px,1.8vw,22px)] font-bold">{red ? "🔴 红队" : "🔵 蓝队"}</div>
<div className="pk-team-score 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={cn(pkPageCursorClass, "pk-start-stage mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-4")}>
<section className="pk-start-card w-full rounded-[24px] bg-white p-[clamp(18px,3vw,36px)] text-center shadow-soft">
<div className="pk-start-visual mb-2 text-[clamp(54px,8vw,92px)] leading-none">🦊 VS 🦓</div>
<h1 className="pk-start-title text-balance text-[clamp(28px,4vw,44px)] font-black text-rose-500">字母单词PK赛</h1>
<p className="pk-start-subtitle mt-2 text-[clamp(15px,1.6vw,19px)] text-slate-600">
红队蓝队轮流答题,答对加{settings.scorePerCorrect}
{settings.scorePerWrong > 0 ? `,答错扣${settings.scorePerWrong}分。` : ",答错不扣分。"}
</p>
<div className="pk-start-panel mx-auto mt-5 grid max-w-3xl gap-3 rounded-2xl border bg-slate-50 p-4 text-left">
<div>
<div className="mb-2 text-xs 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(
"pk-start-choice rounded-lg border bg-white px-4 py-2.5 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-2 text-xs 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(
"pk-start-choice rounded-lg border bg-white px-4 py-2.5 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="pk-start-summary rounded-lg bg-white px-4 py-2 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="pk-start-warning mt-4 text-sm font-medium text-red-500">当前年级和班级类型没有启用题目,请先到后台管理启用词库。</p>
)}
<button
onClick={startGame}
disabled={loadingSetup || availableWords.length === 0}
className="pk-start-button mt-5 rounded-full bg-rose-500 px-10 py-3 text-[clamp(17px,1.8vw,21px)] 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={cn(pkPageCursorClass, "pk-end-stage mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-8")}>
<section className="pk-end-card w-full rounded-[28px] bg-white p-[clamp(28px,6vw,72px)] text-center shadow-soft">
<div className="pk-end-visual mb-5 text-[clamp(72px,10vw,118px)] leading-none">{winner === "draw" ? "🤝" : "🏆"}</div>
<h1 className="pk-end-title text-[clamp(32px,5vw,54px)] font-black text-rose-500">PK结束</h1>
<p
className={cn(
"pk-end-result 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="pk-end-score-grid mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4">
<div className="pk-end-score-card rounded-lg bg-red-50 p-5 text-red-700">
<div className="font-bold">红队</div>
<div className="pk-end-score text-4xl font-black">{scoreRed}</div>
</div>
<div className="pk-end-score-card rounded-lg bg-blue-50 p-5 text-blue-700">
<div className="font-bold">蓝队</div>
<div className="pk-end-score text-4xl font-black">{scoreBlue}</div>
</div>
</div>
<button
onClick={startGame}
className="pk-end-button 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={cn(pkPageCursorClass, "pk-game-stage mx-auto min-h-[calc(100vh-56px)] max-w-7xl px-4 py-5 sm:px-6 sm:py-8")}>
<section className="pk-game-card relative overflow-hidden rounded-[28px] bg-white p-[clamp(18px,3vw,34px)] shadow-soft">
{answerState !== "idle" && (
<div
className={cn(
"pk-answer-feedback 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="pk-game-header mb-[clamp(12px,2vw,22px)] text-center">
<h1 className="pk-game-title text-[clamp(26px,3vw,38px)] font-black text-rose-500">单词PK大作战</h1>
<p className="pk-game-meta mt-2 text-sm font-medium text-slate-500">
{selectedGrade} / {selectedClassType?.name ?? "班型"} / {roundWords.length} 道题
</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="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(
"pk-current-turn 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="pk-listen-area 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="pk-speak-button 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(
"pk-answer-grid 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(
"pk-option-button 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",
"pk-word-text",
variant === "hero" && "pk-word-text-hero mt-4 text-blue-500 [text-shadow:3px_3px_0_#dbeafe]",
variant === "option" && "pk-word-text-option mt-3 text-slate-900",
`pk-word-size-${size}`,
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="pk-word-image size-[clamp(54px,7vw,92px)] rounded-lg object-contain"
/>
);
}
return <span className="pk-word-emoji text-[clamp(44px,6vw,76px)] leading-none">{value}</span>;
}