diff --git a/apps/studio/scripts/graphic-smoke.cjs b/apps/studio/scripts/graphic-smoke.cjs new file mode 100644 index 0000000..975c480 --- /dev/null +++ b/apps/studio/scripts/graphic-smoke.cjs @@ -0,0 +1,18 @@ +const {chromium}=require('playwright-core'); +const assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}})); + await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://127.0.0.1:3011')+'/studio/graphic'); + await page.getByRole('button',{name:'添加文字',exact:true}).click(); + await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始'); + await page.getByRole('button',{name:'添加矩形',exact:true}).click(); + await page.getByRole('button',{name:'撤销',exact:true}).click(); + await page.getByRole('button',{name:'重做',exact:true}).click(); + await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor(); + assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始'))); + const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg')); + await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'}); + await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true}); + await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.'); + }finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/apps/studio/scripts/smoke.mjs b/apps/studio/scripts/smoke.mjs index d5aaadb..5eadc4e 100644 --- a/apps/studio/scripts/smoke.mjs +++ b/apps/studio/scripts/smoke.mjs @@ -9,8 +9,8 @@ const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,autho 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(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'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 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); +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,[process.env.STUDIO_STANDALONE_SERVER || '.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 entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));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'}; diff --git a/apps/studio/src/lib/graphic-document.ts b/apps/studio/src/lib/graphic-document.ts new file mode 100644 index 0000000..9015165 --- /dev/null +++ b/apps/studio/src/lib/graphic-document.ts @@ -0,0 +1,19 @@ +export type GraphicItem = { + id:string;kind:'text'|'rect'|'ellipse'|'image';name:string;text:string; + x:number;y:number;w:number;h:number;fill:string;size:number;hidden:boolean;locked:boolean;src?:string; +}; +export type GraphicDocument = {name:string;background:string;items:GraphicItem[]}; +const color=(value:unknown)=>typeof value==='string'&&/^#[a-f0-9]{6}$/i.test(value); +/** Accept only bounded editable documents; never execute or load external assets. */ +export function parseGraphicDocument(raw:string):GraphicDocument { + if(raw.length>5*1024*1024)throw Error('设计文件不得超过 5MB'); + const d=JSON.parse(raw); + if(!d||typeof d.name!=='string'||d.name.length>100||!color(d.background)||!Array.isArray(d.items)||d.items.length>500)throw Error('设计文件格式不正确'); + const ids=new Set(); + for(const i of d.items){ + if(!i||typeof i.id!=='string'||i.id.length>100||ids.has(i.id)||!['text','rect','ellipse','image'].includes(i.kind)||typeof i.name!=='string'||i.name.length>200||typeof i.text!=='string'||i.text.length>4000||!color(i.fill)||typeof i.hidden!=='boolean'||typeof i.locked!=='boolean'||![i.x,i.y,i.w,i.h,i.size].every(Number.isFinite)||Math.abs(i.x)>4000||Math.abs(i.y)>4000||i.w<1||i.h<1||i.w>4000||i.h>4000||i.size<8||i.size>240)throw Error('设计图层格式不正确'); + if(i.kind==='image'&&(typeof i.src!=='string'||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(i.src)))throw Error('图片必须为嵌入式 PNG、JPEG 或 WebP'); + ids.add(i.id); + } + return {name:d.name,background:d.background,items:d.items.map((i:GraphicItem)=>({id:i.id,kind:i.kind,name:i.name,text:i.text,x:i.x,y:i.y,w:i.w,h:i.h,fill:i.fill,size:i.size,hidden:i.hidden,locked:i.locked,...(i.kind==='image'?{src:i.src}:{})}))}; +} diff --git a/apps/studio/src/pages/graphic/index.tsx b/apps/studio/src/pages/graphic/index.tsx new file mode 100644 index 0000000..ae8bc15 --- /dev/null +++ b/apps/studio/src/pages/graphic/index.tsx @@ -0,0 +1,43 @@ +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 {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'; +const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]}; +const storage='studio.graphic.v1'; +export default function Graphic(){ + const [doc,setDoc]=useState(blank),[selected,setSelected]=useState(''),[past,setPast]=useState([]),[future,setFuture]=useState([]),[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 dialog=useRef(null); + useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector('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('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(null); + const board=useRef(null),upload=useRef(null),viewport=useRef(null),ready=useRef(false),dirty=useRef(false); + const item=doc.items.find(i=>i.id===selected); + useEffect(()=>{try{const raw=localStorage.getItem(storage);if(raw){setDoc(parseGraphicDocument(raw));}}catch{setStatus('无法读取草稿,请导出备份')}ready.current=true;void refresh();},[]); + useEffect(()=>{if(!ready.current)return;try{localStorage.setItem(storage,JSON.stringify(doc));setStatus('已保存到此浏览器');dirty.current=false;}catch{setStatus('本机空间不足,请导出备份');dirty.current=true;}},[doc]); + useEffect(()=>{const warn=(e:BeforeUnloadEvent)=>{if(dirty.current){e.preventDefault();e.returnValue=''}};window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn)},[]); + async function refresh(){try{const r=await fetch('/api/account/session');if(r.ok)setUser((await r.json()).user);}catch{/* Guest editing remains available. */}} + function change(next:Doc){setPast(p=>[...p.slice(-49),doc]);setFuture([]);setDoc(next);dirty.current=true;} + function patch(p:Partial){if(item&&!item.locked)change({...doc,items:doc.items.map(i=>i.id===selected?{...i,...p}:i)})} + function add(kind:Item['kind'],src?:string){if(doc.items.length>=500){setStatus('单页最多 500 个图层');return;}const id=newElementId();const next:Item={id,kind,name:kind==='text'?'文字':kind==='image'?'图片':kind==='rect'?'矩形':'椭圆',text:'让灵感,有形。',x:90+doc.items.length*12%260,y:100+doc.items.length*12%400,w:kind==='text'?480:260,h:kind==='text'?90:220,fill:kind==='text'?'#282832':'#b8b3ff',size:52,hidden:false,locked:false,src};change({...doc,items:[...doc.items,next]});setSelected(id);setTab('图层');} + function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1));} + 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)))} + 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,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 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`${i.text.split('\n').map((line,n)=>`${esc(line)}`).join('')}`:i.kind==='image'?``:i.kind==='ellipse'?``:``).join('');download(`${svg}`,'image/svg+xml','svg');} + async function account(e:React.FormEvent){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
{doc.name + " · Studio Graphic"} +
✳ studioGRAPHICchange({...doc,name:e.target.value})}/>{status}
+
+
setSelected('')}>
画板 01 800 × 1000
{doc.items.filter(i=>!i.hidden).map(i=>
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'?{i.name}:null}{selected===i.id&&<>{Math.round(i.w)} × {Math.round(i.h)}}
)}{!doc.items.length&&

留白,是创作的起点。

添加文字、图形,或放入你的第一张图片。

}
e.stopPropagation()}>{Math.round(zoom*100)}%
+