- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
"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";
|
||
|
||
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<DiffLine, string | null>();
|
||
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 (
|
||
<div className="cmt-diff">
|
||
<div className="cmt-diff-stat">
|
||
<span className="cmt-diff-stat-add">+{addCount}</span>
|
||
<span className="cmt-diff-stat-del">−{delCount}</span>
|
||
</div>
|
||
<div className="cmt-diff-body">
|
||
{rows.map((r, idx) =>
|
||
r.kind === "fold" ? (
|
||
<div key={idx} className="cmt-diff-fold">
|
||
⋯ 已省略 {r.count} 行相同内容 ⋯
|
||
</div>
|
||
) : (
|
||
<div key={idx} className={`cmt-diff-line is-${r.op}`}>
|
||
<span className="cmt-diff-sign" aria-hidden>
|
||
{r.op === "del" ? "-" : r.op === "add" ? "+" : " "}
|
||
</span>
|
||
<span className="cmt-diff-text">
|
||
{segmentLine(r.text, r.pair).map((s, si) =>
|
||
s.chg ? (
|
||
<mark key={si} className="cmt-diff-chg">
|
||
{s.text}
|
||
</mark>
|
||
) : (
|
||
<span key={si}>{s.text}</span>
|
||
)
|
||
)}
|
||
</span>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 弹窗 ---------------- */
|
||
|
||
/**
|
||
* 登录可见:历次编辑的 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 [loading, setLoading] = useState(false);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [items, setItems] = useState<EditHistoryItem[]>([]);
|
||
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 (
|
||
<Modal
|
||
open={open}
|
||
title={title}
|
||
description={total > 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={
|
||
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
|
||
关闭
|
||
</button>
|
||
}
|
||
>
|
||
{loading ? (
|
||
<div className="flex items-center justify-center gap-2 py-10 meta text-[13px]">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
加载中…
|
||
</div>
|
||
) : error && items.length === 0 ? (
|
||
<p className="text-[13.5px] py-6 text-center" style={{ color: "var(--danger)" }}>
|
||
{error}
|
||
</p>
|
||
) : items.length === 0 ? (
|
||
<p className="meta text-[13.5px] py-8 text-center">暂无编辑记录</p>
|
||
) : (
|
||
<div className="flex flex-col gap-4">
|
||
<ul className="flex flex-col gap-4">
|
||
{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 (
|
||
<li
|
||
key={it.id}
|
||
className="rounded-md p-3 sm:p-4"
|
||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||
>
|
||
<div className="flex items-center gap-2 mb-2.5">
|
||
<Link href={`/u/${it.editor.id}`} className="shrink-0">
|
||
<Avatar name={name} src={it.editor.avatar || undefined} size={28} />
|
||
</Link>
|
||
<div className="min-w-0 flex-1">
|
||
<Link
|
||
href={`/u/${it.editor.id}`}
|
||
className="text-[13px] hover:text-[var(--accent)]"
|
||
style={{ color: "var(--ink)" }}
|
||
>
|
||
{name}
|
||
</Link>
|
||
<p className="meta text-[11.5px] tabular-nums">
|
||
{new Date(it.created_at).toLocaleString("zh-CN")} · 第{" "}
|
||
{total - idx} 次编辑
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{titleChanged && (
|
||
<div className="mb-3">
|
||
<p className="meta text-[11.5px] mb-1">标题</p>
|
||
<DiffBody before={it.old_title ?? ""} after={afterTitle ?? ""} />
|
||
</div>
|
||
)}
|
||
{contentChanged ? (
|
||
<DiffBody before={it.old_content} after={afterContent} />
|
||
) : (
|
||
!titleChanged && (
|
||
<p className="meta text-[12.5px]">本次仅变更了标签或其他元数据,未修改标题/正文。</p>
|
||
)
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
{error && items.length > 0 && (
|
||
<p className="text-[12.5px] text-center" style={{ color: "var(--danger)" }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{hasMore && (
|
||
<div className="flex justify-center pt-1 pb-1">
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm"
|
||
disabled={loadingMore}
|
||
onClick={() => void loadPage(page + 1, true)}
|
||
>
|
||
{loadingMore ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Loader2 size={14} className="animate-spin" />
|
||
加载中…
|
||
</span>
|
||
) : (
|
||
`加载更多(已显示 ${items.length}/${total})`
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|