Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbf70936ab | |||
| 1a3a335aae | |||
| 900fd89af9 | |||
| 9b81cad9f9 | |||
| 0d8aacb9a2 | |||
| 8c5d838944 | |||
| f490543853 |
@@ -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});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Run against an already started Studio. BASE_URL is the origin, including port.
|
||||
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.goto((process.env.BASE_URL||'http://localhost:3018')+'/studio/infinite_canvas');await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'添加笔记',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByLabel('节点名称').fill('Smoke note');await page.getByLabel('笔记内容').fill('A saved idea');await page.reload();await page.getByText('Smoke note',{exact:true}).first().waitFor();assert.equal(await page.locator('article').count(),4);await page.getByText('Smoke note',{exact:true}).first().click();await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByRole('button',{name:'适应画布'}).click();const card=page.locator('article').last();const before=await card.getAttribute('style');const box=await card.locator('div').first().boundingBox();await page.mouse.move(box.x+70,box.y+20);await page.mouse.down();await page.mouse.move(box.x+120,box.y+45,{steps:5});await page.mouse.up();assert.notEqual(await card.getAttribute('style'),before);const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出项目'}).click();assert.equal((await download).suggestedFilename(),'studio-canvas.json');if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-desktop.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png',fullPage:true});assert.deepEqual(errors,[]);console.log('Canvas browser smoke passed: create, edit, restore, delete, undo, fit, export, mobile.')}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -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(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>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 <test>'}}})});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'};
|
||||
|
||||
@@ -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<string>();
|
||||
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}:{})}))};
|
||||
}
|
||||
@@ -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<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 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);
|
||||
const board=useRef<HTMLDivElement>(null),upload=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(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<Item>){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<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 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'?`<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>
|
||||
<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=''}}/>
|
||||
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}>✳</span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import Head from 'next/head';
|
||||
import fixed from '../content/templates/layouts.json';
|
||||
import standard from '../content/standard-layout.json';
|
||||
import {nativeLayout} from '../lib/native-layout';
|
||||
import {NativePreview as Preview} from '../components/NativePreview';
|
||||
export default function StudioEntry(){return <main className="studio-library"><Head><title>Studio · 可视化排版 API 库</title></Head><nav className="library-nav"><a href="/">PROMPT / STUDIO</a><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档与工具箱</a><a href="/api/studio/designs?agent_mode=True">API 契约</a></nav><h1>用参数,编排画面。</h1><p className="library-intro">给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。</p><div className="library-heading"><h2>标准排版</h2><a href="/studio/api/layouts/standard-01">参数</a></div><div className="library-standard"><a href="/studio/layouts/standard-01"><Preview layout={standard}/></a><div><h2>一页,一件事。</h2><p>沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。</p><code>layoutId: standard-01</code><p><a href="/studio/layouts/standard-01">打开原生页面</a></p></div></div><div className="library-heading"><h2>固定排版 · 27 套</h2><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档</a></div><div className="library-grid">{fixed.map(l=><article className="library-item" key={l.id}><a href={'/studio/layouts/'+l.id}><Preview layout={nativeLayout(l)}/><h3>{l.name}</h3><p>{l.id.toUpperCase()}</p></a></article>)}</div></main>}
|
||||
import type {GetServerSideProps} from 'next';
|
||||
export default function StudioEntry(){return null}
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight} from 'lucide-react';
|
||||
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};
|
||||
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 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.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(){
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState('');
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc}|null>(null);
|
||||
const active=doc.cards.find(c=>c.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)}
|
||||
function change(field:'title'|'text',value:string){commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,[field]:value}:c)})}
|
||||
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 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+320))-minX,h=Math.max(...doc.cards.map(c=>c.y+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}><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}/><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} ref={viewport} onWheel={e=>{if(e.ctrlKey||e.metaKey){setZoom(z=>Math.max(.2,Math.min(2,z-e.deltaY*.002)))}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{if(e.target===e.currentTarget||tool==='hand'){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?{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}:c)})}} onPointerUp={()=>{const d=drag.current;if(d&&d.id!=='pan'){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{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+320} ${from.y+150} C ${from.x+365} ${from.y+150},${to.x-45} ${to.y+150},${to.x} ${to.y+150}`}/>})}</svg>
|
||||
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} style={{left:c.x,top:c.y}} onClick={()=>select(c.id)}><div className={s.cardHead} onPointerDown={e=>{if(tool==='hand')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={()=>{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}>示例占位 · 点击上传参考图</span></>}</div>:<div className={s.cardBody}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><p>{c.text||'选择节点,在右侧写下你的想法。'}</p></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<span className={s.port}/></footer></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={()=>setZoom(z=>Math.max(.2,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
<aside className={s.inspector}><div className={s.panelHeading}><Workflow size={17}/><strong>节点属性</strong><span>{doc.cards.length} 个节点</span></div>{active?<><span className={s.eyebrow}>INSPECTOR</span><h2>{active.kind==='prompt'?'把想法描述清楚':active.kind==='image'?'让参考彼此连接':'记录灵感的起点'}</h2><label>节点名称<input maxLength={200} value={active.title} onChange={e=>change('title',e.target.value)}/></label><label>{active.kind==='prompt'?'画面描述':'笔记内容'}<textarea maxLength={10000} value={active.text} onChange={e=>change('text',e.target.value)} placeholder="写下你的创作想法…"/></label>{active.kind==='image'&&<button className={s.wide} onClick={()=>file.current?.click()}><ImagePlus size={16}/>选择参考图片</button>}{active.kind==='prompt'&&<div className={s.notice}><Sparkles size={18}/><strong>为下一次创作做好准备</strong><p>提示词可保存在项目中。当前没有连接模型服务,暂不支持 AI 图片或视频生成。</p></div>}<label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button className={s.wide} disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}><Link2 size={16}/>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button className={s.connection} key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>↗ {doc.cards.find(c=>c.id===(a===selected?b:a))?.title} <span>断开 ×</span></button>)}<button className={s.delete} onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('')}}><Trash2 size={15}/>删除节点</button></>:<><h2>给灵感留一点空间</h2><p>选择画布中的节点来编辑,或从左侧添加新内容。</p><button className={s.wide} onClick={()=>add('text')}><Plus size={16}/>添加笔记</button></>}<div className={s.help}>拖动节点标题移动节点<br/>滚轮平移 · Ctrl / ⌘ + 滚轮缩放<br/>草稿仅存于当前浏览器,请及时导出。</div></aside></div>
|
||||
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
|
||||
<input hidden ref={json} 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>15000000)throw Error();const d=JSON.parse(await f.text());if(!valid(d))throw Error();commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseGraphicDocument} from '../src/lib/graphic-document.ts';
|
||||
const item={id:'one',kind:'text',name:'标题',text:'<script>文本</script>',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
|
||||
const doc={name:'作品',background:'#ffffff',items:[item]};
|
||||
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
|
||||
test('graphic backup rejects corrupted layers, duplicate IDs and unbounded geometry',()=>{for(const items of [[{...item,text:null}],[item,item],[{...item,x:Infinity}],[{...item,w:-1}],[{...item,fill:'url(https://example.com)'}]])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items})));});
|
||||
test('graphic backup disallows active SVG and remote image loads',()=>{for(const src of ['https://example.com/image.png','javascript:alert(1)','data:image/svg+xml;base64,PHN2Zz4='])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items:[{...item,kind:'image',src}]})));});
|
||||
@@ -0,0 +1,495 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
|
||||
import {
|
||||
EmptyState,
|
||||
KindBadge,
|
||||
KindIcon,
|
||||
Notice,
|
||||
PageHeading,
|
||||
kindNames,
|
||||
Tabs,
|
||||
} from "@/components/GuidanceUI";
|
||||
import { guides, type Guidance } from "@/content/guides/catalog";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
|
||||
type Summary = {
|
||||
id: string;
|
||||
revision: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
kind: Guidance["kind"];
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
source: "community" | "example";
|
||||
};
|
||||
type Source = "all" | "published" | "examples";
|
||||
const kinds = ["all", "guide", "context", "evidence"] as const;
|
||||
const examples: Summary[] = guides.map((g) => ({ ...g, source: "example" }));
|
||||
export default function DocumentDirectory({
|
||||
communityOnly = false,
|
||||
}: {
|
||||
communityOnly?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const mine = communityOnly && router.query.scope === "mine";
|
||||
return (
|
||||
<GuidanceShell
|
||||
title={mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档"}
|
||||
>
|
||||
<DirectoryContent communityOnly={communityOnly} mine={mine} />
|
||||
</GuidanceShell>
|
||||
);
|
||||
}
|
||||
function DirectoryContent({
|
||||
communityOnly,
|
||||
mine,
|
||||
}: {
|
||||
communityOnly: boolean;
|
||||
mine: boolean;
|
||||
}) {
|
||||
const router = useRouter(),
|
||||
account = useGuidanceAccount();
|
||||
const [published, setPublished] = useState<Summary[]>([]),
|
||||
[loading, setLoading] = useState(true),
|
||||
[error, setError] = useState("");
|
||||
const [q, setQ] = useState(""),
|
||||
[kind, setKind] = useState("all"),
|
||||
[source, setSource] = useState<Source>(communityOnly ? "published" : "all"),
|
||||
[sort, setSort] = useState("default");
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const refresh = useCallback(async () => {
|
||||
controller.current?.abort();
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/documents" +
|
||||
(mine && account.user ? "?author=" + account.user.id : ""),
|
||||
{ signal: abort.signal },
|
||||
);
|
||||
const b = await r.json();
|
||||
if (!r.ok) throw Error(b.error || "目录暂时不可用");
|
||||
if (!Array.isArray(b.documents)) throw Error("目录返回异常,请稍后重试");
|
||||
setPublished(
|
||||
b.documents.map((d: Omit<Summary, "source">) => ({
|
||||
...d,
|
||||
source: "community",
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!abort.signal.aborted)
|
||||
setError(e instanceof Error ? e.message : "目录暂时不可用");
|
||||
} finally {
|
||||
if (!abort.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, [mine, account.user?.id]);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
return () => controller.current?.abort();
|
||||
}, [refresh]);
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
setQ(typeof router.query.q === "string" ? router.query.q : "");
|
||||
setKind(
|
||||
typeof router.query.kind === "string" &&
|
||||
kinds.some((k) => k === router.query.kind)
|
||||
? router.query.kind
|
||||
: "all",
|
||||
);
|
||||
setSource(
|
||||
communityOnly
|
||||
? "published"
|
||||
: router.query.source === "published" ||
|
||||
router.query.source === "examples"
|
||||
? router.query.source
|
||||
: "all",
|
||||
);
|
||||
setSort(router.query.sort === "title" ? "title" : "default");
|
||||
}, [
|
||||
router.isReady,
|
||||
router.query.q,
|
||||
router.query.kind,
|
||||
router.query.source,
|
||||
router.query.sort,
|
||||
communityOnly,
|
||||
]);
|
||||
function remember(
|
||||
next: { q?: string; kind?: string; source?: Source; sort?: string } = {},
|
||||
) {
|
||||
const values = { q, kind, source, sort, ...next },
|
||||
query: Record<string, string> = {};
|
||||
if (values.q) query.q = values.q;
|
||||
if (values.kind !== "all") query.kind = values.kind;
|
||||
if (!communityOnly && values.source !== "all") query.source = values.source;
|
||||
if (values.sort !== "default") query.sort = values.sort;
|
||||
if (mine) query.scope = "mine";
|
||||
void router.push(
|
||||
{ pathname: communityOnly ? "/documents" : "/", query },
|
||||
undefined,
|
||||
{ shallow: true, scroll: false },
|
||||
);
|
||||
}
|
||||
function search(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
remember();
|
||||
}
|
||||
function clear() {
|
||||
setQ("");
|
||||
setKind("all");
|
||||
setSource(communityOnly ? "published" : "all");
|
||||
setSort("default");
|
||||
remember({
|
||||
q: "",
|
||||
kind: "all",
|
||||
source: communityOnly ? "published" : "all",
|
||||
sort: "default",
|
||||
});
|
||||
}
|
||||
const all: Summary[] = communityOnly
|
||||
? published
|
||||
: [...published, ...examples];
|
||||
const term = q.trim().toLocaleLowerCase();
|
||||
const found = all.filter(
|
||||
(d) =>
|
||||
(!mine || d.author === account.user?.id) &&
|
||||
(source === "all" ||
|
||||
(source === "published" && d.source === "community") ||
|
||||
(source === "examples" && d.source === "example")) &&
|
||||
`${d.title} ${d.summary} ${d.id} ${d.tags?.join(" ") || ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(term),
|
||||
);
|
||||
const documents = found.filter((d) => kind === "all" || d.kind === kind);
|
||||
if (sort === "title")
|
||||
documents.sort((a, b) => a.title.localeCompare(b.title, "zh-CN"));
|
||||
const waiting = loading || (mine && account.loading),
|
||||
filtered =
|
||||
!!q ||
|
||||
kind !== "all" ||
|
||||
(!communityOnly && source !== "all") ||
|
||||
sort !== "default";
|
||||
const heading = mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档";
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
title={heading}
|
||||
description={
|
||||
mine
|
||||
? "在这里管理你发布的文档,修改会创建新的固定版本。"
|
||||
: communityOnly
|
||||
? "来自真实作者的独立文档。找到有用的知识,从一篇开始阅读。"
|
||||
: "为当前任务找到有用的指导,按需连接下一篇知识。"
|
||||
}
|
||||
eyebrow={
|
||||
mine
|
||||
? "YOUR KNOWLEDGE"
|
||||
: communityOnly
|
||||
? "COMMUNITY LIBRARY"
|
||||
: "DISCOVER KNOWLEDGE"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className={s.secondaryButton}
|
||||
href="/agent/index.md"
|
||||
target="_blank"
|
||||
>
|
||||
<Bot size={15} />
|
||||
Agent 入口
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</PageHeading>
|
||||
{!communityOnly && (
|
||||
<>
|
||||
<section className={s.introCard}>
|
||||
<span className={s.introIcon}>
|
||||
<Bot size={23} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>好用的知识,从不必整套加载。</h2>
|
||||
<p>
|
||||
每篇文档都有适用条件和固定版本。需要更多时,再沿着有用的链接继续阅读。
|
||||
</p>
|
||||
</div>
|
||||
<div className={s.introDiagram} aria-hidden="true">
|
||||
<span>
|
||||
<FileText size={13} />
|
||||
一篇文档
|
||||
</span>
|
||||
<ArrowRight size={15} />
|
||||
<span>
|
||||
<FileText size={13} />
|
||||
下一页
|
||||
</span>
|
||||
</div>
|
||||
<Link className={s.textLink} href="/protocol">
|
||||
了解工作原理
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</section>
|
||||
<div className={s.topics}>
|
||||
<span>试试这些任务</span>
|
||||
{["代码审查", "规划", "验证"].map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t}
|
||||
onClick={() => {
|
||||
setQ(t);
|
||||
remember({ q: t });
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mine && !account.loading && !account.user ? (
|
||||
<section className={s.panel}>
|
||||
{account.error && (
|
||||
<div className={s.panelBody}>
|
||||
<Notice tone="error">
|
||||
{account.error}{" "}
|
||||
<button type="button" onClick={() => void account.refresh()}>
|
||||
重新检查
|
||||
</button>
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
<EmptyState
|
||||
title="登录后,管理你的知识"
|
||||
description="使用已验证的邮箱账户发布文档、更新版本,并随时回到这里继续编辑。"
|
||||
>
|
||||
<Link
|
||||
className={s.primaryButton}
|
||||
href="/account?next=%2Fdocuments%3Fscope%3Dmine"
|
||||
>
|
||||
登录 / 创建账户
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
</EmptyState>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className={s.panel} aria-label="文档目录">
|
||||
<Tabs
|
||||
tabs={kinds.map((k) => ({
|
||||
id: k,
|
||||
label: k === "all" ? "全部文档" : kindNames[k],
|
||||
count: found.filter((d) => k === "all" || d.kind === k).length,
|
||||
}))}
|
||||
value={kind}
|
||||
onChange={(value) => {
|
||||
setKind(value);
|
||||
remember({ kind: value });
|
||||
}}
|
||||
/>
|
||||
<div className={s.catalogToolbar}>
|
||||
<form className={s.catalogSearch} role="search" onSubmit={search}>
|
||||
<Search size={16} />
|
||||
<input
|
||||
aria-label="搜索文档"
|
||||
placeholder="搜索标题、摘要或文档 ID…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="清空关键词"
|
||||
onClick={() => {
|
||||
setQ("");
|
||||
remember({ q: "" });
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" aria-label="提交搜索">
|
||||
<ArrowRight size={15} />
|
||||
</button>
|
||||
</form>
|
||||
<div className={s.catalogTools}>
|
||||
{!communityOnly && (
|
||||
<select
|
||||
aria-label="文档来源"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as Source;
|
||||
setSource(value);
|
||||
remember({ source: value });
|
||||
}}
|
||||
>
|
||||
<option value="all">全部来源</option>
|
||||
<option value="published">社区发布</option>
|
||||
<option value="examples">协议样例</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
aria-label="文档排序"
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
setSort(e.target.value);
|
||||
remember({ sort: e.target.value });
|
||||
}}
|
||||
>
|
||||
<option value="default">默认顺序</option>
|
||||
<option value="title">按标题排序</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.catalogStatus}>
|
||||
<p role="status">
|
||||
{waiting
|
||||
? "正在加载文档…"
|
||||
: error
|
||||
? `当前显示 ${documents.length} 篇,社区目录加载失败`
|
||||
: `找到 ${documents.length} 篇文档`}
|
||||
</p>
|
||||
{filtered ? (
|
||||
<button type="button" aria-label="清除所有筛选" onClick={clear}>
|
||||
清除所有筛选
|
||||
</button>
|
||||
) : (
|
||||
<span>最多展示最近 100 篇社区文档</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className={s.panelBody}>
|
||||
<Notice tone="error">
|
||||
{error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className={s.textLink}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
<div className={s.tableHeader} aria-hidden="true">
|
||||
<span>文档</span>
|
||||
<span>类型</span>
|
||||
<span>版本</span>
|
||||
<span className={s.tableSource}>来源</span>
|
||||
<span />
|
||||
</div>
|
||||
{waiting && (!documents.length || communityOnly) ? (
|
||||
<div aria-label="正在加载文档" aria-busy="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div className={s.skeletonRow} key={i}>
|
||||
<div className={s.skeletonBox} />
|
||||
<div className={s.skeletonLines}>
|
||||
<div className={s.skeletonLine} />
|
||||
<div className={s.skeletonLine} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
documents.map((d) => (
|
||||
<Link
|
||||
href={`${d.source === "example" ? "/guide/" : "/documents/"}${d.id}?revision=${d.revision}`}
|
||||
key={d.id}
|
||||
className={s.documentRow}
|
||||
>
|
||||
<div className={s.documentPrimary}>
|
||||
<span className={s.documentIcon}>
|
||||
<KindIcon kind={d.kind} />
|
||||
</span>
|
||||
<div className={s.documentDetails}>
|
||||
<h2 className={s.documentTitle}>{d.title}</h2>
|
||||
<p className={s.documentSummary}>{d.summary}</p>
|
||||
<p className={s.documentId} title={d.id}>
|
||||
{d.id}
|
||||
</p>
|
||||
<div className={s.documentMobileMeta}>
|
||||
<KindBadge kind={d.kind} />
|
||||
<span>v{d.revision}</span>
|
||||
<span>
|
||||
{d.source === "example" ? "协议样例" : "社区发布"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={s.documentKind}>
|
||||
<KindBadge kind={d.kind} />
|
||||
</span>
|
||||
<span className={s.documentVersion}>v{d.revision}</span>
|
||||
<span className={s.documentSource}>
|
||||
{d.source === "example" ? "协议样例" : "社区发布"}
|
||||
</span>
|
||||
<ChevronRight size={16} className={s.documentArrow} />
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
{!waiting && !documents.length && !error && (
|
||||
<EmptyState
|
||||
title={
|
||||
filtered
|
||||
? "没有匹配文档"
|
||||
: mine
|
||||
? "你的第一篇文档,从这里开始"
|
||||
: "这里还没有社区文档"
|
||||
}
|
||||
description={
|
||||
filtered
|
||||
? "试试其他关键词或清除筛选,发现更多适合当前任务的知识。"
|
||||
: mine
|
||||
? "把你反复使用的方法写下来,让 Agent 在需要时找到它。"
|
||||
: "你可以先阅读协议样例,也可以发布第一篇独立文档。"
|
||||
}
|
||||
>
|
||||
{filtered ? (
|
||||
<button
|
||||
type="button"
|
||||
className={s.secondaryButton}
|
||||
onClick={clear}
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
) : (
|
||||
<Link className={s.primaryButton} href="/write">
|
||||
<Plus size={15} />
|
||||
发布一篇文档
|
||||
</Link>
|
||||
)}
|
||||
</EmptyState>
|
||||
)}
|
||||
</section>
|
||||
<div className={s.catalogFootnote}>
|
||||
<span>
|
||||
{mine
|
||||
? "只有你可以更新自己的文档,已发布的历史版本保持不变。"
|
||||
: "社区文档与协议样例分别标识,采用前请确认适用条件与版本。"}
|
||||
</span>
|
||||
<Link href="/protocol">
|
||||
了解读取规范
|
||||
<ArrowUpRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
Braces,
|
||||
CalendarDays,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
ListChecks,
|
||||
Link2,
|
||||
Pencil,
|
||||
Target,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import GuidanceShell from "@/components/GuidanceShell";
|
||||
import {
|
||||
CopyButton,
|
||||
KindBadge,
|
||||
Notice,
|
||||
Tabs,
|
||||
kindNames,
|
||||
} from "@/components/GuidanceUI";
|
||||
import { guidanceXml, type Guidance } from "@/content/guides/catalog";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
|
||||
export type ReadingVersion = { revision: number; createdAt?: string };
|
||||
export type ReadingLink = Guidance["links"][number] & { resolved: boolean };
|
||||
export type ReaderProps = {
|
||||
document: Guidance;
|
||||
source: "example" | "community";
|
||||
links: ReadingLink[];
|
||||
versions: ReadingVersion[];
|
||||
backlinks?: {
|
||||
id: string;
|
||||
revision?: number;
|
||||
title: string;
|
||||
source?: "example" | "community";
|
||||
}[];
|
||||
canEdit?: boolean;
|
||||
withdrawn?: boolean;
|
||||
};
|
||||
const relationNames = {
|
||||
guide: "可选指导",
|
||||
context: "背景参考",
|
||||
evidence: "事实依据",
|
||||
};
|
||||
function dateLabel(value?: string) {
|
||||
return value
|
||||
? new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(value))
|
||||
: "协议样例";
|
||||
}
|
||||
export default function GuidanceReader({
|
||||
document: doc,
|
||||
source,
|
||||
links,
|
||||
versions,
|
||||
backlinks = [],
|
||||
canEdit = false,
|
||||
withdrawn = false,
|
||||
}: ReaderProps) {
|
||||
const router = useRouter(),
|
||||
dialog = useRef<HTMLDialogElement>(null);
|
||||
const [tab, setTab] = useState("overview"),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[isWithdrawn, setWithdrawn] = useState(withdrawn);
|
||||
useEffect(() => {
|
||||
setWithdrawn(withdrawn);
|
||||
setError("");
|
||||
setTab("overview");
|
||||
}, [doc.id, withdrawn]);
|
||||
const api = `/api/${source === "example" ? "guides" : "documents"}/${doc.id}?revision=${doc.revision}&format=xml`;
|
||||
const current = versions.find((v) => v.revision === doc.revision),
|
||||
xml = guidanceXml(doc);
|
||||
const route = source === "example" ? "/guide/" : "/documents/";
|
||||
const sectionLinks = [
|
||||
["applies", "适用条件"],
|
||||
["content", "指导正文"],
|
||||
...(doc.steps.length ? [["steps", "建议步骤"]] : []),
|
||||
...(doc.checks.length ? [["checks", "完成检查"]] : []),
|
||||
["related", "条件链接"],
|
||||
];
|
||||
async function withdraw() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/documents/" + doc.id, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw Error(body.error || "撤下失败,请稍后重试");
|
||||
setWithdrawn(true);
|
||||
dialog.current?.close();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "撤下失败,请稍后重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<GuidanceShell title="文档详情">
|
||||
<header className={s.readingHeader}>
|
||||
<Link
|
||||
className={s.backLink}
|
||||
href={source === "example" ? "/" : "/documents"}
|
||||
>
|
||||
<ArrowLeft size={13} />
|
||||
{source === "example" ? "返回探索文档" : "返回社区文档"}
|
||||
</Link>
|
||||
<div className={s.documentBadges}>
|
||||
<KindBadge kind={doc.kind} />
|
||||
<span className={s.neutralBadge}>
|
||||
{source === "example" ? "协议样例" : "社区发布"}
|
||||
</span>
|
||||
<span className={s.neutralBadge}>v{doc.revision}</span>
|
||||
{isWithdrawn && (
|
||||
<span className={s.neutralBadge}>已撤下 · 历史版本</span>
|
||||
)}
|
||||
</div>
|
||||
<h1>{doc.title}</h1>
|
||||
<p className={s.readingSummary}>{doc.summary}</p>
|
||||
<div className={s.readingMeta}>
|
||||
<span>
|
||||
<Globe2 size={13} />
|
||||
公开文档
|
||||
</span>
|
||||
<span>
|
||||
<GitBranch size={13} />
|
||||
固定版本 {doc.revision}
|
||||
</span>
|
||||
{current?.createdAt && (
|
||||
<span>
|
||||
<CalendarDays size={13} />
|
||||
{dateLabel(current.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.readingActions}>
|
||||
<CopyButton
|
||||
label="复制 Agent 读取地址"
|
||||
value={() => window.location.origin + api}
|
||||
/>
|
||||
<a
|
||||
href={api}
|
||||
className={s.secondaryButton}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Braces size={15} />
|
||||
读取规范 XML
|
||||
<ArrowUpRight size={13} />
|
||||
</a>
|
||||
{canEdit && !isWithdrawn && (
|
||||
<Link className={s.secondaryButton} href={"/write?id=" + doc.id}>
|
||||
<Pencil size={14} />
|
||||
编辑最新版本
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isWithdrawn && (
|
||||
<Notice tone="warning">
|
||||
这篇文档已从公开目录撤下。你正在阅读固定历史版本,已有引用仍可继续使用。
|
||||
</Notice>
|
||||
)}
|
||||
<div className={s.readingLayout}>
|
||||
<section className={s.panel}>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: "overview", label: "文档概览" },
|
||||
{ id: "xml", label: "XML 源文档" },
|
||||
{ id: "versions", label: "版本记录", count: versions.length },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === "overview" ? (
|
||||
<article className={s.readingArticle}>
|
||||
<section
|
||||
id="applies"
|
||||
className={`${s.proseSection} ${s.appliesCard}`}
|
||||
>
|
||||
<h2>
|
||||
<Target size={16} />
|
||||
何时使用这篇文档
|
||||
</h2>
|
||||
<p>{doc.applies}</p>
|
||||
</section>
|
||||
<section id="content" className={s.proseSection}>
|
||||
<h2>
|
||||
<FileText size={16} />
|
||||
指导正文
|
||||
</h2>
|
||||
<div className={s.proseBody}>{doc.body}</div>
|
||||
</section>
|
||||
{!!doc.steps.length && (
|
||||
<section id="steps" className={s.proseSection}>
|
||||
<h2>
|
||||
<ListChecks size={17} />
|
||||
建议步骤
|
||||
</h2>
|
||||
<ol className={s.stepList}>
|
||||
{doc.steps.map((step, index) => (
|
||||
<li key={index}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{!!doc.checks.length && (
|
||||
<section id="checks" className={s.proseSection}>
|
||||
<h2>
|
||||
<CheckSquare size={16} />
|
||||
完成检查
|
||||
</h2>
|
||||
<ul className={s.checkList}>
|
||||
{doc.checks.map((check, index) => (
|
||||
<li key={index}>{check}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
<section id="related" className={s.proseSection}>
|
||||
<h2>
|
||||
<Link2 size={16} />
|
||||
按需继续阅读
|
||||
<span className={s.neutralBadge}>{links.length}</span>
|
||||
</h2>
|
||||
<p className={s.formDescription}>
|
||||
只在条件适用、且当前任务需要时,打开下一篇文档。
|
||||
</p>
|
||||
{links.map((link, index) => {
|
||||
const content = (
|
||||
<>
|
||||
<div>
|
||||
<strong>{link.label}</strong>
|
||||
<p>当{link.when}</p>
|
||||
<small>
|
||||
<span>{relationNames[link.relation]}</span>
|
||||
<span>·</span>
|
||||
<span>v{link.revision}</span>
|
||||
{!link.resolved && (
|
||||
<span>· 固定版本缺失,请跳过</span>
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
{link.resolved && <ArrowUpRight size={15} />}
|
||||
</>
|
||||
);
|
||||
return link.resolved ? (
|
||||
<Link
|
||||
className={s.relationCard}
|
||||
key={index}
|
||||
href={
|
||||
(link.target.startsWith("user-")
|
||||
? "/documents/"
|
||||
: "/guide/") +
|
||||
link.target +
|
||||
"?revision=" +
|
||||
link.revision
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={`${s.relationCard} ${s.unresolved}`}
|
||||
key={index}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!links.length && (
|
||||
<p className={s.proseBody}>
|
||||
这篇文档可以独立使用,没有需要继续读取的关联文档。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</article>
|
||||
) : tab === "xml" ? (
|
||||
<>
|
||||
<div className={s.xmlToolbar}>
|
||||
<span>{doc.id}.xml</span>
|
||||
<CopyButton value={xml} label="复制 XML" />
|
||||
</div>
|
||||
<pre className={s.codeBlock}>{xml}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className={s.versionsList}>
|
||||
{versions.map((version) => (
|
||||
<div className={s.versionRow} key={version.revision}>
|
||||
<div>
|
||||
<div className={s.buttonRow}>
|
||||
<strong>版本 {version.revision}</strong>
|
||||
{version.revision === doc.revision && (
|
||||
<span className={s.neutralBadge}>当前阅读</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{dateLabel(version.createdAt)} · 固定版本</p>
|
||||
</div>
|
||||
<Link
|
||||
className={s.textLink}
|
||||
href={route + doc.id + "?revision=" + version.revision}
|
||||
>
|
||||
读取
|
||||
<ChevronRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<aside className={s.readingSidebar}>
|
||||
<section className={s.sideCard}>
|
||||
<h3>文档信息</h3>
|
||||
<dl className={s.metadataList}>
|
||||
<div>
|
||||
<dt>类型</dt>
|
||||
<dd>{kindNames[doc.kind]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>来源</dt>
|
||||
<dd>
|
||||
{source === "example"
|
||||
? "织页协议样例"
|
||||
: canEdit
|
||||
? "你的文档"
|
||||
: "社区作者"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>当前版本</dt>
|
||||
<dd>
|
||||
<select
|
||||
aria-label="选择阅读版本"
|
||||
value={doc.revision}
|
||||
onChange={(e) =>
|
||||
void router.push(
|
||||
route + doc.id + "?revision=" + e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{versions.map((v) => (
|
||||
<option key={v.revision} value={v.revision}>
|
||||
v{v.revision}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>可见范围</dt>
|
||||
<dd>{isWithdrawn ? "仅固定历史版本" : "公开读取"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<code className={s.machineAddress}>{doc.id}</code>
|
||||
{canEdit && !isWithdrawn && (
|
||||
<div className={s.authorActions}>
|
||||
<Link
|
||||
className={s.secondaryButton}
|
||||
href={"/write?id=" + doc.id}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
编辑文档
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={s.quietButton}
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
撤下文档
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={`${s.sideCard} ${s.tocCard}`}>
|
||||
<h3>本篇目录</h3>
|
||||
<nav className={s.toc} aria-label="本篇目录">
|
||||
{sectionLinks.map(([id, label]) => (
|
||||
<a key={id} href={"#" + id} onClick={() => setTab("overview")}>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
<section className={s.sideCard}>
|
||||
<h3>
|
||||
<BookOpen size={15} /> 采用这一篇
|
||||
</h3>
|
||||
<p>
|
||||
把固定版本的读取地址交给 Agent。关联文档是候选帮助,无需一起加载。
|
||||
</p>
|
||||
<code className={s.machineAddress}>{api}</code>
|
||||
<CopyButton
|
||||
label="复制读取地址"
|
||||
value={() => window.location.origin + api}
|
||||
/>
|
||||
</section>
|
||||
{!!backlinks.length && (
|
||||
<section className={s.sideCard}>
|
||||
<h3>被这些文档引用</h3>
|
||||
<div className={s.sideLinks}>
|
||||
{backlinks.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={
|
||||
(b.source === "community" ? "/documents/" : "/guide/") +
|
||||
b.id +
|
||||
(b.revision ? "?revision=" + b.revision : "")
|
||||
}
|
||||
>
|
||||
{b.title}
|
||||
<ArrowUpRight size={11} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<dialog ref={dialog} className={s.modal} aria-labelledby="withdraw-title">
|
||||
<div className={s.modalBody}>
|
||||
<h2 id="withdraw-title">撤下这篇文档?</h2>
|
||||
<p>
|
||||
它将不再出现在目录中。已发布的固定历史版本会保留,已有引用仍然可以访问。
|
||||
</p>
|
||||
{error && <Notice tone="error">{error}</Notice>}
|
||||
</div>
|
||||
<div className={s.modalActions}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className={s.secondaryButton}
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className={s.dangerButton}
|
||||
onClick={() => void withdraw()}
|
||||
>
|
||||
{busy ? "正在撤下…" : "确认撤下"}
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</GuidanceShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,265 @@
|
||||
import Link from 'next/link';
|
||||
import type {ReactNode} from 'react';
|
||||
import s from '@/styles/guidance.module.css';
|
||||
export default function GuidanceShell({children}:{children:ReactNode}){return <div className={s.site}><header className={s.header}><Link className={s.brand} href="/">↗ 织页 <small>实验版</small></Link><nav><Link href="/">探索文档</Link><Link href="/documents">用户文档</Link><Link href="/write">发布文档</Link><Link href="/protocol">协议草案</Link></nav><Link className={s.account} href="/account">登录 / 注册 ↗</Link></header>{children}<footer className={s.footer}><span>织页 · Linked Guidance · 名称与协议仍在探索中</span><Link href="/protocol">让每一页都能成为起点 ↗</Link></footer></div>}
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Files,
|
||||
Globe2,
|
||||
LogIn,
|
||||
Menu,
|
||||
Plus,
|
||||
SquarePen,
|
||||
UserRound,
|
||||
Waypoints,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
|
||||
export type AccountUser = { id: string; email: string; verified_at: string };
|
||||
type AccountState = {
|
||||
user: AccountUser | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
const AccountContext = createContext<AccountState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
refresh: async () => {},
|
||||
});
|
||||
export const useGuidanceAccount = () => useContext(AccountContext);
|
||||
|
||||
export default function GuidanceShell({
|
||||
children,
|
||||
title = "探索文档",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [user, setUser] = useState<AccountUser | null>(null),
|
||||
[loading, setLoading] = useState(true),
|
||||
[error, setError] = useState("");
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await fetch("/api/account/session");
|
||||
const b = await r.json();
|
||||
if (!r.ok) throw Error(b.error || "账户暂时不可用");
|
||||
setUser(b.user || null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "账户暂时不可用");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
useEffect(() => {
|
||||
dialog.current?.close();
|
||||
}, [router.pathname, router.query.scope]);
|
||||
const mine = router.query.scope === "mine";
|
||||
const nav = [
|
||||
{
|
||||
href: "/",
|
||||
label: "探索文档",
|
||||
Icon: Compass,
|
||||
active: router.pathname === "/" || router.pathname.startsWith("/guide/"),
|
||||
},
|
||||
{
|
||||
href: "/documents",
|
||||
label: "社区文档",
|
||||
Icon: Files,
|
||||
active: router.pathname.startsWith("/documents") && !mine,
|
||||
},
|
||||
{
|
||||
href: "/documents?scope=mine",
|
||||
label: "我的文档",
|
||||
Icon: BookOpen,
|
||||
active: router.pathname === "/documents" && mine,
|
||||
},
|
||||
{
|
||||
href: "/write",
|
||||
label: "发布文档",
|
||||
Icon: SquarePen,
|
||||
active: router.pathname === "/write",
|
||||
},
|
||||
];
|
||||
function navigation(mobile = false) {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
className={s.brand}
|
||||
href="/"
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
<span className={s.brandMark}>
|
||||
<Waypoints size={22} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span>
|
||||
织页<small>KNOWLEDGE FOR AGENTS</small>
|
||||
</span>
|
||||
</Link>
|
||||
<div className={s.workspaceLabel}>
|
||||
<Globe2 size={16} />
|
||||
<span>公共知识空间</span>
|
||||
<span className={s.workspaceTag}>Public</span>
|
||||
</div>
|
||||
<p className={s.navLabel}>工作空间</p>
|
||||
<nav
|
||||
aria-label={mobile ? "移动端主导航" : "主导航"}
|
||||
className={s.navigation}
|
||||
>
|
||||
{nav.map(({ href, label, Icon, active }) => (
|
||||
<Link
|
||||
href={href}
|
||||
key={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={active ? s.navActive : ""}
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<p className={s.navLabel}>资源</p>
|
||||
<nav
|
||||
aria-label={mobile ? "移动端资源" : "资源导航"}
|
||||
className={s.navigation}
|
||||
>
|
||||
<Link
|
||||
href="/protocol"
|
||||
aria-current={router.pathname === "/protocol" ? "page" : undefined}
|
||||
className={router.pathname === "/protocol" ? s.navActive : ""}
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
<BookOpen size={18} />
|
||||
<span>文档协议</span>
|
||||
</Link>
|
||||
<a href="/agent/index.md" target="_blank" rel="noreferrer">
|
||||
<Bot size={18} />
|
||||
<span>Agent 读取入口</span>
|
||||
<ArrowUpRight size={14} className={s.navExternal} />
|
||||
</a>
|
||||
</nav>
|
||||
<div className={s.sidebarBottom}>
|
||||
<div className={s.sidebarHelp}>
|
||||
<span className={s.smallLabel}>从一篇文档开始</span>
|
||||
<p>选择适用的指导,需要更多背景时,再沿链接继续阅读。</p>
|
||||
<Link href="/protocol">
|
||||
了解使用方法 <ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/account" className={s.userArea}>
|
||||
<span className={s.avatar}>
|
||||
{user ? (
|
||||
user.email.slice(0, 1).toUpperCase()
|
||||
) : (
|
||||
<UserRound size={18} />
|
||||
)}
|
||||
</span>
|
||||
<span className={s.userInfo}>
|
||||
<strong>
|
||||
{user ? user.email : loading ? "正在检查账户…" : "登录你的账户"}
|
||||
</strong>
|
||||
<small>
|
||||
{user ? "管理与发布你的文档" : "邮箱登录 · 开始发布"}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={s.site}>
|
||||
<Head>
|
||||
<title>{`${title} · 织页`}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="为 Agent 编写、发现和连接可独立使用的指导文档。"
|
||||
/>
|
||||
</Head>
|
||||
<a className={s.skipLink} href="#workspace-content">
|
||||
跳到正文
|
||||
</a>
|
||||
<aside className={s.sidebar}>{navigation()}</aside>
|
||||
<dialog ref={dialog} className={s.mobileDialog} aria-label="工作区导航">
|
||||
<button
|
||||
type="button"
|
||||
className={s.mobileClose}
|
||||
aria-label="关闭导航"
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{navigation(true)}
|
||||
</dialog>
|
||||
<div className={s.workspace}>
|
||||
<header className={s.topbar}>
|
||||
<div className={s.breadcrumb}>
|
||||
<button
|
||||
type="button"
|
||||
className={s.mobileMenu}
|
||||
aria-label="打开导航"
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<span className={s.breadcrumbSpace}>工作空间</span>
|
||||
<ChevronRight size={14} />
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
<div className={s.topbarActions}>
|
||||
<Link className={s.topbarProtocol} href="/protocol">
|
||||
<span className={s.statusDot} />
|
||||
Linked Guidance 0.1
|
||||
</Link>
|
||||
{router.pathname !== "/write" && (
|
||||
<Link className={s.primaryButton} href="/write">
|
||||
<Plus size={16} />
|
||||
创建文档
|
||||
</Link>
|
||||
)}
|
||||
{!user && !loading && (
|
||||
<Link className={s.topbarLogin} href="/account">
|
||||
<LogIn size={16} />
|
||||
<span>登录</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className={s.content} id="workspace-content">
|
||||
{children}
|
||||
</main>
|
||||
<footer className={s.workspaceFooter}>
|
||||
<span>织页 · 让知识按需连接</span>
|
||||
<Link href="/protocol">
|
||||
文档协议 <ArrowUpRight size={12} />
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import type { Guidance } from "@/content/guides/catalog";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
export const kindNames: Record<Guidance["kind"], string> = {
|
||||
guide: "操作指导",
|
||||
context: "背景知识",
|
||||
evidence: "事实依据",
|
||||
};
|
||||
export function KindIcon({
|
||||
kind,
|
||||
size = 18,
|
||||
}: {
|
||||
kind: Guidance["kind"];
|
||||
size?: number;
|
||||
}) {
|
||||
const Icon =
|
||||
kind === "guide" ? FileText : kind === "context" ? BookOpen : FileCheck2;
|
||||
return <Icon size={size} aria-hidden="true" />;
|
||||
}
|
||||
export function KindBadge({ kind }: { kind: Guidance["kind"] }) {
|
||||
return (
|
||||
<span className={`${s.kindBadge} ${s[kind]}`}>
|
||||
<KindIcon kind={kind} size={13} />
|
||||
{kindNames[kind]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = "复制地址",
|
||||
className = "",
|
||||
}: {
|
||||
value: string | (() => string);
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [state, setState] = useState<"idle" | "copied" | "error">("idle");
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
typeof value === "function" ? value() : value,
|
||||
);
|
||||
setState("copied");
|
||||
} catch {
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className={s.copyControl}>
|
||||
<button
|
||||
type="button"
|
||||
className={className || s.secondaryButton}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{state === "copied" ? <Check size={15} /> : <Copy size={15} />}
|
||||
<span>{state === "copied" ? "已复制" : label}</span>
|
||||
</button>
|
||||
<span className={s.srOnly} role="status">
|
||||
{state === "error"
|
||||
? "无法自动复制,请选择下方地址手动复制。"
|
||||
: state === "copied"
|
||||
? "已复制到剪贴板。"
|
||||
: ""}
|
||||
</span>
|
||||
{state === "error" && (
|
||||
<small className={s.copyError}>请手动复制下方地址</small>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
export function Notice({
|
||||
children,
|
||||
tone = "info",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "info" | "success" | "error" | "warning";
|
||||
}) {
|
||||
const Icon =
|
||||
tone === "error" || tone === "warning"
|
||||
? TriangleAlert
|
||||
: tone === "success"
|
||||
? CheckCircle2
|
||||
: Info;
|
||||
return (
|
||||
<div
|
||||
className={`${s.notice} ${s["notice" + tone]}`}
|
||||
role={tone === "error" ? "alert" : "status"}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function PageHeading({
|
||||
title,
|
||||
description,
|
||||
eyebrow,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
eyebrow?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className={s.pageHeading}>
|
||||
<div>
|
||||
{eyebrow && <span className={s.eyebrow}>{eyebrow}</span>}
|
||||
<h1>{title}</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{children && <div className={s.headingActions}>{children}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.emptyState}>
|
||||
<span className={s.emptyIcon}>
|
||||
<BookOpen size={24} />
|
||||
</span>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function Tabs({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: { id: string; label: string; count?: number }[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
const group = useId(),
|
||||
refs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
return (
|
||||
<div role="tablist" aria-label="内容视图" className={s.tabs}>
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
refs.current[index] = el;
|
||||
}}
|
||||
id={`${group}-${tab.id}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={value === tab.id}
|
||||
tabIndex={value === tab.id ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
onKeyDown={(e) => {
|
||||
let next = index;
|
||||
if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
|
||||
else if (e.key === "ArrowLeft")
|
||||
next = (index - 1 + tabs.length) % tabs.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else if (e.key === "End") next = tabs.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
onChange(tabs[next].id);
|
||||
refs.current[next]?.focus();
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && <span>{tab.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,55 @@
|
||||
import { db,transaction } from './db';
|
||||
import { documentInput } from './guidance-input';
|
||||
import { findGuide,type Guidance } from '../content/guides/catalog';
|
||||
export async function resolveDocument(id:string,revision?:number):Promise<Guidance|null>{
|
||||
if(!id.startsWith('user-'))return findGuide(id,revision)||null;
|
||||
const r=await db().query(`SELECT r.content FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=COALESCE($2,d.latest) WHERE d.id=$1 AND ($2::integer IS NOT NULL OR d.deleted_at IS NULL)`,[id,revision??null]);return r.rows[0]?.content||null;
|
||||
import { db, transaction } from "./db";
|
||||
import { documentInput } from "./guidance-input";
|
||||
import { findGuide, type Guidance } from "../content/guides/catalog";
|
||||
export async function resolveDocument(
|
||||
id: string,
|
||||
revision?: number,
|
||||
): Promise<Guidance | null> {
|
||||
if (!id.startsWith("user-")) return findGuide(id, revision) || null;
|
||||
const r = await db().query(
|
||||
`SELECT r.content FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=COALESCE($2,d.latest) WHERE d.id=$1 AND ($2::integer IS NOT NULL OR d.deleted_at IS NULL)`,
|
||||
[id, revision ?? null],
|
||||
);
|
||||
return r.rows[0]?.content || null;
|
||||
}
|
||||
export async function publishDocument(owner: string, value: unknown) {
|
||||
const content = documentInput.parse(value);
|
||||
if (!content.id.startsWith("user-" + owner + "-")) throw Error("INVALID_ID");
|
||||
return transaction(async (c) => {
|
||||
await c.query(
|
||||
"INSERT INTO guidance_documents(id,owner_id) VALUES($1,$2) ON CONFLICT DO NOTHING",
|
||||
[content.id, owner],
|
||||
);
|
||||
const d = (
|
||||
await c.query("SELECT * FROM guidance_documents WHERE id=$1 FOR UPDATE", [
|
||||
content.id,
|
||||
])
|
||||
).rows[0];
|
||||
if (d.owner_id !== owner) throw Error("FORBIDDEN");
|
||||
if (d.deleted_at || content.revision !== d.latest + 1)
|
||||
throw Error("CONFLICT");
|
||||
await c.query(
|
||||
"INSERT INTO guidance_revisions(document_id,revision,content) VALUES($1,$2,$3)",
|
||||
[content.id, content.revision, content],
|
||||
);
|
||||
await c.query("UPDATE guidance_documents SET latest=$2 WHERE id=$1", [
|
||||
content.id,
|
||||
content.revision,
|
||||
]);
|
||||
return content;
|
||||
});
|
||||
}
|
||||
export async function withdrawDocument(owner: string, id: string) {
|
||||
const r = await db().query(
|
||||
"UPDATE guidance_documents SET deleted_at=COALESCE(deleted_at,now()) WHERE id=$1 AND owner_id=$2 RETURNING id",
|
||||
[id, owner],
|
||||
);
|
||||
return !!r.rowCount;
|
||||
}
|
||||
export async function discoverDocuments(author?: string) {
|
||||
const r = await db().query(
|
||||
`SELECT d.id,d.latest AS revision,r.content->>'title' AS title,r.content->>'summary' AS summary,r.content->>'kind' AS kind,d.owner_id AS author FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=d.latest WHERE d.deleted_at IS NULL AND ($1::uuid IS NULL OR d.owner_id=$1::uuid) ORDER BY r.created_at DESC LIMIT 100`,
|
||||
[author ?? null],
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
export async function publishDocument(owner:string,value:unknown){const content=documentInput.parse(value);if(!content.id.startsWith('user-'+owner+'-'))throw Error('INVALID_ID');return transaction(async c=>{
|
||||
await c.query('INSERT INTO guidance_documents(id,owner_id) VALUES($1,$2) ON CONFLICT DO NOTHING',[content.id,owner]);
|
||||
const d=(await c.query('SELECT * FROM guidance_documents WHERE id=$1 FOR UPDATE',[content.id])).rows[0];
|
||||
if(d.owner_id!==owner)throw Error('FORBIDDEN');if(d.deleted_at||content.revision!==d.latest+1)throw Error('CONFLICT');
|
||||
await c.query('INSERT INTO guidance_revisions(document_id,revision,content) VALUES($1,$2,$3)',[content.id,content.revision,content]);await c.query('UPDATE guidance_documents SET latest=$2 WHERE id=$1',[content.id,content.revision]);return content;
|
||||
});}
|
||||
export async function withdrawDocument(owner:string,id:string){const r=await db().query('UPDATE guidance_documents SET deleted_at=COALESCE(deleted_at,now()) WHERE id=$1 AND owner_id=$2 RETURNING id',[id,owner]);return !!r.rowCount;}
|
||||
export async function discoverDocuments(){const r=await db().query(`SELECT d.id,d.latest AS revision,r.content->>'title' AS title,r.content->>'summary' AS summary,r.content->>'kind' AS kind,d.owner_id AS author FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=d.latest WHERE d.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 100`);return r.rows;}
|
||||
|
||||
@@ -1,24 +1,360 @@
|
||||
'use client';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
export default function AccountPage() {
|
||||
const [mode,setMode]=useState('login');const [user,setUser]=useState<{email:string}|null>(null);
|
||||
const [message,setMessage]=useState('');const [busy,setBusy]=useState(false);const [token,setToken]=useState('');
|
||||
async function refresh(){try{const r=await fetch('/api/account/session');const data=await r.json();if(r.ok)setUser(data.user);else setMessage(data.error);}catch{setMessage('暂时无法连接账户服务');}}
|
||||
useEffect(()=>{void refresh();const u=new URL(window.location.href);setToken(u.searchParams.get('verify')||'');if(u.searchParams.has('verify'))window.history.replaceState({},'', '/account');},[]);
|
||||
async function action(name:string,body:object={}) {setBusy(true);setMessage('');try{const r=await fetch('/api/account/'+name,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();setMessage(d.error||d.message||'操作成功');if(r.ok){if(name==='verify')setToken('');await refresh();}}catch{setMessage('网络异常,请稍后重试');}finally{setBusy(false);}}
|
||||
function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const f=new FormData(e.currentTarget);void action(mode,{email:f.get('email'),password:f.get('password')});}
|
||||
const button={padding:'12px 20px',border:'1px solid #394b43',borderRadius:12,cursor:'pointer',background:'#173d30',color:'white'};
|
||||
return <main style={{maxWidth:540,margin:'8vh auto',padding:28,fontFamily:'system-ui',color:'#18382b'}}>
|
||||
<a href="/">← 返回知识站</a><h1 style={{fontSize:36,marginTop:32}}>你的知识站账户</h1>
|
||||
<p>用邮箱保存你的身份。邮箱账户与管理员动态码权限独立。</p>
|
||||
{token&&<section style={{padding:20,background:'#f1f5ef',borderRadius:16}}><h2>确认邮箱验证</h2><p>点击后完成验证,再使用邮箱和密码登录。</p><button disabled={busy} style={button} onClick={()=>void action('verify',{token})}>确认验证邮箱</button></section>}
|
||||
{user?<section><h2>已登录</h2><p>{user.email}</p><p>邮箱已验证</p><button style={button} disabled={busy} onClick={()=>void action('logout')}>退出登录</button></section>:<>
|
||||
<nav style={{display:'flex',gap:10,margin:'24px 0'}}>{[['login','登录'],['register','注册'],['resend','重发验证邮件']].map(([v,label])=><button key={v} disabled={busy} aria-pressed={mode===v} style={{...button,background:mode===v?'#173d30':'#65776c'}} onClick={()=>{setMode(v);setMessage('');}}>{label}</button>)}</nav>
|
||||
<form onSubmit={submit} style={{display:'grid',gap:16}}>
|
||||
<label>邮箱<input style={{display:'block',width:'100%',padding:12,border:'1px solid #b4bfb7',borderRadius:8}} required type="email" name="email" maxLength={254} autoComplete="email" /></label>
|
||||
<label>密码<input style={{display:'block',width:'100%',padding:12,border:'1px solid #b4bfb7',borderRadius:8}} required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'} /></label>
|
||||
<small>密码至少 12 个字符。注册后需先验证邮箱;验证链接有效期 30 分钟。</small>
|
||||
<button style={button} disabled={busy} type="submit">{busy?'处理中…':mode==='login'?'登录':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}
|
||||
<p role="status" style={{lineHeight:1.7,marginTop:24}}>{message}</p>
|
||||
</main>;
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GitBranch,
|
||||
Link2,
|
||||
LogOut,
|
||||
MailCheck,
|
||||
Waypoints,
|
||||
} from "lucide-react";
|
||||
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
|
||||
import { Notice } from "@/components/GuidanceUI";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
type Mode = "login" | "register" | "resend";
|
||||
function localNext(value: string | string[] | undefined) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
/[\\\u0000-\u001f]/.test(value)
|
||||
)
|
||||
return "/write";
|
||||
try {
|
||||
const url = new URL(value, "https://local.invalid");
|
||||
return url.origin === "https://local.invalid"
|
||||
? url.pathname + url.search + url.hash
|
||||
: "/write";
|
||||
} catch {
|
||||
return "/write";
|
||||
}
|
||||
}
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<GuidanceShell title="邮箱账户">
|
||||
<AccountContent />
|
||||
</GuidanceShell>
|
||||
);
|
||||
}
|
||||
function AccountContent() {
|
||||
const account = useGuidanceAccount(),
|
||||
router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("login"),
|
||||
[email, setEmail] = useState(""),
|
||||
[password, setPassword] = useState(""),
|
||||
[showPassword, setShowPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false),
|
||||
[token, setToken] = useState(""),
|
||||
[feedback, setFeedback] = useState<{
|
||||
tone: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const value = url.searchParams.get("verify");
|
||||
if (value) {
|
||||
setToken(value);
|
||||
url.searchParams.delete("verify");
|
||||
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
}, []);
|
||||
async function action(name: Mode | "verify" | "logout", body: object = {}) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const response = await fetch("/api/account/" + name, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || "暂时无法完成操作,请重试");
|
||||
if (name === "verify") {
|
||||
setToken("");
|
||||
setMode("login");
|
||||
setFeedback({ tone: "success", text: "邮箱已验证,现在可以登录。" });
|
||||
} else if (name === "login") {
|
||||
setPassword("");
|
||||
setFeedback({ tone: "success", text: "登录成功,欢迎回到织页。" });
|
||||
} else if (name === "logout") {
|
||||
setPassword("");
|
||||
setFeedback({ tone: "success", text: "已退出登录。" });
|
||||
} else
|
||||
setFeedback({
|
||||
tone: "success",
|
||||
text:
|
||||
data.message ||
|
||||
"如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。",
|
||||
});
|
||||
await account.refresh();
|
||||
} catch (e) {
|
||||
setFeedback({
|
||||
tone: "error",
|
||||
text: e instanceof Error ? e.message : "网络异常,请稍后重试",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void action(mode, { email, password });
|
||||
}
|
||||
function switchMode(value: Mode) {
|
||||
setMode(value);
|
||||
setFeedback(null);
|
||||
}
|
||||
return (
|
||||
<div className={s.accountLayout}>
|
||||
<section className={s.accountIntro}>
|
||||
<span className={s.accountSymbol}>
|
||||
<Waypoints size={28} />
|
||||
</span>
|
||||
<span className={s.eyebrow}>YOUR KNOWLEDGE, CONNECTED</span>
|
||||
<h1>
|
||||
让有用的知识,
|
||||
<br />
|
||||
成为下一次行动的起点。
|
||||
</h1>
|
||||
<p>
|
||||
用邮箱建立你的知识空间。把工作方法写成独立文档,让 Agent
|
||||
在需要的时候找到它。
|
||||
</p>
|
||||
<div className={s.accountFeatures}>
|
||||
<div>
|
||||
<BookOpen size={18} />
|
||||
<div>
|
||||
<strong>每篇文档,都能独立使用</strong>
|
||||
<p>从当前任务出发,无需加载整个集合。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<GitBranch size={18} />
|
||||
<div>
|
||||
<strong>每次更新,都有固定版本</strong>
|
||||
<p>历史引用保持可读,修改始终有迹可循。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Link2 size={18} />
|
||||
<div>
|
||||
<strong>知识之间,按条件连接</strong>
|
||||
<p>需要更多背景时,再沿有用的链接继续阅读。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className={s.authCard} aria-label="邮箱账户">
|
||||
<h2>
|
||||
{account.user
|
||||
? "你的账户"
|
||||
: mode === "register"
|
||||
? "创建你的账户"
|
||||
: mode === "resend"
|
||||
? "重发验证邮件"
|
||||
: "欢迎回来"}
|
||||
</h2>
|
||||
<p className={s.authDescription}>
|
||||
{account.user
|
||||
? "已准备好发布和管理你的独立文档。"
|
||||
: mode === "register"
|
||||
? "验证邮箱后,就可以发布第一篇文档。"
|
||||
: mode === "resend"
|
||||
? "输入注册时的邮箱和密码,重新获取验证链接。"
|
||||
: "使用邮箱与密码登录,继续你的知识工作。"}
|
||||
</p>
|
||||
{token && (
|
||||
<section className={s.verificationCard}>
|
||||
<h3>
|
||||
<MailCheck size={17} />
|
||||
确认验证邮箱
|
||||
</h3>
|
||||
<p>验证链接有效期为 30 分钟。确认后,再使用邮箱与密码登录。</p>
|
||||
<button
|
||||
type="button"
|
||||
className={s.primaryButton}
|
||||
disabled={busy}
|
||||
onClick={() => void action("verify", { token })}
|
||||
>
|
||||
{busy ? "正在验证…" : "确认验证邮箱"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
{feedback && <Notice tone={feedback.tone}>{feedback.text}</Notice>}
|
||||
{account.error && (
|
||||
<Notice tone="error">
|
||||
{account.error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className={s.textLink}
|
||||
onClick={() => void account.refresh()}
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</Notice>
|
||||
)}
|
||||
{account.loading ? (
|
||||
<div className={s.accountLoading} aria-busy="true">
|
||||
正在检查账户状态…
|
||||
</div>
|
||||
) : account.user ? (
|
||||
<>
|
||||
<div className={s.loggedUser}>
|
||||
<span className={s.avatar}>
|
||||
{account.user.email.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{account.user.email}</strong>
|
||||
<span className={s.verifiedBadge}>
|
||||
<CheckCircle2 size={13} />
|
||||
邮箱已验证
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.accountInfo}>
|
||||
<p>
|
||||
<strong>你的内容,由你管理。</strong>
|
||||
<br />
|
||||
你可以发布文档、创建新版本,以及撤下自己的内容。公开文档的固定历史版本会继续保留。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={s.primaryButton}
|
||||
href={localNext(router.query.next)}
|
||||
>
|
||||
继续使用织页
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
<div className={s.authSecondary}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void action("logout")}
|
||||
>
|
||||
<LogOut size={12} /> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mode !== "resend" && (
|
||||
<nav className={s.authTabs} aria-label="账户操作">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "login"}
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "register"}
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("register")}
|
||||
>
|
||||
创建账户
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<form onSubmit={submit}>
|
||||
<fieldset className={s.formFieldset} disabled={busy}>
|
||||
<div className={s.formField}>
|
||||
<label htmlFor="account-email">邮箱地址</label>
|
||||
<input
|
||||
id="account-email"
|
||||
className={s.input}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
maxLength={254}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.formField}>
|
||||
<label htmlFor="account-password">密码</label>
|
||||
<div className={s.passwordField}>
|
||||
<input
|
||||
id="account-password"
|
||||
className={s.input}
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
autoComplete={
|
||||
mode === "register"
|
||||
? "new-password"
|
||||
: "current-password"
|
||||
}
|
||||
placeholder={
|
||||
mode === "register"
|
||||
? "设置至少 12 个字符的密码"
|
||||
: "输入你的密码"
|
||||
}
|
||||
minLength={12}
|
||||
maxLength={256}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<small className={s.fieldHint}>
|
||||
建议使用包含字母、数字的长密码。
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<button className={s.primaryButton} type="submit">
|
||||
{busy
|
||||
? "正在处理…"
|
||||
: mode === "login"
|
||||
? "登录"
|
||||
: mode === "register"
|
||||
? "创建账户"
|
||||
: "发送验证邮件"}
|
||||
{!busy && <ArrowRight size={15} />}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div className={s.authSecondary}>
|
||||
{mode === "resend" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("resend")}
|
||||
>
|
||||
没有收到验证邮件?重新发送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.authFootnote}>
|
||||
注册后需要先验证邮箱。公开文档始终可以直接阅读。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,126 @@
|
||||
import { ZodError } from 'zod';
|
||||
import type { NextApiRequest,NextApiResponse } from 'next';
|
||||
import { currentAccount } from '@/lib/account';
|
||||
import { trustedOrigin } from '@/lib/account-crypto';
|
||||
import { documentInput,parseDocumentXml } from '@/lib/guidance-input';
|
||||
import { discoverDocuments,publishDocument,resolveDocument,withdrawDocument } from '@/lib/guidance-store';
|
||||
import { guidanceXml } from '@/content/guides/catalog';
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
res.setHeader('Cache-Control','no-store');const path=req.query.path||[];if(!Array.isArray(path)||path.length>1)return res.status(404).end();const id=path[0];
|
||||
try{
|
||||
if(req.method==='GET'){
|
||||
if(!id)return res.json({documents:await discoverDocuments()});
|
||||
const revision=req.query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return res.status(400).json({error:'版本无效'});
|
||||
const doc=await resolveDocument(id,revision===undefined?undefined:Number(revision));if(!doc)return res.status(404).json({error:'文档或精确版本不存在'});
|
||||
if(req.query.format==='xml'){res.setHeader('Content-Type','application/xml; charset=utf-8');return res.send(guidanceXml(doc));}return res.json({document:doc});}
|
||||
if(!['POST','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, DELETE');return res.status(405).end();}
|
||||
const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers:new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][])});
|
||||
if(!trustedOrigin(request))return res.status(403).json({error:'请求来源不受信任'});
|
||||
const user=await currentAccount(request);if(!user)return res.status(401).json({error:'请先登录已验证邮箱账户'});
|
||||
if(req.method==='DELETE')return id&&await withdrawDocument(user.id,id)?res.json({ok:true}):res.status(404).json({error:'无权删除或不存在'});
|
||||
const input=typeof req.body==='string'?parseDocumentXml(req.body):documentInput.parse(req.body);
|
||||
if(id==='preview'){const links=await Promise.all(input.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));return res.json({document:input,xml:guidanceXml(input),links});}
|
||||
if(id&&id!==input.id)return res.status(400).json({error:'文档 ID 不匹配'});
|
||||
return res.status(201).json({document:await publishDocument(user.id,input)});
|
||||
}catch(e){if(e instanceof ZodError)return res.status(400).json({error:'输入不符合协议,请检查以下字段',fields:e.issues.slice(0,12).map(i=>i.path.join('.')||'文档')});const message=e instanceof Error?e.message:'';if(message==='INVALID_ID')return res.status(400).json({error:'文档 ID 必须以 user-你的账户ID- 开头,请使用编辑器生成的前缀'});if(message==='FORBIDDEN')return res.status(403).json({error:'仅作者可编辑'});if(message==='CONFLICT'||(e as {code?:string}).code==='23505')return res.status(409).json({error:'版本冲突:请加载最新版本,已发布版本不可修改'});if(e instanceof Error&&(e.name==='ZodError'||/XML|实体|元素|属性|协议|链接/.test(message)))return res.status(400).json({error:'输入不符合协议'});return res.status(503).json({error:'文档服务暂不可用'});}
|
||||
import { ZodError } from "zod";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { currentAccount } from "@/lib/account";
|
||||
import { trustedOrigin } from "@/lib/account-crypto";
|
||||
import { documentInput, parseDocumentXml } from "@/lib/guidance-input";
|
||||
import {
|
||||
discoverDocuments,
|
||||
publishDocument,
|
||||
resolveDocument,
|
||||
withdrawDocument,
|
||||
} from "@/lib/guidance-store";
|
||||
import { guidanceXml } from "@/content/guides/catalog";
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const path = req.query.path || [];
|
||||
if (!Array.isArray(path) || path.length > 1) return res.status(404).end();
|
||||
const id = path[0];
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
if (!id) {
|
||||
const author = req.query.author;
|
||||
if (
|
||||
author !== undefined &&
|
||||
(typeof author !== "string" ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
author,
|
||||
))
|
||||
)
|
||||
return res.status(400).json({ error: "作者 ID 无效" });
|
||||
return res.json({ documents: await discoverDocuments(author) });
|
||||
}
|
||||
const revision = req.query.revision;
|
||||
if (
|
||||
revision !== undefined &&
|
||||
(typeof revision !== "string" ||
|
||||
!/^[1-9][0-9]{0,9}$/.test(revision) ||
|
||||
Number(revision) > 2147483647)
|
||||
)
|
||||
return res.status(400).json({ error: "版本无效" });
|
||||
const doc = await resolveDocument(
|
||||
id,
|
||||
revision === undefined ? undefined : Number(revision),
|
||||
);
|
||||
if (!doc) return res.status(404).json({ error: "文档或精确版本不存在" });
|
||||
if (req.query.format === "xml") {
|
||||
res.setHeader("Content-Type", "application/xml; charset=utf-8");
|
||||
return res.send(guidanceXml(doc));
|
||||
}
|
||||
return res.json({ document: doc });
|
||||
}
|
||||
if (!["POST", "DELETE"].includes(req.method || "")) {
|
||||
res.setHeader("Allow", "GET, POST, DELETE");
|
||||
return res.status(405).end();
|
||||
}
|
||||
const request = new Request(
|
||||
new URL(
|
||||
req.url || "/",
|
||||
process.env.ACCOUNT_PUBLIC_ORIGIN || "http://" + req.headers.host,
|
||||
),
|
||||
{
|
||||
headers: new Headers(
|
||||
Object.entries(req.headers).filter(
|
||||
([, v]) => typeof v === "string",
|
||||
) as [string, string][],
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!trustedOrigin(request))
|
||||
return res.status(403).json({ error: "请求来源不受信任" });
|
||||
const user = await currentAccount(request);
|
||||
if (!user) return res.status(401).json({ error: "请先登录已验证邮箱账户" });
|
||||
if (req.method === "DELETE")
|
||||
return id && (await withdrawDocument(user.id, id))
|
||||
? res.json({ ok: true })
|
||||
: res.status(404).json({ error: "无权删除或不存在" });
|
||||
const input =
|
||||
typeof req.body === "string"
|
||||
? parseDocumentXml(req.body)
|
||||
: documentInput.parse(req.body);
|
||||
if (id === "preview") {
|
||||
const links = await Promise.all(
|
||||
input.links.map(async (l) => ({
|
||||
...l,
|
||||
resolved: !!(await resolveDocument(l.target, l.revision)),
|
||||
})),
|
||||
);
|
||||
return res.json({ document: input, xml: guidanceXml(input), links });
|
||||
}
|
||||
if (id && id !== input.id)
|
||||
return res.status(400).json({ error: "文档 ID 不匹配" });
|
||||
return res
|
||||
.status(201)
|
||||
.json({ document: await publishDocument(user.id, input) });
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError)
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
error: "输入不符合协议,请检查以下字段",
|
||||
fields: e.issues.slice(0, 12).map((i) => i.path.join(".") || "文档"),
|
||||
});
|
||||
const message = e instanceof Error ? e.message : "";
|
||||
if (message === "INVALID_ID")
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
error: "文档 ID 必须以 user-你的账户ID- 开头,请使用编辑器生成的前缀",
|
||||
});
|
||||
if (message === "FORBIDDEN")
|
||||
return res.status(403).json({ error: "仅作者可编辑" });
|
||||
if (message === "CONFLICT" || (e as { code?: string }).code === "23505")
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "版本冲突:请加载最新版本,已发布版本不可修改" });
|
||||
if (
|
||||
e instanceof Error &&
|
||||
(e.name === "ZodError" || /XML|实体|元素|属性|协议|链接/.test(message))
|
||||
)
|
||||
return res.status(400).json({ error: "输入不符合协议" });
|
||||
return res.status(503).json({ error: "文档服务暂不可用" });
|
||||
}
|
||||
}
|
||||
export const config={api:{bodyParser:{sizeLimit:'128kb'}}};
|
||||
export const config = { api: { bodyParser: { sizeLimit: "128kb" } } };
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
import type { GetServerSideProps } from 'next';
|
||||
import type { Guidance } from '@/content/guides/catalog';
|
||||
import styles from '@/styles/documents.module.css';
|
||||
type Props={canEdit:boolean;versions:number[];doc:Guidance;links:(Guidance['links'][number]&{resolved:boolean})[]};
|
||||
export default function Read({doc,links,canEdit,versions}:Props){return <main className={styles.page}><nav><a href="/documents">用户文档</a> · <a href={canEdit?'/write?id='+doc.id:'/write'}>{canEdit?'编辑我的文档':'发布文档'}</a></nav><h1>{doc.title}</h1><p>{doc.kind} · {doc.id} · 固定版本 {doc.revision}</p><p>版本:{versions.map(v=><a key={v} href={'/documents/'+doc.id+'?revision='+v}> v{v} </a>)}</p><p>{doc.summary}</p><h2>适用条件</h2><p>{doc.applies}</p><p className={styles.body}>{doc.body}</p><ol>{doc.steps.map((s,i)=><li key={i}>{s}</li>)}</ol><h2>条件链接</h2>{links.map((l,i)=><article key={i}><p>条件:{l.when} · {l.relation}</p>{l.resolved?<a href={(l.target.startsWith('user-')?'/documents/':'/guide/')+l.target+'?revision='+l.revision}>{l.label} · v{l.revision}</a>:<p>{l.label}:精确版本缺失,请跳过</p>}</article>)}<h2>检查项</h2><ul>{doc.checks.map((s,i)=><li key={i}>{s}</li>)}</ul><a href={'/api/documents/'+doc.id+'?revision='+doc.revision+'&format=xml'}>读取规范 XML</a>{canEdit&&<p><button onClick={async()=>{if(!confirm('撤下后仍保留固定历史版本,确定撤下?'))return;const r=await fetch('/api/documents/'+doc.id,{method:'DELETE'});alert(r.ok?'已撤下,历史版本仍可访问':(await r.json()).error);}}>作者撤下文档</button></p>}</main>;}
|
||||
export const getServerSideProps:GetServerSideProps<Props>=async({params,query,res,req})=>{res.setHeader('Cache-Control','no-store');const revision=query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return {notFound:true};const {resolveDocument}=await import('@/lib/guidance-store');const doc=await resolveDocument(String(params?.id),revision===undefined?undefined:Number(revision));if(!doc)return {notFound:true};const links=await Promise.all(doc.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));const {db}=await import('@/lib/db');const {currentAccount}=await import('@/lib/account');const user=await currentAccount(new Request('http://internal',{headers:{cookie:req.headers.cookie||''}}));const owner=await db().query('SELECT owner_id FROM guidance_documents WHERE id=$1',[doc.id]);const rows=await db().query('SELECT revision FROM guidance_revisions WHERE document_id=$1 ORDER BY revision DESC',[doc.id]);return {props:{doc,links,canEdit:!!user&&owner.rows[0]?.owner_id===user.id,versions:rows.rows.map(r=>r.revision)}};};
|
||||
import type { GetServerSideProps } from "next";
|
||||
import GuidanceReader, { type ReaderProps } from "@/components/GuidanceReader";
|
||||
export default function Read(props: ReaderProps) {
|
||||
return <GuidanceReader {...props} />;
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<ReaderProps> = async ({
|
||||
params,
|
||||
query,
|
||||
res,
|
||||
req,
|
||||
}) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const revision = query.revision;
|
||||
if (
|
||||
revision !== undefined &&
|
||||
(typeof revision !== "string" ||
|
||||
!/^[1-9][0-9]{0,9}$/.test(revision) ||
|
||||
Number(revision) > 2147483647)
|
||||
)
|
||||
return { notFound: true };
|
||||
const { resolveDocument } = await import("@/lib/guidance-store");
|
||||
const document = await resolveDocument(
|
||||
String(params?.id),
|
||||
revision === undefined ? undefined : Number(revision),
|
||||
);
|
||||
if (!document) return { notFound: true };
|
||||
const links = await Promise.all(
|
||||
document.links.map(async (link) => ({
|
||||
...link,
|
||||
resolved: !!(await resolveDocument(link.target, link.revision)),
|
||||
})),
|
||||
);
|
||||
const { db } = await import("@/lib/db");
|
||||
const { currentAccount } = await import("@/lib/account");
|
||||
const user = await currentAccount(
|
||||
new Request("http://internal", {
|
||||
headers: { cookie: req.headers.cookie || "" },
|
||||
}),
|
||||
);
|
||||
const owner = await db().query(
|
||||
"SELECT owner_id,deleted_at FROM guidance_documents WHERE id=$1",
|
||||
[document.id],
|
||||
);
|
||||
const rows = await db().query(
|
||||
"SELECT revision,created_at FROM guidance_revisions WHERE document_id=$1 ORDER BY revision DESC",
|
||||
[document.id],
|
||||
);
|
||||
return {
|
||||
props: {
|
||||
document,
|
||||
source: "community",
|
||||
links,
|
||||
canEdit: !!user && owner.rows[0]?.owner_id === user.id,
|
||||
withdrawn: !!owner.rows[0]?.deleted_at,
|
||||
versions: rows.rows.map((r) => ({
|
||||
revision: r.revision,
|
||||
createdAt: new Date(r.created_at).toISOString(),
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect,useState } from 'react';
|
||||
import styles from '@/styles/documents.module.css';
|
||||
export default function Documents(){const [docs,setDocs]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[error,setError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setDocs(b.documents);}).catch(e=>setError(e.message));},[]);return <main className={styles.page}><nav><a href="/">首页</a> · <a href="/write">发布文档</a></nav><h1>用户发布的文档</h1><p>{error}</p>{docs.map(d=><article key={d.id}><h2><a href={'/documents/'+d.id+'?revision='+d.revision}>{d.title}</a></h2><p>{d.kind} · v{d.revision}</p><p>{d.summary}</p></article>)}</main>;}
|
||||
import DocumentDirectory from "@/components/DocumentDirectory";
|
||||
export default function Documents() {
|
||||
return <DocumentDirectory communityOnly />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import GuidanceShell from '@/components/GuidanceShell';
|
||||
import {findGuide,guides,guidanceXml,type Guidance} from '@/content/guides/catalog';
|
||||
import s from '@/styles/guidance.module.css';
|
||||
export default function Guide({guide:g,backlinks}:{guide:Guidance;backlinks:{id:string;title:string}[]}){const[xml,setXml]=useState(false);return <GuidanceShell><Head><title>{g.title} · 织页</title></Head><main className={s.detail}><p className={s.meta}><Link href="/">文档</Link> / protocol-examples / {g.id}</p><span className={s.eyebrow}>{g.kind.toUpperCase()} · REVISION {g.revision}</span><h1>{g.title}</h1><p>{g.summary}</p><div className={s.tabs}><button aria-pressed={!xml} onClick={()=>setXml(false)}>阅读</button><button aria-pressed={xml} onClick={()=>setXml(true)}>XML 源文档</button><a href={`/api/guides/${g.id}?revision=${g.revision}&format=xml`}>单篇机器接口 ↗</a></div><div className={s.detailGrid}><article className={s.prose}>{xml?<pre>{guidanceXml(g)}</pre>:<><h2>何时使用</h2><p>{g.applies}</p><h2>指导正文</h2><p>{g.body}</p><h2>建议步骤</h2><ol>{g.steps.map(t=><li key={t}>{t}</li>)}</ol><h2>完成检查</h2><ul>{g.checks.map(t=><li key={t}>{t}</li>)}</ul><h2>继续探索</h2>{g.links.map(l=><Link className={s.related} key={l.target} href={`/guide/${l.target}?revision=${l.revision}`}><strong>{l.label} ↗</strong><span>当{l.when} · {l.relation} · r{l.revision}</span></Link>)}{!g.links.length&&<p>这篇文档可独立使用,没有建议继续读取的链接。</p>}</>}</article><aside className={s.note}><h3>采用这篇文档</h3><p>把单篇机器接口交给 Agent。仅当链接条件适用时,再读取关联文档。</p><p>读取链接不授予执行权限。背景和依据不作为操作指令。</p><h3>被这些文档引用</h3>{backlinks.map(b=><p key={b.id}><Link href={`/guide/${b.id}`}>{b.title} ↗</Link></p>)}{!backlinks.length&&<p>暂时没有反向链接。</p>}</aside></div></main></GuidanceShell>}
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query})=>{const r=query.revision;if(r!==undefined&&(typeof r!=='string'||!/^\d+$/.test(r)))return{notFound:true};const guide=findGuide(String(params?.id),r===undefined?undefined:Number(r));return guide?{props:{guide,backlinks:guides.filter(g=>g.links.some(l=>l.target===guide.id&&l.revision===guide.revision)).map(({id,title})=>({id,title}))}}:{notFound:true};};
|
||||
import type { GetServerSideProps } from "next";
|
||||
import GuidanceReader, { type ReaderProps } from "@/components/GuidanceReader";
|
||||
import { findGuide, guides } from "@/content/guides/catalog";
|
||||
export default function Guide(props: ReaderProps) {
|
||||
return <GuidanceReader {...props} />;
|
||||
}
|
||||
export const getServerSideProps: GetServerSideProps<ReaderProps> = async ({
|
||||
params,
|
||||
query,
|
||||
}) => {
|
||||
const r = query.revision;
|
||||
if (r !== undefined && (typeof r !== "string" || !/^\d+$/.test(r)))
|
||||
return { notFound: true };
|
||||
const document = findGuide(
|
||||
String(params?.id),
|
||||
r === undefined ? undefined : Number(r),
|
||||
);
|
||||
if (!document) return { notFound: true };
|
||||
return {
|
||||
props: {
|
||||
document,
|
||||
source: "example",
|
||||
versions: [{ revision: document.revision }],
|
||||
links: document.links.map((link) => ({
|
||||
...link,
|
||||
resolved: !!findGuide(link.target, link.revision),
|
||||
})),
|
||||
backlinks: guides
|
||||
.filter((g) =>
|
||||
g.links.some(
|
||||
(link) =>
|
||||
link.target === document.id &&
|
||||
link.revision === document.revision,
|
||||
),
|
||||
)
|
||||
.map(({ id, title, revision }) => ({
|
||||
id,
|
||||
title,
|
||||
revision,
|
||||
source: "example",
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,75 +1,4 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import GuidanceShell from '@/components/GuidanceShell';
|
||||
import {guides} from '@/content/guides/catalog';
|
||||
import s from '@/styles/guidance.module.css';
|
||||
|
||||
type PublishedDocument={id:string;revision:number;title:string;summary:string;kind:string};
|
||||
const categories=[['all','全部文档'],['guide','操作指导'],['context','背景知识'],['evidence','事实依据']];
|
||||
export default function Home(){
|
||||
const router=useRouter();
|
||||
const [published,setPublished]=useState<PublishedDocument[]>([]);
|
||||
const [loadError,setLoadError]=useState('');
|
||||
const [loading,setLoading]=useState(true);
|
||||
const [q,setQ]=useState('');
|
||||
const [kind,setKind]=useState('all');
|
||||
useEffect(()=>{
|
||||
let active=true;
|
||||
fetch('/api/documents').then(async r=>{
|
||||
const b=await r.json();if(!r.ok)throw Error(b.error);
|
||||
if(active)setPublished(b.documents);
|
||||
}).catch(e=>{if(active)setLoadError(e.message);}).finally(()=>{if(active)setLoading(false);});
|
||||
return()=>{active=false;};
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
if(!router.isReady)return;
|
||||
setQ(typeof router.query.q==='string'?router.query.q:'');
|
||||
setKind(typeof router.query.kind==='string'&&categories.some(([key])=>key===router.query.kind)?router.query.kind:'all');
|
||||
},[router.isReady,router.query.q,router.query.kind]);
|
||||
function rememberFilters(query:string,category:string){
|
||||
const next:{q?:string;kind?:string}={};
|
||||
if(query)next.q=query;if(category!=='all')next.kind=category;
|
||||
void router.push({pathname:'/',query:next},undefined,{shallow:true,scroll:false});
|
||||
}
|
||||
function search(e:FormEvent){e.preventDefault();rememberFilters(q,kind);}
|
||||
function chooseKind(value:string){setKind(value);rememberFilters(q,value);}
|
||||
function clearFilters(){setQ('');setKind('all');rememberFilters('','all');}
|
||||
const matches=(g:{title:string;summary:string;kind:string;tags?:string[]})=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags?.join(' ')||''}`.toLowerCase().includes(q.trim().toLowerCase());
|
||||
const items=guides.filter(matches),userItems=published.filter(matches);
|
||||
const total=items.length+userItems.length;
|
||||
return <GuidanceShell>
|
||||
<Head><title>织页 · 为 Agent 找到下一页</title></Head>
|
||||
<section className={s.hero}>
|
||||
<span className={s.eyebrow}>A WEB OF USEFUL KNOWLEDGE</span>
|
||||
<h1>一个起点,<br/>连接下一种可能。</h1>
|
||||
<p>每一篇文档,都能独立帮助你的 Agent。<br/>从当前问题出发,沿着有用的链接继续探索。</p>
|
||||
<form className={s.search} role="search" onSubmit={search}>
|
||||
<span aria-hidden="true">⌕</span><input aria-label="搜索文档" placeholder="搜索文档、任务或一种想法…" value={q} onChange={e=>setQ(e.target.value)}/><button type="submit">搜索</button>
|
||||
</form>
|
||||
<div className={s.topics}>{['代码审查','规划','验证'].map(t=><button key={t} onClick={()=>{setQ(t);rememberFilters(t,kind);}}>{t} ↗</button>)}</div>
|
||||
</section>
|
||||
<main className={s.layout}>
|
||||
<aside className={s.sidebar}>
|
||||
<h2>文档目录</h2>
|
||||
{categories.map(([key,label])=><button key={key} aria-pressed={kind===key} onClick={()=>chooseKind(key)}>{label}<span>{[...guides,...published].filter(g=>key==='all'||g.kind===key).length}</span></button>)}
|
||||
<p>一个文档就是一个入口。<br/>集合是组织方式,<br/>无需整套安装。</p>
|
||||
</aside>
|
||||
<section aria-label="文档搜索结果">
|
||||
<div className={s.sectionTitle}><p role="status">{loading?'正在加载用户文档…':loadError?`协议样例 ${items.length} 篇;用户文档加载失败`:`找到 ${total} 篇文档`}</p>{(q||kind!=='all')&&<button type="button" onClick={clearFilters}>清除所有筛选</button>}</div>
|
||||
{loadError&&<p role="alert">用户文档暂未加载:{loadError}。下方仍可阅读协议样例。</p>}
|
||||
{!loading&&!loadError&&total===0&&<p>没有匹配文档,请尝试其他关键词或清除筛选。</p>}
|
||||
<div className={s.sectionTitle}><h2>用户发布 <small>{userItems.length} 篇</small></h2><Link href="/write">发布一篇文档 ↗</Link></div>
|
||||
<div className={s.list}>{userItems.map(g=><article className={s.row} key={g.id}><div className={s.icon}>↗</div><div><div className={s.meta}>{g.id} · {g.kind} · v{g.revision}</div><h3><Link href={`/documents/${g.id}?revision=${g.revision}`}>{g.title}</Link></h3><p>{g.summary}</p></div></article>)}</div>
|
||||
{!loading&&!loadError&&!published.length&&<p>还没有用户文档。你可以发布第一篇,或先阅读下方样例。</p>}
|
||||
<div className={s.sectionTitle}><h2>从这里开始 <small>{items.length} 篇</small></h2><span>协议样例 · v0.1</span></div>
|
||||
<div className={s.list}>{items.map(g=><article className={s.row} key={g.id}><div className={s.icon}>{g.kind==='guide'?'↗':'≋'}</div><div><div className={s.meta}>织页 / protocol-examples · r{g.revision}</div><h3><Link href={`/guide/${g.id}`}>{g.title}</Link></h3><p>{g.summary}</p><div className={s.tags}>{g.tags.map(t=><span key={t}>{t}</span>)}<span>↗ {g.links.length} 个关联</span></div></div></article>)}</div>
|
||||
</section>
|
||||
<aside className={s.right}>
|
||||
<div className={s.manifest}><span className={s.eyebrow}>DOCUMENTS, CONNECTED.</span><h2>知识不必<br/>打包带走。</h2><p>读一页,解决眼前的问题。<br/>需要更多时,再打开下一页。</p><Link href="/protocol">了解文档协议 ↗</Link></div>
|
||||
<div className={s.note}><h3>为两种 Agent 而写</h3><p>Coding agent 的工作方法,Personal agent 的日常指导,都可以从一篇文档开始。</p><h3>这是一个早期原型</h3><p>已验证邮箱用户可以发布独立文档,更新会创建不可变版本。下方协议样例与真实用户文档分别展示。</p></div>
|
||||
</aside>
|
||||
</main>
|
||||
</GuidanceShell>;
|
||||
import DocumentDirectory from "@/components/DocumentDirectory";
|
||||
export default function Home() {
|
||||
return <DocumentDirectory />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,202 @@
|
||||
import Head from 'next/head';
|
||||
import GuidanceShell from '@/components/GuidanceShell';
|
||||
import {guides,guidanceXml} from '@/content/guides/catalog';
|
||||
import s from '@/styles/guidance.module.css';
|
||||
export default function Protocol(){return <GuidanceShell><Head><title>Linked Guidance 0.1 · 织页</title></Head><main className={s.detail}><span className={s.eyebrow}>AN OPEN EXPERIMENT · 0.1</span><h1>让每一页,都能成为起点。</h1><p>Linked Guidance 协议草案 · 名称待定</p><article className={s.prose}><h2>从文档出发,按需连接</h2><p>每篇文档都有独立正文、适用条件和固定版本。集合只是整理方式,不要求整套安装。Agent 先阅读一篇,再判断链接是否有助于当前任务。</p><h2>从哪里开始</h2><p><a href="/agent/index.md">Agent 阅读入口 ↗</a> · <a href="/documents">真实用户文档 ↗</a> · <a href="/write">发布文档 ↗</a></p><h2>一个最小示例</h2><pre>{guidanceXml(guides[0])}</pre><h2>三种关系</h2><p>guide:可供采用的指导;context:背景知识;evidence:事实依据。背景和依据不会自动成为指令。</p><h2>读取约定</h2><ol><li>判断入口文档是否适合当前任务。</li><li>只跟随满足 when 条件且有帮助的链接。</li><li>按文档与版本去重;默认最多 8 篇、深度 3 层。</li><li>固定版本缺失不退回最新版;预算耗尽时报告不完整。</li></ol><h2>权限不沿链接传播</h2><p>文档不会改变用户授权或运行环境约束。0.1 仅引用本站文档 id,不自动请求任意外部 URL,不执行 XML 实体或脚本。</p><h2>协议状态</h2><p>这是待验证原型,并非现有 Agent 原生标准。当前提供单篇 XML 接口、已验证邮箱作者发布与固定版本历史。跨站发现与原生 Agent 适配仍待实现。</p><p>研究起点:<a href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f">Karpathy 的 LLM Wiki 原始提案 ↗</a>。XML 语法是本项目提出的设计。</p></article></main></GuidanceShell>}
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Braces,
|
||||
GitBranch,
|
||||
Link2,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import GuidanceShell from "@/components/GuidanceShell";
|
||||
import { CopyButton, KindBadge, PageHeading } from "@/components/GuidanceUI";
|
||||
import { guides, guidanceXml } from "@/content/guides/catalog";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
const sections = [
|
||||
["idea", "独立文档"],
|
||||
["start", "开始读取"],
|
||||
["syntax", "文档语法"],
|
||||
["relations", "三种关系"],
|
||||
["reading", "读取约定"],
|
||||
["permissions", "权限边界"],
|
||||
["status", "协议状态"],
|
||||
];
|
||||
export default function Protocol() {
|
||||
const xml = guidanceXml(guides[0]);
|
||||
return (
|
||||
<GuidanceShell title="文档协议">
|
||||
<PageHeading
|
||||
title="从一篇文档,连接下一种可能。"
|
||||
description="Linked Guidance 0.1 · 为独立指导文档设计的实验协议"
|
||||
eyebrow="DOCUMENTATION"
|
||||
>
|
||||
<a
|
||||
href="/agent/index.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={s.secondaryButton}
|
||||
>
|
||||
<Bot size={15} />
|
||||
Agent 读取入口
|
||||
<ArrowUpRight size={13} />
|
||||
</a>
|
||||
</PageHeading>
|
||||
<div className={s.protocolLayout}>
|
||||
<article className={`${s.panel} ${s.protocolArticle}`}>
|
||||
<section id="idea" className={s.proseSection}>
|
||||
<h2>
|
||||
<BookOpen size={17} />
|
||||
文档是起点,集合只是组织方式
|
||||
</h2>
|
||||
<p>
|
||||
每篇文档都有完整正文、适用条件和固定版本。Coding agent 与 personal
|
||||
agent
|
||||
都可以从一篇文档开始;需要更多帮助时,再判断关联链接是否适合当前任务。无需把一整组文档一起安装或加载。
|
||||
</p>
|
||||
</section>
|
||||
<section id="start" className={s.proseSection}>
|
||||
<h2>
|
||||
<Target size={17} />
|
||||
从哪里开始
|
||||
</h2>
|
||||
<p>
|
||||
先通过目录发现摘要,再读取一篇文档的固定版本。人的阅读界面与 Agent
|
||||
的 XML 接口引用同一份内容。
|
||||
</p>
|
||||
<div className={s.buttonRow} style={{ marginTop: 16 }}>
|
||||
<Link className={s.secondaryButton} href="/">
|
||||
探索文档
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
<Link className={s.secondaryButton} href="/documents">
|
||||
社区文档
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
<Link className={s.secondaryButton} href="/write">
|
||||
发布文档
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
<code className={s.machineAddress}>/agent/index.md</code>
|
||||
<CopyButton
|
||||
label="复制 Agent 入口"
|
||||
value={() => window.location.origin + "/agent/index.md"}
|
||||
/>
|
||||
</section>
|
||||
<section id="syntax" className={s.proseSection}>
|
||||
<h2>
|
||||
<Braces size={17} />
|
||||
一份可独立读取的文档
|
||||
</h2>
|
||||
<p>
|
||||
XML 描述文档本身,以及何时可能需要其他文档。固定 revision
|
||||
保留精确引用;未知版本与缺失目标需要明确处理。
|
||||
</p>
|
||||
<pre className={s.codeBlock}>{xml}</pre>
|
||||
<CopyButton label="复制示例 XML" value={xml} />
|
||||
</section>
|
||||
<section id="relations" className={s.proseSection}>
|
||||
<h2>
|
||||
<Link2 size={17} />
|
||||
把关系写清楚
|
||||
</h2>
|
||||
<p>
|
||||
链接是有条件的阅读建议,不是强制依赖。when
|
||||
用自然语言说明何时可能需要目标文档。
|
||||
</p>
|
||||
<div className={s.relationGrid}>
|
||||
<div>
|
||||
<KindBadge kind="guide" />
|
||||
<p>可供采用的工作方法。仍需先判断是否适用,并遵守用户授权。</p>
|
||||
</div>
|
||||
<div>
|
||||
<KindBadge kind="context" />
|
||||
<p>用于理解任务的背景知识,不会自动成为操作指令。</p>
|
||||
</div>
|
||||
<div>
|
||||
<KindBadge kind="evidence" />
|
||||
<p>为判断提供事实与依据。引用时需要保留实际来源和版本。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="reading" className={s.proseSection}>
|
||||
<h2>
|
||||
<GitBranch size={17} />
|
||||
按需读取,固定版本
|
||||
</h2>
|
||||
<ol>
|
||||
<li>先看入口文档的适用条件,再读正文与步骤。</li>
|
||||
<li>只跟随 when 条件成立、且能补充当前任务所需知识的链接。</li>
|
||||
<li>
|
||||
以文档 ID 与版本去重,默认读取预算为 8 篇、最大深度 3 层。
|
||||
</li>
|
||||
<li>
|
||||
固定版本缺失时不静默回退最新版;遇到循环或预算耗尽时停止扩展,并说明未完成的读取。
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section id="permissions" className={s.proseSection}>
|
||||
<h2>
|
||||
<ShieldCheck size={17} />
|
||||
权限不会沿链接传播
|
||||
</h2>
|
||||
<p>
|
||||
读取文档不等于接受其中所有指令。用户任务和运行环境规则始终优先,文档不能要求获取秘密、扩大权限或改变目标。0.1
|
||||
只引用本站逻辑 ID,不自动请求任意外部 URL,也不执行 XML
|
||||
实体或脚本。
|
||||
</p>
|
||||
</section>
|
||||
<section id="status" className={s.proseSection}>
|
||||
<h2>协议状态</h2>
|
||||
<p>
|
||||
Linked Guidance 是本项目提出的实验协议,并非现有 Agent
|
||||
已原生支持的标准。当前支持独立 XML/JSON
|
||||
读取、已验证邮箱作者发布,以及不可变版本;跨站发现与原生 Agent
|
||||
适配仍待实现。
|
||||
</p>
|
||||
<div className={s.protocolCallout}>
|
||||
<p>
|
||||
研究起点:
|
||||
<a
|
||||
className={s.textLink}
|
||||
href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Karpathy 的 LLM Wiki 提案
|
||||
<ArrowUpRight size={11} />
|
||||
</a>
|
||||
。本项目在持久交叉引用的基础上,提出独立指导单元、条件链接、关系类型和版本约定。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
<aside className={s.readingSidebar}>
|
||||
<section className={s.sideCard}>
|
||||
<h3>本页目录</h3>
|
||||
<nav className={s.toc} aria-label="协议目录">
|
||||
{sections.map(([id, label]) => (
|
||||
<a href={"#" + id} key={id}>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
<section className={s.sideCard}>
|
||||
<h3>开始编写你的第一篇</h3>
|
||||
<p>从适用条件与清晰正文开始。关联链接可以在需要时再补充。</p>
|
||||
<Link
|
||||
className={s.textLink}
|
||||
href="/write"
|
||||
style={{ marginTop: 14 }}
|
||||
>
|
||||
创建文档
|
||||
<ArrowUpRight size={12} />
|
||||
</Link>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</GuidanceShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,729 @@
|
||||
import { useEffect,useState } from 'react';
|
||||
import type { Guidance } from '@/content/guides/catalog';
|
||||
import styles from '@/styles/documents.module.css';
|
||||
const blank:Guidance={id:'user-',revision:1,kind:'guide',title:'',summary:'',applies:'',body:'',steps:[],checks:[],links:[],tags:[]};
|
||||
export default function Write(){
|
||||
const [busy,setBusy]=useState(false),[published,setPublished]=useState<{id:string;revision:number}|null>(null);
|
||||
const [doc,setDoc]=useState(blank),[xml,setXml]=useState(''),[mode,setMode]=useState(false),[message,setMessage]=useState(''),[preview,setPreview]=useState<{document:Guidance;xml:string;links:{label:string;when:string;resolved:boolean}[]}|null>(null);
|
||||
useEffect(()=>{setPreview(null);},[doc,xml,mode]);
|
||||
useEffect(()=>{fetch('/api/account/session').then(r=>r.json()).then(b=>{if(b.user){const id=new URLSearchParams(window.location.search).get('id');if(id)fetch('/api/documents/'+encodeURIComponent(id)).then(responseBody).then(b=>setDoc({...b.document,revision:b.document.revision+1})).catch(e=>setMessage(e.message));else setDoc(d=>({...d,id:'user-'+b.user.id+'-'}));}else setMessage('请先登录并验证邮箱,再发布文档。');}).catch(()=>setMessage('账户服务暂不可用,请稍后重试。'));},[]);
|
||||
async function responseBody(r:Response){const b=await r.json().catch(()=>({error:'服务返回异常,请稍后重试'}));if(!r.ok)throw Error((b.error||'请求失败')+(b.fields?.length?':'+b.fields.join('、'):''));return b;}
|
||||
async function load(){setBusy(true);setPreview(null);try{const b=await responseBody(await fetch('/api/documents/'+encodeURIComponent(doc.id)));setDoc({...b.document,revision:b.document.revision+1});setMessage('已加载,保存将创建新版本');}catch(e){setMessage(e instanceof Error?e.message:'加载失败');}finally{setBusy(false);}}
|
||||
async function submit(action:string){setBusy(true);setMessage('处理中…');setPreview(null);setPublished(null);try{const b=await responseBody(await fetch('/api/documents'+(action==='preview'?'/preview':''),{method:'POST',headers:{'Content-Type':mode?'application/xml':'application/json'},body:mode?xml:JSON.stringify(doc)}));if(action==='preview'){setPreview(b);setMessage('预览成功,尚未发布');}else{setDoc({...b.document,revision:b.document.revision+1});setPublished({id:b.document.id,revision:b.document.revision});if(mode)setXml(xml.replace(/revision="[1-9][0-9]*"/,`revision="${b.document.revision+1}"`));setMessage('已发布版本 '+b.document.revision);}}catch(e){setMessage(e instanceof Error?e.message:'请求失败,请稍后重试');}finally{setBusy(false);}}
|
||||
return <main className={styles.page}><nav><a href="/">首页</a> · <a href="/documents">用户文档</a> · <a href="/account">邮箱账户</a></nav><h1>发布独立文档</h1><p>需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。</p><fieldset disabled={busy}><label><input type="checkbox" checked={mode} onChange={e=>{setMode(e.target.checked);setPreview(null);}}/> 使用协议 XML(zh-CN)</label>{mode?<label>XML 输入<textarea rows={22} value={xml} onChange={e=>{setXml(e.target.value);setPreview(null);}}/></label>:<><label>文档 ID(保留账户前缀,在末尾填写短名)<input value={doc.id} onChange={e=>setDoc({...doc,id:e.target.value})}/></label><button disabled={busy} onClick={load}>加载最新版本以编辑</button><label>待发布版本<input type="number" min={1} value={doc.revision} onChange={e=>setDoc({...doc,revision:Number(e.target.value)})}/></label><label>种类<select value={doc.kind} onChange={e=>setDoc({...doc,kind:e.target.value as Guidance['kind']})}><option>guide</option><option>context</option><option>evidence</option></select></label>{(['title','summary','applies','body'] as const).map(k=><label key={k}>{{title:'标题',summary:'摘要',applies:'适用条件',body:'正文'}[k]}<textarea value={doc[k]} onChange={e=>{setDoc({...doc,[k]:e.target.value});setPreview(null);}}/></label>)}{(['steps','checks'] as const).map(k=><label key={k}>{k==='steps'?'步骤':'检查项'}(每行一项)<textarea value={doc[k].join('\n')} onChange={e=>setDoc({...doc,[k]:e.target.value.split('\n').filter(Boolean)})}/></label>)}<h2>条件链接</h2>{doc.links.map((l,i)=><fieldset key={i}>{(['target','revision','relation','when','label'] as const).map(k=><label key={k}>{k}<input value={l[k]} onChange={e=>setDoc({...doc,links:doc.links.map((v,j)=>j===i?{...v,[k]:k==='revision'?Number(e.target.value):e.target.value}:v)})}/></label>)}<button onClick={()=>setDoc({...doc,links:doc.links.filter((_,j)=>j!==i)})}>移除链接</button></fieldset>)}<button onClick={()=>setDoc({...doc,links:[...doc.links,{target:'',revision:1,relation:'context',when:'',label:''}]})}>添加精确版本链接</button></>}<p><button disabled={busy} onClick={()=>submit('preview')}>校验并预览</button> <button disabled={busy} onClick={()=>submit('publish')}>发布新版本</button></p></fieldset><p role="status">{message}</p>{published&&<p><a href={'/documents/'+published.id+'?revision='+published.revision}>打开已发布文档 v{published.revision} ↗</a></p>}{preview&&<article><h2>{preview.document.title}</h2><p>{preview.document.summary}</p><p>适用:{preview.document.applies}</p><p className={styles.body}>{preview.document.body}</p>{preview.document.steps.map((s,i)=><p key={i}>{i+1}. {s}</p>)}{preview.links.map((l,i)=><p key={i}>{l.label} · 条件:{l.when} · {l.resolved?'精确版本可用':'目标缺失,读取时需跳过'}</p>)}<pre>{preview.xml}</pre></article>}</main>;
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Braces,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Globe2,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
|
||||
import {
|
||||
EmptyState,
|
||||
KindBadge,
|
||||
Notice,
|
||||
PageHeading,
|
||||
kindNames,
|
||||
} from "@/components/GuidanceUI";
|
||||
import { guidanceXml, type Guidance } from "@/content/guides/catalog";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
const blank: Guidance = {
|
||||
id: "",
|
||||
revision: 1,
|
||||
kind: "guide",
|
||||
title: "",
|
||||
summary: "",
|
||||
applies: "",
|
||||
body: "",
|
||||
steps: [],
|
||||
checks: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
};
|
||||
type Preview = {
|
||||
document: Guidance;
|
||||
xml: string;
|
||||
links: { label: string; when: string; resolved: boolean }[];
|
||||
};
|
||||
function fieldName(path: string) {
|
||||
const labels: Record<string, string> = {
|
||||
id: "文档短名",
|
||||
revision: "版本",
|
||||
kind: "文档类型",
|
||||
title: "标题",
|
||||
summary: "一句话摘要",
|
||||
applies: "适用条件",
|
||||
body: "正文",
|
||||
steps: "建议步骤",
|
||||
checks: "完成检查",
|
||||
tags: "标签",
|
||||
target: "目标文档 ID",
|
||||
relation: "关系类型",
|
||||
when: "读取条件",
|
||||
label: "链接标题",
|
||||
};
|
||||
const [group, index, field] = path.split(".");
|
||||
if (group === "links")
|
||||
return (
|
||||
"关联文档" +
|
||||
(index === undefined ? "" : " " + (Number(index) + 1)) +
|
||||
(field ? " · " + (labels[field] || "内容") : "")
|
||||
);
|
||||
return (
|
||||
(labels[group] || "文档内容") +
|
||||
(index !== undefined ? " " + (Number(index) + 1) : "")
|
||||
);
|
||||
}
|
||||
async function responseBody(response: Response) {
|
||||
const body = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "服务返回异常,请稍后重试" }));
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
(body.error || "请求失败") +
|
||||
(body.fields?.length
|
||||
? ":" + body.fields.map(fieldName).join("、")
|
||||
: ""),
|
||||
);
|
||||
return body;
|
||||
}
|
||||
export default function Write() {
|
||||
return (
|
||||
<GuidanceShell title="发布文档">
|
||||
<Editor />
|
||||
</GuidanceShell>
|
||||
);
|
||||
}
|
||||
function Editor() {
|
||||
const account = useGuidanceAccount(),
|
||||
router = useRouter(),
|
||||
dialog = useRef<HTMLDialogElement>(null);
|
||||
const [doc, setDoc] = useState<Guidance>(blank),
|
||||
[xml, setXml] = useState(""),
|
||||
[mode, setMode] = useState<"form" | "xml">("form"),
|
||||
[busy, setBusy] = useState(false),
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[preview, setPreview] = useState<Preview | null>(null),
|
||||
[published, setPublished] = useState<{
|
||||
id: string;
|
||||
revision: number;
|
||||
} | null>(null),
|
||||
[dirty, setDirty] = useState(true);
|
||||
const prefix = account.user ? "user-" + account.user.id + "-" : "",
|
||||
editId = typeof router.query.id === "string" ? router.query.id : "";
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !account.user) return;
|
||||
let active = true;
|
||||
setError("");
|
||||
setPublished(null);
|
||||
setPreview(null);
|
||||
setMode("form");
|
||||
setDirty(true);
|
||||
if (!editId) {
|
||||
setDoc({
|
||||
...blank,
|
||||
id:
|
||||
"user-" +
|
||||
account.user.id +
|
||||
"-document-" +
|
||||
Math.random().toString(16).slice(2, 8),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!editId.startsWith("user-" + account.user.id + "-")) {
|
||||
setError("只能编辑你自己发布的文档。");
|
||||
setDoc(blank);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
fetch("/api/documents/" + encodeURIComponent(editId))
|
||||
.then(responseBody)
|
||||
.then((body) => {
|
||||
if (active)
|
||||
setDoc({ ...body.document, revision: body.document.revision + 1 });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) {
|
||||
setError(e.message);
|
||||
setDoc(blank);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [account.user?.id, editId, router.isReady]);
|
||||
function change<K extends keyof Guidance>(key: K, value: Guidance[K]) {
|
||||
setDoc((current) => ({ ...current, [key]: value }));
|
||||
setPreview(null);
|
||||
setDirty(true);
|
||||
setPublished(null);
|
||||
}
|
||||
async function request(action: "preview" | "publish" | "convert") {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setPreview(null);
|
||||
try {
|
||||
const body = await responseBody(
|
||||
await fetch(
|
||||
"/api/documents" + (action === "publish" ? "" : "/preview"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type":
|
||||
mode === "xml" ? "application/xml" : "application/json",
|
||||
},
|
||||
body: mode === "xml" ? xml : JSON.stringify(normalized()),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (action === "convert") {
|
||||
setDoc(body.document);
|
||||
setMode("form");
|
||||
} else if (action === "preview") {
|
||||
setPreview(body);
|
||||
dialog.current?.showModal();
|
||||
} else {
|
||||
setDoc({ ...body.document, revision: body.document.revision + 1 });
|
||||
setXml(
|
||||
guidanceXml({
|
||||
...body.document,
|
||||
revision: body.document.revision + 1,
|
||||
}),
|
||||
);
|
||||
setPublished({
|
||||
id: body.document.id,
|
||||
revision: body.document.revision,
|
||||
});
|
||||
setDirty(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "请求失败,请稍后重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function normalized() {
|
||||
return {
|
||||
...doc,
|
||||
steps: doc.steps.filter((step) => step.trim()),
|
||||
checks: doc.checks.filter((check) => check.trim()),
|
||||
tags: doc.tags.filter((tag) => tag.trim()),
|
||||
};
|
||||
}
|
||||
function selectXml() {
|
||||
setXml(guidanceXml(normalized()));
|
||||
setMode("xml");
|
||||
setPreview(null);
|
||||
}
|
||||
const authorized = account.user?.verified_at;
|
||||
return (
|
||||
<>
|
||||
<PageHeading
|
||||
title="发布独立文档"
|
||||
description="一篇文档,一项清晰的指导。按需关联,让 Agent 自由选择。"
|
||||
eyebrow={editId ? "UPDATE DOCUMENT" : "NEW DOCUMENT"}
|
||||
/>
|
||||
{account.loading ? (
|
||||
<div className={s.accountLoading}>正在读取账户…</div>
|
||||
) : !authorized ? (
|
||||
<section className={`${s.panel} ${s.publishGate}`}>
|
||||
<EmptyState
|
||||
title={account.user ? "验证邮箱后继续" : "登录后开始编写"}
|
||||
description={
|
||||
account.error ||
|
||||
"公开阅读无需登录。发布和维护文档,需要一个已验证的邮箱账户。"
|
||||
}
|
||||
>
|
||||
<Link className={s.primaryButton} href="/account?next=%2Fwrite">
|
||||
<ShieldCheck size={15} />
|
||||
{account.user ? "前往验证邮箱" : "登录 / 创建账户"}
|
||||
</Link>
|
||||
</EmptyState>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{error && <Notice tone="error">{error}</Notice>}
|
||||
{published && (
|
||||
<Notice tone="success">
|
||||
版本 v{published.revision} 已公开发布。
|
||||
<div className={s.successActions}>
|
||||
<Link
|
||||
href={
|
||||
"/documents/" +
|
||||
published.id +
|
||||
"?revision=" +
|
||||
published.revision
|
||||
}
|
||||
>
|
||||
查看已发布版本
|
||||
<ArrowUpRight size={12} />
|
||||
</Link>
|
||||
<Link href="/documents?scope=mine">查看我的文档</Link>
|
||||
</div>
|
||||
</Notice>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className={s.accountLoading}>正在加载最新版本…</div>
|
||||
) : (
|
||||
doc.id && (
|
||||
<fieldset className={s.formFieldset} disabled={busy}>
|
||||
<div className={s.editorTabs} aria-label="编辑方式">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "form"}
|
||||
onClick={() => {
|
||||
if (mode === "xml") void request("convert");
|
||||
}}
|
||||
>
|
||||
可视化编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "xml"}
|
||||
onClick={() => {
|
||||
if (mode === "form") selectXml();
|
||||
}}
|
||||
>
|
||||
协议 XML
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.formGrid}>
|
||||
<div className={s.formSections}>
|
||||
{mode === "xml" ? (
|
||||
<section className={`${s.panel} ${s.formPanel}`}>
|
||||
<h2 className={s.formSectionTitle}>
|
||||
<Braces size={17} />
|
||||
协议源文档
|
||||
</h2>
|
||||
<p className={s.formDescription}>
|
||||
可直接粘贴完整
|
||||
XML。切回可视化编辑时,会先校验并解析当前内容;校验失败时保留输入。
|
||||
</p>
|
||||
<label className={s.formField}>
|
||||
<span className={s.fieldLabel}>XML 输入</span>
|
||||
<textarea
|
||||
className={`${s.textarea} ${s.xmlInput}`}
|
||||
spellCheck={false}
|
||||
value={xml}
|
||||
onChange={(e) => {
|
||||
setXml(e.target.value);
|
||||
setDirty(true);
|
||||
setPreview(null);
|
||||
setPublished(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className={`${s.panel} ${s.formPanel}`}>
|
||||
<h2 className={s.formSectionTitle}>
|
||||
<FileText size={17} />
|
||||
基本信息
|
||||
</h2>
|
||||
<p className={s.formDescription}>
|
||||
帮助读者和 Agent 判断,这篇文档是否适合当前任务。
|
||||
</p>
|
||||
<div className={s.fieldColumns}>
|
||||
<label className={s.formField}>
|
||||
<span className={s.fieldLabel}>文档短名</span>
|
||||
<input
|
||||
className={s.input}
|
||||
aria-label="文档短名"
|
||||
maxLength={37}
|
||||
pattern="[a-z0-9-]+"
|
||||
value={doc.id.slice(prefix.length)}
|
||||
disabled={!!editId || doc.revision > 1}
|
||||
onChange={(e) =>
|
||||
change("id", prefix + e.target.value)
|
||||
}
|
||||
/>
|
||||
<small className={s.fieldHint}>
|
||||
小写字母、数字和连字符。发布后不可修改。
|
||||
</small>
|
||||
</label>
|
||||
<label className={s.formField}>
|
||||
<span className={s.fieldLabel}>文档类型</span>
|
||||
<select
|
||||
className={s.select}
|
||||
value={doc.kind}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
"kind",
|
||||
e.target.value as Guidance["kind"],
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(kindNames).map(
|
||||
([id, label]) => (
|
||||
<option value={id} key={id}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{(["title", "summary", "applies"] as const).map(
|
||||
(key) => (
|
||||
<label className={s.formField} key={key}>
|
||||
<span className={s.fieldLabel}>
|
||||
{
|
||||
{
|
||||
title: "标题",
|
||||
summary: "一句话摘要",
|
||||
applies: "适用条件",
|
||||
}[key]
|
||||
}
|
||||
<span className={s.required}>*</span>
|
||||
</span>
|
||||
{key === "title" ? (
|
||||
<input
|
||||
className={s.input}
|
||||
value={doc[key]}
|
||||
placeholder="例如:审查一次代码改动"
|
||||
onChange={(e) =>
|
||||
change(key, e.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className={s.textarea}
|
||||
rows={2}
|
||||
value={doc[key]}
|
||||
placeholder={
|
||||
key === "summary"
|
||||
? "这篇文档可以帮助完成什么?"
|
||||
: "什么情况下应该阅读?哪些情况不适用?"
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(key, e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
<label className={s.formField}>
|
||||
<span className={s.fieldLabel}>标签</span>
|
||||
<input
|
||||
className={s.input}
|
||||
value={doc.tags.join(", ")}
|
||||
placeholder="代码审查, 开发协作"
|
||||
onChange={(e) =>
|
||||
change(
|
||||
"tags",
|
||||
e.target.value
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim()),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<small className={s.fieldHint}>
|
||||
使用逗号分隔。
|
||||
</small>
|
||||
</label>
|
||||
</section>
|
||||
<section className={`${s.panel} ${s.formPanel}`}>
|
||||
<h2 className={s.formSectionTitle}>
|
||||
<ListChecks size={17} />
|
||||
指导内容
|
||||
</h2>
|
||||
<p className={s.formDescription}>
|
||||
正文应当完整、可独立理解。步骤和检查项可以留空。
|
||||
</p>
|
||||
<label className={s.formField}>
|
||||
<span className={s.fieldLabel}>
|
||||
正文<span className={s.required}>*</span>
|
||||
</span>
|
||||
<textarea
|
||||
className={`${s.textarea} ${s.bodyInput}`}
|
||||
value={doc.body}
|
||||
placeholder="写下清晰、具体的指导…"
|
||||
onChange={(e) => change("body", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{(["steps", "checks"] as const).map((key) => (
|
||||
<label className={s.formField} key={key}>
|
||||
<span className={s.fieldLabel}>
|
||||
{key === "steps" ? "建议步骤" : "完成检查"}
|
||||
</span>
|
||||
<textarea
|
||||
className={s.textarea}
|
||||
aria-label={
|
||||
key === "steps" ? "建议步骤" : "完成检查"
|
||||
}
|
||||
rows={3}
|
||||
value={doc[key].join("\n")}
|
||||
onChange={(e) =>
|
||||
change(key, e.target.value.split("\n"))
|
||||
}
|
||||
/>
|
||||
<small className={s.fieldHint}>
|
||||
每行一项,空行会在提交时忽略。
|
||||
</small>
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
<section className={`${s.panel} ${s.formPanel}`}>
|
||||
<h2 className={s.formSectionTitle}>
|
||||
<Link2 size={17} />
|
||||
条件链接
|
||||
<span className={s.neutralBadge}>
|
||||
{doc.links.length}
|
||||
</span>
|
||||
</h2>
|
||||
<p className={s.formDescription}>
|
||||
仅当条件适用时,才建议读取关联文档。请指定固定版本;无需链接也可以独立发布。
|
||||
</p>
|
||||
{!doc.links.length && (
|
||||
<div className={s.linkEmpty}>
|
||||
还没有关联文档。你可以先完成正文,再为需要的场景补充链接。
|
||||
</div>
|
||||
)}
|
||||
{doc.links.map((link, index) => (
|
||||
<div className={s.linkEditor} key={index}>
|
||||
<div className={s.linkEditorHeader}>
|
||||
关联文档 {index + 1}
|
||||
<button
|
||||
type="button"
|
||||
className={s.quietButton}
|
||||
aria-label={"移除关联文档 " + (index + 1)}
|
||||
onClick={() =>
|
||||
change(
|
||||
"links",
|
||||
doc.links.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.fieldColumns}>
|
||||
{(
|
||||
[
|
||||
"target",
|
||||
"revision",
|
||||
"relation",
|
||||
"label",
|
||||
"when",
|
||||
] as const
|
||||
).map((key) => (
|
||||
<label className={s.formField} key={key}>
|
||||
<span className={s.fieldLabel}>
|
||||
{
|
||||
{
|
||||
target: "目标文档 ID",
|
||||
revision: "固定版本",
|
||||
relation: "关系类型",
|
||||
label: "链接标题",
|
||||
when: "什么情况下读取",
|
||||
}[key]
|
||||
}
|
||||
</span>
|
||||
{key === "relation" ? (
|
||||
<select
|
||||
className={s.select}
|
||||
value={link.relation}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
"links",
|
||||
doc.links.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
relation: e.target
|
||||
.value as Guidance["kind"],
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(kindNames).map(
|
||||
([id, label]) => (
|
||||
<option value={id} key={id}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className={s.input}
|
||||
type={
|
||||
key === "revision" ? "number" : "text"
|
||||
}
|
||||
min={key === "revision" ? 1 : undefined}
|
||||
value={link[key]}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
"links",
|
||||
doc.links.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
[key]:
|
||||
key === "revision"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={s.secondaryButton}
|
||||
onClick={() =>
|
||||
change("links", [
|
||||
...doc.links,
|
||||
{
|
||||
target: "",
|
||||
revision: 1,
|
||||
relation: "context",
|
||||
when: "",
|
||||
label: "",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加条件链接
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<aside className={s.publishSidebar}>
|
||||
<section className={s.sideCard}>
|
||||
<h3>
|
||||
<Globe2 size={15} />
|
||||
发布设置
|
||||
</h3>
|
||||
<div className={s.publishMeta}>
|
||||
公开文档 · 待发布版本 v{doc.revision}
|
||||
</div>
|
||||
<KindBadge kind={doc.kind} />
|
||||
<ul className={s.publishChecklist}>
|
||||
<li className={s.completed}>
|
||||
<CheckCircle2 size={14} />
|
||||
邮箱已验证
|
||||
</li>
|
||||
<li>
|
||||
<CheckCircle2 size={14} />
|
||||
发布后任何人均可读取
|
||||
</li>
|
||||
<li>
|
||||
<CheckCircle2 size={14} />
|
||||
每次发布创建不可变版本
|
||||
</li>
|
||||
</ul>
|
||||
<p>内容暂存在当前页面,请在离开前发布或自行保存 XML。</p>
|
||||
<div className={s.publishButtons}>
|
||||
<button
|
||||
type="button"
|
||||
className={s.secondaryButton}
|
||||
onClick={() => void request("preview")}
|
||||
>
|
||||
校验并预览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={s.primaryButton}
|
||||
disabled={!dirty}
|
||||
onClick={() => void request("publish")}
|
||||
>
|
||||
{busy
|
||||
? "正在处理…"
|
||||
: doc.revision === 1
|
||||
? "发布文档"
|
||||
: "发布新版本"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className={s.sideCard}>
|
||||
<h3>独立使用,按需连接</h3>
|
||||
<p>
|
||||
关系类型和适用条件帮助 Agent
|
||||
选择下一篇文档。链接不会继承账户权限,也不要求整组加载。
|
||||
</p>
|
||||
<Link className={s.textLink} href="/protocol">
|
||||
阅读协议说明
|
||||
<ArrowUpRight size={12} />
|
||||
</Link>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<dialog
|
||||
className={`${s.modal} ${s.largeModal}`}
|
||||
ref={dialog}
|
||||
aria-labelledby="preview-title"
|
||||
>
|
||||
<div className={s.modalBody}>
|
||||
<h2 id="preview-title">发布预览</h2>
|
||||
<p>校验已通过,内容尚未发布。请确认正文和关联条件。</p>
|
||||
</div>
|
||||
{preview && (
|
||||
<div className={s.previewBody}>
|
||||
<KindBadge kind={preview.document.kind} />
|
||||
<h2 className={s.previewTitle}>{preview.document.title}</h2>
|
||||
<p className={s.formDescription}>{preview.document.summary}</p>
|
||||
<div className={s.appliesCard}>
|
||||
<p>{preview.document.applies}</p>
|
||||
</div>
|
||||
<div className={s.proseBody}>{preview.document.body}</div>
|
||||
{preview.document.steps.length > 0 && (
|
||||
<ol className={s.stepList}>
|
||||
{preview.document.steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{preview.document.checks.length > 0 && (
|
||||
<ul className={s.checkList}>
|
||||
{preview.document.checks.map((check, i) => (
|
||||
<li key={i}>{check}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{preview.links.map((link, i) => (
|
||||
<div className={s.relationCard} key={i}>
|
||||
<div>
|
||||
<strong>{link.label}</strong>
|
||||
<p>当{link.when}</p>
|
||||
<small>
|
||||
{link.resolved ? "固定版本可用" : "目标缺失,读取时需跳过"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={s.modalActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={s.secondaryButton}
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
返回编辑
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,6 +27,7 @@ export async function verifyGuidancePublishing(base:string){
|
||||
const created=await request('','POST',guidanceXml(httpDoc),{'Content-Type':'application/xml'});assert.equal(created.status,201,await created.clone().text());
|
||||
assert.equal((await request('','POST',httpDoc)).status,409);
|
||||
assert.equal((await request('','POST',{...httpDoc,id:'user-'+other+'-http'})).status,400);
|
||||
const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400);
|
||||
const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/<tag>往返标签<\/tag>/);
|
||||
const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档'));
|
||||
// The homepage and discovery list hydrate from the real API, so verify them in Chromium on the CI runner.
|
||||
@@ -35,7 +36,7 @@ export async function verifyGuidancePublishing(base:string){
|
||||
const page=await browser.newPage();
|
||||
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
|
||||
await page.goto(base+'/',{waitUntil:'domcontentloaded'});
|
||||
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).textContent(),httpDoc.title);
|
||||
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title);
|
||||
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
|
||||
const search=page.getByRole('textbox',{name:'搜索文档'});
|
||||
await search.fill(httpDoc.title);await search.press('Enter');
|
||||
@@ -45,14 +46,14 @@ export async function verifyGuidancePublishing(base:string){
|
||||
await page.reload({waitUntil:'domcontentloaded'});
|
||||
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
|
||||
assert.equal(await search.inputValue(),httpDoc.title);
|
||||
await page.getByRole('button',{name:'事实依据'}).click();
|
||||
await page.getByRole('tab',{name:/事实依据/}).click();
|
||||
await page.waitForURL(url=>url.searchParams.get('kind')==='evidence');
|
||||
await page.getByText('没有匹配文档,请尝试其他关键词或清除筛选。',{exact:true}).waitFor();
|
||||
await page.getByRole('heading',{name:'没有匹配文档',exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'清除所有筛选'}).click();
|
||||
await page.waitForURL(url=>url.search==='');
|
||||
await page.locator(link).waitFor({state:'visible'});
|
||||
assert.equal(await search.inputValue(),'');
|
||||
await page.locator('header a[href="/documents"]').click();await page.locator(link).waitFor({state:'visible'});
|
||||
await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'});
|
||||
await page.locator(link).click();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title);
|
||||
await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');
|
||||
}finally{await browser.close();}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/** UI contract checks use isolated API fixtures; real storage/auth remain covered by guidance-integration.ts. */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright-core";
|
||||
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
|
||||
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
|
||||
const base = process.env.WORKSPACE_TEST_URL;
|
||||
const owner = "11111111-1111-4111-8111-111111111111";
|
||||
const authored: Guidance = {
|
||||
id: "user-" + owner + "-review",
|
||||
revision: 1,
|
||||
kind: "guide",
|
||||
title: "准备一次清晰的代码审查",
|
||||
summary: "用明确的问题和证据,完成一次有用的代码审查。",
|
||||
applies: "提交合并请求前",
|
||||
body: "先理解改动目标,再核对实现与验证。",
|
||||
steps: [],
|
||||
checks: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
};
|
||||
test(
|
||||
"workspace filters, reading tabs, account states and editor work on desktop and mobile",
|
||||
{ skip: !base },
|
||||
async () => {
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROMIUM_PATH ||
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
let signedIn = false,
|
||||
previewError = false,
|
||||
requestedAuthor: string | null = null,
|
||||
published: Guidance | null = null;
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
await context.route("**/api/account/session", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
user: signedIn
|
||||
? {
|
||||
id: owner,
|
||||
email: "editor@example.com",
|
||||
verified_at: "2026-10-29T00:00:00Z",
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await context.route(
|
||||
(url) => url.pathname === "/api/documents",
|
||||
async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
requestedAuthor = new URL(route.request().url()).searchParams.get(
|
||||
"author",
|
||||
);
|
||||
return route.fulfill({
|
||||
json: { documents: [{ ...authored, author: owner }] },
|
||||
});
|
||||
}
|
||||
published = documentInput.parse(route.request().postDataJSON());
|
||||
return route.fulfill({ status: 201, json: { document: published } });
|
||||
},
|
||||
);
|
||||
await context.route("**/api/documents/preview", (route) => {
|
||||
if (previewError)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "输入不符合协议", fields: ["applies"] },
|
||||
});
|
||||
let document: Guidance;
|
||||
try {
|
||||
document =
|
||||
route.request().headers()["content-type"] === "application/xml"
|
||||
? parseDocumentXml(route.request().postData() || "")
|
||||
: documentInput.parse(route.request().postDataJSON());
|
||||
} catch {
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { error: "输入不符合协议" },
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
document,
|
||||
xml: guidanceXml(document),
|
||||
links: document.links.map((link) => ({ ...link, resolved: true })),
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route("**/api/documents/" + authored.id, (route) =>
|
||||
route.fulfill({ json: { document: authored } }),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(base!, { waitUntil: "networkidle" });
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.title(), "探索文档 · 织页");
|
||||
const search = page.getByRole("textbox", { name: "搜索文档" });
|
||||
await search.fill("不存在的文档");
|
||||
await search.press("Enter");
|
||||
await page
|
||||
.getByRole("heading", { name: "没有匹配文档", exact: true })
|
||||
.waitFor();
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
assert.equal(await search.inputValue(), "不存在的文档");
|
||||
await page
|
||||
.getByRole("button", { name: "清除所有筛选", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
await page.getByRole("tab", { name: /事实依据/ }).focus();
|
||||
await page.keyboard.press("Home");
|
||||
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
|
||||
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.goto(base + "/guide/review-changes?revision=1", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
await page.getByRole("tab", { name: "XML 源文档" }).click();
|
||||
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
|
||||
await page.getByRole("tab", { name: /版本记录/ }).click();
|
||||
await page.getByText("当前阅读", { exact: true }).waitFor();
|
||||
await page.getByRole("tab", { name: "文档概览" }).click();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/account", { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "创建账户", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
|
||||
await page
|
||||
.getByLabel("密码", { exact: true })
|
||||
.fill("a-long-example-password");
|
||||
await page.getByRole("button", { name: "显示密码" }).click();
|
||||
assert.equal(
|
||||
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
|
||||
"text",
|
||||
);
|
||||
await page.getByRole("button", { name: "隐藏密码" }).click();
|
||||
await page.getByLabel("密码", { exact: true }).fill("");
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/write", { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
|
||||
signedIn = true;
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
|
||||
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
|
||||
await page
|
||||
.getByLabel("一句话摘要", { exact: false })
|
||||
.fill("可独立使用的指导");
|
||||
await page
|
||||
.getByLabel("适用条件", { exact: false })
|
||||
.fill("检查界面流程时");
|
||||
await page
|
||||
.getByLabel("正文", { exact: false })
|
||||
.fill("先判断适用条件,再阅读正文。");
|
||||
await page
|
||||
.getByLabel("建议步骤", { exact: true })
|
||||
.fill("第一步\n\n第二步");
|
||||
previewError = true;
|
||||
await page
|
||||
.getByRole("button", { name: "校验并预览", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
|
||||
previewError = false;
|
||||
await page
|
||||
.getByRole("button", { name: "校验并预览", exact: true })
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "返回编辑" }).click();
|
||||
assert.equal(
|
||||
await page.getByLabel("正文", { exact: false }).inputValue(),
|
||||
"先判断适用条件,再阅读正文。",
|
||||
);
|
||||
await page.getByRole("button", { name: "添加条件链接" }).click();
|
||||
await page.getByRole("button", { name: "移除关联文档 1" }).click();
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "发布文档", exact: true }).click();
|
||||
await page
|
||||
.getByRole("status")
|
||||
.filter({ hasText: "已公开发布" })
|
||||
.waitFor();
|
||||
assert.ok(published);
|
||||
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
|
||||
assert.equal(
|
||||
(published as Guidance).id,
|
||||
"user-" + owner + "-workspace-test",
|
||||
);
|
||||
await page.goto(base + "/documents?scope=mine", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
assert.equal(requestedAuthor, owner);
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
await page.goto(base + "/write?id=" + authored.id, {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
assert.equal(
|
||||
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel("标题", { exact: false }).inputValue(),
|
||||
authored.title,
|
||||
);
|
||||
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
|
||||
const xmlInput = page.getByLabel("XML 输入");
|
||||
assert.match(await xmlInput.inputValue(), /revision="2"/);
|
||||
await xmlInput.fill(
|
||||
(await xmlInput.inputValue()).replace(
|
||||
authored.title,
|
||||
"通过 XML 更新的文档",
|
||||
),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "可视化编辑", exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page.getByLabel("标题", { exact: false }).inputValue(),
|
||||
"通过 XML 更新的文档",
|
||||
);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(base!, { waitUntil: "networkidle" });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.getByRole("button", { name: "打开导航" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "工作区导航" })
|
||||
.waitFor({ state: "visible" });
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(
|
||||
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
|
||||
false,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/guide/review-changes?revision=1", {
|
||||
waitUntil: "networkidle",
|
||||
});
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/account", { waitUntil: "networkidle" });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.goto(base + "/write", { waitUntil: "networkidle" });
|
||||
await page.getByLabel("文档短名", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
|
||||
await page.getByLabel("XML 输入").waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await page.goto(base + "/protocol", { waitUntil: "networkidle" });
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.env.WORKSPACE_SCREENSHOT_DIR)
|
||||
await page.screenshot({
|
||||
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -31,6 +31,8 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
|
||||
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
|
||||
await verifyEmailAccounts();
|
||||
await verifyGuidancePublishing(base);
|
||||
const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:{...process.env,WORKSPACE_TEST_URL:base},timeout:90000,stdio:'inherit'});
|
||||
const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks');
|
||||
await verifyFlatNoteMigration();
|
||||
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
|
||||
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
|
||||
@@ -214,8 +216,8 @@ 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'])assert.ok(home.includes('href=\"'+href+'\"'),`Guidance homepage links to ${href}`);
|
||||
assert.ok(home.includes('用户发布'));assert.ok(home.includes('协议样例'));
|
||||
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('协议样例'));
|
||||
// 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');
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
# 交付验收清单
|
||||
# 交付验收记录
|
||||
|
||||
这是要求与证据的对照表,不能用提交、PR 或测试意图代替实际验收。所有 PR 由用户手动合并。构建只使用 ces-control,网页只在指定服务器验收。
|
||||
所有 PR 由用户手动合并,只有 main 更新触发 ces-control CI/CD。用户后续明确允许在当前项目目录内本地开发和测试;本次 SaaS 重构使用独立工作区,结束后清理自建临时依赖、预览和构建产物。
|
||||
|
||||
| 用户要求 | 应验证的行为 | 当前证据 / 待办 |
|
||||
| --- | --- | --- |
|
||||
| 研究 LLM Wiki | 能指出原始提案与本项目新增设计的边界 | protocol.md 引用 Karpathy 原始 gist;XML、条件链接和版本规则为本项目提案 |
|
||||
| SkillHub 逐页截图与设计分析 | 首页、目录、详情各标签、作者、登录、发布入口具备截图和观察记录 | 主 chat 已截取首页首屏与下半屏、目录、概述、文件、版本、评论、评测、登录、作者及发布限制;另补插件、SkillPay、大赛、使用指南、专家包列表/详情与企业专区首屏;截图目前在 chat 中,窄屏参考待补 |
|
||||
| 可独立使用的文档 | 单篇内容具有适用条件、正文和步骤,不要求安装包 | catalog.ts 与 /guide 原型;真实文档实现已随 PR #9 合入,待 CI 与运行时验证 |
|
||||
| 按链接寻找帮助 | 条件、关系、精确版本可读,缺失目标明确,背景不升级权限 | 协议及静态阅读页;真实文档预览与链接解析待 PR #9 验证 |
|
||||
| 新语法 | XML 可序列化、校验、往返,拒绝未知协议与非法实体 | 原型 XML 输出已合入;严格输入与往返测试已随 PR #9 合入,远程执行待确认 |
|
||||
| 基于现有 wiki 改造 | 沿用 Pages Router 与现有部署数据 | main 已包含新首页原型,实际服务仍在旧版本,等待 CI 部署 |
|
||||
| 保留旧小红书 UI | 旧首页和组件仍可在代码库找到,新入口不再使用 | src/legacy/WaterfallHome.tsx 以及原 ExploreFeed/Shell 保留;部署后需检查新首页 |
|
||||
| 邮箱与密码登录并验证邮箱 | 注册发信、验证一次消费、未验证不能登录、会话可注销 | #6/#8 已由用户合并;SMTP 配置、TLS 登录及诊断邮件实际投递已验证;实际注册、验证和登录待部署 |
|
||||
| 正常用户与管理员隔离 | 普通邮箱 cookie 无法授权旧管理写 API | auth.test.ts 与 account-integration.ts;最终 main CI 结果仍待确认 |
|
||||
| 真实发布与版本 | 已验证作者能发布和更新,其他人不能覆盖,旧版本不变 | PR #9 的 PostgreSQL 所有权/并发/撤下保留测试,待远程执行与网页验证 |
|
||||
| 独立开发与人工合并 | 每个方向独立分支和可评审 PR | #6/#7/#8/#9/#11/#12/#13/#14/#15/#16 已由用户合并;#10 待最终证据完善;所有 merge 由用户完成 |
|
||||
## 已部署功能的实际证据
|
||||
|
||||
## SMTP 验收边界
|
||||
2026-10-29,main run 508 / job 556 成功:类型检查、51 项单元测试、生产构建、smoke、真实 PostgreSQL/S3/Chromium 集成、认证检查与生产部署全部通过。验收入口为 http://8.211.154.134:18131/ ,健康检查返回应用版本 `9a603cf87e9b726a566ae68f4f479030e0d02629`。随后 #18 仅修改工作流与说明,没有应用代码变更。
|
||||
|
||||
配置已写入服务器 root 所有、0600 权限的 /etc/prompt.env。密码不在仓库、PR 或 CI 日志中。SMTP_SSL 证书验证、连接与认证已通过。2026-10-29 10:23 UTC 发送诊断邮件 LG-SMTP-20261029-01,SMTP 接受;随后通过 Gmail 定向检索确认该编号邮件位于指定测试收件箱的 INBOX、UNREAD、CATEGORY_PERSONAL,非垃圾邮件。诊断邮件不含验证链接;这不证明注册流程已经完成。生产服务尚未因配置而手动重启,等待正常 CI/CD 部署。
|
||||
| 用户要求 | 已有证据 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 研究 LLM Wiki 与新语法 | protocol.md 引用 Karpathy 原始 gist;独立 XML、条件链接、关系、预算与版本规则是本项目提出的实验协议,不声称 Agent 已原生支持 |
|
||||
| SkillHub 截图与设计分析 | chat 中已截取首页、目录、详情概述/文件/版本/评论/评测、登录、作者、发布限制;补充插件、SkillPay、大赛、使用指南、专家包与企业专区首屏。受限发布编辑器未完整验收 |
|
||||
| 单篇可独立采用 | 公开入口 GET/HEAD、Agent 索引、样例与持久文档读取均已验证;不要求安装整个集合 |
|
||||
| 条件链接与固定版本 | 实际预览解析 source-trust 固定版本;缺失版本返回 404;XML 条件和标签往返已验证 |
|
||||
| 邮箱、密码、邮箱验证 | 指定真实收件箱收到注册邮件并确认位于 INBOX;未验证登录 401,验证一次 200、重复消费 400,验证后登录 200 |
|
||||
| 普通账户权限隔离 | 普通邮箱会话调用旧管理员写 API 返回 401;匿名预览 401,跨来源写入 403 |
|
||||
| 真实发布与版本 | 实际发布 v1/v2,旧版本正文保持不变,旧版本号重复发布返回 409;目录返回最新版本 |
|
||||
| 撤下与退出 | 验收文档已撤下,最新入口 404、固定历史版本 200、公开目录不再列出;注销后会话为空 |
|
||||
| 保留旧界面 | src/legacy/WaterfallHome.tsx 与旧 Shell/ExploreFeed 保留,新入口不再使用旧首页;旧内容路由仍在集成测试覆盖范围内 |
|
||||
| 独立分支、人工合并 | #6–#18 由用户合并;当前 SaaS 重构使用 codex/wiki-saas,交付 PR 等待用户手动合并 |
|
||||
|
||||
## 当前未完成工作
|
||||
## 邮件与敏感信息
|
||||
|
||||
补齐窄屏参考及验收截图;完成当前 main 的远程 CI(仅 main 更新触发,PR/手动不触发);其余 PR 由用户手动合并;等待正常部署;在服务器上测试邮箱注册验证与文档发布、编辑、精确版本及关联阅读;记录桌面与窄屏效果。只有运行时证据齐全后才认定完整交付。
|
||||
SMTP 运行时配置位于服务器 root 所有、0600 权限的 /etc/prompt.env。密码、注册验证令牌与测试账户密码不进入仓库、PR、截图或报告。早期 SMTP 诊断邮件只证明投递;上述后续真实注册验证才证明完整账户流程。验收文档已撤下;固定历史版本按产品约定保留。
|
||||
|
||||
## Agent 发现入口
|
||||
## 本次 SaaS 视觉重构
|
||||
|
||||
PR #12 将 llms.txt 与 /agent/index.md、/index.md 导向独立指导文档,索引同时包含持久化用户文档与示例,并只公开 GET/HEAD。测试覆盖发现入口、预算与固定版本链接;已由用户合并;远程 CI 和部署验收尚未完成。
|
||||
详细设计参考与页面规范见 ../wiki-saas/DESIGN.md。重构范围为公共外壳、目录、社区/我的文档、阅读、账户、编辑与协议页。
|
||||
|
||||
## 最近的 main CI 证据
|
||||
本地独立预览中的 UI 回归使用明确的 API 测试夹具,覆盖 URL 筛选恢复、清除、键盘标签导航、阅读/XML/版本、账户模式与密码可见性、发布登录门槛、表单错误保留输入、校验预览、发布输入规范化、现有文档编辑、XML 往返、移动端导航关闭及无横向溢出。该测试不代替上述真实数据库、SMTP 和权限验证;已接入 main 集成测试流程。
|
||||
|
||||
run 504(main 1925d15)类型检查、50 项单元测试与生产构建通过,随后 smoke 因缺管理员配置返回 503 而失败;不代表完整 CI 通过。PR #16 仅给绑定回环地址的 smoke 子进程设置显式测试模式,已由用户合并。main 254ac62 的后续检查仍待完成;未在本机编译,未手动触发构建。
|
||||
类型检查、51 项单元测试、生产构建与 standalone smoke 已通过。生产构建保留 preview.ts 动态文件跟踪的既有警告。
|
||||
|
||||
实际检查了 1440px 桌面目录/阅读/账户/编辑器,以及 390px 手机目录/阅读/XML 编辑器/协议截图。原生桌面浏览器当时因 Mac 锁定不可操作,截图由项目隔离的无头 Chromium 回归测试生成,未将其表述为线上部署验收。
|
||||
|
||||
## 剩余交付边界
|
||||
|
||||
SaaS 改版尚未合并和部署。PR 就绪后等待用户手动合并,届时由正常 main CI/CD 运行真实存储集成与部署,再在指定服务器复核新页面。不得把旧版本的成功 CI 或本地夹具截图称为新版线上验证。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Infinite canvas plan
|
||||
|
||||
Scope: only /studio/infinite_canvas, its CSS module, browser interaction checks. Based on main f490543 in isolated worktree. No homepage, graphic, shared authentication or deployment changes.
|
||||
|
||||
## Research before implementation
|
||||
- Baseline server screenshot captured 2026-10-29: existing server requires dynamic-code authentication before route access. Do not bypass it.
|
||||
- Internet image search: AI infinite canvas ComfyUI RunningHub interface; reviewed workflow canvas and media card arrangements. No unlicensed product screenshots copied into product.
|
||||
- https://jimeng.jianying.com — prompt-driven creation, image references, canvas.
|
||||
- https://klingai.com — attempted primary source; robots denied access. No unsupported detailed claims.
|
||||
- https://hailuoai.video — reference input and media-first exploration.
|
||||
- https://www.liblib.art and https://www.liblib.tv — Liblib/LibTV reference targets; client-rendered content limits source inspection.
|
||||
- https://docs.comfy.org/interface/overview — explicit node workflow and workspace regions.
|
||||
- https://www.runninghub.cn — canvas-oriented creation and reusable workflows.
|
||||
- https://github.com/bergside/awesome-design-skills — reference catalogue; apply deliberate typography, spacing, accessible controls and consistent hierarchy, without installing external skills.
|
||||
|
||||
## Design and behavior
|
||||
Dark graphite creation desk with warm lime accent. Narrow left tool rail, large dotted canvas, editable right inspector and floating zoom controls. Start with a clearly labelled sample storyboard: brief → reference → shot plan. Native drag and zoom, selection, editable text/prompt cards, image upload, visible links, add/delete, undo/redo, fit view. Persist bounded browser-local drafts and export/import portable JSON. Images remain local. No simulated generation; generation cards explicitly say model service is not connected. Account link uses existing /account and GET /api/account/session contract.
|
||||
|
||||
## Validation
|
||||
Local TypeScript and existing tests; local build explicitly authorized for this task. Browser checks for desktop/mobile, creation, drag, undo, reload, JSON export and disconnected AI notice. Clean owned screenshots, build directories and temporary dependency links before delivery. Push branch and open PR against main; no merge or deployment.
|
||||
|
||||
## Results
|
||||
TypeScript passed; existing 10 tests passed. Project-local Next CLI webpack build passed. Default Turbopack cannot resolve dependencies outside isolated worktree; webpack standalone tracing warns about the temporary dependency symlinks, so deployment artifact completeness is not claimed. Browser smoke passed on desktop and mobile. No deployment performed.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Studio Graphic 实施计划
|
||||
|
||||
## 研究与范围
|
||||
- 改造前证据:ProjectMedia/studio-editor-workspace-qa.png(2026-10-27);本次在线截图因内置浏览器超时未成功,不当作最新截图。
|
||||
- https://github.com/bergside/awesome-design-skills :参考 token、组件规范、可访问性与质量检查组织方法;其 Stitch 风格不等同于 Google Stitch 产品界面。
|
||||
- https://codelabs.developers.google.com/design-to-code-with-antigravity-stitch :官方界面图参考。
|
||||
- https://moda.app/blog/google-stitch-review :图片搜索找到的多画板与设计系统截图,仅作视觉参考。
|
||||
- Figma 的三栏编辑器布局:图层、画布、属性。采用白色工具栏、冷灰点阵底、蓝紫选中态、集中式底部工具条。
|
||||
|
||||
## 五个交付阶段
|
||||
1. 阅读现有代码、引用 chat,独立分支,协调 infinite_canvas。
|
||||
2. 收集参考与截图、明确设计与复用边界,记录计划。
|
||||
3. /studio/graphic 实现可编辑画板、图层、历史、保存、导出与同站邮箱账户。
|
||||
4. 本地类型、行为、构建和视觉检查;记录真实结果及限制。
|
||||
5. 检查完整差异,提交、推送、创建 PR;清理临时产物,等待人工合并。
|
||||
|
||||
## 清理边界
|
||||
Studio 首页不再展示旧 27 套项目。保留旧路由与数据库,避免破坏现有链接与用户作品;不删除其他 chat 文件。新工作台默认空白,预设只能由用户显式插入。浏览器草稿明确标识本机保存,邮箱账户使用已有 /api/account/* 服务,避免另建身份系统。未配置模型时不提供虚假 AI 生成。
|
||||
|
||||
## 分工
|
||||
Infinite Canvas 独立 chat 01a18775-33e3-7770-ba63-64b4a6a00c89,仅负责 /studio/infinite_canvas 与专属文件。两分支完成后再评估集成,不自动合并 main。
|
||||
|
||||
## 验证记录
|
||||
- 用户已确认只清理入口、保留历史数据。
|
||||
- 13 项单元测试和 TypeScript 通过。webpack 生产构建通过;默认 Turbopack 被本地 CSS 工作进程端口权限阻止。
|
||||
- 自动浏览器检查通过:新增文字/矩形、文字修改、撤销/重做、刷新恢复、SVG 下载、账户弹窗、390px 无横向溢出。截图布局已检查,交付前清理截图。
|
||||
- 账户 session 使用访客 fixture;真实邮件注册登录未在本次验证。独立 Studio 本地进程没有账户后端,部署后复用同域 /api/account 服务。
|
||||
- 单画板与本机草稿,无云端设计同步、协作或 AI 生成。完整属性编辑以桌面为主。
|
||||
- Infinite Canvas 独立 PR #19 已由外部操作合入 main;本分支交付前同步该主线,检查共存。
|
||||
|
||||
- standalone 兼容检查通过:27 套历史页面、保存作品读取、旧重定向与渲染 API;测试脚本支持本地 monorepo standalone 路径。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 织页 SaaS 界面重构
|
||||
|
||||
用户明确要求以正式 SaaS 产品为标准,重做当前视觉;保留独立指导文档、条件链接、固定版本与邮箱账户的功能。本次本地开发测试获用户在“重构 Studio 设计与无限画布”chat 的直接授权,最终清理自建临时产物,不删除用户原有文件。所有 main 合并仍由用户完成。
|
||||
|
||||
## 设计参考与取舍
|
||||
|
||||
- https://github.com/bergside/awesome-design-skills :阅读 professional、shadcn、clean 与 minimal 的说明,采用语义令牌、明确状态、统一间距和键盘可达性。professional 当前示例的商店品牌与黄色不适合知识工作台,不直接复制其品牌色。
|
||||
- https://ui.shadcn.com/docs/components/sidebar :参考侧栏的导航分组、账户区与移动端抽屉结构,使用现有 React/CSS Modules 实现,不引入新的组件库依赖。
|
||||
- https://linear.app/features :参考工作型产品的信息层级和克制的工具界面,而非复制营销首页。
|
||||
- 原 SkillHub、Hugging Face、GitHub 研究继续用于文档发现、单篇读取、来源与版本的组织。集合不会变成强制加载单位。
|
||||
|
||||
## 视觉基础
|
||||
|
||||
背景 #F8F9FC,面板 #FFFFFF,正文 #182230,次级文字 #667085,分隔线 #EAECF0,主色 #4F46E5。错误/成功颜色只表达真实反馈。间距 4/8/12/16/24/32,常规正文 14px,阅读正文 15–16px,页面标题 28px。按钮圆角 8px,面板圆角 12px;不使用装饰性渐变、低对比文字、虚假图表或无行为控件。
|
||||
|
||||
## 页面与实现
|
||||
|
||||
1. 应用外壳:左侧工作区、文档导航、资源和真实账户状态;顶部面包屑与创建入口;移动端可关闭、可键盘操作的导航。
|
||||
2. 目录:首页与社区/我的文档复用搜索、类型/来源筛选、排序、URL 恢复、按作者查询的我的文档(在数据库限制最近 100 篇前筛选作者)、加载/错误/空状态与文档行;数量来自实际内容。
|
||||
3. 阅读页:摘要、适用条件、正文、步骤、检查项、条件链接、固定版本和 XML;读取地址可复制,作者操作具有明确反馈。
|
||||
4. 发布页:按内容分组的表单,易读的条件链接编辑,结构化/XML 模式、真实校验预览与版本发布状态;普通用户不用手动拼接账户 UUID。
|
||||
5. 账户页:统一表单、注册/登录/重发验证、验证码链接状态、已验证会话和退出反馈;不展示不存在的重置密码、OAuth 或计费入口。
|
||||
6. 协议页:与产品一致的文档阅读布局,保留协议的实验状态与权限边界。
|
||||
|
||||
## 验证
|
||||
|
||||
在独立工作区本地运行类型、相关单测与构建,真实数据行为沿用现有隔离集成检查。通过可用的浏览器入口查看桌面与窄屏页面,检查导航、搜索/重置、表单、长 ID/链接、键盘焦点和错误反馈。最终提交完整改版 PR,等待用户手动合并后才触发 ces-control 部署;不以开发预览代替正式部署。
|
||||
|
||||
## 本次检查结果
|
||||
|
||||
- Node 24:类型检查、51 项单元测试、生产构建与 standalone smoke 通过。构建保留 preview.ts 动态文件跟踪的既有警告。
|
||||
- 项目 UI 回归使用隔离 API 夹具,覆盖作者筛选、筛选 URL 恢复、空状态、键盘标签、阅读/XML/版本、账户模式、密码显示、发布门槛、错误保留输入、预览、规范化发布和结构化/XML 往返。
|
||||
- 已检查 1440px 桌面与 390px 手机截图;新版真实 PostgreSQL/S3 集成尚待用户手动合并后的 main CI。此前已部署应用的真实邮件、账户、版本与权限证据见 ../linked-guidance/acceptance.md,不能替代新改版的部署验收。
|
||||
- 可在运行独立预览后执行:`WORKSPACE_TEST_URL=http://127.0.0.1:3217 CHROMIUM_PATH=<chromium可执行文件> node --import tsx --test tests/guidance-workspace.test.ts`。设置 WORKSPACE_SCREENSHOT_DIR 可生成截图。该用例也随原有集成测试在 CI 隔离栈运行,未添加 PR 或手动 CI 触发器。
|
||||
- 目录仅展示最近 100 篇社区文档;我的文档先在数据库筛选作者,再应用此上限。搜索与类型筛选作用于当前已加载目录,不声称全站无限搜索。
|
||||
|
||||
## 可审阅截图
|
||||
|
||||
以下为最终生产模式预览截图。社区条目、作者邮箱和编辑器内容均为明确的回归测试夹具,不是生产用户数据;它们只证明 UI 状态,不证明真实邮件投递或持久化发布。
|
||||
|
||||
- [桌面目录](screenshots/directory.png)
|
||||
- [桌面阅读](screenshots/reader.png)
|
||||
- [账户注册](screenshots/account.png)
|
||||
- [结构化编辑器](screenshots/editor.png)
|
||||
- [手机目录](screenshots/mobile-directory.png)
|
||||
- [手机阅读](screenshots/mobile-reader.png)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Reference in New Issue
Block a user