Introduce creative landing page and workspace with focused editor layouts
creative workspace review checks / verify (push) Failing after 4s
creative workspace review checks / verify (pull_request) Failing after 2s

This commit is contained in:
2026-10-30 14:10:40 +08:00
parent 82bef38536
commit 5a0554b3b1
13 changed files with 75 additions and 20 deletions
+22
View File
@@ -0,0 +1,22 @@
name: creative workspace review checks
on:
push:
branches: [codex/landing-workspace]
pull_request:
branches: [main]
paths:
- 'apps/studio/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/creative-workspace.yml'
jobs:
verify:
runs-on: ces-control
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Check patch formatting
run: git diff --check HEAD^ HEAD
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Verify Wiki types, tests, build and smoke
run: docker build --target build -f apps/waterfall-wiki/Dockerfile -t "creative-wiki-review:${{ gitea.sha }}" .
+9 -6
View File
@@ -1,6 +1,6 @@
import Head from 'next/head';
import {useEffect,useRef,useState,type PointerEvent} from 'react';
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X} from 'lucide-react';
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen} from 'lucide-react';
import {newElementId} from '../../lib/native-layout';
import styles from '../../styles/graphic.module.css';
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../lib/graphic-document';
@@ -8,6 +8,7 @@ const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
const storage='studio.graphic.v1';
export default function Graphic(){
const [doc,setDoc]=useState<Doc>(blank),[selected,setSelected]=useState(''),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[zoom,setZoom]=useState(.65),[status,setStatus]=useState('本机草稿'),[tab,setTab]=useState('图层'),[user,setUser]=useState<{email:string}|null>(null),[auth,setAuth]=useState(false),[mode,setMode]=useState('login'),[message,setMessage]=useState(''),[busy,setBusy]=useState(false);
const [tool,setTool]=useState('select'),[offset,setOffset]=useState({x:0,y:0});
const dialog=useRef<HTMLDivElement>(null);
useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector<HTMLElement>('button,input')?.focus();function key(e:KeyboardEvent){if(e.key==='Escape'){setAuth(false);return}if(e.key!=='Tab'||!box)return;const nodes=Array.from(box.querySelectorAll<HTMLElement>('button:not(:disabled),input,a[href]'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);old?.focus()}},[auth]);
const restore=useRef<HTMLInputElement>(null);
@@ -24,17 +25,19 @@ export default function Graphic(){
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1));}
function remove(){if(item&&!item.locked){change({...doc,items:doc.items.filter(i=>i.id!==selected)});setSelected('')}}
function duplicate(){if(item&&doc.items.length<500){const id=newElementId();change({...doc,items:[...doc.items,{...item,id,x:item.x+24,y:item.y+24,name:item.name+' 副本',locked:false}]});setSelected(id)}}
function fit(){if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
function fit(){setOffset({x:0,y:0});if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
useEffect(()=>{fit();},[]);
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
function drag(e:PointerEvent<HTMLDivElement>,i:Item){e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
function drag(e:PointerEvent<HTMLDivElement>,i:Item){e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0||tool==='hand')return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
function resize(e:PointerEvent<HTMLElement>){e.stopPropagation();if(!item||item.locked)return;e.preventDefault();const start=doc,x=e.clientX,y=e.clientY,target=e.currentTarget;target.setPointerCapture(e.pointerId);let next=start;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(i=>i.id===item.id?{...i,w:Math.max(1,Math.min(4000,item.w+(p.clientX-x)/zoom)),h:Math.max(1,Math.min(4000,item.h+(p.clientY-y)/zoom))}:i)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
function panBoard(e:PointerEvent<HTMLElement>){if(tool!=='hand')return;e.preventDefault();const x=e.clientX,y=e.clientY,start=offset,target=e.currentTarget;target.setPointerCapture(e.pointerId);const move=(p:globalThis.PointerEvent)=>setOffset({x:start.x+p.clientX-x,y:start.y+p.clientY-y});const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end)};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
return <div className={styles.app}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
<header className={styles.top}><a href="/studio/" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/studio/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
<main ref={viewport} className={styles.viewport} onPointerDown={()=>setSelected('')}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" onClick={()=>setSelected('')}><MousePointer2 size={20}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
<header className={styles.top}><a href="/workspace" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>由外部 Agent 发起连接;这里不显示额度或模拟对话。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
<input ref={restore} hidden type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>5*1024*1024)throw Error('文件不得超过 5MB');const restored=parseGraphicDocument(await f.text());change(restored);setSelected('');}catch(error){setStatus(error instanceof Error?error.message:'导入失败')}}}/>
<input ref={upload} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={e=>{const f=e.target.files?.[0];if(!f)return;if(f.size>3*1024*1024||!['image/png','image/jpeg','image/webp'].includes(f.type)){setStatus('请使用 3MB 以内的 PNG / JPEG / WebP');return}const reader=new FileReader();reader.onload=()=>add('image',String(reader.result));reader.readAsDataURL(f);e.target.value=''}}/>
@@ -4,7 +4,8 @@ import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Mi
import s from '../../styles/infinite-canvas.module.css';
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string;width?:number;height?:number};
type Doc={version:1;cards:Card[];links:[string,string][]};
const initial:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
const initial:Doc={version:1,cards:[],links:[]};
const key='studio.infinite-canvas.v1';
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&& (c.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
export default function InfiniteCanvas(){
@@ -28,10 +29,10 @@ export default function InfiniteCanvas(){
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
return <main className={s.app} data-theme={theme}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
<section className={s.canvasArea}><div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);if(e.target===e.currentTarget)setEditing('');if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget)add('text')}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);if(e.target===e.currentTarget)setEditing('');if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''} ${editing===c.id?s.editing:s.preview}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onPointerDown={e=>{if(editing===c.id||tool==='hand'||e.button!==0||(e.target as HTMLElement).closest('button'))return;e.preventDefault();e.stopPropagation();setMenu(null);select(c.id);setEditing('');viewport.current?.setPointerCapture(e.pointerId);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} onClick={()=>{if(tool==='hand'||editing!==c.id)return;select(c.id);file.current?.click()}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>{editing===c.id?'点击上传参考图':'参考图 · 点击编辑后上传'}</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><input readOnly={editing!==c.id} tabIndex={editing===c.id?0:-1} aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea readOnly={editing!==c.id} tabIndex={editing===c.id?0:-1} aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<button aria-label={`${editing===c.id?'完成编辑':'编辑'} ${c.title}`} onClick={e=>{e.stopPropagation();select(c.id);setEditing(editing===c.id?'':c.id)}}>{editing===c.id?'完成':'编辑'}</button><button aria-label={`${c.title} 操作`} onClick={e=>{e.stopPropagation();select(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}}>•••</button></footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}}>↘</button></article>)}
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
File diff suppressed because one or more lines are too long
@@ -9,3 +9,5 @@
.rail button{background:transparent;color:#bfcbd3}.rail button[aria-pressed=true]{background:#d9ef9b;color:#24301d}.app[data-theme=light] .rail button{background:transparent;color:#405663}.app[data-theme=light] .rail button[aria-pressed=true]{background:#d8e9c4;color:#34501d}
/* Preview cards drag from any content area; editing is an explicit mode. */
.preview{cursor:grab;user-select:none;touch-action:none}.preview:active{cursor:grabbing}.preview input,.preview textarea{pointer-events:none;user-select:none}.preview .image{cursor:grab}.preview img{-webkit-user-drag:none;pointer-events:none}.editing{cursor:default}.editing .cardBody{user-select:text}.editing .image{cursor:pointer}.editing{outline:2px solid #93b569;outline-offset:3px}.card footer{gap:6px}.card footer>button:first-of-type{margin-left:auto}
.startPanel{position:absolute;z-index:3;left:50%;top:46%;transform:translate(-50%,-50%);width:min(700px,calc(100% - 40px));text-align:center;pointer-events:none}.startPanel>span{font-size:9px;letter-spacing:3px;color:#8d9884}.startPanel h2{font-size:30px;font-weight:400;letter-spacing:-1px;margin:18px 0}.startPanel p{font-size:12px;opacity:.5;margin-bottom:30px}.startPanel>div{display:flex;justify-content:center;gap:12px}.startPanel button,.startPanel a{pointer-events:auto}.startPanel>div button{padding:18px 24px;border-radius:12px;gap:15px}.startPanel>button{margin-top:25px;border:0;background:transparent;font-size:11px;opacity:.65}.startPanel>a{display:block;font-size:10px;margin-top:25px;opacity:.5;text-decoration:none}.app[data-theme=light] .startPanel>button{background:transparent}@media(max-width:700px){.startPanel h2{font-size:23px}.startPanel>div{flex-wrap:wrap;gap:8px}.startPanel>div button{padding:12px;font-size:11px}}
+7
View File
@@ -3,8 +3,15 @@ import managedMedia from "./src/content/managed-media.json";
const nextConfig: NextConfig = {
serverExternalPackages: ["playwright-core"],
async redirects(){return [
{source:'/studio/graphic',destination:'/graphic',permanent:true},
{source:'/studio/infinite_canvas',destination:'/infinite_canvas',permanent:true},
{source:'/studio',destination:'/workspace',permanent:false},
]},
async rewrites(){return [
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
{source:'/graphic',destination:'http://prompt-studio:3000/studio/graphic'},
{source:'/infinite_canvas',destination:'http://prompt-studio:3000/studio/infinite_canvas'},
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
{source:"/index.md",destination:"/api/agent-index"},
{source:"/skills/index.md",destination:"/api/guidance/skills"},
+4 -3
View File
@@ -24,14 +24,14 @@ function localNext(value: string | string[] | undefined) {
value.startsWith("//") ||
/[\\\u0000-\u001f]/.test(value)
)
return "/write";
return "/workspace";
try {
const url = new URL(value, "https://local.invalid");
return url.origin === "https://local.invalid"
? url.pathname + url.search + url.hash
: "/write";
: "/workspace";
} catch {
return "/write";
return "/workspace";
}
}
export default function AccountPage() {
@@ -93,6 +93,7 @@ function AccountContent() {
"如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。",
});
await account.refresh();
if (name === "login") await router.push(localNext(router.query.next));
} catch (e) {
setFeedback({
tone: "error",
+10 -4
View File
@@ -1,4 +1,10 @@
import DocumentDirectory from "@/components/DocumentDirectory";
export default function Home() {
return <DocumentDirectory />;
}
import Head from 'next/head';
import {ArrowUpRight, MousePointer2, Plus, Type, Image, Sparkles, Workflow, Layers, ArrowRight} from 'lucide-react';
import s from '@/styles/landing.module.css';
export default function Home(){return <main className={s.page}>
<Head><title>织页 Studio · 让灵感,有形。</title><meta name="description" content="连接你的 coding agent,在图形编辑器与无限画布中,把想法组织成作品。"/></Head>
<nav className={s.nav}><a className={s.logo} href="/">✳ <b>织页</b><span>STUDIO</span></a><div><a href="#tools">创作工具</a><a href="/documents">知识与教程</a><a href="#workflow">工作方式</a></div><a href="/account?next=/workspace">登录</a><a className={s.dark} href="/account?next=/workspace">进入工作台 <ArrowUpRight size={15}/></a></nav>
<section className={s.hero}><p className={s.eyebrow}>A SPACE FOR HUMAN IDEAS & AGENTS</p><h1>想法不该停在对话里。<br/><span>让灵感,有形。</span></h1><p className={s.intro}>从一闪而过的念头,到值得分享的作品。<br/>连接你的 coding agent,在一张画布上,把创作继续下去。</p><div className={s.actions}><a className={s.dark} href="/account?next=/workspace">开始创作 <ArrowRight size={17}/></a><a href="#tools">探索你的创作空间 ↓</a></div><div className={s.product}><div className={s.productBar}><b>✳ STUDIO</b><span>日常的另一种可能 / 创意探索</span><span>本机草稿</span></div><div className={s.demo}><aside>工作空间<hr/>图层与素材<p>◈ 创意简报</p><p>▧ 视觉探索</p><p>✳ 镜头构思</p><small>把线索连接起来</small></aside><div className={s.demoWorld}><svg viewBox="0 0 800 330" preserveAspectRatio="none"><path d="M190 170 C300 170 260 120 360 120 S520 240 620 240"/></svg><article className={s.note}><small>01 / THE IDEA</small><h3>把日常,<br/>拍成一场电影。</h3><p>自然光、细腻颗粒,<br/>让每一帧都有呼吸感。</p></article><article className={s.art}><small>02 / VISUAL STUDY</small><div className={s.orbit}/><h3>光的形状</h3><span>LIGHT / SPACE / EMOTION</span></article><article className={s.prompt}><Sparkles size={17}/><small>03 / PROMPT</small><h3>留住清晨的光。</h3><p>窗边的植物,安静的房间。<br/>35mm 胶片,缓慢推进。</p></article><div className={s.dock}><MousePointer2/><Plus/><Type/><Image/><Sparkles/></div></div></div></div><p className={s.caption}>一个创作场景示意。你的想法、素材与工具,在这里相遇。</p></section>
<section className={s.tools} id="tools"><p className={s.eyebrow}>TWO TOOLS. ONE CREATIVE FLOW.</p><h2>给不同的想法,<br/>恰好的空间。</h2><div className={s.toolGrid}><a href="/graphic"><Layers/><span>01 / GRAPHIC</span><h3>把灵感排成作品。</h3><p>文字、图形与图片自由编排。精确调整图层和位置,把设计导出为 SVG。</p><div className={s.poster}><span>MAKE<br/>ROOM<br/><i>for ideas.</i></span><b>✳</b></div><strong>打开图形编辑器 <ArrowUpRight size={17}/></strong></a><a href="/infinite_canvas"><Workflow/><span>02 / INFINITE CANVAS</span><h3>让思路,自由生长。</h3><p>把笔记、参考图与提示词连成线索。在无限画布上平移、缩放,整理下一次创作。</p><div className={s.miniCanvas}><b>一个念头</b><i>⟶</i><b>一张参考</b><i>⟶</i><b>新的可能</b></div><strong>打开无限画布 <ArrowUpRight size={17}/></strong></a></div></section>
<section className={s.workflow} id="workflow"><div><p className={s.eyebrow}>YOUR AGENT. YOUR WORKFLOW.</p><h2>用熟悉的 Agent,<br/>继续你的创作。</h2><p>通过项目已有的 API 与文档,让 coding agent 理解模板和创作内容。画布负责呈现,你来决定下一步。</p><a href="/studio/api">查看 Agent 接入文档 <ArrowUpRight size={16}/></a></div><ol><li><b>01</b><div><h3>找到起点</h3><p>从空白设计、素材或一段灵感出发。</p></div></li><li><b>02</b><div><h3>连接工作方式</h3><p>将接入文档交给你的 coding agent。</p></div></li><li><b>03</b><div><h3>亲手打磨,保存作品</h3><p>在编辑器中调整细节,导出并保留备份。</p></div></li></ol></section>
<section className={s.end}><span>✳</span><h2>下一件作品,<br/>从这里开始。</h2><a className={s.dark} href="/account?next=/workspace">进入你的工作台 <ArrowRight size={17}/></a></section><footer className={s.footer}><a className={s.logo} href="/">✳ 织页 STUDIO</a><span>给想法留白,给创作空间。</span><a href="/documents">知识与教程 ↗</a></footer></main>}
@@ -0,0 +1,5 @@
import Head from 'next/head';
import {useEffect,useState} from 'react';
import {ArrowUpRight, Layers, Workflow, BookOpen, Link2} from 'lucide-react';
import s from '@/styles/workspace.module.css';
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 织页 Studio</title></Head><aside><a className={s.brand} href="/">✳ 织页 <small>STUDIO</small></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
+1 -1
View File
@@ -4,7 +4,7 @@ export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
// Public knowledge pages are read-only. Account handlers enforce their own origin and session checks.
const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || ['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas'].includes(path) || path.startsWith('/studio/_next/') || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
if (path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}});
if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders});
let config;
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.app{display:flex;min-height:100dvh;background:#fafaf8;color:#30352c;font-family:Arial,'PingFang SC',sans-serif}.app *{box-sizing:border-box}.app a{color:inherit;text-decoration:none}.app>aside{width:230px;flex-shrink:0;border-right:1px solid #e0e3da;background:#f3f4ef;padding:30px 20px;display:flex;flex-direction:column;gap:9px}.brand{font-size:22px;font-weight:600;margin-bottom:45px}.brand small{font-size:9px;letter-spacing:1px;color:#87917c}.app aside>span{font-size:10px;color:#99a08f;margin-bottom:10px;padding-left:10px}.app aside>a:not(.brand){display:flex;align-items:center;gap:12px;font-size:12px;padding:12px;border-radius:7px}.app aside>a[aria-current]{background:#e5eadb}.app aside>.account{margin-top:auto;overflow-wrap:anywhere}.app>section{padding:35px 55px;max-width:1250px;width:100%;margin:auto;min-height:100dvh}.app header{display:flex;justify-content:space-between;color:#8d9582;font-size:10px;letter-spacing:1px}.kicker{font-size:10px;letter-spacing:2px;color:#8a9876;margin-top:65px}.app h1{font-size:34px;font-weight:500;letter-spacing:-1px;margin:18px 0}.sub{font-size:13px;color:#929887}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:35px}.grid>a{background:white;border:1px solid #dfe3d6;border-radius:12px;padding:26px;transition:transform .2s}.grid>a:hover{transform:translateY(-3px)}.grid small{font-size:9px;letter-spacing:1.5px;margin-left:12px;color:#909b82}.grid>a>svg{vertical-align:middle;width:20px;color:#7b8c65}.grid h2{font-size:23px;font-weight:500;margin:24px 0 12px}.grid p{font-size:12px;color:#929a87;line-height:1.8}.poster,.canvas{height:190px;margin:25px 0;background:#ecf0e3;position:relative;overflow:hidden;border-radius:5px}.poster{padding:25px;font-size:40px;line-height:1;font-weight:600;letter-spacing:-2px}.poster i{font-family:Georgia,serif;font-weight:400}.poster b{position:absolute;right:0;top:12px;font-size:155px;color:#a7b88b}.canvas{display:flex;align-items:center;justify-content:center;gap:12px;background:radial-gradient(#c5cfb8 1px,transparent 1px) 0 0/18px 18px,#f3f5ef;color:#91a276}.canvas span{background:white;border:1px solid #d5ddc9;padding:20px 13px;font-size:12px;border-radius:6px;color:#59684a}.grid strong{display:flex;justify-content:space-between;font-size:12px;font-weight:400}.agent{display:flex;align-items:center;gap:18px;border:1px solid #dde3d1;border-radius:10px;padding:23px;margin-top:28px}.agent>svg{color:#83956c}.agent h3{font-size:14px;margin:0 0 8px}.agent p{font-size:11px;color:#8a947d;margin:0;line-height:1.8}.agent a{margin-left:auto;font-size:12px;white-space:nowrap}.hint{font-size:10px;color:#a1a793;margin-top:25px}.app a:focus-visible{outline:2px solid #718358;outline-offset:4px}@media(max-width:850px){.app>aside{width:180px}.app>section{padding:30px 25px}.grid{gap:15px}.grid>a{padding:20px}.app h1{font-size:28px}.canvas{gap:5px}.canvas span{padding:15px 8px}}@media(max-width:600px){.app{display:block}.app>aside{width:100%;flex-direction:row;flex-wrap:wrap;padding:18px;gap:5px;border-right:0;border-bottom:1px solid #ddd}.brand{margin:0;width:100%}.app aside>span,.app aside>.account{display:none}.app aside>a:not(.brand){padding:8px;font-size:10px}.grid{grid-template-columns:1fr}.kicker{margin-top:35px}.agent{flex-wrap:wrap}.agent a{margin-left:0}}
@@ -230,8 +230,11 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
}
const machineIndex=await fetch(base+'/api/guides');assert.equal(machineIndex.status,200);assert.equal((await machineIndex.json()).protocol,'linked-guidance/0.1');
const home=await fetch(base+'/').then(r=>r.text());
for(const href of ['/documents','/write','/protocol','/guide/review-changes?revision=1'])assert.ok(home.includes('href=\"'+href+'\"'),`Guidance homepage links to ${href}`);
assert.ok(home.includes('社区发布'));assert.ok(home.includes('协议样例'));
for(const href of ['/documents','/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes('href="'+href+'"'),`Landing page links to ${href}`);
assert.ok(home.includes('让灵感,有形。'));
for(const [oldPath,newPath] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
const response=await fetch(base+oldPath,{redirect:'manual'});assert.equal(response.status,308);assert.equal(response.headers.get('location'),newPath);
}
// Legacy media and skill functionality stays covered by its own routes above and below.
const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent');