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
+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>