Publish reference layouts as API-authored text and image notes
wiki CI and deployment / verify-and-deploy (push) Successful in 16m28s
wiki CI and deployment / verify-and-deploy (push) Successful in 16m28s
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
import layouts from './reference/manifest.json';
|
||||
import type {Block} from '../skills/catalog';
|
||||
import type {GuidanceDocument,GuidanceResource} from '../skills/guidance';
|
||||
import {guidanceUrl} from '../page-links';
|
||||
export {layouts};
|
||||
const p=(text:string):Block=>({type:'paragraph',text});
|
||||
const h=(text:string):Block=>({type:'heading',text});
|
||||
const code=(text:string):Block=>({type:'code',text});
|
||||
const links=(...refs:string[]):Block=>({type:'links',refs});
|
||||
const doc=(id:string,title:string):GuidanceResource=>({id,kind:'document',title,documentPath:'premium-layout/'+id,href:guidanceUrl('premium-layout/'+id)+'?agent_mode=True',human:guidanceUrl('premium-layout/'+id)});
|
||||
const file=(id:string,title:string,href:string):GuidanceResource=>({id,kind:'document',title,href});
|
||||
const image=(id:string,title:string,href:string):GuidanceResource=>({id,kind:'image',title,href});
|
||||
const typeName=(kind:string)=>kind==='text'?'文字':kind==='image'?'图片占位 / 色块':'装饰';
|
||||
const sourceNote='原始拼图仅 736×980,每张约 112×173 像素。小字无法可靠辨认;其内容标为待核对,并保存独立原字形图层。放大不会恢复缺失信息。灰绿色区域为原设计中的图片占位 / 色块,参考图没有可辨认的真实照片。';
|
||||
export const legacyLayoutIds=['5971fc3d2563aa8005df5c13','9ad47529a7eaeee1fef771ca','7f158059ba3a2b80dd750cc3','370d1881f229af63f1946df9','31a978620a0d388f84772c95','c34cf81137654ad38850c9bd','dbe94ccaa708bd120549ae86','768587e537d6f1ee77a51961','0962c2bf1c558e0e32425f59','347b732caa4af4afa43b65a4','3edf144a443c1fdf23827fb3','97227671a9dc95c4948e591b'];
|
||||
export function reactExample(id:string){return `// components/Example.tsx\nimport ReferenceLayout from './reference-layouts/ReferenceLayout';\nexport default function Example() {\n return <ReferenceLayout id="${id}" />;\n}\n// 原样模式使用该 ID 的独立图层,保留原字形与原构图。\n// 组件、manifest.json 和 public/layout-reference 素材必须一起复制。`}
|
||||
export function nextExample(id:string){return `// app/designs/${id}/page.tsx\nimport type { Metadata } from 'next';\nimport ReferenceLayout from '@/components/reference-layouts/ReferenceLayout';\nexport const metadata: Metadata = {title:'${id} · 原图对应排版'};\nexport default function Page() {\n return <main><ReferenceLayout id="${id}" /></main>;\n}\n// 沿用项目根布局。@/ 指向源码根,组件可直接作为 Server Component。`}
|
||||
export function htmlExample(id:string){return `<!doctype html>\n<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${id}</title>\n<style>body{margin:0}.canvas{margin:0;position:relative;width:min(100%,672px);background:#f4f3ee}.canvas img{position:absolute;display:block}</style></head>\n<body><figure id="canvas" class="canvas" aria-label="${id} 原图构件重组"></figure>\n<script type="module">\nconst id='${id}';\nconst response=await fetch('/layout-reference/'+id+'/manifest.json');\nif(!response.ok)throw Error('构件清单未加载');\nconst m=await response.json();\nif(m.id!==id)throw Error('版面 ID 不一致');\nconst canvas=document.getElementById('canvas');\ncanvas.style.aspectRatio=m.width+'/'+m.height;\nconst bg=document.createElement('img');bg.src=m.background;bg.alt='';bg.style.cssText='inset:0;width:100%;height:100%';canvas.append(bg);\nfor(const e of m.elements){\n const [x,y,w,h]=e.box;const img=document.createElement('img');\n img.src=e.asset;img.dataset.component=id+'/'+e.id;img.dataset.kind=e.kind;\n img.alt=e.kind==='text'?(e.text||e.role+':未辨识原字形'):'';\n Object.assign(img.style,{left:x/m.width*100+'%',top:y/m.height*100+'%',width:w/m.width*100+'%',height:h/m.height*100+'%',zIndex:e.kind==='text'?'3':e.kind==='decoration'?'2':'1'});\n canvas.append(img);\n}\n</script><!-- 同站点服务 /layout-reference 素材;不要以 file:// 打开。 --></body></html>`}
|
||||
const router:GuidanceDocument={
|
||||
path:'premium-layout/router',title:'高级感排版 · 27 张原图 Router',collection:'高级感排版',
|
||||
resources:[image('source-map','27 张原图编号对应图','/layout-reference/numbered-source.png'),file('inspector','27 张构件检查器','/layout-reference/index.html'),file('index-json','27 张机器索引','/layout-reference/index.json'),file('runtime','React / TSX 完整组件源文件','/layout-reference/ReferenceLayout.tsx'),file('manifest','React 组件配套 manifest.json','/layout-reference/manifest.json'),file('bundle','27 张完整源码与素材 ZIP','/layout-reference/layout-reference-toolkit.zip'),...layouts.map(l=>doc(l.id,l.id+' · '+l.name))],
|
||||
blocks:[p('本系列严格对应用户参考图中的 27 张独立设计,每张保留自己的原图裁片、文字、图片占位 / 色块、装饰和坐标。左侧“高级感排版 / 模板合集”说明栏不计入。编号从上到下、每行从左到右:前 3 行各 5 张,后 2 行各 6 张,共 27 张设计 post,另有本篇 Router。'),
|
||||
h('先查原图编号,再调用固定 ID'),links('inspector','index-json','bundle'),p('指定 ref-01 至 ref-27 中的一个 ID。禁止把旧的“同心圆”“巨字”等类型名当成任意近似模板;每个 ID 只对应一个 source.box、一份 manifest 和本设计的构件。无法确定编号时先返回候选原图裁片,由使用者选择,再生成。'),
|
||||
p('| ID | 原图位置(含左侧说明栏所在列) | 对应设计 |\n| --- | --- | --- |\n'+layouts.map(l=>`| ${l.id} | 第 ${l.source.row} 行、第 ${l.source.column} 列 | ${l.name} |`).join('\n')),
|
||||
links(...layouts.map(l=>l.id)),
|
||||
h('识别算法与人工复核'),p('1. 从拼图外沿采样灰底,按 RGB 最大通道差 >19 建立前景二值掩膜。2. 四邻域连通域提取外框,按宽 86–124px、高 146–189px 过滤,检测出 27 张卡片。3. 按 y 坐标聚成 5 行,行内按 x 排序,保存绝对裁片坐标。4. 使用本机 macOS Vision 提取 OCR 候选与置信度,再逐张复核文字区、色块与装饰 ROI。5. 根据各图位的局部颜色种子建立图形掩膜,文字前景独立分离;残余字形按基线、间距、字高与已有文字块合并。6. 对所有独立图层重组并执行像素比较。'),
|
||||
p('OCR 置信度不是事实保证;本清单对可读标题进行了人工核对。ref-01 的品牌字标整体为 T01,不能把末尾字符再标为 T03。编号在一张图内按文字 T、图片占位 I、装饰 D 分组;对外调用使用完整键 ref-01/T01,防止跨版面混用。'),
|
||||
h('三类构件及背景'),p('T / text:标题、正文、品牌字、日期、图注与字距标签。文字不属于图片区;已核对内容记录在 text 字段,无法辨认的内容为 null。I / image:原设计的图片占位或灰绿色色块。它们可由实际照片替换,原始形状和位置由 mask 保持;本参考没有真实照片。D / decoration:线框、圆环、圆节点、引线、分隔线与贴条。纸底与原拼图画框单独保存在 background。'),
|
||||
h('必须区分:保真重组与可编辑替换'),p('默认模式按分类好的原图构件重组,保留原字形和像素;它不是高分辨率矢量重绘,也不意味着 OCR 全部识别正确。替换模式按同一 ID 和原坐标渲染新文字或新图片;字体与内容变化会产生视觉差异。替换过的版本不得称为与原图逐像素一致。曲线文字保留原字形,若要替换须沿该版面的专用 SVG 路径排字,通用组件会拒绝普通文本替换,以免把弧形改成直排。'),p(sourceNote),
|
||||
h('严格调用协议'),code(JSON.stringify({layoutId:'ref-01',mode:'reference',sourceBox:layouts[0].source.box,components:{'ref-01/T01':{kind:'text',role:'品牌字标'},'ref-01/T02':{kind:'text',role:'左下说明'},'ref-01/I01':{kind:'image',role:'上方主图'},'ref-01/I02':{kind:'image',role:'下方副图'}}},null,2)),
|
||||
h('React / TSX 与 Next.js 文件准备'),links('runtime','manifest','bundle'),p('将 ZIP 中 components/reference-layouts 放进项目组件目录,将 public/layout-reference 放到项目 public 下。React 项目直接导入组件使用;Next.js App Router 可将组件保留为 Server Component。已有根布局继续沿用,设置中文 lang,按项目路径调整 @/ 别名。只有图层开关、拖拽或编辑器等交互外壳需要 use client;图片和字形保真层使用原生 img 避免优化改变原像素或透明遮罩。'),
|
||||
code(reactExample('ref-01')),code(nextExample('ref-01')),
|
||||
h('替换必须按构件类型和完整键执行'),code(`import ReferenceLayout from './reference-layouts/ReferenceLayout';\nexport default function Customized() {\n return <ReferenceLayout id="ref-01"\n text={{'ref-01/T01':'MY BRAND'}}\n images={{'ref-01/I01':'/photos/main.jpg','ref-01/I02':'/photos/detail.jpg'}}\n />;\n}\n// ref-02/T01 不能传给 ref-01;I01 不能作为文字键。运行时会拒绝。\n// 保留某个构件原样:不要传对应键。未知细字不能猜写。`),
|
||||
h('字体、图片与高级工具'),p('参考图中的字形已保存在独立文字资产;尚不能从低清拼图可靠确定具体字体。编辑时使用用户提供且已授权的中文 / 拉丁字体,先检查横排、竖排、旋转和倾斜方向,再调整字距与行距。SVG 适用于弧形文字和可编辑轮廓,Figma 可按每张 manifest 的源坐标建立画板与图层;不要重新套用另一张版面的圆形、网格或色块位置。图片替换采用 cover,并保留本设计的 mask;需要完整主体时先检查裁切。'),
|
||||
h('验收与停止条件'),p('逐张核对原裁片、编号和 source.box;检查文字、图片占位和装饰三类掩膜。默认模式必须逐像素重组一致;该检查只证明资产重组保真,不证明语义标注或 OCR 一定正确,仍须核对标注图。特别检查同一单词是否被拆成两个 T、数字是否被旋转标题吞并、底线是否被并入文字。调用时不能跨设计借构件。更换内容后再次检查溢出、换行、透明边缘与图片裁切。网页正文另外提供可读的 DOM 文本,不把 112×173 的海报小字当作移动端正文。'),
|
||||
],
|
||||
};
|
||||
export const layoutDocuments:GuidanceDocument[]=[router,...layouts.map((l):GuidanceDocument=>{
|
||||
const title=l.id+' · '+l.name;
|
||||
const texts=l.elements.filter(e=>e.kind==='text'),images=l.elements.filter(e=>e.kind==='image'),decor=l.elements.filter(e=>e.kind==='decoration');
|
||||
return {path:'premium-layout/'+l.id,title,collection:'高级感排版',resources:[image('reference',l.id+' · 原图裁片',l.reference),image('annotated',l.id+' · 文字/图片/装饰标注',l.annotated),image('text-map',l.id+' · 仅文字掩膜(蓝色)',`/layout-reference/${l.id}/text-map.png`),image('image-map',l.id+' · 仅图片占位/色块(橙色)',`/layout-reference/${l.id}/image-map.png`),image('decor-map',l.id+' · 仅装饰掩膜(绿色)',`/layout-reference/${l.id}/decoration-map.png`),doc('router','27 张原图 Router'),file('inspect','打开本设计构件检查器',`/layout-reference/${l.id}/index.html`),file('json','本设计完整构件 JSON',`/layout-reference/${l.id}/manifest.json`),file('runtime','React 完整组件','/layout-reference/ReferenceLayout.tsx'),file('bundle','完整源码与素材 ZIP','/layout-reference/layout-reference-toolkit.zip')],blocks:[
|
||||
p(`${l.id} 只对应原图第 ${l.source.row} 行、可见第 ${l.source.column} 列的“${l.name}”。原裁片坐标 [x,y,w,h] = [${l.source.box.join(', ')}];本篇逐项列出文字 ${texts.length} 组、图片占位/色块 ${images.length} 组、装饰 ${decor.length} 组,不替换成近似构图。`),links('inspect','json'),
|
||||
h('原图与分类图怎么看'),p('本 post 的第 1 张是直接裁出的原版面,第 2 张标出构件框,第 3 张只显示蓝色文字掩膜,第 4 张只显示橙色图片区掩膜,第 5 张只显示绿色装饰。T 与 I 可在坐标上叠放,例如标题压住图位;它们仍是不同类型,不能合并成图片。检查器可独立关闭各类构件并点击表格定位。'),
|
||||
h('构件逐项对应表'),p('| 完整构件键 | 类型 | 用途 | 本裁片坐标 x,y,w,h | 内容 / 状态 |\n| --- | --- | --- | --- | --- |\n'+l.elements.map(e=>`| ${l.id}/${e.id} | ${typeName(e.kind)} | ${e.role} | ${e.box.join(', ')} | ${e.kind==='text'?(e.text??'待核对,保留原字形'):'非文字'} |`).join('\n')),
|
||||
h('布局与制作步骤'),p(`1. 以 ${l.width}×${l.height} 的源坐标建立画板;输出可等比放大,比例必须保持 ${l.width}:${l.height}。2. 建立本设计的 ${images.map(e=>e.id+' '+e.role).join('、')},使用对应 mask,保持它们各自位置。3. 单独放置 ${texts.map(e=>e.id+' '+e.role).join('、')},按表内方向与坐标定位。4. 还原 ${decor.map(e=>e.id+' '+e.role).join('、')||'原设计已有的细节'},不得借用别的设计。5. 与原裁片叠加比较,再检查文字是否被图层遮挡。`),
|
||||
h('文字和图层的具体规则'),p(texts.map(e=>`${e.id}:${e.direction==='vertical'?'竖排':e.direction==='rotated'?'整体旋转排字':e.direction==='angled'?'倾斜排字':e.direction==='curved'?'沿曲线排字':'横排'};${e.text?'已核对可读内容“'+e.text+'”':'内容无法可靠辨认,保留原字形,不编造正文'}。`).join('\n')),p(sourceNote),
|
||||
h('HTML / CSS:固定本设计 ID'),code(htmlExample(l.id)),
|
||||
h('React / TSX:固定本设计组件'),code(reactExample(l.id)),links('runtime','bundle'),
|
||||
h('Next.js App Router:固定本设计路由'),code(nextExample(l.id)),
|
||||
h('本设计的验收标准'),p(`layoutId 必须等于 ${l.id},source.box 必须为 [${l.source.box.join(', ')}];所有构件资产必须来自 /layout-reference/${l.id}/。核对本篇 ${texts.length} 组 T、${images.length} 组 I、${decor.length} 组 D 的归属和坐标。默认原字形模式的图层拼回与原裁片一致;文字替换模式则按布局与可读性验收,不再声称像素一致。原字标、日期和细字不能因为识别残余而重复编号。`),links('router'),
|
||||
]};
|
||||
})];
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import type {CSSProperties} from 'react';
|
||||
import layouts from './manifest.json';
|
||||
export const layoutIds = ['ref-01','ref-02','ref-03','ref-04','ref-05','ref-06','ref-07','ref-08','ref-09','ref-10','ref-11','ref-12','ref-13','ref-14','ref-15','ref-16','ref-17','ref-18','ref-19','ref-20','ref-21','ref-22','ref-23','ref-24','ref-25','ref-26','ref-27'] as const;
|
||||
export type LayoutId=typeof layoutIds[number];
|
||||
export type Replacements={text?:Record<string,string>;images?:Record<string,string>};
|
||||
export function getLayout(id:string){return layouts.find(layout=>layout.id===id)}
|
||||
export function validateReplacements(id:string,replacements:Replacements){
|
||||
const layout=getLayout(id);if(!layout)throw Error(`Unknown layout ID: ${id}`);
|
||||
for(const[kind,values]of [['text',replacements.text],['image',replacements.images]] as const)for(const key of Object.keys(values||{})){
|
||||
if(!layout.elements.some(e=>key===id+'/'+e.id&&e.kind===kind))throw Error(`Component does not belong to ${id} or has the wrong kind: ${key}`);
|
||||
}return layout;
|
||||
}
|
||||
function textStyle(text:string,box:number[],width:number,direction:string):CSSProperties{
|
||||
const vertical=direction==='vertical'||direction==='rotated',lines=text.split('\n'),units=Math.max(...lines.map(line=>Array.from(line).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:.56),0)),1);
|
||||
const size=vertical?Math.min(box[2],box[3]/Math.max(text.length,1)):Math.min(box[3]/(lines.length*1.1),box[2]/units);
|
||||
return {fontSize:`${size/width*100}cqw`,lineHeight:1.1,whiteSpace:'pre-wrap',writingMode:vertical?'vertical-rl':undefined,textOrientation:direction==='rotated'?'sideways':undefined,transform:direction==='angled'?'rotate(-12deg)':undefined,transformOrigin:'left center',color:'#222',fontFamily:'"Songti SC", "Noto Serif CJK SC", serif'};
|
||||
}
|
||||
/** Untouched mode reconstructs typed source layers; replacement mode keeps the exact preset geometry. */
|
||||
export default function ReferenceLayout({id,text={},images={},assetBase=''}:{id:LayoutId;text?:Record<string,string>;images?:Record<string,string>;assetBase?:string}){
|
||||
const layout=validateReplacements(id,{text,images}),src=(url:string)=>assetBase+url;
|
||||
return <figure data-layout-id={id} aria-label={`${id} ${layout.name}`} style={{margin:0,width:'min(100%, 672px)',containerType:'inline-size'}}>
|
||||
<div style={{position:'relative',width:'100%',aspectRatio:`${layout.width}/${layout.height}`,isolation:'isolate',background:'#f4f3ee'}}>
|
||||
<img src={src(layout.background)} alt="" width={layout.width} height={layout.height} style={{position:'absolute',inset:0,width:'100%',height:'100%'}}/>
|
||||
{layout.elements.map(e=>{
|
||||
const[x,y,w,h]=e.box,key=id+'/'+e.id,position:CSSProperties={position:'absolute',left:x/layout.width*100+'%',top:y/layout.height*100+'%',width:w/layout.width*100+'%',height:h/layout.height*100+'%',zIndex:e.kind==='text'?3:e.kind==='decoration'?2:1};
|
||||
if(e.kind==='text'&&Object.hasOwn(text,key)){
|
||||
if(e.direction==='curved')throw Error(`Curved text requires the source-specific SVG path: ${key}`);
|
||||
return <React.Fragment key={key}>
|
||||
{'underlay' in e&&<img src={src(String(e.underlay))} alt="" style={position}/>}
|
||||
<span data-component={key} data-kind="text" style={{...position,...textStyle(text[key],e.box,layout.width,e.direction)}}>{text[key]}</span>
|
||||
</React.Fragment>;
|
||||
}
|
||||
if(e.kind==='image'&&Object.hasOwn(images,key))return <img key={key} data-component={key} data-kind="image" src={images[key]} alt={e.role} style={{...position,objectFit:'cover',maskImage:'mask' in e?`url("${src(String(e.mask))}")`:undefined,maskSize:'100% 100%'}}/>;
|
||||
return <img key={key} data-component={key} data-kind={e.kind} src={src(e.asset)} alt={e.kind==='text'?e.text||`${e.role}:原字形保真,内容未辨识`:''} width={w} height={h} style={position}/>;
|
||||
})}
|
||||
</div>
|
||||
</figure>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
export type Box=[number,number,number,number];
|
||||
export type TextRegion={box:Box;direction:string};
|
||||
/** Associate residual glyphs with an existing semantic word/paragraph, never create a second word ID. */
|
||||
export function textMergeTarget(fragment:Box,regions:TextRegion[]):number{
|
||||
const[x,y,w,h]=fragment;let result=-1,best=Infinity;
|
||||
for(const[index,r]of regions.entries()){
|
||||
const[a,b,c,d]=r.box,vertical=r.direction==='vertical';
|
||||
const gapX=Math.max(a-x-w,x-a-c,0),gapY=Math.max(b-y-h,y-b-d,0);
|
||||
const overlapX=Math.max(0,Math.min(x+w,a+c)-Math.max(x,a)),overlapY=Math.max(0,Math.min(y+h,b+d)-Math.max(y,b));
|
||||
const sameBaseline=overlapY/Math.max(1,Math.min(h,d))>=.55&&gapX<=Math.max(3,Math.min(7,d*.4));
|
||||
const sameColumn=overlapX/Math.max(1,Math.min(w,c))>=.55&&gapY<=Math.max(2,Math.min(4,vertical?c*.3:2));
|
||||
if(!(sameBaseline||sameColumn))continue;
|
||||
// Nearby small footer blocks remain independent when the fragment has no baseline/column affinity.
|
||||
const score=gapX+gapY+(vertical?(1-overlapX/Math.max(1,Math.min(w,c))):(1-overlapY/Math.max(1,Math.min(h,d))));
|
||||
if(score<best){result=index;best=score}
|
||||
}return result;
|
||||
}
|
||||
export function unionBoxes(a:Box,b:Box):Box{const x=Math.min(a[0],b[0]),y=Math.min(a[1],b[1]);return[x,y,Math.max(a[0]+a[2],b[0]+b[2])-x,Math.max(a[1]+a[3],b[1]+b[3])-y]}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,493 +0,0 @@
|
||||
[
|
||||
{
|
||||
"file" : "ref-01.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.073746314917825531,
|
||||
0.12865497059314956,
|
||||
0.31268436578171094,
|
||||
0.062378167641325533
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "BULUBLUE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-02.png",
|
||||
"lines" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-03.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.022132919255381901,
|
||||
0.028650177161836932,
|
||||
0.33292714615314328,
|
||||
0.089135101994567756
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "蓝羽毛"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-04.png",
|
||||
"lines" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-05.png",
|
||||
"lines" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-06.png",
|
||||
"lines" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-07.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.026785715591931197,
|
||||
0.75581395330534029,
|
||||
0.23511904761904764,
|
||||
0.067829457364341095
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "2025"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.52921506067374158,
|
||||
0.90859448832286593,
|
||||
0.43264130183628624,
|
||||
0.051028077916581482
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "能車贤好雞 蓋羽毛"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-08.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.041297932751125623,
|
||||
0.019267822827299441,
|
||||
0.89380530973451322,
|
||||
0.061657032755298657
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "BULUBLUE"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.041297933952802367,
|
||||
0.078998073513166345,
|
||||
0.91150442477876115,
|
||||
0.088631984585741841
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "蓝羽毛设计"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-09.png",
|
||||
"lines" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-10.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
1.2867459860650037e-09,
|
||||
0.32755298650587994,
|
||||
0.7463126843657818,
|
||||
0.1502890173410405
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "DUE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-11.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.0029239778265106767,
|
||||
0.044315992678227389,
|
||||
0.47660818713450293,
|
||||
0.11560693641618491
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "蓝羽毛"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.026110667628392013,
|
||||
0.17872041532632799,
|
||||
0.60859738177026224,
|
||||
0.11654760842148737
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "版式灵感"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-12.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.014880952149470902,
|
||||
-3.6751579557403602e-10,
|
||||
0.2678571428571429,
|
||||
0.19845857418111745
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "版"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-13.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.07309941752558477,
|
||||
0.067437379901252426,
|
||||
0.8421052631578948,
|
||||
0.10404624277456653
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "版式=灵感"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-14.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.07374630927974439,
|
||||
0.45785440622605367,
|
||||
0.81710914454277295,
|
||||
0.049808429118773923
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "置羽毛 版式煲感设计分享"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-15.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.043711436004017792,
|
||||
0.054446479151035843,
|
||||
0.68638665335518967,
|
||||
0.089565615203798576
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "bulublue"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.043607735502465307,
|
||||
0.1459897803830712,
|
||||
0.5169511976696195,
|
||||
0.10108402377148817
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "design"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-16.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.085458582825798823,
|
||||
0.67023348199897037,
|
||||
0.28041027147861358,
|
||||
0.037182361404330733
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "經引毛飯式"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-17.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.050147494284660782,
|
||||
0.065134099832375503,
|
||||
0.5663716814159292,
|
||||
0.068965517241379226
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "Bulublue"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.046612091924946461,
|
||||
0.13624270198706689,
|
||||
0.48789676452456682,
|
||||
0.076173599652403667
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "Design"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.44788804184380798,
|
||||
0.30109252421060173,
|
||||
0.54965164949760315,
|
||||
0.075975871177468735
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "高级感版式,"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-18.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.03818345576318917,
|
||||
0.6239403036164336,
|
||||
0.68469503847195334,
|
||||
0.083537017705340055
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "Bulublue"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.036029248937117057,
|
||||
0.69494847907178836,
|
||||
0.73614549214861047,
|
||||
0.14112464495545596
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "Design m"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-19.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.041297934983846013,
|
||||
0.015594541432284448,
|
||||
0.22713864306784656,
|
||||
0.2007797270955165
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "高"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.032448377516592908,
|
||||
0.70760233846856724,
|
||||
0.23893805309734517,
|
||||
0.22807017543859653
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "排"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.69616519158554568,
|
||||
-5.3118909271177017e-10,
|
||||
0.23303834808259583,
|
||||
0.21247563352826515
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "级"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.70501474906895278,
|
||||
0.71734892719298249,
|
||||
0.2271386430678467,
|
||||
0.21832358674463936
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "版"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-20.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.19446010141722203,
|
||||
0.58329392990046902,
|
||||
0.31818376507675439,
|
||||
0.099253917245736178
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "高级"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-21.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.032163742013888892,
|
||||
0.65125240855009636,
|
||||
0.10818713450292397,
|
||||
0.046242774566473965
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "25"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.70095725028416789,
|
||||
0.02139049098390966,
|
||||
0.2679496675904034,
|
||||
0.69436710188613915
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "Bulublue:"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.74242488044068555,
|
||||
0.72010859462359933,
|
||||
0.21105667025025121,
|
||||
0.049185508478124196
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "2025"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-22.png",
|
||||
"lines" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-23.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.017024611458375291,
|
||||
0.45011763555939854,
|
||||
0.9542548642521016,
|
||||
0.15596627819445708
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "BULUBLUE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-24.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.027486666794077769,
|
||||
0.00811422680517071,
|
||||
0.61448201317351125,
|
||||
0.19655708194703103
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "蓝羽毛版式"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-25.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.064896738135557824,
|
||||
0.23121387201950871,
|
||||
0.082595906778071132,
|
||||
0.26011560693641611
|
||||
],
|
||||
"confidence" : 0.30000001192092896,
|
||||
"text" : "高設•版式"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-26.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
0.011695904385964868,
|
||||
0.017341040546724473,
|
||||
0.65497076023391809,
|
||||
0.07707129094412335
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "Format 58"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.014619884705353111,
|
||||
0.14065510587668595,
|
||||
0.43274853801169588,
|
||||
0.08285163776493254
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "Bulubl"
|
||||
},
|
||||
{
|
||||
"box" : [
|
||||
0.017385159403405345,
|
||||
0.23650755152999758,
|
||||
0.58218874568827672,
|
||||
0.083824973574952666
|
||||
],
|
||||
"confidence" : 1,
|
||||
"text" : "Bulublue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"file" : "ref-27.png",
|
||||
"lines" : [
|
||||
{
|
||||
"box" : [
|
||||
-0.021385720300243924,
|
||||
0.43859550876054387,
|
||||
0.83889416285923546,
|
||||
0.20254762043324548
|
||||
],
|
||||
"confidence" : 0.5,
|
||||
"text" : "蓝羽毛-版式"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -12,45 +12,5 @@
|
||||
"wiki:Main:welcome": "54d12ae51429f66fa7991ef5",
|
||||
"wiki:Help:namespaces": "1a33340838520492b2e095c0",
|
||||
"wiki:Template:idea": "40e0e456e2f5943610fef538",
|
||||
"wiki:Draft:inbox": "a599a5734d13ec81fed1ffd1",
|
||||
"guidance:premium-layout/router": "8eb8c76500580f7a4b56ba3c",
|
||||
"guidance:premium-layout/offset-panels": "5971fc3d2563aa8005df5c13",
|
||||
"guidance:premium-layout/vertical-rail": "9ad47529a7eaeee1fef771ca",
|
||||
"guidance:premium-layout/monumental-type": "7f158059ba3a2b80dd750cc3",
|
||||
"guidance:premium-layout/orbital-rings": "370d1881f229af63f1946df9",
|
||||
"guidance:premium-layout/organic-ellipse": "31a978620a0d388f84772c95",
|
||||
"guidance:premium-layout/modular-grid": "c34cf81137654ad38850c9bd",
|
||||
"guidance:premium-layout/editorial-serif": "dbe94ccaa708bd120549ae86",
|
||||
"guidance:premium-layout/horizontal-band": "768587e537d6f1ee77a51961",
|
||||
"guidance:premium-layout/diagonal-cards": "0962c2bf1c558e0e32425f59",
|
||||
"guidance:premium-layout/annotation-lines": "347b732caa4af4afa43b65a4",
|
||||
"guidance:premium-layout/split-contrast": "3edf144a443c1fdf23827fb3",
|
||||
"guidance:premium-layout/type-frame": "97227671a9dc95c4948e591b",
|
||||
"guidance:premium-layout/ref-01": "eb937b42be92a9f6feee684f",
|
||||
"guidance:premium-layout/ref-02": "1179d1b1a5f21a99fa0cf542",
|
||||
"guidance:premium-layout/ref-03": "f3079f1dbb242180e04b602b",
|
||||
"guidance:premium-layout/ref-04": "b7a5981ea582ed94384af2ae",
|
||||
"guidance:premium-layout/ref-05": "a01a0ab78461632b8cb148f9",
|
||||
"guidance:premium-layout/ref-06": "c524f9c3858052ebcc5ab8ef",
|
||||
"guidance:premium-layout/ref-07": "7d4cefdfada28bbe9e570af3",
|
||||
"guidance:premium-layout/ref-08": "e53f7620a90d3f8076c44326",
|
||||
"guidance:premium-layout/ref-09": "18aff77f5f032eda1a2cd04b",
|
||||
"guidance:premium-layout/ref-10": "d5906c34d3efb6fdbcb40da8",
|
||||
"guidance:premium-layout/ref-11": "d1718fd03494af0c8c95e4c4",
|
||||
"guidance:premium-layout/ref-12": "f78131152506cbf7dec7506a",
|
||||
"guidance:premium-layout/ref-13": "d3c5eae33973291530d1118e",
|
||||
"guidance:premium-layout/ref-14": "b042317268286cff3e52d145",
|
||||
"guidance:premium-layout/ref-15": "acbebf9c77fb2432be877bf0",
|
||||
"guidance:premium-layout/ref-16": "d3b2ec816983aadb5b4690be",
|
||||
"guidance:premium-layout/ref-17": "7b1b2fc5b9690213bbbe07ac",
|
||||
"guidance:premium-layout/ref-18": "5b376e57c70ce8a49cf4e491",
|
||||
"guidance:premium-layout/ref-19": "246c33f9fff157ab9a4c1df7",
|
||||
"guidance:premium-layout/ref-20": "b5db869088ed8296d8329f83",
|
||||
"guidance:premium-layout/ref-21": "fd6b7caabcf3077f9bdc32c7",
|
||||
"guidance:premium-layout/ref-22": "8561d1fd6f23335373eab1ba",
|
||||
"guidance:premium-layout/ref-23": "30f3978167ba3da8770ec5de",
|
||||
"guidance:premium-layout/ref-24": "6c72e53a6e2f497852f98f04",
|
||||
"guidance:premium-layout/ref-25": "51c0712b17a8372a5fa151df",
|
||||
"guidance:premium-layout/ref-26": "f356b77194288909ee4352b4",
|
||||
"guidance:premium-layout/ref-27": "b30a9778355b7132633f51b3"
|
||||
"wiki:Draft:inbox": "a599a5734d13ec81fed1ffd1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {guidanceUrl} from '../page-links';
|
||||
import {layoutDocuments} from '../layouts/documents';
|
||||
import {getSkill,skillCatalog,type Block,type Resource} from './catalog';
|
||||
|
||||
export type GuidanceResource=Resource&{documentPath?:string};
|
||||
@@ -44,7 +43,6 @@ guidanceDocuments.push({
|
||||
{type:'links',refs:['doc:street-people/checks','doc:shadow-puppet/delivery']},
|
||||
],
|
||||
});
|
||||
guidanceDocuments.unshift(...layoutDocuments);
|
||||
const registry=new Map(guidanceDocuments.map(doc=>[doc.path,doc]));
|
||||
export function getGuidance(path:string){return registry.get(path);}
|
||||
export function guidanceBacklinks(path:string){return guidanceDocuments.filter(doc=>doc.path!==path&&doc.blocks.some(b=>b.type==='links'&&b.refs!.some(id=>doc.resources.find(r=>r.id===id)?.documentPath===path)));}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const apiDocuments:Record<string,readonly string[]>={
|
||||
"统一内容流",
|
||||
"GET",
|
||||
"查询参数:q(可选,最多 200 字符);category(可选,标签名称或命名空间);cursor(可选,上一页 nextCursor)。",
|
||||
"JSON:entries 数组与 nextCursor;没有更多结果时 nextCursor 为 null。静态内容仅出现在第一页。",
|
||||
"JSON:entries 数组与 nextCursor;没有更多结果时 nextCursor 为 null。静态内容仅出现在第一页;已由相同 publicId 的数据库笔记接管时不重复展示。",
|
||||
"GET /api/explore-feed?q=排版",
|
||||
"503:素材库暂时无法连接。"
|
||||
],
|
||||
@@ -21,18 +21,18 @@ export const apiDocuments:Record<string,readonly string[]>={
|
||||
"/api/notes/{id}": [
|
||||
"读取与编辑笔记",
|
||||
"GET, PUT",
|
||||
"路径参数:id,笔记稳定 ID。PUT 使用 application/json,所有字段必填:title(1–200 字符)、description(最多 1000 字符)、markdown(最多 100000 字符)、tags(最多 50 个标签 UUID)、revision(GET 返回的 entry.revision,64 位十六进制)。",
|
||||
"GET 返回 {entry,tags};PUT 返回更新后的 {entry}。先 GET 获取 revision,再 PUT;冲突后重新读取。",
|
||||
"路径参数:id,笔记稳定 ID(publicId 或存储 UUID)。PUT 使用 application/json,以下字段必填:title(1–200 字符)、description(最多 1000 字符)、markdown(最多 100000 字符)、tags(最多 50 个标签 UUID)、revision(GET 返回的 entry.revision,64 位十六进制)。可选 assets(最多 100 项 {id:媒体 UUID,role:content|caption},替换媒体关联,原二进制不删除);coverAssetId(图片 UUID 或 null,必须为本笔记 content 图片;null 清除显式封面)。静态代码页须先通过创建接口保存为数据库笔记,才可更新媒体。",
|
||||
"GET 返回 {entry,tags},entry.assets 为媒体关联、entry.coverAssetId 为显式封面;PUT 返回更新后的 {entry}。先 GET 获取 revision,再 PUT;冲突后重新读取。",
|
||||
"GET /api/notes/{id}\nPUT /api/notes/{id}\n{\"title\":\"标题\",\"description\":\"简介\",\"markdown\":\"正文\",\"tags\":[],\"revision\":\"<GET 返回的 entry.revision>\"}",
|
||||
"400:输入错误;404:笔记不存在;409:版本冲突。"
|
||||
],
|
||||
"/api/posts": [
|
||||
"创建笔记或素材包",
|
||||
"POST",
|
||||
"application/json:kind(必填,image / video / audio / article / motion)、title(必填,1–200 字符);slug(可选,最多 120 字符,以小写字母或数字开头,仅含小写字母、数字、连字符);description(可选,最多 1000 字符);text(可选,最多 100000 字符);tags(可选,最多 50 个 UUID);coverAssetId(可选,图片 UUID);assets(可选,最多 100 项,{id: UUID,role: content | caption},role 默认 content)。媒体类型必须引用至少一个对应类型的 content 素材。",
|
||||
"201 JSON:{id,slug}。引用已有素材;不会在此接口上传二进制文件。",
|
||||
"application/json:kind(必填,image / video / audio / article / motion)、title(必填,1–200 字符);publicId(可选,唯一的 24 位小写十六进制稳定 ID,用于保留原页面地址);slug(可选,最多 120 字符,以小写字母或数字开头,仅含小写字母、数字、连字符);description(可选,最多 1000 字符);text(可选,最多 100000 字符);tags(可选,最多 50 个 UUID);coverAssetId(可选,图片 UUID);assets(可选,最多 100 项,{id: UUID,role: content | caption},role 默认 content)。媒体类型必须引用至少一个对应类型的 content 素材。",
|
||||
"201 JSON:{id,slug,url,publicId?},url 为 /explore/<publicId 或 id>。id 仍为存储 UUID。引用已有素材;不会在此接口上传二进制文件。",
|
||||
"POST /api/posts\n{\"kind\":\"article\",\"title\":\"新笔记\",\"text\":\"正文\",\"tags\":[]}",
|
||||
"400:输入或素材引用不合法;409:slug 已存在。"
|
||||
"400:输入或素材引用不合法;409:slug 或 publicId 已存在。"
|
||||
],
|
||||
"/api/tags": [
|
||||
"标签列表与创建",
|
||||
@@ -43,10 +43,10 @@ export const apiDocuments:Record<string,readonly string[]>={
|
||||
"400:输入错误;409:slug 冲突。"
|
||||
],
|
||||
"/api/upload": [
|
||||
"上传媒体并创建素材包",
|
||||
"上传媒体,可独立保存素材",
|
||||
"POST",
|
||||
"multipart/form-data:kind(必填,image / video / audio);title(必填,1–200 字符);slug(可选,小写字母或数字组成的非空段,以连字符分隔,最多 120 字符);description(可选,最多 100000 字符);tags(可选,UUID 数组的 JSON 字符串,最多 50 项);文件字段名任意,至少一个文件。最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB。图片支持 JPG、PNG、WebP、GIF、AVIF;音频必须为独立声音文件。",
|
||||
"201 JSON:{id,slug,url,assets},assets 为素材 UUID 数组。视频会自动进入后台加工队列。",
|
||||
"multipart/form-data:kind(必填,image / video / audio);title(普通上传必填,1–200 字符;assetOnly=true 时可省略);assetOnly(可选,字符串 true / false,默认 false;true 时只存媒体,不创建 post);slug(可选,小写字母或数字组成的非空段,以连字符分隔,最多 120 字符);description(可选,最多 100000 字符);tags(可选,UUID 数组的 JSON 字符串,最多 50 项);文件字段名任意,至少一个文件。最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB。图片支持 JPG、PNG、WebP、GIF、AVIF;音频必须为独立声音文件。",
|
||||
"201 JSON:普通上传为 {id,slug,url,assets};assetOnly=true 为 {assets,files:[{id,filename,sha256}]},不创建 post。assets 为素材 UUID 数组,之后可交给 /api/posts 组合;视频素材会自动进入后台加工队列。",
|
||||
"POST /api/upload\nContent-Type: multipart/form-data\nkind=image\ntitle=示例图片\ntags=[]\nfile=<图片文件>",
|
||||
"400:输入或媒体不合法;409:slug 冲突;413:上传超过限制。"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ export function apiError(res: NextApiResponse,error: unknown) {
|
||||
if(error instanceof ZodError) return res.status(400).json({error:"请检查输入内容",issues:error.issues});
|
||||
const message=error instanceof Error?error.message:"";
|
||||
const clientMessages:Record<string,string>={
|
||||
'Media changes require a stored note':'媒体与封面需先通过创建接口保存为数据库笔记',
|
||||
'Character PNG must have transparency':'请上传带真实透明背景的 PNG,透明区域 alpha 为 0,人物内部 alpha 至少为 250',
|
||||
'Referenced asset does not exist':'引用的素材不存在',
|
||||
'A media post needs matching content':'素材包需要包含对应类型的素材',
|
||||
|
||||
@@ -3,22 +3,24 @@ import {noteDescriptions} from '@/content/note-descriptions';
|
||||
import {guidanceDocuments,guidanceMarkdown} from '@/content/skills/guidance';
|
||||
import {wikiPages,wikiMarkdown} from '@/content/wiki';
|
||||
import {skillCatalog} from '@/content/skills/catalog';
|
||||
import {publicPostId} from './model';
|
||||
import {pageId,exploreUrl,guidanceUrl} from '@/content/page-links';
|
||||
import {getPostById,feed} from './library';
|
||||
import {getPostById,feed,publishedPublicIds} from './library';
|
||||
import {applyStaticEdit,staticEdits,withRevision} from './note-edits';
|
||||
export type ExploreMedia={kind:'image'|'video'|'audio';url:string;title:string;poster?:string};
|
||||
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string;editableMarkdown?:string;revision?:string};
|
||||
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string;editableMarkdown?:string;revision?:string;assets?:{id:string;kind:string;role:string}[];coverAssetId?:string|null};
|
||||
export const staticEntries:ExploreEntry[]=[...guidanceDocuments.map(doc=>{const card=skillCatalog.find(c=>doc.path===c.id);return {id:pageId('guidance:'+doc.path),title:doc.title,description:noteDescriptions[doc.path]||doc.blocks.find(b=>b.type==='paragraph')?.text||doc.title,imageCount:new Set([...(card?[card.image]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>r.href)]).size,namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media:Array.from(new Map([...(card?[{kind:'image' as const,url:card.image,title:card.alt}]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>({kind:'image' as const,url:r.href,title:r.title}))].map(m=>[m.url,m])).values()),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,description:page.summary,imageCount:0,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))];
|
||||
// Canonicalize document references, including absolute links; pictures remain explicit links.
|
||||
export function canonicalMarkdown(text:string){return text.replace(/\]\((?:http:\/\/8\.211\.154\.134:18131)?\/skills\/([^ )]+)\)/g,(whole,path:string)=>{const clean=path.replace(/\/index\.md$/,'').replace(/\.md$/,'');return pageId('guidance:'+clean)?']('+guidanceUrl(clean)+'?agent_mode=True)':whole;});}
|
||||
export async function getEntry(id:string,client?:PoolClient):Promise<ExploreEntry|null>{
|
||||
const page=staticEntries.find(e=>e.id===id);if(page){const edits=await staticEdits(client);return withRevision(applyStaticEdit({...page,markdown:canonicalMarkdown(page.markdown)},edits.find(e=>e.page_id===id)));}
|
||||
if(!/^[0-9a-f-]{36}$/.test(id))return null;
|
||||
const post=await getPostById(id,client);if(!post)return null;
|
||||
const media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text'&&a.role==='content').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+post.assets.find(p=>p.source_asset_id===a.id&&p.derivation==='poster')?.id}:{})}));
|
||||
const validId=/^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(id);
|
||||
const post=validId&&(process.env.DATABASE_URL||client||id.length===36)?await getPostById(id,client):null;
|
||||
if(!post){const page=staticEntries.find(e=>e.id===id);if(!page)return null;const edits=await staticEdits(client);return withRevision(applyStaticEdit({...page,markdown:canonicalMarkdown(page.markdown)},edits.find(e=>e.page_id===id)));}
|
||||
const orderedAssets=[...post.assets].sort((a,b)=>Number(b.id===post.cover_asset_id)-Number(a.id===post.cover_asset_id));
|
||||
const media:ExploreMedia[]=orderedAssets.filter(a=>a.kind!=='text'&&a.role==='content').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+post.assets.find(p=>p.source_asset_id===a.id&&p.derivation==='poster')?.id}:{})}));
|
||||
const editableMarkdown=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n');
|
||||
const body=editableMarkdown+'\n\n'+post.assets.filter(a=>a.derivation).map(a=>'['+({poster:'视频封面',audio_track:'独立音轨',silent_video:'静音视频'}[a.derivation||'']||a.derivation)+'](/api/media/'+a.id+')').join(' · ');
|
||||
return withRevision({id:post.id,title:post.title,editableMarkdown,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug});
|
||||
return withRevision({id:publicPostId(post),assets:post.assets.filter(a=>a.kind!=='text').map(a=>({id:a.id,kind:a.kind,role:a.role})),coverAssetId:post.cover_asset_id,title:post.title,editableMarkdown,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug});
|
||||
}
|
||||
export async function exploreFeed(q='',category='',cursor?:string){const [data,edits]=await Promise.all([feed({q,cursor}),staticEdits()]);const pages=staticEntries.map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));return {entries:[...(cursor?[]:pages.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+post.assets.find(a=>a.source_asset_id===video.id&&a.derivation==='poster')?.id,title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};}
|
||||
export async function exploreFeed(q='',category='',cursor?:string){const [data,edits,published]=await Promise.all([feed({q,cursor}),staticEdits(),publishedPublicIds()]);const pages=staticEntries.filter(e=>!published.includes(e.id)).map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));return {entries:[...(cursor?[]:pages.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:publicPostId(post),title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+post.assets.find(a=>a.source_asset_id===video.id&&a.derivation==='poster')?.id,title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};}
|
||||
export function entryText(entry:ExploreEntry){return `${entry.markdown}\n\nDescription: ${entry.description}\nNamespace: ${entry.namespace}\nCategories: ${entry.categories.join(', ')}\n\n`+entry.media.map(m=>`- ${m.kind}: [${m.title}](${m.url})`).join('\n')+'\n\n[网页]('+exploreUrl(entry.id)+')\n';}
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function getPost(slug: string): Promise<Post | null> {
|
||||
return serialize(result.rows,await tags())[0] || null;
|
||||
}
|
||||
export async function getPostById(id:string,client?:PoolClient):Promise<Post|null>{
|
||||
const result=await (client||db()).query(`${expand} WHERE p.id=$1`,[id]);
|
||||
const result=await (client||db()).query(`${expand} WHERE ${/^[0-9a-f]{24}$/.test(id)?'p.public_id':'p.id'}=$1`,[id]);
|
||||
return serialize(result.rows,await tags(client))[0]||null;
|
||||
}
|
||||
export async function feed(options: {kind?: PostKind; path?: string; cursor?: string; q?: string} = {}): Promise<FeedData> {
|
||||
@@ -31,3 +31,5 @@ export async function feed(options: {kind?: PostKind; path?: string; cursor?: st
|
||||
const contributions=(await db().query(`SELECT to_char(occurred_at AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day,sum(amount)::integer AS total FROM contribution_events WHERE occurred_at>=now()-interval '366 days' GROUP BY day ORDER BY day`)).rows;
|
||||
return {posts,tags:allTags,contributions,nextCursor:hasMore&&last?encodeCursor(last.created_at,last.id):null};
|
||||
}
|
||||
|
||||
export async function publishedPublicIds():Promise<string[]>{return (await db().query("SELECT public_id FROM posts WHERE public_id IS NOT NULL")).rows.map(p=>p.public_id);}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const sections = [
|
||||
export type PostKind = typeof sections[number]["kind"];
|
||||
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[]; role: string; source_asset_id: string | null; derivation: string | null };
|
||||
export type Tag = { id: string; name: string; slug: string; path: string };
|
||||
export type Post = { description: string; id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
|
||||
export type Post = { description: string; id: string; public_id?: string | null; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
|
||||
export type FeedData = { posts: Post[]; tags: Tag[]; contributions: { day: string; total: number }[]; nextCursor: string | null; unavailable?: boolean };
|
||||
export function validSection(path: string) { return sections.find(s => s.path === path); }
|
||||
export const mediaUrl = (id: string, variant?: string) => `/api/media/${id}${variant ? `?variant=${variant}` : ""}`;
|
||||
@@ -20,3 +20,5 @@ export function decodeCursor(value?: string) {
|
||||
} catch { /* invalid pagination cursor */ }
|
||||
throw new Error("Invalid cursor");
|
||||
}
|
||||
|
||||
export const publicPostId=(post:Pick<Post,'id'|'public_id'>)=>post.public_id||post.id;
|
||||
|
||||
@@ -3,36 +3,54 @@ import {createHash,randomUUID} from 'node:crypto';
|
||||
import {z} from 'zod';
|
||||
import {db,transaction} from './db';
|
||||
import type {ExploreEntry} from './explore';
|
||||
import {linkDerivedAssets} from './asset-links';
|
||||
import {postInput} from './write';
|
||||
export function withoutTitle(markdown:string){return markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'');}
|
||||
export function withRevision(entry:ExploreEntry):ExploreEntry{
|
||||
const editableMarkdown=entry.editableMarkdown??withoutTitle(entry.markdown);
|
||||
const revision=createHash('sha256').update(JSON.stringify([entry.title,entry.description,editableMarkdown,[...entry.categories].sort()])).digest('hex');
|
||||
const revision=createHash('sha256').update(JSON.stringify([entry.title,entry.description,editableMarkdown,[...entry.categories].sort(),entry.coverAssetId??null,entry.assets?.map(a=>[a.id,a.role])??[]])).digest('hex');
|
||||
return {...entry,editableMarkdown,revision};
|
||||
}
|
||||
export async function staticEdits(client?:PoolClient){if(!process.env.DATABASE_URL)return [];return (await (client||db()).query(`SELECT e.*,COALESCE((SELECT jsonb_agg(t.name ORDER BY t.name) FROM note_edit_tags et JOIN tags t ON t.id=et.tag_id WHERE et.page_id=e.page_id),'[]') AS categories FROM note_edits e`)).rows;}
|
||||
export function applyStaticEdit(entry:ExploreEntry,edit?:{title:string;description:string;markdown:string;categories:string[]}):ExploreEntry{
|
||||
return edit?{...entry,title:edit.title,description:edit.description,editableMarkdown:edit.markdown,markdown:'# '+edit.title+'\n\n'+edit.markdown,categories:edit.categories}:entry;
|
||||
}
|
||||
export const noteEditInput=z.object({title:z.string().trim().min(1).max(200),description:z.string().trim().max(1000),markdown:z.string().max(100000),tags:z.array(z.uuid()).max(50),revision:z.string().regex(/^[0-9a-f]{64}$/)});
|
||||
export const noteEditInput=z.object({title:z.string().trim().min(1).max(200),description:z.string().trim().max(1000),markdown:z.string().max(100000),tags:z.array(z.uuid()).max(50),revision:z.string().regex(/^[0-9a-f]{64}$/),assets:postInput.shape.assets.unwrap().optional(),coverAssetId:z.uuid().nullable().optional()});
|
||||
export class NoteEditConflict extends Error{}
|
||||
export async function saveNote(id:string,input:z.infer<typeof noteEditInput>,read:(client:PoolClient)=>Promise<ExploreEntry|null>){
|
||||
await transaction(async client=>{
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',['note-edit:'+id]);
|
||||
const isPost=/^[0-9a-f-]{36}$/.test(id);
|
||||
if(isPost)await client.query('SELECT id FROM posts WHERE id=$1 FOR UPDATE',[id]);
|
||||
const stored=(await client.query(`SELECT id,kind,cover_asset_id FROM posts WHERE ${/^[0-9a-f]{24}$/.test(id)?'public_id':'id'}=$1 FOR UPDATE`,[id])).rows[0];
|
||||
const isPost=Boolean(stored),postId=stored?.id;
|
||||
const current=await read(client);if(!current)throw Error('Note not found');
|
||||
if(current.revision!==input.revision)throw new NoteEditConflict('这篇笔记已被修改,请重新打开编辑后再保存。');
|
||||
const tagIds=[...new Set(input.tags)];
|
||||
if((await client.query('SELECT id FROM tags WHERE id=ANY($1::uuid[])',[tagIds])).rowCount!==tagIds.length)throw Error('Tag not found');
|
||||
if(isPost){
|
||||
await client.query('UPDATE posts SET title=$2,description=$3,updated_at=now(),edited_at=now() WHERE id=$1',[id,input.title,input.description]);
|
||||
if(input.assets!==undefined){
|
||||
const ids=[...new Set(input.assets.map(a=>a.id))];
|
||||
const assets=ids.length?(await client.query('SELECT id,kind FROM assets WHERE id=ANY($1::uuid[])',[ids])).rows:[];
|
||||
if(assets.length!==ids.length||assets.some(a=>a.kind==='text'))throw Error('Referenced asset does not exist');
|
||||
if(['image','video','audio'].includes(stored.kind)&&!input.assets.some(a=>a.role==='content'&&assets.some(r=>r.id===a.id&&r.kind===stored.kind)))throw Error('A media post needs matching content');
|
||||
await client.query("DELETE FROM post_assets pa USING assets a WHERE pa.post_id=$1 AND pa.asset_id=a.id AND a.kind<>'text'",[postId]);
|
||||
let position=Number((await client.query('SELECT COALESCE(max(position),-1)+1 AS n FROM post_assets WHERE post_id=$1',[postId])).rows[0].n);
|
||||
for(const a of input.assets)await client.query('INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,$4)',[postId,a.id,position++,a.role]);
|
||||
}
|
||||
if(input.assets!==undefined&&input.coverAssetId===undefined&&stored.cover_asset_id&&!input.assets.some(a=>a.id===stored.cover_asset_id&&a.role==='content'))await client.query("UPDATE posts SET cover_asset_id=(SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND pa.role='content' AND a.kind='image' ORDER BY pa.position LIMIT 1) WHERE id=$1",[postId]);
|
||||
if(input.coverAssetId!==undefined){
|
||||
if(input.coverAssetId!==null&&!(await client.query("SELECT 1 FROM assets a JOIN post_assets pa ON pa.asset_id=a.id WHERE pa.post_id=$1 AND a.id=$2 AND a.kind='image' AND pa.role='content'",[postId,input.coverAssetId])).rowCount)throw Error('Cover must reference an image');
|
||||
await client.query('UPDATE posts SET cover_asset_id=$2 WHERE id=$1',[postId,input.coverAssetId]);
|
||||
}
|
||||
await client.query('UPDATE posts SET title=$2,description=$3,updated_at=now(),edited_at=now() WHERE id=$1',[postId,input.title,input.description]);
|
||||
// Replace this note's text link, never modify a text asset shared by other notes.
|
||||
await client.query("DELETE FROM post_assets pa USING assets a WHERE pa.post_id=$1 AND pa.asset_id=a.id AND a.kind='text'",[id]);
|
||||
await client.query("DELETE FROM post_assets pa USING assets a WHERE pa.post_id=$1 AND pa.asset_id=a.id AND a.kind='text'",[postId]);
|
||||
const assetId=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[assetId,input.markdown]);
|
||||
await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) SELECT $1,$2,COALESCE(max(position)+1,0),'caption' FROM post_assets WHERE post_id=$1",[id,assetId]);
|
||||
await client.query('DELETE FROM post_tags WHERE post_id=$1',[id]);
|
||||
for(const tagId of tagIds)await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[id,tagId]);
|
||||
await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) SELECT $1,$2,COALESCE(max(position)+1,0),'caption' FROM post_assets WHERE post_id=$1",[postId,assetId]);
|
||||
await client.query('DELETE FROM post_tags WHERE post_id=$1',[postId]);
|
||||
for(const tagId of tagIds)await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[postId,tagId]);
|
||||
if(input.assets!==undefined)await linkDerivedAssets(client,postId);
|
||||
}else{
|
||||
if(input.assets!==undefined||input.coverAssetId!==undefined)throw Error('Media changes require a stored note');
|
||||
await client.query(`INSERT INTO note_edits(page_id,title,description,markdown) VALUES($1,$2,$3,$4) ON CONFLICT(page_id) DO UPDATE SET title=excluded.title,description=excluded.description,markdown=excluded.markdown,updated_at=now()`,[id,input.title,input.description,input.markdown]);
|
||||
await client.query('DELETE FROM note_edit_tags WHERE page_id=$1',[id]);
|
||||
for(const tagId of tagIds)await client.query('INSERT INTO note_edit_tags(page_id,tag_id) VALUES($1,$2)',[id,tagId]);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { transaction } from "./db";
|
||||
import {linkDerivedAssets} from "./asset-links";
|
||||
export const postInput=z.object({
|
||||
publicId:z.string().regex(/^[0-9a-f]{24}$/).optional(),
|
||||
slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,119}$/).optional(),
|
||||
kind:z.enum(["image","video","audio","article","motion"]),title:z.string().trim().min(1).max(200),
|
||||
assets:z.array(z.object({id:z.uuid(),role:z.enum(["content","caption"]).default("content")})).max(100).default([]),
|
||||
@@ -18,11 +19,11 @@ export async function createPost(input:z.infer<typeof postInput>) {
|
||||
if(assets.length!==ids.length) throw new Error("Referenced asset does not exist");
|
||||
if(["image","video","audio"].includes(input.kind)&&!input.assets.some(link=>link.role==='content'&&assets.some(a=>a.id===link.id&&a.kind===input.kind))) throw new Error("A media post needs matching content");
|
||||
if(input.coverAssetId && !(await client.query("SELECT 1 FROM assets WHERE id=$1 AND kind='image'",[input.coverAssetId])).rowCount) throw new Error("Cover must reference an image");
|
||||
await client.query('INSERT INTO posts(id,slug,kind,title,cover_asset_id,description) VALUES($1,$2,$3,$4,$5,$6)',[id,slug,input.kind,input.title,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title]);
|
||||
await client.query('INSERT INTO posts(id,slug,kind,title,cover_asset_id,description,public_id) VALUES($1,$2,$3,$4,$5,$6,$7)',[id,slug,input.kind,input.title,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title,input.publicId||null]);
|
||||
for(const [position,a] of input.assets.entries()) await client.query('INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,$4)',[id,a.id,position,a.role]);
|
||||
if(input.text){const textId=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,input.text]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[id,textId,input.assets.length]);}
|
||||
for(const tag of new Set(input.tags)) await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[id,tag]);
|
||||
await linkDerivedAssets(client,id);
|
||||
return {id,slug};
|
||||
return {id,slug,...(input.publicId?{publicId:input.publicId}:{}),url:'/explore/'+(input.publicId||id)};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ export async function upload(req:NextApiRequest,res:NextApiResponse,policy:Uploa
|
||||
const form=formidable({maxFileSize:250*1024*1024,maxTotalFileSize:1024*1024*1024,maxFiles:16,allowEmptyFiles:false});
|
||||
form.on('fileBegin',(_name,file)=>temporary.push(file.filepath));
|
||||
const [fields,files]=await form.parse(req);
|
||||
const assetOnly=z.enum(['true','false']).default('false').parse(fields.assetOnly?.[0])==='true';
|
||||
const kind=z.enum(['image','video','audio']).parse(policy.validateImage?'image':fields.kind?.[0]);
|
||||
const title=z.string().trim().min(1).max(200).parse(fields.title?.[0]||policy.defaultTitle);
|
||||
const title=z.string().trim().min(1).max(200).parse(fields.title?.[0]||policy.defaultTitle||(assetOnly?'上传素材':undefined));
|
||||
const slug=z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(120).optional().parse(fields.slug?.[0]);
|
||||
const description=z.string().trim().max(100000).parse(fields.description?.[0]||'');
|
||||
const tagIds=z.array(z.uuid()).max(50).parse(JSON.parse(fields.tags?.[0]||'[]'));
|
||||
@@ -46,19 +47,21 @@ export async function upload(req:NextApiRequest,res:NextApiResponse,policy:Uploa
|
||||
const id=randomUUID(),key=`originals/${id}`;await putFile(key,file.filepath,mime);uploaded.push(key);
|
||||
records.push({id,key,mime,width,height,duration,hasAudio,size:file.size,hash:hash.digest('hex'),filename:file.originalFilename||'未命名素材'});
|
||||
}
|
||||
const postId=randomUUID();
|
||||
const postId=assetOnly?null:randomUUID();
|
||||
await transaction(async client=>{
|
||||
await client.query('INSERT INTO posts(id,slug,kind,title,description) VALUES($1,$2,$3,$4,$5)',[postId,slug||postId,kind,title,description.slice(0,1000)||title]);
|
||||
if(postId)await client.query('INSERT INTO posts(id,slug,kind,title,description) VALUES($1,$2,$3,$4,$5)',[postId,slug||postId,kind,title,description.slice(0,1000)||title]);
|
||||
for(const [position,a] of records.entries()){
|
||||
await client.query('INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height,duration,has_audio,processing_status) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',[a.id,kind,a.filename,a.key,a.mime,a.size,a.hash,a.width,a.height,a.duration,a.hasAudio,kind==='video'?'pending':'ready']);
|
||||
await client.query('INSERT INTO post_assets(post_id,asset_id,position) VALUES($1,$2,$3)',[postId,a.id,position]);
|
||||
if(postId)await client.query('INSERT INTO post_assets(post_id,asset_id,position) VALUES($1,$2,$3)',[postId,a.id,position]);
|
||||
for(const tag of new Set(tagIds))await client.query('INSERT INTO asset_tags(asset_id,tag_id) VALUES($1,$2)',[a.id,tag]);
|
||||
if(kind==='video')await client.query('INSERT INTO media_jobs(id,asset_id) VALUES($1,$2)',[randomUUID(),a.id]);
|
||||
}
|
||||
if(description){const id=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[id,description]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[postId,id,records.length]);}
|
||||
for(const tag of new Set(tagIds))await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[postId,tag]);
|
||||
if(postId&&description){const id=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[id,description]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[postId,id,records.length]);}
|
||||
if(postId)for(const tag of new Set(tagIds))await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[postId,tag]);
|
||||
});
|
||||
uploaded.length=0;return res.status(201).json({id:postId,slug:slug||postId,url:`/p/${slug||postId}`,assets:records.map(a=>a.id)});
|
||||
uploaded.length=0;
|
||||
if(assetOnly)return res.status(201).json({assets:records.map(a=>a.id),files:records.map(a=>({id:a.id,filename:a.filename,sha256:a.hash}))});
|
||||
return res.status(201).json({id:postId,slug:slug||postId,url:`/p/${slug||postId}`,assets:records.map(a=>a.id)});
|
||||
}catch(e){if(e instanceof Error && (/maxFileSize|maxTotalFileSize|maxFiles/.test(e.message)))return res.status(413).json({error:'最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB'});return apiError(res,e);}
|
||||
finally{await Promise.allSettled(temporary.map(p=>rm(p,{force:true})));await Promise.allSettled(uploaded.map(removeObject));}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {publishedPublicIds} from '@/lib/library';
|
||||
import {staticEntries} from '@/lib/explore';
|
||||
import {applyStaticEdit,staticEdits} from '@/lib/note-edits';
|
||||
import {exploreUrl} from '@/content/page-links';
|
||||
export default async function handler(_:NextApiRequest,res:NextApiResponse){const edits=await staticEdits();const entries=staticEntries.map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));res.setHeader('Content-Type','text/plain; charset=utf-8');res.send('# PROMPT\n\n'+entries.map(e=>`- [${e.title}](${exploreUrl(e.id)}?agent_mode=True)`).join('\n')+'\n\n素材分页索引:/api/explore-feed(使用 nextCursor 翻页)。所有内容均使用 /explore/{id}?agent_mode=True 读取纯文本。\n');}
|
||||
export default async function handler(_:NextApiRequest,res:NextApiResponse){const [edits,published]=await Promise.all([staticEdits(),process.env.DATABASE_URL?publishedPublicIds():Promise.resolve([])]);const entries=staticEntries.filter(e=>!published.includes(e.id)).map(e=>applyStaticEdit(e,edits.find(edit=>edit.page_id===e.id)));res.setHeader('Content-Type','text/plain; charset=utf-8');res.send('# PROMPT\n\n'+entries.map(e=>`- [${e.title}](${exploreUrl(e.id)}?agent_mode=True)`).join('\n')+'\n\n素材分页索引:/api/explore-feed(使用 nextCursor 翻页)。所有内容均使用 /explore/{id}?agent_mode=True 读取纯文本。\n');}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import {legacyLayoutIds} from '@/content/layouts/documents';
|
||||
import {guidanceUrl} from '@/content/page-links';
|
||||
import {ExploreDetail} from '@/components/ExploreDetail';
|
||||
import {getEntry,entryText,exploreFeed} from '@/lib/explore';
|
||||
export default ExploreDetail;
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query,res,req})=>{if(legacyLayoutIds.includes(String(params?.id)))return {redirect:{destination:guidanceUrl('premium-layout/router'),permanent:false}};try{const entry=await getEntry(String(params?.id||''));if(!entry)return {notFound:true};if(String(query.agent_mode||'').toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-cache');res.end(req.method==='HEAD'?undefined:entryText(entry));return {props:{}};}return {props:{entry,background:(await exploreFeed()).entries}};}catch{res.statusCode=503;res.setHeader('Retry-After','10');res.end('内容暂时无法读取,请稍后重试');return {props:{}};}};
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query,res,req})=>{try{const entry=await getEntry(String(params?.id||''));if(!entry)return {notFound:true};if(String(query.agent_mode||'').toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-cache');res.end(req.method==='HEAD'?undefined:entryText(entry));return {props:{}};}return {props:{entry,background:(await exploreFeed()).entries}};}catch{res.statusCode=503;res.setHeader('Retry-After','10');res.end('内容暂时无法读取,请稍后重试');return {props:{}};}};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import {publicPostId} from '@/lib/model';
|
||||
import {getPost} from '@/lib/library';
|
||||
export default function LegacyPost(){return null;}
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query})=>{const post=await getPost(String(params?.slug||''));return post?{redirect:{destination:'/explore/'+post.id+(String(query.agent_mode||'').toLowerCase()==='true'?'?agent_mode=True':''),permanent:true}}:{notFound:true};};
|
||||
export const getServerSideProps:GetServerSideProps=async({params,query})=>{const post=await getPost(String(params?.slug||''));return post?{redirect:{destination:'/explore/'+publicPostId(post)+(String(query.agent_mode||'').toLowerCase()==='true'?'?agent_mode=True':''),permanent:true}}:{notFound:true};};
|
||||
|
||||
Reference in New Issue
Block a user