Files
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

23 lines
5.6 KiB
TypeScript
Raw Permalink 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, type ChangeEvent } from 'react';
import { apiRequest, ensureLogin } from '../lib/api';
import { drawPoster } from '../lib/bracketPoster';
import type { TestResult } from '../lib/types';
type ThemeId='clean'|'cream'|'night';type LayoutId='symmetric'|'single';type ViewId='overview'|'left'|'right'|'top16'|'top8'|'final';type ToolId='filter'|'text'|'sticker'|'crop'|'adjust'|'layout'|'brush';type Adjustments={brightness:number;contrast:number;saturate:number};
const TOOLS:readonly [ToolId,string,string][]=[['filter','滤镜','◌'],['text','文字','T'],['sticker','贴纸','▣'],['crop','裁剪','⌗'],['adjust','调整','☷'],['layout','布局','▦'],['brush','画笔','⌁']];
const VIEWS:readonly [ViewId,string][]=[['overview','总览'],['left','左半区'],['right','右半区'],['top16','16 强'],['top8','8 强'],['final','决赛']];
export default function PosterEditor({runId}:{runId:string}){
const router=useRouter(),canvasRef=useRef<HTMLCanvasElement>(null);const[result,setResult]=useState<TestResult|null>(null),[error,setError]=useState(''),[theme,setTheme]=useState<ThemeId>('clean'),[layout,setLayout]=useState<LayoutId>('symmetric'),[view,setView]=useState<ViewId>('overview'),[tool,setTool]=useState<ToolId>('filter'),[sticker,setSticker]=useState(true),[caption,setCaption]=useState(''),[background,setBackground]=useState<HTMLImageElement|null>(null),[adjust,setAdjust]=useState<Adjustments>({brightness:100,contrast:100,saturate:100});
useEffect(()=>{async function load(){const cached=sessionStorage.getItem('pk_last_result');if(cached){const parsed=JSON.parse(cached) as TestResult;if(parsed.id===runId)return parsed;}await ensureLogin();return apiRequest<TestResult>(`/api/v1/me/results/${runId}`);}load().then(setResult).catch((reason:Error)=>setError(reason.message));},[runId]);
const draw=useCallback(()=>{const canvas=canvasRef.current;if(!result||!canvas)return;const ctx=canvas.getContext('2d');if(ctx)drawPoster(ctx,result,{themeId:theme,layout,view,caption,sticker,background,adjust});},[adjust,background,caption,layout,result,sticker,theme,view]);useEffect(()=>draw(),[draw]);
function pickBackground(event:ChangeEvent<HTMLInputElement>){const file=event.target.files?.[0];if(!file)return;const image=new Image();const url=URL.createObjectURL(file);image.onload=()=>{setBackground(image);URL.revokeObjectURL(url);};image.src=url;}
function save(){const canvas=canvasRef.current;if(!canvas||!result)return;const link=document.createElement('a');link.download=`我的PK晋级图-${result.champion_name}.png`;link.href=canvas.toDataURL('image/png');link.click();}
if(error)return <main className="arenaState"><h1>结果读取失败</h1><p>{error}</p><button onClick={()=>router.push('/')}>返回首页</button></main>;if(!result)return <main className="arenaState">正在打开编辑器…</main>;
return <main className="editorPage"><header className="editorHeader"><button className="backButton" onClick={()=>router.push('/me')} aria-label="返回">‹</button><div><strong>编辑分享图</strong><small>{result.test_title} · {result.champion_name}</small></div><button className="exportButton" onClick={save}>保存</button></header><section className="editorWorkspace"><div className="canvasStage"><canvas ref={canvasRef} width="1080" height="1440"/></div><aside className="editorControls"><div className="scrollRow viewTabs"><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 className="bracketThumb"><b>树</b><small>晋级图</small></span><label className="addMedia"><b>+</b><small>添加底图</small><input type="file" accept="image/*" onChange={pickBackground}/></label></div><nav className="toolRow">{TOOLS.map(([id,label,icon])=><button key={id} className={tool===id?'active':''} onClick={()=>setTool(id)}><i>{icon}</i><span>{label}</span></button>)}</nav><motion.div key={tool} className="toolPanel" initial={{opacity:0,y:6}} animate={{opacity:1,y:0}}>{tool==='filter'?<div className="presetRow"><button className={theme==='clean'?'selected':''} onClick={()=>setTheme('clean')}><i className="swatch clean"/>清透</button><button className={theme==='cream'?'selected':''} onClick={()=>setTheme('cream')}><i className="swatch cream"/>暖白</button><button className={theme==='night'?'selected':''} onClick={()=>setTheme('night')}><i className="swatch night"/>夜色</button></div>:null}{tool==='text'?<input value={caption} maxLength={24} onChange={event=>setCaption(event.target.value)} placeholder="添加一句话…"/>:null}{tool==='sticker'?<button className="settingButton" onClick={()=>setSticker(value=>!value)}><span>冠军标记</span><b>{sticker?'开启':'关闭'}</b></button>:null}{tool==='adjust'?<AdjustControls value={adjust} onChange={setAdjust}/>:null}{['crop','layout','brush'].includes(tool)?<p>更多预设将从素材服务加载;当前不会破坏原始晋级数据。</p>:null}</motion.div></aside></section></main>;
}
function AdjustControls({value,onChange}:{value:Adjustments;onChange:(value:Adjustments)=>void}){return <div className="adjustControls">{Object.entries(value).map(([name,amount])=><label key={name}><span>{name}</span><input type="range" min="50" max="150" value={amount} onChange={event=>onChange({...value,[name]:Number(event.target.value)})}/></label>)}</div>;}