From dba97853792011d536b9559d35852fbc2ca35293 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 14:45:37 +0800 Subject: [PATCH] Build Studio visual layout library with recoverable design CRUD --- apps/studio/scripts/smoke.mjs | 4 +- apps/studio/src/components/LayoutCanvas.tsx | 2 +- .../studio/src/components/LayoutSelection.tsx | 3 +- .../studio/src/components/LayoutWorkbench.tsx | 2 +- apps/studio/src/components/TemplateTrial.tsx | 3 +- apps/studio/src/content/standard-layout.json | 78 +++++ apps/studio/src/content/tool-links.ts | 2 +- apps/studio/src/lib/design-server.ts | 5 +- apps/studio/src/lib/layout-images-client.ts | 6 + apps/studio/src/lib/native-layout.ts | 27 +- apps/studio/src/pages/api/layouts/[id].ts | 13 +- apps/studio/src/pages/api/layouts/index.ts | 11 +- apps/studio/src/pages/index.tsx | 11 +- apps/studio/src/pages/layouts/[layoutId].tsx | 4 +- apps/studio/src/styles/globals.css | 12 + apps/waterfall-wiki/db/012_studio_library.sql | 7 + .../src/content/standard-layout.json | 78 +++++ .../src/lib/studio-design-documentation.ts | 35 +-- .../src/lib/studio-design-posts.ts | 2 +- apps/waterfall-wiki/src/lib/studio-designs.ts | 21 +- apps/waterfall-wiki/src/pages/api/index.ts | 4 +- .../src/pages/api/studio/designs/[id].ts | 8 +- .../src/pages/api/studio/designs/index.ts | 2 +- apps/waterfall-wiki/tests/integration.test.ts | 18 ++ content/studio-tools/design-review.md | 11 + content/studio-tools/layouts-api.md | 272 ++++++++++-------- scripts/publish-studio-library.mjs | 31 ++ 27 files changed, 477 insertions(+), 195 deletions(-) create mode 100644 apps/studio/src/content/standard-layout.json create mode 100644 apps/studio/src/lib/layout-images-client.ts create mode 100644 apps/waterfall-wiki/db/012_studio_library.sql create mode 100644 apps/waterfall-wiki/src/content/standard-layout.json create mode 100644 content/studio-tools/design-review.md create mode 100644 scripts/publish-studio-library.mjs diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs index f46db1e..5a72740 100644 --- a/apps/studio/scripts/smoke.mjs +++ b/apps/studio/scripts/smoke.mjs @@ -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(!/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 '}}})});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(!/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 '}}})});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')); diff --git a/apps/studio/src/components/LayoutCanvas.tsx b/apps/studio/src/components/LayoutCanvas.tsx index 9d1b30f..408b779 100644 --- a/apps/studio/src/components/LayoutCanvas.tsx +++ b/apps/studio/src/components/LayoutCanvas.tsx @@ -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 {vertical?Array.from(t.value.replaceAll('\n','')).map((c,i)=>{c}):s.direction==='curved'?<>{t.value}:lines.map((l,i)=>{l})}} if(kind==='decoration'&&d?.hidden)return null; const fill=kind==='image'?im?.color:d?.color; - if(fill||im?.assetId)return {fill&&} {im?.assetId&&}; + if(fill||im?.assetId)return {fill&&} {im?.assetId&&}; return ; }))}}{guides&&layout.elements.map(s=>{const[x,y,w,h]=s.box,color=s.kind==='text'?'#2765da':s.kind==='image'?'#d86b27':'#08875d';return })}; } diff --git a/apps/studio/src/components/LayoutSelection.tsx b/apps/studio/src/components/LayoutSelection.tsx index 06b7812..144bf27 100644 --- a/apps/studio/src/components/LayoutSelection.tsx +++ b/apps/studio/src/components/LayoutSelection.tsx @@ -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
三种排版 · Studio 工具
← PROMPT 使用教程STUDIO / LAYOUT SELECTION
REF-01 / REF-07 / REF-26

三个构图,各有位置。

切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。