Build Studio visual layout library with recoverable design CRUD
studio CI and deployment / verify-and-deploy (pull_request) Successful in 5m33s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Successful in 16m52s

This commit is contained in:
2026-10-28 14:45:37 +08:00
parent ee4d818eed
commit dba9785379
27 changed files with 477 additions and 195 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ export function LayoutCanvas({layout,params,guides=false,imageSources={}}:{layou
if(kind==='text'&&t){const lines=t.value.split('\n'),len=(v:string)=>Array.from(v).reduce((a,c)=>a+(/[\u3400-\u9fff]/.test(c)?1:.6),0),vertical=s.direction==='vertical',size=Math.min(vertical?w*.9:h/(lines.length*1.2),vertical?h/Math.max(1,Array.from(t.value).length):w/Math.max(1,...lines.map(len)))*(t.scale||1),align=t.align||'start';return <g key={s.id} data-slot={s.id} fill={t.color||'#26362f'} fontFamily="Arial,'Noto Sans CJK SC','PingFang SC',sans-serif"><defs><clipPath id={id+'clip'}><rect x={x} y={y} width={w} height={h}/></clipPath></defs><g clipPath={`url(#${id}clip)`}>{vertical?Array.from(t.value.replaceAll('\n','')).map((c,i)=><text key={i} x={x+w/2} y={y+size*(i+.85)} textAnchor="middle" fontSize={size}>{c}</text>):s.direction==='curved'?<><defs><path id={id+'path'} d={`M${x} ${y+h*.2} Q${x+w/2} ${y+h*1.7} ${x+w} ${y+h*.2}`}/></defs><text fontSize={size}><textPath href={'#'+id+'path'}>{t.value}</textPath></text></>:lines.map((l,i)=><text key={i} x={x+(align==='middle'?w/2:align==='end'?w:0)} y={y+size*(.88+i*1.2)} textAnchor={align} fontSize={size}>{l}</text>)}</g></g>}
if(kind==='decoration'&&d?.hidden)return null;
const fill=kind==='image'?im?.color:d?.color;
if(fill||im?.assetId)return <g key={s.id} data-slot={s.id}><defs><mask id={id} maskUnits="userSpaceOnUse" x={x} y={y} width={w} height={h} style={{maskType:'alpha'}}><image href={s.shape||s.raster} x={x} y={y} width={w} height={h}/></mask></defs><g mask={'url(#'+id+')'}>{fill&&<rect x={x} y={y} width={w} height={h} fill={fill}/>} {im?.assetId&&<image href={imageSources[im.assetId]||'/api/media/'+im.assetId} x={x} y={y} width={w} height={h} preserveAspectRatio={im.fit==='contain'?'xMidYMid meet':'xMidYMid slice'}/>}</g></g>;
if(fill||im?.assetId)return <g key={s.id} data-slot={s.id}><defs><mask id={id} maskUnits="userSpaceOnUse" x={x} y={y} width={w} height={h} style={{maskType:'alpha'}}><image href={s.shape||s.raster} x={x} y={y} width={w} height={h}/></mask></defs><g mask={'url(#'+id+')'}>{fill&&<rect x={x} y={y} width={w} height={h} fill={fill}/>} {im?.assetId&&<image href={imageSources[im.assetId]||'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId,width:'960'})} x={x} y={y} width={w} height={h} preserveAspectRatio={im.fit==='contain'?'xMidYMid meet':'xMidYMid slice'}/>}</g></g>;
return <image key={s.id} data-slot={s.id} href={s.raster} x={x} y={y} width={w} height={h}/>;
}))}</>}{guides&&layout.elements.map(s=>{const[x,y,w,h]=s.box,color=s.kind==='text'?'#2765da':s.kind==='image'?'#d86b27':'#08875d';return <g key={s.id} aria-hidden="true"><rect x={x} y={y} width={w} height={h} fill="none" stroke={color} strokeWidth=".35"/><text x={x} y={y+3} fontSize="3" fill={color}>{s.id}</text></g>})}</svg>;
}
@@ -1,3 +1,4 @@
import {referencePreview} from '../lib/layout-images-client';
import {useState} from 'react';
import Head from 'next/head';
import {SourceCover,selections,type CoverValues} from './SourceCover';
@@ -9,5 +10,5 @@ export function LayoutSelection(){
async function download(format:'svg'|'json'){
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
}
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={layout.referenceUrl} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
}
@@ -69,7 +69,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
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} spacing={t?.letterSpacing} style={{fontFamily:t?.fontFamily==='serif'?'Georgia,serif':t?.fontFamily==='mono'?'monospace':t?.fontFamily==='sans'?'Arial,sans-serif':undefined,fontWeight:t?.fontWeight,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>:
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/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId,width:'960'})} srcSet={[240,480,960,1280].map(width=>'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId!,width:String(width)})+' '+width+'w').join(',')} sizes={Math.min(860,(ls.width??s.box[2])/layout.width*860)+'px'} 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:ls.radius!==undefined?`${ls.radius/layout.width*100}cqw`:(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>;
+2 -1
View File
@@ -1,3 +1,4 @@
import {referencePreview} from '../lib/layout-images-client';
import {useState} from 'react';
import Head from 'next/head';
import {tutorialPost} from '../content/tool-links';
@@ -23,7 +24,7 @@ export function TemplateTrial({idea}:{idea:Idea}){
<div className="trial-actions"><button className="trial-primary" onClick={exportSvg}>下载当前 SVG</button><button onClick={()=>download(JSON.stringify({version:2,...current},null,2),'application/json',idea.slug+'.json')}>导出当前试版 JSON</button><button onClick={()=>download(ideaMarkdown(current),'text/markdown',idea.slug+'.md')}>导出说明 Markdown</button><button onClick={reset}>恢复初始试版</button></div>
<p className="trial-help">修改仅保留在当前页面,刷新会恢复;可下载 JSON 保存试版。主视觉仍是色块占位。</p><p role="status" className="trial-notice">{notice}</p>
</aside><div><div className="trial-grid">{slides.map((slide,i)=><figure className={'proof-card '+(i===active?'proof-active':'')} key={slide.key} data-proof={slide.key}><button className="proof-select" onClick={()=>setActive(i)} aria-label={'编辑'+slide.label} aria-pressed={i===active}><Ref17Cover template={template} slide={slide} masthead={masthead} guides={guides}/></button><figcaption><b>{slide.label}</b><span>共用 REF-17</span><a href={'/studio/api/export/'+idea.slug+'?format=svg&slide='+i}>初始 SVG ↗</a></figcaption></figure>)}</div>
<details className="trial-reference"><summary>查看原图与构件对应</summary><div className="trial-reference-body"><img src={template.referenceUrl} alt="ref-17 原始参考裁片"/><div><p>保留 {template.width}×{template.height} 的画板比例与九个构件位置;本试版重新绘制可编辑文字与色块,替换了文案和色调。</p><table><thead><tr><th>构件</th><th>用途</th><th>原图坐标</th></tr></thead><tbody>{template.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table><a href={template.tutorialUrl+'?agent_mode=True'}>读取教程纯文本 ↗</a></div></div></details>
<details className="trial-reference"><summary>查看原图与构件对应</summary><div className="trial-reference-body"><img src={referencePreview(template.referenceUrl)} alt="ref-17 原始参考裁片"/><div><p>保留 {template.width}×{template.height} 的画板比例与九个构件位置;本试版重新绘制可编辑文字与色块,替换了文案和色调。</p><table><thead><tr><th>构件</th><th>用途</th><th>原图坐标</th></tr></thead><tbody>{template.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table><a href={template.tutorialUrl+'?agent_mode=True'}>读取教程纯文本 ↗</a></div></div></details>
</div></div>
</main>;
}
@@ -0,0 +1,78 @@
{
"id": "standard-01",
"name": "标准排版 · 自由图文",
"width": 120,
"height": 160,
"elements": [
{
"id": "T01",
"kind": "text",
"role": "主标题",
"box": [
10,
16,
100,
28
],
"direction": "horizontal",
"text": "让每一页\n只讲一件事",
"reading": "standard"
},
{
"id": "T02",
"kind": "text",
"role": "正文",
"box": [
10,
112,
100,
27
],
"direction": "horizontal",
"text": "一个标题,一段解释,\n一个值得被记住的画面。",
"reading": "standard"
},
{
"id": "T03",
"kind": "text",
"role": "栏目",
"box": [
10,
7,
100,
4
],
"direction": "horizontal",
"text": "PROMPT STUDIO / 01",
"reading": "standard"
},
{
"id": "I01",
"kind": "image",
"role": "主题图片",
"box": [
10,
53,
100,
50
],
"direction": "horizontal",
"text": null,
"reading": "standard"
},
{
"id": "D01",
"kind": "decoration",
"role": "分隔线",
"box": [
10,
146,
100,
1
],
"direction": "horizontal",
"text": null,
"reading": "standard"
}
]
}
+1 -1
View File
@@ -1,2 +1,2 @@
// Stable Wiki post ID; the tutorial is published through /api/posts, never registered as a Wiki route.
export const tutorialPost='/explore/9ebd8f78d54b4b9db741b4e8';
export const tutorialPost='/explore/8eb8c76500580f7a4b56ba3c';
+4 -1
View File
@@ -1,6 +1,8 @@
import type {IncomingMessage} from 'node:http';
import {parameters,uuid} from './layout-parameters';
import layouts from '../content/templates/layouts.json';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
const layouts=[...fixedLayouts,standardLayout];
import type {Design} from './native-layout';
/** Only forward the session to our fixed private Wiki service, never a request Host. */
@@ -16,5 +18,6 @@ export async function readDesign(id:unknown,req:IncomingMessage):Promise<Design|
const {design}=await response.json() as {design:Design};
const layout=layouts.find(l=>l.id===design.layoutId);
if(!layout||design.id!==id)throw new Error('Invalid saved layout');
if((design as Design&{archived?:boolean}).archived)return null;
return {...design,parameters:parameters(layout,design.parameters)};
}
@@ -0,0 +1,6 @@
/** References in the retained toolbox are internal Wiki media, displayed as derivatives. */
export function referencePreview(source:string){
const path=new URL(source,'http://wiki.internal').pathname;
if(!/^\/api\/media\/[0-9a-f-]{36}$/.test(path))throw Error('Invalid reference media');
return '/api/preview?'+new URLSearchParams({source:path,width:'960'});
}
+20 -7
View File
@@ -9,8 +9,21 @@ export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}
export function nativeLayout(layout:Layout):Layout {
return {id:layout.id,name:layout.name,width:layout.width,height:layout.height,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))};
}
export function paperColor(id:string){return ['ref-04','ref-22'].includes(id)?'#a8b0ae':'#f5f5ef'}
export function textColor(id:string,slot:Slot){return (id==='ref-04'&&['T01','T02'].includes(slot.id))||(id==='ref-22'&&slot.id==='T01')||(id==='ref-27'&&slot.id==='T01')||(id==='ref-10'&&slot.id==='T03')?'#f9faf5':'#28312e'}
// Four coordinated inks per composition: paper, ink, image field and accent.
const palettes=[
['#f3eee4','#293b42','#7c9a9e','#c4d5cb'],['#f5f2e9','#344335','#879782','#d7c39f'],['#efede7','#383d4a','#8996a6','#c5d0d7'],
['#647c7a','#f5f0e5','#b6c8be','#d9ded0'],['#f5eddf','#5c3b32','#b47d68','#d9bba0'],['#eceee8','#263d43','#688a94','#b4c7c7'],
['#f3eddf','#4c4a36','#a1a185','#d8cba9'],['#edf0ef','#23424b','#73929a','#b6cbd2'],['#f5f1e9','#2f3e38','#7f9688','#d1d7ba'],
['#eeece4','#344b57','#526e80','#afc4c9'],['#f4eee8','#4e393b','#a58a90','#d8bdb3'],['#f1f0e9','#424b36','#8c997b','#d1c7aa'],
['#f3ebe5','#5b3e39','#b48e7e','#dec6ab'],['#eef0e7','#384c42','#789485','#c4d1b5'],['#e9edee','#2e4555','#839ba8','#c6d3d4'],
['#f4f0e7','#50483a','#ada18a','#d9d1c0'],['#eeeee5','#394d42','#7e9785','#c8d5bc'],['#f2ede6','#3e424e','#8a92a3','#d0c7c0'],
['#f5eede','#443d31','#a79b7a','#dbca9f'],['#eeefe8','#34463f','#809b89','#c5d3ba'],['#f0ebe5','#51413d','#a48b7f','#d5bfb3'],
['#64797d','#f8f2e8','#9fb3b5','#cbd4c9'],['#f0efe9','#2f414d','#8199a7','#c5d2d6'],['#f4eee5','#5a443b','#ae927b','#dfc8ac'],
['#ebeee8','#344b41','#829c8b','#c7d5bb'],['#edece8','#3c4354','#8b93aa','#c5cbd7'],['#f2ece2','#3b4e4b','#6f918c','#b8cdc3']
];
export function layoutPalette(id:string){return palettes[Number(id.slice(4))-1]||['#ffe5df','#303d43','#a9bec0','#f0bca6']}
export function paperColor(id:string){return layoutPalette(id)[0]}
export function textColor(id:string,slot:Slot){return (id==='ref-04'&&['T01','T02'].includes(slot.id))||(id==='ref-22'&&slot.id==='T01')||(id==='ref-27'&&slot.id==='T01')||(id==='ref-10'&&slot.id==='T03')?'#f9faf5':layoutPalette(id)[1]}
// The screenshot's illegible small print is replaced with editable editorial copy.
export function defaultText(layout:Layout,slot:Slot):string {
if(slot.text)return slot.text;
@@ -19,7 +32,7 @@ export function defaultText(layout:Layout,slot:Slot):string {
if(slot.role.includes('英文')||slot.role.includes('品牌')||slot.role.includes('标识'))return 'BULUBLUE DESIGN';
if(slot.role.includes('图注')||slot.role.includes('标签')||slot.box[3]<10)return 'FORM / SPACE / DESIGN';
if(slot.direction==='vertical')return '设计灵感 · 日常观察';
return '在留白之间,\n寻找新的秩序。\n让文字与形状相遇,\n记录日常的设计灵感。';
return slot.box[2]<25?'留白之间\n观察日常\n记录灵感':'让文字与形状相遇。\n在留白之间,寻找新的秩序。';
}
export function slotPosition(layout:Layout,slot:Slot):CSSProperties {
const [x,y,w,h]=slot.box;return {left:`${x/layout.width*100}%`,top:`${y/layout.height*100}%`,width:`${w/layout.width*100}%`,height:`${h/layout.height*100}%`};
@@ -33,15 +46,15 @@ export function textSize(layout:Layout,slot:Slot,text:string):number {
return Math.min(h/(lines.length*1.15),w/Math.max(1,...lines.map(length)))*.92;
}
export function imageShape(layout:Layout,slot:Slot):CSSProperties {
const n=Number(layout.id.slice(4)),s:CSSProperties={backgroundColor:slot.id==='I01'?'#9ba6a3':'#788785'};
const n=Number(layout.id.slice(4)),s:CSSProperties={backgroundColor:layoutPalette(layout.id)[slot.id==='I01'?2:3],backgroundImage:'linear-gradient(145deg,#ffffff12,transparent 65%)'};
if([4,5,7,13,19,20].includes(n)||(n===17&&slot.id==='I02'))s.borderRadius='50%';
if(n===4)s.transform='rotate(-9deg)';
if(n===15&&slot.id==='I01')s.borderRadius='100% 0 0 0';
if(n===22)s.clipPath=slot.id==='I01'?'polygon(0 0,100% 0,100% 48%,0 100%)':slot.id==='I02'?'polygon(0 30%,100% 0,100% 70%,0 100%)':'polygon(0 50%,100% 0,100% 100%,0 100%)';
if(n===27)s.clipPath=slot.id==='I01'?'polygon(0 0,100% 35%,100% 100%,0 68%)':'polygon(0 40%,100% 0,100% 100%,0 100%)';
if(n===10)s.backgroundColor='#596c67';
if(n===16)s.backgroundColor=slot.id==='I01'?'#b6bfba':'#7e8e87';
if(n===20&&slot.id==='I02')s.backgroundColor='#e3e6dc';
if(n===10)s.backgroundColor=layoutPalette(layout.id)[2];
if(n===16)s.backgroundColor=layoutPalette(layout.id)[slot.id==='I01'?3:2];
if(n===20&&slot.id==='I02')s.backgroundColor=layoutPalette(layout.id)[3];
return s;
}
export function decorationKind(slot:Slot){
+11 -2
View File
@@ -3,6 +3,7 @@ import {embedLayoutImages} from '../../../lib/layout-images';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import layouts from '../../../content/templates/layouts.json';
import standardLayout from '../../../content/standard-layout.json';
import {LayoutCanvas} from '../../../components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../lib/layout-parameters';
const workspaceSchema={
@@ -14,8 +15,16 @@ const workspaceSchema={
};
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(`# 扩展编辑参数\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(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 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\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(req.query.id==='standard-01'){
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}
try{const p=parameters(standardLayout,req.method==='POST'?req.body:{});
if(req.query.format!==undefined&&req.query.format!=='json')return res.status(400).json({error:'标准排版提供原生 HTML 页面,不提供 SVG 导出;format 仅支持 json'});
if(req.method==='HEAD')return res.status(200).end();
return res.json({...standardLayout,schemaVersion:1,family:'standard',parameters:p,workspaceSchema,url:'/studio/layouts/standard-01',saveApi:'/api/studio/designs'});
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
}
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,workspaceSchema,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({schemaVersion:1,family:'fixed',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'}}};
+9 -2
View File
@@ -1,3 +1,10 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import layouts from '../../../content/templates/layouts.json';
export default function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}const entries=layouts.map(l=>({id:l.id,name:l.name,url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id,tutorialUrl:l.tutorialUrl}));res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')return res.status(200).end();if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# 27 套排版 API\n\n'+entries.map(e=>`${e.id} · ${e.name}\n效果页:${e.url}\n参数和渲染:${e.api}?agent_mode=True`).join('\n\n')+'\n\n效果保存:/api/studio/designs?agent_mode=True\nRouter:/explore/8eb8c76500580f7a4b56ba3c')}return res.json({version:1,layouts:entries,saveApi:'/api/studio/designs'})}
import fixed from '../../../content/templates/layouts.json';
import standard from '../../../content/standard-layout.json';
export default function handler(req:NextApiRequest,res:NextApiResponse){
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')return res.status(200).end();
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\nCRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
return res.json({version:1,layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
}
+7 -4
View File
@@ -1,4 +1,7 @@
import type {GetServerSideProps} from 'next';
export default function StudioEntry(){return null;}
// Studio tools are entered from their Wiki posts; there is no separate catalogue homepage.
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/',permanent:false,basePath:false}});
import Head from 'next/head';
import fixed from '../content/templates/layouts.json';
import standard from '../content/standard-layout.json';
import type {Layout} from '../lib/layout-parameters';
import {nativeLayout,paperColor,textColor,defaultText,textSize,slotPosition,imageShape} from '../lib/native-layout';
function Preview({layout}:{layout:Layout}){return <div className={'native-layout '+layout.id} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:paperColor(layout.id)}} aria-label={layout.name}>{layout.elements.map(s=><div key={s.id} className={'native-element native-'+s.kind} style={slotPosition(layout,s)}>{s.kind==='text'?<div className={'native-copy direction-'+s.direction} style={{fontSize:textSize(layout,s,defaultText(layout,s))/layout.width*100+'cqw',color:textColor(layout.id,s)}}>{defaultText(layout,s)}</div>:s.kind==='image'?<div className="native-image-fill" style={imageShape(layout,s)}/>:<div style={{borderTop:'1px solid',color:textColor(layout.id,s)}}/>}</div>)}</div>}
export default function StudioEntry(){return <main className="studio-library"><Head><title>Studio · 可视化排版 API 库</title></Head><nav className="library-nav"><a href="/">PROMPT / STUDIO</a><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档与工具箱</a><a href="/api/studio/designs?agent_mode=True">API 契约</a></nav><h1>用参数,编排画面。</h1><p className="library-intro">给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。</p><div className="library-heading"><h2>标准排版</h2><a href="/studio/api/layouts/standard-01">参数</a></div><div className="library-standard"><a href="/studio/layouts/standard-01"><Preview layout={standard}/></a><div><h2>一页,一件事。</h2><p>沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。</p><code>layoutId: standard-01</code><p><a href="/studio/layouts/standard-01">打开原生页面</a></p></div></div><div className="library-heading"><h2>固定排版 · 27 套</h2><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档</a></div><div className="library-grid">{fixed.map(l=><article className="library-item" key={l.id}><a href={'/studio/layouts/'+l.id}><Preview layout={nativeLayout(l)}/><h3>{l.name}</h3><p>{l.id.toUpperCase()}</p></a></article>)}</div></main>}
+3 -1
View File
@@ -1,6 +1,8 @@
import type {GetServerSideProps} from 'next';
import {LayoutWorkbench,type LayoutPageProps} from '../../components/LayoutWorkbench';
import layouts from '../../content/templates/layouts.json';
import fixedLayouts from '../../content/templates/layouts.json';
import standardLayout from '../../content/standard-layout.json';
const layouts=[...fixedLayouts,standardLayout];
import {nativeLayout} from '../../lib/native-layout';
import {readDesign} from '../../lib/design-server';
export default function LayoutPage(props:LayoutPageProps){return <LayoutWorkbench key={props.design?.id||props.layout.id} {...props}/>}
+12
View File
@@ -69,3 +69,15 @@
@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}}
.workbench-layers,.workbench-properties{padding:0}.workbench-top{flex-wrap:nowrap}.workbench-top a:first-child{margin-right:0}
/* Composition-specific type: preserve all 27 source geometries. */
.native-layout{font-family:"Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif}
.native-copy{font-weight:450;letter-spacing:-.025em;line-height:1.2}
.native-text[data-element=T01] .native-copy{font-weight:650;letter-spacing:-.055em}
.ref-02 .direction-vertical,.ref-05 .direction-vertical,.ref-06 .direction-vertical,.ref-09 .direction-vertical,.ref-12 .direction-vertical,.ref-19 .direction-vertical,.ref-22 .direction-vertical,.ref-25 .direction-vertical,.ref-27 .direction-vertical{font-family:"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.06em;font-weight:500}
.ref-03 .native-copy,.ref-11 .native-copy,.ref-13 .native-copy,.ref-14 .native-copy{letter-spacing:.025em}
.ref-04 .native-copy,.ref-20 .native-copy{font-weight:350;letter-spacing:.08em}
.ref-16 .native-copy,.ref-23 .native-copy{font-weight:600;letter-spacing:.035em}
.ref-21 .direction-rotated,.ref-24 .direction-angled,.ref-26 .native-text[data-element=T01] .native-copy,.ref-26 .native-text[data-element=T02] .native-copy,.ref-26 .native-text[data-element=T03] .native-copy{font-family:Georgia,"Songti SC",serif;font-weight:400;letter-spacing:-.055em}
.studio-library{max-width:1440px;margin:auto;padding:48px 5vw 80px}.library-nav{display:flex;justify-content:space-between;font-size:13px}.studio-library h1{font-size:clamp(32px,5vw,64px);letter-spacing:-.05em;margin:64px 0 16px}.library-intro{max-width:640px;line-height:1.8;color:#69716e}.library-heading{margin:64px 0 24px;display:flex;align-items:baseline;gap:18px}.library-heading h2{margin:0;font-size:24px}.library-heading a{font-size:13px;text-decoration:underline}.library-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:36px 24px}.library-item{min-width:0}.library-item .native-layout{width:100%;pointer-events:none}.library-item h3{font-size:13px;font-weight:500;margin:14px 0 5px}.library-item p{font-size:11px;color:#7b837e;margin:0}.library-item>a:focus-visible{outline:2px solid #48786a;outline-offset:5px}.library-standard{display:grid;grid-template-columns:minmax(200px,320px) 1fr;gap:40px;align-items:center}.library-standard p{max-width:480px;line-height:1.9;color:#69716e}.library-standard code{font-size:13px}.library-standard a{text-decoration:underline}.library-item:hover .native-layout{filter:brightness(1.025)}
@media(max-width:950px){.library-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:650px){.library-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:26px 16px}.library-standard{grid-template-columns:1fr}.library-standard .native-layout{max-width:280px}.studio-library{padding:28px 20px 60px}.library-nav{gap:12px}}