Build Studio visual layout library with recoverable design CRUD
This commit is contained in:
@@ -5,8 +5,8 @@ let reads=0;
|
||||
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,27);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const library=await fetch(base+'/studio').then(r=>r.text());assert.ok(library.includes('standard-01'));assert.ok(library.includes('ref-27'));const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');
|
||||
const headers={cookie:'session=smoke-test'};
|
||||
const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
@@ -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){
|
||||
|
||||
@@ -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'}}};
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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}/>}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
ALTER TABLE studio_designs DROP CONSTRAINT studio_designs_layout_id_check;
|
||||
ALTER TABLE studio_designs ADD CONSTRAINT studio_designs_layout_id_check CHECK (layout_id='standard-01' OR layout_id ~ '^ref-(0[1-9]|1[0-9]|2[0-7])$');
|
||||
ALTER TABLE studio_designs ADD COLUMN archived boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE studio_designs ADD COLUMN archived_at timestamptz;
|
||||
INSERT INTO schema_migrations(version) VALUES('012_studio_library');
|
||||
COMMIT;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ import {randomUUID} from 'node:crypto';
|
||||
import type {PoolClient} from 'pg';
|
||||
import type {Parameters} from './layout-parameters';
|
||||
|
||||
export type DesignRow={id:string;layout_id:string;name:string;parameters:Parameters;revision:number;post_id:string|null;created_at:Date;updated_at:Date};
|
||||
export type DesignRow={id:string;layout_id:string;name:string;parameters:Parameters;revision:number;post_id:string|null;archived?:boolean;created_at:Date;updated_at:Date};
|
||||
export const studioPageUrl=(design:Pick<DesignRow,'id'|'layout_id'>)=>`/studio/layouts/${design.layout_id}?id=${design.id}`;
|
||||
function postDescription(design:DesignRow){return `已保存的 ${design.layout_id} 排版页面,可直接打开并继续编辑。`}
|
||||
/** Caller holds the design row lock. All writes share the design's transaction. */
|
||||
|
||||
@@ -3,12 +3,14 @@ import {z} from 'zod';
|
||||
import type {PoolClient} from 'pg';
|
||||
import {db,transaction} from './db';
|
||||
import {parameters,LayoutParameterError} from './layout-parameters';
|
||||
import layouts from '../content/studio-layouts.json';
|
||||
import fixedLayouts from '../content/studio-layouts.json';
|
||||
import standardLayout from '../content/standard-layout.json';
|
||||
const layouts=[...fixedLayouts,standardLayout];
|
||||
import {ensureDesignPost,studioPageUrl,type DesignRow} from './studio-design-posts';
|
||||
export const designInput=z.object({layoutId:z.string(),name:z.string().trim().min(1).max(120),parameters:z.unknown()}).strict();
|
||||
export const designInput=z.object({schemaVersion:z.literal(1).optional(),layoutId:z.string(),name:z.string().trim().min(1).max(120),parameters:z.unknown()}).strict();
|
||||
export const updateDesignInput=designInput.omit({layoutId:true}).extend({revision:z.number().int().positive()}).strict();
|
||||
export class DesignConflict extends Error{}
|
||||
export function designOutput(r:Record<string,unknown>){return {id:r.id,layoutId:r.layout_id,name:r.name,parameters:r.parameters,revision:r.revision,createdAt:r.created_at,updatedAt:r.updated_at,url:studioPageUrl(r as DesignRow),schemaUrl:'/studio/api/layouts/'+r.layout_id,postId:r.post_id||null,postUrl:r.post_id?'/explore/'+r.post_id:null,renderUrl:'/studio/api/layouts/'+r.layout_id,legacySvgUrl:'/studio/api/layouts/'+r.layout_id+'?format=svg'};}
|
||||
export function designOutput(r:Record<string,unknown>){return {id:r.id,layoutId:r.layout_id,name:r.name,parameters:r.parameters,revision:r.revision,schemaVersion:1,family:r.layout_id==='standard-01'?'standard':'fixed',archived:r.archived||false,archivedAt:r.archived_at||null,createdAt:r.created_at,updatedAt:r.updated_at,url:studioPageUrl(r as DesignRow),schemaUrl:'/studio/api/layouts/'+r.layout_id,postId:r.post_id||null,postUrl:r.post_id?'/explore/'+r.post_id:null,renderUrl:studioPageUrl(r as DesignRow),legacySvgUrl:r.layout_id==='standard-01'?null:'/studio/api/layouts/'+r.layout_id+'?format=svg'};}
|
||||
export async function validateDesign(layoutId:string,raw:unknown,client?:PoolClient){const layout=layouts.find(l=>l.id===layoutId);if(!layout)throw new LayoutParameterError('排版不存在');const p=parameters(layout,raw),images=[...new Set(Object.values(p.images).flatMap(i=>i.assetId?[i.assetId]:[]))];if(images.length){const found=await (client||db()).query("SELECT id FROM assets WHERE kind='image' AND id=ANY($1::uuid[])",[images]);if(found.rowCount!==images.length)throw new LayoutParameterError('引用的图片素材不存在')}return p;}
|
||||
export async function createDesign(input:z.infer<typeof designInput>){
|
||||
const p=await validateDesign(input.layoutId,input.parameters);
|
||||
@@ -21,6 +23,7 @@ export async function updateDesign(id:string,input:z.infer<typeof updateDesignIn
|
||||
return transaction(async client=>{
|
||||
const old=(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0] as DesignRow|undefined;
|
||||
if(!old)return null;
|
||||
if(old.archived)throw new DesignConflict('页面已归档,请先恢复');
|
||||
if(old.revision!==input.revision)throw new DesignConflict('效果已被修改,请重新读取后再保存');
|
||||
const p=await validateDesign(old.layout_id,input.parameters,client);
|
||||
const result=await client.query('UPDATE studio_designs SET name=$2,parameters=$3,revision=revision+1,updated_at=now() WHERE id=$1 RETURNING *',[id,input.name,JSON.stringify(p)]);
|
||||
@@ -36,3 +39,15 @@ export async function publishDesignPost(id:string){
|
||||
return {design:designOutput(await ensureDesignPost(client,row)),created};
|
||||
});
|
||||
}
|
||||
|
||||
export const archiveDesignInput=z.object({revision:z.number().int().positive(),archived:z.boolean().optional()}).strict();
|
||||
export async function archiveDesign(id:string,revision:number,archived:boolean){
|
||||
return transaction(async client=>{
|
||||
const old=(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0];
|
||||
if(!old)return null;
|
||||
if(old.revision!==revision)throw new DesignConflict('修订冲突,请重新读取');
|
||||
const result=await client.query(`UPDATE studio_designs SET archived=$2,archived_at=CASE WHEN $2 THEN COALESCE(archived_at,now()) ELSE NULL END,revision=revision+1,updated_at=now() WHERE id=$1 RETURNING *`,[id,archived]);
|
||||
if(old.post_id)await client.query('UPDATE posts SET archived=$2,archived_at=CASE WHEN $2 THEN COALESCE(archived_at,now()) ELSE NULL END,updated_at=now() WHERE id=$1',[old.post_id,archived]);
|
||||
return designOutput(result.rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ 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=[...Object.entries(apiDocuments).map(([path,doc])=>({path,title:doc[0],methods:doc[1],documentation:path+'?agent_mode=True'})),
|
||||
{path:'/studio/api/export/{slug}',title:'封面渲染与方案导出',methods:'GET, POST',documentation:'/studio/api/export/{slug}?agent_mode=True'},
|
||||
{path:'/studio/api/layouts',title:'27 套排版目录与效果页',methods:'GET',documentation:'/studio/api/layouts?agent_mode=True'},
|
||||
{path:'/studio/api/layouts',title:'标准与27套固定排版目录',methods:'GET',documentation:'/studio/api/layouts?agent_mode=True'},
|
||||
{path:'/studio/api/layouts/{id}',title:'排版参数与 SVG 渲染',methods:'GET, POST',documentation:'/studio/api/layouts/{id}?agent_mode=True'},
|
||||
{path:'/api/studio/designs',title:'保存与列举 Studio 效果',methods:'GET, POST',documentation:'/api/studio/designs?agent_mode=True'},
|
||||
{path:'/api/studio/designs/{id}',title:'读取与更新效果 ID',methods:'GET, PATCH',documentation:'/api/studio/designs/{id}?agent_mode=True'},
|
||||
{path:'/api/studio/designs/{id}',title:'读取、更新与可恢复删除页面',methods:'GET, PATCH, DELETE, POST',documentation:'/api/studio/designs/{id}?agent_mode=True'},
|
||||
{path:'/api/studio/designs/{id}/post',title:'为旧页面补建 Wiki 入口',methods:'POST',documentation:'/api/studio/designs/{id}/post?agent_mode=True'},
|
||||
{path:'/studio/api/health',title:'创作站健康检查',methods:'GET, HEAD',documentation:'/studio/api/health?agent_mode=True'}];
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
|
||||
@@ -2,16 +2,18 @@ import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {z} from 'zod';
|
||||
import {db} from '@/lib/db';
|
||||
import {sameOrigin,apiError} from '@/lib/api';
|
||||
import {designOutput,updateDesignInput,updateDesign,DesignConflict} from '@/lib/studio-designs';
|
||||
import {designOutput,updateDesignInput,updateDesign,DesignConflict,archiveDesign,archiveDesignInput} from '@/lib/studio-designs';
|
||||
import {LayoutParameterError} from '@/lib/layout-parameters';
|
||||
import {studioDesignDocumentation} from '@/lib/studio-design-documentation';
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store');
|
||||
try{
|
||||
if(!['GET','PATCH'].includes(req.method||'')){res.setHeader('Allow','GET, PATCH');return res.status(405).end()}
|
||||
if(!['GET','PATCH','DELETE','POST'].includes(req.method||'')){res.setHeader('Allow','GET, PATCH, DELETE, POST');return res.status(405).end()}
|
||||
const id=z.uuid().parse(req.query.id);
|
||||
if(req.method==='GET'){const row=(await db().query('SELECT * FROM studio_designs WHERE id=$1',[id])).rows[0];return row?res.json({design:designOutput(row)}):res.status(404).json({error:'效果不存在'})}
|
||||
sameOrigin(req);const design=await updateDesign(id,updateDesignInput.parse(req.body));
|
||||
sameOrigin(req);
|
||||
if(req.method==='DELETE'||req.method==='POST'){const input=(req.method==='DELETE'?archiveDesignInput.omit({archived:true}):archiveDesignInput).parse(req.body);if(req.method==='POST'&&(!('archived' in input)||input.archived!==false))return res.status(400).json({error:'恢复需 archived:false'});const design=await archiveDesign(id,input.revision,req.method==='DELETE');return design?res.json({design}):res.status(404).json({error:'效果不存在'})}
|
||||
const design=await updateDesign(id,updateDesignInput.parse(req.body));
|
||||
return design?res.json({design}):res.status(404).json({error:'效果不存在'});
|
||||
}catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});if(e instanceof DesignConflict)return res.status(409).json({error:e.message});return apiError(res,e)}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {studioDesignDocumentation} from '@/lib/studio-design-documentation';
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store');
|
||||
try{if(req.method==='POST'){sameOrigin(req);return res.status(201).json({design:await createDesign(designInput.parse(req.body))})}
|
||||
if(req.method==='GET'){const query=z.object({layoutId:z.string().regex(/^ref-(0[1-9]|1[0-9]|2[0-7])$/).optional(),limit:z.coerce.number().int().min(1).max(50).default(20),before:z.uuid().optional()}).parse(req.query);const r=await db().query('SELECT * FROM studio_designs WHERE ($1::text IS NULL OR layout_id=$1) AND ($2::uuid IS NULL OR (created_at,id)<(SELECT created_at,id FROM studio_designs WHERE id=$2)) ORDER BY created_at DESC,id DESC LIMIT $3',[query.layoutId||null,query.before||null,query.limit+1]);return res.json({designs:r.rows.slice(0,query.limit).map(designOutput),nextCursor:r.rows.length>query.limit?r.rows[query.limit-1].id:null})}
|
||||
if(req.method==='GET'){const query=z.object({layoutId:z.string().regex(/^(standard-01|ref-(0[1-9]|1[0-9]|2[0-7]))$/).optional(),archived:z.enum(['true','false']).default('false'),limit:z.coerce.number().int().min(1).max(50).default(20),before:z.uuid().optional()}).parse(req.query);const r=await db().query('SELECT * FROM studio_designs WHERE archived=$4 AND ($1::text IS NULL OR layout_id=$1) AND ($2::uuid IS NULL OR (created_at,id)<(SELECT created_at,id FROM studio_designs WHERE id=$2)) ORDER BY created_at DESC,id DESC LIMIT $3',[query.layoutId||null,query.before||null,query.limit+1,query.archived==='true']);return res.json({designs:r.rows.slice(0,query.limit).map(designOutput),nextCursor:r.rows.length>query.limit?r.rows[query.limit-1].id:null})}
|
||||
res.setHeader('Allow','GET, POST');return res.status(405).end();
|
||||
}catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});return apiError(res,e)}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,24 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
assert.deepEqual(repaired.map(r=>r.status).sort(),[200,201]);const repairs=await Promise.all(repaired.map(r=>r.json()));assert.equal(repairs[0].design.postId,repairs[1].design.postId);assert.equal(repairs[0].design.revision,1);
|
||||
assert.equal((await db().query("SELECT count(*)::integer AS n FROM posts WHERE slug=$1",['studio-design-'+legacyId])).rows[0].n,1);
|
||||
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID()+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).status,404);
|
||||
// Both families share content validation and revision-guarded, recoverable CRUD.
|
||||
for(const layoutId of ['standard-01','ref-07']){
|
||||
const created=(await request('/api/studio/designs',{schemaVersion:1,layoutId,name:'CRUD '+layoutId,parameters:{texts:{T01:{value:'API 标题'}},images:{I01:{assetId:first.assets[0],fit:'cover',cropX:25,zoom:1.5}},added:[{id:'user-caption',kind:'text',role:'补充',box:[1,2,30,10],direction:'horizontal',text:'自定义',reading:'custom'}]}})).design;
|
||||
assert.equal(created.family,layoutId==='standard-01'?'standard':'fixed');assert.equal(created.schemaVersion,1);
|
||||
const call=(method:string,body:unknown)=>fetch(base+'/api/studio/designs/'+created.id,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
assert.equal((await call('PATCH',{name:'新标题',parameters:created.parameters,revision:1})).status,200);
|
||||
assert.equal((await call('DELETE',{revision:1})).status,409);
|
||||
const deleted=await call('DELETE',{revision:2});assert.equal(deleted.status,200);assert.equal((await deleted.json()).design.archived,true);
|
||||
const read=(await request('/api/studio/designs/'+created.id)).design;assert.equal(read.revision,3);assert.deepEqual(read.parameters,created.parameters);
|
||||
assert.equal((await request('/api/notes/'+created.postId)).entry.archived,true);
|
||||
assert.ok(!(await request('/api/studio/designs?layoutId='+layoutId)).designs.some((d:{id:string})=>d.id===created.id));
|
||||
assert.ok((await request('/api/studio/designs?archived=true&layoutId='+layoutId)).designs.some((d:{id:string})=>d.id===created.id));
|
||||
assert.equal((await call('PATCH',{name:'禁止修改归档',parameters:{},revision:3})).status,409);
|
||||
assert.equal((await call('POST',{revision:3,archived:false})).status,200);
|
||||
assert.equal((await request('/api/notes/'+created.postId)).entry.archived,false);
|
||||
assert.equal((await request('/api/studio/designs/'+created.id)).design.revision,4);
|
||||
}
|
||||
assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({schemaVersion:2,layoutId:'standard-01',name:'未来版本',parameters:{}})})).status,400);
|
||||
// A post failure must roll back the design and text assets as one unit.
|
||||
const beforeFailure=(await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0];
|
||||
await db().query(`CREATE FUNCTION reject_studio_post_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.title='atomic-studio-post-test' THEN RAISE EXCEPTION 'test publication failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_studio_post_test BEFORE INSERT ON posts FOR EACH ROW EXECUTE FUNCTION reject_studio_post_test()`);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Studio 固定构图视觉审查
|
||||
|
||||
2026-10-28:先搜索互联网设计 skills,再检查源文件。采用 [Anthropic frontend-design 原始 SKILL.md](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md) 中与本任务适用的字体层级、基于内容选择配色、保留构图个性及逐版审视方法;不采用其自由重构产品布局的方向,因为本任务要求保留27套构图及现有工具箱。只阅读说明,没有安装依赖或执行第三方代码。
|
||||
|
||||
搜索结果中的第三方技能聚合页只用于发现,不作为实现依据。另检查了 Impeccable 候选源路径,但该路径返回404,未读取到可信内容,因此不采用,也不声称安装或使用了该技能。
|
||||
|
||||
实现把每套构图的纸色、文字、主图位、副图位组织成独立四色色谱。蓝灰用于横向英文刊头,暖赭用于出血椭圆与斜题,苔绿用于竖题与同心圆;没有把构图改为统一卡片。竖向中文使用宋体栈,英文大刊头保留衬线,模块式构图加强字重与字距,小字替换成适合槽位的简短可编辑内容。原始画板、元素ID、裁切轮廓与主要坐标保持不变。
|
||||
|
||||
可视化目录用原生文字和几何展示构图,页面本身继续由现有 LayoutWorkbench 原生渲染。颜色、字体和几何显式参数优先于模板默认值;模板默认外观会跟随版本美化,要求固定外观的调用者应传入显式参数。
|
||||
|
||||
验收应在部署完成后仅访问指定服务器,检查27套页面、移动宽度、保存ID恢复、字体/几何扩展、图片网络请求,以及旧工具功能。原生页面不能以旧SVG导出代替视觉验收。
|
||||
+145
-127
@@ -1,131 +1,149 @@
|
||||
# 27 套排版 · Studio 参数 API 与保存教程
|
||||
# Studio 排版 API · 标准图文与 27 套固定构图
|
||||
|
||||
把这篇 post 交给 agent,即可选排版、预览、调整参数并保存可再次打开的效果。每套 ref-01…ref-27 都有独立页面,与原图的 T(文字)、I(图位)、D(装饰)构件对应。
|
||||
把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。
|
||||
|
||||
## 先看效果,再通过 API 调整
|
||||
[打开可视化 API 库](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)
|
||||
|
||||
## 两类排版,同一内容模型
|
||||
|
||||
- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。
|
||||
- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。
|
||||
|
||||
两类都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。用户可按需进入现有编辑工作区,工具箱功能保持可用。
|
||||
|
||||
## 1. 发现能力并选择模板
|
||||
|
||||
以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。
|
||||
|
||||
```sh
|
||||
BASE=http://8.211.154.134:18131
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/studio/api/layouts"
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/studio/api/layouts/standard-01"
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/studio/api/layouts/ref-17"
|
||||
```
|
||||
|
||||
目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。
|
||||
|
||||
## 2. 生成图片,上传一次,显示压缩衍生图
|
||||
|
||||
先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:
|
||||
|
||||
```sh
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" \
|
||||
-F kind=image -F assetOnly=true -F files=@hero.png "$BASE/api/upload"
|
||||
```
|
||||
|
||||
返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。
|
||||
|
||||
原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:
|
||||
|
||||
```sh
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" \
|
||||
"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960" -o hero-preview.webp
|
||||
```
|
||||
|
||||
width 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。
|
||||
|
||||
## 3. 创建:POST /api/studio/designs
|
||||
|
||||
创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,当前仅支持 1。创建额外传 `layoutId`,不传 revision 或 id。
|
||||
|
||||
```sh
|
||||
curl -fsS -X POST "$BASE/api/studio/designs" \
|
||||
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"schemaVersion":1,"layoutId":"standard-01","name":"观察日常","parameters":{"paper":"#edf0ef","texts":{"T01":{"value":"观察日常\n记录灵感","fontFamily":"serif","color":"#23424b"},"T02":{"value":"让每一页只讲一件事。","lineHeight":1.4}},"images":{"I01":{"color":"#73929a","fit":"cover","cropX":50,"cropY":45,"zoom":1}},"decorations":{"D01":{"color":"#73929a"}}}}'
|
||||
```
|
||||
|
||||
有图片时在 I01 加 `assetId:"上传返回的UUID"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。
|
||||
|
||||
201 返回 `{design}`。design 包含:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |
|
||||
| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |
|
||||
| name / parameters | 名称与规范化内容;省略的分组返回空对象 |
|
||||
| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |
|
||||
| schemaUrl | 当前模板参数地址 |
|
||||
| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |
|
||||
| archived / archivedAt | 可恢复归档状态与时间 |
|
||||
| createdAt / updatedAt | 创建与最后修改时间 |
|
||||
| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |
|
||||
|
||||
创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。
|
||||
|
||||
## 4. 读取与打开
|
||||
|
||||
```sh
|
||||
ID=创建返回的UUID
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/designs/$ID"
|
||||
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/designs?layoutId=standard-01&limit=20"
|
||||
```
|
||||
|
||||
读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。
|
||||
|
||||
直接打开返回的 url:`/studio/layouts/standard-01?id={UUID}` 或 `/studio/layouts/ref-17?id={UUID}`,默认看到原生页面,点击“编辑页面”才进入工作区。`/studio/designs/{UUID}` 是兼容跳转。ID 与模板不匹配返回404。归档设计仍能经 API 读取以恢复,渲染页返回404。
|
||||
|
||||
## 5. 更新:PATCH /api/studio/designs/{id}
|
||||
|
||||
先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:
|
||||
|
||||
```sh
|
||||
curl -fsS -X PATCH "$BASE/api/studio/designs/$ID" \
|
||||
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"观察日常 · 修订","revision":1,"parameters":{"texts":{"T01":{"value":"新的标题"}},"images":{},"decorations":{}}}'
|
||||
```
|
||||
|
||||
200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。
|
||||
|
||||
## 6. 删除与恢复
|
||||
|
||||
DELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。
|
||||
|
||||
```sh
|
||||
curl -fsS -X DELETE "$BASE/api/studio/designs/$ID" \
|
||||
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' -d '{"revision":2}'
|
||||
# 删除后读取新 revision,再恢复。此例删除后为3:
|
||||
curl -fsS -X POST "$BASE/api/studio/designs/$ID" \
|
||||
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' -d '{"revision":3,"archived":false}'
|
||||
```
|
||||
|
||||
两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。
|
||||
|
||||
## 参数清单(两类共用)
|
||||
|
||||
parameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。
|
||||
|
||||
| 分组 | 字段与约束 |
|
||||
| --- | --- |
|
||||
| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |
|
||||
| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |
|
||||
| decorations.{装饰ID} | color;hidden 布尔值 |
|
||||
| paper | 纸张颜色 |
|
||||
| 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 布尔值 |
|
||||
| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |
|
||||
|
||||
新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。
|
||||
|
||||
错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。
|
||||
|
||||
## 现有工具箱与兼容导出
|
||||
|
||||
工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。
|
||||
|
||||
旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。
|
||||
|
||||
```sh
|
||||
curl -fsS -X POST "$BASE/studio/api/export/learning-skills-template?format=svg" \
|
||||
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"slide":1,"heading":"笔记复习","color":"#7a8b7e"}' -o cover.svg
|
||||
curl -fsS -X POST "$BASE/studio/api/export/layout-selection?format=svg" \
|
||||
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"layoutId":"ref-01","fields":{"T01":"STUDY"},"color":"#83918a"}' -o selection.svg
|
||||
```
|
||||
|
||||
固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,或用浏览器打印。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。
|
||||
|
||||
## 固定排版索引
|
||||
|
||||
{{LAYOUTS}}
|
||||
|
||||
以上页面都可以修改文字、图位颜色或图片、装饰颜色和显隐、纸色,也能输入完整参数 JSON。打开“显示 T/I/D 构件边界”核对位置;“原图与构件对应表”可查看原图及坐标。页面下方可找到已保存的此排版效果。
|
||||
|
||||
## Agent 调用顺序
|
||||
|
||||
1. 读取 [全部 27 套排版目录与接口](/studio/api/layouts?agent_mode=True),选择 ID。
|
||||
2. GET `/studio/api/layouts/ref-01` 获取当前模板的真实构件 ID、坐标、方向与可辨识文字。也可以先读 [渲染接口说明](/studio/api/layouts/ref-01?agent_mode=True)。不要把其他模板的 T/I/D ID 混用。
|
||||
3. POST 同地址传入参数,得到 SVG。渲染不会自动保存;迭代参数直到效果合适。
|
||||
4. POST `/api/studio/designs` 保存新效果,返回 `design.id`、`design.url` 与 `design.revision`。通过 `design.url` 可直接看效果。
|
||||
5. GET `/api/studio/designs/{id}` 读取上次参数;PATCH 同地址携带 revision 更新同一 ID。要保留两个不同版本,使用 POST 保存两个新 ID。
|
||||
|
||||
所有 API 与 Wiki 共用现有登录 Cookie 或 Bearer JWT。浏览器修改请求必须同源。Studio 本身不配置数据库连接,保存由 Wiki 写入同一个 PostgreSQL。
|
||||
|
||||
## 参数示例:ref-01
|
||||
|
||||
```json
|
||||
{
|
||||
"texts": {
|
||||
"T01": {"value": "STUDY", "color": "#26362f", "scale": 1, "align": "start"},
|
||||
"T02": {"value": "知识合集\n从记录到创作"}
|
||||
},
|
||||
"images": {
|
||||
"I01": {"color": "#7f9188"},
|
||||
"I02": {"color": "#b7c4bb"}
|
||||
},
|
||||
"decorations": {"D01": {"color": "#53645b", "hidden": false}},
|
||||
"paper": "#f4f2eb"
|
||||
}
|
||||
```
|
||||
|
||||
文字最多 500 字、8 行,scale 范围 0.25–2,align 为 start/middle/end。文字 value 为空则移除这一组;删除其参数键则恢复原图字形。图片可以只改色,也可先通过 `/api/upload` 的 `assetOnly=true` 上传,再传入 `assetId` UUID 与 `fit: cover/contain`;不接受任意图片网址。装饰支持颜色与 hidden。参数在画板内裁切,未知字段、错误构件 ID、非法颜色和超限输入返回 400。请求最大 64KB。
|
||||
|
||||
原始参考来自低分辨率拼图。默认效果保留原图;未替换的构件继续使用原始分层,图位使用原始形状蒙版。可替换文字生成 SVG 文字;无法辨识的小字标为 unreadable,保留原图层而不编造文字。纸色作用于原有底层,不替代图位色块。页面预览通过同站媒体读取图片;API 导出时会使用当前登录读取 Wiki 有尺寸上限的 WebP 预览,并内嵌到 SVG,使下载结果不依赖外部图片链接。原始大图仍保留在 Wiki,导出使用最长 960 像素的预览。
|
||||
|
||||
## HTML / JavaScript:渲染与保存
|
||||
|
||||
```html
|
||||
<div id="preview"></div>
|
||||
<script type="module">
|
||||
const layoutId = 'ref-01';
|
||||
const parameters = {texts:{T01:{value:'STUDY'}},images:{I01:{color:'#7f9188'}}};
|
||||
const response = await fetch(`/studio/api/layouts/${layoutId}`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(parameters)
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const previewUrl = URL.createObjectURL(await response.blob());
|
||||
const image = new Image(); image.src = previewUrl;
|
||||
document.querySelector('#preview').replaceChildren(image);
|
||||
// 预览完成后可 URL.revokeObjectURL(previewUrl)。
|
||||
const saved = await fetch('/api/studio/designs', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({layoutId,name:'知识合集第一版',parameters})
|
||||
});
|
||||
if (!saved.ok) throw new Error(await saved.text());
|
||||
const {design} = await saved.json();
|
||||
console.log(design.id, design.url, design.revision);
|
||||
</script>
|
||||
```
|
||||
|
||||
## React:参数变化重新预览
|
||||
|
||||
```tsx
|
||||
import {useEffect,useState} from 'react';
|
||||
export function Preview(){
|
||||
const [title,setTitle]=useState('STUDY'),[src,setSrc]=useState(''),[error,setError]=useState('');
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();let url='';
|
||||
fetch('/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({texts:{T01:{value:title}}}),signal:controller.signal})
|
||||
.then(async r=>{if(!r.ok)throw Error(await r.text());return r.blob()})
|
||||
.then(blob=>{if(!controller.signal.aborted){url=URL.createObjectURL(blob);setSrc(url);setError('')}})
|
||||
.catch(e=>{if(!controller.signal.aborted)setError(e.message)});
|
||||
return()=>{controller.abort();if(url)URL.revokeObjectURL(url)};
|
||||
},[title]);
|
||||
return <><input value={title} onChange={e=>setTitle(e.target.value)}/>{error&&<p>{error}</p>}{src&&<img src={src} alt="当前排版效果"/>}</>;
|
||||
}
|
||||
```
|
||||
|
||||
## Next.js:客户端编辑组件
|
||||
|
||||
```tsx
|
||||
// app/layout-playground/page.tsx
|
||||
'use client';
|
||||
import {useState} from 'react';
|
||||
export default function Playground(){
|
||||
const [title,setTitle]=useState('STUDY'),[link,setLink]=useState(''),[status,setStatus]=useState('');
|
||||
async function save(){
|
||||
const response=await fetch('/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({layoutId:'ref-01',name:title||'排版效果',parameters:{texts:{T01:{value:title}}}})});
|
||||
const data=await response.json();if(!response.ok){setStatus(data.error);return}setLink(data.design.url);setStatus('已保存');
|
||||
}
|
||||
return <><input value={title} onChange={e=>setTitle(e.target.value)}/><button onClick={save}>保存新效果</button><p>{status}</p>{link&&<a href={link}>打开效果</a>}</>;
|
||||
}
|
||||
```
|
||||
|
||||
这些前端示例应放在已登录的 PROMPT 同站环境。服务端 agent 使用既有 Bearer JWT,只在进程内读取凭据,勿把令牌写入 post、源码、日志或 URL。接口错误不可当作渲染结果处理。
|
||||
|
||||
## 按 ID 读取、修改、保留分支
|
||||
|
||||
```js
|
||||
const headers = {'Content-Type':'application/json'};
|
||||
const read = await fetch(`/api/studio/designs/${id}`);
|
||||
if (!read.ok) throw Error(await read.text());
|
||||
const {design} = await read.json();
|
||||
const parameters = {...design.parameters,texts:{...design.parameters.texts,T01:{value:'第二版'}}};
|
||||
const update = await fetch(`/api/studio/designs/${id}`, {
|
||||
method:'PATCH',headers,
|
||||
body:JSON.stringify({name:'知识合集第二版',parameters,revision:design.revision})
|
||||
});
|
||||
if (update.status===409) throw Error('有人已经修改,请重新读取后合并');
|
||||
if (!update.ok) throw Error(await update.text());
|
||||
// 想保留旧版时,改用 POST /api/studio/designs,传 {layoutId:design.layoutId,name,parameters}。
|
||||
```
|
||||
|
||||
PATCH 的 parameters 整体替换,因此先 GET 并合并未修改的构件;layoutId 不可改变。错误:400 参数不合法、401 未登录、404 不存在、409 修订冲突、413 超限。[效果保存接口说明](/api/studio/designs?agent_mode=True)。列表可用 `layoutId` 筛选、`limit=1..50`;返回 nextCursor 后用 `before=<nextCursor>` 继续。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 27 个 ID 都有独立效果页、参数说明与渲染 API,来源链接与 T/I/D 构件一一对应。
|
||||
- 默认效果展示原图;替换一组文字、一个图位或装饰后,只改变指定构件。
|
||||
- API 渲染与界面使用相同模板、验证和渲染器;导出的 SVG 不包含辅助边界。
|
||||
- 保存返回独立 UUID,刷新或再次访问效果 URL 能恢复参数;更新后 revision 增加,过期修订返回 409。
|
||||
- 图片 assetId 必须是 Wiki 中已上传的图片;不接受未知构件或外站 URL。
|
||||
- Wiki 与 Studio 共用现有登录和数据库,Studio 无独立持久化或数据库凭据。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Execute via production auth-cli run; token stays in memory. Uses only the designated origin. */
|
||||
import {readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
const base=process.env.AUTH_CHECK_BASE_URL||'http://8.211.154.134:18131';
|
||||
if(base!=='http://8.211.154.134:18131')throw Error('Use the designated acceptance origin');
|
||||
const dir=process.env.STUDIO_LIBRARY_DIR||'/tmp/studio-library';
|
||||
await mkdir(dir,{recursive:true});
|
||||
async function api(path,body,method=body?'POST':'GET'){
|
||||
const response=await fetch(base+path,{method,...(body?{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{})});
|
||||
if(!response.ok)throw Error(path+' status '+response.status);
|
||||
return response.json();
|
||||
}
|
||||
const canonical='8eb8c76500580f7a4b56ba3c',legacy='9ebd8f78d54b4b9db741b4e8';
|
||||
const {entry:current,tags}=await api('/api/notes/'+canonical),{entry:old}=await api('/api/notes/'+legacy);
|
||||
// Snapshot only non-secret editorial state. Retain the first backup across repeat publication.
|
||||
for(const [id,entry] of [[canonical,current],[legacy,old]]){try{await readFile(dir+'/'+id+'-before.json')}catch{await writeFile(dir+'/'+id+'-before.json',JSON.stringify(entry),{mode:0o600})}}
|
||||
const catalogue=await api('/studio/api/layouts');
|
||||
let markdown=(await readFile(dir+'/layouts-api.md','utf8')).replace('{{LAYOUTS}}',catalogue.layouts.filter(l=>l.family==='fixed').map(l=>`- [${l.id} · ${l.name}](${l.url}) · [参数](${l.api})`).join('\n'));
|
||||
const legacyOriginal=JSON.parse(await readFile(dir+'/'+legacy+'-before.json','utf8')).editableMarkdown;
|
||||
markdown+='\n\n## 兼容工具原说明与代码示例\n\n以下保留原工具的说明与 HTML/React/Next.js 调用示例;它们是无状态兼容接口,持久化页面请使用上文 CRUD。\n\n'+legacyOriginal;
|
||||
const tagIds=entry=>[...new Set([...(entry.systemTags||[]).map(t=>t.id),...entry.categories.map(name=>tags.find(t=>t.name===name)?.id).filter(Boolean)])];
|
||||
const body=(entry,text,title=entry.title)=>({title,description:entry.description,markdown:text,tags:tagIds(entry),assets:entry.assets,coverAssetId:entry.coverAssetId,revision:entry.revision});
|
||||
await api('/api/notes/'+canonical,{...body(current,markdown,'Studio 排版 API · 标准图文与27套固定构图'),description:'coding agent 经 HTTP 选择标准图文或27套固定构图,上传压缩展示素材,创建、读取、更新、归档与恢复稳定 ID 原生页面;含兼容工具箱。'},'PUT');
|
||||
const redirectText=`# Studio 文档已合并\n\n[前往 Studio 排版 API 统一入口](/explore/${canonical})\n\n[可视化 API 库](/studio) · [原 ref-17 工具](/studio/ideas/learning-skills-template) · [原三种构图工具](/studio/ideas/layout-selection)\n\n完整原说明与代码示例已保留在统一入口的兼容工具章节。此记录仅可恢复归档,未删除工具、素材或设计。`;
|
||||
await api('/api/notes/'+legacy,body(old,redirectText),'PUT');
|
||||
await api('/api/posts/'+legacy+'/archive',{archived:true});
|
||||
const entries=await api('/api/explore-feed?category='+encodeURIComponent('文档入口'));
|
||||
const expected=['3c98946754c33309d73d5284','4ce3283fd98bb03a8fb39a19',canonical].sort();
|
||||
if(entries.total!==3||entries.nextCursor||JSON.stringify(entries.entries.map(e=>e.id).sort())!==JSON.stringify(expected))throw Error('Document-entry category does not contain exactly the intended three posts');
|
||||
const after=(await api('/api/notes/'+legacy)).entry;
|
||||
if(!after.archived||!after.editableMarkdown.includes(canonical))throw Error('Legacy compatibility link missing');
|
||||
console.log(JSON.stringify({canonical:base+'/explore/'+canonical,legacyArchived:true,documentEntryCount:entries.total}));
|
||||
Reference in New Issue
Block a user