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