Files
rice-pk-xhs/web/components/RicePosterEditor.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
7.5 KiB
React
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 { motion } from 'motion/react';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiRequest, ensureLogin } from '../lib/api';
const THEMES={celebration:['#ff6038','#fffaf4','#25211f','#ffe568'],editorial:['#f3eadf','#201d1b','#fff','#285ee8'],notebook:['#c9f06b','#1e2810','#ff72a7','#fff']};
const VIEWS=[['overview','总览'],['left','左半区'],['right','右半区'],['top16','16 强'],['top8','8 强'],['final','决赛']];
const TOOLS=[['filter','滤镜'],['text','文字'],['sticker','贴纸'],['crop','裁剪'],['adjust','调整'],['layout','布局'],['brush','画笔']];
export default function RicePosterEditor({runId}){
const router=useRouter(),canvasRef=useRef(null);const[result,setResult]=useState(null),[error,setError]=useState(''),[theme,setTheme]=useState('celebration'),[layout,setLayout]=useState('symmetric'),[view,setView]=useState('overview'),[tool,setTool]=useState('filter'),[caption,setCaption]=useState(''),[sticker,setSticker]=useState(true),[background,setBackground]=useState(null),[adjust,setAdjust]=useState({brightness:100,contrast:100,saturate:100});
useEffect(()=>{async function loadResult(){const cached=sessionStorage.getItem('rice_last_result');if(cached){const parsed=JSON.parse(cached);if(parsed.id===runId)return parsed;}await ensureLogin();return apiRequest(`/api/v1/me/results/${runId}`);}loadResult().then(setResult).catch(reason=>setError(reason.message));},[runId]);
const draw=useCallback(()=>{if(!result||!canvasRef.current)return;const canvas=canvasRef.current,ctx=canvas.getContext('2d'),colors=THEMES[theme];ctx.clearRect(0,0,750,1000);ctx.fillStyle=colors[0];ctx.fillRect(0,0,750,1000);if(background){ctx.save();ctx.filter=`brightness(${adjust.brightness}%) contrast(${adjust.contrast}%) saturate(${adjust.saturate}%)`;cover(ctx,background,0,0,750,1000);ctx.restore();ctx.fillStyle=`${colors[0]}d8`;ctx.fillRect(0,0,750,1000);}ctx.fillStyle=colors[1];ctx.font='700 24px system-ui';ctx.fillText('RICE PK / BRACKET',42,62);ctx.font='900 48px system-ui';ctx.fillText(result.test_title,42,125);ctx.fillStyle=colors[2];ctx.fillRect(28,165,694,690);drawBracket(ctx,result,colors,layout,view);ctx.fillStyle=colors[1];ctx.font='800 25px system-ui';ctx.fillText(caption||`冠军 ${result.champion_name}`,42,910);if(sticker){ctx.fillStyle=colors[3];ctx.beginPath();ctx.arc(660,100,48,0,Math.PI*2);ctx.fill();ctx.fillStyle=colors[2];ctx.font='900 21px system-ui';ctx.fillText('开饭',636,108);}ctx.fillStyle=colors[1];ctx.font='18px system-ui';ctx.fillText('保存完整晋级路线,分享你的最终选择',42,962);},[adjust,background,caption,layout,result,sticker,theme,view]);useEffect(()=>{draw();},[draw]);
function pick(event){const file=event.target.files?.[0];if(!file)return;const image=new Image();image.onload=()=>setBackground(image);image.src=URL.createObjectURL(file);}function save(){const link=document.createElement('a');link.download=`我的本命饭-${result.champion_name}.png`;link.href=canvasRef.current.toDataURL('image/png');link.click();}
if(error)return <main className="centerState"><h1>结果读取失败</h1><p>{error}</p><button onClick={()=>router.push('/')}>返回首页</button></main>;if(!result)return <main className="centerState">正在打开编辑器…</main>;
return <main className="editorPage"><header className="editorHeader"><button onClick={()=>router.push('/')}>←</button><div><strong>分享图编辑器</strong><small>{result.test_title} · {result.champion_name}</small></div><button className="export" onClick={save}>导出</button></header><section className="editorWorkspace"><canvas ref={canvasRef} width="750" height="1000"/><aside><div className="scrollRow"><button className={layout==='symmetric'?'active':''} onClick={()=>setLayout('symmetric')}>对称图</button><button className={layout==='single'?'active':''} onClick={()=>setLayout('single')}>单边图</button>{VIEWS.map(([id,label])=><button key={id} className={view===id?'active':''} onClick={()=>setView(id)}>{label}</button>)}</div><div className="mediaRow"><span>晋级图</span><label>+<small>添加底图</small><input type="file" accept="image/*" onChange={pick}/></label></div><div className="toolRow">{TOOLS.map(([id,label],index)=><button key={id} className={tool===id?'active':''} onClick={()=>setTool(id)}><i>{index+1}</i>{label}</button>)}</div><motion.div key={tool} className="toolPanel" initial={{opacity:0,y:8}} animate={{opacity:1,y:0}}>{tool==='filter'?<><button onClick={()=>setTheme('celebration')}>庆祝派对</button><button onClick={()=>setTheme('editorial')}>杂志封面</button><button onClick={()=>setTheme('notebook')}>贴纸手账</button></>:null}{tool==='text'?<input value={caption} maxLength={20} onChange={event=>setCaption(event.target.value)} placeholder="输入花字标题"/>:null}{tool==='sticker'?<button onClick={()=>setSticker(value=>!value)}>冠军贴纸:{sticker?'开启':'关闭'}</button>:null}{tool==='adjust'?<div className="adjust">{Object.entries(adjust).map(([name,value])=><label key={name}><span>{name}</span><input type="range" min="50" max="150" value={value} onChange={event=>setAdjust({...adjust,[name]:Number(event.target.value)})}/></label>)}</div>:null}{['crop','layout','brush'].includes(tool)?<p>该工具由 PosterDocument 图层绑定驱动,可继续从素材服务加载预设。</p>:null}</motion.div></aside></section></main>;
}
function cover(ctx,image,x,y,width,height){const ratio=Math.max(width/image.width,height/image.height),w=image.width*ratio,h=image.height*ratio;ctx.drawImage(image,x+(width-w)/2,y+(height-h)/2,w,h);}
function drawBracket(ctx,result,colors,layout,view){const decisions=result.decisions||[],names=result.candidate_names||{};if(!decisions.length){ctx.fillStyle=colors[1];ctx.font='900 72px system-ui';ctx.fillText(result.champion_name,70,500);return;}const maxRound=Math.max(...decisions.map(item=>item.round)),minRound=view==='final'?maxRound:view==='top8'?Math.max(1,maxRound-2):view==='top16'?Math.max(1,maxRound-3):1;let visible=decisions.filter(item=>item.round>=minRound);if(view==='left'||view==='right')visible=visible.filter((item,index)=>item.round===maxRound||index%2===(view==='left'?0:1));const groups={},positions={};visible.forEach(item=>(groups[item.round]||=[]).push(item));Object.entries(groups).forEach(([roundValue,items])=>{const round=Number(roundValue);items.forEach((item,index)=>{const symmetric=layout==='symmetric',side=symmetric&&round<maxRound&&index%2?1:-1,x=symmetric?375+side*Math.min(260,78+(maxRound-round)*62):70+(round-minRound)*(580/Math.max(1,maxRound-minRound)),y=230+(index+1)*(520/(items.length+1));positions[`${item.winner_id}:${item.round}`]={x,y,item};});});ctx.strokeStyle=colors[3];ctx.lineWidth=3;Object.values(positions).forEach(position=>{const parentDecision=visible.find(item=>item.round>position.item.round&&[item.left_id,item.right_id].includes(position.item.winner_id)),parent=parentDecision&&positions[`${parentDecision.winner_id}:${parentDecision.round}`];if(!parent)return;ctx.beginPath();ctx.moveTo(position.x,position.y);ctx.lineTo((position.x+parent.x)/2,position.y);ctx.lineTo((position.x+parent.x)/2,parent.y);ctx.lineTo(parent.x,parent.y);ctx.stroke();});Object.values(positions).forEach(position=>{const label=names[position.item.winner_id]||result.champion_name;ctx.fillStyle=position.item.round===maxRound?colors[3]:colors[0];ctx.fillRect(position.x-56,position.y-20,112,40);ctx.fillStyle=colors[1];ctx.font=`${label.length>6?13:16}px system-ui`;ctx.fillText(label.slice(0,8),position.x-47,position.y+6);});}