Files
rice-pk-xhs/web/components/RiceArena.jsx
T
codex-bot 2bf21a9f97
Verify mini program and deploy preview / verify-deploy (push) Successful in 3m0s
feat: migrate rice preview to Next.js
2026-09-20 14:22:47 +08:00

19 lines
4.3 KiB
React

'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';
const MODES=[['single','一对'],['multi','多对'],['swipe','滑动']];
export default function RiceArena(){
const router=useRouter(),pointerStart=useRef(null);const[test,setTest]=useState(null),[bracket,setBracket]=useState(null),[mode,setMode]=useState('single'),[error,setError]=useState(''),[saving,setSaving]=useState(false);
useEffect(()=>{apiRequest('/api/v1/tests/rice').then(data=>{setTest(data);setBracket(createBracket(data.candidates));}).catch(reason=>setError(reason.message));},[]);
const total=test?test.candidates.length-1:1,currentIndex=bracket?firstOpenMatch(bracket):-1,current=currentIndex>=0?bracket.matches[currentIndex]:null,visible=useMemo(()=>bracket?.matches.slice(bracket.offset,bracket.offset+4)||[],[bracket]);
async function complete(finalState){setSaving(true);const champion=finalState.pool[0],last=finalState.decisions.at(-1),runner=last.left_id===champion.id?last.right_id:last.left_id;try{await ensureLogin();const result=await apiRequest('/api/v1/tests/rice/runs',{method:'POST',body:{app_key:'rice',champion_id:champion.id,runner_up_id:runner,ranking_ids:[champion,...finalState.eliminated].map(item=>item.id),decisions:finalState.decisions}});sessionStorage.setItem('rice_last_result',JSON.stringify(result));router.replace(`/results/${result.id}`);}catch(reason){setError(reason.message);setSaving(false);}}
function select(index,side){if(saving)return;const outcome=chooseMatch(bracket,index,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){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="centerState"><h1>暂时无法继续</h1><p>{error}</p><button onClick={()=>router.push('/')}>返回首页</button></main>;if(!test||!bracket)return <main className="centerState">正在抽签…</main>;
return <main className="arenaPage"><header className="arenaHeader"><button onClick={()=>router.push('/')} aria-label="返回">←</button><strong>第 {bracket.round} 轮</strong><small>{bracket.decisions.length} / {total}</small></header><div className="progress"><i style={{width:`${Math.round(bracket.decisions.length/total*100)}%`}}/></div><div className="modes">{MODES.map(([id,label])=><button key={id} className={mode===id?'active':''} onClick={()=>setMode(id)}>{label}</button>)}</div>
{mode==='multi'?<section className="multiArena"><AnimatePresence mode="popLayout">{visible.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?<strong>晋级<br/>{match.winner.name}</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={`pair ${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="choice 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="choice cool"><small>B</small><strong>{current.right.name}</strong><span>{mode==='swipe'?'向右滑选它':'点按晋级'}</span></motion.button></section>}{saving?<div className="toast">正在保存你的冠军…</div>:null}</main>;
}