feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -381,11 +381,19 @@ export interface Post {
|
||||
type_status?: string;
|
||||
pinned: number;
|
||||
recommended: boolean;
|
||||
/** 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) */
|
||||
locked: boolean;
|
||||
/** 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) */
|
||||
last_reply_at?: string | null;
|
||||
/** 旧帖回复确认提示(详情接口按查看者计算;存在时提交回复前需用户确认) */
|
||||
necro_reply?: { after_hours: number; penalty: number } | null;
|
||||
status: string;
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
comment_count: number;
|
||||
liked: boolean;
|
||||
/** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */
|
||||
edited?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
board: Board;
|
||||
@@ -608,7 +616,7 @@ export interface NotificationItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
actor_id: number;
|
||||
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge";
|
||||
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply";
|
||||
post_id: number;
|
||||
comment_id: number;
|
||||
room_id?: number;
|
||||
@@ -918,6 +926,14 @@ export interface PublicSettings {
|
||||
points_reply_daily_cap: number;
|
||||
/** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */
|
||||
points_recommend_reward: number;
|
||||
/** 帖子可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */
|
||||
post_edit_lock_hours: number;
|
||||
/** 评论可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */
|
||||
comment_edit_lock_hours: number;
|
||||
/** 旧帖回复判定时长(小时);0=关闭;缺省 0 */
|
||||
necro_reply_after_hours: number;
|
||||
/** 旧帖回复扣除积分;0=仅提醒不扣分;缺省 0;上限 100 */
|
||||
necro_reply_penalty: number;
|
||||
/** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */
|
||||
levels: LevelDef[];
|
||||
/** 等级特效动画总开关(流光动画);缺省开 */
|
||||
@@ -980,6 +996,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
points_reply_reward: 0,
|
||||
points_reply_daily_cap: 0,
|
||||
points_recommend_reward: 0,
|
||||
post_edit_lock_hours: 0,
|
||||
comment_edit_lock_hours: 0,
|
||||
necro_reply_after_hours: 0,
|
||||
necro_reply_penalty: 0,
|
||||
levels: [...DEFAULT_LEVEL_DEFS],
|
||||
levels_fx: true,
|
||||
};
|
||||
@@ -1068,11 +1088,40 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
|
||||
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
|
||||
...normalizePointsSettings(data),
|
||||
...normalizeLockSettings(data),
|
||||
levels: normalizeLevels(data?.levels),
|
||||
levels_fx: data?.levels_fx !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 内容锁定 4 键:小时 0–8760 / 积分 0–100 有效整数,越界/异常回落默认值 */
|
||||
function normalizeLockSettings(
|
||||
data: Partial<PublicSettings> | null | undefined,
|
||||
): Pick<
|
||||
PublicSettings,
|
||||
| "post_edit_lock_hours"
|
||||
| "comment_edit_lock_hours"
|
||||
| "necro_reply_after_hours"
|
||||
| "necro_reply_penalty"
|
||||
> {
|
||||
const hours = (key: keyof PublicSettings): number | undefined => {
|
||||
const v = data?.[key];
|
||||
return typeof v === "number" && v >= 0 && v <= 8760 ? Math.round(v) : undefined;
|
||||
};
|
||||
const points = (key: keyof PublicSettings): number | undefined => {
|
||||
const v = data?.[key];
|
||||
return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined;
|
||||
};
|
||||
return {
|
||||
post_edit_lock_hours: hours("post_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.post_edit_lock_hours,
|
||||
comment_edit_lock_hours:
|
||||
hours("comment_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.comment_edit_lock_hours,
|
||||
necro_reply_after_hours:
|
||||
hours("necro_reply_after_hours") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_after_hours,
|
||||
necro_reply_penalty: points("necro_reply_penalty") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_penalty,
|
||||
};
|
||||
}
|
||||
|
||||
/** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */
|
||||
function normalizePointsSettings(
|
||||
data: Partial<PublicSettings> | null | undefined,
|
||||
@@ -1977,6 +2026,15 @@ export async function apiToggleRecommend(postId: string): Promise<{ recommended:
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 切换帖子手动锁定(管理员及以上);锁定后普通用户不可编辑/回复 */
|
||||
export async function apiTogglePostLock(postId: string): Promise<{ locked: boolean }> {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/lock`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiChangePassword(oldPassword: string, newPassword: string) {
|
||||
const res = await fetchWithRefresh("/api/change-password", {
|
||||
method: "POST",
|
||||
@@ -3092,6 +3150,10 @@ export type UpdateSiteSettingsBody = {
|
||||
points_reply_reward?: number;
|
||||
points_reply_daily_cap?: number;
|
||||
points_recommend_reward?: number;
|
||||
post_edit_lock_hours?: number;
|
||||
comment_edit_lock_hours?: number;
|
||||
necro_reply_after_hours?: number;
|
||||
necro_reply_penalty?: number;
|
||||
/** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */
|
||||
levels?: LevelDef[];
|
||||
levels_fx?: boolean;
|
||||
@@ -3264,6 +3326,8 @@ export interface AdminContentComment {
|
||||
board_id: number;
|
||||
content: string;
|
||||
status: string;
|
||||
/** 相对创建已编辑(服务端判定);无编辑时隐藏历史入口 */
|
||||
edited: boolean;
|
||||
deleted: boolean;
|
||||
deleted_at?: string;
|
||||
created_at: string;
|
||||
@@ -3352,15 +3416,44 @@ export interface CommentEditHistoryItem {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 管理:评论编辑历史(分页;默认每页 10 条) */
|
||||
export async function apiAdminCommentHistory(
|
||||
/** 评论编辑历史(登录可见;分页;默认每页 10 条) */
|
||||
export async function apiCommentHistory(
|
||||
commentId: number,
|
||||
page = 1,
|
||||
size = 10
|
||||
): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> {
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/admin/content/comments/${commentId}/history?${params}`,
|
||||
`/api/comments/${commentId}/history?${params}`,
|
||||
{ headers: clientHeaders() }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface PostEditHistoryItem {
|
||||
id: number;
|
||||
editor: {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
};
|
||||
old_title: string;
|
||||
old_content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 帖子编辑历史(登录可见;分页;默认每页 10 条) */
|
||||
export async function apiPostHistory(
|
||||
postId: number,
|
||||
page = 1,
|
||||
size = 10
|
||||
): Promise<{ items: PostEditHistoryItem[]; total: number; page: number; size: number }> {
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/posts/${postId}/history?${params}`,
|
||||
{ headers: clientHeaders() }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
214
frontend/lib/smartDiff.test.ts
Normal file
214
frontend/lib/smartDiff.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { smartDiff, toFoldedRows } from "./smartDiff.ts";
|
||||
|
||||
/** 把结果压成便于断言的短格式:每行 "op:text" */
|
||||
function flat(text: string, other: string): string[] {
|
||||
const r = smartDiff(text, other);
|
||||
return r.blocks.flatMap((b) => b.lines.map((l) => `${l.op}:${l.text}`));
|
||||
}
|
||||
|
||||
describe("smartDiff", () => {
|
||||
it("场景1:整段中文说明 → 整段 R 代码,判定 block-replace 而非碎片对齐", () => {
|
||||
// 两段各留一个空行作为「诱饵公共行」——朴素 LCS 会围绕它做对齐
|
||||
const oldText = [
|
||||
"本函数用于处理用户上传的数据。",
|
||||
"",
|
||||
"首先校验输入的合法性,",
|
||||
"然后将数据逐行写入临时表,",
|
||||
"最后清理缓存并返回结果。",
|
||||
].join("\n");
|
||||
const newText = [
|
||||
"process_data <- function(df) {",
|
||||
"",
|
||||
" stopifnot(!is.null(df))",
|
||||
" out <- transform(df, total = sum(x))",
|
||||
' write.csv(out, "out.csv")',
|
||||
" invisible(out)",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
const r = smartDiff(oldText, newText);
|
||||
assert.equal(r.blocks.length, 1);
|
||||
assert.equal(r.blocks[0].type, "block-replace");
|
||||
// 大删除块在前、大新增块在后,不出现 -/+ 交替碎片
|
||||
const ops = r.blocks[0].lines.map((l) => l.op);
|
||||
assert.ok(ops.slice(0, 5).every((o) => o === "del"));
|
||||
assert.ok(ops.slice(5).every((o) => o === "ins"));
|
||||
assert.equal(r.stats.dels, 5);
|
||||
assert.equal(r.stats.adds, 7);
|
||||
// 整段替换不做行内细分
|
||||
assert.ok(r.blocks[0].lines.every((l) => l.segments === undefined));
|
||||
});
|
||||
|
||||
it("场景2:单行内改一个词,输出词级 segments 而非整行标红标绿", () => {
|
||||
const oldText = "const total = countItems(list);";
|
||||
const newText = "const total = countUsers(list);";
|
||||
const r = smartDiff(oldText, newText);
|
||||
assert.equal(r.blocks.length, 1);
|
||||
assert.equal(r.blocks[0].type, "replace");
|
||||
const del = r.blocks[0].lines[0];
|
||||
const ins = r.blocks[0].lines[1];
|
||||
assert.ok(del.segments && del.segments.length > 0, "del 行应有 segments");
|
||||
assert.ok(ins.segments && ins.segments.length > 0, "ins 行应有 segments");
|
||||
// 删除行上被标记的正是 countItems,新增行上是 countUsers
|
||||
const delMarked = del.segments!.map((s) => del.text.slice(s.start, s.end)).join("");
|
||||
const insMarked = ins.segments!.map((s) => ins.text.slice(s.start, s.end)).join("");
|
||||
assert.equal(delMarked, "countItems");
|
||||
assert.equal(insMarked, "countUsers");
|
||||
// 片段不应包含未变化的部分
|
||||
for (const s of del.segments!) {
|
||||
assert.ok(!del.text.slice(s.start, s.end).includes("const"));
|
||||
}
|
||||
});
|
||||
|
||||
it("场景3:仅行尾空白变化,不产生假改动", () => {
|
||||
const oldText = "第一行 \n第二行\t";
|
||||
const newText = "第一行\n第二行";
|
||||
const r = smartDiff(oldText, newText);
|
||||
assert.equal(r.blocks.length, 1);
|
||||
assert.equal(r.blocks[0].type, "equal");
|
||||
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
|
||||
// 展示保留原文(含行尾空白),但比较视为相同
|
||||
assert.equal(r.blocks[0].lines[0].text, "第一行 ");
|
||||
});
|
||||
|
||||
it("场景4:大量重复空行/花括号的代码,对齐不错位、变更块不碎", () => {
|
||||
const oldText = [
|
||||
"function a() {",
|
||||
"",
|
||||
" if (x) {",
|
||||
"",
|
||||
" return 1;",
|
||||
"",
|
||||
" }",
|
||||
"",
|
||||
"}",
|
||||
].join("\n");
|
||||
const newText = [
|
||||
"function a() {",
|
||||
"",
|
||||
" if (y) {",
|
||||
"",
|
||||
" return 2;",
|
||||
"",
|
||||
" }",
|
||||
"",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
const r = smartDiff(oldText, newText);
|
||||
// 期望:equal → replace(if 行) → equal → replace(return 行) → equal
|
||||
assert.deepEqual(
|
||||
r.blocks.map((b) => b.type),
|
||||
["equal", "replace", "equal", "replace", "equal"]
|
||||
);
|
||||
// 两个 replace 块均有行内细分,且只标记真正变化的字符
|
||||
const [b1, b2] = [r.blocks[1], r.blocks[3]];
|
||||
assert.equal(b1.lines[0].text, " if (x) {");
|
||||
assert.equal(
|
||||
b1.lines[0].segments!.map((s) => b1.lines[0].text.slice(s.start, s.end)).join(""),
|
||||
"x"
|
||||
);
|
||||
assert.equal(
|
||||
b1.lines[1].segments!.map((s) => b1.lines[1].text.slice(s.start, s.end)).join(""),
|
||||
"y"
|
||||
);
|
||||
assert.equal(
|
||||
b2.lines[0].segments!.map((s) => b2.lines[0].text.slice(s.start, s.end)).join(""),
|
||||
"1"
|
||||
);
|
||||
// 空行不进入任何变更块
|
||||
assert.ok(r.blocks.every((b) => b.type === "equal" || !b.lines.some((l) => l.text === "")));
|
||||
});
|
||||
|
||||
it("场景5:完全相同的文本,只有一个 equal 块", () => {
|
||||
const text = "第一行\n第二行\n第三行";
|
||||
const r = smartDiff(text, text);
|
||||
assert.equal(r.blocks.length, 1);
|
||||
assert.equal(r.blocks[0].type, "equal");
|
||||
assert.equal(r.blocks[0].lines.length, 3);
|
||||
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
|
||||
});
|
||||
|
||||
it("CRLF / CR 行尾符差异不影响比较", () => {
|
||||
const r = smartDiff("a\r\nb\rc", "a\nb\nc");
|
||||
assert.equal(r.blocks.length, 1);
|
||||
assert.equal(r.blocks[0].type, "equal");
|
||||
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
|
||||
});
|
||||
|
||||
it("单侧为空:整体删除 / 整体新增", () => {
|
||||
const ins = smartDiff("", "新的一行\n再来一行");
|
||||
assert.deepEqual(ins.blocks.map((b) => b.type), ["insert"]);
|
||||
assert.equal(ins.stats.adds, 2);
|
||||
|
||||
const del = smartDiff("旧行一\n旧行二", "");
|
||||
assert.deepEqual(del.blocks.map((b) => b.type), ["delete"]);
|
||||
assert.equal(del.stats.dels, 2);
|
||||
|
||||
assert.deepEqual(smartDiff("", "").blocks, []);
|
||||
});
|
||||
|
||||
it("后处理不变式:相邻同向块必合并,replace 块内 del 全部在 ins 之前", () => {
|
||||
const oldText = Array.from({ length: 30 }, (_, i) => `common ${i}`).join("\n");
|
||||
const newText = oldText
|
||||
.replace("common 5", "changed 5")
|
||||
.replace("common 6", "changed 6")
|
||||
.replace("common 20", "changed 20");
|
||||
const r = smartDiff(oldText, newText);
|
||||
// 相邻块不允许同为变更块(equal 必然隔开两个变更区域)
|
||||
for (let i = 1; i < r.blocks.length; i++) {
|
||||
const prev = r.blocks[i - 1];
|
||||
const cur = r.blocks[i];
|
||||
assert.ok(
|
||||
!(prev.type !== "equal" && cur.type !== "equal"),
|
||||
"相邻变更块未被合并"
|
||||
);
|
||||
}
|
||||
for (const b of r.blocks) {
|
||||
if (b.type !== "replace") continue;
|
||||
const firstIns = b.lines.findIndex((l) => l.op === "ins");
|
||||
const lastDel = b.lines.map((l) => l.op).lastIndexOf("del");
|
||||
assert.ok(lastDel < firstIns, "replace 块内应先删后增");
|
||||
}
|
||||
});
|
||||
|
||||
it("上下文折叠:长 equal 块折叠为上下文 + 省略标记,头/尾单侧保留", () => {
|
||||
const lines = Array.from({ length: 40 }, (_, i) => `L${i}`);
|
||||
const oldText = lines.join("\n");
|
||||
const newText = [...lines.slice(0, 20), "changed", ...lines.slice(21)].join("\n");
|
||||
const rows = toFoldedRows(smartDiff(oldText, newText).blocks);
|
||||
// 头部 equal 块(L0..L19,isHead)只保留尾部 3 行上下文 L17/L18/L19 + 折叠 17 行;
|
||||
// 尾部 equal 块(L21..L39,isTail)只保留头部 3 行上下文 L21/L22/L23 + 折叠 16 行
|
||||
const folds = rows.filter((r) => r.kind === "fold") as Array<{ kind: "fold"; count: number }>;
|
||||
assert.deepEqual(
|
||||
folds.map((f) => f.count),
|
||||
[17, 16]
|
||||
);
|
||||
// 头部 equal 块(L0..L19,isHead):省略前 17 行 → 保留尾部上下文 L17/L18/L19;
|
||||
// 尾部 equal 块(L21..L39,isTail):保留头部上下文 L21/L22/L23 → 省略后 16 行
|
||||
assert.deepEqual(rows.slice(0, 4).map((r) => (r.kind === "fold" ? "fold" : r.text)), [
|
||||
"fold",
|
||||
"L17",
|
||||
"L18",
|
||||
"L19",
|
||||
]);
|
||||
});
|
||||
|
||||
it("10 万行大文件在 2 秒内完成", () => {
|
||||
const n = 100_000;
|
||||
const oldLines = Array.from({ length: n }, (_, i) => `line-${i}`);
|
||||
const newLines = [...oldLines];
|
||||
const changes = 20;
|
||||
for (let k = 0; k < changes; k++) {
|
||||
newLines[k * 4_000 + 7] = `line-${k * 4_000 + 7}-changed`;
|
||||
}
|
||||
const start = performance.now();
|
||||
const r = smartDiff(oldLines.join("\n"), newLines.join("\n"));
|
||||
const elapsed = performance.now() - start;
|
||||
assert.equal(r.stats.dels, changes);
|
||||
assert.equal(r.stats.adds, changes);
|
||||
assert.ok(elapsed < 2_000, `应在 2 秒内完成,实际 ${elapsed.toFixed(0)}ms`);
|
||||
});
|
||||
});
|
||||
527
frontend/lib/smartDiff.ts
Normal file
527
frontend/lib/smartDiff.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* smartDiff —— 面向可读性的结构化行级 diff(自研,无第三方依赖)。
|
||||
*
|
||||
* 四层处理:
|
||||
* 1. 预处理:CRLF/CR → LF(仅用于比较与展示),行尾空白在比较时忽略(展示保留原文)。
|
||||
* 2. 算法选择:行级相似度(difflib ratio 思路:2M/(a+b),M 为多重集交集)低于阈值时
|
||||
* 判定「整段替换」,输出一个 block-replace,跳过逐行对齐——避免把毫无对应关系的
|
||||
* 两段文本切成几十对 -/+ 碎片;否则用 Patience diff(锚定双方唯一行,LIS 选链),
|
||||
* 无锚点/超大规模/超深递归的子区间回退为 LCS 或整块替换。
|
||||
* 3. 后处理:相邻同向变更合并为一个块(del 在前 ins 在后),消除 LCS 的交叉配对;
|
||||
* 上下文折叠(默认 3 行,头/尾块只保留单侧上下文)。
|
||||
* 4. 行内细分:replace 块内按下标配对的行对,先分词(CJK 单字、西文单词、空白、符号),
|
||||
* 词级 LCS 相似度 > 阈值时输出变更片段的字符偏移(segments),否则整行视为变更。
|
||||
*
|
||||
* 输出为结构化块数组,供 diff 视图组件渲染;segments 直接挂在对应行上
|
||||
* (start/end 为该行文本的 UTF-16 偏移),UI 只高亮变更部分。
|
||||
*/
|
||||
|
||||
export type LineOp = "equal" | "del" | "ins";
|
||||
export type BlockType = "equal" | "insert" | "delete" | "replace" | "block-replace";
|
||||
|
||||
/** 行内变更片段:start/end 为行文本的 UTF-16 偏移,type 为该行视角下的变更方向 */
|
||||
export interface InlineSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
type: "del" | "ins";
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
op: LineOp;
|
||||
/** 原始行文本(保留行尾空白,供展示) */
|
||||
text: string;
|
||||
/** 仅 replace 块中被判定为「修改」的行对附带;缺省表示整行变更 */
|
||||
segments?: InlineSegment[];
|
||||
}
|
||||
|
||||
export interface DiffBlock {
|
||||
type: BlockType;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface DiffStats {
|
||||
adds: number;
|
||||
dels: number;
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
blocks: DiffBlock[];
|
||||
stats: DiffStats;
|
||||
}
|
||||
|
||||
export interface SmartDiffOptions {
|
||||
/** 行级相似度低于该值判定整段替换(默认 0.2;仅任一侧 ≥2 行时生效) */
|
||||
replaceThreshold?: number;
|
||||
/** 行对词级相似度高于该值才做行内细分(默认 0.6) */
|
||||
inlineThreshold?: number;
|
||||
}
|
||||
|
||||
// 行级 LCS 回退的规模上限(超出则该区间整块替换,避免 O(n·m) 爆炸)
|
||||
const MAX_REGION_CELLS = 400_000;
|
||||
// 行内词级 LCS 的 token 数上限
|
||||
const MAX_INLINE_CELLS = 20_000;
|
||||
// 行对合计字符数超过则跳过行内细分
|
||||
const MAX_INLINE_CHARS = 4_000;
|
||||
// Patience 递归深度上限(超过直接整块替换,防御构造性深递归)
|
||||
const MAX_PATIENCE_DEPTH = 32;
|
||||
|
||||
/* ---------------- 第一层:预处理 ---------------- */
|
||||
|
||||
interface SplitText {
|
||||
raw: string[];
|
||||
/** 归一化后的行(比较用):行尾空白已去除 */
|
||||
cmp: string[];
|
||||
}
|
||||
|
||||
function splitLines(text: string): SplitText {
|
||||
const norm = text.replace(/\r\n?/g, "\n");
|
||||
const raw = norm === "" ? [] : norm.split("\n");
|
||||
return { raw, cmp: raw.map((l) => l.replace(/\s+$/, "")) };
|
||||
}
|
||||
|
||||
/* ---------------- 第二层:相似度与算法选择 ---------------- */
|
||||
|
||||
/** 行级相似度:difflib ratio 思路,2M/(a+b),M 为行多重集交集大小 */
|
||||
function lineSimilarity(a: string[], b: string[]): number {
|
||||
if (a.length === 0 && b.length === 0) return 1;
|
||||
const freq = new Map<string, number>();
|
||||
for (const l of a) freq.set(l, (freq.get(l) ?? 0) + 1);
|
||||
let m = 0;
|
||||
for (const l of b) {
|
||||
const c = freq.get(l) ?? 0;
|
||||
if (c > 0) {
|
||||
m += 1;
|
||||
freq.set(l, c - 1);
|
||||
}
|
||||
}
|
||||
return (2 * m) / (a.length + b.length);
|
||||
}
|
||||
|
||||
type RawOp = { op: LineOp; ai: number; bi: number };
|
||||
|
||||
/**
|
||||
* Patience diff:在区间内找到「双方都恰好出现一次」的公共行作锚点,
|
||||
* 对锚点按 i 升序求 j 的最长递增子序列(patience sorting,O(k log k))作骨架,
|
||||
* 骨架之间的间隙递归;无锚点/超深的区间回退 fallbackRegion。
|
||||
*/
|
||||
function patienceDiff(
|
||||
cmpA: string[],
|
||||
cmpB: string[],
|
||||
loA: number,
|
||||
hiA: number,
|
||||
loB: number,
|
||||
hiB: number,
|
||||
out: RawOp[],
|
||||
depth: number
|
||||
): void {
|
||||
if (loA >= hiA && loB >= hiB) return;
|
||||
|
||||
const countA = new Map<string, number>();
|
||||
for (let i = loA; i < hiA; i++) countA.set(cmpA[i], (countA.get(cmpA[i]) ?? 0) + 1);
|
||||
const countB = new Map<string, number>();
|
||||
for (let j = loB; j < hiB; j++) countB.set(cmpB[j], (countB.get(cmpB[j]) ?? 0) + 1);
|
||||
|
||||
const posA = new Map<string, number>();
|
||||
for (let i = loA; i < hiA; i++) {
|
||||
if (countA.get(cmpA[i]) === 1) posA.set(cmpA[i], i);
|
||||
}
|
||||
const anchors: Array<{ i: number; j: number }> = [];
|
||||
for (let j = loB; j < hiB; j++) {
|
||||
if (countB.get(cmpB[j]) === 1) {
|
||||
const i = posA.get(cmpB[j]);
|
||||
if (i !== undefined) anchors.push({ i, j });
|
||||
}
|
||||
}
|
||||
if (anchors.length === 0 || depth >= MAX_PATIENCE_DEPTH) {
|
||||
fallbackRegion(cmpA, cmpB, loA, hiA, loB, hiB, out);
|
||||
return;
|
||||
}
|
||||
|
||||
anchors.sort((x, y) => x.i - y.i);
|
||||
const tails: number[] = [];
|
||||
const prev = new Array<number>(anchors.length).fill(-1);
|
||||
for (let k = 0; k < anchors.length; k++) {
|
||||
const j = anchors[k].j;
|
||||
let lo = 0;
|
||||
let hi = tails.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (anchors[tails[mid]].j < j) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo > 0) prev[k] = tails[lo - 1];
|
||||
if (lo === tails.length) tails.push(k);
|
||||
else tails[lo] = k;
|
||||
}
|
||||
const chain: number[] = [];
|
||||
for (let k = tails[tails.length - 1]; k !== -1; k = prev[k]) chain.push(k);
|
||||
chain.reverse();
|
||||
|
||||
let ai = loA;
|
||||
let bi = loB;
|
||||
for (const k of chain) {
|
||||
const { i, j } = anchors[k];
|
||||
patienceDiff(cmpA, cmpB, ai, i, bi, j, out, depth + 1);
|
||||
out.push({ op: "equal", ai: i, bi: j });
|
||||
ai = i + 1;
|
||||
bi = j + 1;
|
||||
}
|
||||
patienceDiff(cmpA, cmpB, ai, hiA, bi, hiB, out, depth + 1);
|
||||
}
|
||||
|
||||
/** 无锚点区间:无公共行或规模超限时整块替换;否则 LCS 细对齐 */
|
||||
function fallbackRegion(
|
||||
cmpA: string[],
|
||||
cmpB: string[],
|
||||
loA: number,
|
||||
hiA: number,
|
||||
loB: number,
|
||||
hiB: number,
|
||||
out: RawOp[]
|
||||
): void {
|
||||
const n = hiA - loA;
|
||||
const m = hiB - loB;
|
||||
if (n === 0 && m === 0) return;
|
||||
if (n === 0) {
|
||||
for (let j = loB; j < hiB; j++) out.push({ op: "ins", ai: loA, bi: j });
|
||||
return;
|
||||
}
|
||||
if (m === 0) {
|
||||
for (let i = loA; i < hiA; i++) out.push({ op: "del", ai: i, bi: loB });
|
||||
return;
|
||||
}
|
||||
if (n * m > MAX_REGION_CELLS || !hasCommonLine(cmpA, cmpB, loA, hiA, loB, hiB)) {
|
||||
for (let i = loA; i < hiA; i++) out.push({ op: "del", ai: i, bi: loB });
|
||||
for (let j = loB; j < hiB; j++) out.push({ op: "ins", ai: hiA, bi: j });
|
||||
return;
|
||||
}
|
||||
lcsRegion(cmpA, cmpB, loA, hiA, loB, hiB, out);
|
||||
}
|
||||
|
||||
function hasCommonLine(
|
||||
cmpA: string[],
|
||||
cmpB: string[],
|
||||
loA: number,
|
||||
hiA: number,
|
||||
loB: number,
|
||||
hiB: number
|
||||
): boolean {
|
||||
const set = new Set<string>();
|
||||
for (let i = loA; i < hiA; i++) set.add(cmpA[i]);
|
||||
for (let j = loB; j < hiB; j++) {
|
||||
if (set.has(cmpB[j])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 行级 LCS(规模已由调用方限制),供无锚点但有公共行的区间使用 */
|
||||
function lcsRegion(
|
||||
cmpA: string[],
|
||||
cmpB: string[],
|
||||
loA: number,
|
||||
hiA: number,
|
||||
loB: number,
|
||||
hiB: number,
|
||||
out: RawOp[]
|
||||
): void {
|
||||
const n = hiA - loA;
|
||||
const m = hiB - loB;
|
||||
const width = m + 1;
|
||||
const dp = new Uint32Array((n + 1) * width);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const aLine = cmpA[loA + i];
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i * width + j] =
|
||||
aLine === cmpB[loB + j]
|
||||
? dp[(i + 1) * width + (j + 1)] + 1
|
||||
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (cmpA[loA + i] === cmpB[loB + j]) {
|
||||
out.push({ op: "equal", ai: loA + i, bi: loB + j });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
|
||||
out.push({ op: "del", ai: loA + i, bi: loB + j });
|
||||
i += 1;
|
||||
} else {
|
||||
out.push({ op: "ins", ai: loA + i, bi: loB + j });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
out.push({ op: "del", ai: loA + i, bi: loB + j });
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
out.push({ op: "ins", ai: loA + i, bi: loB + j });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 第三层:后处理(块构建 + 上下文折叠) ---------------- */
|
||||
|
||||
/**
|
||||
* 把扁平 op 序列整理成块:
|
||||
* - 连续 equal → equal 块(展示用旧文本原文)
|
||||
* - 连续非 equal 合并为一个变更区域:del 按原顺序在前、ins 按新顺序在后,
|
||||
* 两侧都有 → replace(后续做行内细分),单侧 → delete / insert。
|
||||
* 这一步消除了 LCS 可能出现的 -/+ 交叉碎片(del,ins,del,ins → 一个 replace 块)。
|
||||
*/
|
||||
function buildBlocks(ops: RawOp[], rawA: string[], rawB: string[]): DiffBlock[] {
|
||||
const blocks: DiffBlock[] = [];
|
||||
let k = 0;
|
||||
while (k < ops.length) {
|
||||
if (ops[k].op === "equal") {
|
||||
const lines: DiffLine[] = [];
|
||||
while (k < ops.length && ops[k].op === "equal") {
|
||||
lines.push({ op: "equal", text: rawA[ops[k].ai] });
|
||||
k += 1;
|
||||
}
|
||||
blocks.push({ type: "equal", lines });
|
||||
continue;
|
||||
}
|
||||
const dels: DiffLine[] = [];
|
||||
const inss: DiffLine[] = [];
|
||||
while (k < ops.length && ops[k].op !== "equal") {
|
||||
const o = ops[k];
|
||||
if (o.op === "del") dels.push({ op: "del", text: rawA[o.ai] });
|
||||
else inss.push({ op: "ins", text: rawB[o.bi] });
|
||||
k += 1;
|
||||
}
|
||||
const type: BlockType =
|
||||
dels.length > 0 && inss.length > 0 ? "replace" : dels.length > 0 ? "delete" : "insert";
|
||||
blocks.push({ type, lines: [...dels, ...inss] });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export type FoldedRow =
|
||||
| { kind: "line"; op: LineOp; text: string; segments?: InlineSegment[] }
|
||||
| { kind: "fold"; count: number };
|
||||
|
||||
/**
|
||||
* 上下文折叠:超过 context*2+2 行的 equal 块只保留变更两侧各 context 行,
|
||||
* 头/尾 equal 块只保留单侧;返回渲染用的行序列(fold 为折叠标记)。
|
||||
*/
|
||||
export function toFoldedRows(blocks: DiffBlock[], context = 3): FoldedRow[] {
|
||||
const rows: FoldedRow[] = [];
|
||||
const pushLine = (l: DiffLine) =>
|
||||
rows.push({ kind: "line", op: l.op, text: l.text, segments: l.segments });
|
||||
blocks.forEach((b, bi) => {
|
||||
if (!(b.type === "equal" && b.lines.length > context * 2 + 2)) {
|
||||
b.lines.forEach(pushLine);
|
||||
return;
|
||||
}
|
||||
const isHead = bi === 0;
|
||||
const isTail = bi === blocks.length - 1;
|
||||
if (isHead && isTail) {
|
||||
// 整个 diff 都是相同内容:不折叠
|
||||
b.lines.forEach(pushLine);
|
||||
return;
|
||||
}
|
||||
const head = isHead ? 0 : context;
|
||||
const tail = isTail ? 0 : context;
|
||||
for (let i = 0; i < head; i++) pushLine(b.lines[i]);
|
||||
rows.push({ kind: "fold", count: b.lines.length - head - tail });
|
||||
for (let i = b.lines.length - tail; i < b.lines.length; i++) pushLine(b.lines[i]);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/* ---------------- 第四层:行内细分(词级 diff) ---------------- */
|
||||
|
||||
// CJK 逐字成 token(中文无词边界),西文按单词,空白连续,其余单字符
|
||||
const TOKEN_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]|[A-Za-z0-9_]+|\s+|[^\s]/gu;
|
||||
|
||||
interface Tokens {
|
||||
tokens: string[];
|
||||
starts: number[];
|
||||
ends: number[];
|
||||
}
|
||||
|
||||
function tokenize(line: string): Tokens {
|
||||
const tokens: string[] = [];
|
||||
const starts: number[] = [];
|
||||
const ends: number[] = [];
|
||||
for (const m of line.matchAll(TOKEN_RE)) {
|
||||
const start = m.index;
|
||||
if (start === undefined) continue;
|
||||
tokens.push(m[0]);
|
||||
starts.push(start);
|
||||
ends.push(start + m[0].length);
|
||||
}
|
||||
return { tokens, starts, ends };
|
||||
}
|
||||
|
||||
function lcsTokenLen(a: string[], b: string[]): number {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
return dp[0];
|
||||
}
|
||||
|
||||
/** X 视角下的变更片段:词级 LCS 中未被匹配的连续 token 合并为一个字符区间 */
|
||||
function changedSegments(
|
||||
t: Tokens,
|
||||
ops: Array<{ op: "equal" | "chg"; idx: number }>,
|
||||
type: "del" | "ins"
|
||||
): InlineSegment[] {
|
||||
const segs: InlineSegment[] = [];
|
||||
let cur: InlineSegment | null = null;
|
||||
for (const o of ops) {
|
||||
if (o.op === "equal") {
|
||||
cur = null;
|
||||
continue;
|
||||
}
|
||||
const start = t.starts[o.idx];
|
||||
const end = t.ends[o.idx];
|
||||
if (cur && cur.end === start) {
|
||||
cur.end = end;
|
||||
} else {
|
||||
cur = { start, end, type };
|
||||
segs.push(cur);
|
||||
}
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
/** 词级 LCS 回溯,产出 X/Y 各自的 equal/chg 序列(idx 为各自 token 下标) */
|
||||
function tokenOps(x: Tokens, y: Tokens): {
|
||||
xOps: Array<{ op: "equal" | "chg"; idx: number }>;
|
||||
yOps: Array<{ op: "equal" | "chg"; idx: number }>;
|
||||
} {
|
||||
const n = x.tokens.length;
|
||||
const m = y.tokens.length;
|
||||
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] =
|
||||
x.tokens[i] === y.tokens[j]
|
||||
? dp[(i + 1) * width + (j + 1)] + 1
|
||||
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
|
||||
}
|
||||
}
|
||||
const xOps: Array<{ op: "equal" | "chg"; idx: number }> = [];
|
||||
const yOps: Array<{ op: "equal" | "chg"; idx: number }> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (x.tokens[i] === y.tokens[j]) {
|
||||
xOps.push({ op: "equal", idx: i });
|
||||
yOps.push({ op: "equal", idx: j });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
|
||||
xOps.push({ op: "chg", idx: i });
|
||||
i += 1;
|
||||
} else {
|
||||
yOps.push({ op: "chg", idx: j });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
xOps.push({ op: "chg", idx: i });
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
yOps.push({ op: "chg", idx: j });
|
||||
j += 1;
|
||||
}
|
||||
return { xOps, yOps };
|
||||
}
|
||||
|
||||
/** 对 replace 块内按下标配对的行对做词级细分;相似度不足则整行变更、不产出 segments */
|
||||
function annotateInline(blocks: DiffBlock[], inlineThreshold: number): void {
|
||||
for (const b of blocks) {
|
||||
if (b.type !== "replace") continue;
|
||||
const dels = b.lines.filter((l) => l.op === "del");
|
||||
const inss = b.lines.filter((l) => l.op === "ins");
|
||||
const n = Math.min(dels.length, inss.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const delLine = dels[i];
|
||||
const insLine = inss[i];
|
||||
if (delLine.text.length + insLine.text.length > MAX_INLINE_CHARS) continue;
|
||||
const x = tokenize(delLine.text);
|
||||
const y = tokenize(insLine.text);
|
||||
if (x.tokens.length === 0 || y.tokens.length === 0) continue;
|
||||
if (x.tokens.length * y.tokens.length > MAX_INLINE_CELLS) continue;
|
||||
const ratio = (2 * lcsTokenLen(x.tokens, y.tokens)) / (x.tokens.length + y.tokens.length);
|
||||
if (!(ratio > inlineThreshold)) continue;
|
||||
const { xOps, yOps } = tokenOps(x, y);
|
||||
const delSegs = changedSegments(x, xOps, "del");
|
||||
const insSegs = changedSegments(y, yOps, "ins");
|
||||
if (delSegs.length > 0) delLine.segments = delSegs;
|
||||
if (insSegs.length > 0) insLine.segments = insSegs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 入口 ---------------- */
|
||||
|
||||
export function smartDiff(
|
||||
oldText: string,
|
||||
newText: string,
|
||||
options: SmartDiffOptions = {}
|
||||
): DiffResult {
|
||||
const replaceThreshold = options.replaceThreshold ?? 0.2;
|
||||
const inlineThreshold = options.inlineThreshold ?? 0.6;
|
||||
const a = splitLines(oldText);
|
||||
const b = splitLines(newText);
|
||||
|
||||
const ops: RawOp[] = [];
|
||||
let wholeReplace = false;
|
||||
if (a.cmp.length === 0 && b.cmp.length === 0) {
|
||||
// 两侧皆空:无块
|
||||
} else if (a.cmp.length === 0 || b.cmp.length === 0) {
|
||||
// 单侧为空:整体删除/新增
|
||||
if (a.cmp.length > 0) {
|
||||
for (let i = 0; i < a.cmp.length; i++) ops.push({ op: "del", ai: i, bi: 0 });
|
||||
} else {
|
||||
for (let j = 0; j < b.cmp.length; j++) ops.push({ op: "ins", ai: 0, bi: j });
|
||||
}
|
||||
} else if (
|
||||
a.cmp.length >= 2 &&
|
||||
b.cmp.length >= 2 &&
|
||||
lineSimilarity(a.cmp, b.cmp) < replaceThreshold
|
||||
) {
|
||||
// 整段替换:跳过逐行对齐,输出一个大删除块 + 一个大新增块
|
||||
wholeReplace = true;
|
||||
for (let i = 0; i < a.cmp.length; i++) ops.push({ op: "del", ai: i, bi: 0 });
|
||||
for (let j = 0; j < b.cmp.length; j++) ops.push({ op: "ins", ai: 0, bi: j });
|
||||
} else {
|
||||
patienceDiff(a.cmp, b.cmp, 0, a.cmp.length, 0, b.cmp.length, ops, 0);
|
||||
}
|
||||
|
||||
const blocks = buildBlocks(ops, a.raw, b.raw);
|
||||
if (wholeReplace) {
|
||||
// 相似度低于阈值判定为整段替换:唯一变更块标记为 block-replace,
|
||||
// 不做行内细分(两侧内容无对应关系,细对齐只会产出噪音)
|
||||
const blk = blocks.find((x) => x.type === "replace");
|
||||
if (blk) blk.type = "block-replace";
|
||||
}
|
||||
annotateInline(blocks, inlineThreshold);
|
||||
|
||||
let dels = 0;
|
||||
let adds = 0;
|
||||
for (const blk of blocks) {
|
||||
for (const l of blk.lines) {
|
||||
if (l.op === "del") dels += 1;
|
||||
else if (l.op === "ins") adds += 1;
|
||||
}
|
||||
}
|
||||
return { blocks, stats: { adds, dels } };
|
||||
}
|
||||
Reference in New Issue
Block a user