Modernize native layout editing workspace and shared design parameters
studio CI and deployment / verify-and-deploy (push) Failing after 2m22s
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 17m53s

This commit is contained in:
2026-10-27 17:37:26 +08:00
parent 3069a797b4
commit ad1f83d398
6 changed files with 90 additions and 45 deletions
+45 -29
View File
@@ -1,6 +1,7 @@
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent} from 'react';
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Check,Save} from 'lucide-react';
import Head from 'next/head';
import type {Layout,Parameters,Slot} from '../lib/layout-parameters';
import type {Layout,Parameters,Slot,LayerValue,AddedSlot} from '../lib/layout-parameters';
import {parameters} from '../lib/layout-parameters';
import {decorationKind,defaultText,designUrl,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout';
@@ -15,11 +16,24 @@ function CurvedCopy({layout,slot,value,style}:{layout:Layout;slot:Slot;value:str
return <div className="native-copy native-arc" aria-label={value} style={style}>{letters.map((letter,index)=>{const t=index/Math.max(1,letters.length-1),x=tall?90-75*Math.sin(Math.PI*t):4+92*t,y=tall?2+94*t:5+75*Math.sin(Math.PI*t),angle=tall?90+Math.cos(Math.PI*t)*18:Math.atan2(slot.box[3]*.75*Math.PI*Math.cos(Math.PI*t),slot.box[2]*.92)*180/Math.PI;return <span key={index} aria-hidden="true" style={{left:x+'%',top:y+'%',transform:`translate(-50%,-50%) rotate(${angle}deg)`}}>{letter===' '?'\u00a0':letter}</span>})}</div>;
}
export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
const [params,setParams]=useState<Parameters>(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState<string|null>(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState<Parameters[]>([]);
const upload=useRef<HTMLInputElement>(null),slot=layout.elements.find(s=>s.id===selected);
const [params,setParams]=useState<Parameters>(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState<string|null>(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState<Parameters[]>([]),[future,setFuture]=useState<Parameters[]>([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState<string|null>(null),[conflict,setConflict]=useState(false);
const canvas=useRef<HTMLElement>(null),viewport=useRef<HTMLDivElement>(null);
const elements=[...layout.elements,...(params.added||[])];
const upload=useRef<HTMLInputElement>(null),slot=elements.find(s=>s.id===selected);
useEffect(()=>{const warn=(event:BeforeUnloadEvent)=>{if(dirty){event.preventDefault();event.returnValue=''}};window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn)},[dirty]);
function patch(next:Parameters){try{const valid=parameters(layout,next);setHistory(old=>[...old.slice(-39),params]);setParams(valid);setDirty(true);setStatus('')}catch(e){setStatus(e instanceof Error?e.message:'修改失败')}}
function patch(next:Parameters){try{const valid=parameters(layout,next);setHistory(old=>[...old.slice(-39),params]);setFuture([]);setParams(valid);setDirty(true);setStatus('')}catch(e){setStatus(e instanceof Error?e.message:'修改失败')}}
function updateText(s:Slot,changes:Partial<Parameters['texts'][string]>){patch({...params,texts:{...params.texts,[s.id]:{...params.texts[s.id],value:params.texts[s.id]?.value??defaultText(layout,s),...changes}}})}
function layer(s:Slot):LayerValue{return params.layers?.[s.id]||{}}
function updateLayer(s:Slot,changes:LayerValue){patch({...params,layers:{...params.layers,[s.id]:{...layer(s),...changes}}})}
function undo(){if(!history.length)return;setFuture(f=>[params,...f]);setParams(history[history.length-1]);setHistory(history.slice(0,-1));setDirty(true)}
function redo(){if(!future.length)return;setHistory(h=>[...h,params]);setParams(future[0]);setFuture(future.slice(1));setDirty(true)}
function fit(){if(!viewport.current)return;setZoom(Math.max(.1,Math.min(2,(viewport.current.clientWidth-96)/860,(viewport.current.clientHeight-96)/(860*layout.height/layout.width))))}
function add(kind:string,shape?:'rectangle'|'ellipse'){const id='user-'+crypto.randomUUID();const added:AddedSlot={id,kind,role:kind==='text'?'新文字':kind==='image'?'新图片':shape==='ellipse'?'椭圆':'矩形',box:[layout.width*.15,layout.height*.15,layout.width*.3,layout.height*.16],direction:'horizontal',text:kind==='text'?'输入文字':null,reading:'custom',...(shape?{shape}:{})};patch({...params,added:[...(params.added||[]),added]});setSelected(id);setPanel('properties')}
function remove(){if(!slot||layer(slot).locked)return;updateLayer(slot,{hidden:true});setSelected(null)}
function duplicate(){if(!slot)return;const id='user-'+crypto.randomUUID(),l=layer(slot),group=slot.kind==='text'?'texts':slot.kind==='image'?'images':'decorations';patch({...params,added:[...(params.added||[]),{...slot,id,direction:'horizontal',reading:'custom',role:slot.role+' 副本',text:slot.kind==='text'?params.texts[slot.id]?.value??defaultText(layout,slot):null}],layers:{...params.layers,[id]:{...l,x:(l.x??slot.box[0])+2,y:(l.y??slot.box[1])+2,locked:false}},[group]:{...params[group],[id]:params[group][slot.id]|| (slot.kind==='text'?{value:defaultText(layout,slot)}:{})}});setSelected(id)}
function pointer(e:PointerEvent,s:Slot,mode:'move'|'resize'|'rotate'='move'){if(!editing||busy||layer(s).locked||textEditing===s.id||e.button!==0)return;e.preventDefault();e.stopPropagation();setSelected(s.id);const start=params,l=layer(s),rect=canvas.current!.getBoundingClientRect(),x=e.clientX,y=e.clientY,bx=l.x??s.box[0],by=l.y??s.box[1],w=l.width??s.box[2],h=l.height??s.box[3];const cx=rect.left+(bx+w/2)/layout.width*rect.width,cy=rect.top+(by+h/2)/layout.height*rect.height;let next=start;const move=(event:globalThis.PointerEvent)=>{const dx=(event.clientX-x)/rect.width*layout.width,dy=(event.clientY-y)/rect.height*layout.height;let change:LayerValue=mode==='move'?{x:bx+dx,y:by+dy}:mode==='resize'?{width:Math.max(.1,w+dx),height:Math.max(.1,h+dy)}:{rotation:Math.max(-360,Math.min(360,(l.rotation||0)+(Math.atan2(event.clientY-cy,event.clientX-cx)-Math.atan2(y-cy,x-cx))*180/Math.PI))};next={...start,layers:{...start.layers,[s.id]:{...l,...change}}};setParams(next)};const end=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',end);window.removeEventListener('pointercancel',end);if(next!==start){setHistory(old=>[...old.slice(-39),start]);setFuture([]);setDirty(true)}};window.addEventListener('pointermove',move);window.addEventListener('pointerup',end);window.addEventListener('pointercancel',end)}
useEffect(()=>{if(editing){fit()}},[editing]);
useEffect(()=>{function key(e:globalThis.KeyboardEvent){if(!editing)return;const typing=(e.target as HTMLElement).closest('input,textarea,select,[contenteditable="plaintext-only"]');if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='s'){e.preventDefault();if(!busy)void save();return}if(typing)return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape'){setSelected(null);setTextEditing(null)}else if(slot&&!layer(slot).locked&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();const step=e.shiftKey?10:1,l=layer(slot);updateLayer(slot,{x:(l.x??slot.box[0])+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:(l.y??slot.box[1])+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
function select(s:Slot){if(busy)return;setEditing(true);setSelected(s.id)}
function reset(s:Slot){const group=s.kind==='text'?'texts':s.kind==='image'?'images':'decorations',values={...params[group]};delete values[s.id];patch({...params,[group]:values})}
function keySelect(e:KeyboardEvent,s:Slot){if(e.key==='ContextMenu'||(e.shiftKey&&e.key==='F10')){e.preventDefault();select(s)}if(e.key==='Escape')setSelected(null)}
@@ -27,8 +41,8 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
setBusy(true);try{
const valid=parameters(layout,params),updating=design&&!copy;
const response=await fetch('/api/studio/designs'+(updating?'/'+design.id:''),{method:updating?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(updating?{name,parameters:valid,revision:design.revision}:{name,layoutId:layout.id,parameters:valid})});
const data=await response.json();if(!response.ok)throw Error(data.error||'保存失败');
setDesign(data.design);setDirty(false);setEditing(false);setSelected(null);setStatus('已保存,当前网址可直接打开此页面');
const data=await response.json();if(!response.ok){if(response.status===409)setConflict(true);throw Error(data.error||'保存失败')};setConflict(false);
setDesign(data.design);setDirty(false);setStatus('已保存,当前网址可直接打开此页面');
window.history.replaceState(window.history.state,'',designUrl(layout.id,data.design.id));
}catch(e){setStatus(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}
}
@@ -36,36 +50,38 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
if(!file||!slot||slot.kind!=='image')return;const target=slot.id;setBusy(true);setStatus('正在上传图片…');
try{const body=new FormData();body.append('assetOnly','true');body.append('kind','image');body.append('files',file);const response=await fetch('/api/upload',{method:'POST',body});const data=await response.json();if(!response.ok||!data.assets?.[0])throw Error(data.error||'上传失败');patch({...params,images:{...params.images,[target]:{...params.images[target],assetId:data.assets[0]}}});setStatus('图片已替换,保存后生效于此网址')}catch(e){setStatus(e instanceof Error?e.message:'上传失败')}finally{setBusy(false);if(upload.current)upload.current.value=''}
}
const l=slot?layer(slot):{},t=slot?params.texts[slot.id]:undefined,im=slot?params.images[slot.id]:undefined;
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
<article className={'native-layout '+layout.id} inert={busy} aria-label={layout.name} data-layout-id={layout.id} data-design-id={design?.id||undefined} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:params.paper||paperColor(layout.id)}}>
{['image','decoration','text'].flatMap(kind=>layout.elements.filter(s=>s.kind===kind).map(s=>{
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],text=t?.value??defaultText(layout,s),hidden=kind==='decoration'&&d?.hidden;
const position=slotPosition(layout,s),common={onContextMenu:(e:React.MouseEvent)=>{e.preventDefault();select(s)},onKeyDown:(e:KeyboardEvent)=>keySelect(e,s),onClick:()=>{if(editing)setSelected(s.id)},onDoubleClick:()=>select(s),tabIndex:editing?0:undefined,'data-element':s.id,'aria-label':s.role};
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
<article ref={canvas} className={'native-layout '+layout.id} inert={busy} aria-label={layout.name} data-layout-id={layout.id} data-design-id={design?.id||undefined} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:params.paper||paperColor(layout.id),...(editing?{width:860*zoom,minWidth:860*zoom,marginBottom:40}:{} )}}>
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],text=t?.value??defaultText(layout,s),hidden=layer(s).hidden||(kind==='decoration'&&d?.hidden),ls=layer(s);
const position={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)},common={onContextMenu:(e:React.MouseEvent)=>{e.preventDefault();select(s)},onKeyDown:(e:KeyboardEvent)=>keySelect(e,s),onClick:()=>{if(editing)setSelected(s.id)},onPointerDown:(e:PointerEvent)=>pointer(e,s),onDoubleClick:()=>{select(s);if(!ls.locked&&kind==='text')setTextEditing(s.id)},tabIndex:editing?0:undefined,'data-element':s.id,'aria-label':s.role};
if(hidden)return null;
return <div key={s.id} {...common} className={`native-element native-${kind}${selected===s.id&&editing?' is-selected':''}`} style={position}>
{kind==='text'&&s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} style={{fontSize:`${textSize(layout,s,text)*(t?.scale||1)/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s)}}/>:kind==='text'?<TextElement className={'native-copy direction-'+s.direction} editable={editing} onDirty={()=>setDirty(true)} value={text} onInvalid={()=>setStatus('文字最多 500 字、8 行')} onChange={value=>updateText(s,{value})} style={{fontSize:`${textSize(layout,s,text)*(t?.scale||1)/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}}/>:
kind==='image'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{})}}>{im?.assetId&&<img src={'/api/media/'+im.assetId} alt={s.role} draggable={false} style={{objectFit:im.fit||'cover'}}/>}{editing&&!im?.assetId&&<span className="native-image-label">{s.role}</span>}</div>:
<div className={'native-decoration decoration-'+decorationKind(s)} style={{color:d?.color||'#83908a',backgroundColor:decorationKind(s)==='band'?d?.color||'#dce1d8':undefined}}>{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=><i key={i}/>)}{decorationKind(s)==='vertical-lines'&&<><i/><i/><i/></>}</div>}
{kind==='text'&&s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} style={{fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s)}}/>:kind==='text'?<TextElement className={'native-copy direction-'+s.direction} editable={editing&&textEditing===s.id&&!ls.locked} onDirty={()=>setDirty(true)} value={text} onInvalid={()=>setStatus('文字最多 500 字、8 行')} onChange={value=>updateText(s,{value})} style={{fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily=== 'serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'}}/>:
kind==='image'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{}),...(ls.radius!==undefined?{borderRadius:`${ls.radius/layout.width*100}cqw`}:{})}}>{im?.assetId&&<img src={'/api/media/'+im.assetId} alt={s.role} draggable={false} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>}{editing&&!im?.assetId&&<span className="native-image-label">{s.role}</span>}</div>:
<div className={'native-decoration decoration-'+((s as AddedSlot).shape?'solid':decorationKind(s))} style={{borderRadius:(s as AddedSlot).shape==='ellipse'?'50%':undefined,color:d?.color||'#83908a',backgroundColor:(s as AddedSlot).shape?d?.color||'#83908a':decorationKind(s)==='band'?d?.color||'#dce1d8':undefined}}>{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=><i key={i}/>)}{decorationKind(s)==='vertical-lines'&&<><i/><i/><i/></>}</div>}
{editing&&selected===s.id&&!ls.locked&&<><button className="resize-handle" aria-label="拖动调整尺寸" onPointerDown={e=>pointer(e,s,'resize')}/><button className="rotate-handle" aria-label="拖动旋转" onPointerDown={e=>pointer(e,s,'rotate')}>↻</button></>}
</div>;
}))}
</article>
<nav className="page-tools" aria-label="页面操作">
<a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回排版目录">←</a>
<span className="page-layout-number">{layout.id.toUpperCase()}</span>
{editing?<><button onClick={()=>{setEditing(false);setSelected(null)}}>查看页面</button><button disabled={!history.length||busy} onClick={()=>{setParams(history[history.length-1]);setHistory(history.slice(0,-1));setDirty(true)}}>撤销</button><button className="page-save" disabled={busy} onClick={()=>void save()}>{busy?'处理中…':design?'保存修改':'保存页面'}</button></>:<button onClick={()=>setEditing(true)}>编辑页面</button>}
{dirty&&<span className="page-dirty" aria-label="尚未保存">●</span>}
</nav>
{editing&&<div className="page-edit-info" inert={busy}><p>点击文字直接改写;点击图形调整。也可以右键选择元素。</p><label>页面名称<input value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/></label><div className="page-edit-options"><label>纸色<input type="color" aria-label="纸色" value={params.paper||paperColor(layout.id)} onChange={e=>patch({...params,paper:e.target.value})}/></label><label>选择元素<select aria-label="选择元素" value={selected||''} onChange={e=>setSelected(e.target.value||null)}><option value="">选择元素…</option>{layout.elements.map(s=><option value={s.id} key={s.id}>{s.role}</option>)}</select></label>{design&&<button disabled={busy} onClick={()=>void save(true)}>另存副本</button>}</div></div>}
{editing&&slot&&<div className="element-popover" inert={busy} role="dialog" aria-label={'编辑'+slot.role}>
<div className="element-popover-heading"><strong>{slot.role}</strong><button aria-label="关闭元素设置" onClick={()=>setSelected(null)}>×</button></div>
<label>颜色<input type="color" aria-label={slot.role+'颜色'} value={selectedColor} onChange={e=>{const color=e.target.value;if(slot.kind==='text')updateText(slot,{color});else if(slot.kind==='image')patch({...params,images:{...params.images,[slot.id]:{...params.images[slot.id],color}}});else patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],color}}})}}/></label>
{slot.kind==='text'&&<><label>字号<input type="range" min="0.25" max="2" step="0.05" value={params.texts[slot.id]?.scale||1} onChange={e=>updateText(slot,{scale:Number(e.target.value)})}/></label><label>对齐<select value={params.texts[slot.id]?.align||'start'} onChange={e=>updateText(slot,{align:e.target.value as 'start'|'middle'|'end'})}><option value="start">左对齐</option><option value="middle">居中</option><option value="end">右对齐</option></select></label><label className="element-text-edit">文字<textarea rows={3} value={params.texts[slot.id]?.value??defaultText(layout,slot)} onChange={e=>updateText(slot,{value:e.target.value})}/></label></>}
{slot.kind==='image'&&<><button disabled={busy} onClick={()=>upload.current?.click()}>替换图片</button>{params.images[slot.id]?.assetId&&<><label>图片适应<select value={params.images[slot.id]?.fit||'cover'} onChange={e=>patch({...params,images:{...params.images,[slot.id]:{...params.images[slot.id],fit:e.target.value as 'cover'|'contain'}}})}><option value="cover">填满</option><option value="contain">完整显示</option></select></label><button onClick={()=>{const image={...params.images[slot.id]};delete image.assetId;patch({...params,images:{...params.images,[slot.id]:image}})}}>移除图片</button></>}</>}
{slot.kind==='decoration'&&<label>隐藏<input type="checkbox" checked={params.decorations[slot.id]?.hidden||false} onChange={e=>patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],hidden:e.target.checked}}})}/></label>}
<button onClick={()=>reset(slot)}>恢复默认</button>
</div>}
</div>
{!editing&&<nav className="page-tools" aria-label="页面操作"><a href="/explore/8eb8c76500580f7a4b56ba3c">←</a><span className="page-layout-number">{layout.id.toUpperCase()}</span><button onClick={()=>setEditing(true)}>编辑页面</button>{dirty&&<span className="page-dirty">●</span>}</nav>}
{editing&&<aside className={'workbench-properties '+(panel==='properties'?'mobile-open':'')} inert={busy}><div className="panel-heading"><SlidersHorizontal size={16}/><strong>{slot?'元素属性':'页面设置'}</strong><span>{slot?.id||'Design'}</span></div>
{!slot?<><section><h3>页面</h3><label>纸张颜色<input type="color" value={params.paper||paperColor(layout.id)} onChange={e=>patch({...params,paper:e.target.value})}/></label><p className="property-hint">选择画布上的元素或左侧图层,调整它的设计属性。</p></section>{design&&<section><button onClick={()=>void save(true)}><Copy size={15}/>另存副本</button></section>}<section><h3>快捷操作</h3><p className="property-hint">⌘/Ctrl S 保存<br/>⌘/Ctrl Z 撤销<br/>Shift ⌘/Ctrl Z 重做<br/>Delete 隐藏所选图层<br/>Esc 取消选择</p></section></>:<><section><h3>{slot.role}<span>{slot.kind.toUpperCase()}</span></h3><div className="property-grid">{(['x','y','width','height','rotation','z'] as const).map((key,i)=><label key={key}>{['X','Y','宽','高','旋转 °','层级'][i]}<input aria-label={['X','Y','宽','高','旋转','层级'][i]} type="number" step="0.1" disabled={l.locked} value={Math.round((l[key]??(i<4?slot.box[i]:key==='z'?(slot.kind==='text'?3:slot.kind==='image'?1:2):0))*100)/100} onChange={e=>updateLayer(slot,{[key]:Number(e.target.value)})}/></label>)}</div><div className="property-actions"><button disabled={l.locked} onClick={()=>updateLayer(slot,{z:(l.z??(slot.kind==='text'?3:slot.kind==='image'?1:2))+1})}><ChevronUp size={14}/>上移</button><button disabled={l.locked} onClick={()=>updateLayer(slot,{z:(l.z??(slot.kind==='text'?3:slot.kind==='image'?1:2))-1})}><ChevronDown size={14}/>下移</button></div></section>
<fieldset disabled={l.locked}><section><h3>外观</h3><label>颜色<input type="color" value={selectedColor} onChange={e=>{const color=e.target.value;if(slot.kind==='text')updateText(slot,{color});else if(slot.kind==='image')patch({...params,images:{...params.images,[slot.id]:{...im,color}}});else patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],color}}})}}/></label><div className="property-grid"><label>不透明度 %<input type="number" min="0" max="100" value={(l.opacity??1)*100} onChange={e=>updateLayer(slot,{opacity:Number(e.target.value)/100})}/></label><label>圆角<input type="number" min="0" max="5000" value={l.radius??0} onChange={e=>updateLayer(slot,{radius:Number(e.target.value)})}/></label></div></section>
{slot.kind==='text'&&<section><h3>文字</h3><textarea aria-label="文字内容" rows={4} value={t?.value??defaultText(layout,slot)} onChange={e=>updateText(slot,{value:e.target.value})}/><label>字体<select value={t?.fontFamily||''} onChange={e=>updateText(slot,{fontFamily:e.target.value as 'sans'|'serif'|'mono'})}><option value="" disabled>模板默认</option><option value="sans">无衬线</option><option value="serif">衬线</option><option value="mono">等宽</option></select></label><div className="property-grid">{(['fontSize','fontWeight','lineHeight','letterSpacing'] as const).map((key,i)=><label key={key}>{['字号','字重','行距','字距'][i]}<input type="number" step={key==='lineHeight'?.1:key==='fontWeight'?100:.1} value={Math.round((t?.[key]??[textSize(layout,slot,t?.value??defaultText(layout,slot))*(t?.scale||1),500,1.15,0][i])*100)/100} onChange={e=>updateText(slot,{[key]:Number(e.target.value)})}/></label>)}</div><label>对齐<select value={t?.align||'start'} onChange={e=>updateText(slot,{align:e.target.value as 'start'|'middle'|'end'})}><option value="start">左对齐</option><option value="middle">居中</option><option value="end">右对齐</option></select></label></section>}
{slot.kind==='image'&&<section><h3>图片</h3><button onClick={()=>upload.current?.click()}><ImagePlus size={15}/>上传 / 替换图片</button><label>适应<select value={im?.fit||'cover'} onChange={e=>patch({...params,images:{...params.images,[slot.id]:{...im,fit:e.target.value as 'cover'|'contain'}}})}><option value="cover">裁切填满</option><option value="contain">完整显示</option></select></label>{(['cropX','cropY','zoom'] as const).map((key,i)=><label key={key}>{['水平裁切','垂直裁切','图片缩放'][i]}<input type="range" min={i===2?1:0} max={i===2?5:100} step={i===2?.05:1} value={im?.[key]??(i===2?1:50)} onChange={e=>patch({...params,images:{...params.images,[slot.id]:{...im,[key]:Number(e.target.value)}}})}/></label>)}</section>}</fieldset>
<section><div className="property-actions"><button disabled={l.locked} onClick={duplicate}><Copy size={14}/>复制</button><button disabled={l.locked} onClick={remove}><Trash2 size={14}/>删除</button></div><button disabled={l.locked} onClick={()=>reset(slot)}>恢复内容默认值</button></section></>}
</aside>}
{editing&&<nav className="workbench-bottom" aria-label="画布工具"><button className={panel==='layers'?'active':''} title="图层面板" onClick={()=>setPanel(panel==='layers'?null:'layers')}><Layers size={17}/></button><MousePointer2 size={18}/><span className="toolbar-divider"/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.1,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(3,z+.1))}><Plus size={16}/></button><button title="适合窗口" aria-label="适合窗口" onClick={fit}><Scan size={17}/></button><span className="toolbar-divider"/><button className={panel==='properties'?'active':''} title="属性面板" onClick={()=>setPanel(panel==='properties'?null:'properties')}><SlidersHorizontal size={17}/></button></nav>}
{conflict&&<div className="conflict-notice" role="alert"><strong>此页面已有更新</strong><p>本地修改已保留。可另存副本,或重新加载服务器版本。</p><button disabled={busy} onClick={()=>void save(true)}>另存副本</button><button onClick={()=>{if(window.confirm('重新加载将丢弃本地未保存修改,继续?')){setDirty(false);window.location.reload()}}}>重新加载</button></div>}
<input ref={upload} type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" hidden onChange={e=>void uploadImage(e.target.files?.[0])}/>
{status&&<p className="page-status" role="status">{status}</p>}
</main>;
+14 -7
View File
@@ -1,23 +1,30 @@
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end'};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain'};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
export type DecorationValue={color?:string;hidden?:boolean};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string};
export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string;layers?:Record<string,LayerValue>;added?:AddedSlot[]};
export class LayoutParameterError extends Error{}
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
function fail(message:string):never{throw new LayoutParameterError(message)}
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail(`数值范围 ${min}–${max}`);return v}
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
export function parameters(layout:Layout,input:unknown):Parameters{
const raw=object(input);keys(raw,['texts','images','decorations','paper']);const result:Parameters={texts:{},images:{},decorations:{}};
const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
const added:AddedSlot[]=[];
if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
const elements=[...layout.elements,...added];
if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record<string,number|boolean>={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record<string,number[]>={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
if(raw.paper!==undefined)result.paper=color(raw.paper);
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
const entries=object(raw[group]??{});keys(entries,layout.elements.filter(e=>e.kind===kind).map(e=>e.id));
const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
for(const [id,value] of Object.entries(entries)){const v=object(value);
if(group==='texts'){keys(v,['value','color','scale','align']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}result.images[id]=image;
if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
}
}
+9 -2
View File
@@ -5,10 +5,17 @@ import {renderToStaticMarkup} from 'react-dom/server';
import layouts from '../../../content/templates/layouts.json';
import {LayoutCanvas} from '../../../components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../lib/layout-parameters';
const workspaceSchema={
added:'可选数组,最多100项:{id:"user-唯一ID",kind:"text|image|decoration",role:"名称",box:[x,y,width,height],direction:"horizontal",text:"文字或null",reading:"custom",shape:"rectangle|ellipse(装饰可选)"}',
layers:'按原生或新增元素ID索引:x/y -10000..10000,width/height 0.1..10000,rotation -360..360,opacity 0..1,radius 0..5000,z -1000..1000,hidden/locked 布尔值;坐标和字号均为模板单位',
texts:'原有 value/color/scale/align,加 fontFamily sans|serif|mono,fontSize 0.1..1000,fontWeight 100..900,lineHeight 0.5..4,letterSpacing -20..100',
images:'原有 assetId/color/fit,加 cropX/cropY 0..100(默认50),zoom 1..5',
export:'新增工作区参数由原生 HTML/CSS 页面呈现。旧 SVG 导出仅支持旧文本、图片与装饰参数,不反映新增元素、图层几何及扩展字体或裁切设置。使用页面打印可保存原生效果。'
};
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 ref-01 .. ref-27。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。带 ?id={designId} 在服务端加载已保存页面;ID 与排版不匹配或不存在返回404。右键或编辑页面可调整元素。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开 /studio/layouts/{layoutId}?id={id}(原生页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 ref-01 .. ref-27。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。带 ?id={designId} 在服务端加载已保存页面;ID 与排版不匹配或不存在返回404。右键或编辑页面可调整元素。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开 /studio/layouts/{layoutId}?id={id}(原生页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
}
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
File diff suppressed because one or more lines are too long
@@ -3,3 +3,5 @@ import {parameters} from '../src/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
@@ -1,23 +1,30 @@
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end'};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain'};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
export type DecorationValue={color?:string;hidden?:boolean};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string};
export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string;layers?:Record<string,LayerValue>;added?:AddedSlot[]};
export class LayoutParameterError extends Error{}
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
function fail(message:string):never{throw new LayoutParameterError(message)}
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail(`数值范围 ${min}–${max}`);return v}
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
export function parameters(layout:Layout,input:unknown):Parameters{
const raw=object(input);keys(raw,['texts','images','decorations','paper']);const result:Parameters={texts:{},images:{},decorations:{}};
const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
const added:AddedSlot[]=[];
if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
const elements=[...layout.elements,...added];
if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record<string,number|boolean>={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record<string,number[]>={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
if(raw.paper!==undefined)result.paper=color(raw.paper);
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
const entries=object(raw[group]??{});keys(entries,layout.elements.filter(e=>e.kind===kind).map(e=>e.id));
const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
for(const [id,value] of Object.entries(entries)){const v=object(value);
if(group==='texts'){keys(v,['value','color','scale','align']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}result.images[id]=image;
if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
}
}