19 lines
7.5 KiB
React
19 lines
7.5 KiB
React
'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);});}
|