Refactor PK page and add Trellis setup
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AdaptiveWordTextProps = {
|
||||
text: string;
|
||||
variant: "hero" | "option";
|
||||
};
|
||||
|
||||
export function AdaptiveWordText({ text, variant }: AdaptiveWordTextProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { pkPageCursorClass } from "../constants";
|
||||
import type { Team } from "../types";
|
||||
|
||||
type Winner = Team | "draw";
|
||||
|
||||
type EndScreenProps = {
|
||||
winner: Winner;
|
||||
scoreRed: number;
|
||||
scoreBlue: number;
|
||||
onRestart: () => void;
|
||||
};
|
||||
|
||||
export function EndScreen({ winner, scoreRed, scoreBlue, onRestart }: EndScreenProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
pkPageCursorClass,
|
||||
"pk-end-stage mx-auto grid min-h-[calc(100vh-60px)] max-w-5xl place-items-center px-4 py-4",
|
||||
)}
|
||||
>
|
||||
<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={onRestart}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Trophy, Volume2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { pkPageCursorClass } from "../constants";
|
||||
import type { AnswerState, AppSettings, Grade, Team, WordItem } from "../types";
|
||||
import { formatWordDisplay } from "../utils";
|
||||
import { AdaptiveWordText } from "./AdaptiveWordText";
|
||||
import { TeamCard } from "./TeamCard";
|
||||
import { WordImage } from "./WordImage";
|
||||
|
||||
type GameScreenProps = {
|
||||
selectedGrade: Grade;
|
||||
selectedClassTypeName?: string;
|
||||
roundWords: WordItem[];
|
||||
currentWord: WordItem;
|
||||
options: WordItem[];
|
||||
optionCount: number;
|
||||
questionIndex: number;
|
||||
currentTeam: Team;
|
||||
scoreRed: number;
|
||||
scoreBlue: number;
|
||||
answerState: AnswerState;
|
||||
selectedWord: number | null;
|
||||
speechTip: string;
|
||||
settings: AppSettings;
|
||||
onSpeakWord: (word: WordItem) => void;
|
||||
onCheckAnswer: (word: WordItem) => void;
|
||||
onNextQuestion: () => void;
|
||||
};
|
||||
|
||||
export function GameScreen({
|
||||
selectedGrade,
|
||||
selectedClassTypeName,
|
||||
roundWords,
|
||||
currentWord,
|
||||
options,
|
||||
optionCount,
|
||||
questionIndex,
|
||||
currentTeam,
|
||||
scoreRed,
|
||||
scoreBlue,
|
||||
answerState,
|
||||
selectedWord,
|
||||
speechTip,
|
||||
settings,
|
||||
onSpeakWord,
|
||||
onCheckAnswer,
|
||||
onNextQuestion,
|
||||
}: GameScreenProps) {
|
||||
const progress = roundWords.length > 0 ? (questionIndex / roundWords.length) * 100 : 0;
|
||||
|
||||
return (
|
||||
<main className={cn(pkPageCursorClass, "pk-game-stage mx-auto min-h-[calc(100vh-60px)] max-w-7xl px-4 py-4 sm:px-6 sm:py-4")}>
|
||||
<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={onNextQuestion}
|
||||
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} / {selectedClassTypeName ?? "班型"} / {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={() => onSpeakWord(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={() => onCheckAnswer(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { grades, pkPageCursorClass } from "../constants";
|
||||
import type { ClassType, Grade } from "../types";
|
||||
|
||||
type StartScreenProps = {
|
||||
selectedGrade: Grade;
|
||||
classTypes: ClassType[];
|
||||
selectedClassTypeId: number;
|
||||
selectedClassTypeName?: string;
|
||||
availableWordCount: number;
|
||||
plannedRoundCount: number;
|
||||
loadingSetup: boolean;
|
||||
scorePerCorrect: number;
|
||||
scorePerWrong: number;
|
||||
onSelectGrade: (grade: Grade) => void;
|
||||
onSelectClassType: (classTypeId: number) => void;
|
||||
onStartGame: () => void;
|
||||
};
|
||||
|
||||
export function StartScreen({
|
||||
selectedGrade,
|
||||
classTypes,
|
||||
selectedClassTypeId,
|
||||
selectedClassTypeName,
|
||||
availableWordCount,
|
||||
plannedRoundCount,
|
||||
loadingSetup,
|
||||
scorePerCorrect,
|
||||
scorePerWrong,
|
||||
onSelectGrade,
|
||||
onSelectClassType,
|
||||
onStartGame,
|
||||
}: StartScreenProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
pkPageCursorClass,
|
||||
"pk-start-stage mx-auto grid min-h-[calc(100vh-60px)] 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">
|
||||
红队蓝队轮流答题,答对加{scorePerCorrect}分
|
||||
{scorePerWrong > 0 ? `,答错扣${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={() => onSelectGrade(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={() => onSelectClassType(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">{selectedClassTypeName ?? "未选择班型"}</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">{availableWordCount}</span> 道
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loadingSetup && availableWordCount === 0 && (
|
||||
<p className="pk-start-warning mt-4 text-sm font-medium text-red-500">当前年级和班级类型没有启用题目,请先到后台管理启用词库。</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onStartGame}
|
||||
disabled={loadingSetup || availableWordCount === 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Team } from "../types";
|
||||
|
||||
type TeamCardProps = {
|
||||
team: Team;
|
||||
active: boolean;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export function TeamCard({ team, active, score }: TeamCardProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isImageSource } from "../utils";
|
||||
|
||||
type WordImageProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function WordImage({ value, label }: WordImageProps) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isImageSource(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>;
|
||||
}
|
||||
Reference in New Issue
Block a user