Connect all 27 source layouts to parameterized Studio pages and shared design storage
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import {useId} from 'react';
|
||||
import type {Layout,Parameters} from '../lib/layout-parameters';
|
||||
export type LayerLayout=Omit<Layout,'elements'>&{source:string;background:string;tutorialUrl:string;sourceSha256:string;elements:(Layout['elements'][number]&{raster:string;shape?:string})[]};
|
||||
export function LayoutCanvas({layout,params,guides=false,imageSources={}}:{layout:LayerLayout;params:Parameters;guides?:boolean;imageSources?:Record<string,string>}){
|
||||
const uid=useId().replace(/[^a-z0-9]/gi,''),W=layout.width,H=layout.height,unchanged=!params.paper&&!Object.keys(params.texts).length&&!Object.keys(params.images).length&&!Object.keys(params.decorations).length;
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${W} ${H}`} width={W*6} height={H*6} role="img" aria-label={layout.id+' '+layout.name} data-layout-id={layout.id} style={{width:'100%',height:'auto',display:'block'}}><title>{layout.id+' · '+layout.name}</title><desc>对应原图的文字、图位和装饰。未替换构件保留原始低分辨率图层;替换文字为 SVG 文字。</desc>{unchanged?<image href={layout.source} width={W} height={H}/>:<><rect width={W} height={H} fill={params.paper||'#f4f2eb'}/><image href={layout.background} width={W} height={H}/>{params.paper&&<><defs><mask id={uid+'paper'}><image href={layout.background} width={W} height={H}/></mask></defs><rect width={W} height={H} fill={params.paper} mask={'url(#'+uid+'paper)'}/></>}{['image','decoration','text'].flatMap(kind=>layout.elements.filter(s=>s.kind===kind).map(s=>{
|
||||
const [x,y,w,h]=s.box,id=uid+s.id,t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id];
|
||||
if(kind==='text'&&t){const lines=t.value.split('\n'),len=(v:string)=>Array.from(v).reduce((a,c)=>a+(/[\u3400-\u9fff]/.test(c)?1:.6),0),vertical=s.direction==='vertical',size=Math.min(vertical?w*.9:h/(lines.length*1.2),vertical?h/Math.max(1,Array.from(t.value).length):w/Math.max(1,...lines.map(len)))*(t.scale||1),align=t.align||'start';return <g key={s.id} data-slot={s.id} fill={t.color||'#26362f'} fontFamily="Arial,'Noto Sans CJK SC','PingFang SC',sans-serif"><defs><clipPath id={id+'clip'}><rect x={x} y={y} width={w} height={h}/></clipPath></defs><g clipPath={`url(#${id}clip)`}>{vertical?Array.from(t.value.replaceAll('\n','')).map((c,i)=><text key={i} x={x+w/2} y={y+size*(i+.85)} textAnchor="middle" fontSize={size}>{c}</text>):s.direction==='curved'?<><defs><path id={id+'path'} d={`M${x} ${y+h*.2} Q${x+w/2} ${y+h*1.7} ${x+w} ${y+h*.2}`}/></defs><text fontSize={size}><textPath href={'#'+id+'path'}>{t.value}</textPath></text></>:lines.map((l,i)=><text key={i} x={x+(align==='middle'?w/2:align==='end'?w:0)} y={y+size*(.88+i*1.2)} textAnchor={align} fontSize={size}>{l}</text>)}</g></g>}
|
||||
if(kind==='decoration'&&d?.hidden)return null;
|
||||
const fill=kind==='image'?im?.color:d?.color;
|
||||
if(fill||im?.assetId)return <g key={s.id} data-slot={s.id}><defs><mask id={id} maskUnits="userSpaceOnUse" x={x} y={y} width={w} height={h} style={{maskType:'alpha'}}><image href={s.shape||s.raster} x={x} y={y} width={w} height={h}/></mask></defs><g mask={'url(#'+id+')'}>{fill&&<rect x={x} y={y} width={w} height={h} fill={fill}/>} {im?.assetId&&<image href={imageSources[im.assetId]||'/api/media/'+im.assetId} x={x} y={y} width={w} height={h} preserveAspectRatio={im.fit==='contain'?'xMidYMid meet':'xMidYMid slice'}/>}</g></g>;
|
||||
return <image key={s.id} data-slot={s.id} href={s.raster} x={x} y={y} width={w} height={h}/>;
|
||||
}))}</>}{guides&&layout.elements.map(s=>{const[x,y,w,h]=s.box,color=s.kind==='text'?'#2765da':s.kind==='image'?'#d86b27':'#08875d';return <g key={s.id} aria-hidden="true"><rect x={x} y={y} width={w} height={h} fill="none" stroke={color} strokeWidth=".35"/><text x={x} y={y+3} fontSize="3" fill={color}>{s.id}</text></g>})}</svg>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import {LayoutCanvas,type LayerLayout} from './LayoutCanvas';
|
||||
import {parameters,type Parameters} from '../lib/layout-parameters';
|
||||
import layouts from '../content/templates/layouts.json';
|
||||
type Design={id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
|
||||
export function LayoutWorkbench({layoutId,designId}:{layoutId?:string;designId?:string}){
|
||||
const [active,setActive]=useState(layoutId||''),[params,setParams]=useState<Parameters>({texts:{},images:{},decorations:{}}),[design,setDesign]=useState<Design|null>(null),[name,setName]=useState('新排版效果'),[status,setStatus]=useState(''),[guides,setGuides]=useState(false),[busy,setBusy]=useState(false),[raw,setRaw]=useState(''),[recent,setRecent]=useState<Design[]>([]);
|
||||
const layout=layouts.find(l=>l.id===active) as LayerLayout|undefined;
|
||||
useEffect(()=>{if(!designId)return;let live=true;fetch('/api/studio/designs/'+designId).then(async r=>{const d=await r.json();if(!r.ok)throw Error(d.error);if(live){setDesign(d.design);setActive(d.design.layoutId);setParams(d.design.parameters);setName(d.design.name)}}).catch(e=>{if(live)setStatus(e.message)});return()=>{live=false}},[designId]);
|
||||
useEffect(()=>{if(!active)return;let live=true;fetch('/api/studio/designs?layoutId='+active).then(async r=>{if(r.ok&&live)setRecent((await r.json()).designs)}).catch(()=>{});return()=>{live=false}},[active,design]);
|
||||
function patch(p:Partial<Parameters>){setParams(old=>({...old,...p}));setStatus('有未保存的修改')}
|
||||
async function save(update=false){if(!layout)return;setBusy(true);try{const body=update&&design?{name,parameters:params,revision:design.revision}:{name,layoutId:layout.id,parameters:params},r=await fetch('/api/studio/designs'+(update&&design?'/'+design.id:''),{method:update?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}),d=await r.json();if(!r.ok)throw Error(d.error);setDesign(d.design);setStatus('已保存效果 · '+d.design.id);window.history.replaceState(null,'',d.design.url)}catch(e){setStatus(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}}
|
||||
async function download(){if(!layout)return;setBusy(true);try{const r=await fetch('/studio/api/layouts/'+layout.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});if(!r.ok)throw Error('渲染失败');const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=(design?.id||layout.id)+'.svg';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}catch(e){setStatus(e instanceof Error?e.message:'导出失败')}finally{setBusy(false)}}
|
||||
if(!layout)return <main className="template-trial"><h1>读取排版效果</h1><p role="status">{status||'读取中…'}</p><a href="/explore/8eb8c76500580f7a4b56ba3c">返回使用教程</a></main>;
|
||||
return <main className="template-trial"><Head><title>{layout.id} · {name} · Studio</title></Head><header className="trial-nav"><a href="/explore/8eb8c76500580f7a4b56ba3c">← PROMPT · 27 套排版教程</a><a href={'/studio/api/layouts/'+layout.id+'?agent_mode=True'}>API 说明</a></header><section className="trial-intro"><div><small>STUDIO / {layout.id.toUpperCase()}</small><h1>{layout.name}</h1><p>每个构件对应原图。修改文字、图位和装饰,保存为独立 ID,可交给 agent 通过 API 继续调整。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><label>排版<select value={active} onChange={e=>{window.location.href='/studio/layouts/'+e.target.value}}>{layouts.map(l=><option key={l.id} value={l.id}>{l.id} · {l.name}</option>)}</select></label><label>效果名称<input value={name} maxLength={120} onChange={e=>setName(e.target.value)}/></label>
|
||||
<details open><summary>文字 T · {layout.elements.filter(s=>s.kind==='text').length} 组</summary>{layout.elements.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<small>{params.texts[s.id]?'替换文字':s.reading==='unreadable'?'原图小字无法辨认;未替换时保留原图层':'未替换时保留原图字形'}</small><textarea rows={2} maxLength={500} placeholder={s.text||'输入替换文字;留空可移除此文字'} value={params.texts[s.id]?.value??''} onChange={e=>patch({texts:{...params.texts,[s.id]:{...params.texts[s.id],value:e.target.value}}})}/><button onClick={()=>{const texts={...params.texts};delete texts[s.id];patch({texts})}}>恢复原图文字</button></label>)}</details>
|
||||
<details><summary>图位 I · {layout.elements.filter(s=>s.kind==='image').length} 组</summary>{layout.elements.filter(s=>s.kind==='image').map(s=><div key={s.id}><label>{s.id} · {s.role}<input type="color" value={params.images[s.id]?.color||'#83918a'} onChange={e=>patch({images:{...params.images,[s.id]:{...params.images[s.id],color:e.target.value}}})}/></label><label>已上传图片的 asset ID<input placeholder="UUID(可留空)" value={params.images[s.id]?.assetId||''} onChange={e=>{const image={...params.images[s.id]};if(e.target.value)image.assetId=e.target.value;else delete image.assetId;patch({images:{...params.images,[s.id]:image}})}}/></label><button onClick={()=>{const images={...params.images};delete images[s.id];patch({images})}}>恢复原图图位</button></div>)}</details>
|
||||
<details><summary>装饰 D · {layout.elements.filter(s=>s.kind==='decoration').length} 组</summary>{layout.elements.filter(s=>s.kind==='decoration').map(s=><div key={s.id}><label>{s.id} · {s.role}<input type="color" value={params.decorations[s.id]?.color||'#53645b'} onChange={e=>patch({decorations:{...params.decorations,[s.id]:{...params.decorations[s.id],color:e.target.value}}})}/></label><label><input type="checkbox" checked={params.decorations[s.id]?.hidden||false} onChange={e=>patch({decorations:{...params.decorations,[s.id]:{...params.decorations[s.id],hidden:e.target.checked}}})}/>隐藏此装饰</label><button onClick={()=>{const decorations={...params.decorations};delete decorations[s.id];patch({decorations})}}>恢复原装饰</button></div>)}</details>
|
||||
<label>纸色<input type="color" value={params.paper||'#f4f2eb'} onChange={e=>patch({paper:e.target.value})}/></label><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示 T/I/D 构件边界</label><details onToggle={e=>{if(e.currentTarget.open)setRaw(JSON.stringify(params,null,2))}}><summary>完整参数 JSON</summary><textarea aria-label="完整参数 JSON" rows={12} value={raw} onChange={e=>setRaw(e.target.value)}/><button onClick={()=>{try{setParams(parameters(layout,JSON.parse(raw)));setStatus('参数已应用,尚未保存')}catch(e){setStatus(e instanceof Error?e.message:'JSON 错误')}}}>应用参数</button></details>
|
||||
<div className="trial-actions"><button disabled={busy} className="trial-primary" onClick={()=>void save()}>保存新效果</button>{design&&<button disabled={busy} onClick={()=>void save(true)}>更新当前 ID</button>}<button disabled={busy} onClick={()=>void download()}>下载当前 SVG</button></div><p role="status">{status}</p>{design&&<p><a href={design.url}>打开已保存效果</a> · 修订 {design.revision}</p>}<p className="trial-help">原图来自低分辨率截图,未替换部分保留其像素质量。渲染不会自动保存;保存新效果生成新 ID,更新当前 ID 会检测修订冲突。</p><details><summary>已保存的此排版效果</summary>{recent.length?recent.map(d=><p key={d.id}><a href={d.url}>{d.name}</a> · r{d.revision}</p>):<p>还没有保存的效果</p>}</details></aside>
|
||||
<div><div className="selection-preview"><LayoutCanvas layout={layout} params={params} guides={guides}/></div><details className="trial-reference"><summary>原图与构件对应表</summary><div className="trial-reference-body"><img src={layout.source} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>原图分层文档 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>坐标</th></tr></thead><tbody>{layout.elements.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
}
|
||||
Reference in New Issue
Block a user