diff --git a/apps/studio/src/components/LayoutWorkbench.tsx b/apps/studio/src/components/LayoutWorkbench.tsx
index 66a33c6..45030d0 100644
--- a/apps/studio/src/components/LayoutWorkbench.tsx
+++ b/apps/studio/src/components/LayoutWorkbench.tsx
@@ -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
{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 {letter===' '?'\u00a0':letter} })}
;
}
export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
- const [params,setParams]=useState(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState([]);
- const upload=useRef(null),slot=layout.elements.find(s=>s.id===selected);
+ const [params,setParams]=useState(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState([]),[future,setFuture]=useState([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState(null),[conflict,setConflict]=useState(false);
+ const canvas=useRef(null),viewport=useRef(null);
+ const elements=[...layout.elements,...(params.added||[])];
+ const upload=useRef(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){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
{name} · Prompt
-
- {['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&&}
+ {editing&&图层与素材 {elements.length}
add('text')}> 文字 add('decoration','rectangle')}> 矩形 add('decoration','ellipse')}> 椭圆 add('image')}> 图片
{layout.id.toUpperCase()} 从上到下排列
{[...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=>
{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?:s.kind==='image'?:}{s.role} updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?:} updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?:}
)}
拖动移动 · 双击文字改写 ⌘/Ctrl D 复制 · 方向键微调 Shift + 方向键移动 10 单位
}
+ {if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
+ {editing&&
{layout.name}{layout.width} × {layout.height}
}
+
+ {['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
- {kind==='text'&&s.direction==='curved'?
:kind==='text'?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'?{im?.assetId&&
}{editing&&!im?.assetId&&
{s.role} }
:
- {decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=>)}{decorationKind(s)==='vertical-lines'&&<> >}
}
+ {kind==='text'&&s.direction==='curved'?:kind==='text'?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'?{im?.assetId&&
}{editing&&!im?.assetId&&
{s.role} }
:
+ {decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=>)}{decorationKind(s)==='vertical-lines'&&<> >}
}
+ {editing&&selected===s.id&&!ls.locked&&<>pointer(e,s,'resize')}/>pointer(e,s,'rotate')}>↻ >}
;
}))}
-
- ←
- {layout.id.toUpperCase()}
- {editing?<>{setEditing(false);setSelected(null)}}>查看页面 {setParams(history[history.length-1]);setHistory(history.slice(0,-1));setDirty(true)}}>撤销 void save()}>{busy?'处理中…':design?'保存修改':'保存页面'} >:setEditing(true)}>编辑页面 }
- {dirty&&● }
-
- {editing&&
}
- {editing&&slot&&
-
{slot.role} setSelected(null)}>×
-
颜色 {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}}})}}/>
- {slot.kind==='text'&&<>
字号 updateText(slot,{scale:Number(e.target.value)})}/> 对齐updateText(slot,{align:e.target.value as 'start'|'middle'|'end'})}>左对齐 居中 右对齐 文字 >}
- {slot.kind==='image'&&<>
upload.current?.click()}>替换图片 {params.images[slot.id]?.assetId&&<>
图片适应patch({...params,images:{...params.images,[slot.id]:{...params.images[slot.id],fit:e.target.value as 'cover'|'contain'}}})}>填满 完整显示 {const image={...params.images[slot.id]};delete image.assetId;patch({...params,images:{...params.images,[slot.id]:image}})}}>移除图片 >}>}
- {slot.kind==='decoration'&&
隐藏 patch({...params,decorations:{...params.decorations,[slot.id]:{...params.decorations[slot.id],hidden:e.target.checked}}})}/> }
-
reset(slot)}>恢复默认
-
}
+
+ {!editing&&← {layout.id.toUpperCase()} setEditing(true)}>编辑页面 {dirty&&● } }
+ {editing&&{slot?'元素属性':'页面设置'} {slot?.id||'Design'}
+ {!slot?<>{design&&}快捷操作 ⌘/Ctrl S 保存 ⌘/Ctrl Z 撤销 Shift ⌘/Ctrl Z 重做 Delete 隐藏所选图层 Esc 取消选择
>:<>{slot.role}{slot.kind.toUpperCase()} {(['x','y','width','height','rotation','z'] as const).map((key,i)=>{['X','Y','宽','高','旋转 °','层级'][i]} updateLayer(slot,{[key]:Number(e.target.value)})}/> )}
updateLayer(slot,{z:(l.z??(slot.kind==='text'?3:slot.kind==='image'?1:2))+1})}>上移 updateLayer(slot,{z:(l.z??(slot.kind==='text'?3:slot.kind==='image'?1:2))-1})}>下移
+
+ {slot.kind==='text'&&}
+ {slot.kind==='image'&&图片 upload.current?.click()}>上传 / 替换图片 适应patch({...params,images:{...params.images,[slot.id]:{...im,fit:e.target.value as 'cover'|'contain'}}})}>裁切填满 完整显示 {(['cropX','cropY','zoom'] as const).map((key,i)=>{['水平裁切','垂直裁切','图片缩放'][i]} patch({...params,images:{...params.images,[slot.id]:{...im,[key]:Number(e.target.value)}}})}/> )} }
+ >}
+ }
+ {editing&&setPanel(panel==='layers'?null:'layers')}> setZoom(z=>Math.max(.1,z-.1))}> {Math.round(zoom*100)}% setZoom(z=>Math.min(3,z+.1))}> setPanel(panel==='properties'?null:'properties')}> }
+ {conflict&&此页面已有更新 本地修改已保留。可另存副本,或重新加载服务器版本。
void save(true)}>另存副本 {if(window.confirm('重新加载将丢弃本地未保存修改,继续?')){setDirty(false);window.location.reload()}}}>重新加载 }
void uploadImage(e.target.files?.[0])}/>
{status&&{status}
}
;
diff --git a/apps/studio/src/lib/layout-parameters.ts b/apps/studio/src/lib/layout-parameters.ts
index 42ee86f..f6125b0 100644
--- a/apps/studio/src/lib/layout-parameters.ts
+++ b/apps/studio/src/lib/layout-parameters.ts
@@ -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;images:Record;decorations:Record;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;images:Record;decorations:Record;paper?:string;layers?:Record;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{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record}
function keys(v:Record,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)||vmax)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={};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={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}
}
}
diff --git a/apps/studio/src/pages/api/layouts/[id].ts b/apps/studio/src/pages/api/layouts/[id].ts
index 51cb711..5d372ca 100644
--- a/apps/studio/src/pages/api/layouts/[id].ts
+++ b/apps/studio/src/pages/api/layouts/[id].ts
@@ -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'}}};
diff --git a/apps/studio/src/styles/globals.css b/apps/studio/src/styles/globals.css
index 022b2a5..5508281 100644
--- a/apps/studio/src/styles/globals.css
+++ b/apps/studio/src/styles/globals.css
@@ -62,3 +62,9 @@
@media print{.native-layout-page{padding:0;background:none}.native-layout{width:100%;margin:0;print-color-adjust:exact}.page-tools,.page-edit-info,.element-popover,.page-status{display:none!important}.native-element{outline:none!important}}
.native-copy.native-arc{position:relative;overflow:visible;white-space:pre;writing-mode:horizontal-tb;letter-spacing:0}.native-arc span{position:absolute;display:block}
+/* Native design workspace: chrome is mounted only while editing. */
+.is-editing.native-layout-page{height:100dvh;min-height:0;padding:58px 272px 0 232px;overflow:hidden;background:#f1f2f4;color:#20232b;font-family:Arial,"PingFang SC",sans-serif}
+.workbench-top{position:fixed;inset:0 0 auto;height:58px;z-index:60;display:flex;align-items:center;gap:10px;padding:0 16px;background:#fff;border-bottom:1px solid #e6e7eb;font-size:12px}.studio-mark{display:flex;align-items:center;gap:7px;font-weight:800;font-size:23px;color:#6857e8}.studio-mark span{font-size:10px;letter-spacing:.1em;color:#262831}.workbench-top input{border:0;background:transparent;font-size:13px;min-width:80px;width:240px;padding:8px}.save-indicator{margin-left:auto;color:#7a7f8b;font-size:11px;white-space:nowrap}.workbench-top button,.workbench-bottom button,.workbench-layers button,.workbench-properties button,.conflict-notice button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:6px;background:transparent;color:inherit;padding:8px;cursor:pointer;font:inherit}.workbench-top button:hover,.workbench-bottom button:hover,.workbench-layers button:hover,.workbench-properties button:hover{background:#f0eefc}.workbench-top button.primary{background:#6857e8;color:#fff;padding:9px 16px}.is-editing button:disabled{opacity:.35;cursor:default}.workbench-layers,.workbench-properties{position:fixed;top:58px;bottom:0;background:#fff;z-index:45;font-size:12px;overflow:auto}.workbench-layers{left:0;width:232px;border-right:1px solid #e3e5e9;display:flex;flex-direction:column}.workbench-properties{right:0;width:272px;border-left:1px solid #e3e5e9}.panel-heading{height:53px;display:flex;align-items:center;gap:8px;padding:16px;border-bottom:1px solid #eeeff2;flex-shrink:0}.panel-heading span{margin-left:auto;font-size:10px;color:#8e929d;max-width:95px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.insert-tools{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:16px}.insert-tools button{background:#f7f7f9;border:1px solid #ececf1;font-size:11px;padding:12px 5px}.insert-tools svg{width:16px;height:16px}.layers-caption{padding:12px 16px;color:#797e8c;font-size:10px;display:flex;justify-content:space-between}.layers-caption span{font-size:10px;color:#a0a4ae}.layer-list{padding:0 8px;flex:1}.layer-row{display:flex;align-items:center;gap:0;border-radius:5px;margin:2px 0;min-height:37px}.layer-row.active{background:#eeeaff;color:#6752d9}.layer-row.muted{opacity:.5}.layer-row button{padding:6px 4px}.layer-row .layer-select{flex:1;justify-content:flex-start;gap:8px;min-width:0;padding:9px 7px}.layer-select span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.layer-select svg{flex-shrink:0}.workbench-tip{font-size:10px;line-height:1.9;color:#9195a0;padding:16px;border-top:1px solid #eee;margin:20px 0 0}.workbench-viewport{height:100%;overflow:auto;padding:38px 48px 100px;background-image:radial-gradient(#cdd0d8 .7px,transparent .7px);background-size:14px 14px;display:flex;flex-direction:column;align-items:center}.canvas-caption{display:flex;justify-content:space-between;width:100%;font-size:10px;color:#858b98;margin-bottom:12px;gap:24px;max-width:860px}.is-editing .native-layout{flex-shrink:0;box-shadow:0 10px 35px #20232b13;overflow:visible}.is-editing .native-element{touch-action:none}.is-editing .native-element:hover{outline-color:#8570ee}.is-editing .native-element.is-selected{outline:1.5px solid #7761e5;outline-offset:0}.is-editing .native-element .native-copy{cursor:move}.is-editing .native-copy[contenteditable=plaintext-only]{cursor:text;user-select:text}.native-element .resize-handle{position:absolute;right:-5px;bottom:-5px;width:9px;height:9px;background:#fff;border:1.5px solid #7761e5;padding:0;border-radius:1px;cursor:nwse-resize;z-index:10}.native-element .rotate-handle{position:absolute;left:calc(50% - 10px);top:-29px;width:20px;height:20px;border:1px solid #7761e5;background:#fff;color:#7761e5;padding:0;border-radius:50%;cursor:grab;font-size:14px;z-index:10}.workbench-properties section{padding:18px 16px;border-bottom:1px solid #eeeff2}.workbench-properties h3{margin:0 0 16px;font-size:12px;font-weight:600;display:flex;justify-content:space-between;gap:10px}.workbench-properties h3 span{font-size:9px;color:#9b9eaa;font-weight:400}.workbench-properties label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#737885;font-size:11px}.workbench-properties input,.workbench-properties select,.workbench-properties textarea{min-width:0;border:1px solid transparent;border-radius:5px;background:#f5f5f8;color:#353947;padding:8px;font:inherit}.workbench-properties input:focus,.workbench-properties textarea:focus,.workbench-properties select:focus{outline:1px solid #8170de;background:#fff}.workbench-properties input[type=color]{width:45px;height:30px;padding:3px}.workbench-properties input[type=range]{width:125px;padding:0;accent-color:#7761e5}.workbench-properties textarea{width:100%;resize:vertical;line-height:1.5}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.property-grid label{margin:0;display:flex;background:#f5f5f8;border-radius:5px;padding-left:9px;white-space:nowrap;font-size:10px}.property-grid input{width:100%;padding:8px 4px}.property-actions{display:flex;gap:8px;margin-top:12px}.property-actions button{flex:1;background:#f6f5fa;border:1px solid #eeedf3}.property-hint{color:#969aa5;font-size:11px;line-height:2;margin:0}.workbench-properties fieldset{border:0;margin:0;padding:0;min-width:0}.workbench-properties fieldset:disabled{opacity:.5}.workbench-bottom{position:fixed;z-index:55;bottom:22px;left:calc(50% - 20px);transform:translateX(-50%);display:flex;align-items:center;gap:6px;padding:6px 9px;border:1px solid #e3e3eb;border-radius:12px;background:#fff;box-shadow:0 5px 20px #30364b14;font-size:11px}.workbench-bottom>span:not(.toolbar-divider){min-width:37px;text-align:center}.toolbar-divider{height:20px;width:1px;background:#e9e9ee;margin:0 4px}.workbench-bottom .active{color:#6857e8;background:#eeeaff}.decoration-solid{background:currentColor}.conflict-notice{position:fixed;z-index:80;left:50%;top:80px;transform:translateX(-50%);width:350px;max-width:90vw;background:#fff;padding:20px;border:1px solid #e4d6b3;border-radius:12px;box-shadow:0 8px 40px #0002;font-size:12px}.conflict-notice p{line-height:1.8;color:#737885}.is-editing .page-status{bottom:85px;background:#383545}.is-editing .native-image-fill,.is-editing .native-decoration{pointer-events:none}
+@media(max-width:1100px){.is-editing.native-layout-page{padding-left:0}.workbench-layers{display:none;box-shadow:8px 0 30px #0001}.workbench-layers.mobile-open{display:flex}.workbench-top .studio-mark span{display:none}.workbench-top input{width:160px}.save-indicator{font-size:10px}.workbench-bottom{left:calc((100% - 272px)/2)}}
+@media(max-width:760px){.is-editing.native-layout-page{padding-right:0}.workbench-properties{display:none;bottom:82px;box-shadow:-8px 0 30px #0001}.workbench-properties.mobile-open{display:block}.workbench-layers{bottom:82px}.workbench-top{gap:3px;padding:0 8px}.workbench-top input{width:110px;flex:1}.save-indicator{display:none}.workbench-top button{padding:7px;font-size:11px}.workbench-top .studio-mark{font-size:20px}.workbench-viewport{padding:28px 24px 100px}.workbench-bottom{left:50%}.canvas-caption{font-size:9px}}
+@media print{.workbench-top,.workbench-layers,.workbench-properties,.workbench-bottom,.canvas-caption,.conflict-notice{display:none!important}.is-editing.native-layout-page{height:auto;padding:0;overflow:visible}.workbench-viewport{display:block;padding:0;overflow:visible}.is-editing .native-layout{width:100%!important;min-width:0!important;box-shadow:none;overflow:hidden}.resize-handle,.rotate-handle{display:none}}
diff --git a/apps/studio/tests/layout-parameters.test.mjs b/apps/studio/tests/layout-parameters.test.mjs
index 1616eac..189f583 100644
--- a/apps/studio/tests/layout-parameters.test.mjs
+++ b/apps/studio/tests/layout-parameters.test.mjs
@@ -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:'',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'');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))});
diff --git a/apps/waterfall-wiki/src/lib/layout-parameters.ts b/apps/waterfall-wiki/src/lib/layout-parameters.ts
index 42ee86f..f6125b0 100644
--- a/apps/waterfall-wiki/src/lib/layout-parameters.ts
+++ b/apps/waterfall-wiki/src/lib/layout-parameters.ts
@@ -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;images:Record;decorations:Record;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;images:Record;decorations:Record;paper?:string;layers?:Record;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{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record}
function keys(v:Record,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)||vmax)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={};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={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}
}
}