Connect all 27 source layouts to parameterized Studio pages and shared design storage
studio CI and deployment / verify-and-deploy (push) Successful in 5m55s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 18m23s

This commit is contained in:
2026-10-27 14:51:17 +08:00
parent 00e1b70398
commit 94932418ca
28 changed files with 374 additions and 4 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加
Studio 负责交互式排版与无状态渲染,知识库负责教程、来源和入口。`/studio` 返回 PROMPT 首页;请从对应 post 打开具体工具。
现有工具:
27 套排版工具:`/studio/layouts/ref-01` 至 `/studio/layouts/ref-27`,每套对应原图 T/I/D 构件。目录与参数 API 为 `/studio/api/layouts` 和 `/studio/api/layouts/<id>`;POST 参数生成 SVG。效果通过 `/api/studio/designs` 存入 Wiki 现有 PostgreSQL,返回独立 ID,`/studio/designs/<id>` 可再次打开;PATCH 携带 revision 更新同一效果。Studio 不需要数据库连接或第二套数据库。详见 [27 套排版 API 教程](content/studio-tools/layouts-api.md)。
其他试版工具:
- `/studio/ideas/learning-skills-template`:ref-17 同一母版的四张占位封面。
- `/studio/ideas/layout-selection`:ref-01、ref-07、ref-26 三种构图;可修改文字与配色,核对构件边界。
+1 -1
View File
@@ -8,7 +8,7 @@ RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY apps/studio/package.json ./
RUN pnpm install --offline --frozen-lockfile
COPY apps/studio/ .
RUN pnpm typecheck && pnpm build
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
+3 -1
View File
@@ -5,7 +5,9 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit"
"typecheck": "next typegen && tsc --noEmit",
"test": "node --test tests/*.test.mjs",
"test:smoke": "node scripts/smoke.mjs"
},
"dependencies": {
"next": "16.4.2",
+6
View File
@@ -0,0 +1,6 @@
/** CI-only isolated runtime checks, never a local preview workflow. */
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
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'},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);assert.ok((await page.text()).includes(l.name));const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.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);const svg=await result.text();assert.ok(svg.includes('&lt;')&&svg.includes('&gt;')); }
assert.equal((await fetch(base+'/studio/api/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 Studio pages and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit')}
@@ -0,0 +1,14 @@
import {useId} from 'react';
import type {Layout,Parameters} from '../lib/layout-parameters';
export type LayerLayout=Omit<Layout,'elements'>&{source:string;background:string;tutorialUrl:string;sourceSha256:string;elements:(Layout['elements'][number]&{raster:string;shape?:string})[]};
export function LayoutCanvas({layout,params,guides=false,imageSources={}}:{layout:LayerLayout;params:Parameters;guides?:boolean;imageSources?:Record<string,string>}){
const uid=useId().replace(/[^a-z0-9]/gi,''),W=layout.width,H=layout.height,unchanged=!params.paper&&!Object.keys(params.texts).length&&!Object.keys(params.images).length&&!Object.keys(params.decorations).length;
return <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${W} ${H}`} width={W*6} height={H*6} role="img" aria-label={layout.id+' '+layout.name} data-layout-id={layout.id} style={{width:'100%',height:'auto',display:'block'}}><title>{layout.id+' · '+layout.name}</title><desc>对应原图的文字、图位和装饰。未替换构件保留原始低分辨率图层;替换文字为 SVG 文字。</desc>{unchanged?<image href={layout.source} width={W} height={H}/>:<><rect width={W} height={H} fill={params.paper||'#f4f2eb'}/><image href={layout.background} width={W} height={H}/>{params.paper&&<><defs><mask id={uid+'paper'}><image href={layout.background} width={W} height={H}/></mask></defs><rect width={W} height={H} fill={params.paper} mask={'url(#'+uid+'paper)'}/></>}{['image','decoration','text'].flatMap(kind=>layout.elements.filter(s=>s.kind===kind).map(s=>{
const [x,y,w,h]=s.box,id=uid+s.id,t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id];
if(kind==='text'&&t){const lines=t.value.split('\n'),len=(v:string)=>Array.from(v).reduce((a,c)=>a+(/[\u3400-\u9fff]/.test(c)?1:.6),0),vertical=s.direction==='vertical',size=Math.min(vertical?w*.9:h/(lines.length*1.2),vertical?h/Math.max(1,Array.from(t.value).length):w/Math.max(1,...lines.map(len)))*(t.scale||1),align=t.align||'start';return <g key={s.id} data-slot={s.id} fill={t.color||'#26362f'} fontFamily="Arial,'Noto Sans CJK SC','PingFang SC',sans-serif"><defs><clipPath id={id+'clip'}><rect x={x} y={y} width={w} height={h}/></clipPath></defs><g clipPath={`url(#${id}clip)`}>{vertical?Array.from(t.value.replaceAll('\n','')).map((c,i)=><text key={i} x={x+w/2} y={y+size*(i+.85)} textAnchor="middle" fontSize={size}>{c}</text>):s.direction==='curved'?<><defs><path id={id+'path'} d={`M${x} ${y+h*.2} Q${x+w/2} ${y+h*1.7} ${x+w} ${y+h*.2}`}/></defs><text fontSize={size}><textPath href={'#'+id+'path'}>{t.value}</textPath></text></>:lines.map((l,i)=><text key={i} x={x+(align==='middle'?w/2:align==='end'?w:0)} y={y+size*(.88+i*1.2)} textAnchor={align} fontSize={size}>{l}</text>)}</g></g>}
if(kind==='decoration'&&d?.hidden)return null;
const fill=kind==='image'?im?.color:d?.color;
if(fill||im?.assetId)return <g key={s.id} data-slot={s.id}><defs><mask id={id} maskUnits="userSpaceOnUse" x={x} y={y} width={w} height={h} style={{maskType:'alpha'}}><image href={s.shape||s.raster} x={x} y={y} width={w} height={h}/></mask></defs><g mask={'url(#'+id+')'}>{fill&&<rect x={x} y={y} width={w} height={h} fill={fill}/>} {im?.assetId&&<image href={imageSources[im.assetId]||'/api/media/'+im.assetId} x={x} y={y} width={w} height={h} preserveAspectRatio={im.fit==='contain'?'xMidYMid meet':'xMidYMid slice'}/>}</g></g>;
return <image key={s.id} data-slot={s.id} href={s.raster} x={x} y={y} width={w} height={h}/>;
}))}</>}{guides&&layout.elements.map(s=>{const[x,y,w,h]=s.box,color=s.kind==='text'?'#2765da':s.kind==='image'?'#d86b27':'#08875d';return <g key={s.id} aria-hidden="true"><rect x={x} y={y} width={w} height={h} fill="none" stroke={color} strokeWidth=".35"/><text x={x} y={y+3} fontSize="3" fill={color}>{s.id}</text></g>})}</svg>;
}
@@ -0,0 +1,23 @@
import {useEffect,useState} from 'react';
import Head from 'next/head';
import {LayoutCanvas,type LayerLayout} from './LayoutCanvas';
import {parameters,type Parameters} from '../lib/layout-parameters';
import layouts from '../content/templates/layouts.json';
type Design={id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
export function LayoutWorkbench({layoutId,designId}:{layoutId?:string;designId?:string}){
const [active,setActive]=useState(layoutId||''),[params,setParams]=useState<Parameters>({texts:{},images:{},decorations:{}}),[design,setDesign]=useState<Design|null>(null),[name,setName]=useState('新排版效果'),[status,setStatus]=useState(''),[guides,setGuides]=useState(false),[busy,setBusy]=useState(false),[raw,setRaw]=useState(''),[recent,setRecent]=useState<Design[]>([]);
const layout=layouts.find(l=>l.id===active) as LayerLayout|undefined;
useEffect(()=>{if(!designId)return;let live=true;fetch('/api/studio/designs/'+designId).then(async r=>{const d=await r.json();if(!r.ok)throw Error(d.error);if(live){setDesign(d.design);setActive(d.design.layoutId);setParams(d.design.parameters);setName(d.design.name)}}).catch(e=>{if(live)setStatus(e.message)});return()=>{live=false}},[designId]);
useEffect(()=>{if(!active)return;let live=true;fetch('/api/studio/designs?layoutId='+active).then(async r=>{if(r.ok&&live)setRecent((await r.json()).designs)}).catch(()=>{});return()=>{live=false}},[active,design]);
function patch(p:Partial<Parameters>){setParams(old=>({...old,...p}));setStatus('有未保存的修改')}
async function save(update=false){if(!layout)return;setBusy(true);try{const body=update&&design?{name,parameters:params,revision:design.revision}:{name,layoutId:layout.id,parameters:params},r=await fetch('/api/studio/designs'+(update&&design?'/'+design.id:''),{method:update?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}),d=await r.json();if(!r.ok)throw Error(d.error);setDesign(d.design);setStatus('已保存效果 · '+d.design.id);window.history.replaceState(null,'',d.design.url)}catch(e){setStatus(e instanceof Error?e.message:'保存失败')}finally{setBusy(false)}}
async function download(){if(!layout)return;setBusy(true);try{const r=await fetch('/studio/api/layouts/'+layout.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});if(!r.ok)throw Error('渲染失败');const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=(design?.id||layout.id)+'.svg';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}catch(e){setStatus(e instanceof Error?e.message:'导出失败')}finally{setBusy(false)}}
if(!layout)return <main className="template-trial"><h1>读取排版效果</h1><p role="status">{status||'读取中…'}</p><a href="/explore/8eb8c76500580f7a4b56ba3c">返回使用教程</a></main>;
return <main className="template-trial"><Head><title>{layout.id} · {name} · Studio</title></Head><header className="trial-nav"><a href="/explore/8eb8c76500580f7a4b56ba3c">← PROMPT · 27 套排版教程</a><a href={'/studio/api/layouts/'+layout.id+'?agent_mode=True'}>API 说明</a></header><section className="trial-intro"><div><small>STUDIO / {layout.id.toUpperCase()}</small><h1>{layout.name}</h1><p>每个构件对应原图。修改文字、图位和装饰,保存为独立 ID,可交给 agent 通过 API 继续调整。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><label>排版<select value={active} onChange={e=>{window.location.href='/studio/layouts/'+e.target.value}}>{layouts.map(l=><option key={l.id} value={l.id}>{l.id} · {l.name}</option>)}</select></label><label>效果名称<input value={name} maxLength={120} onChange={e=>setName(e.target.value)}/></label>
<details open><summary>文字 T · {layout.elements.filter(s=>s.kind==='text').length} 组</summary>{layout.elements.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<small>{params.texts[s.id]?'替换文字':s.reading==='unreadable'?'原图小字无法辨认;未替换时保留原图层':'未替换时保留原图字形'}</small><textarea rows={2} maxLength={500} placeholder={s.text||'输入替换文字;留空可移除此文字'} value={params.texts[s.id]?.value??''} onChange={e=>patch({texts:{...params.texts,[s.id]:{...params.texts[s.id],value:e.target.value}}})}/><button onClick={()=>{const texts={...params.texts};delete texts[s.id];patch({texts})}}>恢复原图文字</button></label>)}</details>
<details><summary>图位 I · {layout.elements.filter(s=>s.kind==='image').length} 组</summary>{layout.elements.filter(s=>s.kind==='image').map(s=><div key={s.id}><label>{s.id} · {s.role}<input type="color" value={params.images[s.id]?.color||'#83918a'} onChange={e=>patch({images:{...params.images,[s.id]:{...params.images[s.id],color:e.target.value}}})}/></label><label>已上传图片的 asset ID<input placeholder="UUID(可留空)" value={params.images[s.id]?.assetId||''} onChange={e=>{const image={...params.images[s.id]};if(e.target.value)image.assetId=e.target.value;else delete image.assetId;patch({images:{...params.images,[s.id]:image}})}}/></label><button onClick={()=>{const images={...params.images};delete images[s.id];patch({images})}}>恢复原图图位</button></div>)}</details>
<details><summary>装饰 D · {layout.elements.filter(s=>s.kind==='decoration').length} 组</summary>{layout.elements.filter(s=>s.kind==='decoration').map(s=><div key={s.id}><label>{s.id} · {s.role}<input type="color" value={params.decorations[s.id]?.color||'#53645b'} onChange={e=>patch({decorations:{...params.decorations,[s.id]:{...params.decorations[s.id],color:e.target.value}}})}/></label><label><input type="checkbox" checked={params.decorations[s.id]?.hidden||false} onChange={e=>patch({decorations:{...params.decorations,[s.id]:{...params.decorations[s.id],hidden:e.target.checked}}})}/>隐藏此装饰</label><button onClick={()=>{const decorations={...params.decorations};delete decorations[s.id];patch({decorations})}}>恢复原装饰</button></div>)}</details>
<label>纸色<input type="color" value={params.paper||'#f4f2eb'} onChange={e=>patch({paper:e.target.value})}/></label><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示 T/I/D 构件边界</label><details onToggle={e=>{if(e.currentTarget.open)setRaw(JSON.stringify(params,null,2))}}><summary>完整参数 JSON</summary><textarea aria-label="完整参数 JSON" rows={12} value={raw} onChange={e=>setRaw(e.target.value)}/><button onClick={()=>{try{setParams(parameters(layout,JSON.parse(raw)));setStatus('参数已应用,尚未保存')}catch(e){setStatus(e instanceof Error?e.message:'JSON 错误')}}}>应用参数</button></details>
<div className="trial-actions"><button disabled={busy} className="trial-primary" onClick={()=>void save()}>保存新效果</button>{design&&<button disabled={busy} onClick={()=>void save(true)}>更新当前 ID</button>}<button disabled={busy} onClick={()=>void download()}>下载当前 SVG</button></div><p role="status">{status}</p>{design&&<p><a href={design.url}>打开已保存效果</a> · 修订 {design.revision}</p>}<p className="trial-help">原图来自低分辨率截图,未替换部分保留其像素质量。渲染不会自动保存;保存新效果生成新 ID,更新当前 ID 会检测修订冲突。</p><details><summary>已保存的此排版效果</summary>{recent.length?recent.map(d=><p key={d.id}><a href={d.url}>{d.name}</a> · r{d.revision}</p>):<p>还没有保存的效果</p>}</details></aside>
<div><div className="selection-preview"><LayoutCanvas layout={layout} params={params} guides={guides}/></div><details className="trial-reference"><summary>原图与构件对应表</summary><div className="trial-reference-body"><img src={layout.source} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>原图分层文档 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>坐标</th></tr></thead><tbody>{layout.elements.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
import type {NextApiRequest} from 'next';
import type {Parameters} from './layout-parameters';
/** Only fetch bounded previews from the authenticated Wiki service, never caller URLs. */
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
return sources;
}
+25
View File
@@ -0,0 +1,25 @@
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end'};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain'};
export type DecorationValue={color?:string;hidden?:boolean};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string};
export class LayoutParameterError extends Error{}
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
function fail(message:string):never{throw new LayoutParameterError(message)}
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
export function parameters(layout:Layout,input:unknown):Parameters{
const raw=object(input);keys(raw,['texts','images','decorations','paper']);const result:Parameters={texts:{},images:{},decorations:{}};
if(raw.paper!==undefined)result.paper=color(raw.paper);
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
const entries=object(raw[group]??{});keys(entries,layout.elements.filter(e=>e.kind===kind).map(e=>e.id));
for(const [id,value] of Object.entries(entries)){const v=object(value);
if(group==='texts'){keys(v,['value','color','scale','align']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}result.images[id]=image;
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
}
}
return result;
}
+1 -1
View File
@@ -1,3 +1,3 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas} from '../../content/ideas';
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
+14
View File
@@ -0,0 +1,14 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {embedLayoutImages} from '../../../lib/layout-images';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import layouts from '../../../content/templates/layouts.json';
import {LayoutCanvas} from '../../../components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../lib/layout-parameters';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 ref-01 .. ref-27。\nGET 同地址 ?format=svg 返回原图对应效果。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/designs/{id};PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({raster,shape,...s})=>s),parameters:params,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'}}};
@@ -0,0 +1,3 @@
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'})}
+3
View File
@@ -0,0 +1,3 @@
import {useRouter} from 'next/router';
import {LayoutWorkbench} from '../../components/LayoutWorkbench';
export default function DesignPage(){const router=useRouter();return typeof router.query.id==='string'?<LayoutWorkbench designId={router.query.id}/>:null}
+6
View File
@@ -0,0 +1,6 @@
import type {GetStaticPaths,GetStaticProps} from 'next';
import {LayoutWorkbench} from '../../components/LayoutWorkbench';
import layouts from '../../content/templates/layouts.json';
export default function LayoutPage({id}:{id:string}){return <LayoutWorkbench layoutId={id}/>}
export const getStaticPaths:GetStaticPaths=async()=>({paths:layouts.map(l=>({params:{id:l.id}})),fallback:false});
export const getStaticProps:GetStaticProps=async({params})=>layouts.some(l=>l.id===params?.id)?{props:{id:params?.id}}:{notFound:true};
+2
View File
@@ -26,3 +26,5 @@
@page ref17Proof{size:113mm 174mm;margin:0}
@media print{.template-trial{padding:0;max-width:none}.trial-nav,.trial-intro,.trial-controls,.trial-reference,.proof-card figcaption{display:none}.trial-workspace,.trial-grid{display:block}.proof-card{page:ref17Proof;break-after:page;width:113mm;height:174mm}.proof-card:last-child{break-after:auto}.proof-select,.proof-active .proof-select{outline:none;box-shadow:none;width:113mm;height:174mm}.proof-select svg{width:113mm!important;height:174mm!important;print-color-adjust:exact}.proof-select svg g[aria-hidden=true]{display:none}}
.selection-preview{max-width:540px;margin:auto;box-shadow:0 3px 15px #28362f14}.selection-thumbs{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:30px}.selection-thumbs button{padding:0;border-radius:0;border:1px solid #d8dcd5;background:#fff}.selection-thumbs button[aria-pressed=true]{outline:2px solid #617d65;outline-offset:4px}.selection-thumbs span{display:block;font-size:12px;padding:10px}
.trial-controls details{border-top:1px solid #dedfd8;padding:12px 0}.trial-controls summary{cursor:pointer;margin-bottom:12px}.trial-controls label small{display:block;font-weight:400;line-height:1.6;color:#687268}.trial-controls textarea{width:100%;box-sizing:border-box}.trial-controls>label input:not([type=checkbox]):not([type=color]){width:100%;box-sizing:border-box}.trial-actions button:disabled{opacity:.5;cursor:wait}
@@ -0,0 +1,5 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../src/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
@@ -0,0 +1,10 @@
CREATE TABLE studio_designs (
id uuid PRIMARY KEY,
layout_id text NOT NULL CHECK (layout_id ~ '^ref-(0[1-9]|1[0-9]|2[0-7])$'),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
parameters jsonb NOT NULL CHECK (jsonb_typeof(parameters)='object'),
revision integer NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX studio_designs_layout_created ON studio_designs(layout_id,created_at DESC,id DESC);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end'};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain'};
export type DecorationValue={color?:string;hidden?:boolean};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string};
export class LayoutParameterError extends Error{}
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
function fail(message:string):never{throw new LayoutParameterError(message)}
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
export function parameters(layout:Layout,input:unknown):Parameters{
const raw=object(input);keys(raw,['texts','images','decorations','paper']);const result:Parameters={texts:{},images:{},decorations:{}};
if(raw.paper!==undefined)result.paper=color(raw.paper);
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
const entries=object(raw[group]??{});keys(entries,layout.elements.filter(e=>e.kind===kind).map(e=>e.id));
for(const [id,value] of Object.entries(entries)){const v=object(value);
if(group==='texts'){keys(v,['value','color','scale','align']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}result.images[id]=image;
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
}
}
return result;
}
@@ -0,0 +1,13 @@
import type {NextApiRequest,NextApiResponse} from 'next';
export const designDocs=`# Studio 效果保存 API(与 Wiki 共用 PostgreSQL)
需要现有 PROMPT 登录 Cookie 或 Bearer JWT;浏览器修改请求需要同源 Origin。
GET /api/studio/designs?layoutId=ref-01&limit=20:列举,nextCursor 用 before 继续。
POST /api/studio/designs:保存新效果,201 {design};每次生成独立 UUID。
请求:{"layoutId":"ref-01","name":"第一版","parameters":{"texts":{"T01":{"value":"STUDY"}},"images":{"I01":{"color":"#788880"}},"decorations":{}}}
GET /api/studio/designs/{id}:读取 {design},包含 layoutId/name/parameters/revision/url/renderUrl。
PATCH /api/studio/designs/{id}:{name,parameters,revision},参数整体替换,layoutId 不变;revision 冲突 409。
效果页 /studio/designs/{id};27 套模板 /studio/layouts/ref-01 .. ref-27。
参数与渲染契约:GET /studio/api/layouts/ref-01?agent_mode=True;POST 同地址返回 SVG(无保存副作用)。
先渲染预览,确认后保存,凭返回 id 再次打开或更新。不同方案用 POST 保存新 ID,更新同一方案使用 PATCH。
错误:400 参数、构件、图片 ID 不合法;401 未登录;404 效果不存在;409 修订冲突;413 请求过大。`;
export function studioDesignDocumentation(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||'')||String(req.query.agent_mode).toLowerCase()!=='true')return false;res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')res.status(200).end();else res.status(200).send(designDocs);return true}
@@ -0,0 +1,10 @@
import {randomUUID} from 'node:crypto';
import {z} from 'zod';
import {db} from './db';
import {parameters,LayoutParameterError} from './layout-parameters';
import layouts from '../content/studio-layouts.json';
export const designInput=z.object({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 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:'/studio/designs/'+r.id,renderUrl:'/studio/api/layouts/'+r.layout_id};}
export async function validateDesign(layoutId:string,raw:unknown){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 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);const r=await db().query('INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,$2,$3,$4) RETURNING *',[randomUUID(),input.layoutId,input.name,JSON.stringify(p)]);return designOutput(r.rows[0]);}
@@ -4,6 +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/{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:'/studio/api/health',title:'创作站健康检查',methods:'GET, HEAD',documentation:'/studio/api/health?agent_mode=True'}];
res.setHeader('Cache-Control','no-store');
const plain=typeof req.query.agent_mode==='string'&&req.query.agent_mode.toLowerCase()==='true';
@@ -0,0 +1,14 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {z} from 'zod';
import {db} from '@/lib/db';
import {sameOrigin,apiError} from '@/lib/api';
import {designOutput,updateDesignInput,validateDesign} 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()}const id=z.uuid().parse(req.query.id),old=(await db().query('SELECT * FROM studio_designs WHERE id=$1',[id])).rows[0];if(!old)return res.status(404).json({error:'效果不存在'});
if(req.method==='GET')return res.json({design:designOutput(old)});sameOrigin(req);const input=updateDesignInput.parse(req.body),p=await validateDesign(old.layout_id,input.parameters),r=await db().query('UPDATE studio_designs SET name=$2,parameters=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$4 RETURNING *',[id,input.name,JSON.stringify(p),input.revision]);if(!r.rowCount)return res.status(409).json({error:'效果已被修改,请重新读取后再保存'});return res.json({design:designOutput(r.rows[0])});
}catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});return apiError(res,e)}
}
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
@@ -0,0 +1,15 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {z} from 'zod';
import {sameOrigin,apiError} from '@/lib/api';
import {db} from '@/lib/db';
import {createDesign,designInput,designOutput} 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(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})}
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)}
}
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
@@ -57,6 +57,16 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio' AND derivation='audio_track'")).rows[0].n,1,'Video soundtracks are ordinary reusable assets');
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
// Studio owns no second database: designs use this same isolated PostgreSQL.
for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});}
const design=await request('/api/studio/designs',{layoutId:'ref-01',name:'可修改效果',parameters:{texts:{T01:{value:'新的标题'}},images:{I01:{assetId:first.assets[0],fit:'contain'}}}});
const designEdit={name:'更新效果',parameters:{texts:{T01:{value:'修改后'}}},revision:design.design.revision};
const patches=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(designEdit)})));assert.deepEqual(patches.map(r=>r.status).sort(),[200,409]);
const designRead=await request('/api/studio/designs/'+design.design.id);assert.equal(designRead.design.revision,2);assert.equal(designRead.design.parameters.texts.T01.value,'修改后');
const designPage=await request('/api/studio/designs?layoutId=ref-01&limit=1');assert.equal(designPage.designs.length,1);assert.ok(designPage.nextCursor);assert.equal((await request('/api/studio/designs?layoutId=ref-01&limit=1&before='+designPage.nextCursor)).designs.length,1);
for(const body of [{layoutId:'ref-28',name:'bad',parameters:{}},{layoutId:'ref-01',name:'bad',parameters:{texts:{I01:{value:'wrong kind'}}}},{layoutId:'ref-01',name:'bad',parameters:{images:{I01:{assetId:randomUUID()}}}}])assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).status,400);
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID())).status,404);
assert.ok((await fetch(base+'/api/studio/designs?agent_mode=True').then(r=>r.text())).includes('revision'));
const published=await publishLearningSeries();await publishLearningSeries();
const articles=(await request('/api/feed?section=articles')).posts;
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
+131
View File
@@ -0,0 +1,131 @@
# 27 套排版 · Studio 参数 API 与保存教程
把这篇 post 交给 agent,即可选排版、预览、调整参数并保存可再次打开的效果。每套 ref-01…ref-27 都有独立页面,与原图的 T(文字)、I(图位)、D(装饰)构件对应。
## 先看效果,再通过 API 调整
{{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 无独立持久化或数据库凭据。
+10
View File
@@ -0,0 +1,10 @@
/** Package the 27 audited layers; no new Wiki document routes or copied DB. */
import {readFile,writeFile} from 'node:fs/promises';
const root=new URL('../',import.meta.url),read=p=>readFile(new URL(p,root)),raw=JSON.parse(await read('content/layout-reference/manifest.json')),ids=JSON.parse(await read('content/layout-reference/post-ids.json'));
const image=async p=>'data:image/png;base64,'+(await read('content/layout-reference/assets/'+p.replace(/^asset:/,''))).toString('base64');
const layouts=[];
for(const m of raw){const elements=[];for(const e of m.elements)elements.push({...e,raster:await image(e.asset),...(e.mask?{shape:await image(e.mask)}:{})});layouts.push({id:m.id,name:m.name,width:m.width,height:m.height,tutorialUrl:'/explore/'+ids[m.id],sourceSha256:m.source.sha256,source:await image(m.reference),background:await image(m.background),elements});}
await writeFile(new URL('apps/studio/src/content/templates/layouts.json',root),JSON.stringify(layouts));
await writeFile(new URL('apps/waterfall-wiki/src/content/studio-layouts.json',root),JSON.stringify(layouts.map(({source,background,elements,...l})=>({...l,elements:elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))}))));
await writeFile(new URL('apps/waterfall-wiki/src/lib/layout-parameters.ts',root),await read('apps/studio/src/lib/layout-parameters.ts'));
console.log('Packaged '+layouts.length+' corresponding layouts');
@@ -0,0 +1,12 @@
/** Run only inside the Wiki container via auth-cli.cjs run. API-only editorial update. */
import {readFile,writeFile} from 'node:fs/promises';
const base='http://127.0.0.1:3000',id='8eb8c76500580f7a4b56ba3c',dir='/tmp/prompt-layout-studio';
const layouts=JSON.parse(await readFile(dir+'/catalog.json','utf8'));
let markdown=await readFile(dir+'/tutorial.md','utf8');
markdown=markdown.replace('{{LAYOUTS}}',layouts.map(l=>`- **[${l.id} · ${l.name}](/studio/layouts/${l.id})** · [构件文档](${l.tutorialUrl}) · [API 参数](/studio/api/layouts/${l.id}?agent_mode=True)`).join('\n'));
const response=await fetch(base+'/api/notes/'+id);if(!response.ok)throw Error('Router unavailable');const {entry}=await response.json();
await writeFile('/tmp/prompt-layout-router-before.json',JSON.stringify(entry),{mode:0o600});
const tags=await fetch(base+'/api/tags').then(r=>r.json()),selected=tags.filter(t=>entry.categories.includes(t.name)||(entry.systemTags||[]).some(s=>s.id===t.id)).map(t=>t.id);
const result=await fetch(base+'/api/notes/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'27 套排版 · Studio 参数 API 与保存教程',description:'27 个原图对应效果页,按 T/I/D 构件调整参数;API 渲染、保存独立 ID 与再次编辑,和 Wiki 共用数据库。',markdown,tags:selected,assets:entry.assets,coverAssetId:entry.coverAssetId,revision:entry.revision})});
if(!result.ok)throw Error('Router update failed: '+result.status);
console.log(JSON.stringify({routerUpdated:true,studioPages:layouts.length,contentExported:false}));