From b4f4facea0bfff8111944be2abd96ce1c7276418 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 25 Oct 2026 23:44:55 +0800 Subject: [PATCH] Show independent note descriptions, tags and image counts without author chrome --- apps/wiki/db/003_note_descriptions.sql | 6 ++++++ apps/wiki/src/components/ExploreDetail.tsx | 6 +++--- apps/wiki/src/components/ExploreFeed.tsx | 13 +++++++++++- apps/wiki/src/content/note-descriptions.ts | 13 ++++++++++++ apps/wiki/src/lib/explore.ts | 11 +++++----- apps/wiki/src/lib/library.ts | 2 +- apps/wiki/src/lib/model.ts | 2 +- apps/wiki/src/lib/publish-learning-series.ts | 6 +++--- apps/wiki/src/lib/write.ts | 3 ++- apps/wiki/src/pages/api/upload.ts | 2 +- apps/wiki/src/pages/categories.tsx | 2 +- apps/wiki/src/pages/index.tsx | 2 +- apps/wiki/src/styles/globals.css | 3 +++ apps/wiki/tests/explore.test.ts | 8 ++++++++ apps/wiki/tests/integration.test.ts | 5 +++-- docs/note-preview-stage.md | 21 ++++++++++++++++++++ 16 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 apps/wiki/db/003_note_descriptions.sql create mode 100644 apps/wiki/src/content/note-descriptions.ts create mode 100644 docs/note-preview-stage.md diff --git a/apps/wiki/db/003_note_descriptions.sql b/apps/wiki/db/003_note_descriptions.sql new file mode 100644 index 0000000..dcc4036 --- /dev/null +++ b/apps/wiki/db/003_note_descriptions.sql @@ -0,0 +1,6 @@ +ALTER TABLE posts ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT ''; +UPDATE posts p SET description=left(COALESCE(( + SELECT a.body FROM post_assets pa JOIN assets a ON a.id=pa.asset_id + WHERE pa.post_id=p.id AND a.kind='text' AND a.body IS NOT NULL + ORDER BY pa.position LIMIT 1 +),p.title),200) WHERE p.description=''; diff --git a/apps/wiki/src/components/ExploreDetail.tsx b/apps/wiki/src/components/ExploreDetail.tsx index 0ee5af2..2cb66f4 100644 --- a/apps/wiki/src/components/ExploreDetail.tsx +++ b/apps/wiki/src/components/ExploreDetail.tsx @@ -8,7 +8,7 @@ import {previewUrl,exploreUrl} from '@/content/page-links'; import {Shell} from './Shell'; import {ExploreFeed} from './ExploreFeed'; function TextBody({text}:{text:string}){return <>{text.split(/\n\n+/).map((part,i)=>{if(part.startsWith('# '))return null;if(part.startsWith('```'))return
{part.replace(/^```[^\n]*\n?|```$/g,'')}
;if(part.startsWith('## '))return

{part.slice(3)}

;return

{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?{m[1]}:piece})}

})}} -export function ExploreDetail({entry,background}:{entry:ExploreEntry;background:ExploreEntry[]}){const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();const closeRef=useRef(null);const detailRef=useRef(null);const media=entry.media[index];const close=()=>void router.push('/');useEffect(()=>{const previous=document.activeElement as HTMLElement|null;closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';function key(e:KeyboardEvent){if(e.key==='Escape')close();if(e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));if(e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));if(e.key==='Tab'){const items=detailRef.current?.querySelectorAll('a[href],button:not([disabled]),video,audio');if(!items?.length)return;const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}}window.addEventListener('keydown',key);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',key);previous?.focus()};},[entry.id]);return {entry.title} · PROMPT
e.stopPropagation()}>
-{media?.kind==='image'?{media.title}:media?.kind==='video'?

{entry.title}

{entry.description}

{entry.categories.map(c=>#{c} )}
内容 ID · {entry.id}
纯文本{media&&原文件 ↗}
} diff --git a/apps/wiki/src/components/ExploreFeed.tsx b/apps/wiki/src/components/ExploreFeed.tsx index e691586..2ea69da 100644 --- a/apps/wiki/src/components/ExploreFeed.tsx +++ b/apps/wiki/src/components/ExploreFeed.tsx @@ -1,5 +1,16 @@ import Link from 'next/link'; import {useState} from 'react'; +import {Images} from 'lucide-react'; import type {ExploreEntry} from '@/lib/explore'; import {exploreUrl,previewUrl} from '@/content/page-links'; -export function ExploreFeed({initial,cursor:initialCursor=null,q='',category=''}:{initial:ExploreEntry[];cursor?:string|null;q?:string;category?:string}){const [entries,setEntries]=useState(initial);const [cursor,setCursor]=useState(initialCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');async function more(){setBusy(true);try{const res=await fetch('/api/explore-feed?'+new URLSearchParams({q,category,cursor:cursor||''}));if(!res.ok)throw Error();const data=await res.json();setEntries(old=>[...old,...data.entries.filter((e:ExploreEntry)=>!old.some(o=>o.id===e.id))]);setCursor(data.nextCursor);setError('');}catch{setError('加载失败,点击重试')}finally{setBusy(false)}}return <>
{entries.map((entry,i)=>
{entry.media[0]?{entry.media[0].title}:<>{entry.namespace.toUpperCase()}{entry.title}记录 · 连接 · 灵感}

{entry.title}

P{entry.categories[0]||entry.namespace}♡
)}
{!entries.length&&

这个分类还没有内容。

}{cursor&&
}} +export function ExploreFeed({initial,cursor:initialCursor=null,q='',category=''}:{initial:ExploreEntry[];cursor?:string|null;q?:string;category?:string}){ + const [entries,setEntries]=useState(initial);const [cursor,setCursor]=useState(initialCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState(''); + async function more(){setBusy(true);try{const res=await fetch('/api/explore-feed?'+new URLSearchParams({q,category,cursor:cursor||''}));if(!res.ok)throw Error();const data=await res.json();setEntries(old=>[...old,...data.entries.filter((e:ExploreEntry)=>!old.some(o=>o.id===e.id))]);setCursor(data.nextCursor);setError('');}catch{setError('加载失败,点击重试')}finally{setBusy(false)}} + return <>
{entries.map((entry,i)=>
+
+ {entry.media[0]?{entry.media[0].title}:<>{entry.namespace.toUpperCase()}{entry.title}记录 · 连接 · 灵感} + {entry.imageCount>1&&{entry.imageCount}} +

{entry.title}

{entry.description}

+ {entry.categories.length>0&&
{entry.categories.map(tag=>#{tag})}
} +
)}
{!entries.length&&

这个分类还没有内容。

}{cursor&&
}; +} diff --git a/apps/wiki/src/content/note-descriptions.ts b/apps/wiki/src/content/note-descriptions.ts new file mode 100644 index 0000000..3b1d4e5 --- /dev/null +++ b/apps/wiki/src/content/note-descriptions.ts @@ -0,0 +1,13 @@ +// Each key describes one independent note. Tags and links connect notes without ownership. +export const noteDescriptions:Record={ + 'street-people':'生成冬日街头人物的透明剪纸素材:画风、参考图片和交付要求。', + 'street-people/style':'松散线条、冬日衣着与奶油色纸边的画风说明及人物生成提示词。', + 'street-people/upload':'透明 PNG 图包的上传字段、接口示例、返回结果与文件限制。', + 'street-people/checks':'检查人物轮廓、真实透明背景、纸片内部和生成来源的交付清单。', + 'shadow-puppet':'把照片主体转译成暖灯宣纸上的皮影形象,制作上下对照海报。', + 'shadow-puppet/workflow':'照片转皮影海报的逐张制作步骤,包括原片整理、画板、绘制和验收。', + 'shadow-puppet/visual-grammar':'皮影海报的造型、配色、镂空、光影和上下等分构图规则。', + 'shadow-puppet/prompt':'照片转皮影海报的可替换提示词与原片、风格参考的使用方式。', + 'shadow-puppet/delivery':'皮影海报的尺寸、画面质量、来源记录和文件上传要求。', + 'image-delivery':'图像尺寸、格式、透明通道、生成来源与交付协议的通用检查说明。', +}; diff --git a/apps/wiki/src/lib/explore.ts b/apps/wiki/src/lib/explore.ts index 0780abf..6128bba 100644 --- a/apps/wiki/src/lib/explore.ts +++ b/apps/wiki/src/lib/explore.ts @@ -1,3 +1,4 @@ +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'; @@ -6,8 +7,8 @@ import {getPostById,feed} from './library'; import {snapshotState} from './snapshots'; import {chapters} from '@/content/learning-skills/data'; export type ExploreMedia={kind:'image'|'video'|'audio';url:string;title:string;poster?:string}; -export type ExploreEntry={id:string;title:string;summary:string;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string}; -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,summary:doc.blocks.find(b=>b.type==='paragraph')?.text||'',namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media: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})),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,summary:page.summary,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))]; +export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string}; +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):Promise{ @@ -17,7 +18,7 @@ export async function getEntry(id:string):Promise{ let media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text').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/'+a.id+'?variant=poster'}:{})})); let body=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n'); if(post.page_key){const chapter=chapters.find(c=>c.key===post.page_key);if(chapter)body+='\n\n'+chapter.subtitle+'\n\n'+chapter.skills.map(skill=>'## '+skill.title+'\n\n'+skill.note+'\n\n输入:'+skill.input+'\n输出:'+skill.output+'\n\n'+skill.prompt+'\n\n[来源]('+skill.source+')').join('\n\n');const state=await snapshotState(post.slug);if(state?.pages.length)media=state.pages.map(p=>({kind:'image' as const,url:'/api/media/'+p.asset_id,title:'图文第 '+p.page_index+' 页'}));} - return {id:post.id,title:post.title,summary:body.slice(0,180),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 {id:post.id,title:post.title,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=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.filter(e=>(!q||(e.title+e.summary).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,summary:post.assets.find(a=>a.kind==='text')?.body||'',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/'+video.id+'?variant=poster',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\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';} +export async function exploreFeed(q='',category='',cursor?:string){const data=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.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')?.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.snapshot_count||post.assets.filter(a=>a.kind==='image').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/'+video.id+'?variant=poster',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';} diff --git a/apps/wiki/src/lib/library.ts b/apps/wiki/src/lib/library.ts index 946ac36..abb030a 100644 --- a/apps/wiki/src/lib/library.ts +++ b/apps/wiki/src/lib/library.ts @@ -3,7 +3,7 @@ import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, ty import { works } from "../content/registry"; const tagTree = `WITH RECURSIVE tree AS (SELECT *,slug::text AS path FROM tags WHERE parent_id IS NULL UNION ALL SELECT t.*,tree.path||'/'||t.slug FROM tags t JOIN tree ON t.parent_id=tree.id) SELECT * FROM tree`; export async function tags(): Promise { return (await db().query(`${tagTree} ORDER BY path`)).rows; } -const expand = `SELECT p.*, +const expand = `SELECT p.*,(SELECT count(*)::integer FROM page_snapshots ps WHERE ps.post_id=p.id) AS snapshot_count, COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.role) FROM asset_variants v WHERE v.asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets, COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`; function serialize(posts: Post[], allTags: Tag[]): Post[] { diff --git a/apps/wiki/src/lib/model.ts b/apps/wiki/src/lib/model.ts index 6bd4662..abdccf4 100644 --- a/apps/wiki/src/lib/model.ts +++ b/apps/wiki/src/lib/model.ts @@ -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[] }; export type Tag = { id: string; parent_id: string | null; name: string; slug: string; path: string }; -export type Post = { id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; page_key: string | null; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] }; +export type Post = { description: string; snapshot_count: number; id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; page_key: string | null; 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}` : ""}`; diff --git a/apps/wiki/src/lib/publish-learning-series.ts b/apps/wiki/src/lib/publish-learning-series.ts index 0b3b227..18a0945 100644 --- a/apps/wiki/src/lib/publish-learning-series.ts +++ b/apps/wiki/src/lib/publish-learning-series.ts @@ -28,9 +28,9 @@ export async function publishLearningSeries() { for(const chapter of chapters){ const child=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,$3,$4) ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId(`tag-${chapter.key}`),root,chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id; - const result=await client.query(`INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id) VALUES($1,$2,'article',$3,$4,$5) - ON CONFLICT(slug) DO UPDATE SET title=excluded.title,cover_asset_id=COALESCE((SELECT asset_id FROM page_snapshots WHERE post_id=posts.id AND page_index=1),excluded.cover_asset_id),updated_at=now() - WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId]); + const result=await client.query(`INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id,description) VALUES($1,$2,'article',$3,$4,$5,$6) + ON CONFLICT(slug) DO UPDATE SET title=excluded.title,description=excluded.description,cover_asset_id=COALESCE((SELECT asset_id FROM page_snapshots WHERE post_id=posts.id AND page_index=1),excluded.cover_asset_id),updated_at=now() + WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId,chapter.subtitle]); if(!result.rows[0])throw new Error(`Publication slug is already used by another post: ${chapter.slug}`); const postId=result.rows[0].id;const captionId=stableId(`caption-${chapter.key}`); const caption=`学习资料自己做 · 第 ${chapter.number} 辑\n${chapter.subtitle}。三篇共用系列封面,每篇 8 页,介绍 10 项工具。\n\n${chapter.skills.map((skill,i)=>`${String(i+1).padStart(2,"0")} ${skill.name} — ${skill.title}`).join("\n")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。内页小样为排版示意;可在末页访问全部来源。`; diff --git a/apps/wiki/src/lib/write.ts b/apps/wiki/src/lib/write.ts index 93dcf16..95c0ac6 100644 --- a/apps/wiki/src/lib/write.ts +++ b/apps/wiki/src/lib/write.ts @@ -6,6 +6,7 @@ export const postInput=z.object({ 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([]), + description:z.string().trim().max(1000).default(""), text:z.string().trim().max(100000).optional(),tags:z.array(z.uuid()).max(50).default([]), coverAssetId:z.uuid().optional(),pageKey:z.string().max(120).optional(), }); @@ -18,7 +19,7 @@ export async function createPost(input:z.infer) { 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,page_key,cover_asset_id) VALUES($1,$2,$3,$4,$5,$6)',[id,slug,input.kind,input.title,input.pageKey||null,input.coverAssetId||null]); + await client.query('INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id,description) VALUES($1,$2,$3,$4,$5,$6,$7)',[id,slug,input.kind,input.title,input.pageKey||null,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title]); 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]); diff --git a/apps/wiki/src/pages/api/upload.ts b/apps/wiki/src/pages/api/upload.ts index 5f81b62..fe2715f 100644 --- a/apps/wiki/src/pages/api/upload.ts +++ b/apps/wiki/src/pages/api/upload.ts @@ -46,7 +46,7 @@ export async function upload(req:NextApiRequest,res:NextApiResponse,policy:Uploa } const postId=randomUUID(); await transaction(async client=>{ - await client.query('INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,$3,$4)',[postId,slug||postId,kind,title]); + 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]); diff --git a/apps/wiki/src/pages/categories.tsx b/apps/wiki/src/pages/categories.tsx index f19fadb..232186b 100644 --- a/apps/wiki/src/pages/categories.tsx +++ b/apps/wiki/src/pages/categories.tsx @@ -6,4 +6,4 @@ import {ExploreFeed} from '@/components/ExploreFeed'; import {staticEntries,exploreFeed,type ExploreEntry} from '@/lib/explore'; import {tags} from '@/lib/library'; export default function Categories({categories,category,entries,cursor,q}:{categories:string[];category:string;entries:ExploreEntry[];cursor:string|null;q:string}){return 分类 · PROMPT
全部{categories.map(c=>{c})}
} -export const getServerSideProps:GetServerSideProps=async({query})=>{const category=String(query.category||'');const q=String(query.q||'').slice(0,200);const categories=new Set(['Main','File','Template','Draft','Help',...staticEntries.flatMap(e=>e.categories)]);try{(await tags()).forEach(t=>categories.add(t.name));const data=await exploreFeed(q,category);return {props:{categories:[...categories],category,q,entries:data.entries,cursor:data.nextCursor}};}catch{return {props:{categories:[...categories],category,q,entries:staticEntries.filter(e=>(!category||e.categories.includes(category)||e.namespace===category)&&(!q||(e.title+e.summary).includes(q))),cursor:null}}}}; +export const getServerSideProps:GetServerSideProps=async({query})=>{const category=String(query.category||'');const q=String(query.q||'').slice(0,200);const categories=new Set(['Main','File','Template','Draft','Help',...staticEntries.flatMap(e=>e.categories)]);try{(await tags()).forEach(t=>categories.add(t.name));const data=await exploreFeed(q,category);return {props:{categories:[...categories],category,q,entries:data.entries,cursor:data.nextCursor}};}catch{return {props:{categories:[...categories],category,q,entries:staticEntries.filter(e=>(!category||e.categories.includes(category)||e.namespace===category)&&(!q||(e.title+e.description).includes(q))),cursor:null}}}}; diff --git a/apps/wiki/src/pages/index.tsx b/apps/wiki/src/pages/index.tsx index f51708f..e88853a 100644 --- a/apps/wiki/src/pages/index.tsx +++ b/apps/wiki/src/pages/index.tsx @@ -5,4 +5,4 @@ import {Shell} from '@/components/Shell'; import {ExploreFeed} from '@/components/ExploreFeed'; import {exploreFeed,staticEntries,type ExploreEntry} from '@/lib/explore'; export default function Home({entries,cursor,q,unavailable=false}:{entries:ExploreEntry[];cursor:string|null;q:string;unavailable?:boolean}){return PROMPT · 发现
发现分类
{unavailable&&

素材库暂时无法连接,先看看这些公开笔记。

}
} -export const getServerSideProps:GetServerSideProps=async({query})=>{const q=String(query.q||'').slice(0,200);try{const data=await exploreFeed(q);return {props:{entries:data.entries,cursor:data.nextCursor,q}}}catch{return {props:{entries:staticEntries.filter(e=>!q||(e.title+e.summary).includes(q)),cursor:null,q,unavailable:true}}}}; +export const getServerSideProps:GetServerSideProps=async({query})=>{const q=String(query.q||'').slice(0,200);try{const data=await exploreFeed(q);return {props:{entries:data.entries,cursor:data.nextCursor,q}}}catch{return {props:{entries:staticEntries.filter(e=>!q||(e.title+e.description).includes(q)),cursor:null,q,unavailable:true}}}}; diff --git a/apps/wiki/src/styles/globals.css b/apps/wiki/src/styles/globals.css index f63a815..02c3f99 100644 --- a/apps/wiki/src/styles/globals.css +++ b/apps/wiki/src/styles/globals.css @@ -15,3 +15,6 @@ /* Discovery and addressable note dialog */ .discovery-tabs{display:flex;gap:12px;overflow:auto;white-space:nowrap;padding:12px 0 24px}.discovery-tabs a{padding:10px 22px;border-radius:30px;color:#777}.discovery-tabs .selected{background:#f4f4f4;color:#222;font-weight:600}.discovery-grid{columns:4 220px;column-gap:22px;padding-bottom:30px}.discovery-card{display:inline-block;width:100%;break-inside:avoid;margin-bottom:25px;text-decoration:none;color:#282828}.discovery-cover{border-radius:17px;overflow:hidden;display:flex;flex-direction:column;justify-content:space-between;padding:28px;position:relative}.discovery-cover:has(img){padding:0}.discovery-cover img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}.discovery-cover strong{font-size:32px;line-height:1.45}.discovery-cover small,.discovery-cover>span{font-size:11px;letter-spacing:2px;opacity:.6}.discovery-card h2{font-size:16px;margin:11px 2px;line-height:1.5}.discovery-card footer{display:flex;gap:7px;align-items:center;color:#888;font-size:12px}.discovery-card footer>span:last-child{margin-left:auto;font-size:20px}.note-overlay{position:fixed;z-index:1000;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;padding:38px 7vw;backdrop-filter:blur(2px)}.note-dialog{width:min(1180px,100%);height:min(860px,88vh);display:grid;grid-template-columns:minmax(0,1.25fr) minmax(350px,1fr);background:white;border-radius:24px;position:relative;box-shadow:0 20px 90px #0002}.note-close{position:absolute;left:-56px;top:0;width:40px;height:40px;display:grid;place-items:center;border:0;border-radius:50%;background:#fff;color:#444;cursor:pointer}.note-stage{position:relative;min-width:0;min-height:0;background:#f8f8f8;display:flex;align-items:center;justify-content:center;border-radius:24px 0 0 24px;overflow:hidden}.note-stage>img,.note-stage>video{width:100%;height:100%;object-fit:contain}.note-stage:has(video){background:#000}.note-title-cover{width:100%;height:100%;padding:55px;display:flex;flex-direction:column;justify-content:space-between}.note-title-cover strong{font-size:clamp(32px,4vw,62px);line-height:1.5;letter-spacing:-2px}.note-title-cover small,.note-title-cover>span{font-size:13px;letter-spacing:3px;opacity:.6}.note-prev,.note-next{position:absolute;top:48%;width:35px;height:35px;background:#fff9;border:0;border-radius:50%;display:grid;place-items:center;cursor:pointer}.note-prev{left:15px}.note-next{right:15px}.note-prev:disabled,.note-next:disabled{opacity:.2}.note-count{position:absolute;right:15px;top:15px;border-radius:20px;background:#0005;color:white;padding:4px 10px;font-size:12px}.note-dots{position:absolute;bottom:24px;display:flex;gap:8px;left:0;right:0;justify-content:center}.note-dots button{width:7px;height:7px;padding:0;border:0;border-radius:50%;background:#9997;cursor:pointer}.note-dots .selected{background:#ff2442}.note-info{display:flex;flex-direction:column;min-height:0;min-width:0}.note-info>header{display:flex;align-items:center;gap:12px;padding:26px 28px;flex-shrink:0}.note-author-avatar{width:40px;height:40px;display:grid;place-items:center;background:#ff2442;color:#fff;border-radius:50%;font-weight:700}.note-info header b{font-size:15px}.note-info header small{display:block;color:#999;font-size:11px;margin-top:4px}.note-category-button{margin-left:auto;border:1px solid #ff2442;color:#ff2442;padding:8px 17px;border-radius:24px;font-size:13px}.note-content{overflow:auto;flex:1;padding:8px 28px 28px;overscroll-behavior:contain}.note-content h1{font-size:22px;line-height:1.6;margin:0 0 14px}.note-content h3{font-size:16px;margin:24px 0 10px}.note-content p{font-size:15px;line-height:1.95;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 20px}.note-content pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.7;background:#faf9f6;padding:14px;border-radius:12px}.note-content a,.note-tags{color:#315775}.note-id{font-size:11px;color:#aaa;margin-top:25px;overflow-wrap:anywhere}.note-info>footer{border-top:1px solid #eee;display:flex;gap:18px;align-items:center;padding:19px 25px;flex-wrap:wrap}.note-info footer button,.note-info footer a{font:inherit;background:none;border:0;display:flex;gap:5px;align-items:center;font-size:12px;color:#777;cursor:pointer}.note-audio{width:80%;text-align:center}.note-audio>span{font-size:100px;display:block;margin-bottom:40px}.note-audio audio{width:100%}.discovery-empty{padding:50px;color:#999;text-align:center} @media(max-width:760px){.discovery-grid{columns:2;column-gap:12px}.discovery-cover{padding:20px}.discovery-cover strong{font-size:24px}.discovery-card h2{font-size:14px}.note-overlay{padding:0;background:#fff;align-items:flex-start;overflow:auto}.note-dialog{display:flex;flex-direction:column;width:100%;height:auto;min-height:100dvh;border-radius:0;box-shadow:none}.note-close{z-index:3;left:14px;top:14px;background:#fffc}.note-stage{height:56dvh;min-height:320px;flex-shrink:0;border-radius:0}.note-info{min-height:44dvh}.note-info>header{padding:20px}.note-content{overflow:visible;padding:0 20px 25px}.note-content h1{font-size:20px}.note-info>footer{position:sticky;bottom:0;background:white}.note-title-cover{padding:45px}.note-title-cover strong{font-size:34px}} + +/* Independent notes: description, topic tags and image count, with no author chrome. */ +.discovery-note-link{display:block;color:inherit;text-decoration:none}.discovery-description{font-size:13px;line-height:1.8;color:#777;margin:0 2px 9px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}.discovery-tags{display:flex;gap:5px 12px;flex-wrap:wrap;margin:0 2px;font-size:12px;line-height:1.7;color:#466478}.discovery-tags a:hover{text-decoration:underline}.discovery-cover>.discovery-image-count{position:absolute;z-index:1;right:12px;top:12px;display:flex;align-items:center;gap:5px;padding:4px 8px;border-radius:14px;background:#0007;color:#fff;opacity:1;letter-spacing:0;font-size:12px;line-height:1.4;backdrop-filter:blur(4px)}.note-info>.note-content{padding-top:32px}.note-content>.note-description{color:#7b7b7b;font-size:14px;line-height:1.8;margin:0 0 12px}.note-content>.note-tags{display:flex;gap:6px 12px;flex-wrap:wrap;margin-bottom:25px;font-size:13px}.note-content>.note-tags a{color:#466478}@media(max-width:760px){.discovery-description{font-size:12px;-webkit-line-clamp:3}.discovery-tags{font-size:11px;gap:3px 8px}.discovery-cover>.discovery-image-count{right:9px;top:9px;font-size:11px}.note-info>.note-content{padding-top:24px}} diff --git a/apps/wiki/tests/explore.test.ts b/apps/wiki/tests/explore.test.ts index f2c2b6a..5eca14a 100644 --- a/apps/wiki/tests/explore.test.ts +++ b/apps/wiki/tests/explore.test.ts @@ -7,3 +7,11 @@ import {getServerSideProps} from '../src/pages/explore/[id]'; test('every public document has a distinct persisted ID and canonical entry',async()=>{assert.equal(new Set(staticEntries.map(e=>e.id)).size,staticEntries.length);for(const e of staticEntries){assert.match(e.id,/^[a-f0-9]{24}$/);assert.equal((await getEntry(e.id))?.title,e.title);}for(const doc of guidanceDocuments)assert.ok(pageId('guidance:'+doc.path));assert.equal(await getEntry('unknown'),null);}); test('agent_mode on the canonical URL emits text with media links and no HTML',async()=>{const entry=staticEntries.find(e=>e.media.length)!;let body='';const headers:Record={};const response={setHeader:(k:string,v:string)=>headers[k]=v,end:(value:string)=>{body=value}};await getServerSideProps({params:{id:entry.id},query:{agent_mode:'True'},res:response,req:{method:'GET'}} as never);assert.match(headers['Content-Type'],/text\/plain/);assert.ok(body.includes(entry.title));assert.ok(body.includes(entry.media[0].url));assert.ok(!body.includes('{const url=new URL(previewUrl('/api/media/id?variant=poster',480),'http://test');assert.equal(url.pathname,'/api/preview');assert.equal(url.searchParams.get('source'),'/api/media/id?variant=poster');assert.equal(url.searchParams.get('width'),'480');}); + +test('independent notes expose their own description, tags and complete image counts',async()=>{ + for(const entry of staticEntries){assert.ok(entry.description.trim());assert.equal(entry.imageCount,entry.media.filter(m=>m.kind==='image').length);assert.equal(new Set(entry.media.map(m=>m.url)).size,entry.media.length);} + const note=(await getEntry(pageId('guidance:shadow-puppet')))!; + assert.equal(note.imageCount,2);assert.ok(note.categories.includes('照片入皮影'));assert.ok(entryText(note).includes('Description: '+note.description)); + const workflow=(await getEntry(pageId('guidance:shadow-puppet/workflow')))!; + assert.notEqual(workflow.description,note.description);assert.notEqual(workflow.id,note.id); +}); diff --git a/apps/wiki/tests/integration.test.ts b/apps/wiki/tests/integration.test.ts index 77b03fe..3608fba 100644 --- a/apps/wiki/tests/integration.test.ts +++ b/apps/wiki/tests/integration.test.ts @@ -29,9 +29,9 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer(); const upload=async(kind:string,files:{data:Uint8Array;name:string;mime:string}[])=>{const form=new FormData();form.set('kind',kind);form.set('title','测试收藏 '+kind);form.set('description','可以复用的独立文字');form.set('tags',JSON.stringify([child.id]));files.forEach(f=>form.append('files',new Blob([new Uint8Array(f.data)],{type:f.mime}),f.name));const res=await fetch(base+'/api/upload',{method:'POST',body:form});assert.equal(res.status,201,await res.clone().text());return res.json();}; const first=await upload('image',[{data:a,name:'a.png',mime:'image/png'},{data:a,name:'b.png',mime:'image/png'}]); - const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',assets:[{id:first.assets[0]}],tags:[root.id]}); + const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',description:'同一素材的独立说明',assets:[{id:first.assets[0]}],tags:[root.id]}); assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[first.assets[0]])).rows[0].n,2); - const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,2);assert.equal((await request('/api/feed?section=images&path=lines/white')).posts.length,1); + const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,2);assert.equal(filtered.posts.find((p:{id:string})=>p.id===reused.id).description,'同一素材的独立说明');assert.equal((await request('/api/feed?section=images&path=lines/white')).posts.length,1); const empty=randomUUID();await db().query("INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,'image','空对象')",[empty,empty]);assert.equal((await request('/api/feed')).posts.length,2); assert.equal((await request('/api/feed')).contributions.length,0,'Import does not invent contribution rules'); await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5); @@ -66,6 +66,7 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video assert.equal(status.pages.length,8,'Screenshot job state: '+JSON.stringify(status.job)); const r:Response=await fetch(base+'/api/media/'+status.pages[2].asset_id);const meta:{width?:number;height?:number}=await sharp(Buffer.from(await r.arrayBuffer())).metadata();assert.equal(meta.width,1080);assert.equal(meta.height,1440); } + const notes=await request('/api/explore-feed');for(const slug of published.published){const post=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===slug);assert.equal(notes.entries.find((e:{id:string})=>e.id===post.id).imageCount,8);} const before=await request(`/api/posts/${published.published[0]}/snapshots`); const refresh=await fetch(base+`/api/posts/${published.published[0]}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({page:3})});assert.equal(refresh.status,202); let after;for(let i=0;i<80;i++){after=await request(`/api/posts/${published.published[0]}/snapshots`);if(after.job?.status==='ready'&&after.pages[2].rendered_at!==before.pages[2].rendered_at)break;await setTimeout(500)} diff --git a/docs/note-preview-stage.md b/docs/note-preview-stage.md new file mode 100644 index 0000000..c1249ca --- /dev/null +++ b/docs/note-preview-stage.md @@ -0,0 +1,21 @@ +# 笔记预览更新 + +所有笔记保持平级,通过标签和链接关联。没有入口优先、父子或附属文档关系,现有 ID 不变。 + +## 逐文件说明 + +- `apps/wiki/src/components/ExploreFeed.tsx`:卡片展示封面、标题、description 和可点击标签;去掉头像与爱心;多图在封面右上角显示真实图数。 +- `apps/wiki/src/components/ExploreDetail.tsx`:移除 PROMPT 作者栏,在标题下展示 description、标签;切换独立笔记时重置图序和复制状态。 +- `apps/wiki/src/styles/globals.css`:增加描述截断、标签和图数角标样式,调整去作者栏后的详情间距。 +- `apps/wiki/src/content/note-descriptions.ts`:为每篇现有指导笔记编写独立的简短 description。 +- `apps/wiki/src/lib/explore.ts`:统一 description 和 imageCount;保留所有去重后的图片;图文按真实截图数量统计,纯文本返回 description。 +- `apps/wiki/src/lib/library.ts`:读取笔记实际截图数量,避免把共用封面误计为图文页数。 +- `apps/wiki/src/lib/model.ts`:补充 description 和截图计数字段类型。 +- `apps/wiki/db/003_note_descriptions.sql`:独立存储 description,为现有记录从说明文字回填摘要,原正文保留。 +- `apps/wiki/src/lib/write.ts`:创建笔记 API 支持保存独立 description,标签机制保持可用。 +- `apps/wiki/src/pages/api/upload.ts`:上传的说明同步保存为笔记 description,完整文字保留。 +- `apps/wiki/src/lib/publish-learning-series.ts`:发布图文时使用各自副标题作为 description。 +- `apps/wiki/src/pages/index.tsx`、`apps/wiki/src/pages/categories.tsx`:搜索与降级展示使用独立 description。 +- `apps/wiki/tests/explore.test.ts`:检查每篇笔记的 description、独立地址、去重图片与准确计数。 +- `apps/wiki/tests/integration.test.ts`:检查 description 的真实存储,以及八页图文在列表中的实际计数。 +- `docs/note-preview-stage.md`:记录本次行为与逐文件改动。