Add shared ref-17 Studio cover template trial
studio CI and deployment / verify-and-deploy (push) Successful in 3m58s

This commit is contained in:
2026-10-26 19:59:51 +08:00
parent f8fde443f5
commit c3765e7a07
9 changed files with 242 additions and 8 deletions
+33
View File
@@ -0,0 +1,33 @@
import type {Idea,Slide} from '../content/ideas';
type Template=NonNullable<Idea['template']>;
export function Ref17Cover({template,slide,masthead=template.masthead,guides=false}:{template:Template;slide:Slide;masthead?:string[];guides?:boolean}){
const scale=10,W=template.width*scale,H=template.height*scale;
const slot=(id:string)=>{const s=template.slots.find(s=>s.id===id);if(!s)throw Error('Missing source slot '+id);return s.box.map(v=>v*scale)};
const [mx,my,mw,mh]=slot('I01'),[cx,cy,cw,ch]=slot('I02'),[dx,dy,dw,dh]=slot('D02'),[bx,by,bw,bh]=slot('D03'),[lx,ly,lw,lh]=slot('D01');
const [aX,aY,aW,aH]=slot('T01'),[bX,bY,bW,bH]=slot('T02'),[tX,tY,tW,tH]=slot('T03'),[sX,sY,sW,sH]=slot('T04');
const first=masthead[0]||'STUDY',second=masthead[1]||'Skills',body=slide.body.split('\n').slice(0,3);
const font1=Math.min(aH*1.2,aW/Math.max(1,first.length*.72)),font2=Math.min(bH,bW/Math.max(1,second.length*.59));
const titleSize=Math.min(tH*.72,tW/Math.max(1,Array.from(slide.heading).length*1.06));
const bodySize=Math.min(32,...body.map(s=>sW/Math.max(1,Array.from(s).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:.5),0))));
const accent=slide.accent||'#d8ddd4';
return <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${W} ${H}`} width={W} height={H} role="img" aria-label={`${slide.label||slide.heading},ref-17 模板封面试版`} data-layout-id={template.id} style={{display:'block',width:'100%',height:'auto'}}>
<title>{slide.label||slide.heading} · 模板试版</title><desc>源自 ref-17 的九个固定构件。图片位使用色块,占位文案可替换,暂无具体工具内容。</desc>
<rect width={W} height={H} fill={template.paper}/>
<g data-slot="I01"><rect x={mx} y={my} width={mw} height={mh} fill={slide.color}/></g>
<g data-slot="I02"><ellipse cx={cx+cw/2} cy={cy+ch/2} rx={cw/2} ry={ch/2} fill={accent}/></g>
<g data-slot="D02" fill="none" stroke={template.ink} strokeWidth="2" opacity=".75"><rect x={dx+1} y={dy+9} width={dw-2} height={dh-18} rx={(dh-18)/2}/><path d={`M${dx+19} ${dy+dh/2}H${dx+dw-19}`}/></g>
<g data-slot="D01"><path d={`M${lx} ${ly+lh/2}H${lx+lw}`} stroke={template.ink} strokeWidth="2"/></g>
<g data-slot="D03"><rect x={bx} y={by} width={bw} height={bh} fill={accent}/></g>
<g fill={template.ink} fontFamily="Georgia, 'Times New Roman', serif" fontWeight="400">
<text data-slot="T01" x={aX} y={aY+aH*.9} fontSize={font1} letterSpacing={-font1*.035}>{first}</text>
<text data-slot="T02" x={bX} y={bY+bH*.76} fontSize={font2} letterSpacing={-font2*.045}>{second}</text>
</g>
<text data-slot="T03" x={tX} y={tY+tH*.8} fill={template.ink} fontFamily="'PingFang SC', 'Microsoft YaHei', sans-serif" fontWeight="600" fontSize={titleSize} letterSpacing={-titleSize*.025}>{slide.heading}</text>
<g data-slot="T04" fill={template.ink} fontFamily="Arial, 'PingFang SC', sans-serif">
<text x={sX} y={sY+32} fontSize="22" letterSpacing="2">{slide.kicker||'TEMPLATE / XX'}</text>
{body.map((line,i)=><text key={i} x={sX} y={sY+135+i*58} fontSize={bodySize}>{line}</text>)}
<text x={sX} y={sY+sH-62} fontSize="19" letterSpacing="1">CONTENT</text><text x={sX} y={sY+sH-26} fontSize="19" letterSpacing="1">PLACEHOLDER</text>
</g>
{guides&&<g aria-hidden="true" fontFamily="Arial,sans-serif">{template.slots.map(s=>{const[x,y,w,h]=s.box.map(v=>v*scale),color=({text:'#2c64d8',image:'#d36b30',decoration:'#08875d'} as Record<string,string>)[s.kind]||'#666';return <g key={s.id}><rect x={x} y={y} width={w} height={h} fill={color} fillOpacity=".04" stroke={color} strokeWidth="2" strokeDasharray="8 5"/><rect x={x} y={y} width="66" height="29" fill={color}/><text x={x+6} y={y+22} fontSize="24" fill="white">{s.id}</text></g>})}</g>}
</svg>;
}
@@ -0,0 +1,29 @@
import {useState} from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {ideaMarkdown,type Idea,type Slide} from '../content/ideas';
import {Ref17Cover} from './Ref17Cover';
export function TemplateTrial({idea}:{idea:Idea}){
const template=idea.template!;
const [slides,setSlides]=useState<Slide[]>(idea.slides),[active,setActive]=useState(0),[masthead,setMasthead]=useState(template.masthead),[guides,setGuides]=useState(false),[notice,setNotice]=useState('');
const selected=slides[active],current={...idea,slides,template:{...template,masthead}};
const update=(patch:Partial<Slide>)=>setSlides(old=>old.map((slide,i)=>i===active?{...slide,...patch}:slide));
const download=(text:string,type:string,name:string)=>{const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),500)};
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
<header className="trial-nav"><Link href="/">← 我的创作</Link><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
<label>本张标题 <small>建议 4–6 字</small><input maxLength={6} value={selected.heading} onChange={e=>update({heading:e.target.value})}/></label>
<label>右侧简介 <small>最多三行</small><textarea rows={3} value={selected.body} onChange={e=>update({body:e.target.value.split('\n').slice(0,3).join('\n')})}/></label>
<div className="trial-colors"><label>主图色块<input type="color" value={selected.color} onChange={e=>update({color:e.target.value})}/></label><label>圆形与贴条<input type="color" value={selected.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={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>
</div></div>
</main>;
}