diff --git a/.gitignore b/.gitignore index bd1fb74..6114edf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ benchmarks/data/manifest.json claude-code-sourcemap-main router_session_parquet +# Locally managed annotation pages served by /annotations +annotation-pages/ + 标签定义 diff --git a/frontend/app/app/annotations/[name]/route.ts b/frontend/app/app/annotations/[name]/route.ts new file mode 100644 index 0000000..ca53bc8 --- /dev/null +++ b/frontend/app/app/annotations/[name]/route.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { resolveAnnotationPage } from "@/lib/annotation-pages"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ name: string }> }, +) { + const { name } = await params; + const filePath = resolveAnnotationPage(name); + if (!filePath) + return new Response("Invalid annotation page", { status: 400 }); + + try { + const content = await readFile(filePath); + return new Response(content, { + headers: { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + "x-content-type-options": "nosniff", + }, + }); + } catch { + return new Response("Annotation page not found", { status: 404 }); + } +} diff --git a/frontend/app/app/annotations/page.tsx b/frontend/app/app/annotations/page.tsx new file mode 100644 index 0000000..44ac5c4 --- /dev/null +++ b/frontend/app/app/annotations/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { + ArrowLeftIcon, + ExternalLinkIcon, + FileTextIcon, + RefreshCwIcon, + SearchIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +type AnnotationPageInfo = { + name: string; + title: string; + size: number; + modifiedAt: string; + href: string; +}; + +export default function AnnotationsPage() { + const [items, setItems] = useState([]); + const [query, setQuery] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const refresh = useCallback(async () => { + setIsLoading(true); + setError(""); + try { + const response = await fetch("/api/annotations", { cache: "no-store" }); + const payload = (await response.json()) as { + items?: AnnotationPageInfo[]; + error?: string; + }; + if (!response.ok) throw new Error(payload.error ?? "加载失败"); + setItems(payload.items ?? []); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "加载失败"); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const filteredItems = useMemo(() => { + const keyword = query.trim().toLocaleLowerCase("zh-CN"); + if (!keyword) return items; + return items.filter((item) => + `${item.title}\n${item.name}` + .toLocaleLowerCase("zh-CN") + .includes(keyword), + ); + }, [items, query]); + + return ( +
+
+
+ +
+

标注页面

+

+ {isLoading ? "正在读取" : `共 ${items.length} 个页面`} +

+
+ +
+
+ +
+
+ + setQuery(event.target.value)} + placeholder="搜索页面" + className="bg-white pl-9" + /> +
+ +
+
+ 页面 + 更新时间 + 大小 + +
+ + {error ? ( +
+ {error} +
+ ) : !isLoading && filteredItems.length === 0 ? ( +
+ 没有匹配的标注页面 +
+ ) : ( + + )} +
+
+
+ ); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); +} + +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/frontend/app/app/api/annotations/route.ts b/frontend/app/app/api/annotations/route.ts new file mode 100644 index 0000000..7478c62 --- /dev/null +++ b/frontend/app/app/api/annotations/route.ts @@ -0,0 +1,21 @@ +import { listAnnotationPages } from "@/lib/annotation-pages"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const items = await listAnnotationPages(); + return Response.json( + { items }, + { headers: { "cache-control": "no-store" } }, + ); + } catch (error) { + return Response.json( + { + error: error instanceof Error ? error.message : "无法读取标注页面目录", + }, + { status: 500, headers: { "cache-control": "no-store" } }, + ); + } +} diff --git a/frontend/app/lib/annotation-pages.ts b/frontend/app/lib/annotation-pages.ts new file mode 100644 index 0000000..af4c679 --- /dev/null +++ b/frontend/app/lib/annotation-pages.ts @@ -0,0 +1,88 @@ +import { mkdir, open, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +export type AnnotationPageInfo = { + name: string; + title: string; + size: number; + modifiedAt: string; + href: string; +}; + +const annotationPagesRoot = path.resolve( + process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages", +); + +export async function listAnnotationPages(): Promise { + await mkdir(annotationPagesRoot, { recursive: true }); + const entries = await readdir(annotationPagesRoot, { withFileTypes: true }); + const pages = await Promise.all( + entries + .filter((entry) => entry.isFile() && isSafeHtmlName(entry.name)) + .map(async (entry) => { + const filePath = path.join(annotationPagesRoot, entry.name); + const [fileStat, title] = await Promise.all([ + stat(filePath), + readHtmlTitle(filePath, entry.name), + ]); + return { + name: entry.name, + title, + size: fileStat.size, + modifiedAt: fileStat.mtime.toISOString(), + href: `/annotations/${encodeURIComponent(entry.name)}`, + }; + }), + ); + + return pages.sort( + (left, right) => + Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) || + left.name.localeCompare(right.name, "zh-CN"), + ); +} + +export function resolveAnnotationPage(name: string): string | null { + if (!isSafeHtmlName(name)) return null; + const resolved = path.resolve(annotationPagesRoot, name); + if (path.dirname(resolved) !== annotationPagesRoot) return null; + return resolved; +} + +function isSafeHtmlName(name: string): boolean { + return ( + name === path.basename(name) && + !name.startsWith(".") && + /\.html?$/i.test(name) + ); +} + +async function readHtmlTitle(filePath: string, fileName: string) { + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(16 * 1024); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const head = buffer.subarray(0, bytesRead).toString("utf8"); + const match = head.match(/]*>([\s\S]*?)<\/title>/i); + const title = match?.[1] + ?.replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + return title ? decodeHtmlEntities(title) : stripHtmlExtension(fileName); + } finally { + await handle.close(); + } +} + +function stripHtmlExtension(fileName: string) { + return fileName.replace(/\.html?$/i, ""); +} + +function decodeHtmlEntities(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"); +}