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

196 lines
9.8 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', '#ffffff', '#285ee8'],
notebook: ['#c9f06b', '#1e2810', '#ff72a7', '#ffffff']
};
const TOOLS = [
['filter', '滤镜'], ['text', '文字'], ['sticker', '贴纸'], ['crop', '裁剪'],
['adjust', '调整'], ['layout', '布局'], ['brush', '画笔']
];
const VIEWS = [['overview', '总览'], ['left', '左半区'], ['right', '右半区'], ['top16', '16 强'], ['top8', '8 强'], ['final', '决赛']];
export default function PosterEditor({ runId }) {
const router = useRouter();
const canvasRef = useRef(null);
const [result, setResult] = useState(null);
const [error, setError] = useState('');
const [theme, setTheme] = useState('celebration');
const [layout, setLayout] = useState('symmetric');
const [view, setView] = useState('overview');
const [tool, setTool] = useState('filter');
const [sticker, setSticker] = useState(true);
const [caption, setCaption] = useState('');
const [background, setBackground] = useState(null);
const [adjust, setAdjust] = useState({ brightness: 100, contrast: 100, saturate: 100 });
useEffect(() => {
async function loadResult() {
const cached = sessionStorage.getItem('pk_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(error => setError(error.message));
}, [runId]);
const draw = useCallback(() => {
if (!result || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const colors = THEMES[theme];
ctx.clearRect(0, 0, canvas.width, canvas.height);
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}%)`;
coverImage(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('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);
if (caption) {
ctx.fillStyle = colors[3];
roundRect(ctx, 42, 870, Math.min(650, 50 + caption.length * 34), 52, 26);
ctx.fill();
ctx.fillStyle = colors[2];
ctx.font = '800 25px system-ui';
ctx.fillText(caption.slice(0, 20), 67, 904);
} else {
ctx.fillStyle = colors[1];
ctx.font = '800 25px system-ui';
ctx.fillText(`冠军 ${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 22px system-ui'; ctx.fillText('WIN', 635, 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 pickBackground(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 = `我的PK冠军-${result.champion_name}.png`;
link.href = canvasRef.current.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 onClick={() => router.push('/me')}>←</button>
<div><strong>分享图编辑器</strong><small>{result.test_title} · {result.champion_name}</small></div>
<button className="exportButton" onClick={save}>导出</button>
</header>
<section className="editorWorkspace">
<canvas ref={canvasRef} width="750" height="1000" />
<aside className="editorControls">
<div className="scrollRow compact">
<button className={layout === 'symmetric' ? 'active' : ''} onClick={() => setLayout('symmetric')}>对称图</button>
<button className={layout === 'single' ? 'active' : ''} onClick={() => setLayout('single')}>单边图</button>
{VIEWS.map(item => <button key={item[0]} className={view === item[0] ? 'active' : ''} onClick={() => setView(item[0])}>{item[1]}</button>)}
</div>
<div className="mediaRow">
<span className="bracketThumb">晋级图</span>
<label className="addMedia">+<small>添加底图</small><input type="file" accept="image/*" onChange={pickBackground} /></label>
</div>
<div className="toolRow">
{TOOLS.map((item, index) => <button key={item[0]} className={tool === item[0] ? 'active' : ''} onClick={() => setTool(item[0])}><i>{index + 1}</i><span>{item[1]}</span></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' ? <AdjustControls value={adjust} onChange={setAdjust} /> : null}
{['crop', 'layout', 'brush'].includes(tool) ? <p>该工具由 PosterDocument 图层绑定驱动,可继续从素材服务加载预设。</p> : null}
</motion.div>
</aside>
</section>
</main>
);
}
function AdjustControls({ value, onChange }) {
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>;
}
function coverImage(ctx, image, x, y, width, height) {
const ratio = Math.max(width / image.width, height / image.height);
const drawWidth = image.width * ratio, drawHeight = image.height * ratio;
ctx.drawImage(image, x + (width - drawWidth) / 2, y + (height - drawHeight) / 2, drawWidth, drawHeight);
}
function roundRect(ctx, x, y, width, height, radius) {
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(x, y, width, height, radius); return; }
ctx.beginPath(); ctx.rect(x, y, width, height);
}
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));
const 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';
const side = symmetric && round < maxRound && index % 2 ? 1 : -1;
const x = symmetric ? 375 + side * Math.min(260, 78 + (maxRound - round) * 62) : 70 + (round - minRound) * (580 / Math.max(1, maxRound - minRound));
const 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));
const 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);
});
}