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"> <div class="text-diff-page">
<!-- 操作栏 start --> <!-- 操作栏 start -->
<div class="diff-toolbar"> <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="ignoreWhitespace">忽略空白</Checkbox>
<Checkbox v-model="onlyChanges">只看差异</Checkbox> <Checkbox v-model="onlyChanges" :disabled="!hasResult">只看差异</Checkbox>
</div>
<div class="toolbar-gap"></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-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-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> </div>
<!-- 操作栏 end --> <!-- 操作栏 end -->
<!-- 统计条 start --> <!-- 统计条 start -->
<div class="diff-stats" v-if="hasResult"> <div class="diff-stats" v-if="hasResult">
<span class="stat-item"> <div class="stat-group">
<span class="stat-dot added-dot"></span>新增 <b class="stat-num added-text">{{ stats.added }}</b> 行 <span class="stat-item added-item">
<i class="stat-dot"></i>新增<b class="stat-num">{{ stats.added }}</b>
</span> </span>
<span class="stat-item"> <span class="stat-item removed-item">
<span class="stat-dot removed-dot"></span>删除 <b class="stat-num removed-text">{{ stats.removed }}</b> 行 <i class="stat-dot"></i>删除<b class="stat-num">{{ stats.removed }}</b>
</span> </span>
<span class="stat-item"> <span class="stat-item changed-item">
<span class="stat-dot changed-dot"></span>修改 <b class="stat-num changed-text">{{ stats.changed }}</b> 行 <i class="stat-dot"></i>修改<b class="stat-num">{{ stats.changed }}</b>
</span> </span>
<span class="stat-item muted">相同 {{ stats.unchanged }} 行</span> <span class="stat-item plain-item">相同<b class="stat-num">{{ stats.unchanged }}</b></span>
<div class="toolbar-gap"></div> </div>
<span class="stat-item muted" v-if="elapsed">耗时 {{ elapsed }} ms</span>
<template v-if="changeRows.length > 0"> <div class="stat-divider" v-if="changeRows.length > 0"></div>
<div class="toolbar-gap"></div>
<span class="stat-item nav-group"> <!-- 差异导航 -->
<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> <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> <Button size="small" icon="md-arrow-down" :disabled="navIndex >= changeRows.length - 1" @click="gotoChange(1)"></Button>
</span> </div>
</template>
<div class="toolbar-gap"></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-if="hasDiff" type="warning">存在差异</Tag>
<Tag v-else type="success">完全一致</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> </div>
<!-- 统计条 end --> <!-- 统计条 end -->
@@ -75,18 +101,14 @@
<!-- 结果区 start --> <!-- 结果区 start -->
<div class="diff-result-area" v-if="hasResult"> <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-wrap">
<div class="diff-table"> <div class="diff-table">
<div class="diff-row diff-head-row"> <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-mark"></div>
<div class="cell-text">原始内容</div> <div class="cell-text col-left">原始内容</div>
<div class="cell-text">对比内容</div> <div class="cell-text col-right">对比内容</div>
</div> </div>
<div v-for="(row, idx) in visibleRows" <div v-for="(row, idx) in visibleRows"
:key="idx" :key="idx"
@@ -94,9 +116,9 @@
:class="[rowClass(row), { active: activeRowIndex === idx }]" :class="[rowClass(row), { active: activeRowIndex === idx }]"
:ref="'row' + idx"> :ref="'row' + idx">
<div class="cell-no">{{ row.leftNo > 0 ? row.leftNo : '' }}</div> <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-mark"></div>
<div class="cell-text"> <div class="cell-text col-left">
<template v-if="row.leftParts"> <template v-if="row.leftParts">
<span v-for="(part, k) in row.leftParts" <span v-for="(part, k) in row.leftParts"
:key="'l' + k" :key="'l' + k"
@@ -104,7 +126,7 @@
</template> </template>
<span v-else class="placeholder-text">—</span> <span v-else class="placeholder-text">—</span>
</div> </div>
<div class="cell-text"> <div class="cell-text col-right">
<template v-if="row.rightParts"> <template v-if="row.rightParts">
<span v-for="(part, k) in row.rightParts" <span v-for="(part, k) in row.rightParts"
:key="'r' + k" :key="'r' + k"
@@ -216,6 +238,7 @@
clearAll () { clearAll () {
this.leftText = '' this.leftText = ''
this.rightText = '' this.rightText = ''
this.onlyChanges = false
this.backToEdit() this.backToEdit()
}, },
swapSides () { swapSides () {
@@ -322,15 +345,27 @@
exportHtml () { exportHtml () {
const rows = this.rows const rows = this.rows
let body = '' 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 => { rows.forEach(row => {
const cls = this.rowClass(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 += '<tr class="' + cls + '">'
body += '<td class="no">' + (row.leftNo > 0 ? row.leftNo : '') + '</td>' body += '<td class="no">' + (row.leftNo > 0 ? row.leftNo : '') + '</td>'
body += '<td class="no">' + (row.rightNo > 0 ? row.rightNo : '') + '</td>' body += '<td class="no">' + (row.rightNo > 0 ? row.rightNo : '') + '</td>'
body += '<td class="txt">' + left + '</td>' body += '<td class="' + cellClass(row) + ' side-left">' + renderParts(row.leftParts) + '</td>'
body += '<td class="txt">' + right + '</td>' body += '<td class="' + cellClass(row) + ' side-right">' + renderParts(row.rightParts) + '</td>'
body += '</tr>' body += '</tr>'
}) })
const html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' 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}' + '.bar{padding:10px 12px;background:#f0f3fa;border-radius:6px;margin-bottom:12px}'
+ 'table{border-collapse:collapse;width:100%;table-layout:fixed}' + '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{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.no{width:44px;background:#f8f8f9;color:#808695;text-align:right;user-select:none;font-variant-numeric:tabular-nums}'
+ 'td.txt{width:40%}' + 'td.txt{width:41%}'
+ 'tr.row-insert td{background:#e8f8ee}' + 'td.side-left{border-right:1px solid #dcdee2}'
+ 'tr.row-delete td{background:#fdecec}' + 'tr.row-insert td.side-right{background:#e8f8ee}'
+ 'tr.row-delete td.side-left{background:#fdecec}'
+ 'tr.row-replace td{background:#fff8e1}' + 'tr.row-replace td{background:#fff8e1}'
+ 'mark{background:#e6a23c;color:#fff;border-radius:2px;padding:0 1px}'
+ 'span.ph{color:#c5c8ce}'
+ '</style></head><body>' + '</style></head><body>'
+ '<div class="bar">新增 ' + this.stats.added + ' 行 | 删除 ' + this.stats.removed + '<div class="bar">新增 ' + this.stats.added + ' 行 | 删除 ' + this.stats.removed
+ ' 行 | 修改 ' + this.stats.changed + ' 行 | 相同 ' + this.stats.unchanged + ' 行</div>' + ' 行 | 修改 ' + this.stats.changed + ' 行 | 相同 ' + this.stats.unchanged + ' 行</div>'
+ '<table><tr><td class="no">行</td><td class="no">行</td><td class="txt">原始内容</td>' + '<table><tr><td class="no">原</td><td class="no">新</td><td class="txt side-left">原始内容</td>'
+ '<td class="txt">对比内容</td></tr>' + body + '</table></body></html>' + '<td class="txt side-right">对比内容</td></tr>' + body + '</table></body></html>'
this.downloadFile('text-diff-result.html', html, 'text/html') this.downloadFile('text-diff-result.html', html, 'text/html')
}, },
@@ -362,7 +400,32 @@
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) 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; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px;
padding-bottom: 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 { .toolbar-gap {
flex: 1; flex: 1;
} }
// 统计条并入浅色卡片,与内容区形成区块感
.diff-stats { .diff-stats {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; 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; font-size: 13px;
color: #515a6e; color: #515a6e;
} }
.stat-item { .stat-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-right: 18px; gap: 14px;
} }
.stat-item.muted { .right-group {
color: #808695; gap: 10px;
}
.stat-item {
display: inline-flex;
align-items: baseline;
white-space: nowrap;
} }
.stat-dot { .stat-dot {
align-self: center;
display: inline-block; display: inline-block;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
margin-right: 6px; margin-right: 5px;
} }
.added-dot { background: #19be6b; } .added-item .stat-dot { background: #19be6b; }
.removed-dot { background: #ed4014; } .removed-item .stat-dot { background: #ed4014; }
.changed-dot { background: #ff9900; } .changed-item .stat-dot { background: #ff9900; }
.stat-num { .stat-item b {
margin: 0 4px 0 2px; margin-left: 4px;
font-size: 14px; font-size: 14px;
font-weight: 600;
} }
.added-text { color: #19be6b; } .added-item b { color: #19be6b; }
.removed-text { color: #ed4014; } .removed-item b { color: #ed4014; }
.changed-text { color: #ff9900; } .changed-item b { color: #ff9900; }
.nav-group .ivu-btn { .plain-item {
margin: 0 4px; 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 { .nav-text {
min-width: 54px; min-width: 48px;
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
font-variant-numeric: tabular-nums;
}
.nav-text em {
font-style: normal;
color: #c5c8ce;
margin: 0 2px;
} }
.diff-input-area { .diff-input-area {
@@ -464,7 +576,8 @@
.input-head { .input-head {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between;
padding-bottom: 6px; padding-bottom: 6px;
} }
@@ -475,9 +588,11 @@
} }
.input-meta { .input-meta {
margin-left: 8px;
font-size: 12px; font-size: 12px;
color: #808695; color: #808695;
background: #f7f8fa;
border-radius: 10px;
padding: 1px 8px;
} }
.diff-textarea { .diff-textarea {
@@ -501,18 +616,6 @@
min-height: 0; 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 { .diff-table-wrap {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@@ -545,7 +648,7 @@
} }
.cell-no { .cell-no {
flex: 0 0 52px; flex: 0 0 48px;
padding: 3px 8px; padding: 3px 8px;
text-align: right; text-align: right;
color: #808695; color: #808695;
@@ -554,11 +657,19 @@
font-size: 12px; font-size: 12px;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums;
}
// 最后一个行号列与色条之间加一条竖分隔,强化左右分栏
.cell-no.col-right {
border-right: none;
} }
.cell-mark { .cell-mark {
flex: 0 0 4px; flex: 0 0 4px;
background: transparent; background: transparent;
border-left: 1px solid #dcdee2;
border-right: 1px solid #dcdee2;
} }
.cell-text { .cell-text {
@@ -570,15 +681,22 @@
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; 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-insert .cell-mark { background: #19be6b; }
.diff-row.row-delete .cell-mark { background: #ed4014; } .diff-row.row-delete .cell-mark { background: #ed4014; }
.diff-row.row-replace .cell-mark { background: #ff9900; } .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.row-replace .cell-text { background: #fff8e1; }
.diff-row.active .cell-text { .diff-row.active .cell-text {