style(tools): 优化文本比对页布局并修复删除行着色错位
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 1m27s

布局优化:
- 操作栏改为「主操作 + 选项组 + 次要操作」三段式,
  勾选项与操作按钮视觉分层,不再混排成一条
- 统计条并入浅色卡片,用 gap 替代多个 flex:1 空白撑开块
- 差异导航加「定位」标签,序号改 tabular-nums 防跳动
- 「重新编辑/导出」并入统计条右侧,释放一行纵向空间
- 表头「行号 行号」改为「原 新」,左右分栏各加竖分隔线
- 输入区行数改为胶囊标签,标题与行数两端对齐

修复:
- 删除行红底打错列:原 :first-of-type 因前置 .cell-no 兄弟而选不中,
  改为显式 col-left / col-right 类名,新增/删除/修改各自单侧或双侧着色
- 只看差异在无结果时禁用(此前点了无反应),清空时重置该状态
- 导出 HTML 的着色规则与页面保持一致,并支持字符级 <mark> 高亮

交互:
- 新增 Ctrl/Cmd+Enter 触发比对、Esc 返回编辑
- 键盘监听仅在 activated 注册,避免 keep-alive 下重复绑定导致按键触发多次
This commit is contained in:
zhangjiaxun
2026-10-20 22:46:54 +08:00
parent 5831c3bdb5
commit 84b6933657
+196 -78
View File
@@ -2,42 +2,68 @@
<div class="text-diff-page">
<!-- 操作栏 start -->
<div class="diff-toolbar">
<Button type="primary" icon="md-git-compare" :loading="comparing" @click="doCompare()">比对</Button>
<!-- 主操作 -->
<Tooltip content="开始比对(Ctrl + Enter)" transfer>
<Button type="primary" icon="md-git-compare" :loading="comparing" @click="doCompare()">开始比对</Button>
</Tooltip>
<!-- 视图选项:与操作按钮视觉分层 -->
<div class="toolbar-group">
<Checkbox v-model="ignoreWhitespace">忽略空白</Checkbox>
<Checkbox v-model="onlyChanges">只看差异</Checkbox>
<Checkbox v-model="onlyChanges" :disabled="!hasResult">只看差异</Checkbox>
</div>
<div class="toolbar-gap"></div>
<!-- 次要操作 -->
<div class="toolbar-group">
<Tooltip content="交换左右两侧内容" transfer>
<Button icon="md-swap" :disabled="comparing" @click="swapSides()">交换</Button>
<Button icon="md-trash" :disabled="comparing" @click="clearAll()">清空</Button>
</Tooltip>
<Tooltip content="填充一组示例数据" transfer>
<Button icon="md-flask" :disabled="comparing" @click="fillSample()">示例</Button>
<Button icon="md-download" :disabled="!hasResult" @click="exportHtml()">导出</Button>
</Tooltip>
<Button icon="md-trash" :disabled="comparing" @click="clearAll()">清空</Button>
</div>
</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> 行
<div class="stat-group">
<span class="stat-item added-item">
<i class="stat-dot"></i>新增<b class="stat-num">{{ 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 class="stat-item removed-item">
<i class="stat-dot"></i>删除<b class="stat-num">{{ 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 class="stat-item changed-item">
<i class="stat-dot"></i>修改<b class="stat-num">{{ 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">
<span class="stat-item plain-item">相同<b class="stat-num">{{ stats.unchanged }}</b></span>
</div>
<div class="stat-divider" v-if="changeRows.length > 0"></div>
<!-- 差异导航 -->
<div class="nav-group" v-if="changeRows.length > 0">
<span class="nav-label">定位</span>
<Button size="small" icon="md-arrow-up" :disabled="navIndex <= 0" @click="gotoChange(-1)"></Button>
<span class="nav-text">{{ navIndex + 1 }} / {{ changeRows.length }}</span>
<span class="nav-text">{{ navIndex + 1 }}<em>/</em>{{ changeRows.length }}</span>
<Button size="small" icon="md-arrow-down" :disabled="navIndex >= changeRows.length - 1" @click="gotoChange(1)"></Button>
</span>
</template>
</div>
<div class="toolbar-gap"></div>
<div class="stat-group right-group">
<span class="stat-item plain-item" v-if="elapsed">耗时{{ elapsed }} ms</span>
<Tag v-if="hasDiff" type="warning">存在差异</Tag>
<Tag v-else type="success">完全一致</Tag>
<Button size="small" icon="md-create" @click="backToEdit()">重新编辑</Button>
<Tooltip content="导出为带颜色标记的 HTML 文件" transfer>
<Button size="small" icon="md-download" :disabled="!hasResult" @click="exportHtml()">导出</Button>
</Tooltip>
</div>
</div>
<!-- 统计条 end -->
@@ -75,18 +101,14 @@
<!-- 结果区 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-no">原</div>
<div class="cell-no col-right">新</div>
<div class="cell-mark"></div>
<div class="cell-text">原始内容</div>
<div class="cell-text">对比内容</div>
<div class="cell-text col-left">原始内容</div>
<div class="cell-text col-right">对比内容</div>
</div>
<div v-for="(row, idx) in visibleRows"
:key="idx"
@@ -94,9 +116,9 @@
: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-no col-right">{{ row.rightNo > 0 ? row.rightNo : '' }}</div>
<div class="cell-mark"></div>
<div class="cell-text">
<div class="cell-text col-left">
<template v-if="row.leftParts">
<span v-for="(part, k) in row.leftParts"
:key="'l' + k"
@@ -104,7 +126,7 @@
</template>
<span v-else class="placeholder-text">—</span>
</div>
<div class="cell-text">
<div class="cell-text col-right">
<template v-if="row.rightParts">
<span v-for="(part, k) in row.rightParts"
:key="'r' + k"
@@ -216,6 +238,7 @@
clearAll () {
this.leftText = ''
this.rightText = ''
this.onlyChanges = false
this.backToEdit()
},
swapSides () {
@@ -322,15 +345,27 @@
exportHtml () {
const rows = this.rows
let body = ''
// 与页面保持一致:新增只染右栏、删除只染左栏、修改两侧都染
const cellClass = row => {
if (row.type === 'insert') return 'txt side-right hit'
if (row.type === 'delete') return 'txt side-left hit'
if (row.type === 'replace') return 'txt hit'
return 'txt'
}
const renderParts = parts => {
if (!parts) return '<span class="ph">—</span>'
return parts.map(p => {
const text = escapeHtml(p.text)
return p.changed ? '<mark>' + text + '</mark>' : text
}).join('')
}
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 += '<td class="' + cellClass(row) + ' side-left">' + renderParts(row.leftParts) + '</td>'
body += '<td class="' + cellClass(row) + ' side-right">' + renderParts(row.rightParts) + '</td>'
body += '</tr>'
})
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
@@ -339,16 +374,19 @@
+ '.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}'
+ 'td.no{width:44px;background:#f8f8f9;color:#808695;text-align:right;user-select:none;font-variant-numeric:tabular-nums}'
+ 'td.txt{width:41%}'
+ 'td.side-left{border-right:1px solid #dcdee2}'
+ 'tr.row-insert td.side-right{background:#e8f8ee}'
+ 'tr.row-delete td.side-left{background:#fdecec}'
+ 'tr.row-replace td{background:#fff8e1}'
+ 'mark{background:#e6a23c;color:#fff;border-radius:2px;padding:0 1px}'
+ 'span.ph{color:#c5c8ce}'
+ '</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>'
+ '<table><tr><td class="no">原</td><td class="no">新</td><td class="txt side-left">原始内容</td>'
+ '<td class="txt side-right">对比内容</td></tr>' + body + '</table></body></html>'
this.downloadFile('text-diff-result.html', html, 'text/html')
},
@@ -362,7 +400,32 @@
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
// Ctrl/Cmd + Enter 触发比对;输入框内换行不受影响
onKeydown (event) {
const isCompare = (event.ctrlKey || event.metaKey) && event.key === 'Enter'
if (isCompare) {
event.preventDefault()
if (!this.comparing) this.doCompare()
return
}
// Esc 退出结果视图
if (event.key === 'Escape' && this.hasResult) {
this.backToEdit()
}
}
},
// keep-alive 下 mounted 与 activated 都会触发,同一函数重复 addEventListener
// 会导致一次按键触发多次,因此只放在 activated 注册,deactivated 统一解绑
activated () {
window.removeEventListener('keydown', this.onKeydown)
window.addEventListener('keydown', this.onKeydown)
},
deactivated () {
window.removeEventListener('keydown', this.onKeydown)
},
beforeDestroy () {
window.removeEventListener('keydown', this.onKeydown)
}
}
@@ -385,66 +448,115 @@
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid #e8eaec;
}
.diff-toolbar .ivu-checkbox-wrapper {
margin-right: 16px;
// 选项组:浅底 + 圆角,与操作按钮形成视觉分层
.toolbar-group {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
}
.toolbar-group .ivu-checkbox-wrapper {
margin-right: 0;
}
.toolbar-gap {
flex: 1;
}
// 统计条并入浅色卡片,与内容区形成区块感
.diff-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 10px 0;
gap: 8px 14px;
margin-top: 12px;
padding: 8px 14px;
background: #f7f8fa;
border: 1px solid #e8eaec;
border-radius: 4px;
font-size: 13px;
color: #515a6e;
}
.stat-item {
.stat-group {
display: inline-flex;
align-items: center;
margin-right: 18px;
gap: 14px;
}
.stat-item.muted {
color: #808695;
.right-group {
gap: 10px;
}
.stat-item {
display: inline-flex;
align-items: baseline;
white-space: nowrap;
}
.stat-dot {
align-self: center;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 6px;
margin-right: 5px;
}
.added-dot { background: #19be6b; }
.removed-dot { background: #ed4014; }
.changed-dot { background: #ff9900; }
.added-item .stat-dot { background: #19be6b; }
.removed-item .stat-dot { background: #ed4014; }
.changed-item .stat-dot { background: #ff9900; }
.stat-num {
margin: 0 4px 0 2px;
.stat-item b {
margin-left: 4px;
font-size: 14px;
font-weight: 600;
}
.added-text { color: #19be6b; }
.removed-text { color: #ed4014; }
.changed-text { color: #ff9900; }
.added-item b { color: #19be6b; }
.removed-item b { color: #ed4014; }
.changed-item b { color: #ff9900; }
.nav-group .ivu-btn {
margin: 0 4px;
.plain-item {
color: #808695;
}
.stat-divider {
width: 1px;
height: 16px;
background: #dcdee2;
}
// 差异导航
.nav-group {
display: inline-flex;
align-items: center;
gap: 4px;
}
.nav-label {
color: #808695;
margin-right: 2px;
}
.nav-text {
min-width: 54px;
min-width: 48px;
text-align: center;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.nav-text em {
font-style: normal;
color: #c5c8ce;
margin: 0 2px;
}
.diff-input-area {
@@ -464,7 +576,8 @@
.input-head {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
padding-bottom: 6px;
}
@@ -475,9 +588,11 @@
}
.input-meta {
margin-left: 8px;
font-size: 12px;
color: #808695;
background: #f7f8fa;
border-radius: 10px;
padding: 1px 8px;
}
.diff-textarea {
@@ -501,18 +616,6 @@
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;
@@ -545,7 +648,7 @@
}
.cell-no {
flex: 0 0 52px;
flex: 0 0 48px;
padding: 3px 8px;
text-align: right;
color: #808695;
@@ -554,11 +657,19 @@
font-size: 12px;
user-select: none;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
// 最后一个行号列与色条之间加一条竖分隔,强化左右分栏
.cell-no.col-right {
border-right: none;
}
.cell-mark {
flex: 0 0 4px;
background: transparent;
border-left: 1px solid #dcdee2;
border-right: 1px solid #dcdee2;
}
.cell-text {
@@ -570,15 +681,22 @@
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
border-right: 1px solid #f0f2f5;
}
// 左右两栏之间用竖线分隔,长文本下更易对照
.cell-text.col-left {
border-right: 1px solid #dcdee2;
}
.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-insert .cell-text.col-right { background: #e8f8ee; }
// 删除:仅左侧栏着色
.diff-row.row-delete .cell-text.col-left { background: #fdecec; }
// 修改:两侧都着色
.diff-row.row-replace .cell-text { background: #fff8e1; }
.diff-row.active .cell-text {