Render saved Studio results directly by ID without an editor
studio CI and deployment / verify-and-deploy (pull_request) Failing after 3m5s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled

This commit is contained in:
2026-10-28 21:02:21 +08:00
parent efffcdb1fe
commit de30ee4ec8
14 changed files with 104 additions and 31 deletions
@@ -0,0 +1,28 @@
import Head from 'next/head';
import type {CSSProperties} from 'react';
import type {AddedSlot,Layout,Parameters} from '../lib/layout-parameters';
import {CurvedCopy} from './NativeCurvedCopy';
import {decorationKind,defaultText,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout';
/** A saved result contains artwork only: no editing state, controls or event handlers. */
export function NativeResult({layout,design}:{layout:Layout;design:Design|null}){
const params:Parameters=design?.parameters||emptyParameters();
const elements=[...layout.elements,...(params.added||[])];
return <main className="native-layout-page">
<Head><title>{design?.name||layout.name} · Prompt</title><meta name="description" content={layout.name+' · 排版结果'}/></Head>
<article className={'native-layout '+layout.id} aria-label={design?.name||layout.name} data-layout-id={layout.id} data-design-id={design?.id} style={{aspectRatio:`${layout.width}/${layout.height}`,backgroundColor:params.paper||paperColor(layout.id)}}>
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],ls=params.layers?.[s.id]||{};
if(ls.hidden||(kind==='decoration'&&d?.hidden))return null;
const text=t?.value??defaultText(layout,s);
const position:CSSProperties={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)};
const textStyle:CSSProperties={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'};
return <div key={s.id} className={'native-element native-'+kind} data-element={s.id} aria-label={s.role} style={position}>
{kind==='text'?(s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} spacing={t?.letterSpacing} style={textStyle}/>:<div className={'native-copy direction-'+s.direction} style={textStyle}>{text}</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={`(max-width: 860px) ${Math.min(100,(ls.width??s.box[2])/layout.width*100)}vw, ${Math.min(860,(ls.width??s.box[2])/layout.width*860)}px`} alt={s.role} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>}
</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>}
</div>;
}))}
</article>
</main>;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export type Design={id:string;name:string;layoutId:string;parameters:Parameters;
// getRandomValues is available on the deployment's HTTP origin; randomUUID is not.
export const newElementId=()=> 'user-'+Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)),n=>n.toString(16).padStart(8,'0')).join('');
export const emptyParameters=():Parameters=>({texts:{},images:{},decorations:{}});
export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}?id=${id}`;
export const designUrl=(_layoutId:string,id:string)=>`/studio/template-layout?id=${id}`;
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}))};
}
+1 -1
View File
@@ -15,7 +15,7 @@ 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 为 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(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 默认排版,不加载原图。保存结果直接打开 /studio/template-layout?id={designId},服务端根据 ID 读取模板、图片和文字,无需选版或编辑器;缺失或不存在的 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/template-layout?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:{});
+7 -6
View File
@@ -1,19 +1,20 @@
import type {GetServerSideProps} from 'next';
import {LayoutWorkbench,type LayoutPageProps} from '../../components/LayoutWorkbench';
import {NativeResult} from '../../components/NativeResult';
import type {Layout} from '../../lib/layout-parameters';
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 {nativeLayout,designUrl} 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}/>}
export const getServerSideProps:GetServerSideProps<LayoutPageProps>=async({params,query,req,res})=>{
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const layout=layouts.find(l=>l.id===params?.layoutId);
if(!layout)return {notFound:true};
if(query.id!==undefined){
const design=await readDesign(query.id,req);
if(!design||design.layoutId!==layout.id)return {notFound:true};
return {props:{layout:nativeLayout(layout),design}};
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
}
return {props:{layout:nativeLayout(layout),design:null}};
return {props:{layout:nativeLayout(layout)}};
};
+18
View File
@@ -0,0 +1,18 @@
import type {GetServerSideProps} from 'next';
import {NativeResult} from '../components/NativeResult';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
import {readDesign} from '../lib/design-server';
import {nativeLayout,type Design} from '../lib/native-layout';
import type {Layout} from '../lib/layout-parameters';
type Props={layout:Layout;design:Design};
export default function TemplateLayout(props:Props){return <NativeResult {...props}/>}
export const getServerSideProps:GetServerSideProps<Props>=async({query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const design=await readDesign(query.id,req);
if(!design)return {notFound:true};
const layout=[standardLayout,...fixedLayouts].find(l=>l.id===design.layoutId);
if(!layout)return {notFound:true};
return {props:{layout:nativeLayout(layout),design}};
};