Files
jiang13-bbs/frontend/components/EditHistoryModal.tsx
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

434 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}