1173 lines
29 KiB
Vue
1173 lines
29 KiB
Vue
<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-header">
|
||
<span class="history-type">{{ item.type === 'encode' ? '编码' : '解码' }}</span>
|
||
<span class="history-encoding">{{
|
||
item.encodingType === 'base64' ? 'Base64' :
|
||
item.encodingType === 'url' ? 'URL' :
|
||
item.encodingType === 'unicode' ? 'Unicode' :
|
||
item.encodingType === 'zlib' ? 'Zlib' :
|
||
item.encodingType
|
||
}}</span>
|
||
<span class="history-time">{{ formatTime(item.time) }}</span>
|
||
</div>
|
||
<div class="history-preview">{{ truncateText(item.input, 50) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区域 -->
|
||
<div class="content-wrapper" :class="{ 'sidebar-pushed': sidebarOpen }">
|
||
<!-- 左侧输入区域 -->
|
||
<div class="left-panel" :style="{ width: leftPanelWidth + '%' }">
|
||
<div class="panel-toolbar">
|
||
<div class="view-tabs">
|
||
<button class="view-tab active">输入</button>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<div class="encoding-type-selector">
|
||
<button
|
||
@click="encodingType = 'base64'"
|
||
:class="['type-btn', { active: encodingType === 'base64' }]"
|
||
title="Base64编码"
|
||
>
|
||
Base64
|
||
</button>
|
||
<button
|
||
@click="encodingType = 'url'"
|
||
:class="['type-btn', { active: encodingType === 'url' }]"
|
||
title="URL编码"
|
||
>
|
||
URL
|
||
</button>
|
||
<button
|
||
@click="encodingType = 'unicode'"
|
||
:class="['type-btn', { active: encodingType === 'unicode' }]"
|
||
title="Unicode编码"
|
||
>
|
||
Unicode
|
||
</button>
|
||
<button
|
||
@click="encodingType = 'zlib'"
|
||
:class="['type-btn', { active: encodingType === 'zlib' }]"
|
||
title="Zlib 压缩/解压"
|
||
>
|
||
Zlib
|
||
</button>
|
||
</div>
|
||
<button @click="copyInputToClipboard" class="toolbar-icon-btn" title="复制">
|
||
<i class="far fa-copy"></i>
|
||
</button>
|
||
<button @click="pasteFromClipboard" class="toolbar-icon-btn" title="粘贴">
|
||
<i class="far fa-paste"></i>
|
||
</button>
|
||
<button @click="encode" class="toolbar-icon-btn" title="编码">
|
||
<i class="fa-solid fa-code"></i>
|
||
</button>
|
||
<button @click="decode" class="toolbar-icon-btn" title="解码">
|
||
<svg viewBox="150 -50 1100 1250" xmlns="http://www.w3.org/2000/svg" width="17" height="17"><path d="M285.352637 0.003641h111.956538v114.687184h-111.956538v282.621991a110.682235 110.682235 0 0 1-33.313896 81.282266 110.682235 110.682235 0 0 1-81.282266 33.313896 110.682235 110.682235 0 0 1 81.282266 33.313897 110.682235 110.682235 0 0 1 33.313896 81.282266v282.621991h111.956538v114.687184h-111.956538a188.050574 188.050574 0 0 1-80.007964-40.049493 93.570179 93.570179 0 0 1-34.67922-74.637691v-226.643722a110.682235 110.682235 0 0 0-33.313896-81.282267 110.682235 110.682235 0 0 0-81.282267-33.313896H0v-111.956537h55.978269a110.682235 110.682235 0 0 0 81.282266-33.313897 110.682235 110.682235 0 0 0 33.313896-81.282266V114.690825A113.776969 113.776969 0 0 1 285.261616 0.003641z m794.61835 0a113.776969 113.776969 0 0 1 114.687184 114.687184v226.643722a113.776969 113.776969 0 0 0 114.687185 114.687184H1365.323624v111.956538h-55.978268a113.776969 113.776969 0 0 0-114.687185 114.687184v226.643722a113.776969 113.776969 0 0 1-114.687184 114.687184h-111.956537V909.309175h111.956537V626.687184a113.776969 113.776969 0 0 1 114.687184-114.687184 113.776969 113.776969 0 0 1-114.687184-114.687184V114.690825h-111.956537V0.003641h111.956537zM682.661812 682.665453a54.612945 54.612945 0 0 1 55.978269 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 54.612945 54.612945 0 0 1-80.007965 0 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z m-226.643721 0a54.612945 54.612945 0 0 1 55.978268 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 52.246384 52.246384 0 0 1-40.049493 17.294099 59.164024 59.164024 0 0 1-58.708916-58.708916 52.246384 52.246384 0 0 1 17.294099-40.049493 58.799937 58.799937 0 0 1 41.505839-15.837754z m453.287443 0a58.799937 58.799937 0 0 1 41.323795 16.019797 52.246384 52.246384 0 0 1 17.294099 40.049493 59.164024 59.164024 0 0 1-58.708916 58.708916 52.246384 52.246384 0 0 1-40.049493-17.294099 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z" fill="#666666" p-id="26339"></path></svg>
|
||
</button>
|
||
<button @click="clearAll" class="toolbar-icon-btn" title="清空">
|
||
<i class="far fa-trash-can"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div ref="inputContainerRef" class="editor-container">
|
||
<div class="editor-body">
|
||
<div class="line-numbers">
|
||
<div v-for="n in inputLineCount" :key="n" class="line-number">{{ n }}</div>
|
||
</div>
|
||
<textarea
|
||
ref="inputEditorRef"
|
||
v-model="inputText"
|
||
@input="updateInputLineCount"
|
||
placeholder="请输入要编码或解码的文本"
|
||
class="text-editor"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-toggle">
|
||
<button @click="toggleSidebar" class="toggle-btn">
|
||
{{ sidebarOpen ? '◀' : '▶' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 可拖拽分割线 -->
|
||
<div
|
||
class="splitter"
|
||
@mousedown="startResize"
|
||
></div>
|
||
|
||
<!-- 右侧输出区域 -->
|
||
<div class="right-panel" :style="{ width: rightPanelWidth + '%' }">
|
||
<div class="panel-toolbar">
|
||
<div class="view-tabs">
|
||
<button class="view-tab active">输出</button>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<button @click="copyOutputToClipboard" class="toolbar-icon-btn" title="复制输出">
|
||
<i class="far fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div ref="outputContainerRef" class="editor-container">
|
||
<div class="editor-body">
|
||
<div class="line-numbers">
|
||
<div v-for="n in outputLineCount" :key="n" class="line-number">{{ n }}</div>
|
||
</div>
|
||
<textarea
|
||
ref="outputEditorRef"
|
||
v-model="outputText"
|
||
readonly
|
||
class="text-editor output-editor"
|
||
placeholder="编码或解码结果将显示在这里"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||
import { zlibSync, decompressSync } from 'fflate'
|
||
import { useLineNumberEditor } from '../composables/useLineNumberEditor'
|
||
|
||
const inputText = ref('')
|
||
const outputText = ref('')
|
||
const encodingType = ref('base64') // 'base64'、'url'、'unicode' 或 'zlib'
|
||
const leftPanelWidth = ref(50)
|
||
const rightPanelWidth = ref(50)
|
||
const isResizing = ref(false)
|
||
const {
|
||
containerRef: inputContainerRef,
|
||
editorRef: inputEditorRef,
|
||
lineCount: inputLineCount,
|
||
updateLineCount: updateInputLineCount,
|
||
initEditor: initInputEditor,
|
||
} = useLineNumberEditor(() => inputText.value)
|
||
const {
|
||
containerRef: outputContainerRef,
|
||
editorRef: outputEditorRef,
|
||
lineCount: outputLineCount,
|
||
updateLineCount: updateOutputLineCount,
|
||
initEditor: initOutputEditor,
|
||
} = useLineNumberEditor(() => outputText.value)
|
||
const sidebarOpen = ref(false)
|
||
|
||
// 历史记录
|
||
const historyList = ref([])
|
||
const STORAGE_KEY = 'encoder-decoder-history'
|
||
const MAX_HISTORY = 50
|
||
|
||
// 提示消息
|
||
const toastMessage = ref('')
|
||
const toastType = ref('error') // 'error' 或 'info'
|
||
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 = ''
|
||
}
|
||
|
||
// 监听输出变化,同步行号
|
||
watch(() => outputText.value, () => {
|
||
updateOutputLineCount()
|
||
})
|
||
|
||
// Zlib: Uint8Array -> Base64(用于显示/复制)
|
||
const bytesToBase64 = (bytes) => {
|
||
let binary = ''
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
binary += String.fromCharCode(bytes[i])
|
||
}
|
||
return btoa(binary)
|
||
}
|
||
|
||
// Zlib: Base64 -> Uint8Array(自动去除空白字符)
|
||
const base64ToBytes = (str) => {
|
||
const clean = str.replace(/\s/g, '')
|
||
const binary = atob(clean)
|
||
const bytes = new Uint8Array(binary.length)
|
||
for (let i = 0; i < binary.length; i++) {
|
||
bytes[i] = binary.charCodeAt(i)
|
||
}
|
||
return bytes
|
||
}
|
||
|
||
// Unicode 编码
|
||
const encodeUnicode = (text) => {
|
||
let result = ''
|
||
let i = 0
|
||
|
||
while (i < text.length) {
|
||
const codePoint = text.codePointAt(i)
|
||
|
||
if (codePoint > 0xFFFF) {
|
||
// 超出 BMP 的字符,使用代理对表示
|
||
const high = Math.floor((codePoint - 0x10000) / 0x400) + 0xD800
|
||
const low = ((codePoint - 0x10000) % 0x400) + 0xDC00
|
||
result += '\\u' + high.toString(16).toUpperCase().padStart(4, '0')
|
||
result += '\\u' + low.toString(16).toUpperCase().padStart(4, '0')
|
||
i += 2 // 代理对占用两个字符位置
|
||
} else {
|
||
// BMP 字符
|
||
result += '\\u' + codePoint.toString(16).toUpperCase().padStart(4, '0')
|
||
i++
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// Unicode 解码
|
||
const decodeUnicode = (text) => {
|
||
try {
|
||
let result = ''
|
||
let i = 0
|
||
|
||
while (i < text.length) {
|
||
// 匹配 \uXXXX 格式
|
||
if (text[i] === '\\' && i + 1 < text.length && text[i + 1] === 'u' && i + 5 < text.length) {
|
||
const hex = text.substring(i + 2, i + 6)
|
||
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||
const code1 = parseInt(hex, 16)
|
||
|
||
// 检查是否是高代理(surrogate high)
|
||
if (code1 >= 0xD800 && code1 <= 0xDBFF && i + 11 < text.length) {
|
||
// 检查下一个是否是低代理
|
||
if (text[i + 6] === '\\' && text[i + 7] === 'u') {
|
||
const hex2 = text.substring(i + 8, i + 12)
|
||
if (/^[0-9a-fA-F]{4}$/.test(hex2)) {
|
||
const code2 = parseInt(hex2, 16)
|
||
// 检查是否是低代理(surrogate low)
|
||
if (code2 >= 0xDC00 && code2 <= 0xDFFF) {
|
||
// 组合代理对
|
||
const codePoint = 0x10000 + ((code1 - 0xD800) << 10) + (code2 - 0xDC00)
|
||
result += String.fromCodePoint(codePoint)
|
||
i += 12
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 普通字符或单独的代理
|
||
result += String.fromCharCode(code1)
|
||
i += 6
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 匹配 \UXXXXXXXX 格式(8位十六进制)
|
||
if (text[i] === '\\' && i + 1 < text.length && text[i + 1] === 'U' && i + 9 < text.length) {
|
||
const hex = text.substring(i + 2, i + 10)
|
||
if (/^[0-9a-fA-F]{8}$/.test(hex)) {
|
||
const code = parseInt(hex, 16)
|
||
if (code > 0x10FFFF) {
|
||
throw new Error('无效的 Unicode 码点:超出范围')
|
||
}
|
||
result += String.fromCodePoint(code)
|
||
i += 10
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 普通字符
|
||
result += text[i]
|
||
i++
|
||
}
|
||
|
||
return result
|
||
} catch (e) {
|
||
throw new Error('Unicode 解码失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
const encode = () => {
|
||
if (!inputText.value.trim()) {
|
||
showToast('请输入要编码的文本')
|
||
return
|
||
}
|
||
try {
|
||
let result = ''
|
||
if (encodingType.value === 'base64') {
|
||
result = btoa(unescape(encodeURIComponent(inputText.value)))
|
||
} else if (encodingType.value === 'url') {
|
||
result = encodeURIComponent(inputText.value)
|
||
} else if (encodingType.value === 'unicode') {
|
||
result = encodeUnicode(inputText.value)
|
||
} else if (encodingType.value === 'zlib') {
|
||
const utf8 = new TextEncoder().encode(inputText.value)
|
||
// 使用 zlib 格式,与 Java Deflater/Inflater 默认格式兼容
|
||
const compressed = zlibSync(utf8)
|
||
result = bytesToBase64(compressed)
|
||
}
|
||
outputText.value = result
|
||
|
||
// 保存到历史记录
|
||
saveToHistory({
|
||
type: 'encode',
|
||
encodingType: encodingType.value,
|
||
input: inputText.value,
|
||
output: result
|
||
})
|
||
|
||
showToast('编码成功', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('编码失败:' + e.message)
|
||
outputText.value = ''
|
||
}
|
||
}
|
||
|
||
const decode = () => {
|
||
if (!inputText.value.trim()) {
|
||
showToast('请输入要解码的字符串')
|
||
return
|
||
}
|
||
try {
|
||
let result = ''
|
||
if (encodingType.value === 'base64') {
|
||
result = decodeURIComponent(escape(atob(inputText.value)))
|
||
} else if (encodingType.value === 'url') {
|
||
result = decodeURIComponent(inputText.value)
|
||
} else if (encodingType.value === 'unicode') {
|
||
result = decodeUnicode(inputText.value)
|
||
} else if (encodingType.value === 'zlib') {
|
||
const compressed = base64ToBytes(inputText.value.trim())
|
||
// decompressSync 自动识别 zlib / raw deflate / gzip,可解 Java Deflater 输出
|
||
const decompressed = decompressSync(compressed)
|
||
result = new TextDecoder().decode(decompressed)
|
||
}
|
||
outputText.value = result
|
||
|
||
// 保存到历史记录
|
||
saveToHistory({
|
||
type: 'decode',
|
||
encodingType: encodingType.value,
|
||
input: inputText.value,
|
||
output: result
|
||
})
|
||
|
||
showToast('解码成功', 'info', 2000)
|
||
} catch (e) {
|
||
const typeName = encodingType.value === 'base64' ? 'Base64' :
|
||
encodingType.value === 'url' ? 'URL' :
|
||
encodingType.value === 'unicode' ? 'Unicode' :
|
||
encodingType.value === 'zlib' ? 'Zlib' :
|
||
encodingType.value
|
||
showToast(`解码失败:请检查输入是否为有效的${typeName}编码字符串`)
|
||
outputText.value = ''
|
||
}
|
||
}
|
||
|
||
const clearAll = () => {
|
||
inputText.value = ''
|
||
outputText.value = ''
|
||
updateInputLineCount()
|
||
updateOutputLineCount()
|
||
showToast('已清空', 'info', 2000)
|
||
}
|
||
|
||
// 复制输入到剪贴板
|
||
const copyInputToClipboard = async () => {
|
||
if (!inputText.value.trim()) {
|
||
showToast('输入内容为空,无法复制')
|
||
return
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(inputText.value)
|
||
showToast('已复制输入到剪贴板', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('复制失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 复制输出到剪贴板
|
||
const copyOutputToClipboard = async () => {
|
||
if (!outputText.value.trim()) {
|
||
showToast('输出内容为空,无法复制')
|
||
return
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(outputText.value)
|
||
showToast('已复制输出到剪贴板', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('复制失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 从剪贴板粘贴
|
||
const pasteFromClipboard = async () => {
|
||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||
try {
|
||
const text = await navigator.clipboard.readText()
|
||
if (text.trim()) {
|
||
inputText.value = text
|
||
updateInputLineCount()
|
||
} else {
|
||
showToast('剪贴板内容为空')
|
||
}
|
||
} catch (e) {
|
||
if (inputEditorRef.value) {
|
||
inputEditorRef.value.focus()
|
||
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
|
||
} else {
|
||
showToast('无法访问编辑器,请手动粘贴内容')
|
||
}
|
||
}
|
||
} else {
|
||
if (inputEditorRef.value) {
|
||
inputEditorRef.value.focus()
|
||
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 拖拽调整宽度
|
||
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('.content-wrapper')
|
||
if (!container) return
|
||
|
||
const containerWidth = container.offsetWidth
|
||
const mouseX = e.clientX - container.getBoundingClientRect().left
|
||
const percentage = (mouseX / containerWidth) * 100
|
||
|
||
// 限制在20%到80%之间
|
||
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 saveToHistory = (item) => {
|
||
const historyItem = {
|
||
...item,
|
||
time: Date.now()
|
||
}
|
||
|
||
// 从localStorage读取现有历史
|
||
let history = []
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY)
|
||
if (stored) {
|
||
history = JSON.parse(stored)
|
||
}
|
||
} catch (e) {
|
||
// 读取历史记录失败,忽略错误
|
||
}
|
||
|
||
// 避免重复保存相同的记录
|
||
const lastHistory = history[0]
|
||
if (lastHistory &&
|
||
lastHistory.type === historyItem.type &&
|
||
lastHistory.encodingType === historyItem.encodingType &&
|
||
lastHistory.input === historyItem.input &&
|
||
lastHistory.output === historyItem.output) {
|
||
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) => {
|
||
inputText.value = item.input
|
||
outputText.value = item.output
|
||
encodingType.value = item.encodingType
|
||
updateInputLineCount()
|
||
updateOutputLineCount()
|
||
}
|
||
|
||
// 切换侧栏
|
||
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.length <= maxLength) return text
|
||
return text.substring(0, maxLength) + '...'
|
||
}
|
||
|
||
onMounted(() => {
|
||
initInputEditor()
|
||
initOutputEditor()
|
||
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-header {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
margin-bottom: 0.25rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.history-type {
|
||
font-size: 0.75rem;
|
||
color: #666666;
|
||
background: #f5f5f5;
|
||
padding: 0.125rem 0.5rem;
|
||
border-radius: 3px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.history-encoding {
|
||
font-size: 0.75rem;
|
||
color: #666666;
|
||
background: #f0f9ff;
|
||
padding: 0.125rem 0.5rem;
|
||
border-radius: 3px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.history-time {
|
||
font-size: 0.75rem;
|
||
color: #999999;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.history-preview {
|
||
font-size: 0.875rem;
|
||
color: #1a1a1a;
|
||
font-family: 'Courier New', monospace;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.content-wrapper {
|
||
flex: 1;
|
||
display: flex;
|
||
position: relative;
|
||
background: #ffffff;
|
||
transition: margin-left 0.3s ease;
|
||
}
|
||
|
||
.left-panel,
|
||
.right-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.left-panel {
|
||
position: relative;
|
||
}
|
||
|
||
/* 面板工具栏 */
|
||
.panel-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0;
|
||
background: #ffffff;
|
||
border-bottom: 1px solid #e5e5e5;
|
||
min-height: 40px;
|
||
}
|
||
|
||
.view-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
padding-left: 0.5rem;
|
||
}
|
||
|
||
.view-tab {
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: #666666;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
transition: all 0.2s;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
}
|
||
|
||
.view-tab:hover {
|
||
color: #1a1a1a;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.view-tab.active {
|
||
color: #ffffff;
|
||
background: #1a1a1a;
|
||
border-bottom-color: #1a1a1a;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
padding: 0.25rem 0.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.encoding-type-selector {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
margin-right: 0.5rem;
|
||
padding-right: 0.5rem;
|
||
border-right: 1px solid #e5e5e5;
|
||
}
|
||
|
||
.type-btn {
|
||
padding: 0.25rem 0.75rem;
|
||
border: 1px solid #e5e5e5;
|
||
border-radius: 3px;
|
||
background: transparent;
|
||
color: #666666;
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.type-btn:hover {
|
||
background: #f5f5f5;
|
||
color: #1a1a1a;
|
||
border-color: #d0d0d0;
|
||
}
|
||
|
||
.type-btn.active {
|
||
background: #1a1a1a;
|
||
color: #ffffff;
|
||
border-color: #1a1a1a;
|
||
}
|
||
|
||
.toolbar-icon-btn {
|
||
padding: 0.375rem;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
color: #666666;
|
||
}
|
||
|
||
.toolbar-icon-btn:hover {
|
||
background: #f5f5f5;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
.toolbar-icon-btn:active {
|
||
background: #e5e5e5;
|
||
}
|
||
|
||
.toolbar-icon-btn svg,
|
||
.toolbar-icon-btn i {
|
||
display: block;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.output-editor {
|
||
background: #fafafa;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
/* 分割线 */
|
||
.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;
|
||
}
|
||
|
||
/* 侧栏切换按钮 */
|
||
.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;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.tool-page {
|
||
padding: 0;
|
||
margin: -1rem;
|
||
height: calc(100vh - 64px + 2rem);
|
||
}
|
||
|
||
.sidebar {
|
||
width: 80%;
|
||
max-width: 300px;
|
||
}
|
||
|
||
.left-panel,
|
||
.right-panel {
|
||
min-width: 0;
|
||
}
|
||
|
||
.view-tabs {
|
||
padding-left: 0.25rem;
|
||
}
|
||
|
||
.view-tab {
|
||
padding: 0.5rem 0.75rem;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.encoding-type-selector {
|
||
margin-right: 0.25rem;
|
||
padding-right: 0.25rem;
|
||
}
|
||
|
||
.type-btn {
|
||
padding: 0.25rem 0.5rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.text-editor {
|
||
padding-left: 2.5rem;
|
||
}
|
||
|
||
.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>
|