Files
ToolBox/src/views/Comparator.vue
T
renjue 11e1b3ca3b
CI / docker (push) Failing after 11s
初始化 ToolBox 开发者工具箱。
包含 JSON 格式化、文本/JSON 对比、编解码、变量名转换、二维码、时间戳与颜色转换等工具;修复编辑器行号同步;补充 Vitest 单元测试、Docker 部署与 Gitea CI;完善开源文档;支持通过环境变量配置站点标题、备案号及第三方集成。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:11:05 +08:00

1473 lines
35 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 class="size-limit">(最大 {{ maxInputLabel }})</span></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 ref="leftContainerRef" class="editor-container">
<div class="editor-body">
<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>
<!-- 可拖拽分割线 -->
<div
class="splitter"
@mousedown="startResize"
></div>
<!-- 右侧输入框 -->
<div class="input-panel" :style="{ width: rightPanelWidth + '%' }">
<div class="panel-header">
<span class="panel-label">文本 B <span class="size-limit">(最大 {{ maxInputLabel }})</span></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 ref="rightContainerRef" class="editor-container">
<div class="editor-body">
<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>
<!-- 对比结果区域 -->
<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'
import { useLineNumberEditor } from '../composables/useLineNumberEditor'
import { getByteLength, truncateToMaxBytes } from '../utils/byteUtils.js'
import {
escapeHtml,
compareTextByLine,
compareTextByChar,
compareJson as compareJsonWithOptions,
} from '../utils/comparator/index.js'
// JSON对比模式:最大 2MB(受算法复杂度限制,O(n*m) 动态规划)
// 文本对比模式:最大 5MB(算法复杂度较低)
const MAX_INPUT_BYTES_JSON = 2 * 1024 * 1024 // 2MB for JSON comparison
const MAX_INPUT_BYTES_TEXT = 5 * 1024 * 1024 // 5MB for text comparison
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 {
containerRef: leftContainerRef,
editorRef: leftEditorRef,
lineCount: leftLineCount,
updateLineCount: updateLeftLineCount,
initEditor: initLeftEditor,
} = useLineNumberEditor(() => leftText.value, {
onBeforeUpdate: () => applyInputLimit('left'),
})
const {
containerRef: rightContainerRef,
editorRef: rightEditorRef,
lineCount: rightLineCount,
updateLineCount: updateRightLineCount,
initEditor: initRightEditor,
} = useLineNumberEditor(() => rightText.value, {
onBeforeUpdate: () => applyInputLimit('right'),
})
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 maxInputBytes = computed(() => {
return compareMode.value === 'json' ? MAX_INPUT_BYTES_JSON : MAX_INPUT_BYTES_TEXT
})
const maxInputLabel = computed(() => {
return compareMode.value === 'json' ? '2MB' : '5MB'
})
// 提示消息
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 = ''
}
const compareJson = (textA, textB) => compareJsonWithOptions(textA, textB, ignoreListOrder.value)
// 执行对比
const performCompare = () => {
if (!leftText.value.trim() && !rightText.value.trim()) {
showToast('请输入要对比的内容')
return
}
// 检查输入大小限制
const maxBytes = compareMode.value === 'json' ? MAX_INPUT_BYTES_JSON : MAX_INPUT_BYTES_TEXT
const maxBytesLabel = compareMode.value === 'json' ? '2MB' : '5MB'
const leftBytes = getByteLength(leftText.value)
const rightBytes = getByteLength(rightText.value)
if (leftBytes > maxBytes || rightBytes > maxBytes) {
showToast(`输入内容超过 ${maxBytesLabel} 限制,请减小输入大小`, 'error', 4000)
return
}
// JSON对比模式:检查是否可能因复杂度过高导致性能问题
if (compareMode.value === 'json') {
try {
const jsonA = JSON.parse(leftText.value)
const jsonB = JSON.parse(rightText.value)
// 检查是否有大型数组(可能影响性能)
const checkLargeArray = (obj, depth = 0) => {
if (depth > 10) return false // 防止无限递归
if (Array.isArray(obj)) {
if (obj.length > 1000) return true
// 检查嵌套数组
for (const item of obj.slice(0, 10)) {
if (checkLargeArray(item, depth + 1)) return true
}
} else if (obj && typeof obj === 'object') {
for (const key in obj) {
if (checkLargeArray(obj[key], depth + 1)) return true
}
}
return false
}
if (checkLargeArray(jsonA) || checkLargeArray(jsonB)) {
showToast('检测到大型数组,对比可能需要较长时间,请耐心等待...', 'info', 5000)
}
} catch (e) {
// JSON解析失败会在下面处理
}
}
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 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) {
// 读取历史记录失败,忽略错误
}
// 检查是否与最新记录相同,如果相同则不保存
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) {
// 保存历史记录失败,忽略错误
}
}
// 加载历史记录列表
const loadHistoryList = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
historyList.value = JSON.parse(stored)
}
} catch (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(() => {
initLeftEditor()
initRightEditor()
loadHistoryList()
})
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-label .size-limit {
font-size: 0.75rem;
font-weight: 400;
color: #999999;
margin-left: 0.25rem;
}
.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;
}
.text-editor {
flex: 1;
width: 0;
min-width: 0;
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: hidden;
box-sizing: border-box;
}
.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;
}
.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>