diff --git a/README.md b/README.md index 949ade5..e3e490d 100644 --- a/README.md +++ b/README.md @@ -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/`;POST 参数生成 SVG。效果通过 `/api/studio/designs` 存入 Wiki 现有 PostgreSQL,返回独立 ID,`/studio/designs/` 可再次打开;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 三种构图;可修改文字与配色,核对构件边界。 diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 818e1b0..599d6bb 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -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 diff --git a/apps/studio/package.json b/apps/studio/package.json index 24af072..e8cc3c3 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs new file mode 100644 index 0000000..802738d --- /dev/null +++ b/apps/studio/scripts/smoke.mjs @@ -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 '}}})});assert.equal(result.status,200);const svg=await result.text();assert.ok(svg.includes('<')&&svg.includes('>')); } +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')} diff --git a/apps/studio/src/components/LayoutCanvas.tsx b/apps/studio/src/components/LayoutCanvas.tsx new file mode 100644 index 0000000..9d1b30f --- /dev/null +++ b/apps/studio/src/components/LayoutCanvas.tsx @@ -0,0 +1,14 @@ +import {useId} from 'react'; +import type {Layout,Parameters} from '../lib/layout-parameters'; +export type LayerLayout=Omit&{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}){ + 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 {layout.id+' · '+layout.name}对应原图的文字、图位和装饰。未替换构件保留原始低分辨率图层;替换文字为 SVG 文字。{unchanged?:<>{params.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 {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&&}; + 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/LayoutWorkbench.tsx b/apps/studio/src/components/LayoutWorkbench.tsx new file mode 100644 index 0000000..843ecd2 --- /dev/null +++ b/apps/studio/src/components/LayoutWorkbench.tsx @@ -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({texts:{},images:{},decorations:{}}),[design,setDesign]=useState(null),[name,setName]=useState('新排版效果'),[status,setStatus]=useState(''),[guides,setGuides]=useState(false),[busy,setBusy]=useState(false),[raw,setRaw]=useState(''),[recent,setRecent]=useState([]); + 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){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

读取排版效果

{status||'读取中…'}

返回使用教程
; + return
{layout.id} · {name} · Studio
← PROMPT · 27 套排版教程API 说明
STUDIO / {layout.id.toUpperCase()}

{layout.name}

每个构件对应原图。修改文字、图位和装饰,保存为独立 ID,可交给 agent 通过 API 继续调整。