feat(tools): 新增文本比对工具页面
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 1m32s

- src/libs/diff.js:零依赖 diff 算法
  - 行级 LCS + 变更块内位置对齐配对,避免连续多行全变被错配成删+增
  - 先剥离公共前后缀再求中间变更区,消除「删除优先」回溯造成的左右不对称
  - 字符级细化,仅对 replace 行标注变化的字符片段
  - 大文本(超阈值)按锚点分段降级,避免构建超大 LCS 矩阵
  - toUnifiedDiff 导出,标准 3 行上下文分组
- src/view/tools/text-diff.vue:比对页面
  - 双栏输入,支持拖入 .txt 文件
  - 行级差异配色(新增绿/删除红/修改黄)+ 字符级高亮
  - 差异统计、上一处/下一处导航、只看差异(含上下文)
  - 交换左右、清空、示例数据、导出带色 HTML
This commit is contained in:
zhangjiaxun
2026-10-20 22:27:58 +08:00
parent 810e343625
commit 5831c3bdb5
2 changed files with 1269 additions and 0 deletions
+661
View File
@@ -0,0 +1,661 @@
/**
* 文本比对算法(零依赖)
*
* 两层结构:
* ① 行级 diff:LCS 求最长公共子序列,回溯出 equal / delete / insert / replace 操作
* ② 字符级 diff:仅对 replace 行再做一次 LCS,标出行内真正变化的片段
*
* 不引入 npm diff 库的原因:UI 展示完全自绘,需要精确控制分段边界;
* 且本项目包体已优化过(61.5MB),为一个纯计算模块再加依赖不划算。
*/
// 行数超过该阈值时,先按哈希分桶缩小 LCS 矩阵规模,避免大文本卡死页面
const HASH_BUCKET_THRESHOLD = 1500
// LCS 矩阵单元格总数上限,超过则退化为「按唯一锚点切段」策略
const MATRIX_CELL_LIMIT = 4000000
/**
* 规范化一行:忽略空白模式下去掉首尾空白、压缩连续空白,且纯空白行归一为空串。
* 这样「只有空白差异的行」与「空行」在 LCS 眼里完全一致,不会被判成删除+新增。
* @param {String} line 原始行
* @param {Boolean} ignoreWhitespace 是否忽略空白
* @returns {String}
*/
function normalizeLine (line, ignoreWhitespace) {
if (!ignoreWhitespace) return line
const trimmed = line.replace(/\s+/g, ' ').replace(/^\s+|\s+$/g, '')
return trimmed
}
/**
* 计算 LCS 长度矩阵
* @param {Array} a 序列 A
* @param {Array} b 序列 B
* @returns {Array} 二维数组,m[i][j] = a[i..] 与 b[j..] 的 LCS 长度
*/
function buildLcsMatrix (a, b) {
const m = a.length
const n = b.length
const matrix = new Array(m + 1)
for (let i = 0; i <= m; i++) matrix[i] = new Array(n + 1).fill(0)
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
if (a[i] === b[j]) {
matrix[i][j] = matrix[i + 1][j + 1] + 1
} else {
matrix[i][j] = Math.max(matrix[i + 1][j], matrix[i][j + 1])
}
}
}
return matrix
}
/**
* 行级 diff:返回操作序列
* @param {Array} leftLines 左侧行数组
* @param {Array} rightLines 右侧行数组
* @returns {Array} ops,形如 {type:'equal'|'delete'|'insert'|'replace', left, right, leftIndex, rightIndex}
*/
export function diffLines (leftLines, rightLines) {
const m = leftLines.length
const n = rightLines.length
// 空文本直接短路
if (m === 0 && n === 0) return []
if (m === 0) {
return rightLines.map((text, i) => ({ type: 'insert', left: null, right: text, leftIndex: -1, rightIndex: i }))
}
if (n === 0) {
return leftLines.map((text, i) => ({ type: 'delete', left: text, right: null, leftIndex: i, rightIndex: -1 }))
}
// 规模过大时按哈希锚点切段,每段单独做 LCS,避免构建超大矩阵
if (m + n > HASH_BUCKET_THRESHOLD || m * n > MATRIX_CELL_LIMIT) {
return diffByAnchors(leftLines, rightLines)
}
// 先按 LCS 锚点把序列切成「相等段 / 变更块」,再在变更块内部做配对
return buildOpsFromMatrix(leftLines, rightLines)
}
/**
* 先剥离公共前后缀,再对中间变更区求 LCS 锚点并切成若干变更块,
* 每个块内部再配对成 replace。这一步是保证「连续多行全变」不被错配的关键:
* 若直接按 LCS 回溯顺序流式输出删除,删除会全部堆到插入之前,配对必然错位。
* @param {Array} leftLines
* @param {Array} rightLines
* @returns {Array} ops
*/
function buildOpsFromMatrix (leftLines, rightLines) {
// 先剥离公共前缀与公共后缀:这两部分在任何 tie-break 策略下都是稳定锚点,
// 剥离后中间才是真正的变更区,避免「删除优先」的回溯造成左右方向不对称
let head = 0
const minLen = Math.min(leftLines.length, rightLines.length)
while (head < minLen && leftLines[head] === rightLines[head]) head++
let tail = 0
while (
tail < minLen - head
&& leftLines[leftLines.length - 1 - tail] === rightLines[rightLines.length - 1 - tail]
) tail++
const ops = []
// 公共前缀
for (let k = 0; k < head; k++) {
ops.push({
type: 'equal',
left: leftLines[k],
right: rightLines[k],
leftIndex: k,
rightIndex: k
})
}
// 中间变更区:用 LCS 找出剩余的等值锚点
const midL = leftLines.slice(head, leftLines.length - tail)
const midR = rightLines.slice(head, rightLines.length - tail)
const midMatrix = buildLcsMatrix(midL, midR)
const anchors = []
let i = 0
let j = 0
while (i < midL.length && j < midR.length) {
if (midL[i] === midR[j]) {
anchors.push({ i: i, j: j })
i++
j++
} else if (midMatrix[i + 1][j] >= midMatrix[i][j + 1]) {
i++
} else {
j++
}
}
let li = 0
let ri = 0
anchors.forEach(anchor => {
if (anchor.i > li || anchor.j > ri) {
// 传入的是 midL/midR 切片段,左右两侧都要补上「公共前缀长度 head」的偏移
ops.push.apply(ops, pairBlock(midL, midR, li, anchor.i, ri, anchor.j, head, head))
}
ops.push({
type: 'equal',
left: midL[anchor.i],
right: midR[anchor.j],
leftIndex: head + anchor.i,
rightIndex: head + anchor.j
})
li = anchor.i + 1
ri = anchor.j + 1
})
if (li < midL.length || ri < midR.length) {
ops.push.apply(ops, pairBlock(midL, midR, li, midL.length, ri, midR.length, head, head))
}
// 公共后缀
for (let k = 0; k < tail; k++) {
const li2 = leftLines.length - tail + k
const ri2 = rightLines.length - tail + k
ops.push({
type: 'equal',
left: leftLines[li2],
right: rightLines[ri2],
leftIndex: li2,
rightIndex: ri2
})
}
return ops
}
/**
* 对一个变更块内的删除行与新增行做贪心配对
* @param {Array} leftLines 左侧完整数组
* @param {Array} rightLines 右侧完整数组
* @param {Number} li 块在左数组中的起始下标
* @param {Number} lEnd 块在左数组中的结束下标(含)
* @param {Number} ri 块在右数组中的起始下标
* @param {Number} rEnd 块在右数组中的结束下标(含)
* @param {Number} baseLeft 左侧块数组下标的补偿偏移
* @param {Number} baseRight 右侧块数组下标的补偿偏移
* @returns {Array} ops
*/
function pairBlock (leftLines, rightLines, li, lEnd, ri, rEnd, baseLeft, baseRight) {
const leftBlock = leftLines.slice(li, lEnd)
const rightBlock = rightLines.slice(ri, rEnd)
// 块内下标 → 原始下标。两侧补偿可能不同(公共前后缀长度不一致)
const lToOrig = k => (baseLeft || 0) + li + k
const rToOrig = k => (baseRight || 0) + ri + k
const ops = []
if (leftBlock.length === 0) {
rightBlock.forEach((text, k) => {
ops.push({ type: 'insert', left: null, right: text, leftIndex: -1, rightIndex: rToOrig(k) })
})
return ops
}
if (rightBlock.length === 0) {
leftBlock.forEach((text, k) => {
ops.push({ type: 'delete', left: text, right: null, leftIndex: lToOrig(k), rightIndex: -1 })
})
return ops
}
// 块内两侧长度不等时,长出的部分是纯新增/删除,短的部分按位置对齐配对。
// 位置对齐是主策略:像 ["a"]→["A"] 这种整体重写,字符相似度为 0,
// 若用相似度卡阈值会导致该行被判成「删除+新增」而非「修改」,与直觉不符。
const offset = ri - li
const pairs = []
const usedRight = new Array(rightBlock.length).fill(false)
const usedLeft = new Array(leftBlock.length).fill(false)
// 第一轮:位置对齐(leftIndex - li 与 rightIndex - ri 相同)直接配对
for (let x = 0; x < leftBlock.length; x++) {
const y = x + offset
if (y >= 0 && y < rightBlock.length && !usedRight[y]) {
usedLeft[x] = true
usedRight[y] = true
pairs.push({ x: x, y: y, score: similarity(leftBlock[x], rightBlock[y]) })
}
}
// 第二轮:剩余行用相似度择优配对,仅接受达到阈值的(避免无关行硬凑成修改)
const rest = []
for (let x = 0; x < leftBlock.length; x++) {
if (usedLeft[x]) continue
for (let y = 0; y < rightBlock.length; y++) {
if (usedRight[y]) continue
const score = similarity(leftBlock[x], rightBlock[y])
if (score >= SIMILARITY_THRESHOLD) rest.push({ x: x, y: y, score: score })
}
}
rest.sort((p, q) => q.score - p.score)
rest.forEach(c => {
if (!usedLeft[c.x] && !usedRight[c.y]) {
usedLeft[c.x] = true
usedRight[c.y] = true
pairs.push(c)
}
})
pairs.sort((p, q) => p.x - q.x)
let x = 0
let y = 0
pairs.forEach(pair => {
while (x < pair.x) {
ops.push({ type: 'delete', left: leftBlock[x], right: null, leftIndex: lToOrig(x), rightIndex: -1 })
x++
}
while (y < pair.y) {
ops.push({ type: 'insert', left: null, right: rightBlock[y], rightIndex: rToOrig(y), leftIndex: -1 })
y++
}
ops.push({
type: 'replace',
left: leftBlock[pair.x],
right: rightBlock[pair.y],
leftIndex: lToOrig(pair.x),
rightIndex: rToOrig(pair.y)
})
x = pair.x + 1
y = pair.y + 1
})
while (x < leftBlock.length) {
ops.push({ type: 'delete', left: leftBlock[x], right: null, leftIndex: lToOrig(x), rightIndex: -1 })
x++
}
while (y < rightBlock.length) {
ops.push({ type: 'insert', left: null, right: rightBlock[y], leftIndex: -1, rightIndex: rToOrig(y) })
y++
}
return ops
}
// 相似度达到该阈值才认定为「同一行的修改」,低于则视为无关的新增/删除。
// 0.34 而非 0.5:中文按字符算相似度偏低,改动几个词时容易掉到 0.5 以下被误判成删+增
const SIMILARITY_THRESHOLD = 0.34
/**
* 两行的相似度(0~1):基于共有字符比例的快速估算
* @param {String} a
* @param {String} b
* @returns {Number}
*/
function similarity (a, b) {
if (a === b) return 1
const len = Math.max(a.length, b.length)
if (len === 0) return 1
const short = a.length < b.length ? a : b
const long = a.length < b.length ? b : a
let hit = 0
const pool = long.split('')
for (let k = 0; k < short.length; k++) {
const idx = pool.indexOf(short[k])
if (idx > -1) {
hit++
pool.splice(idx, 1)
}
}
return (hit * 2) / (a.length + b.length)
}
/**
* 大文本降级策略:以「左右都出现且上下位置单调递增的相同行为锚点」切段,
* 锚点之间的小块各自做 LCS。效果接近完整 LCS,但矩阵规模被限制在单段内。
* @param {Array} leftLines
* @param {Array} rightLines
* @returns {Array}
*/
function diffByAnchors (leftLines, rightLines) {
const rightPos = new Map()
rightLines.forEach((text, idx) => {
if (!rightPos.has(text)) rightPos.set(text, [])
rightPos.get(text).push(idx)
})
// 单调匹配出锚点序列:LCS of (left lines, unique right lines)
const anchorCand = []
const cursor = new Map()
for (let i = 0; i < leftLines.length; i++) {
const text = leftLines[i]
const positions = rightPos.get(text)
if (!positions) continue
const used = cursor.get(text) || 0
if (used < positions.length) {
cursor.set(text, used + 1)
anchorCand.push({ leftIndex: i, rightIndex: positions[used] })
}
}
// 保证锚点本身单调递增,剔除交叉的候选
const anchors = []
let lastRight = -1
for (const cand of anchorCand) {
if (cand.rightIndex > lastRight) {
anchors.push(cand)
lastRight = cand.rightIndex
}
}
const ops = []
let li = 0
let ri = 0
anchors.forEach(anchor => {
const segL = leftLines.slice(li, anchor.leftIndex)
const segR = rightLines.slice(ri, anchor.rightIndex)
if (segL.length || segR.length) {
diffLines(segL, segR).forEach(op => {
ops.push({
type: op.type,
left: op.left,
right: op.right,
leftIndex: op.leftIndex >= 0 ? li + op.leftIndex : -1,
rightIndex: op.rightIndex >= 0 ? ri + op.rightIndex : -1
})
})
}
ops.push({
type: 'equal',
left: leftLines[anchor.leftIndex],
right: rightLines[anchor.rightIndex],
leftIndex: anchor.leftIndex,
rightIndex: anchor.rightIndex
})
li = anchor.leftIndex + 1
ri = anchor.rightIndex + 1
})
const tailL = leftLines.slice(li)
const tailR = rightLines.slice(ri)
if (tailL.length || tailR.length) {
diffLines(tailL, tailR).forEach(op => {
ops.push({
type: op.type,
left: op.left,
right: op.right,
leftIndex: op.leftIndex >= 0 ? li + op.leftIndex : -1,
rightIndex: op.rightIndex >= 0 ? ri + op.rightIndex : -1
})
})
}
return mergeAdjacentOps(ops)
}
/**
* 把紧邻的 delete + insert 合并成 replace,让「修改行」能左右并排显示
* @param {Array} ops
* @returns {Array}
*/
function mergeAdjacentOps (ops) {
const merged = []
let i = 0
while (i < ops.length) {
const cur = ops[i]
const next = ops[i + 1]
if (cur && next && cur.type === 'delete' && next.type === 'insert') {
// 连续的删除/插入块两两配对,配对后剩余的保持原类型
const delRun = []
const insRun = []
let k = i
while (k < ops.length && ops[k].type === 'delete') { delRun.push(ops[k]); k++ }
let k2 = k
while (k2 < ops.length && ops[k2].type === 'insert') { insRun.push(ops[k2]); k2++ }
const pairs = Math.min(delRun.length, insRun.length)
for (let p = 0; p < pairs; p++) {
merged.push({
type: 'replace',
left: delRun[p].left,
right: insRun[p].right,
leftIndex: delRun[p].leftIndex,
rightIndex: insRun[p].rightIndex
})
}
for (let p = pairs; p < delRun.length; p++) merged.push(delRun[p])
for (let p = pairs; p < insRun.length; p++) merged.push(insRun[p])
i = k2
continue
}
if (cur) merged.push(cur)
i++
}
return merged
}
/**
* 字符级 diff:把两个字符串切成片段数组,标记哪些片段变了
* @param {String} a 旧串
* @param {String} b 新串
* @returns {{left:Array, right:Array}} 两边片段数组,元素形如 {text, changed}
*/
export function diffChars (a, b) {
if (a === b) {
return { left: [{ text: a, changed: false }], right: [{ text: b, changed: false }] }
}
// 字符数过大时不做字符级细化,只整体标记,避免 O(n*m) 卡顿
if (a.length > 1200 || b.length > 1200) {
return {
left: [{ text: a, changed: true }],
right: [{ text: b, changed: true }]
}
}
const aArr = a.split('')
const bArr = b.split('')
const matrix = buildLcsMatrix(aArr, bArr)
const leftParts = []
const rightParts = []
let i = 0
let j = 0
while (i < aArr.length && j < bArr.length) {
if (aArr[i] === bArr[j]) {
pushPart(leftParts, aArr[i], false)
pushPart(rightParts, bArr[j], false)
i++
j++
} else if (matrix[i + 1][j] >= matrix[i][j + 1]) {
pushPart(leftParts, aArr[i], true)
i++
} else {
pushPart(rightParts, bArr[j], true)
j++
}
}
while (i < aArr.length) { pushPart(leftParts, aArr[i], true); i++ }
while (j < bArr.length) { pushPart(rightParts, bArr[j], true); j++ }
return { left: leftParts, right: rightParts }
}
/**
* 相邻同标记片段合并,减少 DOM 节点
*/
function pushPart (parts, text, changed) {
const last = parts[parts.length - 1]
if (last && last.changed === changed) {
last.text += text
} else {
parts.push({ text: text, changed: changed })
}
}
/**
* 对外主入口:比对两段文本,产出可直接渲染的行级结果
* @param {String} leftText 左侧(原始)文本
* @param {String} rightText 右侧(新)文本
* @param {Object} options
* @param {Boolean} options.ignoreWhitespace 是否忽略空白差异
* @returns {{rows:Array, stats:Object}}
*/
export function diffText (leftText, rightText, options) {
const ignoreWhitespace = !!(options && options.ignoreWhitespace)
const leftLines = splitLines(leftText)
const rightLines = splitLines(rightText)
// 参与 LCS 的是规范化后的行,展示时仍用原始文本
const leftKeys = leftLines.map(line => normalizeLine(line, ignoreWhitespace))
const rightKeys = rightLines.map(line => normalizeLine(line, ignoreWhitespace))
// 忽略空白模式下,纯空白行两侧都不参与比较:
// 否则「左侧多一个空行」会被 LCS 判成删除,勾选了忽略空白却仍报差异,违背预期
let filteredLeft = leftKeys
let filteredRight = rightKeys
if (ignoreWhitespace) {
filteredLeft = leftKeys.filter(key => key !== '')
filteredRight = rightKeys.filter(key => key !== '')
}
const ops = diffLines(filteredLeft, filteredRight)
// 操作里的下标指向过滤后的数组,需要映射回原始行号供展示
const leftNoMap = buildNoMap(leftKeys, ignoreWhitespace)
const rightNoMap = buildNoMap(rightKeys, ignoreWhitespace)
const stats = { added: 0, removed: 0, changed: 0, unchanged: 0 }
const rows = ops.map(op => {
if (op.type === 'equal') {
stats.unchanged++
return {
type: 'equal',
leftNo: leftNoMap[op.leftIndex],
rightNo: rightNoMap[op.rightIndex],
leftParts: [{ text: leftLines[leftNoMap[op.leftIndex] - 1], changed: false }],
rightParts: [{ text: rightLines[rightNoMap[op.rightIndex] - 1], changed: false }]
}
}
if (op.type === 'delete') {
stats.removed++
const no = leftNoMap[op.leftIndex]
return {
type: 'delete',
leftNo: no,
rightNo: -1,
leftParts: [{ text: leftLines[no - 1], changed: false }],
rightParts: null
}
}
if (op.type === 'insert') {
stats.added++
const no = rightNoMap[op.rightIndex]
return {
type: 'insert',
leftNo: -1,
rightNo: no,
leftParts: null,
rightParts: [{ text: rightLines[no - 1], changed: false }]
}
}
// replace:细化到字符级
stats.changed++
const leftNo = leftNoMap[op.leftIndex]
const rightNo = rightNoMap[op.rightIndex]
const leftTextLine = leftLines[leftNo - 1]
const rightTextLine = rightLines[rightNo - 1]
const parts = diffChars(leftTextLine, rightTextLine)
return {
type: 'replace',
leftNo: leftNo,
rightNo: rightNo,
leftParts: parts.left,
rightParts: parts.right
}
})
return { rows: rows, stats: stats }
}
/**
* 生成「过滤后下标 → 原始行号(1 起)」的映射,
* 忽略空白时空白行被剔除,展示行号仍需对应用户输入的真实位置
* @param {Array} keys 规范化后的全部行
* @param {Boolean} ignoreWhitespace
* @returns {Array}
*/
function buildNoMap (keys, ignoreWhitespace) {
if (!ignoreWhitespace) {
return keys.map((line, idx) => idx + 1)
}
const map = []
keys.forEach((key, idx) => {
if (key !== '') map.push(idx + 1)
})
return map
}
/**
* 文本转行数组,保留空行(末尾换行不额外产生空行)
* @param {String} text
* @returns {Array}
*/
export function splitLines (text) {
if (text === '' || text === null || text === undefined) return []
const normalized = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.split('\n')
// 末尾换行符是行终止符,不算一条空行
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
return lines
}
/**
* 导出为 unified diff 文本,便于粘到终端或提交信息里
* @param {Array} rows
* @param {String} leftLabel
* @param {String} rightLabel
* @returns {String}
*/
export function toUnifiedDiff (rows, leftLabel, rightLabel) {
const lines = ['--- ' + (leftLabel || '左侧'), '+++ ' + (rightLabel || '右侧')]
// 先把 rows 拍平成标准 diff 行序列,同时累计左右各自的行号游标
const seq = []
rows.forEach(row => {
if (row.type === 'equal') {
seq.push({ sign: ' ', text: plainText(row.leftParts), lNo: row.leftNo, rNo: row.rightNo })
} else if (row.type === 'delete') {
seq.push({ sign: '-', text: plainText(row.leftParts), lNo: row.leftNo, rNo: -1 })
} else if (row.type === 'insert') {
seq.push({ sign: '+', text: plainText(row.rightParts), lNo: -1, rNo: row.rightNo })
} else {
seq.push({ sign: '-', text: plainText(row.leftParts), lNo: row.leftNo, rNo: -1 })
seq.push({ sign: '+', text: plainText(row.rightParts), lNo: -1, rNo: row.rightNo })
}
})
const CONTEXT = 3
// 找出所有变更行的下标;相邻(含 CONTEXT 间隔内)的变更合并为一个 hunk
const changeIdx = []
seq.forEach((item, idx) => {
if (item.sign !== ' ') changeIdx.push(idx)
})
if (changeIdx.length === 0) {
return lines.join('\n')
}
const groups = []
let start = Math.max(0, changeIdx[0] - CONTEXT)
let end = Math.min(seq.length - 1, changeIdx[0] + CONTEXT)
for (let k = 1; k < changeIdx.length; k++) {
const idx = changeIdx[k]
if (idx - end <= CONTEXT * 2) {
end = Math.min(seq.length - 1, idx + CONTEXT)
} else {
groups.push([start, end])
start = Math.max(0, idx - CONTEXT)
end = Math.min(seq.length - 1, idx + CONTEXT)
}
}
groups.push([start, end])
groups.forEach(([from, to]) => {
const slice = seq.slice(from, to + 1)
// hunk 的起始行号取该分区内第一条带行号的记录
let lStart = 0
let rStart = 0
slice.forEach(item => {
if (lStart === 0 && item.lNo > 0) lStart = item.lNo
if (rStart === 0 && item.rNo > 0) rStart = item.rNo
})
const lCount = slice.filter(i => i.sign !== '+').length
const rCount = slice.filter(i => i.sign !== '-').length
lines.push('@@ -' + lStart + ',' + lCount + ' +' + rStart + ',' + rCount + ' @@')
slice.forEach(item => {
lines.push(item.sign + item.text)
})
})
return lines.join('\n')
}
function plainText (parts) {
if (!parts) return ''
return parts.map(p => p.text).join('')
}
+608
View File
@@ -0,0 +1,608 @@
<template>
<div class="text-diff-page">
<!-- 操作栏 start -->
<div class="diff-toolbar">
<Button type="primary" icon="md-git-compare" :loading="comparing" @click="doCompare()">比对</Button>
<Checkbox v-model="ignoreWhitespace">忽略空白</Checkbox>
<Checkbox v-model="onlyChanges">只看差异</Checkbox>
<div class="toolbar-gap"></div>
<Button icon="md-swap" :disabled="comparing" @click="swapSides()">交换</Button>
<Button icon="md-trash" :disabled="comparing" @click="clearAll()">清空</Button>
<Button icon="md-flask" :disabled="comparing" @click="fillSample()">示例</Button>
<Button icon="md-download" :disabled="!hasResult" @click="exportHtml()">导出</Button>
</div>
<!-- 操作栏 end -->
<!-- 统计条 start -->
<div class="diff-stats" v-if="hasResult">
<span class="stat-item">
<span class="stat-dot added-dot"></span>新增 <b class="stat-num added-text">{{ stats.added }}</b> 行
</span>
<span class="stat-item">
<span class="stat-dot removed-dot"></span>删除 <b class="stat-num removed-text">{{ stats.removed }}</b> 行
</span>
<span class="stat-item">
<span class="stat-dot changed-dot"></span>修改 <b class="stat-num changed-text">{{ stats.changed }}</b> 行
</span>
<span class="stat-item muted">相同 {{ stats.unchanged }} 行</span>
<div class="toolbar-gap"></div>
<span class="stat-item muted" v-if="elapsed">耗时 {{ elapsed }} ms</span>
<template v-if="changeRows.length > 0">
<div class="toolbar-gap"></div>
<span class="stat-item nav-group">
<Button size="small" icon="md-arrow-up" :disabled="navIndex <= 0" @click="gotoChange(-1)"></Button>
<span class="nav-text">{{ navIndex + 1 }} / {{ changeRows.length }}</span>
<Button size="small" icon="md-arrow-down" :disabled="navIndex >= changeRows.length - 1" @click="gotoChange(1)"></Button>
</span>
</template>
<div class="toolbar-gap"></div>
<Tag v-if="hasDiff" type="warning">存在差异</Tag>
<Tag v-else type="success">完全一致</Tag>
</div>
<!-- 统计条 end -->
<!-- 输入区 start -->
<div class="diff-input-area" v-if="!hasResult">
<div class="input-panel">
<div class="input-head">
<span class="input-title">原始内容</span>
<span class="input-meta">{{ leftLineCount }} 行</span>
</div>
<Input type="textarea"
v-model="leftText"
class="diff-textarea"
placeholder="粘贴原始内容,或把 .txt 文件拖到这里"
:rows="12"
@on-drop.prevent="onDropLeft"
@dragover.prevent
@paste="onPaste"/>
</div>
<div class="input-panel">
<div class="input-head">
<span class="input-title">对比内容</span>
<span class="input-meta">{{ rightLineCount }} 行</span>
</div>
<Input type="textarea"
v-model="rightText"
class="diff-textarea"
placeholder="粘贴对比内容,或把 .txt 文件拖到这里"
:rows="12"
@on-drop.prevent="onDropRight"
@dragover.prevent/>
</div>
</div>
<!-- 输入区 end -->
<!-- 结果区 start -->
<div class="diff-result-area" v-if="hasResult">
<div class="result-toolbar">
<Button size="small" icon="md-create" @click="backToEdit()">重新编辑</Button>
<span class="result-hint">左侧为原始内容,右侧为对比内容</span>
</div>
<div class="diff-table-wrap">
<div class="diff-table">
<div class="diff-row diff-head-row">
<div class="cell-no">行号</div>
<div class="cell-no">行号</div>
<div class="cell-mark"></div>
<div class="cell-text">原始内容</div>
<div class="cell-text">对比内容</div>
</div>
<div v-for="(row, idx) in visibleRows"
:key="idx"
class="diff-row"
:class="[rowClass(row), { active: activeRowIndex === idx }]"
:ref="'row' + idx">
<div class="cell-no">{{ row.leftNo > 0 ? row.leftNo : '' }}</div>
<div class="cell-no">{{ row.rightNo > 0 ? row.rightNo : '' }}</div>
<div class="cell-mark"></div>
<div class="cell-text">
<template v-if="row.leftParts">
<span v-for="(part, k) in row.leftParts"
:key="'l' + k"
:class="{ 'char-changed': part.changed }">{{ part.text }}</span>
</template>
<span v-else class="placeholder-text">—</span>
</div>
<div class="cell-text">
<template v-if="row.rightParts">
<span v-for="(part, k) in row.rightParts"
:key="'r' + k"
:class="{ 'char-changed': part.changed }">{{ part.text }}</span>
</template>
<span v-else class="placeholder-text">—</span>
</div>
</div>
<div class="diff-row empty-row" v-if="visibleRows.length === 0">
<div class="empty-tip">没有可显示的差异行</div>
</div>
</div>
</div>
</div>
<!-- 结果区 end -->
</div>
</template>
<script>
import { diffText, splitLines } from '@/libs/diff'
const LARGE_TEXT_LINES = 3000
export default {
name: 'tools-text-diff',
data () {
return {
leftText: '',
rightText: '',
rows: [],
stats: { added: 0, removed: 0, changed: 0, unchanged: 0 },
ignoreWhitespace: false,
onlyChanges: false,
comparing: false,
hasResult: false,
elapsed: 0,
navIndex: -1,
activeRowIndex: -1
}
},
computed: {
leftLineCount () {
return splitLines(this.leftText).length
},
rightLineCount () {
return splitLines(this.rightText).length
},
changeRows () {
return this.rows.filter(row => row.type !== 'equal')
},
hasDiff () {
return this.stats.added + this.stats.removed + this.stats.changed > 0
},
visibleRows () {
if (!this.onlyChanges) return this.rows
// 「只看差异」时保留变更行,并在每段变更前后各带 2 行上下文便于阅读
const CONTEXT = 2
const keep = new Set()
this.rows.forEach((row, idx) => {
if (row.type !== 'equal') {
for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(this.rows.length - 1, idx + CONTEXT); k++) {
keep.add(k)
}
}
})
return this.rows.filter((row, idx) => keep.has(idx))
}
},
methods: {
doCompare () {
if (this.comparing) return
const totalLines = this.leftLineCount + this.rightLineCount
if (totalLines > LARGE_TEXT_LINES) {
this.$Modal.warning({
title: '文本较大',
content: `两侧合计约 ${totalLines} 行,比对可能需要一点时间,确认继续吗?`,
okText: '继续比对',
cancelText: '取消',
onOk: () => this.runCompare()
})
return
}
this.runCompare()
},
runCompare () {
this.comparing = true
// 让 loading 动画有机会渲染出来,再执行计算
this.$nextTick(() => {
window.setTimeout(() => {
const start = new Date().getTime()
const result = diffText(this.leftText, this.rightText, {
ignoreWhitespace: this.ignoreWhitespace
})
this.rows = result.rows
this.stats = result.stats
this.elapsed = new Date().getTime() - start
this.hasResult = true
this.comparing = false
this.navIndex = -1
this.activeRowIndex = -1
}, 30)
})
},
backToEdit () {
this.hasResult = false
this.rows = []
this.activeRowIndex = -1
},
clearAll () {
this.leftText = ''
this.rightText = ''
this.backToEdit()
},
swapSides () {
const tmp = this.leftText
this.leftText = this.rightText
this.rightText = tmp
// 已出结果时交换后立即重算,统计与内容保持一致
if (this.hasResult) {
this.runCompare()
}
},
fillSample () {
this.backToEdit()
this.leftText = [
'package com.freedom.admin.service.impl;',
'',
'public class UserServiceImpl implements IUserService {',
'',
' @Autowired',
' private UserMapper userMapper;',
'',
' @Override',
' public Page<User> findPageList(UserQuery query) {',
' Page<User> page = new Page<>(query.getPageNum(), query.getPageSize());',
' return userMapper.selectPage(page, query);',
' }',
'',
' @Override',
' public void remove(Integer id) {',
' userMapper.deleteById(id);',
' }',
'}'
].join('\n')
this.rightText = [
'package com.freedom.admin.service.impl;',
'',
'public class UserServiceImpl implements IUserService {',
'',
' @Autowired',
' private UserMapper userMapper;',
'',
' // 分页查询改为按创建时间倒序',
' @Override',
' public Page<User> findPageList(UserQuery query) {',
' Page<User> page = new Page<>(query.getPageNum(), query.getPageSize());',
' return userMapper.selectPage(page, query, false);',
' }',
'',
' @Override',
' public void remove(Integer id, Integer operatorId) {',
' userMapper.deleteById(id);',
' // 记录操作日志',
' }',
'}'
].join('\n')
},
onPaste (event) {
this.readFileFromEvent(event, 'left')
},
onDropLeft (event) {
this.readFileFromEvent(event, 'left')
},
onDropRight (event) {
this.readFileFromEvent(event, 'right')
},
readFileFromEvent (event, side) {
const dt = event.dataTransfer
if (!dt || !dt.files || dt.files.length === 0) return
const file = dt.files[0]
const reader = new FileReader()
reader.onload = e => {
if (side === 'left') {
this.leftText = e.target.result
} else {
this.rightText = e.target.result
}
this.$Message.success('已读取文件:' + file.name)
}
reader.readAsText(file, 'UTF-8')
},
rowClass (row) {
if (row.type === 'insert') return 'row-insert'
if (row.type === 'delete') return 'row-delete'
if (row.type === 'replace') return 'row-replace'
return 'row-equal'
},
gotoChange (step) {
const next = this.navIndex + step
if (next < 0 || next >= this.changeRows.length) return
this.navIndex = next
// 找到该变更行在可见列表中的位置
const targetRow = this.changeRows[next]
const index = this.visibleRows.indexOf(targetRow)
if (index > -1) {
this.activeRowIndex = index
this.$nextTick(() => {
const el = this.$el.querySelector('[ref="row' + index + '"]')
if (el && el.scrollIntoView) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}
},
exportHtml () {
const rows = this.rows
let body = ''
rows.forEach(row => {
const cls = this.rowClass(row)
const left = row.leftParts ? row.leftParts.map(p => escapeHtml(p.text)).join('') : ''
const right = row.rightParts ? row.rightParts.map(p => escapeHtml(p.text)).join('') : ''
body += '<tr class="' + cls + '">'
body += '<td class="no">' + (row.leftNo > 0 ? row.leftNo : '') + '</td>'
body += '<td class="no">' + (row.rightNo > 0 ? row.rightNo : '') + '</td>'
body += '<td class="txt">' + left + '</td>'
body += '<td class="txt">' + right + '</td>'
body += '</tr>'
})
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
+ '<title>文本比对结果</title><style>'
+ 'body{font-family:Consolas,Monaco,monospace;font-size:13px;margin:16px;color:#17233d}'
+ '.bar{padding:10px 12px;background:#f0f3fa;border-radius:6px;margin-bottom:12px}'
+ 'table{border-collapse:collapse;width:100%;table-layout:fixed}'
+ 'td{border:1px solid #e8eaec;padding:2px 6px;vertical-align:top;white-space:pre-wrap;word-break:break-all}'
+ 'td.no{width:48px;background:#f8f8f9;color:#808695;text-align:right;user-select:none}'
+ 'td.txt{width:40%}'
+ 'tr.row-insert td{background:#e8f8ee}'
+ 'tr.row-delete td{background:#fdecec}'
+ 'tr.row-replace td{background:#fff8e1}'
+ '</style></head><body>'
+ '<div class="bar">新增 ' + this.stats.added + ' 行 | 删除 ' + this.stats.removed
+ ' 行 | 修改 ' + this.stats.changed + ' 行 | 相同 ' + this.stats.unchanged + ' 行</div>'
+ '<table><tr><td class="no">行</td><td class="no">行</td><td class="txt">原始内容</td>'
+ '<td class="txt">对比内容</td></tr>' + body + '</table></body></html>'
this.downloadFile('text-diff-result.html', html, 'text/html')
},
downloadFile (name, content, mime) {
const blob = new Blob([content], { type: mime + ';charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
}
function escapeHtml (str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
</script>
<style lang="less" scoped>
.text-diff-page {
height: 100%;
display: flex;
flex-direction: column;
}
.diff-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid #ddd;
}
.diff-toolbar .ivu-checkbox-wrapper {
margin-right: 16px;
}
.toolbar-gap {
flex: 1;
}
.diff-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 10px 0;
font-size: 13px;
color: #515a6e;
}
.stat-item {
display: inline-flex;
align-items: center;
margin-right: 18px;
}
.stat-item.muted {
color: #808695;
}
.stat-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 6px;
}
.added-dot { background: #19be6b; }
.removed-dot { background: #ed4014; }
.changed-dot { background: #ff9900; }
.stat-num {
margin: 0 4px 0 2px;
font-size: 14px;
}
.added-text { color: #19be6b; }
.removed-text { color: #ed4014; }
.changed-text { color: #ff9900; }
.nav-group .ivu-btn {
margin: 0 4px;
}
.nav-text {
min-width: 54px;
text-align: center;
font-size: 13px;
}
.diff-input-area {
flex: 1;
display: flex;
gap: 16px;
padding-top: 12px;
min-height: 0;
}
.input-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.input-head {
display: flex;
align-items: baseline;
padding-bottom: 6px;
}
.input-title {
font-size: 14px;
color: #17233d;
font-weight: 600;
}
.input-meta {
margin-left: 8px;
font-size: 12px;
color: #808695;
}
.diff-textarea {
flex: 1;
}
.diff-textarea /deep/ textarea {
height: 100%;
min-height: 320px;
resize: none;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.6;
}
.diff-result-area {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 12px;
min-height: 0;
}
.result-toolbar {
display: flex;
align-items: center;
padding-bottom: 8px;
}
.result-hint {
margin-left: 12px;
font-size: 12px;
color: #808695;
}
.diff-table-wrap {
flex: 1;
overflow: auto;
border: 1px solid #e8eaec;
border-radius: 4px;
min-height: 0;
}
.diff-table {
min-width: 100%;
}
.diff-row {
display: flex;
align-items: stretch;
border-bottom: 1px solid #f0f2f5;
}
.diff-row:last-child {
border-bottom: none;
}
.diff-head-row {
position: sticky;
top: 0;
z-index: 2;
background: #f8f8f9;
font-weight: 600;
color: #17233d;
}
.cell-no {
flex: 0 0 52px;
padding: 3px 8px;
text-align: right;
color: #808695;
background: #f8f8f9;
border-right: 1px solid #e8eaec;
font-size: 12px;
user-select: none;
white-space: nowrap;
}
.cell-mark {
flex: 0 0 4px;
background: transparent;
}
.cell-text {
flex: 1 1 50%;
min-width: 0;
padding: 3px 8px;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
border-right: 1px solid #f0f2f5;
}
.diff-row.row-insert .cell-mark { background: #19be6b; }
.diff-row.row-delete .cell-mark { background: #ed4014; }
.diff-row.row-replace .cell-mark { background: #ff9900; }
.diff-row.row-insert .cell-text:last-child { background: #e8f8ee; }
.diff-row.row-delete .cell-text:first-of-type { background: #fdecec; }
.diff-row.row-replace .cell-text { background: #fff8e1; }
.diff-row.active .cell-text {
box-shadow: inset 0 0 0 2px rgba(79, 110, 247, 0.45);
}
.char-changed {
background: #e6a23c;
color: #fff;
border-radius: 2px;
padding: 0 1px;
}
.placeholder-text {
color: #c5c8ce;
}
.empty-row {
padding: 24px 0;
}
.empty-tip {
flex: 1;
text-align: center;
color: #808695;
}
</style>