diff --git a/apps/waterfall-wiki/db/015_guidance_documents.sql b/apps/waterfall-wiki/db/015_guidance_documents.sql new file mode 100644 index 0000000..ea23d9d --- /dev/null +++ b/apps/waterfall-wiki/db/015_guidance_documents.sql @@ -0,0 +1,7 @@ +BEGIN; +CREATE TABLE guidance_documents(id text PRIMARY KEY CHECK(id ~ '^user-[a-z0-9-]{1,74}$'), owner_id uuid NOT NULL REFERENCES account_users(id), latest integer NOT NULL DEFAULT 0, deleted_at timestamptz, CHECK(id LIKE 'user-' || owner_id::text || '-%')); +CREATE TABLE guidance_revisions(document_id text NOT NULL REFERENCES guidance_documents(id), revision integer NOT NULL CHECK(revision>0), content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(document_id,revision)); +CREATE FUNCTION guidance_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Published revisions are immutable'; END $$; +CREATE TRIGGER guidance_immutable BEFORE UPDATE OR DELETE ON guidance_revisions FOR EACH ROW EXECUTE FUNCTION guidance_immutable(); +INSERT INTO schema_migrations(version) VALUES('015_guidance_documents'); +COMMIT; diff --git a/apps/waterfall-wiki/package.json b/apps/waterfall-wiki/package.json index 80f258c..71b593c 100644 --- a/apps/waterfall-wiki/package.json +++ b/apps/waterfall-wiki/package.json @@ -10,7 +10,7 @@ "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs", "start": "next start", "typecheck": "next typegen && tsc --noEmit", - "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts", + "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", diff --git a/apps/waterfall-wiki/src/components/GuidanceShell.tsx b/apps/waterfall-wiki/src/components/GuidanceShell.tsx index 9f88625..bcc5985 100644 --- a/apps/waterfall-wiki/src/components/GuidanceShell.tsx +++ b/apps/waterfall-wiki/src/components/GuidanceShell.tsx @@ -1,4 +1,4 @@ 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
↗ 织页 实验版登录 / 注册 ↗
{children}
} +export default function GuidanceShell({children}:{children:ReactNode}){return
↗ 织页 实验版登录 / 注册 ↗
{children}
} diff --git a/apps/waterfall-wiki/src/content/guides/catalog.ts b/apps/waterfall-wiki/src/content/guides/catalog.ts index c172aee..f58e68b 100644 --- a/apps/waterfall-wiki/src/content/guides/catalog.ts +++ b/apps/waterfall-wiki/src/content/guides/catalog.ts @@ -7,4 +7,4 @@ export const guides:Guidance[]=[ ]; export function findGuide(id:string,revision?:number){return guides.find(g=>g.id===id&&(revision===undefined||g.revision===revision));} const esc=(s:string)=>s.replace(/[<>&"']/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]!)); -export function guidanceXml(g:Guidance){const t=(tag:string,s:string)=>` <${tag}>${esc(s)}`;return `\n\n${[t('title',g.title),t('summary',g.summary),t('applies',g.applies),t('body',g.body),...g.steps.map(s=>t('step',s)),...g.links.map(l=>` ${esc(l.label)}`),...g.checks.map(s=>t('check',s))].join('\n')}\n\n`;} +export function guidanceXml(g:Guidance){const t=(tag:string,s:string)=>` <${tag}>${esc(s)}`;return `\n\n${[t('title',g.title),t('summary',g.summary),t('applies',g.applies),t('body',g.body),...g.steps.map(s=>t('step',s)),...g.links.map(l=>` ${esc(l.label)}`),...g.checks.map(s=>t('check',s)),...g.tags.map(s=>t('tag',s))].join('\n')}\n\n`;} diff --git a/apps/waterfall-wiki/src/lib/guidance-input.ts b/apps/waterfall-wiki/src/lib/guidance-input.ts new file mode 100644 index 0000000..fbadc6c --- /dev/null +++ b/apps/waterfall-wiki/src/lib/guidance-input.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +const kind=z.enum(['guide','context','evidence']); +export const validXmlText=(value:string)=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/.test(value)&&!/[\ud800-\udfff]/u.test(value); +const text=z.string().refine(validXmlText,'非法 XML 字符').trim().min(1).max(20000); +export const documentInput=z.object({id:z.string().regex(/^user-[a-z0-9-]{1,74}$/),revision:z.number().int().min(1).max(2147483647),kind,title:text.max(200),summary:text.max(1000),applies:text,body:text,steps:z.array(text).max(100),checks:z.array(text).max(100),links:z.array(z.object({target:z.string().regex(/^[a-z0-9-]{1,79}$/),revision:z.number().int().min(1).max(2147483647),relation:kind,when:text.max(1000),label:text.max(200)}).strict()).max(100),tags:z.array(text.max(80)).max(20)}).strict(); +// Deliberately narrow XML 1.0 grammar: no DTD, comments, CDATA, nested markup or arbitrary entities. +export function parseDocumentXml(source:string){ + if(!validXmlText(source)||source.includes(']]>'))throw Error('非法 XML 字符'); + if(Buffer.byteLength(source)>131072)throw Error('XML 过大'); + const trimXml=(v:string)=>v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g,''); + let s=source.replace(/^<\?xml version="1.0" encoding="UTF-8"\?>[ \t\r\n]*/, ''); + if(/<\?|{if(/&(?!amp;|lt;|gt;|quot;|apos;)/.test(v))throw Error('非法实体');return v.replace(/&(amp|lt|gt|quot|apos);/g,(_,k)=>({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[k as 'amp'|'lt'|'gt'|'quot'|'apos'] as string));}; + const attrs=(v:string)=>{const result:Record={};while(trimXml(v)){const m=/^[ \t\r\n]+([a-z]+)="([^"<]*)"/.exec(v);if(!m||m[1] in result)throw Error('非法或重复属性');result[m[1]]=decode(m[2]);v=v.slice(m[0].length);}return result;}; + const root=/^([\s\S]*)<\/guidance>[ \t\r\n]*$/.exec(s);if(!root)throw Error('非法根元素');const a=attrs(root[1]); + if(Object.keys(a).sort().join(',')!=='id,kind,lang,revision,xmlns'||a.xmlns!=='urn:linked-guidance:0.1'||a.lang!=='zh-CN'||! /^[1-9][0-9]*$/.test(a.revision))throw Error('不支持的协议或属性'); + const result:Record={id:a.id,kind:a.kind,revision:Number(a.revision),steps:[],checks:[],links:[],tags:[]};s=root[2]; + while(trimXml(s)){const m=/^[ \t\r\n]*<(title|summary|applies|body|step|check|tag|link)((?:[ \t\r\n]+[a-z]+="[^"<]*")*[ \t\r\n]*)>([^<]*)<\/\1>/.exec(s);if(!m)throw Error('非法子元素');const [,tag,raw,value]=m;const at=attrs(raw);const v=decode(value); + if(tag==='link'){if(Object.keys(at).sort().join(',')!=='relation,revision,target,when'||! /^[1-9][0-9]*$/.test(at.revision))throw Error('非法链接');(result.links as unknown[]).push({...at,revision:Number(at.revision),label:v});} + else {if(Object.keys(at).length)throw Error('非法属性');if(tag==='step'||tag==='check'||tag==='tag')(result[tag==='step'?'steps':tag==='tag'?'tags':'checks'] as string[]).push(v);else{if(tag in result)throw Error('重复元素');result[tag]=v;}}s=s.slice(m[0].length);} + return documentInput.parse(result); +} diff --git a/apps/waterfall-wiki/src/lib/guidance-store.ts b/apps/waterfall-wiki/src/lib/guidance-store.ts new file mode 100644 index 0000000..590d37a --- /dev/null +++ b/apps/waterfall-wiki/src/lib/guidance-store.ts @@ -0,0 +1,15 @@ +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{ + 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(){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;} diff --git a/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts new file mode 100644 index 0000000..26e03b7 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts @@ -0,0 +1,27 @@ +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:'文档服务暂不可用'});} +} +export const config={api:{bodyParser:{sizeLimit:'128kb'}}}; diff --git a/apps/waterfall-wiki/src/pages/documents/[id].tsx b/apps/waterfall-wiki/src/pages/documents/[id].tsx new file mode 100644 index 0000000..8fa2407 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/documents/[id].tsx @@ -0,0 +1,6 @@ +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

{doc.title}

{doc.kind} · {doc.id} · 固定版本 {doc.revision}

版本:{versions.map(v=> v{v} )}

{doc.summary}

适用条件

{doc.applies}

{doc.body}

    {doc.steps.map((s,i)=>
  1. {s}
  2. )}

条件链接

{links.map((l,i)=>)}

检查项

    {doc.checks.map((s,i)=>
  • {s}
  • )}
读取规范 XML{canEdit&&

}
;} +export const getServerSideProps:GetServerSideProps=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)}};}; diff --git a/apps/waterfall-wiki/src/pages/documents/index.tsx b/apps/waterfall-wiki/src/pages/documents/index.tsx new file mode 100644 index 0000000..53e3008 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/documents/index.tsx @@ -0,0 +1,3 @@ +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

