"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Loader2 } from "lucide-react"; import Modal from "@/components/Modal"; import Avatar from "@/components/Avatar"; import { userPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; const PAGE_SIZE = 10; export interface EditHistoryItem { id: number; editor: { id: number; username: string; nickname: string; avatar: string; }; old_title?: string; // 帖子场景有;评论场景无 old_content: string; created_at: string; } export type FetchHistoryPage = ( page: number, size: number ) => Promise<{ items: EditHistoryItem[]; total: number; page: number; size: number; }>; /* ---------------- 行级 diff(LCS),git diff 风格渲染 ---------------- */ type DiffOp = "del" | "add" | "ctx"; interface DiffLine { op: DiffOp; text: string; } interface Seg { chg: boolean; text: string; } type Row = | { kind: "line"; op: DiffOp; text: string; pair: string | null } | { kind: "fold"; count: number }; // 超大文本退化为整段替换,避免 O(n·m) 内存膨胀 const MAX_DIFF_CELLS = 400_000; // 单行超过该长度不做行内变更高亮 const MAX_INLINE_DIFF_LEN = 2000; // 相同内容行超过该数量时折叠中段 const CONTEXT_FOLD_LIMIT = 8; function splitLines(text: string): string[] { if (!text) return []; return text.replace(/\r\n/g, "\n").split("\n"); } function diffLines(oldText: string, newText: string): DiffLine[] { const a = splitLines(oldText); const b = splitLines(newText); const n = a.length; const m = b.length; if (n * m > MAX_DIFF_CELLS) { return [ ...a.map((text) => ({ op: "del" as const, text })), ...b.map((text) => ({ op: "add" as const, text })), ]; } const width = m + 1; const dp = new Uint32Array((n + 1) * width); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i * width + j] = a[i] === b[j] ? dp[(i + 1) * width + (j + 1)] + 1 : Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]); } } const out: DiffLine[] = []; let i = 0; let j = 0; while (i < n && j < m) { if (a[i] === b[j]) { out.push({ op: "ctx", text: a[i] }); i += 1; j += 1; } else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) { out.push({ op: "del", text: a[i] }); i += 1; } else { out.push({ op: "add", text: b[j] }); j += 1; } } while (i < n) { out.push({ op: "del", text: a[i] }); i += 1; } while (j < m) { out.push({ op: "add", text: b[j] }); j += 1; } return normalizeBlocks(out); } // 每个连续变更块内统一「先删后增」,与 git diff 呈现一致 function normalizeBlocks(lines: DiffLine[]): DiffLine[] { const out: DiffLine[] = []; let k = 0; while (k < lines.length) { if (lines[k].op === "ctx") { out.push(lines[k]); k += 1; continue; } let end = k; while (end < lines.length && lines[end].op !== "ctx") end += 1; for (let t = k; t < end; t++) if (lines[t].op === "del") out.push(lines[t]); for (let t = k; t < end; t++) if (lines[t].op === "add") out.push(lines[t]); k = end; } return out; } // 行内变更高亮:取公共前/后缀,中间差异段标记 chg function segmentLine(line: string, other: string | null): Seg[] { if ( !other || line === other || line.length + other.length > MAX_INLINE_DIFF_LEN * 2 ) { return [{ chg: false, text: line }]; } const min = Math.min(line.length, other.length); let p = 0; while (p < min && line[p] === other[p]) p += 1; let s = 0; while ( s < min - p && line[line.length - 1 - s] === other[other.length - 1 - s] ) { s += 1; } const segs: Seg[] = []; if (p > 0) segs.push({ chg: false, text: line.slice(0, p) }); const mid = line.slice(p, line.length - s); if (mid) segs.push({ chg: true, text: mid }); if (s > 0) segs.push({ chg: false, text: line.slice(line.length - s) }); return segs.length > 0 ? segs : [{ chg: false, text: line }]; } // 折叠长段相同内容;同块内删除/新增按序号配对做行内高亮 function buildRows(lines: DiffLine[]): Row[] { const rows: Row[] = []; let k = 0; while (k < lines.length) { if (lines[k].op === "ctx") { let end = k; while (end < lines.length && lines[end].op === "ctx") end += 1; const run = end - k; if (run > CONTEXT_FOLD_LIMIT) { for (let t = k; t < k + 3; t++) { rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null }); } rows.push({ kind: "fold", count: run - 6 }); for (let t = end - 3; t < end; t++) { rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null }); } } else { for (let t = k; t < end; t++) { rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null }); } } k = end; } else { let end = k; while (end < lines.length && lines[end].op !== "ctx") end += 1; const dels = lines.slice(k, end).filter((l) => l.op === "del"); const adds = lines.slice(k, end).filter((l) => l.op === "add"); const pairFor = new Map(); const cnt = Math.min(dels.length, adds.length); for (let t = 0; t < cnt; t++) { pairFor.set(dels[t], adds[t].text); pairFor.set(adds[t], dels[t].text); } for (let t = k; t < end; t++) { rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: pairFor.get(lines[t]) ?? null, }); } k = end; } } return rows; } function DiffBody({ before, after }: { before: string; after: string }) { const rows = useMemo(() => buildRows(diffLines(before, after)), [before, after]); const delCount = rows.filter((r) => r.kind === "line" && r.op === "del").length; const addCount = rows.filter((r) => r.kind === "line" && r.op === "add").length; return (
+{addCount} −{delCount}
{rows.map((r, idx) => r.kind === "fold" ? (
⋯ 已省略 {r.count} 行相同内容 ⋯
) : (
{r.op === "del" ? "-" : r.op === "add" ? "+" : " "} {segmentLine(r.text, r.pair).map((s, si) => s.chg ? ( {s.text} ) : ( {s.text} ) )}
) )}
); } /* ---------------- 弹窗 ---------------- */ /** * 登录可见:历次编辑的 git diff 风格修订视图(倒序分页)。 * 后端只存每次编辑前的旧快照:第 i 条的「改后内容」= 第 i-1 条(更新的一条)的快照, * 最新一条的「改后内容」= 当前正文(由调用方传入)。帖子场景另传 currentTitle 渲染标题 diff。 */ export default function EditHistoryModal({ open, title, currentTitle, currentContent, fetchPage, onClose, }: { open: boolean; title: string; currentTitle?: string; currentContent: string; fetchPage: FetchHistoryPage; onClose: () => void; }) { const urlStyle = useUrlStyle(); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(""); const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const hasMore = items.length < total; const loadPage = useCallback( async (nextPage: number, append: boolean) => { if (!open) return; if (append) setLoadingMore(true); else { setLoading(true); setError(""); } try { const res = await fetchPage(nextPage, PAGE_SIZE); const batch = res.items || []; setError(""); setTotal(res.total ?? 0); setPage(res.page ?? nextPage); setItems((prev) => (append ? [...prev, ...batch] : batch)); } catch (e) { const msg = e instanceof Error ? e.message : "加载失败"; if (append) { // 追加失败不清空已有列表 setError(msg); } else { setError(msg); setItems([]); setTotal(0); } } finally { setLoading(false); setLoadingMore(false); } }, [open, fetchPage] ); useEffect(() => { if (!open) return; setItems([]); setPage(1); setTotal(0); setError(""); void loadPage(1, false); }, [open, loadPage]); const hasTitle = typeof currentTitle === "string"; return ( 0 ? `共 ${total} 条修订记录 · - 移除内容 / + 新增内容` : undefined} onClose={onClose} // 对齐详情页中间评论栏可用宽度(1440 三栏布局下约 800px) maxWidthClass="max-w-[min(840px,calc(100vw-2rem))]" panelClassName="p-5 sm:p-6 max-h-[85vh] flex flex-col min-h-0 overflow-hidden" bodyClassName="overflow-y-auto min-h-0 flex-1 -mx-1 px-1" footer={ } > {loading ? (
加载中…
) : error && items.length === 0 ? (

{error}

) : items.length === 0 ? (

暂无编辑记录

) : (
    {items.map((it, idx) => { const name = it.editor.nickname || it.editor.username || "用户"; // 本次编辑的改后内容:上一条(更新的)快照;最新一条为当前正文 const afterContent = idx === 0 ? currentContent : items[idx - 1].old_content; const afterTitle = hasTitle ? idx === 0 ? (currentTitle ?? "") : (items[idx - 1].old_title ?? "") : undefined; const titleChanged = hasTitle && it.old_title !== afterTitle; const contentChanged = it.old_content !== afterContent; return (
  • {name}

    {new Date(it.created_at).toLocaleString("zh-CN")} · 第{" "} {total - idx} 次编辑

    {titleChanged && (

    标题

    )} {contentChanged ? ( ) : ( !titleChanged && (

    本次仅变更了标签或其他元数据,未修改标题/正文。

    ) )}
  • ); })}
{error && items.length > 0 && (

{error}

)} {hasMore && (
)}
)}
); }