- src/libs/diff.js:零依赖 diff 算法 - 行级 LCS + 变更块内位置对齐配对,避免连续多行全变被错配成删+增 - 先剥离公共前后缀再求中间变更区,消除「删除优先」回溯造成的左右不对称 - 字符级细化,仅对 replace 行标注变化的字符片段 - 大文本(超阈值)按锚点分段降级,避免构建超大 LCS 矩阵 - toUnifiedDiff 导出,标准 3 行上下文分组 - src/view/tools/text-diff.vue:比对页面 - 双栏输入,支持拖入 .txt 文件 - 行级差异配色(新增绿/删除红/修改黄)+ 字符级高亮 - 差异统计、上一处/下一处导航、只看差异(含上下文) - 交换左右、清空、示例数据、导出带色 HTML
This commit is contained in:
@@ -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('')
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
</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>
|
||||||
Reference in New Issue
Block a user