用户发布的文档

{error}

{docs.map(d=>

{d.title}

{d.kind} · v{d.revision}

{d.summary}

)}
;} diff --git a/apps/waterfall-wiki/src/pages/index.tsx b/apps/waterfall-wiki/src/pages/index.tsx index a0943b7..200d64f 100644 --- a/apps/waterfall-wiki/src/pages/index.tsx +++ b/apps/waterfall-wiki/src/pages/index.tsx @@ -1,7 +1,7 @@ import Head from 'next/head'; import Link from 'next/link'; -import {useState} from 'react'; +import {useEffect,useState} from 'react'; import GuidanceShell from '@/components/GuidanceShell'; import {guides} from '@/content/guides/catalog'; import s from '@/styles/guidance.module.css'; -export default function Home(){const[q,setQ]=useState(''),[kind,setKind]=useState('all');const items=guides.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase()));return 织页 · 为 Agent 找到下一页
A WEB OF USEFUL KNOWLEDGE

一个起点,
连接下一种可能。

每一篇文档,都能独立帮助你的 Agent。
从当前问题出发,沿着有用的链接继续探索。

{['代码审查','规划','验证'].map(t=>)}

从这里开始 {items.length} 篇

协议样例 · v0.1
{items.map(g=>
{g.kind==='guide'?'↗':'≋'}
织页 / protocol-examples · r{g.revision}

{g.title}

{g.summary}

{g.tags.map(t=>{t})}↗ {g.links.length} 个关联
)}{!items.length&&

没有匹配文档,请尝试其他关键词或分类。

}
} +export default function Home(){const [published,setPublished]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[loadError,setLoadError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setPublished(b.documents);}).catch(e=>setLoadError(e.message));},[]);const[q,setQ]=useState(''),[kind,setKind]=useState('all');const items=guides.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase()));return 织页 · 为 Agent 找到下一页
A WEB OF USEFUL KNOWLEDGE

一个起点,
连接下一种可能。

每一篇文档,都能独立帮助你的 Agent。
从当前问题出发,沿着有用的链接继续探索。

{['代码审查','规划','验证'].map(t=>)}

用户发布

发布一篇文档 ↗
{loadError&&

{loadError}

}
{published.filter(g=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary}`.toLowerCase().includes(q.toLowerCase())).map(g=>
↗
{g.id} · {g.kind} · v{g.revision}

{g.title}

{g.summary}

)}

从这里开始 {items.length} 篇

协议样例 · v0.1
{items.map(g=>
{g.kind==='guide'?'↗':'≋'}
织页 / protocol-examples · r{g.revision}

{g.title}

{g.summary}

{g.tags.map(t=>{t})}↗ {g.links.length} 个关联
)}{!items.length&&

没有匹配文档,请尝试其他关键词或分类。

}
} diff --git a/apps/waterfall-wiki/src/pages/write.tsx b/apps/waterfall-wiki/src/pages/write.tsx new file mode 100644 index 0000000..e42290f --- /dev/null +++ b/apps/waterfall-wiki/src/pages/write.tsx @@ -0,0 +1,14 @@ +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

发布独立文档

需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。

{mode?