diff --git a/frontend/app/app/annotations/[name]/route.ts b/frontend/app/app/annotations/[name]/route.ts index ca53bc8..a7286bd 100644 --- a/frontend/app/app/annotations/[name]/route.ts +++ b/frontend/app/app/annotations/[name]/route.ts @@ -14,8 +14,8 @@ export async function GET( return new Response("Invalid annotation page", { status: 400 }); try { - const content = await readFile(filePath); - return new Response(content, { + const content = await readFile(filePath, "utf8"); + return new Response(injectSubmissionBridge(content, name), { headers: { "cache-control": "no-store", "content-type": "text/html; charset=utf-8", @@ -26,3 +26,44 @@ export async function GET( return new Response("Annotation page not found", { status: 404 }); } } + +function injectSubmissionBridge(content: string, pageName: string) { + if (!content.includes("") || !content.includes("mergedRows")) { + return content; + } + const safePageName = JSON.stringify(pageName).replaceAll("<", "\\u003c"); + const bridge = ` + + +`; + return content.replace(/<\/body>/i, `${bridge}`); +} diff --git a/frontend/app/app/api/annotations/[name]/submissions/route.ts b/frontend/app/app/api/annotations/[name]/submissions/route.ts new file mode 100644 index 0000000..4ffa984 --- /dev/null +++ b/frontend/app/app/api/annotations/[name]/submissions/route.ts @@ -0,0 +1,62 @@ +import { + createAnnotationSubmission, + readAnnotationSubmission, + readAnnotationSubmissions, +} from "@/lib/annotation-pages"; +import { getCurrentAccount } from "@/lib/claw-auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string }> }, +) { + try { + const { name } = await params; + const revisionId = new URL(request.url).searchParams.get("revision"); + if (revisionId) { + const submission = await readAnnotationSubmission(name, revisionId); + if (!submission) return errorResponse("提交记录不存在", 404); + return jsonResponse({ submission }); + } + return jsonResponse(await readAnnotationSubmissions(name)); + } catch (error) { + return errorResponse(errorMessage(error), 404); + } +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ name: string }> }, +) { + const account = await getCurrentAccount(); + if (!account) return errorResponse("请先登录工作台再提交", 401); + try { + const { name } = await params; + const body = (await request.json()) as { annotations?: unknown }; + const submission = await createAnnotationSubmission( + name, + account, + body.annotations, + ); + return jsonResponse({ submission }, 201); + } catch (error) { + return errorResponse(errorMessage(error), 400); + } +} + +function jsonResponse(value: unknown, status = 200) { + return Response.json(value, { + status, + headers: { "cache-control": "no-store" }, + }); +} + +function errorResponse(error: string, status: number) { + return jsonResponse({ error }, status); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "操作失败"; +} diff --git a/frontend/app/lib/annotation-pages.ts b/frontend/app/lib/annotation-pages.ts index af4c679..537cba0 100644 --- a/frontend/app/lib/annotation-pages.ts +++ b/frontend/app/lib/annotation-pages.ts @@ -1,4 +1,13 @@ -import { mkdir, open, readdir, stat } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { + mkdir, + open, + readdir, + readFile, + rename, + stat, + writeFile, +} from "node:fs/promises"; import path from "node:path"; export type AnnotationPageInfo = { @@ -9,9 +18,43 @@ export type AnnotationPageInfo = { href: string; }; +export type AnnotationValue = { + requestId: string; + result: "G" | "S" | "B" | ""; + note: string; +}; + +export type AnnotationSubmission = { + id: string; + version: number; + pageName: string; + submittedAt: string; + submittedBy: { id: string; username: string }; + summary: { + total: number; + completed: number; + G: number; + S: number; + B: number; + }; + annotations: AnnotationValue[]; +}; + +export type AnnotationSubmissionSummary = Omit< + AnnotationSubmission, + "annotations" +>; + const annotationPagesRoot = path.resolve( + /* turbopackIgnore: true */ process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages", ); +const annotationSubmissionsRoot = path.resolve( + /* turbopackIgnore: true */ + process.env.ANNOTATION_SUBMISSIONS_DIR?.trim() || + path.join(annotationPagesRoot, ".submissions"), +); +const submissionWriteQueues = new Map>(); export async function listAnnotationPages(): Promise { await mkdir(annotationPagesRoot, { recursive: true }); @@ -49,6 +92,85 @@ export function resolveAnnotationPage(name: string): string | null { return resolved; } +export async function readAnnotationSubmissions(pageName: string) { + const pagePath = resolveAnnotationPage(pageName); + if (!pagePath) throw new Error("无效的标注页面"); + await stat(pagePath); + + const pageRoot = submissionPageRoot(pageName); + const latest = await readSubmissionFile(path.join(pageRoot, "latest.json")); + const revisionsRoot = path.join(pageRoot, "revisions"); + let names: string[] = []; + try { + names = await readdir(revisionsRoot); + } catch { + return { latest, history: [] as AnnotationSubmissionSummary[] }; + } + + const history = ( + await Promise.all( + names + .filter((name) => name.endsWith(".json")) + .map((name) => readSubmissionFile(path.join(revisionsRoot, name))), + ) + ) + .filter((item): item is AnnotationSubmission => item !== null) + .sort((left, right) => right.version - left.version) + .slice(0, 100) + .map(withoutAnnotations); + + return { latest, history }; +} + +export async function readAnnotationSubmission( + pageName: string, + submissionId: string, +) { + if (!/^[a-zA-Z0-9_-]{8,120}$/.test(submissionId)) return null; + const pagePath = resolveAnnotationPage(pageName); + if (!pagePath) throw new Error("无效的标注页面"); + await stat(pagePath); + return readSubmissionFile( + path.join( + submissionPageRoot(pageName), + "revisions", + `${submissionId}.json`, + ), + ); +} + +export async function createAnnotationSubmission( + pageName: string, + account: { id: string; username: string }, + input: unknown, +) { + const pagePath = resolveAnnotationPage(pageName); + if (!pagePath) throw new Error("无效的标注页面"); + await stat(pagePath); + const annotations = normalizeAnnotations(input); + + return enqueueSubmissionWrite(pageName, async () => { + const pageRoot = submissionPageRoot(pageName); + const revisionsRoot = path.join(pageRoot, "revisions"); + await mkdir(revisionsRoot, { recursive: true }); + const latest = await readSubmissionFile(path.join(pageRoot, "latest.json")); + const submittedAt = new Date().toISOString(); + const id = `${submittedAt.replace(/\D/g, "").slice(0, 17)}-${randomUUID().slice(0, 8)}`; + const submission: AnnotationSubmission = { + id, + version: (latest?.version ?? 0) + 1, + pageName, + submittedAt, + submittedBy: account, + summary: summarizeAnnotations(annotations), + annotations, + }; + await atomicWriteJson(path.join(revisionsRoot, `${id}.json`), submission); + await atomicWriteJson(path.join(pageRoot, "latest.json"), submission); + return submission; + }); +} + function isSafeHtmlName(name: string): boolean { return ( name === path.basename(name) && @@ -57,6 +179,93 @@ function isSafeHtmlName(name: string): boolean { ); } +function submissionPageRoot(pageName: string) { + const digest = createHash("sha256") + .update(pageName) + .digest("hex") + .slice(0, 24); + return path.join(annotationSubmissionsRoot, digest); +} + +function normalizeAnnotations(input: unknown): AnnotationValue[] { + if (!Array.isArray(input)) throw new Error("annotations 必须是数组"); + if (input.length > 20_000) throw new Error("单次提交最多包含 20000 条标注"); + const seen = new Set(); + return input.map((raw, index) => { + if (!raw || typeof raw !== "object") { + throw new Error(`第 ${index + 1} 条标注格式错误`); + } + const value = raw as Record; + const requestId = String(value.requestId ?? value.request_id ?? "").trim(); + const result = String(value.result ?? value.GSB ?? "").trim(); + const note = String(value.note ?? value.人工备注 ?? "").trim(); + if (!requestId || requestId.length > 200) { + throw new Error(`第 ${index + 1} 条缺少有效 request_id`); + } + if (seen.has(requestId)) throw new Error(`request_id 重复:${requestId}`); + if (!["", "G", "S", "B"].includes(result)) { + throw new Error(`第 ${index + 1} 条 GSB 只能是 G、S、B 或空`); + } + if (note.length > 5000) throw new Error(`第 ${index + 1} 条备注过长`); + seen.add(requestId); + return { + requestId, + result: result as AnnotationValue["result"], + note, + }; + }); +} + +function summarizeAnnotations(annotations: AnnotationValue[]) { + const summary = { + total: annotations.length, + completed: 0, + G: 0, + S: 0, + B: 0, + }; + for (const annotation of annotations) { + if (!annotation.result) continue; + summary.completed += 1; + summary[annotation.result] += 1; + } + return summary; +} + +async function readSubmissionFile(filePath: string) { + try { + return JSON.parse(await readFile(filePath, "utf8")) as AnnotationSubmission; + } catch { + return null; + } +} + +function withoutAnnotations( + submission: AnnotationSubmission, +): AnnotationSubmissionSummary { + const { annotations: _annotations, ...summary } = submission; + return summary; +} + +async function atomicWriteJson(filePath: string, value: unknown) { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporaryPath, filePath); +} + +function enqueueSubmissionWrite(pageName: string, task: () => Promise) { + const previous = submissionWriteQueues.get(pageName) ?? Promise.resolve(); + const current = previous.then(task, task); + submissionWriteQueues.set(pageName, current); + const cleanup = () => { + if (submissionWriteQueues.get(pageName) === current) { + submissionWriteQueues.delete(pageName); + } + }; + void current.then(cleanup, cleanup); + return current; +} + async function readHtmlTitle(filePath: string, fileName: string) { const handle = await open(filePath, "r"); try { diff --git a/frontend/app/public/annotation-submissions.js b/frontend/app/public/annotation-submissions.js new file mode 100644 index 0000000..24054d3 --- /dev/null +++ b/frontend/app/public/annotation-submissions.js @@ -0,0 +1,234 @@ +(() => { + const bridge = window.__annotationSubmissionBridge; + if (!bridge?.pageName || typeof bridge.read !== "function") return; + + const endpoint = `/api/annotations/${encodeURIComponent(bridge.pageName)}/submissions`; + const toolbar = document.querySelector(".toolbar"); + if (!toolbar) return; + + const style = document.createElement("style"); + style.textContent = ` + .annotation-submit-status { color: #687385; font-size: 12px; white-space: nowrap; } + .annotation-history-dialog { width: min(680px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 32px)); padding: 0; border: 1px solid #d9dee7; border-radius: 8px; box-shadow: 0 24px 80px rgba(15, 23, 42, .2); } + .annotation-history-dialog::backdrop { background: rgba(15, 23, 42, .32); } + .annotation-history-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #e5e9ef; background: #fff; } + .annotation-history-head h2 { margin: 0; font-size: 17px; } + .annotation-history-close { width: 32px; height: 32px; border: 0; border-radius: 5px; background: #f1f4f8; font-size: 20px; color: #556274; } + .annotation-history-list { margin: 0; padding: 0; list-style: none; overflow: auto; } + .annotation-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #edf0f4; } + .annotation-history-item:last-child { border-bottom: 0; } + .annotation-history-title { font-weight: 650; font-size: 14px; } + .annotation-history-meta { margin-top: 5px; color: #687385; font-size: 12px; } + .annotation-history-summary { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 10px; color: #4b5666; font-size: 12px; } + .annotation-history-actions { display: flex; gap: 7px; } + .annotation-history-empty { padding: 40px 18px; text-align: center; color: #687385; } + @media (max-width: 820px) { .annotation-submit-status { display: none; } } + `; + document.head.appendChild(style); + + const historyButton = button("提交记录", "secondary"); + const submitButton = button("提交", "primary"); + const status = document.createElement("span"); + status.className = "annotation-submit-status"; + toolbar.append(status, historyButton, submitButton); + + const dialog = document.createElement("dialog"); + dialog.className = "annotation-history-dialog"; + dialog.innerHTML = ` +
+

提交记录

+ +
+
    + `; + document.body.appendChild(dialog); + dialog.querySelector(".annotation-history-close").onclick = () => + dialog.close(); + dialog.addEventListener("click", (event) => { + if (event.target === dialog) dialog.close(); + }); + + let history = []; + historyButton.onclick = async () => { + dialog.showModal(); + await refreshHistory(); + }; + submitButton.onclick = async () => { + submitButton.disabled = true; + submitButton.textContent = "提交中"; + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annotations: bridge.read() }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "提交失败"); + history = [ + summaryOf(payload.submission), + ...history.filter((item) => item.id !== payload.submission.id), + ]; + setStatus(payload.submission); + setRevisionUrl(payload.submission.id); + bridge.notify?.(`已提交第 ${payload.submission.version} 版`); + } catch (error) { + bridge.notify?.(error instanceof Error ? error.message : "提交失败"); + } finally { + submitButton.disabled = false; + submitButton.textContent = "提交"; + } + }; + + async function refreshHistory() { + const list = dialog.querySelector(".annotation-history-list"); + list.innerHTML = '
  1. 正在读取
  2. '; + try { + const payload = await fetchJson(endpoint); + history = payload.history || []; + renderHistory(); + } catch (error) { + list.innerHTML = `
  3. ${escapeHtml(error instanceof Error ? error.message : "读取失败")}
  4. `; + } + } + + function renderHistory() { + const list = dialog.querySelector(".annotation-history-list"); + if (!history.length) { + list.innerHTML = + '
  5. 还没有提交记录
  6. '; + return; + } + list.innerHTML = ""; + for (const item of history) { + const li = document.createElement("li"); + li.className = "annotation-history-item"; + li.innerHTML = ` +
    +
    第 ${item.version} 版 · ${escapeHtml(item.submittedBy?.username || "未知用户")}
    +
    ${formatTime(item.submittedAt)}
    +
    + 已标 ${item.summary?.completed || 0}/${item.summary?.total || 0} + G ${item.summary?.G || 0} + S ${item.summary?.S || 0} + B ${item.summary?.B || 0} +
    +
    +
    + + +
    + `; + li.querySelector('[data-action="copy"]').onclick = async () => { + const url = revisionUrl(item.id); + try { + await navigator.clipboard.writeText(url); + bridge.notify?.("版本链接已复制"); + } catch { + window.prompt("复制版本链接", url); + } + }; + li.querySelector('[data-action="view"]').onclick = async () => { + try { + const payload = await fetchJson( + `${endpoint}?revision=${encodeURIComponent(item.id)}`, + ); + bridge.apply(payload.submission.annotations || []); + dialog.close(); + setStatus(payload.submission, "正在查看"); + setRevisionUrl(payload.submission.id); + bridge.notify?.(`已打开第 ${payload.submission.version} 版`); + } catch (error) { + bridge.notify?.(error instanceof Error ? error.message : "读取失败"); + } + }; + list.appendChild(li); + } + } + + async function initialize() { + try { + const requestedRevision = new URLSearchParams(window.location.search).get( + "revision", + ); + if (requestedRevision) { + const payload = await fetchJson( + `${endpoint}?revision=${encodeURIComponent(requestedRevision)}`, + ); + bridge.apply(payload.submission.annotations || []); + setStatus(payload.submission, "正在查看"); + return; + } + const payload = await fetchJson(endpoint); + history = payload.history || []; + if (payload.latest) { + bridge.applyMissing(payload.latest.annotations || []); + setStatus(payload.latest); + } else { + status.textContent = "尚未提交"; + } + } catch { + status.textContent = "提交记录暂不可用"; + } + } + + function setStatus(submission, prefix = "最新") { + status.textContent = `${prefix}:第 ${submission.version} 版 · ${submission.submittedBy?.username || "未知用户"}`; + } + + function revisionUrl(revisionId) { + const url = new URL(window.location.href); + url.searchParams.set("revision", revisionId); + return url.toString(); + } + + function setRevisionUrl(revisionId) { + window.history.replaceState(null, "", revisionUrl(revisionId)); + } + + function button(label, variant) { + const element = document.createElement("button"); + element.type = "button"; + element.className = `btn ${variant}`; + element.textContent = label; + return element; + } + + async function fetchJson(url) { + const response = await fetch(url, { cache: "no-store" }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "请求失败"); + return payload; + } + + function summaryOf(submission) { + const { annotations: _annotations, ...summary } = submission; + return summary; + } + + function formatTime(value) { + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); + } + + function escapeHtml(value) { + return String(value ?? "").replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character], + ); + } + + void initialize(); +})();