Files
ToolBox/src/views/Comparator.vue
2026-01-30 22:02:51 +08:00

2884 lines
78 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="tool-page">
<!-- 浮层提示 -->
<Transition name="toast">
<div v-if="toastMessage" class="toast-notification" :class="toastType">
<div class="toast-content">
<i v-if="toastType === 'error'" class="fas fa-circle-exclamation"></i>
<i v-else class="fas fa-circle-info"></i>
<span>{{ toastMessage }}</span>
</div>
<button @click="closeToast" class="toast-close-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
</Transition>
<div class="main-container">
<!-- 左侧侧栏历史记录 -->
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="sidebar-header">
<h3>历史记录</h3>
<button @click="toggleSidebar" class="close-btn">×</button>
</div>
<div class="sidebar-content">
<div v-if="historyList.length === 0" class="empty-history">
暂无历史记录
</div>
<div
v-for="(item, index) in historyList"
:key="index"
class="history-item"
@click="loadHistory(item)"
>
<div class="history-time">{{ formatTime(item.time) }}</div>
<div class="history-preview">
<div class="history-mode">{{ getModeLabel(item.compareMode, item.textSubMode, item.ignoreListOrder) }}</div>
<div class="history-text">{{ truncateText(item.leftText || '', 30) }} | {{ truncateText(item.rightText || '', 30) }}</div>
</div>
</div>
</div>
</div>
<!-- 主内容区域 -->
<div class="content-wrapper" :class="{ 'sidebar-pushed': sidebarOpen }">
<!-- 工具栏 -->
<div class="toolbar">
<div class="mode-selector">
<button
@click="compareMode = 'text'"
:class="['mode-btn', { active: compareMode === 'text' }]"
title="文本对比"
>
文本对比
</button>
<button
@click="compareMode = 'json'"
:class="['mode-btn', { active: compareMode === 'json' }]"
title="JSON对比"
>
JSON对比
</button>
</div>
<div class="submode-selector" v-if="compareMode === 'text'">
<button
@click="textSubMode = 'line'"
:class="['submode-btn', { active: textSubMode === 'line' }]"
title="按行对比"
>
行维度
</button>
<button
@click="textSubMode = 'char'"
:class="['submode-btn', { active: textSubMode === 'char' }]"
title="按字符对比"
>
字符维度
</button>
</div>
<div class="submode-selector" v-if="compareMode === 'json'">
<label class="switch-label">
<input type="checkbox" v-model="ignoreListOrder" class="switch-input" />
<span class="switch-text">忽略列表顺序</span>
</label>
</div>
<div class="toolbar-actions">
<button @click="performCompare" class="action-btn primary" title="开始对比">
<i class="fas fa-code-compare"></i>
<span>对比</span>
</button>
<button @click="clearAll" class="action-btn" title="清空">
<i class="far fa-trash-can"></i>
</button>
</div>
</div>
<!-- 输入区域 -->
<div class="input-container">
<!-- 左侧输入框 -->
<div class="input-panel" :style="{ width: leftPanelWidth + '%' }">
<div class="panel-header">
<span class="panel-label">文本 A</span>
<div class="panel-actions">
<button @click="copyToClipboard('left')" class="icon-btn" title="复制">
<i class="far fa-copy"></i>
</button>
<button @click="pasteFromClipboard('left')" class="icon-btn" title="粘贴">
<i class="far fa-paste"></i>
</button>
<button @click="clearInput('left')" class="icon-btn" title="清空">
<i class="far fa-trash-can"></i>
</button>
</div>
</div>
<div class="sidebar-toggle">
<button @click="toggleSidebar" class="toggle-btn">
{{ sidebarOpen ? '◀' : '▶' }}
</button>
</div>
<div class="editor-container">
<div class="line-numbers">
<div v-for="n in leftLineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="leftEditorRef"
v-model="leftText"
@input="updateLeftLineCount"
placeholder="请输入或粘贴要对比的内容 A"
class="text-editor"
></textarea>
</div>
</div>
<!-- 可拖拽分割线 -->
<div
class="splitter"
@mousedown="startResize"
></div>
<!-- 右侧输入框 -->
<div class="input-panel" :style="{ width: rightPanelWidth + '%' }">
<div class="panel-header">
<span class="panel-label">文本 B</span>
<div class="panel-actions">
<button @click="copyToClipboard('right')" class="icon-btn" title="复制">
<i class="far fa-copy"></i>
</button>
<button @click="pasteFromClipboard('right')" class="icon-btn" title="粘贴">
<i class="far fa-paste"></i>
</button>
<button @click="clearInput('right')" class="icon-btn" title="清空">
<i class="far fa-trash-can"></i>
</button>
</div>
</div>
<div class="editor-container">
<div class="line-numbers">
<div v-for="n in rightLineCount" :key="n" class="line-number">{{ n }}</div>
</div>
<textarea
ref="rightEditorRef"
v-model="rightText"
@input="updateRightLineCount"
placeholder="请输入或粘贴要对比的内容 B"
class="text-editor"
></textarea>
</div>
</div>
</div>
<!-- 对比结果区域 -->
<div class="result-container" v-if="compareResult">
<div class="result-header">
<span class="result-label">对比结果</span>
<div class="result-stats">
<span class="stat-item">
<span class="stat-label">相同:</span>
<span class="stat-value same">{{ compareResult.stats.same }}</span>
</span>
<span class="stat-item">
<span class="stat-label">插入:</span>
<span class="stat-value insert">{{ compareResult.stats.insert }}</span>
</span>
<span class="stat-item">
<span class="stat-label">删除:</span>
<span class="stat-value delete">{{ compareResult.stats.delete }}</span>
</span>
<span class="stat-item">
<span class="stat-label">修改:</span>
<span class="stat-value modify">{{ compareResult.stats.modify }}</span>
</span>
</div>
<button @click="resultFullscreen = true" class="icon-btn" title="全屏展示">
<i class="fas fa-expand"></i>
</button>
</div>
<div class="result-content" ref="resultContentRef">
<div class="result-panel left-result" ref="leftResultRef" @scroll="handleLeftScroll">
<div class="result-line" v-for="(line, index) in compareResult.left" :key="index">
<span class="line-number">{{ line.lineNumber || index + 1 }}</span>
<span
:class="['line-content', line.type, { 'inline-diff': line.inlineHighlight }]"
v-html="line.html || escapeHtml(line.content)"
></span>
</div>
</div>
<div class="result-panel right-result" ref="rightResultRef" @scroll="handleRightScroll">
<div class="result-line" v-for="(line, index) in compareResult.right" :key="index">
<span class="line-number">{{ line.lineNumber || index + 1 }}</span>
<span
:class="['line-content', line.type, { 'inline-diff': line.inlineHighlight }]"
v-html="line.html || escapeHtml(line.content)"
></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 对比结果全屏展示覆盖整个网页区域 -->
<Teleport to="body">
<Transition name="result-fullscreen">
<div v-if="resultFullscreen" class="result-fullscreen-overlay">
<div class="result-fullscreen-inner">
<div class="result-fullscreen-header">
<span class="result-label">对比结果</span>
<div class="result-stats" v-if="compareResult">
<span class="stat-item">
<span class="stat-label">相同:</span>
<span class="stat-value same">{{ compareResult.stats.same }}</span>
</span>
<span class="stat-item">
<span class="stat-label">插入:</span>
<span class="stat-value insert">{{ compareResult.stats.insert }}</span>
</span>
<span class="stat-item">
<span class="stat-label">删除:</span>
<span class="stat-value delete">{{ compareResult.stats.delete }}</span>
</span>
<span class="stat-item">
<span class="stat-label">修改:</span>
<span class="stat-value modify">{{ compareResult.stats.modify }}</span>
</span>
</div>
<button @click="resultFullscreen = false" class="icon-btn" title="退出全屏">
<i class="fas fa-compress"></i>
</button>
</div>
<div class="result-fullscreen-content" v-if="compareResult">
<div class="result-panel left-result result-fullscreen-panel" ref="leftFullscreenResultRef" @scroll="handleLeftFullscreenScroll">
<div class="result-line" v-for="(line, index) in compareResult.left" :key="index">
<span class="line-number">{{ line.lineNumber || index + 1 }}</span>
<span
:class="['line-content', line.type, { 'inline-diff': line.inlineHighlight }]"
v-html="line.html || escapeHtml(line.content)"
></span>
</div>
</div>
<div class="result-panel right-result result-fullscreen-panel" ref="rightFullscreenResultRef" @scroll="handleRightFullscreenScroll">
<div class="result-line" v-for="(line, index) in compareResult.right" :key="index">
<span class="line-number">{{ line.lineNumber || index + 1 }}</span>
<span
:class="['line-content', line.type, { 'inline-diff': line.inlineHighlight }]"
v-html="line.html || escapeHtml(line.content)"
></span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import {ref, computed, watch, onMounted, onUnmounted} from 'vue'
const MAX_INPUT_BYTES = 500 * 1024 // 最大 500KB
const leftText = ref('')
const rightText = ref('')
const compareMode = ref('text') // 'text' 或 'json'
const textSubMode = ref('line') // 'line' | 'char'
const ignoreListOrder = ref(false) // 是否忽略列表顺序
const leftPanelWidth = ref(50)
const rightPanelWidth = ref(50)
const isResizing = ref(false)
const leftLineCount = ref(1)
const rightLineCount = ref(1)
const leftEditorRef = ref(null)
const rightEditorRef = ref(null)
const compareResult = ref(null)
const resultFullscreen = ref(false)
const sidebarOpen = ref(false)
// 历史记录
const historyList = ref([])
const STORAGE_KEY = 'comparator-history'
const MAX_HISTORY = 50
// 全屏时禁止页面滚动
watch(resultFullscreen, (isFullscreen) => {
document.body.style.overflow = isFullscreen ? 'hidden' : ''
})
const leftResultRef = ref(null)
const rightResultRef = ref(null)
const resultContentRef = ref(null)
const leftFullscreenResultRef = ref(null)
const rightFullscreenResultRef = ref(null)
const isScrolling = ref(false)
// 提示消息
const toastMessage = ref('')
const toastType = ref('error')
let toastTimer = null
// 显示提示
const showToast = (message, type = 'error', duration = 3000) => {
toastMessage.value = message
toastType.value = type
if (toastTimer) {
clearTimeout(toastTimer)
}
toastTimer = setTimeout(() => {
toastMessage.value = ''
}, duration)
}
// 关闭提示
const closeToast = () => {
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
toastMessage.value = ''
}
// 获取字符串 UTF-8 字节长度
const getByteLength = (str) => new TextEncoder().encode(str).length
// 将字符串截断到最大字节数(避免切断多字节字符)
const truncateToMaxBytes = (str, maxBytes) => {
if (getByteLength(str) <= maxBytes) return str
let end = str.length
while (end > 0 && getByteLength(str.slice(0, end)) > maxBytes) end--
return str.slice(0, end)
}
// 应用输入大小限制,超出则截断并提示
const applyInputLimit = (side) => {
const ref = side === 'left' ? leftText : rightText
if (getByteLength(ref.value) <= MAX_INPUT_BYTES) return
ref.value = truncateToMaxBytes(ref.value, MAX_INPUT_BYTES)
showToast(`内容已超过 500KB 限制,已自动截断`, 'info', 3000)
if (side === 'left') updateLeftLineCount()
else updateRightLineCount()
}
// 更新行号
const updateLeftLineCount = () => {
applyInputLimit('left')
if (leftText.value) {
leftLineCount.value = leftText.value.split('\n').length
} else {
leftLineCount.value = 1
}
}
const updateRightLineCount = () => {
applyInputLimit('right')
if (rightText.value) {
rightLineCount.value = rightText.value.split('\n').length
} else {
rightLineCount.value = 1
}
}
// 拖拽调整宽度
const startResize = (e) => {
isResizing.value = true
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
e.preventDefault()
}
const handleResize = (e) => {
if (!isResizing.value) return
const container = document.querySelector('.input-container')
if (!container) return
const containerWidth = container.offsetWidth
const mouseX = e.clientX - container.getBoundingClientRect().left
const percentage = (mouseX / containerWidth) * 100
const leftPercent = Math.max(20, Math.min(80, percentage))
const rightPercent = 100 - leftPercent
leftPanelWidth.value = leftPercent
rightPanelWidth.value = rightPercent
}
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
}
// 复制到剪贴板
const copyToClipboard = async (side) => {
const text = side === 'left' ? leftText.value : rightText.value
if (!text.trim()) {
showToast(`${side === 'left' ? '左侧' : '右侧'}内容为空,无法复制`)
return
}
try {
await navigator.clipboard.writeText(text)
showToast(`已复制${side === 'left' ? '左侧' : '右侧'}内容到剪贴板`, 'info', 2000)
} catch (e) {
showToast('复制失败:' + e.message)
}
}
// 从剪贴板粘贴
const pasteFromClipboard = async (side) => {
if (navigator.clipboard && navigator.clipboard.readText) {
try {
let text = await navigator.clipboard.readText()
if (text.trim()) {
if (getByteLength(text) > MAX_INPUT_BYTES) {
text = truncateToMaxBytes(text, MAX_INPUT_BYTES)
showToast('粘贴内容已超过 500KB 限制,已自动截断', 'info', 3000)
}
if (side === 'left') {
leftText.value = text
updateLeftLineCount()
} else {
rightText.value = text
updateRightLineCount()
}
} else {
showToast('剪贴板内容为空')
}
} catch (e) {
const editorRef = side === 'left' ? leftEditorRef.value : rightEditorRef.value
if (editorRef) {
editorRef.focus()
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
}
}
}
}
// 清空输入
const clearInput = (side) => {
if (side === 'left') {
leftText.value = ''
updateLeftLineCount()
} else {
rightText.value = ''
updateRightLineCount()
}
}
// 清空所有
const clearAll = () => {
leftText.value = ''
rightText.value = ''
compareResult.value = null
updateLeftLineCount()
updateRightLineCount()
showToast('已清空', 'info', 2000)
}
// 转义HTML
const escapeHtml = (text) => {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// 同步滚动处理(非全屏模式)
const handleLeftScroll = () => {
if (isScrolling.value) return
isScrolling.value = true
if (leftResultRef.value && rightResultRef.value) {
rightResultRef.value.scrollTop = leftResultRef.value.scrollTop
}
setTimeout(() => {
isScrolling.value = false
}, 10)
}
const handleRightScroll = () => {
if (isScrolling.value) return
isScrolling.value = true
if (leftResultRef.value && rightResultRef.value) {
leftResultRef.value.scrollTop = rightResultRef.value.scrollTop
}
setTimeout(() => {
isScrolling.value = false
}, 10)
}
// 同步滚动处理(全屏模式)
const handleLeftFullscreenScroll = () => {
if (isScrolling.value) return
isScrolling.value = true
if (leftFullscreenResultRef.value && rightFullscreenResultRef.value) {
rightFullscreenResultRef.value.scrollTop = leftFullscreenResultRef.value.scrollTop
}
setTimeout(() => {
isScrolling.value = false
}, 10)
}
const handleRightFullscreenScroll = () => {
if (isScrolling.value) return
isScrolling.value = true
if (leftFullscreenResultRef.value && rightFullscreenResultRef.value) {
leftFullscreenResultRef.value.scrollTop = rightFullscreenResultRef.value.scrollTop
}
setTimeout(() => {
isScrolling.value = false
}, 10)
}
// 高亮差异文本
const highlightDiff = (text, diffRanges) => {
if (!diffRanges || diffRanges.length === 0) {
return escapeHtml(text)
}
let result = ''
let lastIndex = 0
diffRanges.forEach(range => {
// 添加差异前的正常文本
if (range.start > lastIndex) {
result += escapeHtml(text.substring(lastIndex, range.start))
}
// 添加高亮的差异文本
result += `<span class="diff-highlight">${escapeHtml(text.substring(range.start, range.end))}</span>`
lastIndex = range.end
})
// 添加剩余的文本
if (lastIndex < text.length) {
result += escapeHtml(text.substring(lastIndex))
}
return result
}
// 计算最小编辑距离的diff简化版LCS算法
const computeDiff = (arrA, arrB) => {
const n = arrA.length
const m = arrB.length
// 使用动态规划计算LCS
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (arrA[i - 1] === arrB[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
// 回溯找出所有匹配点。当同一字符在 B 中出现多次时,优先选 j 更小的(靠左匹配)
const matches = []
let i = n, j = m
while (i > 0 && j > 0) {
if (arrA[i - 1] === arrB[j - 1]) {
// 若 dp[i][j-1] === dp[i][j],说明不匹配 (i-1,j-1) 也能得到同样长的 LCS可先 j-- 尝试更靠左的匹配
if (j > 1 && dp[i][j - 1] === dp[i][j]) {
j--
} else {
matches.unshift({x: i - 1, y: j - 1})
i--
j--
}
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--
} else {
j--
}
}
// 构建路径:每个匹配点都要加入,否则 compareTextByChar 会漏掉匹配(如 hello vs helloworld 的 o
const path = []
for (const match of matches) {
path.push({x: match.x, y: match.y})
}
if (path.length > 0) {
const last = path[path.length - 1]
if (last.x < n || last.y < m) {
path.push({x: n, y: m})
}
} else {
path.push({x: n, y: m})
}
return path
}
// 文本对比 - 行维度(使用最小编辑距离)
const compareTextByLine = (textA, textB) => {
const linesA = textA.split('\n')
const linesB = textB.split('\n')
const n = linesA.length
const m = linesB.length
// 使用动态规划计算LCS
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
const path = Array(n + 1).fill(null).map(() => Array(m + 1).fill(null))
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (linesA[i - 1] === linesB[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
path[i][j] = 'match'
} else if (dp[i - 1][j] > dp[i][j - 1]) {
dp[i][j] = dp[i - 1][j]
path[i][j] = 'delete'
} else {
dp[i][j] = dp[i][j - 1]
path[i][j] = 'insert'
}
}
}
// 回溯构建结果
const resultA = []
const resultB = []
let stats = {same: 0, insert: 0, delete: 0, modify: 0}
let i = n, j = m
let lineNumA = n, lineNumB = m
const pendingDelete = []
const pendingInsert = []
// 判断两行是否应该合并为修改(而不是删除+插入)
const shouldMergeAsModify = (lineA, lineB) => {
// 如果两行完全相同,不应该到达这里
if (lineA === lineB) return false
// 对于JSON格式的行检查是否是同一类型的结构
// 例如:都是键值对,但值不同;或者都是数组元素等
const trimmedA = lineA.trim()
const trimmedB = lineB.trim()
// 如果都是键值对格式("key": value且键相同认为是修改
const keyValuePattern = /^\s*"([^"]+)":\s*(.+)$/
const matchA = trimmedA.match(keyValuePattern)
const matchB = trimmedB.match(keyValuePattern)
if (matchA && matchB) {
// 如果键相同,认为是修改
if (matchA[1] === matchB[1]) {
return true
}
// 如果键不同,认为是删除+插入
return false
}
// 如果都是数组元素或对象结构,检查结构相似性
// 这里简化处理:如果行结构相似(都有相同的括号、引号等),可能是修改
const structureA = trimmedA.replace(/"[^"]*"/g, '"..."').replace(/\d+/g, '0')
const structureB = trimmedB.replace(/"[^"]*"/g, '"..."').replace(/\d+/g, '0')
// 如果结构相似度较高,认为是修改
// 这里使用简单的启发式:如果结构字符串的前几个字符相同
if (structureA.length > 0 && structureB.length > 0) {
const minLen = Math.min(structureA.length, structureB.length)
let samePrefix = 0
for (let k = 0; k < minLen && k < 10; k++) {
if (structureA[k] === structureB[k]) {
samePrefix++
} else {
break
}
}
// 如果前缀相似度超过50%,认为是修改
if (samePrefix / minLen > 0.5) {
return true
}
}
// 默认不合并为修改,分别显示为删除和插入
return false
}
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && path[i][j] === 'match') {
// 处理待处理的删除和插入
// 只有在内容相似时才合并为修改
while (pendingDelete.length > 0 && pendingInsert.length > 0) {
const deleteLine = pendingDelete[0]
const insertLine = pendingInsert[0]
if (shouldMergeAsModify(deleteLine, insertLine)) {
resultA.unshift({type: 'modify', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'modify', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.modify++
} else {
// 不相似,分别显示为删除和插入
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
}
// 处理剩余的删除
while (pendingDelete.length > 0) {
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
}
// 处理剩余的插入
while (pendingInsert.length > 0) {
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
// 添加匹配的行
resultA.unshift({type: 'same', content: linesA[i - 1], lineNumber: lineNumA})
resultB.unshift({type: 'same', content: linesB[j - 1], lineNumber: lineNumB})
stats.same++
i--
j--
lineNumA--
lineNumB--
} else if (i > 0 && path[i][j] === 'delete') {
pendingDelete.unshift(linesA[i - 1])
i--
lineNumA--
} else if (j > 0 && path[i][j] === 'insert') {
pendingInsert.unshift(linesB[j - 1])
j--
lineNumB--
} else if (i > 0) {
pendingDelete.unshift(linesA[i - 1])
i--
lineNumA--
} else if (j > 0) {
pendingInsert.unshift(linesB[j - 1])
j--
lineNumB--
} else {
break
}
}
// 处理剩余的待处理项
while (pendingDelete.length > 0 && pendingInsert.length > 0) {
const deleteLine = pendingDelete[0]
const insertLine = pendingInsert[0]
if (shouldMergeAsModify(deleteLine, insertLine)) {
resultA.unshift({type: 'modify', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'modify', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.modify++
} else {
// 不相似,分别显示为删除和插入
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
}
while (pendingDelete.length > 0) {
resultA.unshift({type: 'delete', content: pendingDelete.shift(), lineNumber: lineNumA--})
resultB.unshift({type: 'delete', content: '', lineNumber: null})
stats.delete++
}
while (pendingInsert.length > 0) {
resultA.unshift({type: 'insert', content: '', lineNumber: null})
resultB.unshift({type: 'insert', content: pendingInsert.shift(), lineNumber: lineNumB--})
stats.insert++
}
return {left: resultA, right: resultB, stats}
}
// 文本对比 - 字符维度(高亮差异字符)
const compareTextByChar = (textA, textB) => {
const linesA = textA.split('\n')
const linesB = textB.split('\n')
const resultA = []
const resultB = []
let stats = {same: 0, insert: 0, delete: 0, modify: 0}
const maxLines = Math.max(linesA.length, linesB.length)
for (let i = 0; i < maxLines; i++) {
const lineA = linesA[i] || ''
const lineB = linesB[i] || ''
if (lineA === lineB) {
resultA.push({type: 'same', content: lineA, html: escapeHtml(lineA), lineNumber: i + 1})
resultB.push({type: 'same', content: lineB, html: escapeHtml(lineB), lineNumber: i + 1})
stats.same += lineA.length
} else {
// 按字符进行diff统计按高亮单元字符计数
const charsA = lineA.split('')
const charsB = lineB.split('')
const charDiff = computeDiff(charsA, charsB)
let htmlA = ''
let htmlB = ''
let x = 0, y = 0
let hasDiff = false
for (let j = 0; j < charDiff.length; j++) {
const point = charDiff[j]
const nextPoint = charDiff[j + 1] || {x: charsA.length, y: charsB.length}
// 从 (x,y) 到匹配点 (point.x, point.y) 之前:只可能是差异(如 na vs aa 中 (0,0) 是修改不是相同)
while (x < point.x || y < point.y) {
if (x < point.x && y < point.y) {
htmlA += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsA[x])}</span>`
htmlB += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsB[y])}</span>`
stats.modify++
hasDiff = true
x++
y++
} else if (x < point.x) {
htmlA += `<span class="diff-highlight diff-highlight-delete">${escapeHtml(charsA[x])}</span>`
htmlB += ''
stats.delete++
hasDiff = true
x++
} else {
htmlA += ''
htmlB += `<span class="diff-highlight diff-highlight-insert">${escapeHtml(charsB[y])}</span>`
stats.insert++
hasDiff = true
y++
}
}
// 仅匹配点 (point.x, point.y) 为相同(终点 (n,m) 不是匹配点,不输出)
if (x === point.x && y === point.y && point.x < charsA.length && point.y < charsB.length) {
htmlA += escapeHtml(charsA[x])
htmlB += escapeHtml(charsB[y])
stats.same++
x++
y++
}
// 差异的字符:修改用黄色,仅左为删除(红),仅右为插入(蓝)
if (x < nextPoint.x && y < nextPoint.y) {
htmlA += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsA[x])}</span>`
htmlB += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsB[y])}</span>`
stats.modify++
hasDiff = true
x++
y++
} else if (x < nextPoint.x) {
htmlA += `<span class="diff-highlight diff-highlight-delete">${escapeHtml(charsA[x])}</span>`
htmlB += ''
stats.delete++
hasDiff = true
x++
} else if (y < nextPoint.y) {
htmlA += ''
htmlB += `<span class="diff-highlight diff-highlight-insert">${escapeHtml(charsB[y])}</span>`
stats.insert++
hasDiff = true
y++
}
}
// 处理剩余字符
while (x < charsA.length && y < charsB.length) {
if (charsA[x] === charsB[y]) {
htmlA += escapeHtml(charsA[x])
htmlB += escapeHtml(charsB[y])
stats.same++
} else {
htmlA += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsA[x])}</span>`
htmlB += `<span class="diff-highlight diff-highlight-modify">${escapeHtml(charsB[y])}</span>`
stats.modify++
hasDiff = true
}
x++
y++
}
while (x < charsA.length) {
htmlA += `<span class="diff-highlight diff-highlight-delete">${escapeHtml(charsA[x])}</span>`
stats.delete++
hasDiff = true
x++
}
while (y < charsB.length) {
htmlB += `<span class="diff-highlight diff-highlight-insert">${escapeHtml(charsB[y])}</span>`
stats.insert++
hasDiff = true
y++
}
resultA.push({
type: hasDiff ? 'modify' : (lineA ? 'delete' : 'insert'),
content: lineA,
html: htmlA || escapeHtml(lineA),
lineNumber: i + 1,
inlineHighlight: hasDiff
})
resultB.push({
type: hasDiff ? 'modify' : (lineB ? 'insert' : 'delete'),
content: lineB,
html: htmlB || escapeHtml(lineB),
lineNumber: i + 1,
inlineHighlight: hasDiff
})
}
}
return {left: resultA, right: resultB, stats}
}
const isNullOrUndefined = (value) => {
return value === undefined || value === null
}
const isNotNullAndUndefined = (value) => {
return value !== undefined && value !== null
}
// 判断是否为基本类型(除字符串外)
const isPrimitiveTypeOrNon = (value) => {
return isNullOrUndefined(value) ||
typeof value === 'number' ||
typeof value === 'boolean' ||
(typeof value === 'string' && false)
}
const isPrimitiveType = (value) => {
return isNotNullAndUndefined(value) && (
typeof value === 'number' ||
typeof value === 'boolean' ||
(typeof value === 'string' && false)
)
}
// 判断是否为字符串
const isStringTypeOrNon = (value) => {
return isNullOrUndefined(value) || typeof value === 'string'
}
const isStringType = (value) => {
return isNotNullAndUndefined(value) && typeof value === 'string'
}
// 判断是否为map
const isMapTypeOrNon = (value) => {
return isNullOrUndefined(value) || (typeof value === 'object' && !Array.isArray(value))
}
const isMapType = (value) => {
return isNotNullAndUndefined(value) && typeof value === 'object' && !Array.isArray(value)
}
// 判断是否为list
const isListTypeOrNon = (value) => {
return isNullOrUndefined(value) || Array.isArray(value)
}
const isListType = (value) => {
return isNotNullAndUndefined(value) && Array.isArray(value)
}
// 判断是否为叶子节点(基本类型或字符串)
const isLeafNodeOrNon = (value) => {
return isNullOrUndefined(value) ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'string'
}
// 计算字符串的最长公共子串长度
const longestCommonSubstring = (strA, strB) => {
if (!strA || !strB) return 0
const m = strA.length
const n = strB.length
let maxLen = 0
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (strA[i - 1] === strB[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
maxLen = Math.max(maxLen, dp[i][j])
} else {
dp[i][j] = 0
}
}
}
return maxLen
}
// 计算字符串相似度(公共子串占全体的比例)
const stringSimilarity = (strA, strB) => {
if (strA === strB) return {type: 'same', similarity: 1.0}
const maxLen = Math.max(strA.length, strB.length)
if (maxLen === 0) return {type: 'same', similarity: 1.0}
const lcsLen = longestCommonSubstring(strA, strB)
if (lcsLen === 0) {
return {type: 'different', similarity: 0.0}
}
const similarity = lcsLen / maxLen
return {type: 'similar', similarity}
}
// 节点对比结果类型
const NodeComparisonResult = {
SAME: 'same', // 相同
SIMILAR: 'similar', // 相似
DIFFERENT: 'different' // 不同
}
// 对比两个JSON节点返回对比结果和相似度
const compareJsonNodes = (nodeA, nodeB, ignoreOrder = false) => {
const result = (type, similarity) => ({ type, similarity, nodeA, nodeB })
if (isNullOrUndefined(nodeA) && isNullOrUndefined(nodeB)) {
return nodeA === nodeB ? result(NodeComparisonResult.SAME, 1.0) : result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isNullOrUndefined(nodeA) || isNullOrUndefined(nodeB)) {
return result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isPrimitiveType(nodeA) && isPrimitiveType(nodeB)) {
return nodeA === nodeB ? result(NodeComparisonResult.SAME, 1.0) : result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isStringType(nodeA) && isStringType(nodeB)) {
const { type, similarity } = stringSimilarity(nodeA, nodeB)
return result(type, similarity)
}
if (typeof nodeA !== typeof nodeB) {
return result(NodeComparisonResult.DIFFERENT, 0.0)
}
if (isMapType(nodeA) && isMapType(nodeB)) return compareMaps(nodeA, nodeB, ignoreOrder)
if (isListType(nodeA) && isListType(nodeB)) {
// 根据参数选择是否忽略列表顺序
return ignoreOrder ? compareListsIgnoreOrder(nodeA, nodeB, ignoreOrder) : compareLists(nodeA, nodeB, ignoreOrder)
}
return result(NodeComparisonResult.DIFFERENT, 0.0)
}
// 对比Map对象
const compareMaps = (mapA, mapB, ignoreOrder = false) => {
const keysA = Object.keys(mapA).sort()
const keysB = Object.keys(mapB).sort()
const setA = new Set(keysA)
const setB = new Set(keysB)
const allKeys = new Set([...keysA, ...keysB])
const comparisons = []
let allSame = true
let allDifferent = true
let allInsert = true
let allDelete = true
for (const key of allKeys) {
const hasKeyA = setA.has(key)
const hasKeyB = setB.has(key)
const valueComparison = compareJsonNodes(
hasKeyA ? mapA[key] : undefined,
hasKeyB ? mapB[key] : undefined,
ignoreOrder
)
if (hasKeyA && hasKeyB && valueComparison.type === NodeComparisonResult.DIFFERENT) {
valueComparison.type = NodeComparisonResult.SIMILAR
valueComparison.similarity = 0.5
}
if (valueComparison.type === NodeComparisonResult.SAME) {
allDifferent = false
} else if (valueComparison.type === NodeComparisonResult.SIMILAR) {
allSame = false
allDifferent = false
allInsert = false
allDelete = false
} else if (valueComparison.type === NodeComparisonResult.DIFFERENT) {
allSame = false
if (isNullOrUndefined(mapA) || hasKeyA) allInsert = false
if (isNullOrUndefined(mapB) || hasKeyB) allDelete = false
}
comparisons.push({ ...valueComparison, key })
}
const base = { nodeA: mapA, nodeB: mapB, children: comparisons }
if (allSame) {
return { type: NodeComparisonResult.SAME, similarity: 1.0, ...base }
}
if (allDifferent && !allInsert && !allDelete) {
return { type: NodeComparisonResult.DIFFERENT, similarity: 0.0, ...base }
}
const avgSimilarity = comparisons.length > 0
? comparisons.reduce((sum, c) => sum + c.similarity, 0) / comparisons.length
: 0
return { type: NodeComparisonResult.SIMILAR, similarity: avgSimilarity, ...base }
}
// 对比List数组- 忽略顺序,使用贪心算法寻找最佳匹配
const compareListsIgnoreOrder = (listA, listB, ignoreOrder = false) => {
const n = listA.length
const m = listB.length
// 计算所有元素对的相似度矩阵
const similarityMatrix = []
for (let i = 0; i < n; i++) {
similarityMatrix[i] = []
for (let j = 0; j < m; j++) {
const comp = compareJsonNodes(listA[i], listB[j], ignoreOrder)
similarityMatrix[i][j] = comp
}
}
// 使用贪心算法找到最佳匹配(不考虑顺序)
// 每次选择相似度最高的未匹配对
const matches = []
const usedA = new Set()
const usedB = new Set()
// 创建所有可能的匹配对,按相似度降序排序
const allPairs = []
for (let i = 0; i < n; i++) {
for (let j = 0; j < m; j++) {
allPairs.push({
indexA: i,
indexB: j,
similarity: similarityMatrix[i][j].similarity,
comparison: similarityMatrix[i][j]
})
}
}
allPairs.sort((a, b) => b.similarity - a.similarity)
// 贪心匹配:选择相似度最高的未匹配对
for (const pair of allPairs) {
if (!usedA.has(pair.indexA) && !usedB.has(pair.indexB)) {
matches.push({
indexA: pair.indexA,
indexB: pair.indexB,
comparison: pair.comparison
})
usedA.add(pair.indexA)
usedB.add(pair.indexB)
}
}
// 添加未匹配的A中元素删除
for (let i = 0; i < n; i++) {
if (!usedA.has(i)) {
matches.push({
indexA: i,
indexB: undefined,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA[i],
nodeB: undefined
}
})
}
}
// 添加未匹配的B中元素插入
for (let j = 0; j < m; j++) {
if (!usedB.has(j)) {
matches.push({
indexA: undefined,
indexB: j,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: undefined,
nodeB: listB[j]
}
})
}
}
// 按原始顺序排序匹配结果先A后B保持展示的一致性
matches.sort((a, b) => {
if (a.indexA !== undefined && b.indexA !== undefined) {
return a.indexA - b.indexA
}
if (a.indexA !== undefined) return -1
if (b.indexA !== undefined) return 1
if (a.indexB !== undefined && b.indexB !== undefined) {
return a.indexB - b.indexB
}
return 0
})
// 判断父节点类型
const totalElements = n + m
const matchedElements = matches.filter(m => m.indexA !== undefined && m.indexB !== undefined).length
const sameMatches = matches.filter(m =>
m.indexA !== undefined &&
m.indexB !== undefined &&
m.comparison.type === NodeComparisonResult.SAME
).length
const differentMatches = matches.filter(m =>
m.comparison.type === NodeComparisonResult.DIFFERENT
).length
if (totalElements === 0) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (n !== 0 && m !== 0 && differentMatches === totalElements) {
return {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (sameMatches === matchedElements && matchedElements === totalElements) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
// 计算加权平均相似度
const totalSimilarity = matches.reduce((sum, m) => sum + m.comparison.similarity, 0)
const avgSimilarity = totalElements > 0 ? totalSimilarity / totalElements : 0
return {
type: NodeComparisonResult.SIMILAR,
similarity: avgSimilarity,
nodeA: listA,
nodeB: listB,
matches
}
}
// 对比List数组- 寻找最佳匹配使相似度最高
const compareLists = (listA, listB, ignoreOrder = false) => {
const n = listA.length
const m = listB.length
// 计算所有元素对的相似度矩阵
const similarityMatrix = []
for (let i = 0; i < n; i++) {
similarityMatrix[i] = []
for (let j = 0; j < m; j++) {
const comp = compareJsonNodes(listA[i], listB[j], ignoreOrder)
similarityMatrix[i][j] = comp
}
}
// 使用动态规划寻找最佳匹配(最大化总相似度)
// dp[i][j] 表示 listA[0..i-1] 和 listB[0..j-1] 的最佳匹配总相似度
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
const path = Array(n + 1).fill(null).map(() => Array(m + 1).fill(null))
// 初始化边界情况当j=0时只能跳过A中的元素
for (let i = 1; i <= n; i++) {
path[i][0] = 'skipA'
}
// 初始化边界情况当i=0时只能跳过B中的元素
for (let j = 1; j <= m; j++) {
path[0][j] = 'skipB'
}
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
const matchSimilarity = similarityMatrix[i - 1][j - 1].similarity
const matchScore = dp[i - 1][j - 1] + matchSimilarity
const skipAScore = dp[i - 1][j]
const skipBScore = dp[i][j - 1]
if (matchScore >= skipAScore && matchScore >= skipBScore) {
dp[i][j] = matchScore
path[i][j] = 'match'
} else if (skipAScore >= skipBScore) {
dp[i][j] = skipAScore
path[i][j] = 'skipA'
} else {
dp[i][j] = skipBScore
path[i][j] = 'skipB'
}
}
}
// 回溯构建匹配结果
const matches = []
let i = n, j = m
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && path[i][j] === 'match') {
matches.unshift({
indexA: i - 1,
indexB: j - 1,
comparison: similarityMatrix[i - 1][j - 1]
})
i--
j--
} else if (i > 0 && path[i][j] === 'skipA') {
matches.unshift({
indexA: i - 1,
indexB: undefined,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA[i - 1],
nodeB: undefined
}
})
i--
} else if (j > 0) {
matches.unshift({
indexA: undefined,
indexB: j - 1,
comparison: {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: undefined,
nodeB: listB[j - 1]
}
})
j--
} else {
break
}
}
// 判断父节点类型
const totalElements = n + m
const matchedElements = matches.filter(m => m.indexA !== null && m.indexB !== null).length
const sameMatches = matches.filter(m =>
m.indexA !== undefined &&
m.indexB !== undefined &&
m.comparison.type === NodeComparisonResult.SAME
).length
const differentMatches = matches.filter(m =>
m.comparison.type === NodeComparisonResult.DIFFERENT
).length
if (totalElements === 0) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (n !== 0 && m !== 0 && differentMatches === totalElements) {
return {
type: NodeComparisonResult.DIFFERENT,
similarity: 0.0,
nodeA: listA,
nodeB: listB,
matches
}
}
if (sameMatches === matchedElements && matchedElements === totalElements) {
return {
type: NodeComparisonResult.SAME,
similarity: 1.0,
nodeA: listA,
nodeB: listB,
matches
}
}
// 计算加权平均相似度
const totalSimilarity = matches.reduce((sum, m) => sum + m.comparison.similarity, 0)
const avgSimilarity = totalElements > 0 ? totalSimilarity / totalElements : 0
return {
type: NodeComparisonResult.SIMILAR,
similarity: avgSimilarity,
nodeA: listA,
nodeB: listB,
matches
}
}
// 在展示行末尾添加/移除逗号的辅助
const addTrailingComma = (line, lastNotEmptyRef) => {
if (line.content !== '') {
line.content += ','
lastNotEmptyRef.current = line
}
}
const removeTrailingComma = (lastNotEmptyRef) => {
if (lastNotEmptyRef.current !== undefined) {
lastNotEmptyRef.current.content = lastNotEmptyRef.current.content.slice(0, -1)
}
}
// 将对比结果转换为展示格式
const convertComparisonToDisplay = (comparison, indent = 0) => {
const indentStr = ' '.repeat(indent)
const childIndentStr = ' '.repeat(indent + 1)
const isLeaf = !comparison.children && !comparison.matches
// 根据展示规则确定类型
const { type, nodeA, nodeB } = comparison
let displayType = 'same'
if (type === NodeComparisonResult.DIFFERENT) {
if (nodeA !== undefined && nodeB === undefined) displayType = 'delete'
else if (nodeA === undefined && nodeB !== undefined) displayType = 'insert'
else displayType = 'different'
} else if (type === NodeComparisonResult.SIMILAR && isLeaf) {
displayType = 'modify'
}
// 处理 Map对象
if (!isLeaf && isMapType(nodeA) && isMapType(nodeB)) {
const leftLines = []
const rightLines = []
const sortedKeys = [...new Set(comparison.children.map(c => c.key).filter(Boolean))].sort()
const commaLeft = { current: undefined }
const commaRight = { current: undefined }
leftLines.push({ type: displayType, content: indentStr + '{' })
rightLines.push({ type: displayType, content: indentStr + '{' })
for (const key of sortedKeys) {
const child = comparison.children.find(c => c.key === key)
const childResult = convertComparisonToDisplay(child, indent + 1)
if (childResult.left.length === 0 && childResult.right.length === 0) continue
const leftFirst = childResult.left[0]?.content ?? null
const rightFirst = childResult.right[0]?.content ?? null
const formatFirst = (content) => (content === null || content === '' ? '' : content.trimStart())
leftLines.push({
type: childResult.left[0].type,
content: leftFirst === null || leftFirst === '' ? '' : `${childIndentStr}"${key}": ${formatFirst(leftFirst)}`
})
rightLines.push({
type: childResult.right[0].type,
content: rightFirst === null || rightFirst === '' ? '' : `${childIndentStr}"${key}": ${formatFirst(rightFirst)}`
})
const maxLength = Math.max(childResult.left.length, childResult.right.length)
for (let j = 1; j < maxLength; j++) {
leftLines.push(
j < childResult.left.length ? childResult.left[j] : { type: 'insert', content: '' }
)
rightLines.push(
j < childResult.right.length ? childResult.right[j] : { type: 'delete', content: '' }
)
}
addTrailingComma(leftLines[leftLines.length - 1], commaLeft)
addTrailingComma(rightLines[rightLines.length - 1], commaRight)
}
removeTrailingComma(commaLeft)
removeTrailingComma(commaRight)
leftLines.push({ type: displayType, content: indentStr + '}' })
rightLines.push({ type: displayType, content: indentStr + '}' })
return { left: leftLines, right: rightLines }
}
// 处理 List数组
if (!isLeaf && isListType(nodeA) && isListType(nodeB)) {
const leftLines = []
const rightLines = []
const commaLeft = { current: undefined }
const commaRight = { current: undefined }
leftLines.push({ type: displayType, content: indentStr + '[' })
rightLines.push({ type: displayType, content: indentStr + '[' })
for (const match of comparison.matches) {
const childResult = convertComparisonToDisplay(match.comparison, indent + 1)
if (childResult.left.length === 0 && childResult.right.length === 0) continue
const leftFirst = childResult.left[0]?.content
const rightFirst = childResult.right[0]?.content
const leftFirstLine = {
type: childResult.left[0].type,
content: leftFirst === undefined || leftFirst === '' ? '' : `${childIndentStr}${leftFirst.trimStart()}`
}
const rightFirstLine = {
type: childResult.right[0].type,
content: rightFirst === undefined || rightFirst === '' ? '' : `${childIndentStr}${rightFirst.trimStart()}`
}
leftLines.push(leftFirstLine)
rightLines.push(rightFirstLine)
let leftLastLine = leftFirstLine
let rightLastLine = rightFirstLine
const maxLength = Math.max(childResult.left.length, childResult.right.length)
for (let j = 1; j < maxLength; j++) {
const leftLine = j < childResult.left.length ? childResult.left[j] : { type: 'insert', content: '' }
const rightLine = j < childResult.right.length ? childResult.right[j] : { type: 'delete', content: '' }
if (leftLine.content !== '') leftLastLine = leftLine
if (rightLine.content !== '') rightLastLine = rightLine
leftLines.push(leftLine)
rightLines.push(rightLine)
}
addTrailingComma(leftLastLine, commaLeft)
addTrailingComma(rightLastLine, commaRight)
}
removeTrailingComma(commaLeft)
removeTrailingComma(commaRight)
leftLines.push({ type: displayType, content: indentStr + ']' })
rightLines.push({ type: displayType, content: indentStr + ']' })
return { left: leftLines, right: rightLines }
}
// 处理叶子节点
const leftLines = []
const rightLines = []
const leftData = nodeA !== undefined ? formatJsonData(nodeA, indent) : ['']
const rightData = nodeB !== undefined ? formatJsonData(nodeB, indent) : ['']
if (displayType === 'different') {
for (let i = 0; i < leftData.length; i++) {
leftLines.push({ type: 'delete', content: leftData[i] })
rightLines.push({ type: 'delete', content: '' })
}
for (let i = 0; i < rightData.length; i++) {
leftLines.push({ type: 'insert', content: '' })
rightLines.push({ type: 'insert', content: (i === 0 && rightData[i] !== '' ? indentStr : '') + rightData[i] })
}
} else {
const maxLineSize = Math.max(leftData.length, rightData.length)
for (let i = 0; i < maxLineSize; i++) {
const leftContent = i < leftData.length ? leftData[i] : undefined
const rightContent = i < rightData.length ? (i === 0 && rightData[i] !== '' ? indentStr : '') + rightData[i] : undefined
leftLines.push({ type: displayType, content: leftContent ?? '' })
rightLines.push({ type: displayType, content: rightContent ?? '' })
}
}
return { left: leftLines, right: rightLines }
}
// 格式化JSON值为字符串
const formatJsonData = (value, indent = 0) => {
const indentStr = ' '.repeat(indent)
if (value === null) {
return ["null"]
}
if (typeof value === 'string') {
return [`"${value}"`]
}
if (typeof value === 'number' || typeof value === 'boolean') {
return [String(value)]
}
if (Array.isArray(value)) {
if (value.length === 0) return ['[]']
const lines = ["["]
for (let i = 0; i < value.length; i++) {
const itemLines = formatJsonData(value[i], indent + 1)
for (let j = 0; j < itemLines.length; j++) {
let content = j === 0 ? `${indentStr} ` : ''
content = content + itemLines[j]
if (i < value.length - 1 && j === itemLines.length - 1) {
content = content + ','
}
lines.push(content)
}
}
lines.push(`${indentStr}]`)
return lines
}
if (typeof value === 'object') {
const keys = Object.keys(value).sort()
if (keys.length === 0) return ['{}']
const lines = ['{']
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const val = value[key]
const itemLines = formatJsonData(val, indent + 1)
for (let j = 0; j < itemLines.length; j++) {
let content = j === 0 ? `${indentStr} "${key}": ` : ''
content = content + itemLines[j]
if (i < value.length - 1 && j === itemLines.length - 1) {
content = content + ','
}
lines.push(content)
}
}
lines.push(`${indentStr}}`)
return lines
}
return [String(value)]
}
// 递归计算统计信息
const calculateStats = (comparison) => {
const stats = {same: 0, insert: 0, delete: 0, modify: 0}
const isLeaf = isLeafNodeOrNon(comparison.nodeA) || isLeafNodeOrNon(comparison.nodeB)
if (comparison.type === NodeComparisonResult.DIFFERENT) {
// 不同节点:根据展示规则,左侧显示为删除,右侧显示为插入
if (comparison.nodeA !== undefined && comparison.nodeB === undefined) {
stats.delete++
} else if (comparison.nodeA === undefined && comparison.nodeB !== undefined) {
stats.insert++
} else {
// 两个节点都存在但不同
stats.delete++ // 左侧
stats.insert++ // 右侧
}
} else if (comparison.type === NodeComparisonResult.SAME ||
comparison.type === NodeComparisonResult.SIMILAR) {
// 相同或相似节点
if (isLeaf) {
// 叶子节点:视作修改
if (comparison.type === NodeComparisonResult.SAME) {
stats.same++;
} else {
stats.modify++;
}
} else {
if (comparison.children) {
comparison.children.forEach(child => {
const childStats = calculateStats(child)
stats.same += childStats.same
stats.insert += childStats.insert
stats.delete += childStats.delete
stats.modify += childStats.modify
})
}
if (comparison.matches) {
comparison.matches.forEach(match => {
const matchStats = calculateStats(match.comparison)
stats.same += matchStats.same
stats.insert += matchStats.insert
stats.delete += matchStats.delete
stats.modify += matchStats.modify
})
}
}
}
return stats
}
// JSON对比按节点维度
const compareJson = (textA, textB) => {
try {
const jsonA = JSON.parse(textA)
const jsonB = JSON.parse(textB)
// 执行节点对比,传递忽略列表顺序的参数
const comparison = compareJsonNodes(jsonA, jsonB, ignoreListOrder.value)
console.log(comparison)
// 转换为展示格式
const displayResult = convertComparisonToDisplay(comparison, 0)
console.log(displayResult)
// 计算统计信息
const stats = calculateStats(comparison)
// 为每行添加行号
const maxLines = Math.max(displayResult.left.length, displayResult.right.length)
const leftLines = []
const rightLines = []
for (let i = 0; i < maxLines; i++) {
const leftLine = displayResult.left[i] || {type: 'same', content: ''}
const rightLine = displayResult.right[i] || {type: 'same', content: ''}
leftLines.push({
type: leftLine.type,
content: leftLine.content,
lineNumber: i + 1
})
rightLines.push({
type: rightLine.type,
content: rightLine.content,
lineNumber: i + 1
})
}
return {
left: leftLines,
right: rightLines,
stats
}
} catch (e) {
throw new Error('JSON解析失败' + e.message)
}
}
// 执行对比
const performCompare = () => {
if (!leftText.value.trim() && !rightText.value.trim()) {
showToast('请输入要对比的内容')
return
}
try {
if (compareMode.value === 'json') {
compareResult.value = compareJson(leftText.value, rightText.value)
} else {
// 文本对比
if (textSubMode.value === 'line') {
compareResult.value = compareTextByLine(leftText.value, rightText.value)
} else {
compareResult.value = compareTextByChar(leftText.value, rightText.value)
}
}
// 保存到历史记录
saveToHistory()
showToast('对比完成', 'info', 2000)
} catch (e) {
showToast(e.message)
compareResult.value = null
}
}
// 复制结果
const copyResult = async () => {
if (!compareResult.value) {
showToast('没有对比结果可复制')
return
}
try {
const leftContent = compareResult.value.left.map(l => l.content).join('\n')
const rightContent = compareResult.value.right.map(l => l.content).join('\n')
const result = `文本A:\n${leftContent}\n\n文本B:\n${rightContent}`
await navigator.clipboard.writeText(result)
showToast('已复制对比结果到剪贴板', 'info', 2000)
} catch (e) {
showToast('复制失败:' + e.message)
}
}
const compareTest = () => {
let total = {
left: [],
right: [],
stats: {same: 0, insert: 0, delete: 0, modify: 0}
}
const cases = [
["[]","[\"a\"]"],
["[\"a\"]", "[]"],
["[[\"a\",\"b\"]]", "[]"],
["[]","[\"a\",\"b\"]"],
["[]","[[\"a\",\"b\"]]"],
["{}","{\"a\": {\"c\":\"d\"}}"],
["{}", "{\"a\":\"b\"}"],
["[\"a\",\"b\"]", "[]"],
["{\"a\": {\"c\":\"d\"}}", "[]"],
["{\"a\":\"b\"}", "{}"],
["{\"a\":\"a\",\"b\":\"b\",\"d\":null}", "{\"a\":\"a\",\"c\":\"c\"}"],
["[\"a\",\"b\",null]", "[\"a\",\"c\"]"],
["[{\"a\":\"a\"},{\"b\":\"b\"},null]", "[{\"a\":\"a\"},{\"c\":\"c\"}]"],
["{\"a\":\"a\",\"b\":\"b\",\"c\":null}", "{\"a\":\"a\",\"c\":\"c\"}"],
["[{\"c\":\"d\"}]","[[\"c\",\"d\"]]"],
["{\"a\": {\"c\":\"d\"}}","{\"a\": [\"c\",\"d\"]}"],
["{\"a\": {\"c\":\"d\"}}","[\"c\",\"d\"]"],
["{\"a\": {\"c\":\"d\"}}","{\"a\": null}"],
["{\"a\": null}","{\"a\": {\"c\":\"d\"}}"],
["{\"a\": {\"c\":\"d\"}}","{}"],
["{\"a\":\"a1\"}", "{\"b\":\"b2\"}"],
["{\"a\":{\"c\":\"d\",\"e\":\"f\"}}", "{\"b\":{\"c\":\"d\",\"m\":\"l\"}}"],
["{\"a\":{\"c\":\"d\",\"e\":\"f\"}}", "{\"a\":{\"c\":\"d\",\"e\":\"l\"}}"],
["{\"a\": {\"c\":\"d\"}}", "{\"a\":[\"c\",\"d\"]}"],
["[{\"a\":\"a1\"},{\"b\":\"b2\"}]", "[{\"c\":\"b1\"},{\"a\":\"a1\"}]"],
["[{\"a\":\"a1\"},{\"b\":\"b2\"}]", "[{\"b\":\"b1\"},{\"a\":\"a1\"}]"],
["[{\"a\":\"a1\"},{\"b\":\"b2\"}]", "[{\"c\":\"b1\"},{\"a\":\"a1\"}]"],
["{\"a\":{\"c\":\"d\",\"e\":\"f\"},\"c\":\"a\",\"d\":\"e\",\"g\":[\"a\",\"b\",\"c\"],\"h\":[\"a\"],\"i\":[{\"a\":\"a1\"},{\"b\":\"b2\"},{\"c\":\"b1\"}]}", "{\"b\":{\"c\":\"d\",\"m\":\"l\"},\"c\":\"a\",\"d\":\"f\",\"g\":[\"a\",\"c\",\"b\"],\"h\":[\"b\",\"a\"],\"i\":[{\"b\":\"b1\"},{\"a\":\"a1\"},{\"d\":\"b1\"}]}"],
["[[[\"a\",\"b\"],[]]]","[[[\"a\",\"b\",\"c\"],[\"b\"]],[\"c\"]]"]
]
for (let item of cases) {
console.log("=================================================")
console.log(item)
console.log("=================================================")
let res = compareJson(item[0], item[1])
total.left.push({type: 'same', content: ''}, {
type: 'same',
content: '====================================='
}, {type: 'same', content: ''}, {type: 'same', content: item[0]}, {type: 'same', content: ''}, {
type: 'same',
content: '-----------------------------------'
})
total.right.push({type: 'same', content: ''}, {
type: 'same',
content: '====================================='
}, {type: 'same', content: ''}, {type: 'same', content: item[1]}, {type: 'same', content: ''}, {
type: 'same',
content: '-----------------------------------'
})
total.left.push({type: 'same', content: JSON.stringify(res.stats)}, {
type: 'same',
content: '-----------------------------------'
})
total.right.push({type: 'same', content: JSON.stringify(res.stats)}, {
type: 'same',
content: '-----------------------------------'
})
total.left.push(...res.left)
total.right.push(...res.right)
}
compareResult.value = total
}
// 切换侧栏
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
// 格式化时间
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 截断文本
const truncateText = (text, maxLength) => {
if (!text) return ''
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
// 获取模式标签
const getModeLabel = (mode, subMode, ignoreOrder) => {
if (mode === 'json') {
return ignoreOrder ? 'JSON对比忽略列表顺序' : 'JSON对比'
} else {
return subMode === 'line' ? '文本对比(行维度)' : '文本对比(字符维度)'
}
}
// 保存到历史记录
const saveToHistory = () => {
// 如果两个输入框都为空,不保存
if (!leftText.value.trim() && !rightText.value.trim()) {
return
}
const historyItem = {
leftText: leftText.value,
rightText: rightText.value,
compareMode: compareMode.value,
textSubMode: textSubMode.value,
ignoreListOrder: ignoreListOrder.value,
time: Date.now()
}
// 从localStorage读取现有历史
let history = []
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
history = JSON.parse(stored)
}
} catch (e) {
console.error('读取历史记录失败', e)
}
// 检查是否与最新记录相同,如果相同则不保存
if (history.length > 0) {
const lastItem = history[0]
if (lastItem.leftText === historyItem.leftText &&
lastItem.rightText === historyItem.rightText &&
lastItem.compareMode === historyItem.compareMode &&
lastItem.textSubMode === historyItem.textSubMode &&
lastItem.ignoreListOrder === historyItem.ignoreListOrder) {
return
}
}
// 添加到开头
history.unshift(historyItem)
// 限制最多50条
if (history.length > MAX_HISTORY) {
history = history.slice(0, MAX_HISTORY)
}
// 保存到localStorage
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
loadHistoryList()
} catch (e) {
console.error('保存历史记录失败', e)
}
}
// 加载历史记录列表
const loadHistoryList = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
historyList.value = JSON.parse(stored)
}
} catch (e) {
console.error('加载历史记录失败', e)
historyList.value = []
}
}
// 加载历史记录
const loadHistory = (item) => {
leftText.value = item.leftText || ''
rightText.value = item.rightText || ''
compareMode.value = item.compareMode || 'text'
textSubMode.value = item.textSubMode || 'line'
ignoreListOrder.value = item.ignoreListOrder || false
updateLeftLineCount()
updateRightLineCount()
compareResult.value = null
}
onMounted(() => {
updateLeftLineCount()
updateRightLineCount()
loadHistoryList()
// compareTest()
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
if (toastTimer) {
clearTimeout(toastTimer)
}
})
</script>
<style scoped>
/* 浮层提示样式 */
.toast-notification {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
min-width: 300px;
max-width: 90%;
padding: 0.75rem 1rem;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
font-size: 0.875rem;
}
.toast-notification.error {
background: #fff5f5;
color: #c33;
border: 1px solid #ffcccc;
}
.toast-notification.info {
background: #f0f9ff;
color: #0369a1;
border: 1px solid #bae6fd;
}
.toast-content {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.toast-content svg,
.toast-content i {
flex-shrink: 0;
}
.toast-content span {
flex: 1;
word-break: break-word;
}
.toast-close-btn {
flex-shrink: 0;
margin-left: 0.75rem;
padding: 0.25rem;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
opacity: 0.6;
transition: all 0.2s;
width: 20px;
height: 20px;
}
.toast-close-btn:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.08);
}
.toast-notification.error .toast-close-btn:hover {
background: rgba(204, 51, 51, 0.15);
}
.toast-notification.info .toast-close-btn:hover {
background: rgba(3, 105, 161, 0.15);
}
.toast-close-btn:active {
opacity: 0.8;
}
.toast-close-btn svg,
.toast-close-btn i {
display: block;
font-size: 14px;
}
/* 浮层提示动画 */
.toast-enter-active {
animation: slideUp 0.3s ease-out;
}
.toast-leave-active {
animation: slideDown 0.3s ease-in;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
}
.tool-page {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
margin: -1rem;
padding: 0;
background: #ffffff;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background: #ffffff;
}
/* 侧栏样式 */
.sidebar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 300px;
background: #ffffff;
border-right: 1px solid #e5e5e5;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 10;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.sidebar-open {
transform: translateX(0);
}
.content-wrapper.sidebar-pushed {
margin-left: 300px;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #e5e5e5;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
}
.sidebar-header h3 {
margin: 0;
font-size: 1rem;
color: #1a1a1a;
font-weight: 500;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666666;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: #1a1a1a;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
background: #ffffff;
}
.empty-history {
padding: 2rem;
text-align: center;
color: #999999;
font-size: 0.875rem;
}
.history-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: #ffffff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #e5e5e5;
}
.history-item:hover {
background: #f5f5f5;
border-color: #d0d0d0;
}
.history-time {
font-size: 0.75rem;
color: #999999;
margin-bottom: 0.25rem;
}
.history-preview {
font-size: 0.875rem;
color: #1a1a1a;
}
.history-mode {
font-weight: 500;
color: #1a1a1a;
margin-bottom: 0.25rem;
font-size: 0.8125rem;
}
.history-text {
font-family: 'Courier New', monospace;
word-break: break-all;
color: #666666;
font-size: 0.8125rem;
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
transition: margin-left 0.3s ease;
}
/* 工具栏 */
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #ffffff;
border-bottom: 1px solid #e5e5e5;
flex-wrap: wrap;
}
.mode-selector {
display: flex;
gap: 0.25rem;
}
.mode-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #e5e5e5;
border-radius: 4px;
background: transparent;
color: #666666;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
border-color: #d0d0d0;
}
.mode-btn.active {
background: #1a1a1a;
color: #ffffff;
border-color: #1a1a1a;
}
.submode-selector {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid #e5e5e5;
}
.submode-btn {
padding: 0.25rem 0.5rem;
border: 1px solid #e5e5e5;
border-radius: 3px;
background: transparent;
color: #666666;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.submode-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
border-color: #d0d0d0;
}
.submode-btn.active {
background: #1a1a1a;
color: #ffffff;
border-color: #1a1a1a;
}
.switch-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.switch-input {
width: 36px;
height: 20px;
appearance: none;
background: #e5e5e5;
border-radius: 10px;
position: relative;
cursor: pointer;
transition: background 0.2s;
outline: none;
}
.switch-input:checked {
background: #1a1a1a;
}
.switch-input::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
top: 2px;
left: 2px;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.switch-input:checked::before {
transform: translateX(16px);
}
.switch-text {
font-size: 0.8125rem;
color: #666666;
font-weight: 500;
}
.switch-input:checked + .switch-text {
color: #1a1a1a;
}
.toolbar-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #e5e5e5;
border-radius: 4px;
background: transparent;
color: #666666;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: #f5f5f5;
color: #1a1a1a;
border-color: #d0d0d0;
}
.action-btn.primary {
background: #1a1a1a;
color: #ffffff;
border-color: #1a1a1a;
}
.action-btn.primary:hover {
background: #333333;
}
.action-btn i {
font-size: 14px;
}
/* 输入容器 */
.input-container {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.input-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
position: relative;
}
.sidebar-toggle {
position: absolute;
left: 0;
bottom: 1rem;
z-index: 5;
}
.toggle-btn {
width: 32px;
height: 48px;
background: #1a1a1a;
color: #ffffff;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
}
.toggle-btn:hover {
background: #333333;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: #fafafa;
border-bottom: 1px solid #e5e5e5;
}
.panel-label {
font-size: 0.875rem;
font-weight: 500;
color: #1a1a1a;
}
.panel-actions {
display: flex;
gap: 0.25rem;
}
.icon-btn {
padding: 0.25rem;
border: none;
background: transparent;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #666666;
}
.icon-btn:hover {
background: #e5e5e5;
color: #1a1a1a;
}
.icon-btn i {
font-size: 12px;
}
.editor-container {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
background: #ffffff;
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
padding: 1rem 0.5rem;
background: #fafafa;
border-right: 1px solid #e5e5e5;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #999999;
text-align: right;
user-select: none;
z-index: 1;
overflow-y: auto;
}
.line-number {
line-height: 1.6;
height: 22.4px;
}
.text-editor {
flex: 1;
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: none;
font-family: 'Courier New', monospace;
font-size: 14px;
resize: none;
outline: none;
background: #ffffff;
color: #1a1a1a;
line-height: 1.6;
overflow-y: auto;
}
.text-editor:focus {
background: #ffffff;
}
.text-editor::placeholder {
color: #999999;
}
/* 分割线 */
.splitter {
width: 1px;
background: #e5e5e5;
cursor: col-resize;
position: relative;
flex-shrink: 0;
transition: background 0.2s;
}
.splitter:hover {
background: #d0d0d0;
width: 2px;
}
.splitter::before {
content: '';
position: absolute;
left: -2px;
right: -2px;
top: 0;
bottom: 0;
}
/* 对比结果 */
.result-container {
border-top: 1px solid #e5e5e5;
display: flex;
flex-direction: column;
height: 50%;
min-height: 300px;
background: #ffffff;
}
.result-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #fafafa;
border-bottom: 1px solid #e5e5e5;
}
.result-label {
font-size: 0.875rem;
font-weight: 500;
color: #1a1a1a;
}
.result-stats {
display: flex;
gap: 1rem;
flex: 1;
}
.stat-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
}
.stat-label {
color: #666666;
}
.stat-value {
font-weight: 500;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
}
.stat-value.same {
background: #d1fae5;
color: #065f46;
}
.stat-value.insert {
background: #dbeafe;
color: #1e40af;
}
.stat-value.delete {
background: #fee2e2;
color: #991b1b;
}
.stat-value.modify {
background: #fef3c7;
color: #92400e;
}
.result-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* 对比结果全屏展示(覆盖整个网页) */
.result-fullscreen-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.result-fullscreen-inner {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.result-fullscreen-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #fafafa;
border-bottom: 1px solid #e5e5e5;
flex-shrink: 0;
}
.result-fullscreen-content {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.result-fullscreen-panel {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
}
.result-fullscreen-enter-active,
.result-fullscreen-leave-active {
transition: opacity 0.2s ease;
}
.result-fullscreen-enter-from,
.result-fullscreen-leave-to {
opacity: 0;
}
.result-panel {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
}
.left-result {
border-right: 1px solid #e5e5e5;
}
.result-line {
display: flex;
min-height: 22.4px;
}
.result-line .line-number {
width: 40px;
padding-right: 0.5rem;
text-align: right;
color: #999999;
user-select: none;
flex-shrink: 0;
}
.result-line .line-content {
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
.result-line .line-content.same {
color: #1a1a1a;
background: transparent;
}
.result-line .line-content.modify {
color: #1a1a1a;
background: #fef3c7;
}
.result-line .line-content.delete {
color: #991b1b;
background: #fee2e2;
text-decoration: line-through;
}
.result-line .line-content.insert {
color: #1e40af;
background: #dbeafe;
}
/* 单词/字符维度:整行不铺背景,仅高亮片段 */
.result-line .line-content.inline-diff.modify,
.result-line .line-content.inline-diff.delete,
.result-line .line-content.inline-diff.insert {
background: transparent;
color: #1a1a1a;
text-decoration: none;
}
/* v-html 内的元素无 data-v 属性,须用 :deep() 才能命中 */
.result-line .line-content :deep(.diff-highlight) {
font-weight: 500;
padding: 0 2px;
border-radius: 2px;
}
.result-line .line-content :deep(.diff-highlight-delete) {
background: #fee2e2;
color: #991b1b;
}
.result-line .line-content :deep(.diff-highlight-insert) {
background: #dbeafe;
color: #1e40af;
}
.result-line .line-content :deep(.diff-highlight-modify) {
background: #fef3c7;
color: #92400e;
}
@media (max-width: 768px) {
.tool-page {
padding: 0;
margin: -1rem;
height: calc(100vh - 64px + 2rem);
}
.toolbar {
padding: 0.5rem;
gap: 0.5rem;
}
.mode-btn {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
}
.submode-btn {
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
}
.action-btn {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.action-btn span {
display: none;
}
.input-panel {
min-width: 0;
}
.line-numbers {
width: 32px;
font-size: 12px;
}
.text-editor {
padding-left: 2.5rem;
}
.result-container {
height: 40%;
min-height: 200px;
}
.result-stats {
flex-direction: column;
gap: 0.25rem;
}
.sidebar {
width: 80%;
max-width: 300px;
}
.toast-notification {
bottom: 10px;
left: 1rem;
right: 1rem;
transform: none;
min-width: auto;
max-width: none;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(20px);
}
}
}
</style>