Files
pk-lab-xhs/web/components/TestArena.tsx
T
codex-bot 5051466692
Verify mini program and deploy preview / verify-deploy (push) Successful in 3m9s
feat: redesign typed bracket poster editor
2026-09-20 16:37:35 +08:00

124 lines
6.1 KiB
TypeScript

'use client';
import { AnimatePresence, motion } from 'motion/react';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { apiRequest, ensureLogin } from '../lib/api';
import { chooseMatch, createBracket, firstOpenMatch } from '../lib/bracket';
import type { BracketState, ChoiceSide, TestDetail, TestResult } from '../lib/types';
type Mode = 'single' | 'multi' | 'swipe';
const MODES: readonly { id: Mode; label: string }[] = [
{ id: 'single', label: '一对' },
{ id: 'multi', label: '多对' },
{ id: 'swipe', label: '滑动' }
];
export default function TestArena({ slug }: { slug: string }) {
const router = useRouter();
const [test, setTest] = useState<TestDetail | null>(null);
const [bracket, setBracket] = useState<BracketState | null>(null);
const [mode, setMode] = useState<Mode>('single');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const pointerStart = useRef<number | null>(null);
useEffect(() => {
apiRequest<TestDetail>(`/api/v1/tests/${slug}`).then(data => {
setTest(data);
setBracket(createBracket(data.candidates));
}).catch((error: Error) => setError(error.message));
}, [slug]);
const total = test ? test.candidates.length - 1 : 1;
const progress = bracket ? Math.round(bracket.decisions.length / total * 100) : 0;
const currentIndex = bracket ? firstOpenMatch(bracket) : -1;
const current = bracket && currentIndex >= 0 ? bracket.matches[currentIndex] : null;
const visibleMatches = useMemo(() => bracket?.matches.slice(bracket.offset, bracket.offset + 4) || [], [bracket]);
async function complete(finalState: BracketState) {
setSaving(true);
const champion = finalState.pool[0];
const finalDecision = finalState.decisions.at(-1);
try {
await ensureLogin();
if (!champion || !finalDecision) throw new Error('晋级结果不完整');
const runnerUp = finalDecision.left_id === champion.id ? finalDecision.right_id : finalDecision.left_id;
const result = await apiRequest<TestResult>(`/api/v1/tests/${slug}/runs`, {
method: 'POST',
body: {
app_key: 'hub',
champion_id: champion.id,
runner_up_id: runnerUp,
ranking_ids: [champion, ...finalState.eliminated].map(item => item.id),
decisions: finalState.decisions
}
});
sessionStorage.setItem('pk_last_result', JSON.stringify(result));
router.replace(`/results/${result.id}`);
} catch (error) {
setError(error instanceof Error ? error.message : '保存失败');
setSaving(false);
}
}
function select(matchIndex: number, side: ChoiceSide) {
if (saving || !bracket) return;
const outcome = chooseMatch(bracket, matchIndex, side);
let next = outcome.state;
if (!outcome.finished && mode === 'multi') {
const block = next.matches.slice(next.offset, next.offset + 4);
if (block.length && block.every(match => match.winner)) next = { ...next, offset: next.offset + 4 };
}
setBracket(next);
if (outcome.finished) complete(next);
}
function pointerUp(event: React.PointerEvent<HTMLElement>) {
if (mode !== 'swipe' || pointerStart.current === null) return;
const delta = event.clientX - pointerStart.current;
pointerStart.current = null;
if (Math.abs(delta) >= 55) select(currentIndex, delta < 0 ? 'left' : 'right');
}
if (error) return <main className="arenaState"><h1>暂时无法继续</h1><p>{error}</p><button onClick={() => router.push('/')}>返回首页</button></main>;
if (!test || !bracket) return <main className="arenaState">正在抽签…</main>;
if (!current && mode !== 'multi') return <main className="arenaState">正在生成晋级图…</main>;
return (
<main className="arenaPage">
<header className="arenaHeader">
<button onClick={() => router.push('/')} aria-label="返回">←</button>
<span>第 {bracket.round} 轮</span>
<small>{bracket.decisions.length} / {total}</small>
</header>
<div className="progressTrack"><i style={{ width: `${progress}%` }} /></div>
<div className="modeTabs">
{MODES.map(item => <button key={item.id} className={mode === item.id ? 'active' : ''} onClick={() => setMode(item.id)}>{item.label}</button>)}
</div>
{mode === 'multi' ? (
<section className="multiArena">
<AnimatePresence mode="popLayout">
{visibleMatches.map((match, index) => {
const matchIndex = bracket.offset + index;
return (
<motion.article layout key={`${bracket.round}-${matchIndex}`} className={`miniMatch ${match.winner ? 'resolved' : ''}`} initial={{ opacity: 0, scale: .94 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .9 }}>
{match.winner ? <motion.strong initial={{ scale: .6 }} animate={{ scale: 1 }}>晋级<br />{match.winner.name}</motion.strong> : <><button onClick={() => select(matchIndex, 'left')}>{match.left.name}</button><span>VS</span><button onClick={() => select(matchIndex, 'right')}>{match.right!.name}</button></>}
</motion.article>
);
})}
</AnimatePresence>
</section>
) : (
<section className={`singleArena ${mode === 'swipe' ? 'swipe' : ''}`} onPointerDown={event => { pointerStart.current = event.clientX; }} onPointerUp={pointerUp}>
<motion.button whileTap={{ scale: .94, rotate: -1 }} onClick={() => mode !== 'swipe' && select(currentIndex, 'left')} className="choiceCard warm"><small>A</small><strong>{current!.left.name}</strong><span>{mode === 'swipe' ? '向左滑选它' : '点按晋级'}</span></motion.button>
<b className="versus">VS</b>
<motion.button whileTap={{ scale: .94, rotate: 1 }} onClick={() => mode !== 'swipe' && select(currentIndex, 'right')} className="choiceCard cool"><small>B</small><strong>{current!.right!.name}</strong><span>{mode === 'swipe' ? '向右滑选它' : '点按晋级'}</span></motion.button>
</section>
)}
{saving ? <div className="savingToast">正在保存你的冠军…</div> : null}
</main>
);
}