布局优化: - 操作栏改为「主操作 + 选项组 + 次要操作」三段式, 勾选项与操作按钮视觉分层,不再混排成一条 - 统计条并入浅色卡片,用 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:
+205
-87
@@ -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>
|
||||
<Checkbox v-model="ignoreWhitespace">忽略空白</Checkbox>
|
||||
<Checkbox v-model="onlyChanges">只看差异</Checkbox>
|
||||
<!-- 主操作 -->
|
||||
<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" :disabled="!hasResult">只看差异</Checkbox>
|
||||
</div>
|
||||
|
||||
<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 class="toolbar-group">
|
||||
<Tooltip content="交换左右两侧内容" transfer>
|
||||
<Button icon="md-swap" :disabled="comparing" @click="swapSides()">交换</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="填充一组示例数据" transfer>
|
||||
<Button icon="md-flask" :disabled="comparing" @click="fillSample()">示例</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> 行
|
||||
</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>
|
||||
<div class="stat-group">
|
||||
<span class="stat-item added-item">
|
||||
<i class="stat-dot"></i>新增<b class="stat-num">{{ stats.added }}</b>
|
||||
</span>
|
||||
</template>
|
||||
<span class="stat-item removed-item">
|
||||
<i class="stat-dot"></i>删除<b class="stat-num">{{ stats.removed }}</b>
|
||||
</span>
|
||||
<span class="stat-item changed-item">
|
||||
<i class="stat-dot"></i>修改<b class="stat-num">{{ stats.changed }}</b>
|
||||
</span>
|
||||
<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 }}<em>/</em>{{ changeRows.length }}</span>
|
||||
<Button size="small" icon="md-arrow-down" :disabled="navIndex >= changeRows.length - 1" @click="gotoChange(1)"></Button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-gap"></div>
|
||||
<Tag v-if="hasDiff" type="warning">存在差异</Tag>
|
||||
<Tag v-else type="success">完全一致</Tag>
|
||||
|
||||
<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 {
|
||||
|
||||
Reference in New Issue
Block a user