工具完成
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
2025-06-28 22:38:49 +08:00
parent 2c668fedd0
commit 8400dbfab9
60 changed files with 23197 additions and 144 deletions

View File

@ -0,0 +1,402 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertBase64ToImage"
:disabled="!base64Input.trim() || isConverting"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isConverting ? ['fas', 'spinner'] : ['fas', 'image']"
:class="['mr-2', isConverting && 'animate-spin']"
/>
{{ t('tools.base64_to_image.base64_to_image') }}
</button>
<button
@click="downloadImage"
:disabled="!imageDataUrl"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
{{ t('tools.base64_to_image.download_image') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.base64_to_image.clear') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Base64 输入区域 -->
<div class="space-y-4">
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.base64_to_image.base64_input') }}</h3>
<textarea
v-model="base64Input"
:placeholder="t('tools.base64_to_image.base64_placeholder')"
class="textarea-field h-40"
@input="handleBase64Change"
/>
<div class="text-sm text-secondary mt-2">
字符数: {{ base64Input.length }}
</div>
</div>
<!-- 图片上传区域 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.base64_to_image.image_to_base64') }}</h3>
<div
@click="triggerFileUpload"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleFileDrop"
:class="[
'border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors text-center',
isDragging ? 'border-primary bg-primary bg-opacity-5' : 'border-tertiary hover:border-primary-light'
]"
>
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<div class="text-secondary">
<p>{{ t('tools.base64_to_image.click_or_drag') }}</p>
<p class="text-sm text-tertiary mt-1">支持 JPG, PNG, GIF, WebP 格式</p>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleFileSelect"
>
</div>
</div>
<!-- 预览和结果区域 -->
<div class="space-y-4">
<!-- 图片预览 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.base64_to_image.image_preview') }}</h3>
<div class="flex justify-center items-center min-h-[200px] bg-block rounded-lg">
<div v-if="imageDataUrl" class="text-center max-w-full">
<img
:src="imageDataUrl"
:alt="t('tools.base64_to_image.preview_image')"
class="max-w-full max-h-64 mx-auto rounded border border-primary border-opacity-20"
@load="handleImageLoad"
@error="handleImageError"
>
<div v-if="imageInfo" class="text-sm text-secondary mt-2">
<div>尺寸: {{ imageInfo.width }} × {{ imageInfo.height }}px</div>
<div>大小: {{ imageInfo.size }}</div>
<div>格式: {{ imageInfo.format }}</div>
</div>
</div>
<div v-else-if="isConverting" class="text-center">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.base64_to_image.converting') }}</div>
</div>
<div v-else class="text-center">
<FontAwesomeIcon :icon="['fas', 'image']" class="text-6xl text-tertiary mb-4" />
<div class="text-secondary">{{ t('tools.base64_to_image.no_preview') }}</div>
</div>
</div>
</div>
<!-- Base64 输出 -->
<div v-if="base64Output" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.base64_to_image.base64_output') }}</h3>
<button
@click="copyBase64ToClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="复制"
>
<FontAwesomeIcon
:icon="base64Copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="base64Copied && 'text-success'"
/>
</button>
</div>
<textarea
v-model="base64Output"
class="textarea-field h-32"
readonly
/>
<div class="text-sm text-secondary mt-2">
字符数: {{ base64Output.length }}
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const base64Input = ref('')
const base64Output = ref('')
const imageDataUrl = ref('')
const isDragging = ref(false)
const isConverting = ref(false)
const base64Copied = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 文件输入引用
const fileInput = ref<HTMLInputElement>()
// 图片信息
const imageInfo = ref<{
width: number
height: number
size: string
format: string
} | null>(null)
// 将Base64转换为图片
const convertBase64ToImage = async () => {
if (!base64Input.value.trim()) {
showStatus('请输入Base64编码', 'error')
return
}
isConverting.value = true
statusMessage.value = ''
await nextTick()
try {
let base64Data = base64Input.value.trim()
// 如果没有data URI前缀尝试添加
if (!base64Data.startsWith('data:')) {
// 检测图片格式
const firstChar = base64Data.charAt(0)
let mimeType = 'image/png' // 默认
if (firstChar === '/') {
mimeType = 'image/jpeg'
} else if (firstChar === 'R') {
mimeType = 'image/gif'
} else if (firstChar === 'U') {
mimeType = 'image/webp'
}
base64Data = `data:${mimeType};base64,${base64Data}`
}
// 验证Base64格式
const base64Pattern = /^data:image\/(png|jpe?g|gif|webp);base64,/
if (!base64Pattern.test(base64Data)) {
throw new Error('无效的Base64图片格式')
}
imageDataUrl.value = base64Data
showStatus('Base64转换成功', 'success')
} catch (error) {
console.error('Base64转换失败:', error)
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
imageDataUrl.value = ''
imageInfo.value = null
} finally {
isConverting.value = false
}
}
// 处理文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
convertFileToBase64(file)
}
}
// 处理文件拖拽
const handleFileDrop = (event: DragEvent) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type.startsWith('image/')) {
convertFileToBase64(file)
} else {
showStatus('请选择图片文件', 'error')
}
}
}
const handleDragOver = () => {
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
// 将文件转换为Base64
const convertFileToBase64 = (file: File) => {
if (!file.type.startsWith('image/')) {
showStatus('请选择图片文件', 'error')
return
}
isConverting.value = true
statusMessage.value = ''
const reader = new FileReader()
reader.onload = (e) => {
try {
const result = e.target?.result as string
base64Output.value = result
imageDataUrl.value = result
// 更新图片信息
imageInfo.value = {
width: 0, // 将在图片加载后更新
height: 0,
size: formatFileSize(file.size),
format: file.type.split('/')[1].toUpperCase()
}
showStatus('图片转Base64成功', 'success')
} catch (error) {
console.error('文件转换失败:', error)
showStatus('文件转换失败', 'error')
} finally {
isConverting.value = false
}
}
reader.onerror = () => {
showStatus('文件读取失败', 'error')
isConverting.value = false
}
reader.readAsDataURL(file)
}
// 触发文件上传
const triggerFileUpload = () => {
fileInput.value?.click()
}
// 下载图片
const downloadImage = () => {
if (!imageDataUrl.value) return
try {
const link = document.createElement('a')
link.download = `base64-image-${Date.now()}.png`
link.href = imageDataUrl.value
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
showStatus('图片下载成功', 'success')
} catch (error) {
console.error('下载失败:', error)
showStatus('下载失败', 'error')
}
}
// 复制Base64到剪贴板
const copyBase64ToClipboard = async () => {
if (!base64Output.value) return
try {
await navigator.clipboard.writeText(base64Output.value)
base64Copied.value = true
showStatus('Base64已复制到剪贴板', 'success')
setTimeout(() => {
base64Copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
showStatus('复制失败', 'error')
}
}
// 清除所有内容
const clearAll = () => {
base64Input.value = ''
base64Output.value = ''
imageDataUrl.value = ''
imageInfo.value = null
statusMessage.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
// 处理Base64输入变化
const handleBase64Change = () => {
if (statusMessage.value) {
statusMessage.value = ''
}
}
// 图片加载完成
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
if (imageInfo.value) {
imageInfo.value.width = img.naturalWidth
imageInfo.value.height = img.naturalHeight
}
}
// 图片加载错误
const handleImageError = () => {
showStatus('图片加载失败请检查Base64编码是否正确', 'error')
imageDataUrl.value = ''
imageInfo.value = null
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>

View File

@ -0,0 +1,361 @@
<template>
<div class="chrome-bookmark-recovery">
<ToolHeader
:title="t('tools.chrome_bookmark_recovery.title')"
:description="t('tools.chrome_bookmark_recovery.description')"
/>
<!-- Windows操作说明 -->
<div class="instruction-content mb-8">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{{ t('tools.chrome_bookmark_recovery.instructions.windows.title') }}
</h3>
<ol class="space-y-2 text-gray-700 dark:text-gray-300">
<li
v-for="(step, index) in getInstructionSteps('windows')"
:key="index"
class="flex items-start"
>
<span class="flex-shrink-0 w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5">
{{ index + 1 }}
</span>
<span class="leading-relaxed">{{ step }}</span>
</li>
</ol>
</div>
<!-- 文件上传区域 -->
<div class="upload-section">
<div
ref="dropZone"
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
:class="[
'border-2 border-dashed rounded-lg p-8 text-center transition-colors',
isDragOver
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
]"
>
<div class="space-y-4">
<div class="mx-auto w-12 h-12 text-gray-400">
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/>
</svg>
</div>
<div>
<label class="cursor-pointer">
<input
type="file"
multiple
accept=".bak"
@change="handleFileSelect"
class="hidden"
/>
<span class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium">
选择 Bookmarks.bak 文件
</span>
</label>
<span class="text-gray-500 dark:text-gray-400"> 或拖拽文件到此处</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
仅支持 .bak 格式的 Chrome 书签备份文件
</p>
</div>
</div>
<!-- 结果显示 -->
<div v-if="results.length > 0" class="mt-6">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h4 class="font-medium mb-4 text-gray-900 dark:text-gray-100">
处理结果
</h4>
<ul class="space-y-3">
<li
v-for="result in results"
:key="result.id"
class="flex items-center justify-between p-3 bg-white dark:bg-gray-700 rounded border"
>
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ result.filename }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
最后修改: {{ result.lastModified }} ({{ result.size }}B)
</div>
<div v-if="result.count > 0" class="text-sm text-green-600 dark:text-green-400">
发现 {{ result.count }} 个书签
</div>
<div v-if="result.error" class="text-sm text-red-600 dark:text-red-400">
文件格式错误或无效请选择正确的 Bookmarks.bak 文件
</div>
</div>
<div class="ml-4">
<button
v-if="!result.error"
@click="downloadBookmark(result)"
:disabled="result.downloaded"
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
>
{{ result.downloaded ? '已下载' : '下载 HTML' }}
</button>
</div>
</li>
</ul>
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="mt-6 text-center">
<div class="text-gray-500 dark:text-gray-400">正在处理文件...</div>
</div>
</div>
<!-- 使用说明 -->
<div class="usage-info mt-12">
<h3 class="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
使用说明
</h3>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-4">
<div class="flex items-start space-x-3">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-2">导入恢复的书签</p>
<ol class="list-decimal list-inside space-y-1 ml-4">
<li>下载转换后的 HTML 文件</li>
<li>打开 Chrome 浏览器进入 书签 > 书签管理器</li>
<li>点击右上角菜单三个点选择"导入书签"</li>
<li>选择刚才下载的 HTML 文件</li>
<li>书签将被导入到 Chrome </li>
</ol>
</div>
</div>
</div>
</div>
<!-- 免责声明 -->
<div class="disclaimer mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
免责声明这不是Google的官方产品请在使用前备份重要数据
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import ToolHeader from '@/components/ToolHeader.vue'
// 语言设置
const { t } = useLanguage()
// 文件上传相关
const dropZone = ref<HTMLElement>()
const isDragOver = ref(false)
const isLoading = ref(false)
const results = ref<Array<{
id: string
filename: string
lastModified: string
size: number
count: number
html: string
error: boolean
downloaded: boolean
}>>([])
// 获取指令步骤
const getInstructionSteps = (tab: string) => {
return t(`tools.chrome_bookmark_recovery.instructions.${tab}.steps`)
}
// Chrome时间转换函数
const chromeTime2TimeT = (time: number): number => {
return Math.floor((time - 11644473600000000) / 1000000)
}
// 书签解析类
class Bookmark {
tree: any
html: string
count: number
first: boolean
constructor(raw: string) {
this.tree = JSON.parse(raw)
this.html = ''
this.count = 0
this.first = true
}
walk = (node: any): void => {
if (node.type === 'folder') {
this.html += `<DT><H3 ADD_DATE="${chromeTime2TimeT(node.date_added)}" LAST_MODIFIED="${chromeTime2TimeT(node.date_modified)}"`
if (this.first) {
this.html += ' PERSONAL_TOOLBAR_FOLDER="true"'
this.first = false
}
this.html += `>${node.name}</H3>\n`
this.html += '<DL><p>\n'
node.children.forEach(this.walk)
this.html += '</DL><p>\n'
} else {
this.html += `<DT><A HREF="${node.url}" ADD_DATE="${chromeTime2TimeT(node.date_added)}">${node.name}</A>\n`
this.count++
}
}
parse = (): void => {
this.html = '<!DOCTYPE NETSCAPE-Bookmark-file-1><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"><TITLE>Bookmarks</TITLE><H1>Bookmarks</H1>\n'
this.html += '<DL><p>\n'
const roots = this.tree.roots
this.walk(roots.bookmark_bar)
if (roots.other.children.length > 0) {
this.walk(roots.other)
}
if (roots.synced.children.length > 0) {
this.walk(roots.synced)
}
this.html += '<style>dt, dl { padding-left: 12px; }</style>\n'
}
}
// 验证文件格式
const isValidBookmarkFile = (filename: string): boolean => {
return filename.toLowerCase().endsWith('.bak') &&
(filename.toLowerCase().includes('bookmark') || filename.toLowerCase() === 'bookmarks.bak')
}
// 处理文件读取
const readFile = (file: File): Promise<any> => {
return new Promise((resolve) => {
const reader = new FileReader()
const result = {
id: Math.random().toString(36).substr(2, 9),
filename: file.name,
lastModified: file.lastModified ? new Date(file.lastModified).toLocaleDateString() : 'n/a',
size: file.size,
count: 0,
html: '',
error: false,
downloaded: false
}
// 验证文件格式
if (!isValidBookmarkFile(file.name)) {
result.error = true
resolve(result)
return
}
reader.onloadend = () => {
try {
const bookmark = new Bookmark(reader.result as string)
bookmark.parse()
result.count = bookmark.count
result.html = bookmark.html
} catch (e) {
result.error = true
}
resolve(result)
}
reader.readAsText(file)
})
}
// 处理文件选择
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
await processFiles(Array.from(target.files))
}
}
// 处理文件拖拽
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = false
if (event.dataTransfer?.files) {
await processFiles(Array.from(event.dataTransfer.files))
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = true
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDragOver.value = false
}
// 处理文件列表
const processFiles = async (files: File[]) => {
isLoading.value = true
results.value = []
try {
for (const file of files) {
const result = await readFile(file)
results.value.push(result)
}
} finally {
isLoading.value = false
}
}
// 下载书签
const downloadBookmark = (result: any) => {
const blob = new Blob([result.html], { type: 'text/html' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'chrome_bookmarks_backup.html'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
result.downloaded = true
}
</script>
<style scoped>
.chrome-bookmark-recovery {
max-width: 4xl;
margin: 0 auto;
padding: 1rem;
}
.instruction-content {
min-height: 200px;
}
.upload-section {
margin: 2rem 0;
}
.usage-info {
border-top: 1px solid #e5e7eb;
padding-top: 2rem;
}
@media (max-width: 768px) {
.chrome-bookmark-recovery {
padding: 0.5rem;
}
}
</style>

View File

@ -0,0 +1,565 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="formatCode"
:disabled="!inputCode.trim() || isFormatting"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isFormatting ? ['fas', 'spinner'] : ['fas', 'code']"
:class="['mr-2', isFormatting && 'animate-spin']"
/>
{{ t('tools.code_formatter.format') }}
</button>
<button
@click="minifyCode"
:disabled="!inputCode.trim() || isFormatting"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
{{ t('tools.code_formatter.minify') }}
</button>
<button
@click="copyToClipboard"
:disabled="!outputCode"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
{{ t('tools.code_formatter.copy') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.code_formatter.clear') }}
</button>
</div>
</div>
<!-- 语言选择和设置 -->
<div class="card p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.code_formatter.language') }}
</label>
<select v-model="selectedLanguage" class="select-field" @change="handleLanguageChange">
<option v-for="lang in supportedLanguages" :key="lang.value" :value="lang.value">
{{ lang.label }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.code_formatter.indent_size') }}
</label>
<select v-model="indentSize" class="select-field">
<option value="2">2 空格</option>
<option value="4">4 空格</option>
<option value="tab">制表符</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.code_formatter.line_width') }}
</label>
<input
v-model="lineWidth"
type="number"
min="80"
max="200"
class="input-field"
placeholder="120"
>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.code_formatter.input') }}</h3>
<div class="flex space-x-2">
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
<button
@click="loadExample"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="加载示例"
>
<FontAwesomeIcon :icon="['fas', 'file-code']" />
</button>
</div>
</div>
<textarea
v-model="inputCode"
:placeholder="getPlaceholder()"
class="textarea-field font-mono text-sm"
style="height: 500px; resize: vertical;"
@input="handleInputChange"
/>
<div class="text-sm text-secondary mt-2">
行数: {{ inputLines }} | 字符数: {{ inputCode.length }}
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.code_formatter.output') }}</h3>
<div class="text-sm text-secondary">
{{ outputStats }}
</div>
</div>
<textarea
v-model="outputCode"
:placeholder="t('tools.code_formatter.output_placeholder')"
class="textarea-field font-mono text-sm"
style="height: 500px; resize: vertical;"
readonly
/>
<div v-if="outputCode" class="text-sm text-secondary mt-2">
行数: {{ outputLines }} | 字符数: {{ outputCode.length }}
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputCode = ref('')
const outputCode = ref('')
const selectedLanguage = ref('javascript')
const indentSize = ref('2')
const lineWidth = ref(120)
const isFormatting = ref(false)
const copied = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 支持的语言
const supportedLanguages = [
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'html', label: 'HTML' },
{ value: 'css', label: 'CSS' },
{ value: 'json', label: 'JSON' },
{ value: 'sql', label: 'SQL' },
{ value: 'xml', label: 'XML' },
{ value: 'yaml', label: 'YAML' },
{ value: 'markdown', label: 'Markdown' }
]
// 计算属性
const inputLines = computed(() => {
return inputCode.value.split('\n').length
})
const outputLines = computed(() => {
return outputCode.value.split('\n').length
})
const outputStats = computed(() => {
if (!outputCode.value) return ''
const originalSize = inputCode.value.length
const formattedSize = outputCode.value.length
const diff = formattedSize - originalSize
const diffText = diff > 0 ? `+${diff}` : diff.toString()
return `${diffText} 字符`
})
// 格式化代码
const formatCode = async () => {
if (!inputCode.value.trim()) {
showStatus('请输入要格式化的代码', 'error')
return
}
isFormatting.value = true
statusMessage.value = ''
try {
let formatted = ''
switch (selectedLanguage.value) {
case 'json':
formatted = formatJSON(inputCode.value)
break
case 'html':
formatted = formatHTML(inputCode.value)
break
case 'css':
formatted = formatCSS(inputCode.value)
break
case 'javascript':
case 'typescript':
formatted = formatJavaScript(inputCode.value)
break
case 'sql':
formatted = formatSQL(inputCode.value)
break
case 'xml':
formatted = formatXML(inputCode.value)
break
default:
formatted = formatGeneric(inputCode.value)
}
outputCode.value = formatted
showStatus('代码格式化成功', 'success')
} catch (error) {
console.error('格式化失败:', error)
showStatus('格式化失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
} finally {
isFormatting.value = false
}
}
// 压缩代码
const minifyCode = () => {
if (!inputCode.value.trim()) {
showStatus('请输入要压缩的代码', 'error')
return
}
try {
let minified = ''
switch (selectedLanguage.value) {
case 'json':
const parsed = JSON.parse(inputCode.value)
minified = JSON.stringify(parsed)
break
case 'css':
minified = minifyCSS(inputCode.value)
break
case 'javascript':
minified = minifyJavaScript(inputCode.value)
break
default:
minified = inputCode.value.replace(/\s+/g, ' ').trim()
}
outputCode.value = minified
showStatus('代码压缩成功', 'success')
} catch (error) {
console.error('压缩失败:', error)
showStatus('压缩失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// JSON格式化
const formatJSON = (code: string): string => {
const parsed = JSON.parse(code)
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
return JSON.stringify(parsed, null, indent)
}
// HTML格式化
const formatHTML = (code: string): string => {
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let level = 0
let formatted = ''
// 简单的HTML格式化逻辑
const lines = code.replace(/></g, '>\n<').split('\n')
for (let line of lines) {
line = line.trim()
if (!line) continue
if (line.startsWith('</')) {
level = Math.max(0, level - 1)
}
formatted += indent.repeat(level) + line + '\n'
if (line.startsWith('<') && !line.startsWith('</') && !line.endsWith('/>') && !line.includes('</')) {
level++
}
}
return formatted.trim()
}
// CSS格式化
const formatCSS = (code: string): string => {
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
return code
.replace(/\s*{\s*/g, ' {\n')
.replace(/;\s*/g, ';\n')
.replace(/\s*}\s*/g, '\n}\n')
.split('\n')
.map(line => {
line = line.trim()
if (!line) return ''
if (line.endsWith('{') || line.endsWith('}')) {
return line
}
return indent + line
})
.filter(line => line !== '')
.join('\n')
}
// JavaScript格式化
const formatJavaScript = (code: string): string => {
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let level = 0
let formatted = ''
let inString = false
let stringChar = ''
for (let i = 0; i < code.length; i++) {
const char = code[i]
const prevChar = code[i - 1]
if ((char === '"' || char === "'") && prevChar !== '\\') {
if (!inString) {
inString = true
stringChar = char
} else if (char === stringChar) {
inString = false
stringChar = ''
}
}
if (!inString) {
if (char === '{') {
formatted += char + '\n' + indent.repeat(++level)
continue
} else if (char === '}') {
formatted = formatted.trimEnd() + '\n' + indent.repeat(--level) + char
if (code[i + 1] && code[i + 1] !== ';' && code[i + 1] !== ',' && code[i + 1] !== ')') {
formatted += '\n' + indent.repeat(level)
}
continue
} else if (char === ';') {
formatted += char
if (code[i + 1] && code[i + 1] !== '}') {
formatted += '\n' + indent.repeat(level)
}
continue
}
}
formatted += char
}
return formatted.trim()
}
// SQL格式化
const formatSQL = (code: string): string => {
const keywords = ['SELECT', 'FROM', 'WHERE', 'JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'ORDER BY', 'GROUP BY', 'HAVING', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP']
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let formatted = code.toUpperCase()
keywords.forEach(keyword => {
const regex = new RegExp(`\\b${keyword}\\b`, 'gi')
formatted = formatted.replace(regex, `\n${keyword}`)
})
return formatted
.split('\n')
.map(line => line.trim())
.filter(line => line !== '')
.join('\n')
}
// XML格式化
const formatXML = (code: string): string => {
// 简单的XML格式化复用HTML格式化逻辑
return formatHTML(code)
}
// 通用格式化
const formatGeneric = (code: string): string => {
// 基本的缩进处理
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(parseInt(indentSize.value))
let level = 0
return code.split('\n').map(line => {
line = line.trim()
if (!line) return ''
// 简单的括号缩进
const openBrackets = (line.match(/[{(\[]/g) || []).length
const closeBrackets = (line.match(/[})\]]/g) || []).length
if (closeBrackets > openBrackets) {
level = Math.max(0, level - (closeBrackets - openBrackets))
}
const formatted = indent.repeat(level) + line
if (openBrackets > closeBrackets) {
level += (openBrackets - closeBrackets)
}
return formatted
}).join('\n')
}
// CSS压缩
const minifyCSS = (code: string): string => {
return code
.replace(/\/\*[\s\S]*?\*\//g, '') // 移除注释
.replace(/\s+/g, ' ') // 多个空格替换为单个
.replace(/;\s*}/g, '}') // 移除最后一个分号
.replace(/\s*{\s*/g, '{')
.replace(/;\s*/g, ';')
.replace(/:\s*/g, ':')
.trim()
}
// JavaScript压缩
const minifyJavaScript = (code: string): string => {
return code
.replace(/\/\/.*$/gm, '') // 移除单行注释
.replace(/\/\*[\s\S]*?\*\//g, '') // 移除多行注释
.replace(/\s+/g, ' ') // 多个空格替换为单个
.replace(/\s*([{}();,])\s*/g, '$1') // 移除操作符周围的空格
.trim()
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!outputCode.value) return
try {
await navigator.clipboard.writeText(outputCode.value)
copied.value = true
showStatus('代码已复制到剪贴板', 'success')
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
showStatus('复制失败', 'error')
}
}
// 从剪贴板粘贴
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputCode.value = text
handleInputChange()
} catch (error) {
console.error('粘贴失败:', error)
showStatus('粘贴失败', 'error')
}
}
// 加载示例代码
const loadExample = () => {
const examples: Record<string, string> = {
javascript: `function calculateSum(numbers) {
const result = numbers.reduce((sum, num) => {
return sum + num;
}, 0);
return result;
}
const data = [1, 2, 3, 4, 5];
console.log(calculateSum(data));`,
json: `{"name":"极速箱工具集","version":"1.0.0","tools":[{"id":1,"name":"代码格式化器","active":true},{"id":2,"name":"JSON格式化器","active":true}],"settings":{"theme":"dark","language":"zh-CN"}}`,
html: `<div class="container"><header><h1>标题</h1></header><main><section><p>这是一段文本</p></section></main></div>`,
css: `.container{display:flex;flex-direction:column;}.header{background-color:#333;color:white;padding:20px;}.main{flex:1;padding:20px;}`,
sql: `SELECT u.name, u.email, p.title FROM users u INNER JOIN posts p ON u.id = p.user_id WHERE u.active = 1 ORDER BY p.created_at DESC;`
}
inputCode.value = examples[selectedLanguage.value] || examples.javascript
}
// 清除所有内容
const clearAll = () => {
inputCode.value = ''
outputCode.value = ''
statusMessage.value = ''
}
// 处理输入变化
const handleInputChange = () => {
if (statusMessage.value) {
statusMessage.value = ''
}
}
// 处理语言变化
const handleLanguageChange = () => {
outputCode.value = ''
}
// 获取占位符文本
const getPlaceholder = (): string => {
const placeholders: Record<string, string> = {
javascript: '输入 JavaScript 代码...',
html: '输入 HTML 代码...',
css: '输入 CSS 代码...',
json: '输入 JSON 数据...',
sql: '输入 SQL 语句...'
}
return placeholders[selectedLanguage.value] || '输入代码...'
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>

View File

@ -0,0 +1,461 @@
<template>
<div class="space-y-6">
<!-- 主颜色输入和预览 -->
<div class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">颜色转换器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 颜色输入区域 -->
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">选择颜色</label>
<div class="flex items-center gap-3">
<input
v-model="mainColor"
type="color"
class="w-16 h-16 cursor-pointer rounded-md border-2 border-gray-300"
@input="handleColorChange"
/>
<div class="flex-1">
<input
v-model="mainColor"
type="text"
class="input-field w-full"
placeholder="#6366F1"
@input="handleColorChange"
/>
</div>
<button
@click="generateRandomColor"
class="btn-secondary px-3 py-2"
>
<FontAwesomeIcon :icon="['fas', 'random']" />
</button>
</div>
</div>
</div>
<!-- 颜色预览区域 -->
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">颜色预览</label>
<div
class="h-32 rounded-md flex items-center justify-center relative overflow-hidden border"
:style="{ backgroundColor: mainColor }"
>
<div class="bg-black bg-opacity-40 px-4 py-2 rounded-md text-white">
示例文本
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 颜色值显示和复制 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">颜色值</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="(value, format) in colorValues"
:key="format"
class="bg-block rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-primary">{{ format.toUpperCase() }}</h4>
<button
@click="() => copyColorValue(format)"
class="text-secondary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="copiedFormat === format ? ['fas', 'check'] : ['fas', 'copy']" />
</button>
</div>
<code class="text-xs font-mono text-secondary break-all">{{ value }}</code>
</div>
</div>
</div>
<!-- 颜色色阶 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">颜色色阶</h3>
<div class="grid grid-cols-5 md:grid-cols-9 gap-2">
<div
v-for="(shade, index) in colorShades"
:key="index"
class="h-16 rounded-md flex items-center justify-center transition-all duration-200 cursor-pointer hover:transform hover:scale-105 border"
:style="{ backgroundColor: shade }"
@click="() => selectShade(shade)"
>
<span class="text-xs font-mono text-white bg-black bg-opacity-40 px-1 rounded">
{{ shade }}
</span>
</div>
</div>
<p class="text-xs text-tertiary mt-2">点击任意色阶来使用该颜色</p>
</div>
<!-- 和谐色彩 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 互补色 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">互补色</h3>
<div class="grid grid-cols-2 gap-2">
<div
v-for="(color, index) in complementaryColors"
:key="index"
class="h-24 rounded-md flex items-center justify-center transition-transform cursor-pointer hover:scale-105 border"
:style="{ backgroundColor: color }"
@click="() => selectShade(color)"
>
<span class="text-xs font-mono text-white bg-black bg-opacity-40 px-1 rounded">
{{ color }}
</span>
</div>
</div>
</div>
<!-- 邻近色 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">邻近色</h3>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(color, index) in analogousColors"
:key="index"
class="h-24 rounded-md flex items-center justify-center transition-transform cursor-pointer hover:scale-105 border"
:style="{ backgroundColor: color }"
@click="() => selectShade(color)"
>
<span class="text-xs font-mono text-white bg-black bg-opacity-40 px-1 rounded">
{{ color }}
</span>
</div>
</div>
</div>
</div>
<!-- 调色板 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">我的调色板</h3>
<button
@click="showPaletteInput = !showPaletteInput"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-1" />
添加颜色
</button>
</div>
<!-- 添加新颜色输入 -->
<div v-if="showPaletteInput" class="mb-4 p-4 bg-block rounded-lg">
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm text-secondary font-medium mb-2">颜色名称</label>
<input
v-model="newPaletteName"
type="text"
class="input-field w-full"
placeholder="输入颜色名称..."
@keydown.enter="addToPalette"
/>
</div>
<button
@click="addToPalette"
class="btn-primary px-4 py-2"
:disabled="!newPaletteName.trim()"
>
添加
</button>
<button
@click="showPaletteInput = false"
class="btn-secondary px-4 py-2"
>
取消
</button>
</div>
</div>
<!-- 调色板颜色列表 -->
<div class="space-y-2">
<div
v-for="color in palette"
:key="color.id"
class="flex items-center justify-between p-2 rounded-md hover:bg-hover transition-colors"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-md cursor-pointer border"
:style="{ backgroundColor: color.hex }"
@click="() => selectFromPalette(color.hex)"
/>
<div>
<div class="text-sm text-primary">{{ color.name }}</div>
<div class="text-xs text-secondary font-mono">{{ color.hex }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="() => copyColorValue('hex', color.hex)"
class="text-secondary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
<button
@click="() => removeFromPalette(color.id)"
class="text-secondary hover:text-error transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div>
</div>
</div>
<div v-if="palette.length === 0" class="text-center py-8 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mb-2" />
<div>暂无保存的颜色点击"添加颜色"开始创建调色板</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
// 调色板颜色类型
interface PaletteColor {
id: string
hex: string
name: string
}
// 响应式状态
const mainColor = ref('#6366F1')
const colorShades = ref<string[]>([])
const complementaryColors = ref<string[]>([])
const analogousColors = ref<string[]>([])
const palette = ref<PaletteColor[]>([])
const showPaletteInput = ref(false)
const newPaletteName = ref('')
const copiedFormat = ref<string | null>(null)
// 计算颜色值
const colorValues = computed(() => {
const hex = mainColor.value
const rgb = hexToRgb(hex)
const hsl = rgbToHsl(rgb[0], rgb[1], rgb[2])
return {
hex: hex,
rgb: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`,
hsl: `hsl(${Math.round(hsl[0])}, ${Math.round(hsl[1] * 100)}%, ${Math.round(hsl[2] * 100)}%)`
}
})
// 颜色转换函数
const hexToRgb = (hex: string): [number, number, number] => {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return [r, g, b]
}
const rgbToHsl = (r: number, g: number, b: number): [number, number, number] => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const diff = max - min
let h = 0
if (max === min) {
h = 0
} else if (max === r) {
h = ((g - b) / diff + (g < b ? 6 : 0)) * 60
} else if (max === g) {
h = ((b - r) / diff + 2) * 60
} else {
h = ((r - g) / diff + 4) * 60
}
const l = (max + min) / 2
const s = diff === 0 ? 0 : diff / (1 - Math.abs(2 * l - 1))
return [h, s, l]
}
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
h /= 360
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
if (s === 0) {
return [l * 255, l * 255, l * 255]
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
const r = hue2rgb(p, q, h + 1/3)
const g = hue2rgb(p, q, h)
const b = hue2rgb(p, q, h - 1/3)
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
}
const rgbToHex = (r: number, g: number, b: number): string => {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
// 计算颜色变体
const calculateColorShades = (hexColor: string) => {
const shades: string[] = []
const [r, g, b] = hexToRgb(hexColor)
// 创建9个亮度变体
for (let i = 0.1; i <= 0.9; i += 0.1) {
const factor = i
const newR = Math.round(r * factor)
const newG = Math.round(g * factor)
const newB = Math.round(b * factor)
const newHex = rgbToHex(newR, newG, newB)
shades.push(newHex)
}
colorShades.value = shades.reverse()
}
// 计算互补色和邻近色
const calculateHarmonicColors = (hexColor: string) => {
const [r, g, b] = hexToRgb(hexColor)
const [h, s, l] = rgbToHsl(r, g, b)
// 互补色
const complementaryH = (h + 180) % 360
const complementaryRgb = hslToRgb(complementaryH, s, l)
const complementaryHex = rgbToHex(complementaryRgb[0], complementaryRgb[1], complementaryRgb[2])
complementaryColors.value = [hexColor, complementaryHex]
// 邻近色
const analogous = []
for (let offset of [-30, 0, 30]) {
const analogousH = (h + offset + 360) % 360
const analogousRgb = hslToRgb(analogousH, s, l)
const analogousHex = rgbToHex(analogousRgb[0], analogousRgb[1], analogousRgb[2])
analogous.push(analogousHex)
}
analogousColors.value = analogous
}
// 更新所有颜色计算
const updateColorValues = (color: string) => {
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
return
}
calculateColorShades(color)
calculateHarmonicColors(color)
}
// 事件处理
const handleColorChange = () => {
updateColorValues(mainColor.value)
}
const copyColorValue = async (format: string, customValue?: string) => {
try {
const value = customValue || colorValues.value[format as keyof typeof colorValues.value]
await navigator.clipboard.writeText(value)
copiedFormat.value = format
setTimeout(() => {
copiedFormat.value = null
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const selectShade = (color: string) => {
mainColor.value = color
updateColorValues(color)
}
const selectFromPalette = (hex: string) => {
mainColor.value = hex
updateColorValues(hex)
}
const generateRandomColor = () => {
const randomColor = `#${Math.floor(Math.random()*16777215).toString(16).padStart(6, '0')}`
mainColor.value = randomColor
updateColorValues(randomColor)
}
// 调色板管理
const addToPalette = () => {
if (!newPaletteName.value.trim()) return
const newColor: PaletteColor = {
id: Date.now().toString(),
hex: mainColor.value,
name: newPaletteName.value.trim()
}
palette.value.push(newColor)
newPaletteName.value = ''
showPaletteInput.value = false
// 保存到本地存储
savePalette()
}
const removeFromPalette = (id: string) => {
palette.value = palette.value.filter(color => color.id !== id)
savePalette()
}
const savePalette = () => {
localStorage.setItem('colorPalette', JSON.stringify(palette.value))
}
const loadPalette = () => {
const saved = localStorage.getItem('colorPalette')
if (saved) {
palette.value = JSON.parse(saved)
} else {
// 默认示例调色板
palette.value = [
{ id: 'primary', hex: '#6366F1', name: '主色' },
{ id: 'secondary', hex: '#8B5CF6', name: '辅助色' },
{ id: 'accent', hex: '#EC4899', name: '强调色' },
{ id: 'dark', hex: '#1E293B', name: '深色' },
{ id: 'light', hex: '#F1F5F9', name: '浅色' }
]
}
}
// 监听主颜色变化
watch(mainColor, (newColor) => {
updateColorValues(newColor)
})
// 初始化
onMounted(() => {
loadPalette()
updateColorValues(mainColor.value)
})
</script>

View File

@ -0,0 +1,621 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="generateCron"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'calendar-alt']" class="mr-2" />
{{ t('tools.cron_generator.generate') }}
</button>
<button
@click="copyCronExpression"
:disabled="!cronExpression"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
{{ t('tools.cron_generator.copy') }}
</button>
<button
@click="resetSettings"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'refresh']" class="mr-2" />
{{ t('tools.cron_generator.reset') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 配置区域 -->
<div class="space-y-4">
<!-- 预设任务 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.presets') }}</h3>
<div class="grid grid-cols-1 gap-2">
<button
v-for="preset in presets"
:key="preset.name"
@click="applyPreset(preset)"
class="text-left p-3 rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
<div class="font-medium">{{ preset.name }}</div>
<div class="text-sm text-tertiary">{{ preset.description }}</div>
<div class="text-xs text-tertiary font-mono mt-1">{{ preset.expression }}</div>
</button>
</div>
</div>
<!-- 自定义配置 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.custom_config') }}</h3>
<div class="space-y-4">
<!-- 执行频率类型 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.frequency_type') }}
</label>
<select v-model="frequencyType" class="select-field" @change="handleFrequencyChange">
<option value="minutes">每分钟</option>
<option value="hourly">每小时</option>
<option value="daily">每天</option>
<option value="weekly">每周</option>
<option value="monthly">每月</option>
<option value="yearly">每年</option>
<option value="custom">自定义</option>
</select>
</div>
<!-- 分钟设置 -->
<div v-if="showMinutes">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.minutes') }} (0-59)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.minuteType" class="select-field">
<option value="*">每分钟</option>
<option value="specific">指定分钟</option>
<option value="interval">间隔</option>
</select>
<input
v-if="cronConfig.minuteType !== '*'"
v-model="cronConfig.minuteValue"
type="number"
min="0"
max="59"
class="input-field"
:placeholder="cronConfig.minuteType === 'specific' ? '分钟' : '间隔'"
>
</div>
</div>
<!-- 小时设置 -->
<div v-if="showHours">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.hours') }} (0-23)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.hourType" class="select-field">
<option value="*">每小时</option>
<option value="specific">指定小时</option>
<option value="interval">间隔</option>
</select>
<input
v-if="cronConfig.hourType !== '*'"
v-model="cronConfig.hourValue"
type="number"
min="0"
max="23"
class="input-field"
:placeholder="cronConfig.hourType === 'specific' ? '小时' : '间隔'"
>
</div>
</div>
<!-- 日期设置 -->
<div v-if="showDays">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.days') }} (1-31)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.dayType" class="select-field">
<option value="*">每天</option>
<option value="specific">指定日期</option>
<option value="interval">间隔</option>
</select>
<input
v-if="cronConfig.dayType !== '*'"
v-model="cronConfig.dayValue"
type="number"
min="1"
max="31"
class="input-field"
:placeholder="cronConfig.dayType === 'specific' ? '日期' : '间隔'"
>
</div>
</div>
<!-- 月份设置 -->
<div v-if="showMonths">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.months') }} (1-12)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.monthType" class="select-field">
<option value="*">每月</option>
<option value="specific">指定月份</option>
</select>
<select
v-if="cronConfig.monthType === 'specific'"
v-model="cronConfig.monthValue"
class="select-field"
>
<option v-for="(month, index) in months" :key="index" :value="index + 1">
{{ month }}
</option>
</select>
</div>
</div>
<!-- 星期设置 -->
<div v-if="showWeekdays">
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.cron_generator.weekdays') }} (0-6)
</label>
<div class="grid grid-cols-2 gap-2">
<select v-model="cronConfig.weekdayType" class="select-field">
<option value="*">每天</option>
<option value="specific">指定星期</option>
</select>
<select
v-if="cronConfig.weekdayType === 'specific'"
v-model="cronConfig.weekdayValue"
class="select-field"
>
<option v-for="(day, index) in weekdays" :key="index" :value="index">
{{ day }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- 结果区域 -->
<div class="space-y-4">
<!-- Cron表达式 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.expression') }}</h3>
<div class="bg-block rounded-lg p-4 mb-4">
<div class="font-mono text-lg text-primary text-center">
{{ cronExpression || '* * * * *' }}
</div>
</div>
<!-- 表达式说明 -->
<div class="text-sm text-secondary space-y-1">
<div class="grid grid-cols-5 gap-2 text-center font-medium border-b border-primary border-opacity-20 pb-2">
<div>分钟</div>
<div>小时</div>
<div>日期</div>
<div>月份</div>
<div>星期</div>
</div>
<div class="grid grid-cols-5 gap-2 text-center font-mono">
<div>{{ cronParts.minute }}</div>
<div>{{ cronParts.hour }}</div>
<div>{{ cronParts.day }}</div>
<div>{{ cronParts.month }}</div>
<div>{{ cronParts.weekday }}</div>
</div>
</div>
</div>
<!-- 执行时间描述 -->
<div v-if="cronDescription" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.description') }}</h3>
<div class="text-secondary">
{{ cronDescription }}
</div>
</div>
<!-- 下次执行时间 -->
<div v-if="nextExecutions.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.next_executions') }}</h3>
<div class="space-y-2">
<div
v-for="(execution, index) in nextExecutions"
:key="index"
class="flex justify-between items-center p-2 bg-block rounded"
>
<span class="text-secondary"> {{ index + 1 }} :</span>
<span class="text-primary font-medium">{{ execution }}</span>
</div>
</div>
</div>
<!-- 常用表达式参考 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.cron_generator.reference') }}</h3>
<div class="text-sm space-y-2">
<div class="space-y-1">
<div class="font-medium text-secondary">特殊字符:</div>
<div class="text-tertiary">
<div>* - 任意值</div>
<div>? - 不指定值</div>
<div>- - 范围 (: 1-5)</div>
<div>, - 列表 (: 1,3,5)</div>
<div>/ - 间隔 (: 0/5)</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const frequencyType = ref('daily')
const copied = ref(false)
// Cron配置
const cronConfig = ref({
minuteType: 'specific',
minuteValue: 0,
hourType: 'specific',
hourValue: 0,
dayType: '*',
dayValue: 1,
monthType: '*',
monthValue: 1,
weekdayType: '*',
weekdayValue: 0
})
// 预设任务
const presets = [
{
name: '每分钟执行',
description: '每分钟执行一次',
expression: '* * * * *',
config: { minuteType: '*', hourType: '*', dayType: '*', monthType: '*', weekdayType: '*' }
},
{
name: '每小时执行',
description: '每小时的第0分钟执行',
expression: '0 * * * *',
config: { minuteType: 'specific', minuteValue: 0, hourType: '*', dayType: '*', monthType: '*', weekdayType: '*' }
},
{
name: '每天执行',
description: '每天凌晨执行',
expression: '0 0 * * *',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 0, dayType: '*', monthType: '*', weekdayType: '*' }
},
{
name: '每周执行',
description: '每周日凌晨执行',
expression: '0 0 * * 0',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 0, dayType: '*', monthType: '*', weekdayType: 'specific', weekdayValue: 0 }
},
{
name: '每月执行',
description: '每月1日凌晨执行',
expression: '0 0 1 * *',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 0, dayType: 'specific', dayValue: 1, monthType: '*', weekdayType: '*' }
},
{
name: '工作日执行',
description: '工作日上午9点执行',
expression: '0 9 * * 1-5',
config: { minuteType: 'specific', minuteValue: 0, hourType: 'specific', hourValue: 9, dayType: '*', monthType: '*', weekdayType: 'specific', weekdayValue: '1-5' }
}
]
// 月份和星期名称
const months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
// 显示控制
const showMinutes = computed(() => {
return ['custom', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].includes(frequencyType.value)
})
const showHours = computed(() => {
return ['custom', 'daily', 'weekly', 'monthly', 'yearly'].includes(frequencyType.value)
})
const showDays = computed(() => {
return ['custom', 'monthly', 'yearly'].includes(frequencyType.value)
})
const showMonths = computed(() => {
return ['custom', 'yearly'].includes(frequencyType.value)
})
const showWeekdays = computed(() => {
return ['custom', 'weekly'].includes(frequencyType.value)
})
// Cron表达式各部分
const cronParts = computed(() => {
const minute = getCronPart('minute')
const hour = getCronPart('hour')
const day = getCronPart('day')
const month = getCronPart('month')
const weekday = getCronPart('weekday')
return { minute, hour, day, month, weekday }
})
// 完整Cron表达式
const cronExpression = computed(() => {
const { minute, hour, day, month, weekday } = cronParts.value
return `${minute} ${hour} ${day} ${month} ${weekday}`
})
// Cron描述
const cronDescription = computed(() => {
return generateDescription()
})
// 下次执行时间
const nextExecutions = ref<string[]>([])
// 获取Cron部分
const getCronPart = (type: string): string => {
const config = cronConfig.value
switch (type) {
case 'minute':
if (config.minuteType === '*') return '*'
if (config.minuteType === 'specific') return config.minuteValue.toString()
if (config.minuteType === 'interval') return `*/${config.minuteValue}`
break
case 'hour':
if (config.hourType === '*') return '*'
if (config.hourType === 'specific') return config.hourValue.toString()
if (config.hourType === 'interval') return `*/${config.hourValue}`
break
case 'day':
if (config.dayType === '*') return '*'
if (config.dayType === 'specific') return config.dayValue.toString()
if (config.dayType === 'interval') return `*/${config.dayValue}`
break
case 'month':
if (config.monthType === '*') return '*'
if (config.monthType === 'specific') return config.monthValue.toString()
break
case 'weekday':
if (config.weekdayType === '*') return '*'
if (config.weekdayType === 'specific') return config.weekdayValue.toString()
break
}
return '*'
}
// 生成描述
const generateDescription = (): string => {
switch (frequencyType.value) {
case 'minutes':
return '每分钟执行'
case 'hourly':
return `每小时的第${cronConfig.value.minuteValue}分钟执行`
case 'daily':
return `每天${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
case 'weekly':
return `${weekdays[cronConfig.value.weekdayValue]}${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
case 'monthly':
return `每月${cronConfig.value.dayValue}${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
case 'yearly':
return `每年${months[cronConfig.value.monthValue - 1]}${cronConfig.value.dayValue}${String(cronConfig.value.hourValue).padStart(2, '0')}:${String(cronConfig.value.minuteValue).padStart(2, '0')}执行`
default:
return '自定义表达式'
}
}
// 计算下次执行时间
const calculateNextExecutions = () => {
const executions: string[] = []
const now = new Date()
// 简化的计算逻辑,实际应用中可以使用 cron-parser 库
for (let i = 0; i < 5; i++) {
const nextTime = new Date(now)
switch (frequencyType.value) {
case 'minutes':
nextTime.setMinutes(now.getMinutes() + i + 1)
break
case 'hourly':
nextTime.setHours(now.getHours() + i + 1)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
case 'daily':
nextTime.setDate(now.getDate() + i + 1)
nextTime.setHours(cronConfig.value.hourValue)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
case 'weekly':
nextTime.setDate(now.getDate() + (i + 1) * 7)
nextTime.setHours(cronConfig.value.hourValue)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
case 'monthly':
nextTime.setMonth(now.getMonth() + i + 1)
nextTime.setDate(cronConfig.value.dayValue)
nextTime.setHours(cronConfig.value.hourValue)
nextTime.setMinutes(cronConfig.value.minuteValue)
break
}
executions.push(nextTime.toLocaleString('zh-CN'))
}
nextExecutions.value = executions
}
// 应用预设
const applyPreset = (preset: any) => {
// 解析预设配置
const parts = preset.expression.split(' ')
// 重置配置
resetSettings()
// 应用预设值
if (parts[0] !== '*') {
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = parseInt(parts[0])
}
if (parts[1] !== '*') {
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = parseInt(parts[1])
}
if (parts[2] !== '*') {
cronConfig.value.dayType = 'specific'
cronConfig.value.dayValue = parseInt(parts[2])
}
if (parts[3] !== '*') {
cronConfig.value.monthType = 'specific'
cronConfig.value.monthValue = parseInt(parts[3])
}
if (parts[4] !== '*') {
cronConfig.value.weekdayType = 'specific'
cronConfig.value.weekdayValue = parts[4].includes('-') ? parts[4] : parseInt(parts[4])
}
generateCron()
}
// 处理频率类型变化
const handleFrequencyChange = () => {
// 根据频率类型设置默认值
switch (frequencyType.value) {
case 'minutes':
cronConfig.value.minuteType = '*'
cronConfig.value.hourType = '*'
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'hourly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = '*'
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'daily':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'weekly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = '*'
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = 'specific'
cronConfig.value.weekdayValue = 0
break
case 'monthly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = 'specific'
cronConfig.value.dayValue = 1
cronConfig.value.monthType = '*'
cronConfig.value.weekdayType = '*'
break
case 'yearly':
cronConfig.value.minuteType = 'specific'
cronConfig.value.minuteValue = 0
cronConfig.value.hourType = 'specific'
cronConfig.value.hourValue = 0
cronConfig.value.dayType = 'specific'
cronConfig.value.dayValue = 1
cronConfig.value.monthType = 'specific'
cronConfig.value.monthValue = 1
cronConfig.value.weekdayType = '*'
break
}
generateCron()
}
// 生成Cron表达式
const generateCron = () => {
calculateNextExecutions()
}
// 复制表达式
const copyCronExpression = async () => {
if (!cronExpression.value) return
try {
await navigator.clipboard.writeText(cronExpression.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 重置设置
const resetSettings = () => {
frequencyType.value = 'daily'
cronConfig.value = {
minuteType: 'specific',
minuteValue: 0,
hourType: 'specific',
hourValue: 0,
dayType: '*',
dayValue: 1,
monthType: '*',
monthValue: 1,
weekdayType: '*',
weekdayValue: 0
}
nextExecutions.value = []
}
// 监听配置变化
watch([cronConfig, frequencyType], () => {
generateCron()
}, { deep: true })
// 初始化
generateCron()
</script>

View File

@ -0,0 +1,418 @@
<template>
<div class="space-y-6">
<!-- 算法选择 -->
<div class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">选择加密算法</h3>
<div class="grid grid-cols-2 md:grid-cols-6 gap-2">
<button
v-for="algorithm in algorithms"
:key="algorithm.type"
:class="[
'px-3 py-2 text-sm font-medium rounded transition-all',
activeAlgorithm === algorithm.type
? 'bg-primary text-white shadow-sm'
: 'bg-block text-secondary border hover:bg-hover'
]"
@click="setActiveAlgorithm(algorithm.type)"
>
{{ algorithm.name }}
</button>
</div>
</div>
<!-- 操作模式选择仅对支持编码/解码的算法显示 -->
<div v-if="currentAlgorithm?.isEncodeDecode" class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">操作模式</h3>
<div class="flex gap-2">
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
!isDecoding ? 'bg-primary text-white' : 'bg-block text-secondary border'
]"
@click="isDecoding = false"
>
{{ currentAlgorithm?.type === 'aes' ? '加密' : '编码' }}
</button>
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
isDecoding ? 'bg-primary text-white' : 'bg-block text-secondary border'
]"
@click="isDecoding = true"
>
{{ currentAlgorithm?.type === 'aes' ? '解密' : '解码' }}
</button>
</div>
</div>
<!-- 输入和处理区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-md font-medium text-primary">输入内容</h3>
<div class="flex gap-2">
<button @click="loadExample" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'redo']" class="mr-1" />
示例
</button>
<button @click="clearAll" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'eraser']" class="mr-1" />
清空
</button>
</div>
</div>
<div class="space-y-4">
<!-- 密钥输入仅对需要密钥的算法显示 -->
<div v-if="currentAlgorithm?.needsKey">
<label class="block text-sm text-secondary font-medium mb-2">密钥</label>
<input
v-model="secretKey"
type="text"
class="input-field w-full"
placeholder="请输入加密密钥..."
/>
</div>
<!-- 文本输入 -->
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ isDecoding && currentAlgorithm?.isEncodeDecode ? '待解密/解码内容' : '待加密/编码内容' }}
</label>
<textarea
v-model="inputText"
class="textarea-field h-36 w-full font-mono resize-y"
:placeholder="getInputPlaceholder()"
/>
</div>
<!-- 处理按钮 -->
<button
@click="processOperation"
class="btn-primary w-full flex items-center justify-center gap-2"
:disabled="!inputText.trim() || (currentAlgorithm?.needsKey && !secretKey.trim())"
>
<FontAwesomeIcon :icon="['fas', 'lock']" />
{{ getProcessButtonText() }}
</button>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-md font-medium text-primary">输出结果</h3>
<button
v-if="output"
@click="copyToClipboard"
class="btn-secondary text-sm"
:disabled="!output"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<div class="space-y-4">
<!-- 结果显示 -->
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ isDecoding && currentAlgorithm?.isEncodeDecode ? '解密/解码结果' : '加密/编码结果' }}
</label>
<textarea
v-model="output"
readonly
class="textarea-field h-36 w-full font-mono resize-y bg-block"
placeholder="结果将在这里显示..."
/>
</div>
<!-- 结果信息 -->
<div v-if="output" class="text-sm text-tertiary">
<div>字符长度: {{ output.length }}</div>
<div v-if="currentAlgorithm?.type !== 'aes' && !isDecoding">
哈希值: {{ currentAlgorithm?.name }}
</div>
</div>
</div>
</div>
</div>
<!-- 错误和成功消息 -->
<div v-if="error" class="p-3 bg-red-900/20 border border-red-700/30 rounded-lg text-error">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2" />
{{ error }}
</div>
<div v-if="success" class="p-3 bg-green-900/20 border border-green-700/30 rounded-lg text-green-400">
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
{{ success }}
</div>
<!-- 算法说明 -->
<div class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
算法说明
</h3>
<div class="text-sm text-secondary">
{{ getAlgorithmDescription() }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import CryptoJS from 'crypto-js'
// 加密算法类型
type CryptoType = 'md5' | 'sha1' | 'sha256' | 'sha512' | 'aes' | 'base64'
// 算法配置
interface AlgorithmConfig {
type: CryptoType
name: string
needsKey: boolean
isEncodeDecode: boolean
description: string
}
// 响应式状态
const activeAlgorithm = ref<CryptoType>('md5')
const inputText = ref('')
const secretKey = ref('')
const output = ref('')
const isDecoding = ref(false)
const copied = ref(false)
const error = ref<string | null>(null)
const success = ref<string | null>(null)
// 算法配置
const algorithms: AlgorithmConfig[] = [
{
type: 'md5',
name: 'MD5',
needsKey: false,
isEncodeDecode: false,
description: 'MD5是一种广泛使用的密码散列函数可以产生出一个128位16字节的散列值。常用于文件校验和密码存储。'
},
{
type: 'sha1',
name: 'SHA1',
needsKey: false,
isEncodeDecode: false,
description: 'SHA-1是一种密码散列函数可以产生一个160位20字节的散列值。比MD5更安全但现在也被认为不够安全。'
},
{
type: 'sha256',
name: 'SHA256',
needsKey: false,
isEncodeDecode: false,
description: 'SHA-256是SHA-2家族的一种可以产生一个256位32字节的散列值。目前被认为是安全的哈希算法。'
},
{
type: 'sha512',
name: 'SHA512',
needsKey: false,
isEncodeDecode: false,
description: 'SHA-512是SHA-2家族的一种可以产生一个512位64字节的散列值。比SHA-256更安全计算量也更大。'
},
{
type: 'aes',
name: 'AES',
needsKey: true,
isEncodeDecode: true,
description: 'AES高级加密标准是一种对称加密算法需要相同的密钥进行加密和解密。广泛用于数据保护。'
},
{
type: 'base64',
name: 'Base64',
needsKey: false,
isEncodeDecode: true,
description: 'Base64是一种编码方式常用于在文本环境中传输二进制数据。不是加密算法只是编码转换。'
}
]
// 当前算法配置
const currentAlgorithm = computed(() =>
algorithms.find(algo => algo.type === activeAlgorithm.value)
)
// 获取输入提示文本
const getInputPlaceholder = (): string => {
if (isDecoding.value && currentAlgorithm.value?.isEncodeDecode) {
return currentAlgorithm.value.type === 'aes'
? '请输入要解密的密文...'
: '请输入要解码的内容...'
}
return '请输入要处理的文本内容...'
}
// 获取处理按钮文本
const getProcessButtonText = (): string => {
if (!currentAlgorithm.value) return '处理'
if (currentAlgorithm.value.isEncodeDecode) {
return isDecoding.value
? (currentAlgorithm.value.type === 'aes' ? '解密' : '解码')
: (currentAlgorithm.value.type === 'aes' ? '加密' : '编码')
}
return '生成哈希'
}
// 获取算法描述
const getAlgorithmDescription = (): string => {
return currentAlgorithm.value?.description || ''
}
// 设置活动算法
const setActiveAlgorithm = (type: CryptoType) => {
activeAlgorithm.value = type
output.value = ''
error.value = null
success.value = null
// 如果不支持编码/解码,重置解码状态
if (!currentAlgorithm.value?.isEncodeDecode) {
isDecoding.value = false
}
}
// 处理操作
const processOperation = () => {
error.value = null
success.value = null
output.value = ''
if (!inputText.value.trim()) {
error.value = '请输入要处理的内容'
return
}
if (currentAlgorithm.value?.needsKey && !secretKey.value.trim()) {
error.value = '请输入密钥'
return
}
try {
let result = ''
switch (activeAlgorithm.value) {
case 'md5':
result = CryptoJS.MD5(inputText.value).toString()
break
case 'sha1':
result = CryptoJS.SHA1(inputText.value).toString()
break
case 'sha256':
result = CryptoJS.SHA256(inputText.value).toString()
break
case 'sha512':
result = CryptoJS.SHA512(inputText.value).toString()
break
case 'aes':
if (isDecoding.value) {
// 解密操作
try {
const decrypted = CryptoJS.AES.decrypt(inputText.value, secretKey.value)
result = decrypted.toString(CryptoJS.enc.Utf8)
if (!result) {
throw new Error('解密失败,请检查密文和密钥是否正确')
}
} catch {
throw new Error('解密失败,请检查密文和密钥是否正确')
}
} else {
// 加密操作
result = CryptoJS.AES.encrypt(inputText.value, secretKey.value).toString()
}
break
case 'base64':
if (isDecoding.value) {
// Base64解码
try {
result = CryptoJS.enc.Base64.parse(inputText.value).toString(CryptoJS.enc.Utf8)
} catch {
throw new Error('Base64解码失败请检查输入内容格式')
}
} else {
// Base64编码
result = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(inputText.value))
}
break
}
output.value = result
success.value = isDecoding.value ? '解密/解码成功' : '加密/编码成功'
} catch (err) {
console.error('处理错误:', err)
error.value = `处理失败: ${err instanceof Error ? err.message : '未知错误'}`
}
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!output.value) return
try {
await navigator.clipboard.writeText(output.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('复制失败:', err)
error.value = '复制失败'
}
}
// 清空所有内容
const clearAll = () => {
inputText.value = ''
secretKey.value = ''
output.value = ''
error.value = null
success.value = null
}
// 加载示例
const loadExample = () => {
const examples: Record<CryptoType, { input: string; key?: string }> = {
md5: { input: 'Hello, World!' },
sha1: { input: 'Hello, World!' },
sha256: { input: 'Hello, World!' },
sha512: { input: 'Hello, World!' },
aes: { input: 'Hello, World!', key: 'secret-key-12345' },
base64: { input: 'Hello, World!' }
}
const example = examples[activeAlgorithm.value]
inputText.value = example.input
if (example.key) {
secretKey.value = example.key
}
output.value = ''
error.value = null
success.value = null
}
// 清除状态提示的定时器
watch([error, success], () => {
if (error.value || success.value) {
setTimeout(() => {
error.value = null
success.value = null
}, 3000)
}
})
</script>

View File

@ -0,0 +1,332 @@
<template>
<div class="space-y-6">
<!-- 标题和描述 -->
<div class="card p-6">
<h2 class="text-2xl font-bold text-primary mb-4">CSS渐变生成器</h2>
<p class="text-secondary mb-4">创建线性渐变和径向渐变生成CSS代码并实时预览效果</p>
</div>
<!-- 渐变类型选择 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">渐变类型</h3>
<div class="flex gap-3">
<button
@click="gradientType = 'linear'"
:class="gradientType === 'linear' ? 'btn-primary' : 'btn-secondary'"
class="px-4 py-2 rounded-lg"
>
线性渐变
</button>
<button
@click="gradientType = 'radial'"
:class="gradientType === 'radial' ? 'btn-primary' : 'btn-secondary'"
class="px-4 py-2 rounded-lg"
>
径向渐变
</button>
</div>
</div>
<!-- 线性渐变设置 -->
<div v-if="gradientType === 'linear'" class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">角度设置</h3>
<div class="flex items-center gap-4">
<input
v-model.number="linearAngle"
type="range"
min="0"
max="360"
class="flex-1"
/>
<input
v-model.number="linearAngle"
type="number"
min="0"
max="360"
class="input w-20"
/>
<span>°</span>
</div>
</div>
<!-- 径向渐变设置 -->
<div v-if="gradientType === 'radial'" class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">径向渐变设置</h3>
<!-- 形状 -->
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-2">形状</label>
<div class="flex gap-3">
<button
v-for="shape in radialShapes"
:key="shape.value"
@click="radialShape = shape.value"
:class="{
'btn-primary': radialShape === shape.value,
'btn-secondary': radialShape !== shape.value
}"
class="px-4 py-2 rounded transition-colors"
>
{{ shape.label }}
</button>
</div>
</div>
<!-- 大小 -->
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-2">大小</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="size in radialSizes"
:key="size.value"
@click="radialSize = size.value"
:class="{
'btn-primary': radialSize === size.value,
'btn-secondary': radialSize !== size.value
}"
class="px-3 py-2 text-sm rounded transition-colors"
>
{{ size.label }}
</button>
</div>
</div>
<!-- 位置 -->
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-2">位置</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="position in radialPositions"
:key="position.value"
@click="radialPosition = position.value"
:class="{
'btn-primary': radialPosition === position.value,
'btn-secondary': radialPosition !== position.value
}"
class="px-3 py-2 text-sm rounded transition-colors"
>
{{ position.label }}
</button>
</div>
</div>
</div>
<!-- 颜色设置 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">颜色设置</h3>
<div class="space-y-3 mb-4">
<div v-for="(color, index) in colors" :key="index" class="flex items-center gap-3">
<input
v-model="color.color"
type="color"
class="w-12 h-10 rounded"
/>
<input
v-model="color.color"
type="text"
class="input flex-1"
/>
<input
v-model.number="color.position"
type="number"
min="0"
max="100"
class="input w-20"
/>
<span>%</span>
<button
@click="removeColor(index)"
:disabled="colors.length <= 2"
class="btn-secondary"
>
删除
</button>
</div>
</div>
<button @click="addColor" class="btn-primary">添加颜色</button>
</div>
<!-- 预览 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">预览</h3>
<div
class="w-full h-40 rounded-lg border"
:style="{ background: generatedCSS }"
></div>
</div>
<!-- 生成的CSS -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-primary">生成的CSS</h3>
<button @click="copyCSS" class="btn-secondary">复制</button>
</div>
<textarea
:value="generatedCSS"
readonly
class="input w-full h-20 font-mono"
></textarea>
</div>
<!-- 使用说明 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">使用说明</h3>
<div class="prose text-secondary max-w-none">
<ul class="space-y-2">
<li><strong>线性渐变</strong>沿直线方向的颜色过渡可调整角度和方向</li>
<li><strong>径向渐变</strong>从中心点向外辐射的颜色过渡可调整形状大小和位置</li>
<li><strong>颜色设置</strong>点击颜色块选择颜色调整位置百分比控制渐变分布</li>
<li><strong>预设渐变</strong>提供多种常用渐变效果点击即可应用</li>
<li><strong>实时预览</strong>所有修改都会实时显示在预览区域</li>
<li><strong>代码生成</strong>自动生成标准CSS代码支持复制完整规则或仅background属性</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 颜色接口
interface ColorStop {
color: string
position: number
}
// 渐变类型
const gradientTypes = [
{ label: '线性渐变', value: 'linear' },
{ label: '径向渐变', value: 'radial' }
]
// 线性渐变方向
const linearDirections = [
{ label: '向右', value: 90 },
{ label: '向左', value: 270 },
{ label: '向下', value: 180 },
{ label: '向上', value: 0 },
{ label: '右下', value: 135 },
{ label: '左上', value: 315 },
{ label: '右上', value: 45 },
{ label: '左下', value: 225 }
]
// 径向渐变形状
const radialShapes = [
{ label: '椭圆', value: 'ellipse' },
{ label: '圆形', value: 'circle' }
]
// 径向渐变大小
const radialSizes = [
{ label: '最近边', value: 'closest-side' },
{ label: '最近角', value: 'closest-corner' },
{ label: '最远边', value: 'farthest-side' },
{ label: '最远角', value: 'farthest-corner' }
]
// 径向渐变位置
const radialPositions = [
{ label: '左上', value: 'top left' },
{ label: '上方', value: 'top' },
{ label: '右上', value: 'top right' },
{ label: '左侧', value: 'left' },
{ label: '中心', value: 'center' },
{ label: '右侧', value: 'right' },
{ label: '左下', value: 'bottom left' },
{ label: '下方', value: 'bottom' },
{ label: '右下', value: 'bottom right' }
]
// 响应式状态
const gradientType = ref('linear')
const linearAngle = ref(90)
const radialShape = ref('ellipse')
const radialSize = ref('farthest-corner')
const radialPosition = ref('center')
const colors = ref<ColorStop[]>([
{ color: '#667eea', position: 0 },
{ color: '#764ba2', position: 100 }
])
// 计算生成的CSS
const generatedCSS = computed(() => {
const colorStops = colors.value
.sort((a, b) => a.position - b.position)
.map(color => `${color.color} ${color.position}%`)
.join(', ')
if (gradientType.value === 'linear') {
return `linear-gradient(${linearAngle.value}deg, ${colorStops})`
} else {
return `radial-gradient(${radialShape.value} ${radialSize.value} at ${radialPosition.value}, ${colorStops})`
}
})
// 添加颜色
const addColor = () => {
const newPosition = colors.value.length > 0
? Math.max(...colors.value.map(c => c.position)) + 10
: 50
colors.value.push({
color: '#000000',
position: Math.min(newPosition, 100)
})
}
// 删除颜色
const removeColor = (index: number) => {
if (colors.value.length > 2) {
colors.value.splice(index, 1)
}
}
// 复制CSS
const copyCSS = async () => {
try {
await navigator.clipboard.writeText(generatedCSS.value)
alert('CSS代码已复制到剪贴板')
} catch (error) {
console.error('复制失败:', error)
alert('复制失败,请手动复制')
}
}
</script>
<style scoped>
.slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, #ddd, #ddd);
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: none;
}
.prose ul {
list-style-type: disc;
padding-left: 1.5rem;
}
</style>

View File

@ -0,0 +1,550 @@
<template>
<div class="space-y-6">
<!-- 模式切换 -->
<div class="flex flex-wrap gap-4 justify-between items-center">
<div class="flex items-center bg-card rounded-md p-1 border">
<button
:class="[
'px-4 py-2 text-sm font-medium transition-all rounded-l-md',
mode === 'diff' ? 'bg-primary text-white shadow-sm' : 'text-secondary hover:text-primary'
]"
@click="mode = 'diff'"
>
日期差值计算
</button>
<button
:class="[
'px-4 py-2 text-sm font-medium transition-all rounded-r-md',
mode === 'add' ? 'bg-primary text-white shadow-sm' : 'text-secondary hover:text-primary'
]"
@click="mode = 'add'"
>
日期加减计算
</button>
</div>
</div>
<!-- 日期差值计算 -->
<div v-if="mode === 'diff'" class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">日期差值计算器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入部分 -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-secondary mb-2">开始日期</label>
<div class="flex items-center gap-2">
<input
v-model="startDate"
type="datetime-local"
class="input-field flex-1"
@input="calculateDateDiff"
/>
<button
class="btn-secondary text-sm px-3 py-2"
@click="setStartDateToCurrent"
>
当前时间
</button>
</div>
</div>
<div class="flex justify-center">
<button
class="btn-secondary text-sm px-4 py-2"
@click="swapDates"
>
<FontAwesomeIcon :icon="['fas', 'calendar-alt']" class="mr-2" />
交换日期
</button>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">结束日期</label>
<div class="flex items-center gap-2">
<input
v-model="endDate"
type="datetime-local"
class="input-field flex-1"
@input="calculateDateDiff"
/>
<button
class="btn-secondary text-sm px-3 py-2"
@click="setEndDateToCurrent"
>
当前时间
</button>
</div>
</div>
</div>
<!-- 结果部分 -->
<div class="bg-block rounded-md p-4 border">
<h3 class="text-primary font-medium mb-3">计算结果</h3>
<div class="space-y-2">
<div
v-for="(result, key) in diffResults"
:key="key"
class="flex justify-between items-center py-2 border-b border-primary/10 last:border-0"
>
<span class="text-sm text-secondary">{{ getTimeUnitLabel(key) }}</span>
<div class="flex items-center gap-2">
<span class="text-sm text-primary font-semibold">
{{ result }} {{ getTimeUnitName(key) }}
</span>
<button
@click="() => copyToClipboard(result.toString(), key)"
class="text-tertiary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="copied === key ? ['fas', 'check'] : ['fas', 'copy']" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 日期加减计算 -->
<div v-if="mode === 'add'" class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">日期加减计算器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入部分 -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-secondary mb-2">基准日期</label>
<div class="flex items-center gap-2">
<input
v-model="baseDate"
type="datetime-local"
class="input-field flex-1"
@input="calculateDateAddition"
/>
<button
class="btn-secondary text-sm px-3 py-2"
@click="setBaseDateToCurrent"
>
当前时间
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">操作类型</label>
<div class="flex gap-2">
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
operation === 'add' ? 'bg-primary text-white' : 'bg-block text-secondary border'
]"
@click="() => setOperation('add')"
>
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-2" />
加上
</button>
<button
:class="[
'flex-1 px-4 py-2 text-sm font-medium rounded transition-all',
operation === 'subtract' ? 'bg-primary text-white' : 'bg-block text-secondary border'
]"
@click="() => setOperation('subtract')"
>
<FontAwesomeIcon :icon="['fas', 'minus']" class="mr-2" />
减去
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">时间数量</label>
<input
v-model.number="timeAmount"
type="number"
min="0"
step="1"
class="input-field w-full"
@input="calculateDateAddition"
/>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">时间单位</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button
v-for="unit in timeUnits"
:key="unit.value"
:class="[
'px-3 py-2 text-xs rounded transition-all',
timeUnit === unit.value ? 'bg-primary text-white' : 'btn-secondary'
]"
@click="() => setTimeUnit(unit.value)"
>
{{ unit.label }}
</button>
</div>
</div>
</div>
<!-- 结果部分 -->
<div class="bg-block rounded-md p-4 border">
<h3 class="text-primary font-medium mb-3">计算结果</h3>
<div v-if="addResult" class="space-y-3">
<div class="text-center">
<div class="text-lg text-primary font-bold">{{ addResult }}</div>
<button
@click="() => copyToClipboard(addResult, 'result')"
class="mt-2 btn-secondary text-sm"
>
<FontAwesomeIcon :icon="copied === 'result' ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied === 'result' ? '已复制' : '复制结果' }}
</button>
</div>
<!-- 详细信息 -->
<div class="text-xs text-tertiary text-center">
{{ formatCalculationDescription() }}
</div>
</div>
<div v-else class="text-tertiary text-center py-4">
请填写有效的基准日期和时间数量
</div>
</div>
</div>
</div>
<!-- 快速操作面板 -->
<div class="card p-4">
<h3 class="text-md font-medium text-primary mb-3">快速操作</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<button
v-for="quick in quickOperations"
:key="quick.label"
@click="() => applyQuickOperation(quick)"
class="btn-secondary text-sm"
>
{{ quick.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// 时间单位枚举
enum TimeUnit {
YEARS = 'years',
MONTHS = 'months',
WEEKS = 'weeks',
DAYS = 'days',
HOURS = 'hours',
MINUTES = 'minutes'
}
// 响应式状态
const mode = ref<'diff' | 'add'>('diff')
// 日期差值计算状态
const startDate = ref('')
const endDate = ref('')
const diffResults = ref<Record<string, number>>({})
// 日期加减计算状态
const baseDate = ref('')
const timeAmount = ref(1)
const timeUnit = ref<TimeUnit>(TimeUnit.DAYS)
const operation = ref<'add' | 'subtract'>('add')
const addResult = ref('')
// 复制状态
const copied = ref<string | null>(null)
// 时间单位选项
const timeUnits = [
{ value: TimeUnit.YEARS, label: '年' },
{ value: TimeUnit.MONTHS, label: '月' },
{ value: TimeUnit.WEEKS, label: '周' },
{ value: TimeUnit.DAYS, label: '天' },
{ value: TimeUnit.HOURS, label: '小时' },
{ value: TimeUnit.MINUTES, label: '分钟' }
]
// 快速操作选项
const quickOperations = [
{ label: '距今一周', action: () => setQuickDiff(-7, 'days') },
{ label: '距今一月', action: () => setQuickDiff(-1, 'months') },
{ label: '一周后', action: () => setQuickAdd(7, 'days') },
{ label: '一月后', action: () => setQuickAdd(1, 'months') }
]
// 格式化日期为显示格式
const formatDateForDisplay = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
// 格式化日期为输入框格式
const formatDateForInput = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
// 获取时间单位标签
const getTimeUnitLabel = (key: string): string => {
const labels: Record<string, string> = {
years: '相差年数',
months: '相差月数',
weeks: '相差周数',
days: '相差天数',
hours: '相差小时',
minutes: '相差分钟',
seconds: '相差秒数',
milliseconds: '相差毫秒'
}
return labels[key] || key
}
// 获取时间单位名称
const getTimeUnitName = (key: string): string => {
const names: Record<string, string> = {
years: '年',
months: '月',
weeks: '周',
days: '天',
hours: '小时',
minutes: '分钟',
seconds: '秒',
milliseconds: '毫秒'
}
return names[key] || ''
}
// 计算日期差值
const calculateDateDiff = () => {
if (!startDate.value || !endDate.value) {
diffResults.value = {}
return
}
try {
const startDateTime = new Date(startDate.value)
const endDateTime = new Date(endDate.value)
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
return
}
// 计算毫秒差值
const diffMs = endDateTime.getTime() - startDateTime.getTime()
// 计算各个单位的差值
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
const diffWeeks = Math.floor(diffDays / 7)
// 计算月份差
let months = (endDateTime.getFullYear() - startDateTime.getFullYear()) * 12
months += endDateTime.getMonth() - startDateTime.getMonth()
// 计算年份差
const diffYears = Math.floor(months / 12)
// 设置结果
diffResults.value = {
years: diffYears,
months: months,
weeks: diffWeeks,
days: diffDays,
hours: diffHours,
minutes: diffMinutes,
seconds: diffSeconds,
milliseconds: diffMs
}
} catch (error) {
console.error('计算错误:', error)
}
}
// 计算日期加减
const calculateDateAddition = () => {
if (!baseDate.value || isNaN(timeAmount.value)) {
addResult.value = ''
return
}
try {
const baseDateTime = new Date(baseDate.value)
if (isNaN(baseDateTime.getTime())) {
return
}
const resultDate = new Date(baseDateTime)
const sign = operation.value === 'add' ? 1 : -1
switch (timeUnit.value) {
case TimeUnit.YEARS:
resultDate.setFullYear(resultDate.getFullYear() + sign * timeAmount.value)
break
case TimeUnit.MONTHS:
resultDate.setMonth(resultDate.getMonth() + sign * timeAmount.value)
break
case TimeUnit.WEEKS:
resultDate.setDate(resultDate.getDate() + sign * timeAmount.value * 7)
break
case TimeUnit.DAYS:
resultDate.setDate(resultDate.getDate() + sign * timeAmount.value)
break
case TimeUnit.HOURS:
resultDate.setHours(resultDate.getHours() + sign * timeAmount.value)
break
case TimeUnit.MINUTES:
resultDate.setMinutes(resultDate.getMinutes() + sign * timeAmount.value)
break
}
// 格式化结果
addResult.value = formatDateForDisplay(resultDate)
} catch (error) {
console.error('计算错误:', error)
}
}
// 格式化计算描述
const formatCalculationDescription = (): string => {
const unitName = timeUnits.find(unit => unit.value === timeUnit.value)?.label || ''
const op = operation.value === 'add' ? '加上' : '减去'
return `${formatDateForDisplay(new Date(baseDate.value))} ${op} ${timeAmount.value} ${unitName}`
}
// 设置开始日期为当前时间
const setStartDateToCurrent = () => {
const now = formatDateForInput(new Date())
startDate.value = now
calculateDateDiff()
}
// 设置结束日期为当前时间
const setEndDateToCurrent = () => {
const now = formatDateForInput(new Date())
endDate.value = now
calculateDateDiff()
}
// 设置基准日期为当前时间
const setBaseDateToCurrent = () => {
const now = formatDateForInput(new Date())
baseDate.value = now
calculateDateAddition()
}
// 交换开始和结束日期
const swapDates = () => {
const temp = startDate.value
startDate.value = endDate.value
endDate.value = temp
calculateDateDiff()
}
// 设置时间单位
const setTimeUnit = (unit: TimeUnit) => {
timeUnit.value = unit
calculateDateAddition()
}
// 设置操作类型
const setOperation = (op: 'add' | 'subtract') => {
operation.value = op
calculateDateAddition()
}
// 复制到剪贴板
const copyToClipboard = async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text)
copied.value = type
setTimeout(() => {
copied.value = null
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 设置快速日期差值
const setQuickDiff = (amount: number, unit: string) => {
const now = new Date()
const past = new Date(now)
switch (unit) {
case 'days':
past.setDate(past.getDate() + amount)
break
case 'months':
past.setMonth(past.getMonth() + amount)
break
}
startDate.value = formatDateForInput(past)
endDate.value = formatDateForInput(now)
mode.value = 'diff'
calculateDateDiff()
}
// 设置快速日期加减
const setQuickAdd = (amount: number, unit: string) => {
const now = new Date()
baseDate.value = formatDateForInput(now)
timeAmount.value = amount
switch (unit) {
case 'days':
timeUnit.value = TimeUnit.DAYS
break
case 'months':
timeUnit.value = TimeUnit.MONTHS
break
}
operation.value = 'add'
mode.value = 'add'
calculateDateAddition()
}
// 应用快速操作
const applyQuickOperation = (quick: any) => {
quick.action()
}
// 初始化
onMounted(() => {
const now = new Date()
const oneWeekAgo = new Date(now)
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)
startDate.value = formatDateForInput(oneWeekAgo)
endDate.value = formatDateForInput(now)
baseDate.value = formatDateForInput(now)
// 初始化时计算一次
calculateDateDiff()
calculateDateAddition()
})
</script>

View File

@ -0,0 +1,306 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => encode('base64')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'lock']" class="mr-2" />
{{ t('tools.encoding_converter.base64_encode') }}
</button>
<button
@click="() => decode('base64')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'unlock']" class="mr-2" />
{{ t('tools.encoding_converter.base64_decode') }}
</button>
<button
@click="() => encode('url')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'link']" class="mr-2" />
{{ t('tools.encoding_converter.url_encode') }}
</button>
<button
@click="() => decode('url')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'unlink']" class="mr-2" />
{{ t('tools.encoding_converter.url_decode') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('common.clear') }}
</button>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusMessage.type === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusMessage.type === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage.text }}</span>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<div class="flex space-x-2">
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
</div>
<textarea
v-model="inputText"
:placeholder="t('tools.encoding_converter.input_placeholder')"
class="textarea-field h-80"
/>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<div class="flex space-x-2">
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
</div>
<textarea
v-model="outputText"
:placeholder="t('tools.encoding_converter.output_placeholder')"
class="textarea-field h-80"
readonly
/>
</div>
</div>
<!-- 快速转换工具 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Base64 工具 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">Base64 工具</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<button @click="() => encode('base64')" class="btn-secondary text-sm">编码</button>
<button @click="() => decode('base64')" class="btn-secondary text-sm">解码</button>
</div>
<div class="text-xs text-tertiary">
Base64是一种基于64个可打印字符来表示二进制数据的表示方法
</div>
</div>
</div>
<!-- URL 工具 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">URL 编码工具</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<button @click="() => encode('url')" class="btn-secondary text-sm">编码</button>
<button @click="() => decode('url')" class="btn-secondary text-sm">解码</button>
</div>
<div class="text-xs text-tertiary">
URL编码将特殊字符转换为%XX格式用于URL传输
</div>
</div>
</div>
</div>
<!-- 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">示例</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-medium text-secondary mb-2">Base64 示例</h4>
<div class="bg-block p-3 rounded text-sm font-mono">
<div><strong>原文:</strong> Hello World!</div>
<div><strong>编码:</strong> SGVsbG8gV29ybGQh</div>
</div>
</div>
<div>
<h4 class="font-medium text-secondary mb-2">URL 编码示例</h4>
<div class="bg-block p-3 rounded text-sm font-mono">
<div><strong>原文:</strong> hello world!</div>
<div><strong>编码:</strong> hello%20world%21</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const statusMessage = ref<{ type: 'success' | 'error', text: string } | null>(null)
// Base64 编码
const base64Encode = (text: string): string => {
try {
return btoa(unescape(encodeURIComponent(text)))
} catch (error) {
throw new Error('Base64编码失败')
}
}
// Base64 解码
const base64Decode = (text: string): string => {
try {
return decodeURIComponent(escape(atob(text)))
} catch (error) {
throw new Error('Base64解码失败请检查输入格式')
}
}
// URL 编码
const urlEncode = (text: string): string => {
try {
return encodeURIComponent(text)
} catch (error) {
throw new Error('URL编码失败')
}
}
// URL 解码
const urlDecode = (text: string): string => {
try {
return decodeURIComponent(text)
} catch (error) {
throw new Error('URL解码失败请检查输入格式')
}
}
// 编码函数
const encode = (type: 'base64' | 'url') => {
if (!inputText.value.trim()) {
statusMessage.value = { type: 'error', text: '请输入要编码的内容' }
return
}
try {
let result = ''
switch (type) {
case 'base64':
result = base64Encode(inputText.value)
break
case 'url':
result = urlEncode(inputText.value)
break
}
outputText.value = result
statusMessage.value = { type: 'success', text: `${type.toUpperCase()}编码成功` }
} catch (error) {
outputText.value = ''
statusMessage.value = {
type: 'error',
text: error instanceof Error ? error.message : '编码失败'
}
}
}
// 解码函数
const decode = (type: 'base64' | 'url') => {
if (!inputText.value.trim()) {
statusMessage.value = { type: 'error', text: '请输入要解码的内容' }
return
}
try {
let result = ''
switch (type) {
case 'base64':
result = base64Decode(inputText.value)
break
case 'url':
result = urlDecode(inputText.value)
break
}
outputText.value = result
statusMessage.value = { type: 'success', text: `${type.toUpperCase()}解码成功` }
} catch (error) {
outputText.value = ''
statusMessage.value = {
type: 'error',
text: error instanceof Error ? error.message : '解码失败'
}
}
}
// 清除所有内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
statusMessage.value = null
}
// 从剪贴板粘贴
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制到剪贴板
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
</script>

View File

@ -0,0 +1,939 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertContent"
:disabled="!inputContent.trim()"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'exchange-alt']" class="mr-2" />
转换
</button>
<button
@click="copyResult"
:disabled="!outputContent"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制结果
</button>
<button
@click="swapDirection"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'sync-alt']" class="mr-2" />
交换方向
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-import']" class="mr-2" />
示例
</button>
<button
@click="downloadResult"
:disabled="!outputContent"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<!-- 输入格式选择 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">输入格式</h3>
<div class="grid grid-cols-2 gap-2">
<button
@click="setInputFormat('html')"
:class="[
'p-3 rounded-lg text-left transition-colors',
inputFormat === 'html'
? 'bg-primary text-white'
: 'bg-block hover:bg-block-hover text-secondary hover:text-primary'
]"
>
<div class="font-medium">HTML</div>
<div class="text-xs opacity-80">超文本标记语言</div>
</button>
<button
@click="setInputFormat('markdown')"
:class="[
'p-3 rounded-lg text-left transition-colors',
inputFormat === 'markdown'
? 'bg-primary text-white'
: 'bg-block hover:bg-block-hover text-secondary hover:text-primary'
]"
>
<div class="font-medium">Markdown</div>
<div class="text-xs opacity-80">轻量级标记语言</div>
</button>
</div>
</div>
<!-- 输入内容 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入内容</h3>
<div class="text-sm text-secondary">
{{ inputStats.lines }} | {{ inputStats.chars }} 字符
</div>
</div>
<textarea
v-model="inputContent"
:placeholder="getInputPlaceholder()"
class="textarea-field h-96 font-mono text-sm"
@input="validateInput"
/>
<!-- 验证状态 -->
<div class="mt-2 flex items-center justify-between">
<div
v-if="validationMessage"
:class="[
'text-sm flex items-center space-x-1',
isValid ? 'text-success' : 'text-error'
]"
>
<FontAwesomeIcon
:icon="isValid ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ validationMessage }}</span>
</div>
<div class="flex space-x-2">
<button
@click="formatInput"
:disabled="!inputContent.trim()"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
格式化
</button>
<button
@click="previewInput"
:disabled="!inputContent.trim()"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'eye']" class="mr-1" />
预览
</button>
</div>
</div>
</div>
<!-- 转换选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换选项</h3>
<div class="space-y-3">
<!-- HTML转Markdown选项 -->
<div v-if="inputFormat === 'html'">
<div class="text-sm font-medium text-secondary mb-2">HTML转Markdown选项</div>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.preserveLinks"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留链接</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.preserveImages"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留图片</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.preserveCodeBlocks"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留代码块</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeEmptyElements"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除空元素</span>
</label>
</div>
</div>
<!-- Markdown转HTML选项 -->
<div v-if="inputFormat === 'markdown'">
<div class="text-sm font-medium text-secondary mb-2">Markdown转HTML选项</div>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.addLineBreaks"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">自动换行</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.sanitizeHtml"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">清理HTML</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.enableCodeHighlight"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">代码高亮</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.openLinksInNewTab"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">链接新窗口打开</span>
</label>
</div>
</div>
<!-- 通用选项 -->
<div>
<div class="text-sm font-medium text-secondary mb-2">通用选项</div>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.prettifyOutput"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">美化输出</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.preserveWhitespace"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">保留空白字符</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 输出区域 -->
<div class="space-y-4">
<!-- 输出格式显示 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">
输出格式: {{ outputFormat === 'html' ? 'HTML' : 'Markdown' }}
</h3>
<div class="p-3 bg-block rounded-lg">
<div class="text-sm text-secondary">
{{ outputFormat === 'html'
? '将转换为HTML格式支持在浏览器中直接显示'
: '将转换为Markdown格式适合文档编写和版本控制'
}}
</div>
</div>
</div>
<!-- 输出内容 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出内容</h3>
<div class="text-sm text-secondary">
{{ outputStats.lines }} | {{ outputStats.chars }} 字符
</div>
</div>
<textarea
v-model="outputContent"
readonly
placeholder="转换结果将显示在这里..."
class="textarea-field h-96 font-mono text-sm bg-block"
/>
<!-- 输出操作 -->
<div class="mt-2 flex items-center justify-between">
<div class="flex space-x-2">
<button
@click="previewOutput"
:disabled="!outputContent"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'eye']" class="mr-1" />
预览
</button>
<button
@click="validateOutput"
:disabled="!outputContent"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'check-circle']" class="mr-1" />
验证
</button>
</div>
<div v-if="conversionStats" class="text-xs text-secondary">
转换时间: {{ conversionStats.time }}ms
</div>
</div>
</div>
<!-- 预览窗口 -->
<div v-if="previewContent" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">预览</h3>
<button
@click="closePreview"
class="text-secondary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
<div
class="max-h-80 overflow-auto border border-border rounded-lg p-4 bg-white text-black"
v-html="previewContent"
/>
</div>
<!-- 使用说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">使用说明</h3>
<div class="space-y-3 text-sm text-secondary">
<div>
<div class="font-medium">HTML转Markdown:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>自动识别HTML标签并转换为Markdown语法</li>
<li>保留文本格式链接图片等元素</li>
<li>移除多余的HTML属性和样式</li>
<li>适合将网页内容转换为文档格式</li>
</ul>
</div>
<div>
<div class="font-medium">Markdown转HTML:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>解析Markdown语法并生成HTML</li>
<li>支持标题列表代码块表格等</li>
<li>可添加语法高亮和样式</li>
<li>生成的HTML可直接在浏览器中显示</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputContent = ref('')
const outputContent = ref('')
const inputFormat = ref<'html' | 'markdown'>('html')
const outputFormat = computed(() => inputFormat.value === 'html' ? 'markdown' : 'html')
const copied = ref(false)
const isValid = ref(true)
const validationMessage = ref('')
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
const previewContent = ref('')
// 转换选项
const options = reactive({
// HTML转Markdown选项
preserveLinks: true,
preserveImages: true,
preserveCodeBlocks: true,
removeEmptyElements: true,
// Markdown转HTML选项
addLineBreaks: true,
sanitizeHtml: true,
enableCodeHighlight: false,
openLinksInNewTab: true,
// 通用选项
prettifyOutput: true,
preserveWhitespace: false
})
// 转换统计
const conversionStats = ref<{ time: number } | null>(null)
// 计算属性
const inputStats = computed(() => {
const lines = inputContent.value ? inputContent.value.split('\n').length : 0
const chars = inputContent.value.length
return { lines, chars }
})
const outputStats = computed(() => {
const lines = outputContent.value ? outputContent.value.split('\n').length : 0
const chars = outputContent.value.length
return { lines, chars }
})
// 获取输入占位符
const getInputPlaceholder = (): string => {
if (inputFormat.value === 'html') {
return `<!DOCTYPE html>
<html>
<head>
<title>示例页面</title>
</head>
<body>
<h1>这是标题</h1>
<p>这是一个段落,包含<strong>粗体</strong>和<em>斜体</em>文本。</p>
<ul>
<li>列表项1</li>
<li>列表项2</li>
</ul>
</body>
</html>`
} else {
return `# 这是标题
这是一个段落,包含**粗体**和*斜体*文本。
- 列表项1
- 列表项2
\`\`\`javascript
console.log('Hello, World!');
\`\`\`
[链接文本](https://example.com)`
}
}
// 设置输入格式
const setInputFormat = (format: 'html' | 'markdown') => {
inputFormat.value = format
validateInput()
outputContent.value = ''
}
// 验证输入
const validateInput = () => {
if (!inputContent.value.trim()) {
isValid.value = true
validationMessage.value = ''
return
}
try {
if (inputFormat.value === 'html') {
// 简单的HTML验证
const parser = new DOMParser()
const doc = parser.parseFromString(inputContent.value, 'text/html')
const errors = doc.getElementsByTagName('parsererror')
if (errors.length > 0) {
throw new Error('HTML格式错误')
}
} else {
// Markdown基本验证检查常见语法错误
const content = inputContent.value
// 检查代码块是否匹配
const codeBlockMatches = content.match(/```/g)
if (codeBlockMatches && codeBlockMatches.length % 2 !== 0) {
throw new Error('代码块标记不匹配')
}
}
isValid.value = true
validationMessage.value = '格式正确'
} catch (error) {
isValid.value = false
validationMessage.value = error instanceof Error ? error.message : '格式错误'
}
}
// 格式化输入
const formatInput = () => {
if (!inputContent.value.trim()) return
try {
if (inputFormat.value === 'html') {
// 简单的HTML格式化
inputContent.value = formatHtml(inputContent.value)
} else {
// Markdown格式化主要是调整空行和缩进
inputContent.value = formatMarkdown(inputContent.value)
}
showStatus('格式化完成', 'success')
} catch (error) {
showStatus('格式化失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// HTML格式化
const formatHtml = (html: string): string => {
return html
.replace(/></g, '>\n<')
.split('\n')
.map(line => line.trim())
.filter(line => line)
.join('\n')
}
// Markdown格式化
const formatMarkdown = (markdown: string): string => {
return markdown
.split('\n')
.map(line => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n') // 合并多个空行
}
// 转换内容
const convertContent = () => {
if (!inputContent.value.trim()) {
showStatus('请输入内容', 'error')
return
}
if (!isValid.value) {
showStatus('请先修复输入格式错误', 'error')
return
}
const startTime = Date.now()
try {
let result: string
if (inputFormat.value === 'html') {
result = htmlToMarkdown(inputContent.value)
} else {
result = markdownToHtml(inputContent.value)
}
outputContent.value = result
const endTime = Date.now()
conversionStats.value = { time: endTime - startTime }
showStatus('转换成功', 'success')
} catch (error) {
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
outputContent.value = ''
conversionStats.value = null
}
}
// HTML转Markdown
const htmlToMarkdown = (html: string): string => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const convertElement = (element: Element): string => {
const tagName = element.tagName.toLowerCase()
const text = element.textContent || ''
switch (tagName) {
case 'h1': return `# ${text}\n\n`
case 'h2': return `## ${text}\n\n`
case 'h3': return `### ${text}\n\n`
case 'h4': return `#### ${text}\n\n`
case 'h5': return `##### ${text}\n\n`
case 'h6': return `###### ${text}\n\n`
case 'p': return `${convertChildren(element)}\n\n`
case 'strong':
case 'b': return `**${text}**`
case 'em':
case 'i': return `*${text}*`
case 'code': return `\`${text}\``
case 'pre':
const codeElement = element.querySelector('code')
const code = codeElement ? codeElement.textContent : text
return `\`\`\`\n${code}\n\`\`\`\n\n`
case 'a':
const href = element.getAttribute('href') || '#'
return `[${text}](${href})`
case 'img':
const src = element.getAttribute('src') || ''
const alt = element.getAttribute('alt') || ''
return `![${alt}](${src})`
case 'ul':
return convertList(element, '-') + '\n'
case 'ol':
return convertList(element, '1.') + '\n'
case 'li':
return convertChildren(element)
case 'blockquote':
return `> ${convertChildren(element)}\n\n`
case 'hr':
return '---\n\n'
case 'br':
return '\n'
default:
return convertChildren(element)
}
}
const convertChildren = (element: Element): string => {
let result = ''
for (const child of element.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
result += convertElement(child as Element)
} else if (child.nodeType === Node.TEXT_NODE) {
result += child.textContent || ''
}
}
return result
}
const convertList = (listElement: Element, marker: string): string => {
let result = ''
const items = listElement.querySelectorAll('li')
items.forEach((item, index) => {
const itemMarker = marker === '1.' ? `${index + 1}.` : marker
result += `${itemMarker} ${convertChildren(item)}\n`
})
return result
}
let markdown = convertElement(doc.body || doc.documentElement)
// 清理多余的空行
if (options.removeEmptyElements) {
markdown = markdown.replace(/\n{3,}/g, '\n\n')
}
return markdown.trim()
}
// Markdown转HTML
const markdownToHtml = (markdown: string): string => {
let html = markdown
// 转换标题
html = html.replace(/^# (.*$)/gm, '<h1>$1</h1>')
html = html.replace(/^## (.*$)/gm, '<h2>$1</h2>')
html = html.replace(/^### (.*$)/gm, '<h3>$1</h3>')
html = html.replace(/^#### (.*$)/gm, '<h4>$1</h4>')
html = html.replace(/^##### (.*$)/gm, '<h5>$1</h5>')
html = html.replace(/^###### (.*$)/gm, '<h6>$1</h6>')
// 转换粗体和斜体
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
// 转换代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// 转换代码块
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
// 转换链接
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const target = options.openLinksInNewTab ? ' target="_blank"' : ''
return `<a href="${url}"${target}>${text}</a>`
})
// 转换图片
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
// 转换无序列表
html = html.replace(/^[\s]*[-*+]\s+(.*)$/gm, '<li>$1</li>')
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
// 转换有序列表
html = html.replace(/^\d+\.\s+(.*)$/gm, '<li>$1</li>')
// 转换段落
html = html.split('\n\n').map(paragraph => {
paragraph = paragraph.trim()
if (!paragraph) return ''
// 跳过已经是HTML标签的内容
if (paragraph.startsWith('<') && paragraph.endsWith('>')) {
return paragraph
}
return `<p>${paragraph}</p>`
}).join('\n')
// 转换换行
if (options.addLineBreaks) {
html = html.replace(/\n/g, '<br>')
}
return html
}
// 预览输入
const previewInput = () => {
if (!inputContent.value.trim()) return
try {
if (inputFormat.value === 'html') {
previewContent.value = inputContent.value
} else {
previewContent.value = markdownToHtml(inputContent.value)
}
} catch (error) {
showStatus('预览失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// 预览输出
const previewOutput = () => {
if (!outputContent.value.trim()) return
try {
if (outputFormat.value === 'html') {
previewContent.value = outputContent.value
} else {
previewContent.value = markdownToHtml(outputContent.value)
}
} catch (error) {
showStatus('预览失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// 关闭预览
const closePreview = () => {
previewContent.value = ''
}
// 验证输出
const validateOutput = () => {
if (!outputContent.value) return
try {
if (outputFormat.value === 'html') {
const parser = new DOMParser()
const doc = parser.parseFromString(outputContent.value, 'text/html')
const errors = doc.getElementsByTagName('parsererror')
if (errors.length > 0) {
throw new Error('HTML格式错误')
}
}
showStatus('输出格式正确', 'success')
} catch (error) {
showStatus('输出格式错误: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
}
}
// 交换转换方向
const swapDirection = () => {
inputFormat.value = inputFormat.value === 'html' ? 'markdown' : 'html'
if (outputContent.value) {
const temp = inputContent.value
inputContent.value = outputContent.value
outputContent.value = temp
validateInput()
}
}
// 复制结果
const copyResult = async () => {
if (!outputContent.value) return
try {
await navigator.clipboard.writeText(outputContent.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
showStatus('复制成功', 'success')
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 下载结果
const downloadResult = () => {
if (!outputContent.value) return
const extension = outputFormat.value === 'html' ? '.html' : '.md'
const filename = `converted_${Date.now()}${extension}`
const blob = new Blob([outputContent.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
showStatus('文件下载完成', 'success')
}
// 加载示例
const loadSample = () => {
if (inputFormat.value === 'html') {
inputContent.value = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>示例HTML文档</title>
</head>
<body>
<h1>欢迎使用HTML/Markdown转换器</h1>
<p>这是一个<strong>示例HTML文档</strong>包含了常见的HTML元素。</p>
<h2>功能特性</h2>
<ul>
<li>支持HTML转Markdown</li>
<li>支持Markdown转HTML</li>
<li>保留<em>格式和样式</em></li>
<li>提供<code>实时预览</code>功能</li>
</ul>
<h3>代码示例</h3>
<pre><code>function hello() {
console.log("Hello, World!");
}</code></pre>
<p>访问我们的<a href="https://example.com">官方网站</a>了解更多信息。</p>
<blockquote>
<p>这是一个引用块的示例。</p>
</blockquote>
</body>
</html>`
} else {
inputContent.value = `# 欢迎使用HTML/Markdown转换器
这是一个**示例Markdown文档**包含了常见的Markdown语法。
## 功能特性
- 支持HTML转Markdown
- 支持Markdown转HTML
- 保留*格式和样式*
- 提供\`实时预览\`功能
### 代码示例
\`\`\`javascript
function hello() {
console.log("Hello, World!");
}
\`\`\`
访问我们的[官方网站](https://example.com)了解更多信息。
> 这是一个引用块的示例。
![示例图片](https://via.placeholder.com/300x200)`
}
validateInput()
}
// 清除所有
const clearAll = () => {
inputContent.value = ''
outputContent.value = ''
previewContent.value = ''
validationMessage.value = ''
conversionStats.value = null
statusMessage.value = ''
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 监听选项变化,自动重新转换
watch(() => options, () => {
if (inputContent.value.trim() && outputContent.value) {
convertContent()
}
}, { deep: true })
// 监听输入变化
watch(() => inputContent.value, () => {
validateInput()
})
</script>

View File

@ -0,0 +1,432 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2">
<button
@click="sendRequest"
:disabled="!requestUrl.trim() || isLoading"
class="btn-primary flex items-center space-x-2"
>
<FontAwesomeIcon
:icon="isLoading ? ['fas', 'spinner'] : ['fas', 'paper-plane']"
:class="isLoading && 'animate-spin'"
/>
<span>发送请求</span>
</button>
<button
@click="clearAll"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
<span>清空</span>
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- 左侧请求配置 -->
<div class="space-y-6">
<!-- 请求URL和方法 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-4">请求配置</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="requestMethod" class="select-input w-32">
<option v-for="method in httpMethods" :key="method" :value="method">
{{ method }}
</option>
</select>
<input
v-model="requestUrl"
type="url"
placeholder="https://api.example.com/users"
class="input-field flex-1"
@keyup.enter="sendRequest"
>
</div>
<!-- 快速URL -->
<div class="flex flex-wrap gap-2">
<button
v-for="quickUrl in quickUrls"
:key="quickUrl.name"
@click="setQuickUrl(quickUrl.url)"
class="px-3 py-1 text-xs rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
{{ quickUrl.name }}
</button>
</div>
</div>
</div>
<!-- 请求头配置 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-secondary">请求头</h4>
<button @click="addHeader" class="btn-small">
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-1" />
添加头部
</button>
</div>
<div class="space-y-2">
<div
v-for="(header, index) in requestHeaders"
:key="index"
class="flex space-x-2"
>
<input
v-model="header.key"
type="text"
placeholder="Header Name"
class="input-field flex-1"
>
<input
v-model="header.value"
type="text"
placeholder="Header Value"
class="input-field flex-1"
>
<button
@click="removeHeader(index)"
class="p-2 text-error hover:bg-error/10 rounded transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
</div>
</div>
<!-- 请求体 -->
<div v-if="['POST', 'PUT', 'PATCH'].includes(requestMethod)" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-secondary">请求体</h4>
<select v-model="requestBodyType" class="select-input w-32">
<option value="json">JSON</option>
<option value="text">Text</option>
<option value="form">Form</option>
</select>
</div>
<textarea
v-model="requestBody"
:placeholder="getBodyPlaceholder()"
class="textarea-field h-40 font-mono text-sm"
/>
<div v-if="requestBodyType === 'json'" class="flex justify-between items-center mt-2">
<button @click="formatJsonBody" class="btn-small">
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
格式化
</button>
<div class="text-xs text-secondary">
{{ requestBody.length }} 字符
</div>
</div>
</div>
</div>
<!-- 右侧响应结果 -->
<div class="card p-4 min-h-[600px]">
<h3 class="text-lg font-semibold text-primary mb-4">响应结果</h3>
<div v-if="isLoading" class="flex flex-col items-center justify-center h-96">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<p class="text-secondary">正在发送请求...</p>
</div>
<div v-else-if="lastResponse" class="space-y-4">
<!-- 响应状态栏 -->
<div class="flex items-center justify-between p-3 rounded-lg bg-secondary/10">
<div class="flex items-center space-x-3">
<span :class="[
'px-3 py-1 text-sm font-medium rounded',
getStatusColor(lastResponse.status)
]">
{{ lastResponse.status }} {{ lastResponse.statusText }}
</span>
<span class="text-sm text-secondary">
{{ formatResponseSize(lastResponse.size) }} | {{ lastResponse.time }}ms
</span>
</div>
<button @click="copyResponseContent" class="btn-small">
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<!-- 响应内容 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-secondary">响应内容</span>
<button
v-if="isJsonResponse"
@click="toggleJsonFormat"
class="btn-small"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
{{ jsonFormatted ? '原始' : '格式化' }}
</button>
</div>
<div class="relative">
<pre class="bg-secondary/10 p-4 rounded-lg text-sm font-mono overflow-auto max-h-96 whitespace-pre-wrap">{{ formattedResponseData }}</pre>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-96 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'paper-plane']" class="text-6xl mb-4 opacity-50" />
<p class="text-lg">暂无响应</p>
<p class="text-sm">点击发送请求按钮开始测试</p>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="card p-4 bg-error/10 border-error/20">
<div class="flex items-center space-x-2 text-error">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" />
<span>{{ errorMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 响应式状态
const requestUrl = ref('https://jsonplaceholder.typicode.com/posts/1')
const requestMethod = ref('GET')
const requestHeaders = ref([
{ key: 'Content-Type', value: 'application/json' }
])
const requestBody = ref('')
const requestBodyType = ref('json')
const isLoading = ref(false)
const lastResponse = ref(null)
const errorMessage = ref('')
const copied = ref(false)
const jsonFormatted = ref(true)
// HTTP方法列表
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
// 快速URL列表
const quickUrls = [
{ name: 'JSONPlaceholder', url: 'https://jsonplaceholder.typicode.com/posts/1' },
{ name: 'GitHub API', url: 'https://api.github.com/users/octocat' },
{ name: 'HTTPBin', url: 'https://httpbin.org/get' }
]
// 计算属性
const isJsonResponse = computed(() => {
if (!lastResponse.value) return false
const contentType = lastResponse.value.headers['content-type'] || ''
return contentType.includes('application/json') ||
(typeof lastResponse.value.data === 'object' && lastResponse.value.data !== null)
})
const formattedResponseData = computed(() => {
if (!lastResponse.value) return ''
if (isJsonResponse.value && jsonFormatted.value) {
try {
return JSON.stringify(lastResponse.value.data, null, 2)
} catch {
return String(lastResponse.value.data)
}
}
return typeof lastResponse.value.data === 'string'
? lastResponse.value.data
: JSON.stringify(lastResponse.value.data)
})
// 请求头管理
const addHeader = () => {
requestHeaders.value.push({ key: '', value: '' })
}
const removeHeader = (index: number) => {
requestHeaders.value.splice(index, 1)
}
// 工具函数
const getBodyPlaceholder = () => {
switch (requestBodyType.value) {
case 'json':
return '{\n "key": "value"\n}'
default:
return '请输入请求体内容...'
}
}
const formatJsonBody = () => {
try {
const parsed = JSON.parse(requestBody.value)
requestBody.value = JSON.stringify(parsed, null, 2)
} catch (error) {
console.error('JSON格式化失败:', error)
}
}
const setQuickUrl = (url: string) => {
requestUrl.value = url
}
const getStatusColor = (status: number) => {
if (status >= 200 && status < 300) return 'bg-success/20 text-success'
if (status >= 300 && status < 400) return 'bg-warning/20 text-warning'
if (status >= 400 && status < 500) return 'bg-error/20 text-error'
if (status >= 500) return 'bg-error/30 text-error'
return 'bg-secondary/20 text-secondary'
}
const formatResponseSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
// 发送请求
const sendRequest = async () => {
if (!requestUrl.value.trim()) return
isLoading.value = true
errorMessage.value = ''
lastResponse.value = null
try {
const startTime = performance.now()
// 准备请求头
const headers: Record<string, string> = {}
requestHeaders.value.forEach(h => {
if (h.key.trim() && h.value.trim()) {
headers[h.key] = h.value
}
})
// 准备请求体
let body: string | undefined
if (['POST', 'PUT', 'PATCH'].includes(requestMethod.value)) {
body = requestBody.value
}
// 发送请求
const response = await fetch(requestUrl.value, {
method: requestMethod.value,
headers,
body
})
const endTime = performance.now()
const responseTime = Math.round(endTime - startTime)
// 获取响应头
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
})
// 解析响应体
let responseData: any
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
responseData = await response.json()
} else {
responseData = await response.text()
}
// 计算响应大小
const responseText = typeof responseData === 'string' ? responseData : JSON.stringify(responseData)
const responseSize = new Blob([responseText]).size
lastResponse.value = {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: responseData,
time: responseTime,
size: responseSize
}
} catch (error) {
errorMessage.value = `请求失败: ${(error as Error).message}`
} finally {
isLoading.value = false
}
}
// 其他功能
const clearAll = () => {
requestUrl.value = 'https://jsonplaceholder.typicode.com/posts/1'
requestMethod.value = 'GET'
requestHeaders.value = [{ key: 'Content-Type', value: 'application/json' }]
requestBody.value = ''
lastResponse.value = null
errorMessage.value = ''
}
const copyResponseContent = async () => {
if (!lastResponse.value) return
try {
const textToCopy = formattedResponseData.value
await navigator.clipboard.writeText(textToCopy)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const toggleJsonFormat = () => {
jsonFormatted.value = !jsonFormatted.value
}
</script>
<style scoped>
.select-input {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid rgba(var(--color-primary), 0.2);
background-color: rgb(var(--color-bg-card));
color: rgb(var(--color-text-primary));
outline: none;
transition: all 0.2s ease;
}
.select-input:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 2px rgba(var(--color-primary), 0.2);
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.375rem;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-primary-light));
border: 1px solid rgb(var(--color-primary));
transition: all 0.2s ease;
cursor: pointer;
}
.btn-small:hover {
background-color: rgba(var(--color-primary), 0.1);
border-color: rgb(var(--color-primary-hover));
}
</style>

View File

@ -0,0 +1,502 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="compressImage"
:disabled="!originalImage || isCompressing"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isCompressing ? ['fas', 'spinner'] : ['fas', 'compress']"
:class="['mr-2', isCompressing && 'animate-spin']"
/>
{{ t('tools.image_compressor.compress') }}
</button>
<button
@click="downloadCompressedImage"
:disabled="!compressedImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
{{ t('tools.image_compressor.download') }}
</button>
<button
@click="resetAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.image_compressor.reset') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 上传和设置区域 -->
<div class="space-y-4">
<!-- 图片上传 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.upload_image') }}</h3>
<div
@click="triggerFileUpload"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleFileDrop"
:class="[
'border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors text-center',
isDragging ? 'border-primary bg-primary bg-opacity-5' : 'border-tertiary hover:border-primary-light'
]"
>
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<div class="text-secondary">
<p>{{ t('tools.image_compressor.click_or_drag') }}</p>
<p class="text-sm text-tertiary mt-1">支持 JPG, PNG, WebP 格式最大 10MB</p>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
@change="handleFileSelect"
>
</div>
<!-- 压缩设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.compression_settings') }}</h3>
<div class="space-y-4">
<!-- 压缩质量 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.quality') }}: {{ quality }}%
</label>
<input
v-model="quality"
type="range"
min="10"
max="100"
step="5"
class="w-full"
@input="handleQualityChange"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>10% (最小)</span>
<span>100% (最佳)</span>
</div>
</div>
<!-- 最大宽度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.max_width') }} (px)
</label>
<input
v-model="maxWidth"
type="number"
min="100"
max="5000"
class="input-field"
:placeholder="originalImageInfo?.width?.toString() || '原始宽度'"
>
</div>
<!-- 最大高度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.max_height') }} (px)
</label>
<input
v-model="maxHeight"
type="number"
min="100"
max="5000"
class="input-field"
:placeholder="originalImageInfo?.height?.toString() || '原始高度'"
>
</div>
<!-- 保持宽高比 -->
<div class="flex items-center space-x-2">
<input
v-model="keepAspectRatio"
type="checkbox"
id="keepAspectRatio"
class="rounded"
>
<label for="keepAspectRatio" class="text-sm text-secondary">
{{ t('tools.image_compressor.keep_aspect_ratio') }}
</label>
</div>
<!-- 输出格式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.image_compressor.output_format') }}
</label>
<select v-model="outputFormat" class="select-field">
<option value="auto">自动 (保持原格式)</option>
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WebP</option>
</select>
</div>
</div>
</div>
<!-- 原始图片信息 -->
<div v-if="originalImageInfo" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.original_info') }}</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.size') }}:</span>
<span class="text-primary font-medium">{{ originalImageInfo.size }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.dimensions') }}:</span>
<span class="text-primary font-medium">{{ originalImageInfo.width }} × {{ originalImageInfo.height }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.format') }}:</span>
<span class="text-primary font-medium">{{ originalImageInfo.format }}</span>
</div>
</div>
</div>
</div>
<!-- 预览对比区域 -->
<div class="space-y-4">
<!-- 原始图片预览 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.original_preview') }}</h3>
<div class="bg-block rounded-lg p-4">
<img
:src="originalImage"
alt="原始图片"
class="max-w-full max-h-64 mx-auto rounded border border-primary border-opacity-20"
>
</div>
</div>
<!-- 压缩后预览 -->
<div v-if="compressedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.image_compressor.compressed_preview') }}</h3>
<div class="bg-block rounded-lg p-4">
<img
:src="compressedImage"
alt="压缩后图片"
class="max-w-full max-h-64 mx-auto rounded border border-primary border-opacity-20"
>
</div>
<!-- 压缩结果信息 -->
<div v-if="compressedImageInfo" class="mt-4 space-y-2">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.compressed_size') }}:</span>
<span class="text-primary font-medium">{{ compressedImageInfo.size }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.compression_ratio') }}:</span>
<span class="text-primary font-medium">{{ compressionRatio }}%</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.image_compressor.size_reduction') }}:</span>
<span class="text-success font-medium">{{ sizeReduction }}</span>
</div>
</div>
</div>
<!-- 压缩中状态 -->
<div v-if="isCompressing" class="card p-4">
<div class="text-center py-8">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.image_compressor.compressing') }}</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import Compressor from 'compressorjs'
const { t } = useLanguage()
// 响应式状态
const originalImage = ref('')
const compressedImage = ref('')
const isDragging = ref(false)
const isCompressing = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 压缩设置
const quality = ref(80)
const maxWidth = ref<number | null>(null)
const maxHeight = ref<number | null>(null)
const keepAspectRatio = ref(true)
const outputFormat = ref('auto')
// 文件信息
const originalImageInfo = ref<{
size: string
width: number
height: number
format: string
} | null>(null)
const compressedImageInfo = ref<{
size: string
sizeBytes: number
} | null>(null)
// 文件引用
const fileInput = ref<HTMLInputElement>()
const originalFile = ref<File | null>(null)
// 计算压缩比率
const compressionRatio = computed(() => {
if (!originalImageInfo.value || !compressedImageInfo.value) return '0'
const originalBytes = originalFile.value?.size || 0
const compressedBytes = compressedImageInfo.value.sizeBytes
const ratio = ((compressedBytes / originalBytes) * 100).toFixed(1)
return ratio
})
// 计算减少的大小
const sizeReduction = computed(() => {
if (!originalImageInfo.value || !compressedImageInfo.value) return '0'
const originalBytes = originalFile.value?.size || 0
const compressedBytes = compressedImageInfo.value.sizeBytes
const reduction = originalBytes - compressedBytes
return formatFileSize(reduction)
})
// 处理文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
loadImageFile(file)
}
}
// 处理文件拖拽
const handleFileDrop = (event: DragEvent) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type.startsWith('image/')) {
loadImageFile(file)
} else {
showStatus('请选择图片文件', 'error')
}
}
}
const handleDragOver = () => {
isDragging.value = true
}
const handleDragLeave = () => {
isDragging.value = false
}
// 加载图片文件
const loadImageFile = (file: File) => {
if (!file.type.startsWith('image/')) {
showStatus('请选择图片文件', 'error')
return
}
if (file.size > 10 * 1024 * 1024) { // 10MB
showStatus('文件大小不能超过 10MB', 'error')
return
}
originalFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
const result = e.target?.result as string
originalImage.value = result
// 获取图片信息
const img = new Image()
img.onload = () => {
originalImageInfo.value = {
size: formatFileSize(file.size),
width: img.width,
height: img.height,
format: file.type.split('/')[1].toUpperCase()
}
// 设置默认的最大尺寸
if (!maxWidth.value) maxWidth.value = img.width
if (!maxHeight.value) maxHeight.value = img.height
}
img.src = result
// 清除之前的压缩结果
compressedImage.value = ''
compressedImageInfo.value = null
}
reader.onerror = () => {
showStatus('文件读取失败', 'error')
}
reader.readAsDataURL(file)
}
// 触发文件上传
const triggerFileUpload = () => {
fileInput.value?.click()
}
// 压缩图片
const compressImage = async () => {
if (!originalFile.value) {
showStatus('请先选择图片', 'error')
return
}
isCompressing.value = true
statusMessage.value = ''
try {
await nextTick()
const options: Compressor.Options = {
quality: quality.value / 100,
maxWidth: maxWidth.value || undefined,
maxHeight: maxHeight.value || undefined,
convertTypes: outputFormat.value === 'auto' ? undefined : [outputFormat.value],
convertSize: outputFormat.value === 'auto' ? 5000000 : undefined, // 5MB 以上才转换格式
success: (compressedFile: File) => {
// 创建预览URL
const reader = new FileReader()
reader.onload = (e) => {
compressedImage.value = e.target?.result as string
compressedImageInfo.value = {
size: formatFileSize(compressedFile.size),
sizeBytes: compressedFile.size
}
showStatus('图片压缩成功', 'success')
isCompressing.value = false
}
reader.readAsDataURL(compressedFile)
// 保存压缩后的文件用于下载
compressedFile.name = `compressed_${originalFile.value?.name || 'image'}`
;(window as any).compressedFileForDownload = compressedFile
},
error: (err: Error) => {
console.error('压缩失败:', err)
showStatus('压缩失败: ' + err.message, 'error')
isCompressing.value = false
}
}
new Compressor(originalFile.value, options)
} catch (error) {
console.error('压缩过程出错:', error)
showStatus('压缩过程出错: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
isCompressing.value = false
}
}
// 下载压缩后的图片
const downloadCompressedImage = () => {
const compressedFile = (window as any).compressedFileForDownload as File
if (!compressedFile) {
showStatus('没有可下载的压缩图片', 'error')
return
}
try {
const url = URL.createObjectURL(compressedFile)
const link = document.createElement('a')
link.download = compressedFile.name
link.href = url
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showStatus('图片下载成功', 'success')
} catch (error) {
console.error('下载失败:', error)
showStatus('下载失败', 'error')
}
}
// 重置所有数据
const resetAll = () => {
originalImage.value = ''
compressedImage.value = ''
originalImageInfo.value = null
compressedImageInfo.value = null
originalFile.value = null
maxWidth.value = null
maxHeight.value = null
quality.value = 80
outputFormat.value = 'auto'
statusMessage.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
// 清除下载缓存
delete (window as any).compressedFileForDownload
}
// 处理质量变化
const handleQualityChange = () => {
// 可以实时预览质量变化
// 这里可以添加防抖逻辑来避免频繁压缩
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>

View File

@ -0,0 +1,838 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertToIco"
:disabled="!selectedImage"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'magic']" class="mr-2" />
转换为ICO
</button>
<button
@click="downloadIco"
:disabled="!icoData"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载ICO
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'image']" class="mr-2" />
示例图片
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 图片上传区域 -->
<div class="space-y-4">
<!-- 上传区域 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">选择图片</h3>
<!-- 拖拽上传区域 -->
<div
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@click="selectFile"
:class="[
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
isDragOver
? 'border-primary bg-primary bg-opacity-10'
: 'border-border hover:border-primary hover:bg-block-hover'
]"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
>
<div class="space-y-3">
<FontAwesomeIcon
:icon="['fas', 'cloud-upload-alt']"
class="text-4xl text-secondary"
/>
<div>
<div class="text-primary font-medium">
点击选择或拖拽图片到此处
</div>
<div class="text-sm text-secondary mt-1">
支持 JPGPNGGIFBMPWebP 格式
</div>
</div>
</div>
</div>
<!-- 支持的格式说明 -->
<div class="mt-4 text-sm text-secondary">
<div class="font-medium mb-2">支持的输入格式:</div>
<div class="grid grid-cols-2 gap-2">
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>JPEG (.jpg, .jpeg)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>PNG (.png)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>GIF (.gif)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>BMP (.bmp)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>WebP (.webp)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>SVG (.svg)</span>
</div>
</div>
</div>
</div>
<!-- 图片信息 -->
<div v-if="selectedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片信息</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">文件名:</span>
<span class="text-primary font-medium">{{ imageInfo.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">格式:</span>
<span class="text-primary font-medium">{{ imageInfo.type }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">大小:</span>
<span class="text-primary font-medium">{{ formatFileSize(imageInfo.size) }}</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">宽度:</span>
<span class="text-primary font-medium">{{ imageInfo.width }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">高度:</span>
<span class="text-primary font-medium">{{ imageInfo.height }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">宽高比:</span>
<span class="text-primary font-medium">{{ imageInfo.aspectRatio }}</span>
</div>
</div>
</div>
</div>
<!-- 转换设置 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换设置</h3>
<div class="space-y-4">
<!-- ICO尺寸设置 -->
<div>
<label class="block text-sm text-secondary mb-2">ICO尺寸 (像素)</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="size in icoSizes"
:key="size"
@click="selectIcoSize(size)"
:class="[
'p-2 text-sm rounded border transition-colors',
selectedSizes.includes(size)
? 'border-primary bg-primary text-white'
: 'border-border hover:border-primary text-secondary'
]"
>
{{ size }}×{{ size }}
</button>
</div>
<div class="text-xs text-tertiary mt-1">
可选择多个尺寸生成多尺寸ICO文件
</div>
</div>
<!-- 自定义尺寸 -->
<div>
<label class="block text-sm text-secondary mb-2">自定义尺寸</label>
<div class="flex items-center space-x-2">
<input
v-model.number="customSize"
type="number"
min="16"
max="256"
class="input-field w-20 text-sm"
placeholder="32"
>
<span class="text-secondary text-sm">像素</span>
<button
@click="addCustomSize"
:disabled="!isValidCustomSize"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'plus']" />
</button>
</div>
</div>
<!-- 图片质量设置 -->
<div>
<label class="block text-sm text-secondary mb-2">
图片质量: {{ quality }}%
</label>
<input
v-model.number="quality"
type="range"
min="10"
max="100"
step="5"
class="w-full"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>较小文件</span>
<span>较高质量</span>
</div>
</div>
<!-- 背景颜色设置 -->
<div>
<label class="block text-sm text-secondary mb-2">背景颜色 (透明图片)</label>
<div class="flex items-center space-x-2">
<input
v-model="backgroundColor"
type="color"
class="w-12 h-8 border border-border rounded cursor-pointer"
>
<span class="text-sm text-secondary">{{ backgroundColor }}</span>
<label class="flex items-center space-x-2">
<input
v-model="preserveTransparency"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">保持透明度</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 预览和结果区域 -->
<div class="space-y-4">
<!-- 原图预览 -->
<div v-if="selectedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">原图预览</h3>
<div class="flex justify-center">
<div class="max-w-full max-h-64 overflow-hidden border border-border rounded-lg">
<img
:src="imagePreview"
:alt="imageInfo.name"
class="max-w-full max-h-64 object-contain"
>
</div>
</div>
</div>
<!-- ICO预览 -->
<div v-if="icoPreview" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">ICO预览</h3>
<div class="space-y-4">
<!-- 多尺寸预览 -->
<div class="grid grid-cols-4 gap-4">
<div
v-for="size in selectedSizes"
:key="size"
class="text-center"
>
<div class="border border-border rounded p-2 bg-checkerboard">
<img
:src="icoPreview"
:alt="`ICO ${size}x${size}`"
:style="{ width: size + 'px', height: size + 'px' }"
class="mx-auto object-contain"
>
</div>
<div class="text-xs text-secondary mt-1">{{ size }}×{{ size }}</div>
</div>
</div>
<!-- ICO文件信息 -->
<div v-if="icoInfo" class="bg-block rounded-lg p-3 text-sm">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-secondary">文件大小:</span>
<span class="text-primary font-medium">{{ formatFileSize(icoInfo.size) }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">包含尺寸:</span>
<span class="text-primary font-medium">{{ icoInfo.iconCount }}</span>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-secondary">格式:</span>
<span class="text-primary font-medium">ICO</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">颜色深度:</span>
<span class="text-primary font-medium">32</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 转换历史 -->
<div v-if="conversionHistory.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换历史</h3>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="(record, index) in conversionHistory"
:key="index"
class="flex items-center justify-between p-2 bg-block rounded text-sm"
>
<div class="flex-1">
<div class="font-medium text-primary">{{ record.filename }}</div>
<div class="text-xs text-tertiary">
{{ record.sizes.join(', ') }} | {{ record.time }}
</div>
</div>
<button
@click="downloadHistoryFile(record)"
class="text-secondary hover:text-primary transition-colors"
title="重新下载"
>
<FontAwesomeIcon :icon="['fas', 'download']" />
</button>
</div>
</div>
</div>
<!-- 使用说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">使用说明</h3>
<div class="space-y-3 text-sm text-secondary">
<div>
<div class="font-medium">ICO格式特点:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>Windows图标标准格式</li>
<li>支持多尺寸存储在一个文件中</li>
<li>常用尺寸: 16×16, 32×32, 48×48, 256×256</li>
<li>支持透明背景</li>
</ul>
</div>
<div>
<div class="font-medium">转换建议:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>使用正方形图片效果最佳</li>
<li>PNG格式可保持透明度</li>
<li>选择多个尺寸以适应不同显示场景</li>
<li>16×16和32×32是Windows系统最常用尺寸</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const selectedImage = ref<File | null>(null)
const imagePreview = ref('')
const icoData = ref<Blob | null>(null)
const icoPreview = ref('')
const isDragOver = ref(false)
const quality = ref(90)
const backgroundColor = ref('#ffffff')
const preserveTransparency = ref(true)
const customSize = ref<number | null>(null)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// DOM引用
const fileInput = ref<HTMLInputElement>()
// 图片信息
const imageInfo = reactive({
name: '',
type: '',
size: 0,
width: 0,
height: 0,
aspectRatio: ''
})
// ICO信息
const icoInfo = reactive({
size: 0,
iconCount: 0
})
// ICO尺寸选项
const icoSizes = [16, 24, 32, 48, 64, 96, 128, 256]
const selectedSizes = ref<number[]>([16, 32, 48])
// 转换历史
const conversionHistory = ref<Array<{
filename: string
sizes: string[]
time: string
data: Blob
}>>([])
// 计算属性
const isValidCustomSize = computed(() => {
return customSize.value &&
customSize.value >= 16 &&
customSize.value <= 256 &&
!selectedSizes.value.includes(customSize.value)
})
// 文件选择
const selectFile = () => {
fileInput.value?.click()
}
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
handleImageFile(file)
}
}
// 拖拽处理
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
handleImageFile(files[0])
}
}
// 处理图片文件
const handleImageFile = async (file: File) => {
// 验证文件类型
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
selectedImage.value = file
// 更新图片信息
imageInfo.name = file.name
imageInfo.type = file.type
imageInfo.size = file.size
// 创建预览
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
// 获取图片尺寸
const img = new Image()
img.onload = () => {
imageInfo.width = img.width
imageInfo.height = img.height
imageInfo.aspectRatio = `${(img.width / img.height).toFixed(2)}:1`
// 如果图片不是正方形,给出提示
if (img.width !== img.height) {
showStatus('建议使用正方形图片以获得最佳ICO效果', 'error')
}
}
img.src = URL.createObjectURL(file)
// 清除之前的ICO数据
icoData.value = null
icoPreview.value = ''
}
// ICO尺寸选择
const selectIcoSize = (size: number) => {
const index = selectedSizes.value.indexOf(size)
if (index >= 0) {
selectedSizes.value.splice(index, 1)
} else {
selectedSizes.value.push(size)
}
selectedSizes.value.sort((a, b) => a - b)
}
// 添加自定义尺寸
const addCustomSize = () => {
if (customSize.value && isValidCustomSize.value) {
selectedSizes.value.push(customSize.value)
selectedSizes.value.sort((a, b) => a - b)
customSize.value = null
}
}
// 转换为ICO
const convertToIco = async () => {
if (!selectedImage.value) {
showStatus('请先选择图片', 'error')
return
}
if (selectedSizes.value.length === 0) {
showStatus('请至少选择一个ICO尺寸', 'error')
return
}
try {
showStatus('正在转换中...', 'success')
// 创建canvas来处理图片
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建canvas上下文')
}
// 加载原图
const img = new Image()
img.src = imagePreview.value
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
// 生成多尺寸图标数据
const iconData: Array<{ size: number; data: Uint8Array }> = []
for (const size of selectedSizes.value) {
canvas.width = size
canvas.height = size
// 设置背景颜色(如果不保持透明度)
if (!preserveTransparency.value) {
ctx.fillStyle = backgroundColor.value
ctx.fillRect(0, 0, size, size)
}
// 绘制图片
ctx.drawImage(img, 0, 0, size, size)
// 获取图片数据
const imageData = ctx.getImageData(0, 0, size, size)
iconData.push({
size,
data: new Uint8Array(imageData.data)
})
}
// 生成ICO文件数据简化实现
const icoBlob = await createIcoBlob(iconData)
icoData.value = icoBlob
// 创建预览
icoPreview.value = URL.createObjectURL(icoBlob)
// 更新ICO信息
icoInfo.size = icoBlob.size
icoInfo.iconCount = selectedSizes.value.length
// 添加到历史记录
conversionHistory.value.unshift({
filename: selectedImage.value.name.replace(/\.[^/.]+$/, '.ico'),
sizes: selectedSizes.value.map(s => `${s}×${s}`),
time: new Date().toLocaleTimeString(),
data: icoBlob
})
// 保持历史记录不超过10条
if (conversionHistory.value.length > 10) {
conversionHistory.value = conversionHistory.value.slice(0, 10)
}
showStatus('转换成功!', 'success')
} catch (error) {
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
icoData.value = null
icoPreview.value = ''
}
}
// 创建ICO文件数据 (简化实现)
const createIcoBlob = async (iconData: Array<{ size: number; data: Uint8Array }>): Promise<Blob> => {
// 这是一个简化的ICO文件格式实现
// 实际应用中建议使用专门的ICO库
const iconCount = iconData.length
const headerSize = 6 + iconCount * 16 // ICO文件头 + 图标目录项
// 计算每个图标的PNG数据
const pngData: Uint8Array[] = []
for (const icon of iconData) {
// 将RGBA数据转换为PNG (这里简化处理实际需要PNG编码)
const canvas = document.createElement('canvas')
canvas.width = icon.size
canvas.height = icon.size
const ctx = canvas.getContext('2d')!
const imageData = new ImageData(
new Uint8ClampedArray(icon.data),
icon.size,
icon.size
)
ctx.putImageData(imageData, 0, 0)
// 获取PNG数据
const dataUrl = canvas.toDataURL('image/png', quality.value / 100)
const base64 = dataUrl.split(',')[1]
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
pngData.push(bytes)
}
// 计算总文件大小
let totalSize = headerSize
for (const png of pngData) {
totalSize += png.length
}
// 构建ICO文件
const icoFile = new Uint8Array(totalSize)
let offset = 0
// ICO文件头
icoFile[0] = 0 // 保留字段
icoFile[1] = 0
icoFile[2] = 1 // 类型: ICO
icoFile[3] = 0
icoFile[4] = iconCount & 0xFF // 图标数量
icoFile[5] = (iconCount >> 8) & 0xFF
offset = 6
// 图标目录项
let dataOffset = headerSize
for (let i = 0; i < iconCount; i++) {
const size = iconData[i].size
const pngSize = pngData[i].length
icoFile[offset] = size === 256 ? 0 : size // 宽度
icoFile[offset + 1] = size === 256 ? 0 : size // 高度
icoFile[offset + 2] = 0 // 颜色数
icoFile[offset + 3] = 0 // 保留
icoFile[offset + 4] = 1 // 颜色平面数
icoFile[offset + 5] = 0
icoFile[offset + 6] = 32 // 位深度
icoFile[offset + 7] = 0
// PNG数据大小
icoFile[offset + 8] = pngSize & 0xFF
icoFile[offset + 9] = (pngSize >> 8) & 0xFF
icoFile[offset + 10] = (pngSize >> 16) & 0xFF
icoFile[offset + 11] = (pngSize >> 24) & 0xFF
// PNG数据偏移
icoFile[offset + 12] = dataOffset & 0xFF
icoFile[offset + 13] = (dataOffset >> 8) & 0xFF
icoFile[offset + 14] = (dataOffset >> 16) & 0xFF
icoFile[offset + 15] = (dataOffset >> 24) & 0xFF
offset += 16
dataOffset += pngSize
}
// 写入PNG数据
for (const png of pngData) {
icoFile.set(png, offset)
offset += png.length
}
return new Blob([icoFile], { type: 'image/x-icon' })
}
// 下载ICO
const downloadIco = () => {
if (!icoData.value || !selectedImage.value) return
const filename = selectedImage.value.name.replace(/\.[^/.]+$/, '.ico')
const url = URL.createObjectURL(icoData.value)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
showStatus('ICO文件下载完成', 'success')
}
// 从历史记录下载
const downloadHistoryFile = (record: any) => {
const url = URL.createObjectURL(record.data)
const link = document.createElement('a')
link.href = url
link.download = record.filename
link.click()
URL.revokeObjectURL(url)
}
// 加载示例图片
const loadSample = () => {
// 创建一个简单的示例图片
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const ctx = canvas.getContext('2d')!
// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, 128, 128)
gradient.addColorStop(0, '#3b82f6')
gradient.addColorStop(1, '#1d4ed8')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 128, 128)
// 绘制图标形状
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 60px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('ICO', 64, 64)
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], 'sample.png', { type: 'image/png' })
handleImageFile(file)
}
})
}
// 清除所有
const clearAll = () => {
selectedImage.value = null
imagePreview.value = ''
icoData.value = null
icoPreview.value = ''
selectedSizes.value = [16, 32, 48]
customSize.value = null
statusMessage.value = ''
// 重置图片信息
Object.assign(imageInfo, {
name: '',
type: '',
size: 0,
width: 0,
height: 0,
aspectRatio: ''
})
// 重置ICO信息
Object.assign(icoInfo, {
size: 0,
iconCount: 0
})
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>
<style scoped>
.bg-checkerboard {
background-image:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
}
</style>

View File

@ -0,0 +1,773 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="triggerFileInput"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'upload']" class="mr-2" />
上传图片
</button>
<button
@click="addTextWatermark"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'font']" class="mr-2" />
文字水印
</button>
<button
@click="triggerWatermarkInput"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'image']" class="mr-2" />
图片水印
</button>
<button
@click="downloadImage"
:disabled="!processedImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载图片
</button>
<button
@click="resetImage"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'undo']" class="mr-2" />
重置
</button>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleImageUpload"
>
<input
ref="watermarkInput"
type="file"
accept="image/*"
class="hidden"
@change="handleWatermarkUpload"
>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 设置区域 -->
<div class="space-y-4">
<!-- 文字水印设置 -->
<div v-if="watermarkType === 'text'" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">文字水印设置</h3>
<div class="space-y-4">
<!-- 水印文字 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">水印文字</label>
<input
v-model="textWatermark.text"
type="text"
placeholder="请输入水印文字"
class="input-field"
@input="updateWatermark"
>
</div>
<!-- 字体大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
字体大小: {{ textWatermark.fontSize }}px
</label>
<input
v-model.number="textWatermark.fontSize"
type="range"
min="12"
max="120"
step="2"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 字体颜色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体颜色</label>
<div class="flex space-x-2">
<input
v-model="textWatermark.color"
type="color"
class="w-12 h-8 rounded border border-primary border-opacity-20"
@input="updateWatermark"
>
<input
v-model="textWatermark.color"
type="text"
class="input-field flex-1"
@input="updateWatermark"
>
</div>
</div>
<!-- 透明度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
透明度: {{ Math.round(textWatermark.opacity * 100) }}%
</label>
<input
v-model.number="textWatermark.opacity"
type="range"
min="0.1"
max="1"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 字体样式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体样式</label>
<div class="flex space-x-2">
<button
@click="toggleFontStyle('bold')"
:class="['btn-sm', textWatermark.fontWeight === 'bold' ? 'btn-primary' : 'btn-secondary']"
>
<FontAwesomeIcon :icon="['fas', 'bold']" />
</button>
<button
@click="toggleFontStyle('italic')"
:class="['btn-sm', textWatermark.fontStyle === 'italic' ? 'btn-primary' : 'btn-secondary']"
>
<FontAwesomeIcon :icon="['fas', 'italic']" />
</button>
</div>
</div>
<!-- 旋转角度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
旋转角度: {{ textWatermark.rotation }}°
</label>
<input
v-model.number="textWatermark.rotation"
type="range"
min="-45"
max="45"
step="5"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 图片水印设置 -->
<div v-if="watermarkType === 'image'" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片水印设置</h3>
<div class="space-y-4">
<!-- 水印图片预览 -->
<div v-if="watermarkImage">
<label class="block text-sm font-medium text-secondary mb-2">水印图片</label>
<div class="bg-block rounded-lg p-4">
<img :src="watermarkImage" alt="水印图片" class="max-w-full h-20 object-contain mx-auto">
</div>
</div>
<!-- 缩放比例 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
缩放比例: {{ Math.round(imageWatermark.scale * 100) }}%
</label>
<input
v-model.number="imageWatermark.scale"
type="range"
min="0.1"
max="2"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 透明度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
透明度: {{ Math.round(imageWatermark.opacity * 100) }}%
</label>
<input
v-model.number="imageWatermark.opacity"
type="range"
min="0.1"
max="1"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 旋转角度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
旋转角度: {{ imageWatermark.rotation }}°
</label>
<input
v-model.number="imageWatermark.rotation"
type="range"
min="-180"
max="180"
step="15"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 位置设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">位置设置</h3>
<div class="space-y-4">
<!-- 预设位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">快速定位</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="(pos, key) in positions"
:key="key"
@click="setPosition(key)"
:class="['btn-sm text-xs', currentPosition.key === key ? 'btn-primary' : 'btn-secondary']"
>
{{ pos.name }}
</button>
</div>
</div>
<!-- 自定义位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
X坐标: {{ currentPosition.x }}px
</label>
<input
v-model.number="currentPosition.x"
type="range"
:min="0"
:max="canvasWidth"
class="w-full"
@input="updateWatermark"
>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
Y坐标: {{ currentPosition.y }}px
</label>
<input
v-model.number="currentPosition.y"
type="range"
:min="0"
:max="canvasHeight"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 批量水印设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">批量水印</h3>
<div class="space-y-3">
<label class="flex items-center space-x-2">
<input
v-model="batchSettings.enabled"
type="checkbox"
class="form-checkbox"
@change="updateWatermark"
>
<span class="text-secondary">启用平铺水印</span>
</label>
<div v-if="batchSettings.enabled" class="space-y-3">
<div>
<label class="block text-sm font-medium text-secondary mb-2">
水平间距: {{ batchSettings.spacingX }}px
</label>
<input
v-model.number="batchSettings.spacingX"
type="range"
min="50"
max="300"
step="10"
class="w-full"
@input="updateWatermark"
>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
垂直间距: {{ batchSettings.spacingY }}px
</label>
<input
v-model.number="batchSettings.spacingY"
type="range"
min="50"
max="300"
step="10"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
</div>
</div>
<!-- 预览区域 -->
<div class="lg:col-span-2 space-y-4">
<!-- 原图预览 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片预览</h3>
<div v-if="!originalImage" class="bg-block rounded-lg p-8 text-center">
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<p class="text-secondary mb-4">请上传图片或拖拽图片到此处</p>
<button @click="triggerFileInput" class="btn-primary">
选择图片
</button>
</div>
<div v-else class="relative">
<canvas
ref="previewCanvas"
class="max-w-full border border-primary border-opacity-20 rounded-lg cursor-crosshair"
@click="handleCanvasClick"
/>
<!-- 图片信息 -->
<div class="mt-4 flex justify-between items-center text-sm text-secondary">
<span>{{ imageInfo.width }} × {{ imageInfo.height }}</span>
<span>{{ imageInfo.size }}</span>
<span>{{ imageInfo.format }}</span>
</div>
</div>
</div>
<!-- 处理结果 -->
<div v-if="processedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理结果</h3>
<div class="bg-block rounded-lg p-4">
<img :src="processedImage" alt="处理后的图片" class="max-w-full mx-auto">
</div>
<div class="mt-4 flex justify-between items-center text-sm text-secondary">
<span>质量: {{ outputQuality }}%</span>
<span>大小: {{ outputSize }}</span>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// DOM引用
const fileInput = ref<HTMLInputElement>()
const watermarkInput = ref<HTMLInputElement>()
const previewCanvas = ref<HTMLCanvasElement>()
// 响应式状态
const originalImage = ref('')
const processedImage = ref('')
const watermarkImage = ref('')
const watermarkType = ref<'text' | 'image' | null>(null)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 图片信息
const imageInfo = reactive({
width: 0,
height: 0,
size: '',
format: ''
})
// Canvas尺寸
const canvasWidth = ref(0)
const canvasHeight = ref(0)
// 文字水印设置
const textWatermark = reactive({
text: 'Sample Watermark',
fontSize: 36,
color: '#ffffff',
opacity: 0.7,
fontWeight: 'normal',
fontStyle: 'normal',
rotation: 0
})
// 图片水印设置
const imageWatermark = reactive({
scale: 0.3,
opacity: 0.7,
rotation: 0
})
// 位置设置
const currentPosition = reactive({
x: 0,
y: 0,
key: 'center'
})
// 批量水印设置
const batchSettings = reactive({
enabled: false,
spacingX: 150,
spacingY: 150
})
// 预设位置
const positions = {
'top-left': { name: '左上', x: 0.1, y: 0.1 },
'top-center': { name: '居上', x: 0.5, y: 0.1 },
'top-right': { name: '右上', x: 0.9, y: 0.1 },
'center-left': { name: '居左', x: 0.1, y: 0.5 },
'center': { name: '居中', x: 0.5, y: 0.5 },
'center-right': { name: '居右', x: 0.9, y: 0.5 },
'bottom-left': { name: '左下', x: 0.1, y: 0.9 },
'bottom-center': { name: '居下', x: 0.5, y: 0.9 },
'bottom-right': { name: '右下', x: 0.9, y: 0.9 }
}
// 输出设置
const outputQuality = computed(() => 90)
const outputSize = computed(() => {
if (!processedImage.value) return ''
return '估算大小'
})
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
const triggerWatermarkInput = () => {
watermarkInput.value?.click()
}
// 处理图片上传
const handleImageUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
const reader = new FileReader()
reader.onload = (e) => {
originalImage.value = e.target?.result as string
loadImageInfo(file)
nextTick(() => {
setupCanvas()
})
}
reader.readAsDataURL(file)
}
// 处理水印图片上传
const handleWatermarkUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
const reader = new FileReader()
reader.onload = (e) => {
watermarkImage.value = e.target?.result as string
watermarkType.value = 'image'
updateWatermark()
}
reader.readAsDataURL(file)
}
// 加载图片信息
const loadImageInfo = (file: File) => {
const img = new Image()
img.onload = () => {
imageInfo.width = img.width
imageInfo.height = img.height
imageInfo.size = formatFileSize(file.size)
imageInfo.format = file.type.split('/')[1].toUpperCase()
}
img.src = originalImage.value
}
// 设置Canvas
const setupCanvas = () => {
if (!previewCanvas.value || !originalImage.value) return
const img = new Image()
img.onload = () => {
const canvas = previewCanvas.value!
const ctx = canvas.getContext('2d')!
// 设置画布尺寸
const maxWidth = 600
const maxHeight = 400
let { width, height } = img
if (width > maxWidth) {
height = (height * maxWidth) / width
width = maxWidth
}
if (height > maxHeight) {
width = (width * maxHeight) / height
height = maxHeight
}
canvas.width = width
canvas.height = height
canvasWidth.value = width
canvasHeight.value = height
// 绘制原图
ctx.drawImage(img, 0, 0, width, height)
// 设置默认水印位置
setPosition('bottom-right')
}
img.src = originalImage.value
}
// 添加文字水印
const addTextWatermark = () => {
watermarkType.value = 'text'
updateWatermark()
}
// 更新水印
const updateWatermark = () => {
if (!previewCanvas.value || !originalImage.value || !watermarkType.value) return
const canvas = previewCanvas.value
const ctx = canvas.getContext('2d')!
// 重新绘制原图
const img = new Image()
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// 绘制水印
if (watermarkType.value === 'text') {
drawTextWatermark(ctx)
} else if (watermarkType.value === 'image' && watermarkImage.value) {
drawImageWatermark(ctx)
}
// 生成处理后的图片
processedImage.value = canvas.toDataURL('image/jpeg', outputQuality.value / 100)
}
img.src = originalImage.value
}
// 绘制文字水印
const drawTextWatermark = (ctx: CanvasRenderingContext2D) => {
ctx.save()
// 设置字体
const fontStyle = textWatermark.fontStyle === 'italic' ? 'italic ' : ''
const fontWeight = textWatermark.fontWeight === 'bold' ? 'bold ' : ''
ctx.font = `${fontStyle}${fontWeight}${textWatermark.fontSize}px Arial`
// 设置颜色和透明度
ctx.fillStyle = textWatermark.color
ctx.globalAlpha = textWatermark.opacity
if (batchSettings.enabled) {
// 平铺水印
for (let x = 0; x < canvasWidth.value; x += batchSettings.spacingX) {
for (let y = 0; y < canvasHeight.value; y += batchSettings.spacingY) {
drawSingleTextWatermark(ctx, x + 50, y + 50)
}
}
} else {
// 单个水印
drawSingleTextWatermark(ctx, currentPosition.x, currentPosition.y)
}
ctx.restore()
}
// 绘制单个文字水印
const drawSingleTextWatermark = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
ctx.save()
ctx.translate(x, y)
ctx.rotate((textWatermark.rotation * Math.PI) / 180)
// 添加阴影效果
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
ctx.fillText(textWatermark.text, 0, 0)
ctx.restore()
}
// 绘制图片水印
const drawImageWatermark = (ctx: CanvasRenderingContext2D) => {
const watermarkImg = new Image()
watermarkImg.onload = () => {
ctx.save()
ctx.globalAlpha = imageWatermark.opacity
const scaledWidth = watermarkImg.width * imageWatermark.scale
const scaledHeight = watermarkImg.height * imageWatermark.scale
if (batchSettings.enabled) {
// 平铺水印
for (let x = 0; x < canvasWidth.value; x += batchSettings.spacingX) {
for (let y = 0; y < canvasHeight.value; y += batchSettings.spacingY) {
drawSingleImageWatermark(ctx, watermarkImg, x, y, scaledWidth, scaledHeight)
}
}
} else {
// 单个水印
drawSingleImageWatermark(ctx, watermarkImg, currentPosition.x, currentPosition.y, scaledWidth, scaledHeight)
}
ctx.restore()
}
watermarkImg.src = watermarkImage.value
}
// 绘制单个图片水印
const drawSingleImageWatermark = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, width: number, height: number) => {
ctx.save()
ctx.translate(x, y)
ctx.rotate((imageWatermark.rotation * Math.PI) / 180)
ctx.drawImage(img, -width / 2, -height / 2, width, height)
ctx.restore()
}
// 设置位置
const setPosition = (key: string) => {
const pos = positions[key as keyof typeof positions]
if (pos) {
currentPosition.x = canvasWidth.value * pos.x
currentPosition.y = canvasHeight.value * pos.y
currentPosition.key = key
updateWatermark()
}
}
// 处理Canvas点击
const handleCanvasClick = (event: MouseEvent) => {
if (!previewCanvas.value) return
const rect = previewCanvas.value.getBoundingClientRect()
const scaleX = canvasWidth.value / rect.width
const scaleY = canvasHeight.value / rect.height
currentPosition.x = (event.clientX - rect.left) * scaleX
currentPosition.y = (event.clientY - rect.top) * scaleY
currentPosition.key = 'custom'
updateWatermark()
}
// 切换字体样式
const toggleFontStyle = (style: 'bold' | 'italic') => {
if (style === 'bold') {
textWatermark.fontWeight = textWatermark.fontWeight === 'bold' ? 'normal' : 'bold'
} else {
textWatermark.fontStyle = textWatermark.fontStyle === 'italic' ? 'normal' : 'italic'
}
updateWatermark()
}
// 下载图片
const downloadImage = () => {
if (!processedImage.value) return
const link = document.createElement('a')
link.download = `watermarked-image-${Date.now()}.jpg`
link.href = processedImage.value
link.click()
showStatus('图片下载完成', 'success')
}
// 重置图片
const resetImage = () => {
originalImage.value = ''
processedImage.value = ''
watermarkImage.value = ''
watermarkType.value = null
statusMessage.value = ''
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>

View File

@ -0,0 +1,426 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="queryIP"
:disabled="!ipInput.trim() || isLoading"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isLoading ? ['fas', 'spinner'] : ['fas', 'search']"
:class="['mr-2', isLoading && 'animate-spin']"
/>
{{ t('tools.ip_lookup.query') }}
</button>
<button
@click="getMyIP"
:disabled="isLoading"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'globe']" class="mr-2" />
{{ t('tools.ip_lookup.get_my_ip') }}
</button>
<button
@click="clearResults"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.ip_lookup.clear') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.ip_input') }}</h3>
<input
v-model="ipInput"
type="text"
:placeholder="t('tools.ip_lookup.placeholder')"
class="input-field"
@keyup.enter="queryIP"
@input="validateIP"
>
<div v-if="ipValidation.message" class="mt-2 text-sm" :class="ipValidation.isValid ? 'text-success' : 'text-error'">
{{ ipValidation.message }}
</div>
</div>
<!-- 常用IP -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.common_ips') }}</h3>
<div class="space-y-2">
<button
v-for="ip in commonIPs"
:key="ip.ip"
@click="selectCommonIP(ip.ip)"
class="w-full text-left p-2 rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
<div class="font-medium">{{ ip.ip }}</div>
<div class="text-sm text-tertiary">{{ ip.description }}</div>
</button>
</div>
</div>
</div>
<!-- 结果区域 -->
<div class="space-y-4">
<!-- 加载状态 -->
<div v-if="isLoading" class="card p-4">
<div class="text-center py-8">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.ip_lookup.querying') }}</div>
</div>
</div>
<!-- IP信息结果 -->
<div v-else-if="ipResult" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.ip_info') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.ip_address') }}:</span>
<span class="text-primary font-medium">{{ ipResult.ip }}</span>
</div>
<div v-if="ipResult.type" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.ip_type') }}:</span>
<span class="text-primary font-medium">{{ ipResult.type }}</span>
</div>
<div v-if="ipResult.country" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.country') }}:</span>
<span class="text-primary font-medium">{{ ipResult.country }}</span>
</div>
<div v-if="ipResult.region" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.region') }}:</span>
<span class="text-primary font-medium">{{ ipResult.region }}</span>
</div>
<div v-if="ipResult.city" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.city') }}:</span>
<span class="text-primary font-medium">{{ ipResult.city }}</span>
</div>
<div v-if="ipResult.isp" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.isp') }}:</span>
<span class="text-primary font-medium">{{ ipResult.isp }}</span>
</div>
<div v-if="ipResult.org" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.organization') }}:</span>
<span class="text-primary font-medium">{{ ipResult.org }}</span>
</div>
<div v-if="ipResult.timezone" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.timezone') }}:</span>
<span class="text-primary font-medium">{{ ipResult.timezone }}</span>
</div>
<div v-if="ipResult.lat && ipResult.lon" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.coordinates') }}:</span>
<span class="text-primary font-medium">{{ ipResult.lat }}, {{ ipResult.lon }}</span>
</div>
</div>
</div>
<!-- 当前IP信息 -->
<div v-if="currentIP" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.current_ip') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.ip_address') }}:</span>
<span class="text-primary font-medium">{{ currentIP.ip }}</span>
</div>
<div v-if="currentIP.location" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.location') }}:</span>
<span class="text-primary font-medium">{{ currentIP.location }}</span>
</div>
<div v-if="currentIP.isp" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.isp') }}:</span>
<span class="text-primary font-medium">{{ currentIP.isp }}</span>
</div>
</div>
</div>
<!-- IP类型检测 -->
<div v-if="ipInput.trim()" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.ip_lookup.ip_analysis') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.format') }}:</span>
<span class="text-primary font-medium">{{ getIPFormat(ipInput) }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.access_type') }}:</span>
<span class="text-primary font-medium">{{ getIPAccessType(ipInput) }}</span>
</div>
<div v-if="isIPv4(ipInput)" class="flex justify-between">
<span class="text-secondary">{{ t('tools.ip_lookup.class') }}:</span>
<span class="text-primary font-medium">{{ getIPClass(ipInput) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import { api } from '@/utils/api'
const { t } = useLanguage()
// 响应式状态
const ipInput = ref('')
const isLoading = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// IP验证状态
const ipValidation = ref({
isValid: false,
message: ''
})
// 查询结果
const ipResult = ref<{
ip: string
type?: string
country?: string
region?: string
city?: string
isp?: string
org?: string
timezone?: string
lat?: number
lon?: number
} | null>(null)
// 当前IP信息
const currentIP = ref<{
ip: string
location?: string
isp?: string
} | null>(null)
// 常用IP列表
const commonIPs = [
{ ip: '8.8.8.8', description: 'Google DNS' },
{ ip: '1.1.1.1', description: 'Cloudflare DNS' },
{ ip: '114.114.114.114', description: '114 DNS' },
{ ip: '223.5.5.5', description: '阿里 DNS' },
{ ip: '180.76.76.76', description: '百度 DNS' }
]
// 查询IP信息
const queryIP = async () => {
if (!ipInput.value.trim()) {
showStatus('请输入IP地址', 'error')
return
}
if (!ipValidation.value.isValid) {
showStatus('请输入有效的IP地址', 'error')
return
}
isLoading.value = true
statusMessage.value = ''
try {
// 使用免费的IP查询API
const response = await fetch(`http://ip-api.com/json/${ipInput.value}?lang=zh-CN`)
const data = await response.json()
if (data.status === 'success') {
ipResult.value = {
ip: data.query,
type: isIPv4(data.query) ? 'IPv4' : 'IPv6',
country: data.country,
region: data.regionName,
city: data.city,
isp: data.isp,
org: data.org,
timezone: data.timezone,
lat: data.lat,
lon: data.lon
}
showStatus('IP查询成功', 'success')
} else {
throw new Error(data.message || 'IP查询失败')
}
} catch (error) {
console.error('IP查询失败:', error)
showStatus('IP查询失败: ' + (error instanceof Error ? error.message : '网络错误'), 'error')
ipResult.value = null
} finally {
isLoading.value = false
}
}
// 获取当前IP
const getMyIP = async () => {
isLoading.value = true
statusMessage.value = ''
try {
// 首先尝试获取当前IP
const ipResponse = await fetch('https://api.ipify.org?format=json')
const ipData = await ipResponse.json()
// 然后查询IP详细信息
const detailResponse = await fetch(`http://ip-api.com/json/${ipData.ip}?lang=zh-CN`)
const detailData = await detailResponse.json()
if (detailData.status === 'success') {
currentIP.value = {
ip: ipData.ip,
location: `${detailData.country} ${detailData.regionName} ${detailData.city}`,
isp: detailData.isp
}
// 同时设置到输入框
ipInput.value = ipData.ip
validateIP()
showStatus('当前IP获取成功', 'success')
} else {
throw new Error('获取IP详细信息失败')
}
} catch (error) {
console.error('获取当前IP失败:', error)
showStatus('获取当前IP失败: ' + (error instanceof Error ? error.message : '网络错误'), 'error')
} finally {
isLoading.value = false
}
}
// 选择常用IP
const selectCommonIP = (ip: string) => {
ipInput.value = ip
validateIP()
}
// 清除结果
const clearResults = () => {
ipInput.value = ''
ipResult.value = null
currentIP.value = null
statusMessage.value = ''
ipValidation.value = { isValid: false, message: '' }
}
// 验证IP地址
const validateIP = () => {
const ip = ipInput.value.trim()
if (!ip) {
ipValidation.value = { isValid: false, message: '' }
return
}
if (isIPv4(ip)) {
ipValidation.value = { isValid: true, message: '有效的IPv4地址' }
} else if (isIPv6(ip)) {
ipValidation.value = { isValid: true, message: '有效的IPv6地址' }
} else {
ipValidation.value = { isValid: false, message: '无效的IP地址格式' }
}
}
// 检查是否为IPv4
const isIPv4 = (ip: string): boolean => {
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return ipv4Regex.test(ip)
}
// 检查是否为IPv6
const isIPv6 = (ip: string): boolean => {
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/
return ipv6Regex.test(ip)
}
// 获取IP格式
const getIPFormat = (ip: string): string => {
if (isIPv4(ip)) return 'IPv4'
if (isIPv6(ip)) return 'IPv6'
return '无效格式'
}
// 获取IP访问类型
const getIPAccessType = (ip: string): string => {
if (!isIPv4(ip)) return '未知'
const parts = ip.split('.').map(Number)
const first = parts[0]
const second = parts[1]
// 私有IP地址
if (first === 10) return '私有网络 (Class A)'
if (first === 172 && second >= 16 && second <= 31) return '私有网络 (Class B)'
if (first === 192 && second === 168) return '私有网络 (Class C)'
if (first === 127) return '本地回环'
if (first === 169 && second === 254) return '链路本地'
return '公网'
}
// 获取IP类别 (仅IPv4)
const getIPClass = (ip: string): string => {
if (!isIPv4(ip)) return ''
const first = parseInt(ip.split('.')[0])
if (first >= 1 && first <= 126) return 'A类 (1-126)'
if (first >= 128 && first <= 191) return 'B类 (128-191)'
if (first >= 192 && first <= 223) return 'C类 (192-223)'
if (first >= 224 && first <= 239) return 'D类 (组播)'
if (first >= 240 && first <= 255) return 'E类 (保留)'
return '未知'
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 组件挂载时获取当前IP
onMounted(() => {
// 可以选择是否自动获取当前IP
// getMyIP()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,843 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="formatJson"
:disabled="!jsonText.trim()"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-2" />
格式化
</button>
<button
@click="compressJson"
:disabled="!jsonText.trim()"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
压缩
</button>
<button
@click="validateJson"
:disabled="!jsonText.trim()"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
验证
</button>
<button
@click="copyJson"
:disabled="!jsonText.trim()"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制
</button>
<button
@click="clearEditor"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-import']" class="mr-2" />
示例
</button>
<div class="ml-auto flex items-center space-x-2">
<label class="text-sm text-secondary">视图:</label>
<select
v-model="viewMode"
class="select-field text-sm"
>
<option value="text">文本编辑</option>
<option value="tree">树形视图</option>
<option value="split">分屏视图</option>
</select>
</div>
</div>
</div>
<div class="grid" :class="viewMode === 'split' ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'" style="gap: 1.5rem;">
<!-- 文本编辑器 -->
<div v-if="viewMode === 'text' || viewMode === 'split'" class="space-y-4">
<!-- 编辑器选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">文本编辑器</h3>
<div class="flex flex-wrap gap-4 mb-4">
<div>
<label class="block text-sm text-secondary mb-1">缩进设置</label>
<select v-model="indentSize" class="select-field text-sm">
<option :value="2">2个空格</option>
<option :value="4">4个空格</option>
<option :value="'tab'">制表符</option>
</select>
</div>
<div>
<label class="block text-sm text-secondary mb-1">字体大小</label>
<select v-model="fontSize" class="select-field text-sm">
<option value="12">12px</option>
<option value="14">14px</option>
<option value="16">16px</option>
<option value="18">18px</option>
</select>
</div>
<div class="flex items-center space-x-4">
<label class="flex items-center space-x-2">
<input
v-model="showLineNumbers"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">显示行号</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="wordWrap"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">自动换行</span>
</label>
</div>
</div>
<!-- JSON输入区域 -->
<div class="relative">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-secondary">
行数: {{ lineCount }} | 字符数: {{ charCount }}
</div>
<div v-if="currentPath" class="text-sm text-tertiary">
当前路径: {{ currentPath }}
</div>
</div>
<div class="relative">
<!-- 行号 -->
<div
v-if="showLineNumbers"
class="absolute left-0 top-0 bottom-0 w-12 bg-block-hover border-r border-border text-xs text-tertiary font-mono flex flex-col z-10"
:style="{ fontSize: fontSize + 'px' }"
>
<div
v-for="n in lineCount"
:key="n"
class="h-6 flex items-center justify-end pr-2"
>
{{ n }}
</div>
</div>
<!-- 编辑器 -->
<textarea
v-model="jsonText"
@input="handleTextInput"
@keydown="handleKeyDown"
@click="updateCursor"
@keyup="updateCursor"
:style="{
fontSize: fontSize + 'px',
paddingLeft: showLineNumbers ? '3rem' : '1rem',
whiteSpace: wordWrap ? 'pre-wrap' : 'pre'
}"
class="textarea-field font-mono resize-none transition-all"
:class="[
'h-96 w-full',
jsonError ? 'border-error' : 'border-border'
]"
placeholder="请输入JSON数据..."
spellcheck="false"
/>
</div>
</div>
<!-- 错误信息 -->
<div v-if="jsonError" class="mt-2 p-3 bg-error bg-opacity-10 border border-error rounded-lg">
<div class="flex items-start space-x-2">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="text-error mt-0.5" />
<div>
<div class="font-medium text-error">JSON格式错误</div>
<div class="text-sm text-error opacity-80">{{ jsonError }}</div>
<div v-if="errorLine" class="text-xs text-error opacity-60 mt-1">
{{ errorLine }} | {{ errorColumn }}
</div>
</div>
</div>
</div>
<!-- 验证成功信息 -->
<div v-else-if="validationMessage" class="mt-2 p-3 bg-success bg-opacity-10 border border-success rounded-lg">
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success" />
<span class="text-success">{{ validationMessage }}</span>
</div>
</div>
</div>
</div>
<!-- 树形视图 -->
<div v-if="viewMode === 'tree' || viewMode === 'split'" class="space-y-4">
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">树形视图</h3>
<div class="flex space-x-2">
<button
@click="expandAll"
:disabled="!parsedJson"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'expand']" class="mr-1" />
全部展开
</button>
<button
@click="collapseAll"
:disabled="!parsedJson"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-1" />
全部折叠
</button>
</div>
</div>
<div class="max-h-96 overflow-auto border border-border rounded-lg p-4 bg-block font-mono text-sm">
<JsonTreeNode
v-if="parsedJson !== null"
:data="parsedJson"
:path="[]"
:expanded="expandedNodes"
@toggle="toggleNode"
@select="selectNode"
/>
<div v-else class="text-tertiary text-center py-8">
请输入有效的JSON数据
</div>
</div>
</div>
<!-- JSON路径查询 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">路径查询</h3>
<div class="space-y-3">
<div>
<label class="block text-sm text-secondary mb-1">JSON路径 (支持 . [] 语法)</label>
<div class="flex space-x-2">
<input
v-model="jsonPath"
type="text"
class="input-field flex-1 font-mono text-sm"
placeholder="例如: user.name 或 users[0].email"
@keyup.enter="queryPath"
>
<button
@click="queryPath"
:disabled="!jsonPath.trim() || !parsedJson"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'search']" />
</button>
</div>
</div>
<!-- 查询结果 -->
<div v-if="pathResult !== null" class="p-3 bg-block rounded-lg">
<div class="text-sm text-secondary mb-2">查询结果:</div>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap">{{ pathResult }}</pre>
</div>
<div v-if="pathError" class="p-3 bg-error bg-opacity-10 border border-error rounded-lg">
<div class="text-sm text-error">{{ pathError }}</div>
</div>
</div>
</div>
<!-- JSON统计信息 -->
<div v-if="parsedJson" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">统计信息</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">总键数:</span>
<span class="text-primary font-medium">{{ jsonStats.totalKeys }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">总值数:</span>
<span class="text-primary font-medium">{{ jsonStats.totalValues }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">嵌套深度:</span>
<span class="text-primary font-medium">{{ jsonStats.maxDepth }}</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">数组数量:</span>
<span class="text-primary font-medium">{{ jsonStats.arrayCount }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">对象数量:</span>
<span class="text-primary font-medium">{{ jsonStats.objectCount }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">字符串数量:</span>
<span class="text-primary font-medium">{{ jsonStats.stringCount }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态栏 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const jsonText = ref('')
const viewMode = ref<'text' | 'tree' | 'split'>('text')
const indentSize = ref<number | string>(2)
const fontSize = ref('14')
const showLineNumbers = ref(true)
const wordWrap = ref(false)
const copied = ref(false)
const jsonError = ref('')
const validationMessage = ref('')
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
const currentPath = ref('')
const jsonPath = ref('')
const pathResult = ref<any>(null)
const pathError = ref('')
const errorLine = ref<number | null>(null)
const errorColumn = ref<number | null>(null)
// 树形视图状态
const expandedNodes = ref<Set<string>>(new Set())
// 计算属性
const lineCount = computed(() => {
return jsonText.value ? jsonText.value.split('\n').length : 1
})
const charCount = computed(() => {
return jsonText.value.length
})
const parsedJson = computed(() => {
if (!jsonText.value.trim()) return null
try {
return JSON.parse(jsonText.value)
} catch {
return null
}
})
const jsonStats = computed(() => {
if (!parsedJson.value) {
return {
totalKeys: 0,
totalValues: 0,
maxDepth: 0,
arrayCount: 0,
objectCount: 0,
stringCount: 0
}
}
const stats = {
totalKeys: 0,
totalValues: 0,
maxDepth: 0,
arrayCount: 0,
objectCount: 0,
stringCount: 0
}
const analyze = (obj: any, depth: number = 0): void => {
stats.maxDepth = Math.max(stats.maxDepth, depth)
if (Array.isArray(obj)) {
stats.arrayCount++
stats.totalValues++
for (const item of obj) {
analyze(item, depth + 1)
}
} else if (typeof obj === 'object' && obj !== null) {
stats.objectCount++
stats.totalValues++
for (const [key, value] of Object.entries(obj)) {
stats.totalKeys++
analyze(value, depth + 1)
}
} else if (typeof obj === 'string') {
stats.stringCount++
stats.totalValues++
} else {
stats.totalValues++
}
}
analyze(parsedJson.value)
return stats
})
// 处理文本输入
const handleTextInput = () => {
validateJson()
pathResult.value = null
pathError.value = ''
}
// 处理键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
// Tab键缩进
if (event.key === 'Tab') {
event.preventDefault()
const textarea = event.target as HTMLTextAreaElement
const start = textarea.selectionStart
const end = textarea.selectionEnd
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(Number(indentSize.value))
if (event.shiftKey) {
// Shift+Tab: 减少缩进
const lines = jsonText.value.split('\n')
const startLine = jsonText.value.substring(0, start).split('\n').length - 1
const endLine = jsonText.value.substring(0, end).split('\n').length - 1
for (let i = startLine; i <= endLine; i++) {
if (lines[i].startsWith(indent)) {
lines[i] = lines[i].substring(indent.length)
}
}
jsonText.value = lines.join('\n')
} else {
// Tab: 增加缩进
const value = jsonText.value
jsonText.value = value.substring(0, start) + indent + value.substring(end)
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + indent.length
})
}
}
// Ctrl+Enter: 格式化
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault()
formatJson()
}
}
// 更新光标位置
const updateCursor = (event: Event) => {
const textarea = event.target as HTMLTextAreaElement
const cursorPos = textarea.selectionStart
const textBeforeCursor = jsonText.value.substring(0, cursorPos)
const lines = textBeforeCursor.split('\n')
const currentLine = lines.length
const currentCol = lines[lines.length - 1].length + 1
currentPath.value = `${currentLine} 行,第 ${currentCol}`
}
// 验证JSON
const validateJson = () => {
if (!jsonText.value.trim()) {
jsonError.value = ''
validationMessage.value = ''
errorLine.value = null
errorColumn.value = null
return
}
try {
JSON.parse(jsonText.value)
jsonError.value = ''
validationMessage.value = 'JSON格式正确'
errorLine.value = null
errorColumn.value = null
} catch (error) {
if (error instanceof SyntaxError) {
jsonError.value = error.message
// 尝试提取行号和列号
const match = error.message.match(/position (\d+)/)
if (match) {
const position = parseInt(match[1])
const lines = jsonText.value.substring(0, position).split('\n')
errorLine.value = lines.length
errorColumn.value = lines[lines.length - 1].length
}
} else {
jsonError.value = '未知错误'
}
validationMessage.value = ''
}
}
// 格式化JSON
const formatJson = () => {
if (!jsonText.value.trim()) {
showStatus('请输入JSON数据', 'error')
return
}
try {
const parsed = JSON.parse(jsonText.value)
const indent = indentSize.value === 'tab' ? '\t' : Number(indentSize.value)
jsonText.value = JSON.stringify(parsed, null, indent)
showStatus('格式化完成', 'success')
validateJson()
} catch (error) {
showStatus('JSON格式错误无法格式化', 'error')
}
}
// 压缩JSON
const compressJson = () => {
if (!jsonText.value.trim()) {
showStatus('请输入JSON数据', 'error')
return
}
try {
const parsed = JSON.parse(jsonText.value)
jsonText.value = JSON.stringify(parsed)
showStatus('压缩完成', 'success')
validateJson()
} catch (error) {
showStatus('JSON格式错误无法压缩', 'error')
}
}
// 复制JSON
const copyJson = async () => {
if (!jsonText.value.trim()) return
try {
await navigator.clipboard.writeText(jsonText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
showStatus('复制成功', 'success')
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 清除编辑器
const clearEditor = () => {
jsonText.value = ''
jsonError.value = ''
validationMessage.value = ''
currentPath.value = ''
pathResult.value = null
pathError.value = ''
statusMessage.value = ''
}
// 加载示例
const loadSample = () => {
const sample = {
"user": {
"id": 1,
"name": "张三",
"email": "zhangsan@example.com",
"profile": {
"age": 30,
"city": "北京",
"skills": ["JavaScript", "Vue.js", "Node.js"]
}
},
"posts": [
{
"id": 1,
"title": "Vue.js入门指南",
"content": "这是一篇关于Vue.js的入门教程...",
"tags": ["vue", "javascript", "前端"],
"published": true
},
{
"id": 2,
"title": "JSON数据处理技巧",
"content": "本文介绍JSON数据的处理方法...",
"tags": ["json", "数据处理"],
"published": false
}
],
"metadata": {
"version": "1.0.0",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-15T12:30:00Z"
}
}
const indent = indentSize.value === 'tab' ? '\t' : Number(indentSize.value)
jsonText.value = JSON.stringify(sample, null, indent)
validateJson()
}
// 树形视图相关
const toggleNode = (path: string[]) => {
const pathStr = path.join('.')
if (expandedNodes.value.has(pathStr)) {
expandedNodes.value.delete(pathStr)
} else {
expandedNodes.value.add(pathStr)
}
}
const selectNode = (path: string[]) => {
jsonPath.value = path.join('.')
queryPath()
}
const expandAll = () => {
const expand = (obj: any, path: string[] = []): void => {
if (typeof obj === 'object' && obj !== null) {
expandedNodes.value.add(path.join('.'))
for (const key in obj) {
expand(obj[key], [...path, key])
}
}
}
if (parsedJson.value) {
expand(parsedJson.value)
}
}
const collapseAll = () => {
expandedNodes.value.clear()
}
// 路径查询
const queryPath = () => {
if (!jsonPath.value.trim() || !parsedJson.value) {
pathResult.value = null
pathError.value = ''
return
}
try {
const result = getValueByPath(parsedJson.value, jsonPath.value)
pathResult.value = JSON.stringify(result, null, 2)
pathError.value = ''
} catch (error) {
pathResult.value = null
pathError.value = error instanceof Error ? error.message : '路径查询失败'
}
}
// 根据路径获取值
const getValueByPath = (obj: any, path: string): any => {
const keys = path.split(/[.\[\]]+/).filter(key => key)
let current = obj
for (const key of keys) {
if (current === null || current === undefined) {
throw new Error(`路径 "${path}" 中的 "${key}" 不存在`)
}
if (Array.isArray(current)) {
const index = parseInt(key)
if (isNaN(index) || index < 0 || index >= current.length) {
throw new Error(`数组索引 "${key}" 无效`)
}
current = current[index]
} else if (typeof current === 'object') {
if (!(key in current)) {
throw new Error(`属性 "${key}" 不存在`)
}
current = current[key]
} else {
throw new Error(`无法在基本类型上访问属性 "${key}"`)
}
}
return current
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 监听JSON文本变化
watch(() => jsonText.value, () => {
validateJson()
})
</script>
<!-- JSON树形节点组件 -->
<script lang="ts">
export default {
name: 'JsonTreeNode',
props: {
data: {
type: [Object, Array, String, Number, Boolean],
required: true
},
path: {
type: Array as () => string[],
required: true
},
expanded: {
type: Set as () => Set<string>,
required: true
}
},
emits: ['toggle', 'select'],
setup(props, { emit }) {
const isExpanded = computed(() => {
return props.expanded.has(props.path.join('.'))
})
const isObject = computed(() => {
return typeof props.data === 'object' && props.data !== null
})
const isArray = computed(() => {
return Array.isArray(props.data)
})
const dataType = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) return 'array'
return typeof props.data
})
const displayValue = computed(() => {
if (props.data === null) return 'null'
if (typeof props.data === 'string') return `"${props.data}"`
if (typeof props.data === 'boolean') return props.data.toString()
if (typeof props.data === 'number') return props.data.toString()
return ''
})
const toggle = () => {
if (isObject.value) {
emit('toggle', props.path)
}
}
const select = () => {
emit('select', props.path)
}
return {
isExpanded,
isObject,
isArray,
dataType,
displayValue,
toggle,
select
}
},
template: `
<div class="json-node">
<div
class="flex items-center space-x-1 hover:bg-block-hover rounded px-1 cursor-pointer"
@click="select"
>
<button
v-if="isObject"
@click.stop="toggle"
class="w-4 h-4 flex items-center justify-center text-xs text-secondary hover:text-primary"
>
<FontAwesomeIcon
:icon="isExpanded ? ['fas', 'chevron-down'] : ['fas', 'chevron-right']"
/>
</button>
<div v-else class="w-4"></div>
<span
v-if="path.length > 0"
class="text-blue-400 font-medium"
>
{{ path[path.length - 1] }}:
</span>
<span
v-if="!isObject"
:class="{
'text-green-400': dataType === 'string',
'text-blue-400': dataType === 'number',
'text-purple-400': dataType === 'boolean',
'text-gray-400': dataType === 'null'
}"
>
{{ displayValue }}
</span>
<span v-if="isArray" class="text-gray-400">
[{{ data.length }}]
</span>
<span v-else-if="isObject && !isArray" class="text-gray-400">
{{{ Object.keys(data).length }}}
</span>
</div>
<div v-if="isObject && isExpanded" class="ml-4 border-l border-border pl-2 mt-1">
<JsonTreeNode
v-for="(value, key, index) in data"
:key="index"
:data="value"
:path="[...path, key.toString()]"
:expanded="expanded"
@toggle="$emit('toggle', $event)"
@select="$emit('select', $event)"
/>
</div>
</div>
`
}
</script>

View File

@ -0,0 +1,847 @@
<template>
<div class="space-y-6">
<!-- 验证状态显示 -->
<div v-if="validationResult.message || isLoading" class="text-center">
<div v-if="isLoading" class="flex items-center justify-center text-tertiary">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="animate-spin mr-2" />
<span>{{ isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.parsing_json') }}</span>
</div>
<div v-else-if="validationResult.message" :class="[
'flex items-center justify-center space-x-2',
validationResult.isValid ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="validationResult.isValid ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ validationResult.message }}</span>
</div>
</div>
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="toggleCompression"
:disabled="isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="isCompressed ? ['fas', 'expand'] : ['fas', 'compress']" />
<span>{{ isCompressed ? t('tools.json_formatter.beautify') : t('tools.json_formatter.compress') }}</span>
</button>
<button
@click="toggleFoldable"
:disabled="isLoading || !jsonOutput"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="isFoldable ? ['fas', 'folder'] : ['fas', 'folder-open']" />
<span>{{ isFoldable ? t('tools.json_formatter.normal_mode') : t('tools.json_formatter.fold_mode') }}</span>
</button>
<button
@click="copyToClipboard"
:disabled="!jsonOutput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" />
<span>{{ copied ? t('common.copySuccess') : t('tools.json_formatter.copy') }}</span>
</button>
<button
@click="clearInput"
:disabled="isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
<span>{{ t('tools.json_formatter.clear') }}</span>
</button>
<button
@click="loadExample"
:disabled="isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'code']" />
<span>{{ t('tools.json_formatter.load_example') }}</span>
</button>
<button
@click="reformat"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="isLoading ? ['fas', 'spinner'] : ['fas', 'sync']" :class="isLoading && 'animate-spin'" />
<span>{{ isLoading ? t('tools.json_formatter.processing') : t('tools.json_formatter.reformat') }}</span>
</button>
<button
@click="openSaveModal"
:disabled="!jsonOutput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'save']" />
<span>{{ t('tools.json_formatter.save') }}</span>
</button>
<button
@click="openHistory"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'history']" />
<span>{{ t('tools.json_formatter.history') }}</span>
</button>
<button
@click="removeSlashes"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'eraser']" />
<span>{{ t('tools.json_formatter.remove_slash') }}</span>
</button>
<button
@click="escapeString"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'code']" />
<span>{{ t('tools.json_formatter.escape_string') }}</span>
</button>
<button
@click="unescapeString"
:disabled="!jsonInput || isLoading"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'undo']" />
<span>{{ t('tools.json_formatter.unescape_string') }}</span>
</button>
<button
v-if="isLoading"
@click="cancelFormatting"
class="btn-primary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
<span>{{ t('tools.json_formatter.cancel') }}</span>
</button>
</div>
</div>
<!-- 主内容区 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-secondary">{{ t('tools.json_formatter.input_json') }}</label>
<div class="text-xs text-tertiary">{{ t('tools.json_formatter.paste_json_here') }}</div>
</div>
<textarea
v-model="jsonInput"
ref="jsonInputRef"
:placeholder="t('tools.json_formatter.paste_json_placeholder')"
class="textarea-field h-96 font-mono text-sm"
:disabled="isLoading"
@input="handleInputChange"
@blur="handleBlur"
@paste="handlePaste"
/>
<div v-if="errorMessage" class="mt-2 text-sm text-error">
{{ errorMessage }}
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-secondary">{{ t('tools.json_formatter.output') }}</label>
<div class="text-xs text-tertiary">
<span v-if="jsonOutput && !isLoading">{{ jsonOutput.length.toLocaleString() }} {{ t('tools.json_formatter.characters') }}</span>
<span v-if="isLoading" class="flex items-center">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="animate-spin mr-1" />
{{ isLargeJson ? t('tools.json_formatter.processing_large_json') : t('tools.json_formatter.processing') }}
</span>
</div>
</div>
<div class="relative h-96 border border-primary/20 rounded-lg overflow-hidden bg-secondary/5">
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center bg-secondary/10 backdrop-blur-sm z-10">
<div class="flex flex-col items-center space-y-2">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="animate-spin text-2xl text-primary" />
<span class="text-secondary text-center">
{{ isLargeJson ? t('tools.json_formatter.processing_large_json_message') : t('tools.json_formatter.parsing_json') }}
</span>
<button @click="cancelFormatting" class="mt-3 px-3 py-1.5 text-xs rounded btn-secondary">
{{ t('tools.json_formatter.cancel_processing') }}
</button>
</div>
</div>
<div v-else-if="jsonOutput" class="h-full overflow-auto p-4">
<div v-if="isFoldable && parsedJson" class="json-viewer">
<!-- 可折叠的JSON视图 - 简化版本 -->
<JsonTreeView :data="parsedJson" />
</div>
<pre v-else class="whitespace-pre-wrap text-sm font-mono text-primary leading-relaxed">{{ jsonOutput }}</pre>
</div>
<div v-else class="h-full flex items-center justify-center text-tertiary">
<span>{{ t('tools.json_formatter.output_placeholder') }}</span>
</div>
</div>
</div>
</div>
<!-- JSONPath查询 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.json_formatter.jsonpath_query') }}</h3>
<div class="text-xs text-tertiary mb-3">{{ t('tools.json_formatter.enter_jsonpath') }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="relative">
<FontAwesomeIcon
:icon="['fas', 'search']"
class="absolute left-3 top-1/2 -translate-y-1/2 text-tertiary"
/>
<input
v-model="jsonPath"
ref="jsonPathInputRef"
type="text"
:placeholder="t('tools.json_formatter.jsonpath_placeholder')"
class="input-field pl-10"
:disabled="isLoading || !jsonOutput"
@keyup.enter="queryJsonPath"
/>
</div>
</div>
<div>
<div class="p-3 rounded-lg min-h-[40px] text-sm bg-secondary/10 border border-primary/10">
<pre v-if="pathResult" class="whitespace-pre-wrap text-secondary">{{ pathResult }}</pre>
<span v-else class="text-tertiary">{{ t('tools.json_formatter.query_result_placeholder') }}</span>
</div>
</div>
</div>
</div>
<!-- 历史记录侧边栏 -->
<div v-if="isHistoryOpen" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="closeHistory"></div>
<div class="absolute top-0 right-0 h-full w-96 bg-card border-l border-primary/20 shadow-xl overflow-y-auto">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-primary">{{ t('tools.json_formatter.history') }}</h3>
<button @click="closeHistory" class="p-2 rounded text-secondary hover:text-primary">
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
<div v-if="historyItems.length === 0" class="text-center text-tertiary py-8">
<FontAwesomeIcon :icon="['fas', 'history']" class="text-4xl mb-2" />
<p>{{ t('tools.json_formatter.no_history') }}</p>
</div>
<div v-else>
<!-- 收藏的项目 -->
<div v-if="favoriteItems.length > 0" class="mb-6">
<h4 class="text-sm font-medium text-secondary mb-2">{{ t('tools.json_formatter.favorites') }}</h4>
<div v-for="item in favoriteItems" :key="item.id" class="history-item" @click="loadFromHistory(item)">
<div class="flex-1 truncate">
<div class="font-medium truncate text-primary">{{ item.title }}</div>
<div class="text-xs text-tertiary">{{ formatTimestamp(item.timestamp) }}</div>
</div>
<div class="flex items-center space-x-2">
<button @click.stop="toggleFavorite(item.id)" class="text-warning" :title="t('tools.json_formatter.remove_favorite')">
<FontAwesomeIcon :icon="['fas', 'star']" />
</button>
<button @click.stop="startEditingTitle(item)" class="text-tertiary hover:text-primary" :title="t('tools.json_formatter.edit_title')">
<FontAwesomeIcon :icon="['fas', 'edit']" />
</button>
<button @click.stop="deleteHistoryItem(item.id)" class="text-tertiary hover:text-error" :title="t('tools.json_formatter.delete')">
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div>
</div>
</div>
<!-- 所有历史记录 -->
<h4 class="text-sm font-medium text-secondary mb-2">{{ t('tools.json_formatter.all_history') }}</h4>
<div v-for="item in historyItems" :key="item.id" class="history-item" @click="loadFromHistory(item)">
<div class="flex-1 truncate">
<div class="font-medium truncate text-primary">{{ item.title }}</div>
<div class="text-xs text-tertiary">{{ formatTimestamp(item.timestamp) }}</div>
</div>
<div class="flex items-center space-x-2">
<button
@click.stop="toggleFavorite(item.id)"
:class="item.isFavorite ? 'text-warning' : 'text-tertiary hover:text-warning'"
:title="item.isFavorite ? t('tools.json_formatter.remove_favorite') : t('tools.json_formatter.add_favorite')"
>
<FontAwesomeIcon :icon="['fas', 'star']" />
</button>
<button @click.stop="startEditingTitle(item)" class="text-tertiary hover:text-primary" :title="t('tools.json_formatter.edit_title')">
<FontAwesomeIcon :icon="['fas', 'edit']" />
</button>
<button @click.stop="deleteHistoryItem(item.id)" class="text-tertiary hover:text-error" :title="t('tools.json_formatter.delete')">
<FontAwesomeIcon :icon="['fas', 'trash']" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 保存模态框 -->
<div v-if="isSaveModalOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="closeSaveModal"></div>
<div class="relative bg-card rounded-lg p-6 w-full max-w-md mx-4 border border-primary/20 shadow-xl">
<h3 class="text-lg font-medium text-primary mb-4">
{{ editingItem ? t('tools.json_formatter.edit_saved_json') : t('tools.json_formatter.save_to_history') }}
</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-secondary mb-1">{{ t('tools.json_formatter.modal_title') }}</label>
<input
v-model="savingTitle"
type="text"
:placeholder="t('tools.json_formatter.enter_title')"
class="input-field w-full"
@keyup.enter="saveToHistory"
/>
</div>
<div class="flex justify-end space-x-2">
<button @click="closeSaveModal" class="btn-secondary">
{{ t('tools.json_formatter.cancel') }}
</button>
<button @click="saveToHistory" class="btn-primary" :disabled="!savingTitle.trim()">
{{ editingItem ? t('tools.json_formatter.update') : t('tools.json_formatter.save') }}
</button>
</div>
</div>
</div>
<!-- 使用指南 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.json_formatter.usage_guide') }}</h3>
<ul class="text-sm text-tertiary space-y-1 list-disc pl-5">
<li>{{ t('tools.json_formatter.guide_1') }}</li>
<li>{{ t('tools.json_formatter.guide_2') }}</li>
<li>{{ t('tools.json_formatter.guide_3') }}</li>
<li>{{ t('tools.json_formatter.guide_4') }}</li>
<li>{{ t('tools.json_formatter.guide_5') }}</li>
<li>{{ t('tools.json_formatter.guide_6') }}</li>
<li>{{ t('tools.json_formatter.guide_7') }}</li>
<li>{{ t('tools.json_formatter.guide_8') }}</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 历史记录项目类型
interface JsonHistoryItem {
id: string
title: string
json: string
timestamp: number
isFavorite?: boolean
}
// 响应式状态
const jsonInput = ref('')
const jsonOutput = ref('')
const jsonPath = ref('')
const pathResult = ref('')
const errorMessage = ref('')
const isLoading = ref(false)
const isCompressed = ref(false)
const isFoldable = ref(true)
const copied = ref(false)
const isLargeJson = ref(false)
const validationResult = ref({
isValid: false,
message: ''
})
// 历史记录相关状态
const historyItems = ref<JsonHistoryItem[]>([])
const isHistoryOpen = ref(false)
const isSaveModalOpen = ref(false)
const savingTitle = ref('')
const editingItem = ref<JsonHistoryItem | null>(null)
// refs
const jsonInputRef = ref<HTMLTextAreaElement>()
const jsonPathInputRef = ref<HTMLInputElement>()
// 处理参考,用于取消操作
const processingRef = ref(false)
// 计算属性
const parsedJson = computed(() => {
if (!jsonOutput.value) return null
try {
return JSON.parse(jsonOutput.value)
} catch {
return null
}
})
const favoriteItems = computed(() => {
return historyItems.value.filter(item => item.isFavorite)
})
// 从本地存储加载历史记录
onMounted(() => {
const savedHistory = localStorage.getItem('json_formatter_history')
if (savedHistory) {
try {
historyItems.value = JSON.parse(savedHistory)
} catch (e) {
console.error('加载历史记录失败:', e)
}
}
})
// 保存历史记录到本地存储
const saveHistoryToLocalStorage = () => {
localStorage.setItem('json_formatter_history', JSON.stringify(historyItems.value))
}
// 格式化JSON
const formatJson = (json: string, compress = false) => {
if (!json.trim()) {
jsonOutput.value = ''
validationResult.value = { isValid: false, message: '' }
return
}
// 检查JSON大小
const isLarge = json.length > 100000
isLargeJson.value = isLarge
// 设置加载状态
isLoading.value = true
processingRef.value = true
// 使用setTimeout确保UI更新
setTimeout(() => {
if (!processingRef.value) return // 检查是否被取消
try {
// 处理可能的JS对象文本
const processedJson = json
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":')
.replace(/'/g, '"')
let parsed
try {
parsed = JSON.parse(processedJson)
} catch (e) {
// 尝试使用Function构造器处理JS对象
try {
parsed = new Function('return ' + json)()
} catch {
throw e
}
}
// 根据模式输出不同格式
let formattedJson
if (compress) {
formattedJson = JSON.stringify(parsed)
} else {
formattedJson = JSON.stringify(parsed, null, 2)
}
if (!processingRef.value) return // 再次检查是否被取消
// 设置输出
jsonOutput.value = formattedJson
// 计算大小
const sizeKB = (formattedJson.length / 1024).toFixed(1)
const largeJsonMessage = t('tools.json_formatter.large_json_processed').replace('{size}', sizeKB)
validationResult.value = {
isValid: true,
message: isLarge ? largeJsonMessage : t('tools.json_formatter.json_valid')
}
errorMessage.value = ''
// 如果有JSONPath查询执行查询
if (jsonPath.value) {
queryJsonPath()
}
} catch (error) {
if (!processingRef.value) return
if (error instanceof Error) {
errorMessage.value = error.message
validationResult.value = { isValid: false, message: t('tools.json_formatter.json_invalid') }
}
} finally {
isLoading.value = false
processingRef.value = false
}
}, 0)
}
// 切换压缩/美化
const toggleCompression = () => {
isCompressed.value = !isCompressed.value
formatJson(jsonInput.value, isCompressed.value)
}
// 切换折叠功能
const toggleFoldable = () => {
isFoldable.value = !isFoldable.value
}
// 取消格式化操作
const cancelFormatting = () => {
processingRef.value = false
isLoading.value = false
}
// 移除JSON中的转义斜杠
const removeSlashes = () => {
if (!jsonInput.value) return
try {
const processed = jsonInput.value.replace(/\\\//g, '/')
if (processed === jsonInput.value) {
console.log('没有检测到需要替换的内容')
return
}
jsonInput.value = processed
setTimeout(() => formatJson(processed, isCompressed.value), 100)
} catch (error) {
console.error('移除斜杠处理失败:', error)
}
}
// 字符串转义
const escapeString = () => {
if (!jsonInput.value) return
try {
const processed = jsonInput.value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\f/g, '\\f')
.replace(/\b/g, '\\b')
if (processed === jsonInput.value) {
console.log('没有检测到需要转义的内容')
return
}
jsonInput.value = processed
} catch (error) {
console.error('字符串转义处理失败:', error)
}
}
// 字符串反转义
const unescapeString = () => {
if (!jsonInput.value) return
try {
const processed = jsonInput.value
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\f/g, '\f')
.replace(/\\b/g, '\b')
.replace(/\\\\/g, '\\')
if (processed === jsonInput.value) {
console.log('没有检测到需要反转义的内容')
return
}
jsonInput.value = processed
} catch (error) {
console.error('字符串反转义处理失败:', error)
}
}
// 复制结果到剪贴板
const copyToClipboard = async () => {
if (!jsonOutput.value) return
try {
await navigator.clipboard.writeText(jsonOutput.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 清空输入
const clearInput = () => {
if (isLoading.value) {
cancelFormatting()
}
jsonInput.value = ''
jsonOutput.value = ''
errorMessage.value = ''
validationResult.value = { isValid: false, message: '' }
pathResult.value = ''
nextTick(() => {
jsonInputRef.value?.focus()
})
}
// 处理输入变化
const handleInputChange = () => {
if (validationResult.value.message) {
validationResult.value = { isValid: false, message: '' }
errorMessage.value = ''
}
}
// 处理失焦事件
const handleBlur = () => {
if (jsonInput.value && !isLoading.value) {
formatJson(jsonInput.value, isCompressed.value)
}
}
// 处理粘贴事件
const handlePaste = async (e: Event) => {
const clipboardEvent = e as ClipboardEvent
const pastedText = clipboardEvent.clipboardData?.getData('text')
if (pastedText && pastedText.trim().length > 0) {
if (isLoading.value) {
cancelFormatting()
}
jsonInput.value = pastedText
isLoading.value = true
processingRef.value = true
setTimeout(() => formatJson(pastedText, isCompressed.value), 100)
}
}
// 路径查询变化
const queryJsonPath = () => {
if (!jsonPath.value || !jsonOutput.value) {
pathResult.value = ''
return
}
try {
const parsed = JSON.parse(jsonOutput.value)
const result = getValueByPath(parsed, jsonPath.value)
if (typeof result === 'object' && result !== null) {
pathResult.value = JSON.stringify(result, null, 2)
} else {
pathResult.value = String(result)
}
} catch (error) {
pathResult.value = `查询错误: ${error instanceof Error ? error.message : '未知错误'}`
}
}
// 通过路径获取值的辅助函数
const getValueByPath = (obj: any, path: string): any => {
const segments = path
.replace(/\[(\w+)\]/g, '.$1')
.replace(/^\./, '')
.split('.')
let result = obj
for (const segment of segments) {
if (typeof result === 'object' && result !== null && segment in result) {
result = result[segment]
} else {
throw new Error(`路径 '${path}' 不存在`)
}
}
return result
}
// 加载示例JSON
const loadExample = () => {
if (isLoading.value) {
cancelFormatting()
}
const example = {
name: "极速箱",
version: "1.0.0",
description: "高效开发工具集成平台",
author: {
name: "JiSuXiang开发团队",
email: "support@jisuxiang.com"
},
features: [
"JSON格式化与验证",
"时间戳转换",
"编码转换工具",
"正则表达式测试"
],
statistics: {
tools: 24,
users: 100000,
rating: 4.9
},
isOpenSource: true,
lastUpdate: "2024-12-01T08:00:00Z"
}
const exampleJson = JSON.stringify(example)
jsonInput.value = exampleJson
formatJson(exampleJson, isCompressed.value)
}
// 重新格式化
const reformat = () => {
if (isLoading.value) {
cancelFormatting()
}
formatJson(jsonInput.value, isCompressed.value)
}
// 历史记录相关函数
const openSaveModal = () => {
savingTitle.value = `JSON ${new Date().toLocaleString()}`
isSaveModalOpen.value = true
}
const closeSaveModal = () => {
isSaveModalOpen.value = false
editingItem.value = null
savingTitle.value = ''
}
const openHistory = () => {
isHistoryOpen.value = true
}
const closeHistory = () => {
isHistoryOpen.value = false
}
const saveToHistory = () => {
if (!jsonOutput.value || !savingTitle.value.trim()) return
if (editingItem.value) {
// 更新现有项目
const updatedItem = {
...editingItem.value,
title: savingTitle.value,
json: jsonOutput.value,
timestamp: Date.now()
}
const index = historyItems.value.findIndex(item => item.id === editingItem.value!.id)
if (index !== -1) {
historyItems.value[index] = updatedItem
}
} else {
// 创建新项目
const newItem: JsonHistoryItem = {
id: Date.now().toString(),
title: savingTitle.value,
json: jsonOutput.value,
timestamp: Date.now()
}
historyItems.value.unshift(newItem)
}
saveHistoryToLocalStorage()
closeSaveModal()
}
const loadFromHistory = (item: JsonHistoryItem) => {
jsonInput.value = item.json
formatJson(item.json, isCompressed.value)
closeHistory()
}
const deleteHistoryItem = (id: string) => {
historyItems.value = historyItems.value.filter(item => item.id !== id)
saveHistoryToLocalStorage()
}
const startEditingTitle = (item: JsonHistoryItem) => {
editingItem.value = item
savingTitle.value = item.title
isSaveModalOpen.value = true
}
const toggleFavorite = (id: string) => {
const item = historyItems.value.find(item => item.id === id)
if (item) {
item.isFavorite = !item.isFavorite
saveHistoryToLocalStorage()
}
}
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
// 简化的JSON树形视图组件
const JsonTreeView = ({ data }: { data: any }) => {
// 这里应该是一个简化的树形视图实现
// 为了保持代码简洁我们暂时返回普通的JSON字符串
return JSON.stringify(data, null, 2)
}
</script>
<style scoped>
.json-viewer {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.6;
}
.history-item {
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid;
border-color: rgba(var(--color-primary), 0.15);
background-color: rgba(var(--color-bg-secondary), 0.5);
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.history-item:hover {
border-color: rgba(var(--color-primary), 0.4);
background-color: rgba(var(--color-bg-secondary), 0.8);
}
</style>

View File

@ -0,0 +1,525 @@
<template>
<div class="space-y-6">
<!-- JWT 输入区域 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-primary">JWT 解码器</h2>
<div class="flex gap-2">
<button @click="loadExample" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'sync']" class="mr-1" />
示例
</button>
<button @click="clearAll" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'eraser']" class="mr-1" />
清空
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">JWT Token</label>
<textarea
v-model="jwtToken"
class="textarea-field h-24 w-full resize-none font-mono text-sm"
placeholder="粘贴你的 JWT token 到这里..."
/>
</div>
<!-- 错误和成功消息 -->
<div v-if="error" class="p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2" />
{{ error }}
</div>
<div v-if="success" class="p-3 bg-green-900/20 border border-green-700/30 text-green-400 rounded-lg">
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
{{ success }}
</div>
</div>
</div>
<!-- JWT 信息展示 -->
<div v-if="decodedJwt" class="space-y-6">
<!-- Token 状态信息 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">Token 状态</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- 过期状态 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">过期状态</div>
<div :class="getExpirationStatusClass()">
{{ getExpirationStatusText() }}
</div>
<div v-if="decodedJwt.expiresIn" class="text-xs text-tertiary mt-1">
剩余: {{ decodedJwt.expiresIn }}
</div>
</div>
<!-- 算法 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">签名算法</div>
<div class="text-sm font-medium text-primary">
{{ decodedJwt.header.alg }}
</div>
</div>
<!-- 类型 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">Token 类型</div>
<div class="text-sm font-medium text-primary">
{{ decodedJwt.header.typ }}
</div>
</div>
</div>
</div>
<!-- Token 可视化 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">Token 结构</h3>
<button
@click="showTokenParts = !showTokenParts"
class="btn-secondary text-sm"
>
{{ showTokenParts ? '隐藏结构' : '显示结构' }}
</button>
</div>
<div v-if="showTokenParts" class="space-y-3">
<div class="text-xs text-tertiary">JWT由三部分组成"."分隔</div>
<div class="grid grid-cols-1 gap-2 font-mono text-xs">
<div class="flex items-center gap-2">
<span class="bg-blue-500 text-white px-2 py-1 rounded">Header</span>
<span class="text-secondary break-all">{{ getTokenParts().header }}</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-purple-500 text-white px-2 py-1 rounded">Payload</span>
<span class="text-secondary break-all">{{ getTokenParts().payload }}</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-green-500 text-white px-2 py-1 rounded">Signature</span>
<span class="text-secondary break-all">{{ getTokenParts().signature }}</span>
</div>
</div>
</div>
</div>
<!-- JWT 内容标签页 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex border-b border-gray-200">
<button
v-for="tab in tabs"
:key="tab"
:class="[
'px-4 py-2 font-medium text-sm transition-colors',
activeTab === tab
? 'text-primary border-b-2 border-primary'
: 'text-tertiary hover:text-secondary'
]"
@click="activeTab = tab"
>
{{ getTabLabel(tab) }}
</button>
</div>
<button
@click="copyToClipboard"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<!-- 内容显示 -->
<div class="bg-block border border-gray-200 rounded-lg">
<pre class="h-80 p-4 overflow-auto text-sm font-mono whitespace-pre-wrap">{{ getCurrentTabContent() }}</pre>
</div>
</div>
<!-- Payload 关键信息 -->
<div v-if="activeTab === 'payload'" class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">Payload 关键信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 标准声明 -->
<div v-if="hasStandardClaims()" class="space-y-3">
<h4 class="text-sm font-medium text-secondary">标准声明</h4>
<div v-if="decodedJwt.payload.sub" class="bg-block rounded-lg p-3">
<div class="text-xs text-tertiary">Subject (sub)</div>
<div class="text-sm text-primary font-mono">{{ decodedJwt.payload.sub }}</div>
</div>
<div v-if="decodedJwt.payload.iat" class="bg-block rounded-lg p-3">
<div class="text-xs text-tertiary">Issued At (iat)</div>
<div class="text-sm text-primary">{{ formatTimestamp(decodedJwt.payload.iat) }}</div>
</div>
<div v-if="decodedJwt.payload.exp" class="bg-block rounded-lg p-3">
<div class="text-xs text-tertiary">Expiration (exp)</div>
<div class="text-sm text-primary">{{ formatTimestamp(decodedJwt.payload.exp) }}</div>
</div>
</div>
<!-- 自定义声明 -->
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary">自定义声明</h4>
<div class="space-y-2">
<div
v-for="[key, value] in getCustomClaims()"
:key="key"
class="bg-block rounded-lg p-3"
>
<div class="text-xs text-tertiary">{{ key }}</div>
<div class="text-sm text-primary font-mono break-all">
{{ formatValue(value) }}
</div>
</div>
</div>
<div v-if="getCustomClaims().length === 0" class="text-sm text-tertiary text-center py-4">
暂无自定义声明
</div>
</div>
</div>
</div>
</div>
<!-- 使用说明 -->
<div v-if="!decodedJwt" class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
使用说明
</h3>
<div class="text-sm text-secondary space-y-2">
<p> JWTJSON Web Token是一种开放标准RFC 7519用于在各方之间安全地传输信息</p>
<p> JWT由三部分组成Header头部Payload载荷和Signature签名</p>
<p> 这个工具只解码JWT内容不验证签名的有效性</p>
<p> 请不要在这里输入包含敏感信息的生产环境JWT</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// JWT 结构接口
interface JwtPayload {
exp?: number
iat?: number
sub?: string
[key: string]: unknown
}
interface JwtHeader {
alg: string
typ: string
[key: string]: unknown
}
interface DecodedJwt {
header: JwtHeader
payload: JwtPayload
signature: string
isValid: boolean
expirationStatus: 'valid' | 'expired' | 'not-set'
expiresIn?: string
}
// 响应式状态
const jwtToken = ref('')
const decodedJwt = ref<DecodedJwt | null>(null)
const activeTab = ref<'header' | 'payload' | 'signature'>('payload')
const error = ref('')
const success = ref('')
const copied = ref(false)
const showTokenParts = ref(false)
// 标签页选项
const tabs: ('header' | 'payload' | 'signature')[] = ['header', 'payload', 'signature']
// 标准JWT声明字段
const standardClaims = ['iss', 'sub', 'aud', 'exp', 'nbf', 'iat', 'jti']
// 获取标签页标签
const getTabLabel = (tab: 'header' | 'payload' | 'signature'): string => {
const labels = {
header: 'Header (头部)',
payload: 'Payload (载荷)',
signature: 'Signature (签名)'
}
return labels[tab]
}
// 获取当前标签页内容
const getCurrentTabContent = (): string => {
if (!decodedJwt.value) return ''
switch (activeTab.value) {
case 'header':
return JSON.stringify(decodedJwt.value.header, null, 2)
case 'payload':
return JSON.stringify(decodedJwt.value.payload, null, 2)
case 'signature':
return decodedJwt.value.signature
default:
return ''
}
}
// 获取过期状态样式类
const getExpirationStatusClass = (): string => {
if (!decodedJwt.value) return ''
const status = decodedJwt.value.expirationStatus
if (status === 'valid') return 'px-2 py-1 rounded-md text-xs bg-green-900/20 text-green-400'
if (status === 'expired') return 'px-2 py-1 rounded-md text-xs bg-red-900/20 text-error'
return 'px-2 py-1 rounded-md text-xs bg-gray-500/20 text-tertiary'
}
// 获取过期状态文本
const getExpirationStatusText = (): string => {
if (!decodedJwt.value) return ''
const status = decodedJwt.value.expirationStatus
if (status === 'valid') return '有效'
if (status === 'expired') return '已过期'
return '未设置过期时间'
}
// 检查是否有标准声明
const hasStandardClaims = (): boolean => {
if (!decodedJwt.value?.payload) return false
return standardClaims.some(claim =>
decodedJwt.value?.payload[claim] !== undefined
)
}
// 获取自定义声明
const getCustomClaims = (): [string, unknown][] => {
if (!decodedJwt.value?.payload) return []
return Object.entries(decodedJwt.value.payload).filter(
([key]) => !standardClaims.includes(key)
)
}
// 获取Token各部分
const getTokenParts = () => {
if (!jwtToken.value) return { header: '', payload: '', signature: '' }
const parts = jwtToken.value.split('.')
return {
header: parts[0] || '',
payload: parts[1] || '',
signature: parts[2] || ''
}
}
// 格式化时间戳
const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 格式化值
const formatValue = (value: unknown): string => {
if (typeof value === 'string') return value
if (typeof value === 'number') return value.toString()
if (typeof value === 'boolean') return value.toString()
return JSON.stringify(value)
}
// Base64 URL解码
const base64UrlDecode = (str: string): string => {
// 替换URL安全Base64字符为标准Base64
let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
// 添加填充字符
while (base64.length % 4) {
base64 += '='
}
try {
// 解码
return decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
} catch {
throw new Error('无效的Base64编码')
}
}
// 计算剩余时间
const getTimeRemaining = (expirationDate: Date): string => {
const now = new Date()
const diff = expirationDate.getTime() - now.getTime()
if (diff <= 0) {
return '已过期'
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
let timeStr = ''
if (days > 0) timeStr += `${days}`
if (hours > 0 || days > 0) timeStr += `${hours}小时 `
if (minutes > 0 || hours > 0 || days > 0) timeStr += `${minutes}分钟 `
timeStr += `${seconds}`
return timeStr
}
// 解码JWT令牌
const decodeJwt = (token: string): DecodedJwt => {
if (!token) {
throw new Error('Token为空')
}
const parts = token.split('.')
if (parts.length !== 3) {
throw new Error('无效的JWT格式应包含三个部分')
}
try {
// 解码header和payload
const header = JSON.parse(base64UrlDecode(parts[0])) as JwtHeader
const payload = JSON.parse(base64UrlDecode(parts[1])) as JwtPayload
const signature = parts[2]
// 计算过期状态
let expirationStatus: 'valid' | 'expired' | 'not-set' = 'not-set'
let expiresIn: string | undefined
if (payload.exp) {
const expiration = new Date(payload.exp * 1000)
const now = new Date()
if (expiration > now) {
expirationStatus = 'valid'
expiresIn = getTimeRemaining(expiration)
} else {
expirationStatus = 'expired'
}
}
// 简单验证
const isValid = parts.length === 3 && !!parts[2]
return {
header,
payload,
signature,
isValid,
expirationStatus,
expiresIn
}
} catch {
throw new Error('解析失败无效的JWT内容')
}
}
// 复制当前标签内容到剪贴板
const copyToClipboard = async () => {
if (!decodedJwt.value) return
try {
const content = getCurrentTabContent()
await navigator.clipboard.writeText(content)
copied.value = true
success.value = '复制成功'
setTimeout(() => {
copied.value = false
success.value = ''
}, 2000)
} catch (err) {
console.error('复制失败:', err)
error.value = '复制失败'
}
}
// 清空所有内容
const clearAll = () => {
jwtToken.value = ''
decodedJwt.value = null
error.value = ''
success.value = ''
copied.value = false
showTokenParts.value = false
}
// 加载示例JWT
const loadExample = () => {
// 创建一个示例JWT不包含敏感信息
const header = { alg: 'HS256', typ: 'JWT' }
const payload = {
sub: 'user123',
name: 'John Doe',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1小时后过期
role: 'admin'
}
// 简单的Base64URL编码
const base64UrlEncode = (obj: object): string => {
return btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
const encodedHeader = base64UrlEncode(header)
const encodedPayload = base64UrlEncode(payload)
const signature = 'example-signature-not-real'
jwtToken.value = `${encodedHeader}.${encodedPayload}.${signature}`
success.value = '示例JWT已加载'
setTimeout(() => {
success.value = ''
}, 2000)
}
// 监听JWT变化并解析
watch(jwtToken, (newToken) => {
if (!newToken.trim()) {
decodedJwt.value = null
error.value = ''
return
}
try {
const result = decodeJwt(newToken)
decodedJwt.value = result
error.value = ''
} catch (err) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = '解析错误'
}
decodedJwt.value = null
}
})
</script>

View File

@ -0,0 +1,464 @@
<template>
<div class="space-y-6">
<!-- 进制选择 -->
<div class="card p-6">
<h2 class="text-lg font-medium text-primary mb-4">数字进制转换器</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 源进制选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">从进制</label>
<div class="grid grid-cols-2 gap-2 mb-3">
<button
v-for="base in baseOptions"
:key="'from-' + base.id"
:class="[
'px-3 py-2 text-sm rounded transition-all',
fromBase === base.id ? 'bg-primary text-white' : 'btn-secondary'
]"
@click="setFromBase(base.id)"
>
{{ base.name }}
</button>
</div>
<!-- 自定义进制输入 -->
<div v-if="fromBase === 'custom'" class="flex items-center gap-2">
<span class="text-sm text-secondary">自定义进制:</span>
<input
v-model.number="customFromBase"
type="number"
min="2"
max="36"
class="w-20 p-1 bg-block border border-primary/20 rounded text-center input-field"
/>
</div>
</div>
<!-- 目标进制选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">到进制</label>
<div class="grid grid-cols-2 gap-2 mb-3">
<button
v-for="base in baseOptions"
:key="'to-' + base.id"
:class="[
'px-3 py-2 text-sm rounded transition-all',
toBase === base.id ? 'bg-primary text-white' : 'btn-secondary'
]"
@click="setToBase(base.id)"
>
{{ base.name }}
</button>
</div>
<!-- 自定义进制输入 -->
<div v-if="toBase === 'custom'" class="flex items-center gap-2">
<span class="text-sm text-secondary">自定义进制:</span>
<input
v-model.number="customToBase"
type="number"
min="2"
max="36"
class="w-20 p-1 bg-block border border-primary/20 rounded text-center input-field"
/>
</div>
</div>
</div>
</div>
<!-- 输入和输出区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">输入数字</h3>
<div class="flex gap-2">
<button @click="loadExample" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'sync-alt']" class="mr-1" />
示例
</button>
<button @click="clearAll" class="btn-secondary text-sm">
<FontAwesomeIcon :icon="['fas', 'eraser']" class="mr-1" />
清空
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ getBaseLabel(getCurrentFromBase()) }} 数字
</label>
<textarea
v-model="inputValue"
class="textarea-field h-32 w-full resize-y font-mono"
:placeholder="getInputPlaceholder()"
/>
</div>
<!-- 错误消息 -->
<div v-if="error" class="p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="mr-2" />
{{ error }}
</div>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">转换结果</h3>
<button
v-if="outputValue"
@click="copyToClipboard"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary font-medium mb-2">
{{ getBaseLabel(getCurrentToBase()) }} 数字
</label>
<textarea
v-model="outputValue"
readonly
class="textarea-field h-32 w-full resize-y font-mono bg-block"
placeholder="转换结果将在这里显示..."
/>
</div>
<!-- 转换信息 -->
<div v-if="outputValue" class="text-sm text-tertiary">
<div>十进制值: {{ getDecimalValue() }}</div>
<div>字符长度: {{ outputValue.length }}</div>
</div>
</div>
</div>
</div>
<!-- 高级选项 -->
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-md font-medium text-primary">高级选项</h3>
<button
@click="showAdvancedOptions = !showAdvancedOptions"
class="btn-secondary text-sm"
>
<FontAwesomeIcon :icon="['fas', 'cog']" class="mr-1" />
{{ showAdvancedOptions ? '隐藏选项' : '显示选项' }}
</button>
</div>
<div v-if="showAdvancedOptions" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="flex items-center">
<input
v-model="useUppercase"
type="checkbox"
class="w-4 h-4 text-primary bg-block rounded border-primary/20 focus:ring-primary focus:ring-opacity-25"
/>
<span class="ml-2 text-sm text-secondary">使用大写字母</span>
</label>
<label class="flex items-center">
<input
v-model="addPrefix"
type="checkbox"
class="w-4 h-4 text-primary bg-block rounded border-primary/20 focus:ring-primary focus:ring-opacity-25"
/>
<span class="ml-2 text-sm text-secondary">添加进制前缀</span>
</label>
<label class="flex items-center">
<input
v-model="groupDigits"
type="checkbox"
class="w-4 h-4 text-primary bg-block rounded border-primary/20 focus:ring-primary focus:ring-opacity-25"
/>
<span class="ml-2 text-sm text-secondary">数字分组</span>
</label>
</div>
</div>
<!-- 快速转换面板 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">快速转换</h3>
<div v-if="inputValue && !error" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div
v-for="quickBase in quickBases"
:key="quickBase.id"
class="bg-block rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-secondary">{{ quickBase.name }}</h4>
<button
@click="() => copyQuickResult(quickBase.id)"
class="text-tertiary hover:text-primary transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
</div>
<code class="text-sm text-primary font-mono break-all">
{{ getQuickConversion(quickBase.id) }}
</code>
</div>
</div>
<div v-else class="text-center py-8 text-tertiary">
请输入有效数字以查看快速转换结果
</div>
</div>
<!-- 使用说明 -->
<div class="card p-6">
<h3 class="text-md font-medium text-primary mb-4">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
使用说明
</h3>
<div class="text-sm text-secondary space-y-2">
<p> 支持 2-36 进制之间的任意转换</p>
<p> 可以输入带前缀的数字 0x, 0b, 0o</p>
<p> 支持大写/小写字母添加前缀数字分组等格式选项</p>
<p> 十六进制以上的进制使用字母 A-Z 表示 10-35</p>
<p> 输入时可以使用空格或下划线分隔系统会自动忽略</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// 响应式状态
const inputValue = ref('')
const outputValue = ref('')
const fromBase = ref('10')
const toBase = ref('2')
const customFromBase = ref(10)
const customToBase = ref(2)
const error = ref('')
const copied = ref(false)
const showAdvancedOptions = ref(false)
const useUppercase = ref(true)
const addPrefix = ref(false)
const groupDigits = ref(false)
// 进制选项
const baseOptions = [
{ id: '2', name: '二进制' },
{ id: '8', name: '八进制' },
{ id: '10', name: '十进制' },
{ id: '16', name: '十六进制' },
{ id: 'custom', name: '自定义' }
]
// 快速转换进制
const quickBases = [
{ id: '2', name: '二进制' },
{ id: '8', name: '八进制' },
{ id: '10', name: '十进制' },
{ id: '16', name: '十六进制' }
]
// 获取当前源进制
const getCurrentFromBase = (): number => {
return fromBase.value === 'custom' ? customFromBase.value : parseInt(fromBase.value)
}
// 获取当前目标进制
const getCurrentToBase = (): number => {
return toBase.value === 'custom' ? customToBase.value : parseInt(toBase.value)
}
// 获取进制标签
const getBaseLabel = (base: number): string => {
const labels: Record<number, string> = {
2: '二进制',
8: '八进制',
10: '十进制',
16: '十六进制'
}
return labels[base] || `${base}进制`
}
// 获取输入提示
const getInputPlaceholder = (): string => {
const base = getCurrentFromBase()
if (base === 2) return '例如: 1010, 0b1010'
if (base === 8) return '例如: 755, 0o755'
if (base === 10) return '例如: 42, 255'
if (base === 16) return '例如: FF, 0xFF'
return `请输入${base}进制数字...`
}
// 获取十进制值
const getDecimalValue = (): string => {
if (!inputValue.value.trim() || error.value) return '-'
try {
const cleanValue = inputValue.value.replace(/^0[bxo]|[\s_]/gi, '')
const decimal = parseInt(cleanValue, getCurrentFromBase())
return isNaN(decimal) ? '-' : decimal.toString()
} catch {
return '-'
}
}
// 进制转换函数
const convertBase = (value: string, from: number, to: number): string => {
// 验证进制范围
if (from < 2 || from > 36 || to < 2 || to > 36) {
throw new Error('进制范围必须在 2-36 之间')
}
// 移除输入中可能存在的前缀和格式化字符
const cleanValue = value.replace(/^0[bxo]|[\s_]/gi, '')
// 转换为十进制
let decimalValue
try {
decimalValue = parseInt(cleanValue, from)
if (isNaN(decimalValue)) {
throw new Error()
}
} catch {
throw new Error('输入的数字格式无效')
}
// 转换为目标进制
let result = decimalValue.toString(to)
// 大写字母
if (useUppercase.value && to > 10) {
result = result.toUpperCase()
}
// 添加前缀
if (addPrefix.value) {
if (to === 2) result = '0b' + result
else if (to === 8) result = '0o' + result
else if (to === 16) result = '0x' + result
}
// 数字分组
if (groupDigits.value) {
const prefix = result.match(/^0[bxo]/i)?.[0] || ''
const digits = result.replace(/^0[bxo]/i, '')
let grouped = ''
if (to === 2) {
// 二进制每8位分组
grouped = digits.match(/.{1,8}/g)?.join('_') || digits
} else if (to === 16) {
// 十六进制每4位分组
grouped = digits.match(/.{1,4}/g)?.join('_') || digits
} else {
// 其他进制每4位分组
grouped = digits.match(/.{1,4}/g)?.join('_') || digits
}
result = prefix + grouped
}
return result
}
// 获取快速转换结果
const getQuickConversion = (baseId: string): string => {
if (!inputValue.value.trim() || error.value) return '-'
try {
return convertBase(inputValue.value, getCurrentFromBase(), parseInt(baseId))
} catch {
return '错误'
}
}
// 设置源进制
const setFromBase = (base: string) => {
fromBase.value = base
}
// 设置目标进制
const setToBase = (base: string) => {
toBase.value = base
}
// 复制输出内容
const copyToClipboard = async () => {
if (!outputValue.value) return
try {
await navigator.clipboard.writeText(outputValue.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('复制失败:', err)
error.value = '复制失败'
}
}
// 复制快速转换结果
const copyQuickResult = async (baseId: string) => {
const result = getQuickConversion(baseId)
if (result === '-' || result === '错误') return
try {
await navigator.clipboard.writeText(result)
} catch (err) {
console.error('复制失败:', err)
}
}
// 清空所有内容
const clearAll = () => {
inputValue.value = ''
outputValue.value = ''
error.value = ''
}
// 加载示例
const loadExample = () => {
const examples: Record<number, string> = {
2: '1010',
8: '755',
10: '42',
16: 'FF'
}
const currentFromBase = getCurrentFromBase()
const example = examples[currentFromBase] || examples[10]
inputValue.value = example
}
// 监听输入变化并自动转换
watch([inputValue, fromBase, toBase, customFromBase, customToBase, useUppercase, addPrefix, groupDigits], () => {
if (inputValue.value.trim() === '') {
outputValue.value = ''
error.value = ''
return
}
try {
const result = convertBase(inputValue.value, getCurrentFromBase(), getCurrentToBase())
outputValue.value = result
error.value = ''
} catch (err) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = '转换错误'
}
outputValue.value = ''
}
})
</script>

View File

@ -0,0 +1,289 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="generateQRCode"
:disabled="!qrText.trim() || isGenerating"
class="btn-primary"
>
<FontAwesomeIcon
:icon="isGenerating ? ['fas', 'spinner'] : ['fas', 'qrcode']"
:class="['mr-2', isGenerating && 'animate-spin']"
/>
{{ t('tools.qrcode_generator.generate') }}
</button>
<button
@click="downloadQRCode"
:disabled="!qrCodeDataUrl"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
{{ t('tools.qrcode_generator.download') }}
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.qrcode_generator.clear') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入配置区域 -->
<div class="space-y-6">
<!-- 文本输入 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.qrcode_generator.text_input') }}</h3>
<textarea
v-model="qrText"
:placeholder="t('tools.qrcode_generator.placeholder')"
class="textarea-field h-32"
@input="handleTextChange"
/>
<div class="text-sm text-secondary mt-2">
字符数: {{ qrText.length }}
</div>
</div>
<!-- 配置选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.qrcode_generator.settings') }}</h3>
<div class="space-y-4">
<!-- 尺寸 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.size') }}: {{ qrSize }}px
</label>
<input
v-model="qrSize"
type="range"
min="100"
max="800"
step="50"
class="w-full"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>100px</span>
<span>800px</span>
</div>
</div>
<!-- 容错级别 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.error_level') }}
</label>
<select v-model="errorLevel" class="select-field">
<option value="L"> (L) - 7%</option>
<option value="M"> (M) - 15%</option>
<option value="Q">中高 (Q) - 25%</option>
<option value="H"> (H) - 30%</option>
</select>
</div>
<!-- 前景色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.foreground_color') }}
</label>
<div class="flex space-x-2">
<input
v-model="foregroundColor"
type="color"
class="w-12 h-10 rounded border border-primary border-opacity-20"
>
<input
v-model="foregroundColor"
type="text"
class="input-field flex-1"
placeholder="#000000"
>
</div>
</div>
<!-- 背景色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
{{ t('tools.qrcode_generator.background_color') }}
</label>
<div class="flex space-x-2">
<input
v-model="backgroundColor"
type="color"
class="w-12 h-10 rounded border border-primary border-opacity-20"
>
<input
v-model="backgroundColor"
type="text"
class="input-field flex-1"
placeholder="#FFFFFF"
>
</div>
</div>
</div>
</div>
</div>
<!-- 预览区域 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.qrcode_generator.preview') }}</h3>
<div class="flex justify-center items-center min-h-[300px]">
<div v-if="qrCodeDataUrl" class="text-center">
<img
:src="qrCodeDataUrl"
:alt="t('tools.qrcode_generator.qr_code')"
class="mx-auto mb-4 rounded border border-primary border-opacity-20"
:style="{ maxWidth: '100%', height: 'auto' }"
>
<div class="text-sm text-secondary">
{{ qrSize }}x{{ qrSize }}px
</div>
</div>
<div v-else-if="isGenerating" class="text-center">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<div class="text-secondary">{{ t('tools.qrcode_generator.generating') }}</div>
</div>
<div v-else class="text-center">
<FontAwesomeIcon :icon="['fas', 'qrcode']" class="text-6xl text-tertiary mb-4" />
<div class="text-secondary">{{ t('tools.qrcode_generator.no_preview') }}</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
import QRCode from 'qrcode'
const { t } = useLanguage()
// 响应式状态
const qrText = ref('')
const qrSize = ref(300)
const errorLevel = ref('M')
const foregroundColor = ref('#000000')
const backgroundColor = ref('#FFFFFF')
const qrCodeDataUrl = ref('')
const isGenerating = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 生成二维码
const generateQRCode = async () => {
if (!qrText.value.trim()) {
showStatus('请输入要生成二维码的内容', 'error')
return
}
isGenerating.value = true
statusMessage.value = ''
try {
const options = {
width: qrSize.value,
height: qrSize.value,
errorCorrectionLevel: errorLevel.value as 'L' | 'M' | 'Q' | 'H',
color: {
dark: foregroundColor.value,
light: backgroundColor.value
},
margin: 2
}
const dataUrl = await QRCode.toDataURL(qrText.value, options)
qrCodeDataUrl.value = dataUrl
showStatus('二维码生成成功', 'success')
} catch (error) {
console.error('生成二维码失败:', error)
showStatus('生成二维码失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
qrCodeDataUrl.value = ''
} finally {
isGenerating.value = false
}
}
// 下载二维码
const downloadQRCode = () => {
if (!qrCodeDataUrl.value) return
try {
const link = document.createElement('a')
link.download = `qrcode-${Date.now()}.png`
link.href = qrCodeDataUrl.value
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
showStatus('二维码下载成功', 'success')
} catch (error) {
console.error('下载失败:', error)
showStatus('下载失败', 'error')
}
}
// 清除所有内容
const clearAll = () => {
qrText.value = ''
qrCodeDataUrl.value = ''
statusMessage.value = ''
}
// 处理文本变化
const handleTextChange = () => {
// 文本变化时清除状态
if (statusMessage.value) {
statusMessage.value = ''
}
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 防抖重新生成
let regenerateTimer: number | null = null
const debouncedRegenerate = () => {
if (regenerateTimer) {
clearTimeout(regenerateTimer)
}
regenerateTimer = window.setTimeout(() => {
if (qrCodeDataUrl.value && qrText.value.trim()) {
generateQRCode()
}
}, 500)
}
// 监听配置变化,自动重新生成
watch([qrSize, errorLevel, foregroundColor, backgroundColor], debouncedRegenerate)
</script>

View File

@ -0,0 +1,404 @@
<template>
<div class="space-y-6">
<!-- 主内容区 -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- 左侧面板 - 常用示例和选项 -->
<div class="lg:col-span-1 space-y-6">
<!-- 常用示例 -->
<div class="card p-4">
<h2 class="text-md font-medium text-primary mb-4">常用示例</h2>
<div class="space-y-2">
<button
v-for="(example, index) in examples"
:key="index"
class="text-left w-full px-3 py-2 rounded-md text-sm text-secondary hover:bg-hover transition-colors"
@click="() => applyExample(example)"
>
{{ example.name }}
</button>
</div>
</div>
<!-- 正则选项 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">选项</h2>
<button
class="text-tertiary hover:text-error transition-colors text-sm"
@click="clearAll"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-1" />
清空
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-secondary mb-2">标志位</label>
<div class="flex flex-wrap gap-2">
<button
:class="flags.includes('g') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('g')"
>
g (全局)
</button>
<button
:class="flags.includes('i') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('i')"
>
i (忽略大小写)
</button>
<button
:class="flags.includes('m') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('m')"
>
m (多行)
</button>
<button
:class="flags.includes('s') ? 'btn-primary text-xs' : 'btn-secondary text-xs'"
@click="() => toggleFlag('s')"
>
s (单行)
</button>
</div>
</div>
<div>
<label class="flex items-center text-sm text-secondary">
<input
v-model="showGroups"
type="checkbox"
class="mr-2"
/>
显示捕获组
</label>
</div>
</div>
</div>
</div>
<!-- 右侧面板 - 测试区域 -->
<div class="lg:col-span-3 space-y-6">
<!-- 正则表达式输入 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">正则表达式</h2>
<button
class="text-tertiary hover:text-primary transition-colors text-sm"
@click="copyRegex"
:disabled="!regexString"
>
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<div class="relative">
<div class="absolute left-3 top-[13px] text-tertiary">/</div>
<input
v-model="regexString"
type="text"
placeholder="输入正则表达式..."
class="input-field pl-7 pr-14"
/>
<div class="absolute right-14 top-[13px] text-tertiary">/</div>
<input
v-model="flags"
type="text"
placeholder="flags"
class="absolute right-3 top-[13px] w-8 bg-transparent border-none outline-none text-tertiary"
/>
</div>
<div v-if="regexError" class="mt-2 text-sm text-error flex items-center gap-2">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" />
<span>{{ regexError }}</span>
</div>
</div>
<!-- 测试输入 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">测试文本</h2>
<div class="text-sm text-secondary">
字符数: <span class="text-primary">{{ testString.length }}</span>
</div>
</div>
<textarea
v-model="testString"
placeholder="输入要测试的文本..."
class="textarea-field min-h-[150px] w-full resize-y"
/>
</div>
<!-- 匹配结果 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-md font-medium text-primary">匹配结果</h2>
<div class="text-sm text-secondary">
匹配数量: <span class="text-primary">{{ matchCount }}</span>
</div>
</div>
<div v-if="testString" class="space-y-4">
<!-- 高亮显示的匹配文本 -->
<div class="bg-block rounded-md p-4 whitespace-pre-wrap font-mono text-sm">
<div v-if="matchCount > 0">
<div class="mb-3 text-tertiary text-xs flex items-center justify-between">
<span>
找到 <span class="text-primary font-medium">{{ matchCount }}</span> 个匹配项
</span>
<span class="text-tertiary text-xs">
原文长度: {{ testString.length }} 字符
</span>
</div>
<!-- 使用 v-html 显示高亮结果但要确保安全 -->
<div v-html="highlightedText" class="break-all"></div>
</div>
<span v-else class="text-tertiary">无匹配项</span>
</div>
<!-- 捕获组详情 -->
<div v-if="showGroups && matchCount > 0">
<h3 class="text-sm font-medium text-primary mb-2">捕获组详情</h3>
<div class="space-y-2">
<div
v-for="(match, index) in matches"
:key="index"
class="bg-block rounded-md p-3"
>
<div class="text-xs text-tertiary mb-2">
匹配 #{{ index + 1 }} (位置: {{ match.index }})
</div>
<div class="space-y-1">
<div class="flex items-start gap-2">
<span class="text-xs text-tertiary min-w-[40px]">完整:</span>
<code class="text-sm text-primary bg-primary/10 px-1 rounded break-all">
{{ match[0] || '' }}
</code>
</div>
<div
v-for="group in Math.max(0, match.length - 1)"
:key="group"
class="flex items-start gap-2"
>
<span class="text-xs text-tertiary min-w-[40px]"> {{ group }}:</span>
<code class="text-sm text-primary bg-primary/10 px-1 rounded break-all">
{{ match[group] || '(空)' }}
</code>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex items-center justify-center p-4 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'info-circle']" class="mr-2" />
请输入测试文本
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// 定义匹配结果类型
interface MatchResult extends RegExpExecArray {
index: number
}
// 响应式状态
const regexString = ref('')
const flags = ref('g')
const testString = ref('')
const matches = ref<MatchResult[]>([])
const matchCount = ref(0)
const showGroups = ref(true)
const regexError = ref<string | null>(null)
const copied = ref(false)
// 常用正则表达式示例
const examples = [
{
name: '邮箱地址',
pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}',
flags: 'g',
testText: 'test@example.com, invalid-email, another.email@domain.co.uk'
},
{
name: '手机号码',
pattern: '1[3-9]\\d{9}',
flags: 'g',
testText: '我的手机号是13812345678她的是15987654321座机010-12345678'
},
{
name: 'URL地址',
pattern: 'https?://[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)',
flags: 'g',
testText: '访问 https://www.example.com 或 http://test.org/path?query=1'
},
{
name: 'IP地址',
pattern: '\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b',
flags: 'g',
testText: '服务器IP: 192.168.1.1, 公网IP: 8.8.8.8, 错误格式: 999.999.999.999'
},
{
name: '中文字符',
pattern: '[\\u4e00-\\u9fa5]',
flags: 'g',
testText: 'Hello 世界! This is 中文 mixed with English.'
}
]
// HTML转义函数
const escapeHtml = (text: string): string => {
if (!text) return ''
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// 计算高亮文本
const highlightedText = computed(() => {
if (!testString.value || matchCount.value === 0) {
return escapeHtml(testString.value)
}
let result = ''
let lastIndex = 0
// 按索引顺序排序匹配项
const sortedMatches = [...matches.value].sort((a, b) => a.index - b.index)
// 遍历每个匹配项
sortedMatches.forEach(match => {
// 添加匹配前的文本
result += escapeHtml(testString.value.substring(lastIndex, match.index))
// 添加高亮的匹配内容
result += `<span style="background-color:rgba(var(--color-primary), 0.3); color:rgb(var(--color-primary)); font-weight:bold; padding:0 4px; border-radius:3px;">${escapeHtml(match[0])}</span>`
// 更新lastIndex
lastIndex = match.index + match[0].length
})
// 添加最后一个匹配后的文本
if (lastIndex < testString.value.length) {
result += escapeHtml(testString.value.substring(lastIndex))
}
return result
})
// 测试正则表达式
const testRegex = () => {
if (!regexString.value || !testString.value) {
matches.value = []
matchCount.value = 0
regexError.value = null
return
}
try {
// 验证正则表达式是否有效
new RegExp(regexString.value, flags.value)
regexError.value = null
if (flags.value.includes('g')) {
// 获取所有匹配
const allMatches: MatchResult[] = []
let match: RegExpExecArray | null
const regexWithGroups = new RegExp(regexString.value, flags.value)
// 收集所有匹配和捕获组
while ((match = regexWithGroups.exec(testString.value)) !== null) {
allMatches.push(match as MatchResult)
// 防止无限循环如果匹配长度为0手动增加索引
if (match.index === regexWithGroups.lastIndex) {
regexWithGroups.lastIndex++
}
}
matches.value = allMatches
matchCount.value = allMatches.length
} else {
// 单次匹配模式
const regexWithoutG = new RegExp(regexString.value, flags.value.replace('g', ''))
const execMatch = regexWithoutG.exec(testString.value)
if (execMatch) {
matches.value = [execMatch as MatchResult]
matchCount.value = 1
} else {
matches.value = []
matchCount.value = 0
}
}
} catch (error) {
console.error('正则表达式错误:', error)
regexError.value = (error as Error).message
matches.value = []
matchCount.value = 0
}
}
// 复制正则表达式
const copyRegex = async () => {
if (!regexString.value) return
try {
const regexText = `/${regexString.value}/${flags.value}`
await navigator.clipboard.writeText(regexText)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 应用示例
const applyExample = (example: { pattern: string; flags: string; testText: string }) => {
regexString.value = example.pattern
flags.value = example.flags
testString.value = example.testText
}
// 清空所有内容
const clearAll = () => {
regexString.value = ''
flags.value = 'g'
testString.value = ''
matches.value = []
matchCount.value = 0
regexError.value = null
}
// 切换标志位
const toggleFlag = (flag: string) => {
if (flags.value.includes(flag)) {
flags.value = flags.value.replace(flag, '')
} else {
flags.value = flags.value + flag
}
}
// 监听输入变化,自动测试
watch([regexString, flags, testString, showGroups], () => {
testRegex()
}, { immediate: true })
</script>

View File

@ -0,0 +1,299 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="clearText"
:disabled="!text.trim()"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
{{ t('tools.text_counter.clear') }}
</button>
<button
@click="pasteFromClipboard"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" class="mr-2" />
{{ t('tools.text_counter.paste') }}
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-alt']" class="mr-2" />
{{ t('tools.text_counter.sample') }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 文本输入区域 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.text_input') }}</h3>
<textarea
v-model="text"
:placeholder="t('tools.text_counter.placeholder')"
class="textarea-field"
style="height: 400px; resize: vertical;"
@input="handleTextChange"
/>
</div>
<!-- 统计结果区域 -->
<div class="space-y-4">
<!-- 基础统计 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.basic_stats') }}</h3>
<div class="grid grid-cols-2 gap-4">
<div class="stat-item">
<div class="stat-value">{{ stats.characters }}</div>
<div class="stat-label">{{ t('tools.text_counter.characters') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.charactersNoSpaces }}</div>
<div class="stat-label">{{ t('tools.text_counter.characters_no_spaces') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.words }}</div>
<div class="stat-label">{{ t('tools.text_counter.words') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.lines }}</div>
<div class="stat-label">{{ t('tools.text_counter.lines') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.paragraphs }}</div>
<div class="stat-label">{{ t('tools.text_counter.paragraphs') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.sentences }}</div>
<div class="stat-label">{{ t('tools.text_counter.sentences') }}</div>
</div>
</div>
</div>
<!-- 字符类型统计 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.character_types') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.letters') }}:</span>
<span class="text-primary font-medium">{{ stats.letters }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.numbers') }}:</span>
<span class="text-primary font-medium">{{ stats.numbers }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.spaces') }}:</span>
<span class="text-primary font-medium">{{ stats.spaces }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.punctuation') }}:</span>
<span class="text-primary font-medium">{{ stats.punctuation }}</span>
</div>
</div>
</div>
<!-- 阅读时间估算 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.reading_time') }}</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.slow_reading') }} (200 WPM):</span>
<span class="text-primary font-medium">{{ readingTime.slow }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.normal_reading') }} (250 WPM):</span>
<span class="text-primary font-medium">{{ readingTime.normal }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">{{ t('tools.text_counter.fast_reading') }} (300 WPM):</span>
<span class="text-primary font-medium">{{ readingTime.fast }}</span>
</div>
</div>
</div>
<!-- 最常用单词 -->
<div v-if="topWords.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.text_counter.top_words') }}</h3>
<div class="space-y-2">
<div
v-for="(word, index) in topWords.slice(0, 10)"
:key="word.word"
class="flex justify-between items-center"
>
<span class="text-secondary">
{{ index + 1 }}. {{ word.word }}
</span>
<span class="text-primary font-medium">{{ word.count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const text = ref('')
// 基础统计计算
const stats = computed(() => {
const content = text.value
// 字符数
const characters = content.length
// 不含空格的字符数
const charactersNoSpaces = content.replace(/\s/g, '').length
// 单词数
const words = content.trim() === '' ? 0 : content.trim().split(/\s+/).length
// 行数
const lines = content === '' ? 0 : content.split('\n').length
// 段落数
const paragraphs = content.trim() === '' ? 0 :
content.trim().split(/\n\s*\n/).filter(p => p.trim() !== '').length
// 句子数
const sentences = content.trim() === '' ? 0 :
content.split(/[.!?]+/).filter(s => s.trim() !== '').length
// 字母数
const letters = (content.match(/[a-zA-Z\u4e00-\u9fa5]/g) || []).length
// 数字数
const numbers = (content.match(/\d/g) || []).length
// 空格数
const spaces = (content.match(/\s/g) || []).length
// 标点符号数
const punctuation = (content.match(/[^\w\s\u4e00-\u9fa5]/g) || []).length
return {
characters,
charactersNoSpaces,
words,
lines,
paragraphs,
sentences,
letters,
numbers,
spaces,
punctuation
}
})
// 阅读时间估算
const readingTime = computed(() => {
const words = stats.value.words
const formatTime = (minutes: number) => {
if (minutes < 1) {
return '< 1 分钟'
} else if (minutes < 60) {
return `${Math.round(minutes)} 分钟`
} else {
const hours = Math.floor(minutes / 60)
const mins = Math.round(minutes % 60)
return `${hours} 小时 ${mins} 分钟`
}
}
return {
slow: formatTime(words / 200),
normal: formatTime(words / 250),
fast: formatTime(words / 300)
}
})
// 最常用单词
const topWords = computed(() => {
if (!text.value.trim()) return []
// 提取单词并统计频率
const words = text.value
.toLowerCase()
.replace(/[^\w\s\u4e00-\u9fa5]/g, '')
.split(/\s+/)
.filter(word => word.length > 2) // 过滤掉过短的单词
const wordCount = new Map<string, number>()
words.forEach(word => {
wordCount.set(word, (wordCount.get(word) || 0) + 1)
})
// 转换为数组并排序
return Array.from(wordCount.entries())
.map(([word, count]) => ({ word, count }))
.sort((a, b) => b.count - a.count)
})
// 清除文本
const clearText = () => {
text.value = ''
}
// 从剪贴板粘贴
const pasteFromClipboard = async () => {
try {
const clipText = await navigator.clipboard.readText()
text.value = clipText
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 加载示例文本
const loadSample = () => {
text.value = `这是一个文本统计工具的示例文本。
它可以帮助您统计文本的各种信息,包括字符数、单词数、行数等。
这个工具支持中文和英文文本的统计。它会计算:
- 总字符数和不含空格的字符数
- 单词数和行数
- 段落数和句子数
- 不同类型字符的统计
- 预估的阅读时间
您可以将任何文本粘贴到输入框中,工具会实时更新统计结果。
这对于写作、编辑和内容创作非常有用。
This is a bilingual text counter tool. It supports both Chinese and English text analysis.
The tool provides comprehensive statistics including character count, word count, reading time estimation, and more.`
}
// 处理文本变化
const handleTextChange = () => {
// 可以在这里添加实时处理逻辑
}
</script>
<style scoped>
.stat-item {
@apply text-center p-3 bg-block rounded-lg;
}
.stat-value {
@apply text-2xl font-bold text-primary;
}
.stat-label {
@apply text-sm text-secondary mt-1;
}
</style>

View File

@ -0,0 +1,711 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="processText"
:disabled="!inputText.trim()"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'magic']" class="mr-2" />
处理文本
</button>
<button
@click="copyResult"
:disabled="!outputText"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制结果
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="swapContent"
:disabled="!outputText"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'exchange-alt']" class="mr-2" />
交换内容
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<!-- 输入文本 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">输入文本</h3>
<textarea
v-model="inputText"
placeholder="请输入需要处理的文本..."
class="textarea-field h-80 font-mono text-sm"
@input="updateStats"
/>
<!-- 输入统计 -->
<div class="flex justify-between items-center mt-2 text-sm text-secondary">
<span>{{ inputStats.chars }} 字符 | {{ inputStats.lines }} </span>
<span>{{ inputStats.spaces }} 空格 | {{ inputStats.tabs }} 制表符</span>
</div>
</div>
<!-- 处理选项 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理选项</h3>
<div class="space-y-4">
<!-- 空格处理 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">空格处理</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeLeadingSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除行首空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeTrailingSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除行尾空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeAllSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除所有空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.collapseSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">合并连续空格</span>
</label>
</div>
</div>
<!-- 制表符处理 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">制表符处理</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeTabs"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除制表符</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.tabsToSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">制表符转空格</span>
</label>
<div v-if="options.tabsToSpaces" class="ml-6">
<label class="block text-xs text-tertiary mb-1">空格数量</label>
<input
v-model.number="options.tabSize"
type="number"
min="1"
max="8"
class="input-field w-20 text-sm"
>
</div>
</div>
</div>
<!-- 换行处理 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">换行处理</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeEmptyLines"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除空行</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.collapseEmptyLines"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">合并连续空行</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.normalizeLineEndings"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">统一换行符</span>
</label>
</div>
</div>
<!-- 其他选项 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">其他选项</label>
<div class="space-y-2">
<label class="flex items-center space-x-2">
<input
v-model="options.removeNonBreakingSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除不间断空格 (&nbsp;)</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.removeZeroWidthSpaces"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">移除零宽空格</span>
</label>
<label class="flex items-center space-x-2">
<input
v-model="options.trimLines"
type="checkbox"
class="form-checkbox"
>
<span class="text-secondary">修剪每行首尾空白</span>
</label>
</div>
</div>
</div>
</div>
<!-- 快速预设 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速预设</h3>
<div class="grid grid-cols-1 gap-2">
<button
v-for="preset in presets"
:key="preset.name"
@click="applyPreset(preset)"
class="text-left p-3 rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
<div class="font-medium">{{ preset.name }}</div>
<div class="text-sm text-tertiary">{{ preset.description }}</div>
</button>
</div>
</div>
</div>
<!-- 输出区域 -->
<div class="space-y-4">
<!-- 输出文本 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理结果</h3>
<textarea
v-model="outputText"
readonly
placeholder="处理结果将显示在这里..."
class="textarea-field h-80 font-mono text-sm bg-block"
/>
<!-- 输出统计 -->
<div class="flex justify-between items-center mt-2 text-sm text-secondary">
<span>{{ outputStats.chars }} 字符 | {{ outputStats.lines }} </span>
<span>{{ outputStats.spaces }} 空格 | {{ outputStats.tabs }} 制表符</span>
</div>
</div>
<!-- 处理统计 -->
<div v-if="processStats" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理统计</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary">字符变化:</span>
<span :class="processStats.charsDiff >= 0 ? 'text-error' : 'text-success'">
{{ processStats.charsDiff > 0 ? '+' : '' }}{{ processStats.charsDiff }}
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">行数变化:</span>
<span :class="processStats.linesDiff >= 0 ? 'text-error' : 'text-success'">
{{ processStats.linesDiff > 0 ? '+' : '' }}{{ processStats.linesDiff }}
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">空格移除:</span>
<span class="text-success">{{ processStats.spacesRemoved }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">制表符移除:</span>
<span class="text-success">{{ processStats.tabsRemoved }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">空行移除:</span>
<span class="text-success">{{ processStats.emptyLinesRemoved }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">压缩率:</span>
<span class="text-primary">{{ processStats.compressionRatio }}%</span>
</div>
</div>
</div>
<!-- 字符分析 -->
<div v-if="charAnalysis" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">字符分析</h3>
<div class="space-y-3">
<!-- 可见字符 -->
<div>
<div class="text-sm font-medium text-secondary mb-1">可见字符分布</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex justify-between">
<span class="text-tertiary">字母:</span>
<span class="text-primary">{{ charAnalysis.letters }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">数字:</span>
<span class="text-primary">{{ charAnalysis.numbers }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">标点:</span>
<span class="text-primary">{{ charAnalysis.punctuation }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">符号:</span>
<span class="text-primary">{{ charAnalysis.symbols }}</span>
</div>
</div>
</div>
<!-- 空白字符 -->
<div>
<div class="text-sm font-medium text-secondary mb-1">空白字符</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex justify-between">
<span class="text-tertiary">普通空格:</span>
<span class="text-primary">{{ charAnalysis.spaces }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">制表符:</span>
<span class="text-primary">{{ charAnalysis.tabs }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">换行符:</span>
<span class="text-primary">{{ charAnalysis.newlines }}</span>
</div>
<div class="flex justify-between">
<span class="text-tertiary">不间断空格:</span>
<span class="text-primary">{{ charAnalysis.nbspaces }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 处理日志 -->
<div v-if="processLog.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理日志</h3>
<div class="space-y-1 max-h-40 overflow-y-auto">
<div
v-for="(log, index) in processLog"
:key="index"
class="text-sm text-secondary"
>
{{ log }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const processLog = ref<string[]>([])
// 处理选项
const options = ref({
removeLeadingSpaces: false,
removeTrailingSpaces: false,
removeAllSpaces: false,
collapseSpaces: false,
removeTabs: false,
tabsToSpaces: false,
tabSize: 4,
removeEmptyLines: false,
collapseEmptyLines: false,
normalizeLineEndings: false,
removeNonBreakingSpaces: false,
removeZeroWidthSpaces: false,
trimLines: false
})
// 预设配置
const presets = [
{
name: '基本清理',
description: '移除行首尾空格,合并连续空格',
options: {
removeLeadingSpaces: false,
removeTrailingSpaces: true,
trimLines: true,
collapseSpaces: true,
collapseEmptyLines: true
}
},
{
name: '代码格式化',
description: '制表符转空格,统一缩进',
options: {
tabsToSpaces: true,
tabSize: 4,
normalizeLineEndings: true,
removeTrailingSpaces: true
}
},
{
name: '完全清理',
description: '移除所有不必要的空白字符',
options: {
removeAllSpaces: true,
removeTabs: true,
removeEmptyLines: true,
removeNonBreakingSpaces: true,
removeZeroWidthSpaces: true
}
},
{
name: '最小化',
description: '压缩到最小体积',
options: {
removeAllSpaces: true,
removeTabs: true,
removeEmptyLines: true,
removeNonBreakingSpaces: true,
removeZeroWidthSpaces: true,
normalizeLineEndings: true
}
}
]
// 计算统计信息
const inputStats = computed(() => {
return calculateStats(inputText.value)
})
const outputStats = computed(() => {
return calculateStats(outputText.value)
})
const processStats = computed(() => {
if (!outputText.value) return null
const input = inputStats.value
const output = outputStats.value
const charsDiff = output.chars - input.chars
const linesDiff = output.lines - input.lines
const spacesRemoved = input.spaces - output.spaces
const tabsRemoved = input.tabs - output.tabs
const emptyLinesRemoved = Math.max(0, input.emptyLines - output.emptyLines)
const compressionRatio = input.chars > 0
? Math.round((Math.abs(charsDiff) / input.chars) * 100)
: 0
return {
charsDiff,
linesDiff,
spacesRemoved,
tabsRemoved,
emptyLinesRemoved,
compressionRatio
}
})
const charAnalysis = computed(() => {
if (!inputText.value) return null
const text = inputText.value
let letters = 0, numbers = 0, punctuation = 0, symbols = 0
let spaces = 0, tabs = 0, newlines = 0, nbspaces = 0
for (const char of text) {
if (/[a-zA-Z\u4e00-\u9fff]/.test(char)) {
letters++
} else if (/\d/.test(char)) {
numbers++
} else if (/[.,;:!?]/.test(char)) {
punctuation++
} else if (/[^\s\w]/.test(char)) {
symbols++
} else if (char === ' ') {
spaces++
} else if (char === '\t') {
tabs++
} else if (char === '\n' || char === '\r') {
newlines++
} else if (char === '\u00A0') {
nbspaces++
}
}
return {
letters,
numbers,
punctuation,
symbols,
spaces,
tabs,
newlines,
nbspaces
}
})
// 计算文本统计
const calculateStats = (text: string) => {
const chars = text.length
const lines = text ? text.split('\n').length : 0
const spaces = (text.match(/ /g) || []).length
const tabs = (text.match(/\t/g) || []).length
const emptyLines = text ? text.split('\n').filter(line => line.trim() === '').length : 0
return { chars, lines, spaces, tabs, emptyLines }
}
// 处理文本
const processText = () => {
if (!inputText.value.trim()) return
let result = inputText.value
processLog.value = []
// 移除零宽空格
if (options.value.removeZeroWidthSpaces) {
const before = result.length
result = result.replace(/[\u200B-\u200D\uFEFF]/g, '')
const removed = before - result.length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个零宽空格`)
}
}
// 移除不间断空格
if (options.value.removeNonBreakingSpaces) {
const before = (result.match(/\u00A0/g) || []).length
result = result.replace(/\u00A0/g, ' ')
if (before > 0) {
processLog.value.push(`转换了 ${before} 个不间断空格`)
}
}
// 制表符转空格
if (options.value.tabsToSpaces) {
const before = (result.match(/\t/g) || []).length
const spaces = ' '.repeat(options.value.tabSize)
result = result.replace(/\t/g, spaces)
if (before > 0) {
processLog.value.push(`转换了 ${before} 个制表符为空格`)
}
}
// 移除制表符
if (options.value.removeTabs && !options.value.tabsToSpaces) {
const before = (result.match(/\t/g) || []).length
result = result.replace(/\t/g, '')
if (before > 0) {
processLog.value.push(`移除了 ${before} 个制表符`)
}
}
// 统一换行符
if (options.value.normalizeLineEndings) {
result = result.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
processLog.value.push('统一了换行符为 LF')
}
// 按行处理
let lines = result.split('\n')
// 移除行首空格
if (options.value.removeLeadingSpaces) {
const before = lines.join('').length
lines = lines.map(line => line.replace(/^[ \t]+/, ''))
const removed = before - lines.join('').length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个行首空白字符`)
}
}
// 移除行尾空格
if (options.value.removeTrailingSpaces) {
const before = lines.join('').length
lines = lines.map(line => line.replace(/[ \t]+$/, ''))
const removed = before - lines.join('').length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个行尾空白字符`)
}
}
// 修剪每行首尾空白
if (options.value.trimLines) {
const before = lines.join('').length
lines = lines.map(line => line.trim())
const removed = before - lines.join('').length
if (removed > 0) {
processLog.value.push(`修剪了行首尾空白,移除 ${removed} 个字符`)
}
}
// 移除空行
if (options.value.removeEmptyLines) {
const before = lines.length
lines = lines.filter(line => line.trim() !== '')
const removed = before - lines.length
if (removed > 0) {
processLog.value.push(`移除了 ${removed} 个空行`)
}
}
// 合并连续空行
if (options.value.collapseEmptyLines && !options.value.removeEmptyLines) {
const before = lines.length
const collapsed: string[] = []
let lastWasEmpty = false
for (const line of lines) {
const isEmpty = line.trim() === ''
if (!isEmpty || !lastWasEmpty) {
collapsed.push(line)
}
lastWasEmpty = isEmpty
}
lines = collapsed
const removed = before - lines.length
if (removed > 0) {
processLog.value.push(`合并了连续空行,移除 ${removed}`)
}
}
result = lines.join('\n')
// 移除所有空格
if (options.value.removeAllSpaces) {
const before = (result.match(/ /g) || []).length
result = result.replace(/ /g, '')
if (before > 0) {
processLog.value.push(`移除了所有 ${before} 个空格`)
}
}
// 合并连续空格
if (options.value.collapseSpaces && !options.value.removeAllSpaces) {
const before = result.length
result = result.replace(/ +/g, ' ')
const removed = before - result.length
if (removed > 0) {
processLog.value.push(`合并连续空格,移除 ${removed} 个字符`)
}
}
outputText.value = result
if (processLog.value.length === 0) {
processLog.value.push('没有需要处理的内容')
}
}
// 应用预设
const applyPreset = (preset: any) => {
// 重置所有选项
Object.keys(options.value).forEach(key => {
options.value[key as keyof typeof options.value] = false
})
// 应用预设选项
Object.assign(options.value, preset.options)
// 如果有输入文本,立即处理
if (inputText.value.trim()) {
processText()
}
}
// 复制结果
const copyResult = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 交换内容
const swapContent = () => {
const temp = inputText.value
inputText.value = outputText.value
outputText.value = temp
}
// 清除所有
const clearAll = () => {
inputText.value = ''
outputText.value = ''
processLog.value = []
}
// 更新统计
const updateStats = () => {
// 自动更新统计信息
}
// 监听输入变化,自动处理
watch(() => options.value, () => {
if (inputText.value.trim()) {
processText()
}
}, { deep: true })
</script>

View File

@ -0,0 +1,256 @@
<template>
<div class="space-y-6">
<!-- 当前时间戳 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.timestamp_converter.current_timestamp') }}</h3>
<div class="space-y-3">
<div class="flex items-center space-x-4">
<span class="text-secondary">当前时间戳:</span>
<span class="text-primary font-mono text-lg">{{ currentTimestamp }}</span>
<button
@click="copyTimestamp"
class="btn-secondary px-3 py-1 text-sm"
>
<FontAwesomeIcon :icon="['fas', 'copy']" class="mr-1" />
{{ t('tools.timestamp_converter.copy_timestamp') }}
</button>
</div>
<div class="flex items-center space-x-4">
<span class="text-secondary">当前日期:</span>
<span class="text-primary">{{ currentDateTime }}</span>
<button
@click="copyDateTime"
class="btn-secondary px-3 py-1 text-sm"
>
<FontAwesomeIcon :icon="['fas', 'copy']" class="mr-1" />
{{ t('tools.timestamp_converter.copy_date') }}
</button>
</div>
</div>
</div>
<!-- 转换工具 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 时间戳转日期 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.timestamp_converter.timestamp_to_date') }}</h3>
<div class="space-y-3">
<input
v-model="timestampInput"
type="text"
:placeholder="t('tools.timestamp_converter.timestamp_placeholder')"
class="input-field"
@input="convertTimestampToDate"
>
<button
@click="useCurrentTimestamp"
class="btn-secondary"
>
使用当前时间戳
</button>
<div v-if="timestampResult" class="space-y-2">
<label class="block text-sm font-medium text-secondary">转换结果:</label>
<div class="bg-block p-3 rounded font-mono text-sm">
<div><strong>本地时间:</strong> {{ timestampResult.local }}</div>
<div><strong>UTC时间:</strong> {{ timestampResult.utc }}</div>
<div><strong>ISO格式:</strong> {{ timestampResult.iso }}</div>
</div>
</div>
</div>
</div>
<!-- 日期转时间戳 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">{{ t('tools.timestamp_converter.date_to_timestamp') }}</h3>
<div class="space-y-3">
<input
v-model="dateInput"
type="datetime-local"
class="input-field"
@input="convertDateToTimestamp"
>
<button
@click="useCurrentDate"
class="btn-secondary"
>
使用当前时间
</button>
<div v-if="dateResult" class="space-y-2">
<label class="block text-sm font-medium text-secondary">转换结果:</label>
<div class="bg-block p-3 rounded font-mono text-sm">
<div><strong>时间戳():</strong> {{ dateResult.seconds }}</div>
<div><strong>时间戳(毫秒):</strong> {{ dateResult.milliseconds }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 快速转换 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速转换</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
v-for="quick in quickOptions"
:key="quick.label"
@click="() => applyQuickTimestamp(quick.timestamp)"
class="btn-secondary text-sm"
>
{{ quick.label }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const currentTimestamp = ref(0)
const currentDateTime = ref('')
const timestampInput = ref('')
const dateInput = ref('')
const timestampResult = ref<any>(null)
const dateResult = ref<any>(null)
// 更新当前时间戳
const updateCurrentTime = () => {
const now = new Date()
currentTimestamp.value = Math.floor(now.getTime() / 1000)
currentDateTime.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 时间戳转日期
const convertTimestampToDate = () => {
const input = timestampInput.value.trim()
if (!input) {
timestampResult.value = null
return
}
try {
let timestamp = parseInt(input)
// 判断是秒还是毫秒
if (timestamp.toString().length === 10) {
timestamp *= 1000
}
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
timestampResult.value = { error: '无效的时间戳' }
return
}
timestampResult.value = {
local: date.toLocaleString('zh-CN'),
utc: date.toUTCString(),
iso: date.toISOString()
}
} catch (error) {
timestampResult.value = { error: '转换失败' }
}
}
// 日期转时间戳
const convertDateToTimestamp = () => {
if (!dateInput.value) {
dateResult.value = null
return
}
try {
const date = new Date(dateInput.value)
const timestamp = date.getTime()
if (isNaN(timestamp)) {
dateResult.value = { error: '无效的日期' }
return
}
dateResult.value = {
seconds: Math.floor(timestamp / 1000),
milliseconds: timestamp
}
} catch (error) {
dateResult.value = { error: '转换失败' }
}
}
// 使用当前时间戳
const useCurrentTimestamp = () => {
timestampInput.value = currentTimestamp.value.toString()
convertTimestampToDate()
}
// 使用当前日期
const useCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
dateInput.value = `${year}-${month}-${day}T${hours}:${minutes}`
convertDateToTimestamp()
}
// 复制时间戳
const copyTimestamp = async () => {
try {
await navigator.clipboard.writeText(currentTimestamp.value.toString())
} catch (error) {
console.error('复制失败:', error)
}
}
// 复制日期时间
const copyDateTime = async () => {
try {
await navigator.clipboard.writeText(currentDateTime.value)
} catch (error) {
console.error('复制失败:', error)
}
}
// 快速选项
const quickOptions = [
{ label: '1小时前', timestamp: () => Math.floor(Date.now() / 1000) - 3600 },
{ label: '1天前', timestamp: () => Math.floor(Date.now() / 1000) - 86400 },
{ label: '1周前', timestamp: () => Math.floor(Date.now() / 1000) - 604800 },
{ label: '1月前', timestamp: () => Math.floor(Date.now() / 1000) - 2592000 }
]
// 应用快速时间戳
const applyQuickTimestamp = (timestampFn: () => number) => {
timestampInput.value = timestampFn().toString()
convertTimestampToDate()
}
// 定时器
let timer: NodeJS.Timeout
onMounted(() => {
updateCurrentTime()
timer = setInterval(updateCurrentTime, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>

View File

@ -0,0 +1,654 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="updateCurrentTime"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'sync']" class="mr-2" />
刷新时间
</button>
<button
@click="copyResult"
:disabled="!selectedTime"
class="btn-secondary"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="['mr-2', copied && 'text-success']"
/>
复制时间
</button>
<button
@click="resetToNow"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'clock']" class="mr-2" />
当前时间
</button>
<button
@click="addCustomTimezone"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-2" />
添加时区
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<!-- 时间输入 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">输入时间</h3>
<div class="space-y-4">
<!-- 日期时间输入 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">选择日期时间</label>
<input
v-model="inputDateTime"
type="datetime-local"
class="input-field"
@change="convertTimezones"
>
</div>
<!-- 源时区 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">源时区</label>
<select v-model="sourceTimezone" class="select-field" @change="convertTimezones">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.label }}
</option>
</select>
</div>
<!-- 快速时间选择 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">快速选择</label>
<div class="grid grid-cols-2 gap-2">
<button
v-for="quickTime in quickTimes"
:key="quickTime.label"
@click="setQuickTime(quickTime)"
class="btn-sm btn-secondary text-xs"
>
{{ quickTime.label }}
</button>
</div>
</div>
</div>
</div>
<!-- 常用时区 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">常用时区</h3>
<div class="space-y-2">
<div
v-for="timezone in commonTimezones.slice(0, 8)"
:key="timezone.value"
class="flex items-center justify-between p-2 bg-block rounded"
>
<div>
<div class="font-medium text-primary">{{ timezone.name }}</div>
<div class="text-sm text-secondary">{{ timezone.value }}</div>
</div>
<div class="text-sm text-primary font-mono">
{{ getTimezoneTime(timezone.value) }}
</div>
</div>
</div>
</div>
<!-- 时区偏移计算 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">时差计算</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="timezone1" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
<span class="self-center text-secondary">vs</span>
<select v-model="timezone2" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
</div>
<div class="bg-block rounded p-3 text-center">
<div class="text-sm text-secondary">时差</div>
<div class="text-lg font-medium text-primary">{{ getTimeDifference() }}</div>
</div>
</div>
</div>
</div>
<!-- 结果区域 -->
<div class="lg:col-span-2 space-y-4">
<!-- 世界时钟 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">世界时钟</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="timezone in displayTimezones"
:key="timezone.value"
class="bg-block rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<div>
<div class="font-medium text-primary">{{ timezone.name }}</div>
<div class="text-xs text-tertiary">{{ timezone.value }}</div>
</div>
<button
@click="removeTimezone(timezone.value)"
class="text-error hover:bg-error hover:bg-opacity-10 p-1 rounded"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
<div class="text-center">
<div class="text-2xl font-mono font-bold text-primary mb-1">
{{ getTimezoneTime(timezone.value, 'HH:mm:ss') }}
</div>
<div class="text-sm text-secondary">
{{ getTimezoneTime(timezone.value, 'yyyy-MM-dd EEEE') }}
</div>
<div class="text-xs text-tertiary mt-1">
UTC{{ getTimezoneOffset(timezone.value) }}
</div>
</div>
</div>
</div>
</div>
<!-- 转换结果 -->
<div v-if="conversionResults.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换结果</h3>
<div class="space-y-3">
<div
v-for="result in conversionResults"
:key="result.timezone"
class="flex items-center justify-between p-3 bg-block rounded-lg"
>
<div class="flex-1">
<div class="font-medium text-primary">{{ result.name }}</div>
<div class="text-sm text-secondary">{{ result.timezone }}</div>
</div>
<div class="text-right">
<div class="text-lg font-mono text-primary">{{ result.time }}</div>
<div class="text-xs text-secondary">{{ result.date }}</div>
</div>
<button
@click="copySpecificTime(result)"
class="ml-3 p-2 text-secondary hover:text-primary transition-colors"
:title="'复制 ' + result.name + ' 时间'"
>
<FontAwesomeIcon :icon="['fas', 'copy']" />
</button>
</div>
</div>
</div>
<!-- 时区信息 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">时区信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- UTC时间 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">协调世界时 (UTC)</div>
<div class="text-xl font-mono text-primary">{{ utcTime }}</div>
<div class="text-xs text-tertiary mt-1">Coordinated Universal Time</div>
</div>
<!-- 本地时间 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">本地时间</div>
<div class="text-xl font-mono text-primary">{{ localTime }}</div>
<div class="text-xs text-tertiary mt-1">{{ localTimezone }}</div>
</div>
<!-- Unix时间戳 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">Unix时间戳</div>
<div class="text-lg font-mono text-primary">{{ unixTimestamp }}</div>
<div class="text-xs text-tertiary mt-1"> / 毫秒</div>
</div>
<!-- ISO 8601 -->
<div class="bg-block rounded-lg p-4">
<div class="text-sm text-secondary mb-1">ISO 8601</div>
<div class="text-sm font-mono text-primary break-all">{{ isoTime }}</div>
<div class="text-xs text-tertiary mt-1">国际标准时间格式</div>
</div>
</div>
</div>
<!-- 日程助手 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">会议时间建议</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="meetingTimezone1" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
<select v-model="meetingTimezone2" class="select-field flex-1">
<option v-for="timezone in commonTimezones" :key="timezone.value" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
</div>
<div class="bg-block rounded p-4">
<div class="text-sm text-secondary mb-2">最佳会议时间段 (工作时间 9:00-18:00)</div>
<div class="space-y-2">
<div
v-for="suggestion in getMeetingSuggestions()"
:key="suggestion.time"
class="flex justify-between items-center text-sm"
>
<span class="text-primary">{{ suggestion.time }}</span>
<span class="text-secondary">{{ suggestion.zones }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const inputDateTime = ref('')
const sourceTimezone = ref('Asia/Shanghai')
const selectedTime = ref('')
const copied = ref(false)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
const currentTime = ref(new Date())
// 时区比较
const timezone1 = ref('Asia/Shanghai')
const timezone2 = ref('America/New_York')
// 会议时间建议
const meetingTimezone1 = ref('Asia/Shanghai')
const meetingTimezone2 = ref('America/New_York')
// 显示的时区列表
const displayTimezones = ref([
{ name: '北京', value: 'Asia/Shanghai' },
{ name: '纽约', value: 'America/New_York' },
{ name: '伦敦', value: 'Europe/London' },
{ name: '东京', value: 'Asia/Tokyo' }
])
// 转换结果
const conversionResults = ref<Array<{
name: string
timezone: string
time: string
date: string
fullTime: string
}>>([])
// 常用时区
const commonTimezones = [
{ name: '北京 (CST)', value: 'Asia/Shanghai', label: 'Asia/Shanghai (UTC+8)' },
{ name: '东京 (JST)', value: 'Asia/Tokyo', label: 'Asia/Tokyo (UTC+9)' },
{ name: '首尔 (KST)', value: 'Asia/Seoul', label: 'Asia/Seoul (UTC+9)' },
{ name: '新加坡 (SGT)', value: 'Asia/Singapore', label: 'Asia/Singapore (UTC+8)' },
{ name: '香港 (HKT)', value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (UTC+8)' },
{ name: '悉尼 (AEDT)', value: 'Australia/Sydney', label: 'Australia/Sydney (UTC+11)' },
{ name: '伦敦 (GMT)', value: 'Europe/London', label: 'Europe/London (UTC+0)' },
{ name: '巴黎 (CET)', value: 'Europe/Paris', label: 'Europe/Paris (UTC+1)' },
{ name: '莫斯科 (MSK)', value: 'Europe/Moscow', label: 'Europe/Moscow (UTC+3)' },
{ name: '纽约 (EST)', value: 'America/New_York', label: 'America/New_York (UTC-5)' },
{ name: '洛杉矶 (PST)', value: 'America/Los_Angeles', label: 'America/Los_Angeles (UTC-8)' },
{ name: '芝加哥 (CST)', value: 'America/Chicago', label: 'America/Chicago (UTC-6)' },
{ name: '丹佛 (MST)', value: 'America/Denver', label: 'America/Denver (UTC-7)' },
{ name: 'UTC', value: 'UTC', label: 'UTC (UTC+0)' }
]
// 快速时间选择
const quickTimes = [
{ label: '现在', offset: 0 },
{ label: '1小时后', offset: 1 },
{ label: '明天此时', offset: 24 },
{ label: '下周此时', offset: 24 * 7 }
]
// 计算属性
const utcTime = computed(() => {
return formatTime(currentTime.value, 'UTC', 'yyyy-MM-dd HH:mm:ss')
})
const localTime = computed(() => {
return formatTime(currentTime.value, Intl.DateTimeFormat().resolvedOptions().timeZone, 'yyyy-MM-dd HH:mm:ss')
})
const localTimezone = computed(() => {
return Intl.DateTimeFormat().resolvedOptions().timeZone
})
const unixTimestamp = computed(() => {
const seconds = Math.floor(currentTime.value.getTime() / 1000)
const milliseconds = currentTime.value.getTime()
return `${seconds} / ${milliseconds}`
})
const isoTime = computed(() => {
return currentTime.value.toISOString()
})
// 定时器
let timeInterval: number | undefined
// 格式化时间
const formatTime = (date: Date, timezone: string, format: string): string => {
try {
const options: Intl.DateTimeFormatOptions = {
timeZone: timezone
}
if (format.includes('yyyy')) {
options.year = 'numeric'
}
if (format.includes('MM')) {
options.month = '2-digit'
}
if (format.includes('dd')) {
options.day = '2-digit'
}
if (format.includes('HH')) {
options.hour = '2-digit'
options.hour12 = false
}
if (format.includes('mm')) {
options.minute = '2-digit'
}
if (format.includes('ss')) {
options.second = '2-digit'
}
if (format.includes('EEEE')) {
options.weekday = 'long'
}
const formatter = new Intl.DateTimeFormat('zh-CN', options)
if (format === 'HH:mm:ss') {
return date.toLocaleTimeString('zh-CN', {
timeZone: timezone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} else if (format === 'yyyy-MM-dd EEEE') {
return date.toLocaleDateString('zh-CN', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'long'
})
} else {
return formatter.format(date)
}
} catch (error) {
return date.toISOString()
}
}
// 获取时区时间
const getTimezoneTime = (timezone: string, format: string = 'yyyy-MM-dd HH:mm:ss'): string => {
return formatTime(currentTime.value, timezone, format)
}
// 获取时区偏移
const getTimezoneOffset = (timezone: string): string => {
try {
const date = new Date()
const utc = date.getTime() + (date.getTimezoneOffset() * 60000)
const targetTime = new Date(utc + getTimezoneOffsetMinutes(timezone) * 60000)
const offset = getTimezoneOffsetMinutes(timezone) / 60
return offset >= 0 ? `+${offset}` : `${offset}`
} catch {
return '+0'
}
}
// 获取时区偏移分钟数
const getTimezoneOffsetMinutes = (timezone: string): number => {
try {
const date = new Date()
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
const targetDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }))
return (targetDate.getTime() - utcDate.getTime()) / (1000 * 60)
} catch {
return 0
}
}
// 获取时差
const getTimeDifference = (): string => {
const offset1 = getTimezoneOffsetMinutes(timezone1.value)
const offset2 = getTimezoneOffsetMinutes(timezone2.value)
const diffMinutes = Math.abs(offset1 - offset2)
const hours = Math.floor(diffMinutes / 60)
const minutes = diffMinutes % 60
if (hours === 0) {
return `${minutes} 分钟`
} else if (minutes === 0) {
return `${hours} 小时`
} else {
return `${hours} 小时 ${minutes} 分钟`
}
}
// 获取会议建议
const getMeetingSuggestions = (): Array<{ time: string; zones: string }> => {
const suggestions = []
for (let hour = 9; hour <= 18; hour++) {
const time1 = `${hour.toString().padStart(2, '0')}:00`
const date = new Date()
date.setHours(hour, 0, 0, 0)
const time2 = formatTime(date, meetingTimezone2.value, 'HH:mm')
const hour2 = parseInt(time2.split(':')[0])
if (hour2 >= 9 && hour2 <= 18) {
suggestions.push({
time: `${time1} - ${time2}`,
zones: `${getTimezoneName(meetingTimezone1.value)} - ${getTimezoneName(meetingTimezone2.value)}`
})
}
}
return suggestions.slice(0, 3)
}
// 获取时区名称
const getTimezoneName = (timezone: string): string => {
const found = commonTimezones.find(tz => tz.value === timezone)
return found ? found.name : timezone
}
// 设置快速时间
const setQuickTime = (quickTime: any) => {
const date = new Date()
date.setHours(date.getHours() + quickTime.offset)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
inputDateTime.value = `${year}-${month}-${day}T${hours}:${minutes}`
convertTimezones()
}
// 转换时区
const convertTimezones = () => {
if (!inputDateTime.value) return
const inputDate = new Date(inputDateTime.value)
if (isNaN(inputDate.getTime())) return
conversionResults.value = displayTimezones.value.map(timezone => {
const time = formatTime(inputDate, timezone.value, 'HH:mm:ss')
const date = formatTime(inputDate, timezone.value, 'yyyy-MM-dd EEEE')
const fullTime = formatTime(inputDate, timezone.value, 'yyyy-MM-dd HH:mm:ss')
return {
name: timezone.name,
timezone: timezone.value,
time,
date,
fullTime
}
})
}
// 添加自定义时区
const addCustomTimezone = () => {
const timezone = prompt('请输入时区标识符 (如: Asia/Shanghai):')
if (!timezone) return
try {
// 验证时区是否有效
formatTime(new Date(), timezone, 'HH:mm:ss')
const name = prompt('请输入时区显示名称:', timezone) || timezone
displayTimezones.value.push({
name,
value: timezone
})
convertTimezones()
showStatus('时区添加成功', 'success')
} catch (error) {
showStatus('无效的时区标识符', 'error')
}
}
// 移除时区
const removeTimezone = (timezone: string) => {
displayTimezones.value = displayTimezones.value.filter(tz => tz.value !== timezone)
convertTimezones()
}
// 更新当前时间
const updateCurrentTime = () => {
currentTime.value = new Date()
}
// 重置到现在
const resetToNow = () => {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
inputDateTime.value = `${year}-${month}-${day}T${hours}:${minutes}`
convertTimezones()
}
// 复制结果
const copyResult = async () => {
if (!selectedTime.value) return
try {
await navigator.clipboard.writeText(selectedTime.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 复制特定时间
const copySpecificTime = async (result: any) => {
try {
await navigator.clipboard.writeText(result.fullTime)
showStatus(`已复制 ${result.name} 时间`, 'success')
} catch (error) {
showStatus('复制失败', 'error')
}
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
// 组件挂载
onMounted(() => {
resetToNow()
updateCurrentTime()
// 每秒更新时间
timeInterval = setInterval(() => {
updateCurrentTime()
}, 1000)
})
// 组件卸载
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
}
})
</script>

View File

@ -0,0 +1,323 @@
<template>
<div class="space-y-6">
<!-- 转换工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => convertTo('unicode')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-right']" class="mr-2" />
转为Unicode编码
</button>
<button
@click="() => convertTo('text')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-left']" class="mr-2" />
解码为文本
</button>
<button
@click="() => convertTo('hex')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'hashtag']" class="mr-2" />
转为16进制
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
<textarea
v-model="inputText"
placeholder="输入要转换的文本或Unicode编码..."
class="textarea-field h-80"
/>
<div class="mt-3 text-sm text-tertiary">
<p>字符数量: {{ inputText.length }}</p>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
<textarea
v-model="outputText"
placeholder="转换结果将显示在这里..."
class="textarea-field h-80"
readonly
/>
</div>
</div>
<!-- 字符信息 -->
<div v-if="charInfo.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">字符详细信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(char, index) in charInfo"
:key="index"
class="bg-block p-3 rounded"
>
<div class="text-center space-y-2">
<div class="text-2xl font-bold text-primary">{{ char.char }}</div>
<div class="text-sm text-secondary space-y-1">
<div><strong>Unicode:</strong> {{ char.unicode }}</div>
<div><strong>UTF-8:</strong> {{ char.utf8 }}</div>
<div><strong>十进制:</strong> {{ char.decimal }}</div>
<div><strong>十六进制:</strong> {{ char.hex }}</div>
<div v-if="char.description"><strong>描述:</strong> {{ char.description }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 快速转换示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速转换示例</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-medium text-secondary mb-2">常用字符</h4>
<div class="space-y-2">
<button
v-for="example in examples"
:key="example.text"
@click="() => useExample(example.text)"
class="block w-full text-left p-2 bg-block hover:bg-hover rounded text-sm"
>
<span class="font-mono">{{ example.text }}</span>
<span class="text-secondary ml-2">{{ example.unicode }}</span>
</button>
</div>
</div>
<div>
<h4 class="font-medium text-secondary mb-2">转换格式说明</h4>
<div class="space-y-2 text-sm">
<div class="bg-block p-3 rounded">
<div><strong>Unicode编码格式:</strong></div>
<div class="font-mono text-xs">
\\u4E2D (JavaScript格式)<br>
U+4E2D (标准格式)<br>
&#20013; (HTML实体)
</div>
</div>
<div class="bg-block p-3 rounded">
<div><strong>支持的输入格式:</strong></div>
<div class="text-xs">
普通文本<br>
Unicode编码序列<br>
十六进制编码
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const charInfo = ref<Array<{
char: string
unicode: string
utf8: string
decimal: number
hex: string
description?: string
}>>([])
// 示例数据
const examples = [
{ text: '中', unicode: '\\u4E2D' },
{ text: '文', unicode: '\\u6587' },
{ text: '😀', unicode: '\\uD83D\\uDE00' },
{ text: '©', unicode: '\\u00A9' },
{ text: '™', unicode: '\\u2122' },
{ text: '€', unicode: '\\u20AC' }
]
// 文本转Unicode
const textToUnicode = (text: string): string => {
return text.split('').map(char => {
const code = char.charCodeAt(0)
if (code > 127) {
return '\\u' + code.toString(16).toUpperCase().padStart(4, '0')
}
return char
}).join('')
}
// Unicode转文本
const unicodeToText = (unicode: string): string => {
try {
// 处理不同格式的Unicode编码
let processedUnicode = unicode
.replace(/\\u([0-9a-fA-F]{4})/g, '\\u$1')
.replace(/U\+([0-9a-fA-F]{4})/g, '\\u$1')
.replace(/&#(\d+);/g, (match, dec) => {
const hex = parseInt(dec).toString(16).toUpperCase().padStart(4, '0')
return '\\u' + hex
})
return JSON.parse('"' + processedUnicode + '"')
} catch (error) {
throw new Error('Unicode格式错误')
}
}
// 文本转十六进制
const textToHex = (text: string): string => {
return text.split('').map(char => {
const code = char.charCodeAt(0)
return '0x' + code.toString(16).toUpperCase().padStart(4, '0')
}).join(' ')
}
// 转换函数
const convertTo = (type: 'unicode' | 'text' | 'hex') => {
if (!inputText.value.trim()) return
try {
switch (type) {
case 'unicode':
outputText.value = textToUnicode(inputText.value)
break
case 'text':
outputText.value = unicodeToText(inputText.value)
break
case 'hex':
outputText.value = textToHex(inputText.value)
break
}
} catch (error) {
outputText.value = '转换失败: ' + (error instanceof Error ? error.message : '未知错误')
}
}
// 分析字符信息
const analyzeCharacters = (text: string) => {
if (!text || text.length > 20) {
charInfo.value = []
return
}
charInfo.value = text.split('').map(char => {
const code = char.charCodeAt(0)
const unicode = '\\u' + code.toString(16).toUpperCase().padStart(4, '0')
// UTF-8 编码
const utf8Bytes = new TextEncoder().encode(char)
const utf8 = Array.from(utf8Bytes).map(b => '0x' + b.toString(16).toUpperCase()).join(' ')
return {
char,
unicode,
utf8,
decimal: code,
hex: '0x' + code.toString(16).toUpperCase(),
description: getCharDescription(char, code)
}
})
}
// 获取字符描述
const getCharDescription = (char: string, code: number): string | undefined => {
if (code >= 0x4E00 && code <= 0x9FFF) return 'CJK统一汉字'
if (code >= 0x3040 && code <= 0x309F) return '平假名'
if (code >= 0x30A0 && code <= 0x30FF) return '片假名'
if (code >= 0x1F600 && code <= 0x1F64F) return 'Emoji表情'
if (code >= 0x0020 && code <= 0x007F) return 'ASCII字符'
if (code >= 0x00A0 && code <= 0x00FF) return 'Latin-1补充'
return undefined
}
// 使用示例
const useExample = (text: string) => {
inputText.value = text
convertTo('unicode')
}
// 清空所有内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
charInfo.value = []
}
// 粘贴功能
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制功能
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 监听输入变化,自动分析字符
watch(inputText, (newValue) => {
analyzeCharacters(newValue)
}, { immediate: true })
</script>

View File

@ -0,0 +1,341 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => convert('encode')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'lock']" class="mr-2" />
URL编码
</button>
<button
@click="() => convert('decode')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'unlock']" class="mr-2" />
URL解码
</button>
<button
@click="() => convert('component-encode')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
组件编码
</button>
<button
@click="() => convert('component-decode')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'expand']" class="mr-2" />
组件解码
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
<textarea
v-model="inputText"
placeholder="输入要编码或解码的URL或文本..."
class="textarea-field h-80"
/>
<div class="mt-3 text-sm text-tertiary">
<p>字符数量: {{ inputText.length }}</p>
</div>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
<textarea
v-model="outputText"
placeholder="转换结果将显示在这里..."
class="textarea-field h-80"
readonly
/>
</div>
</div>
<!-- URL 分析 -->
<div v-if="urlParts" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">URL 分析</h3>
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-if="urlParts.protocol" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">协议</div>
<div class="font-mono text-sm">{{ urlParts.protocol }}</div>
</div>
<div v-if="urlParts.hostname" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">主机名</div>
<div class="font-mono text-sm">{{ urlParts.hostname }}</div>
</div>
<div v-if="urlParts.port" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">端口</div>
<div class="font-mono text-sm">{{ urlParts.port }}</div>
</div>
<div v-if="urlParts.pathname" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">路径</div>
<div class="font-mono text-sm">{{ urlParts.pathname }}</div>
</div>
<div v-if="urlParts.search" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">查询参数</div>
<div class="font-mono text-sm">{{ urlParts.search }}</div>
</div>
<div v-if="urlParts.hash" class="bg-block p-3 rounded">
<div class="text-sm font-medium text-secondary mb-1">锚点</div>
<div class="font-mono text-sm">{{ urlParts.hash }}</div>
</div>
</div>
<div v-if="queryParams.length > 0" class="mt-4">
<h4 class="text-lg font-semibold text-primary mb-2">查询参数详情</h4>
<div class="space-y-2">
<div
v-for="(param, index) in queryParams"
:key="index"
class="bg-block p-3 rounded flex justify-between"
>
<div class="font-mono text-sm">
<span class="text-primary">{{ param.key }}</span>
<span class="text-secondary mx-2">=</span>
<span class="text-secondary">{{ param.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 编码对照表 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">常用字符编码对照表</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="char in commonChars"
:key="char.original"
class="bg-block p-3 rounded text-center"
>
<div class="text-lg font-bold text-primary">{{ char.original }}</div>
<div class="text-sm text-secondary font-mono">{{ char.encoded }}</div>
</div>
</div>
</div>
<!-- 快速示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">快速示例</h3>
<div class="space-y-3">
<div
v-for="example in examples"
:key="example.name"
class="bg-block p-3 rounded"
>
<div class="flex items-center justify-between mb-2">
<h4 class="font-medium text-secondary">{{ example.name }}</h4>
<button
@click="() => useExample(example.url)"
class="btn-secondary text-sm"
>
使用此示例
</button>
</div>
<div class="font-mono text-sm break-all">{{ example.url }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const copied = ref(false)
const urlParts = ref<any>(null)
const queryParams = ref<Array<{ key: string, value: string }>>([])
// 常用字符编码对照
const commonChars = [
{ original: ' ', encoded: '%20' },
{ original: '!', encoded: '%21' },
{ original: '#', encoded: '%23' },
{ original: '$', encoded: '%24' },
{ original: '&', encoded: '%26' },
{ original: "'", encoded: '%27' },
{ original: '(', encoded: '%28' },
{ original: ')', encoded: '%29' },
{ original: '+', encoded: '%2B' },
{ original: ',', encoded: '%2C' },
{ original: '/', encoded: '%2F' },
{ original: ':', encoded: '%3A' },
{ original: ';', encoded: '%3B' },
{ original: '=', encoded: '%3D' },
{ original: '?', encoded: '%3F' },
{ original: '@', encoded: '%40' }
]
// 示例URL
const examples = [
{
name: 'Google搜索',
url: 'https://www.google.com/search?q=URL编码&hl=zh-CN'
},
{
name: '包含中文的URL',
url: 'https://example.com/用户/信息?姓名=张三&年龄=25'
},
{
name: '包含特殊字符',
url: 'https://api.example.com/data?filter=name eq "John Doe"&sort=created_at desc'
},
{
name: '已编码的URL',
url: 'https://example.com/%E7%94%A8%E6%88%B7?name=%E5%BC%A0%E4%B8%89'
}
]
// 转换函数
const convert = (type: 'encode' | 'decode' | 'component-encode' | 'component-decode') => {
if (!inputText.value.trim()) return
try {
switch (type) {
case 'encode':
outputText.value = encodeURI(inputText.value)
break
case 'decode':
outputText.value = decodeURI(inputText.value)
break
case 'component-encode':
outputText.value = encodeURIComponent(inputText.value)
break
case 'component-decode':
outputText.value = decodeURIComponent(inputText.value)
break
}
} catch (error) {
outputText.value = '转换失败: ' + (error instanceof Error ? error.message : '未知错误')
}
}
// 分析URL结构
const analyzeURL = (url: string) => {
try {
const urlObj = new URL(url)
urlParts.value = {
protocol: urlObj.protocol,
hostname: urlObj.hostname,
port: urlObj.port || '默认端口',
pathname: urlObj.pathname,
search: urlObj.search,
hash: urlObj.hash
}
// 解析查询参数
queryParams.value = []
urlObj.searchParams.forEach((value, key) => {
queryParams.value.push({ key, value })
})
} catch (error) {
urlParts.value = null
queryParams.value = []
}
}
// 使用示例
const useExample = (url: string) => {
inputText.value = url
}
// 清空所有内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
urlParts.value = null
queryParams.value = []
}
// 粘贴功能
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制功能
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 监听输入变化分析URL
watch(inputText, (newValue) => {
if (newValue.trim()) {
analyzeURL(newValue)
} else {
urlParts.value = null
queryParams.value = []
}
}, { immediate: true })
</script>

View File

@ -0,0 +1,489 @@
<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="() => convert('yml-to-properties')"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-right']" class="mr-2" />
YML Properties
</button>
<button
@click="() => convert('properties-to-yml')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'arrow-left']" class="mr-2" />
Properties YML
</button>
<button
@click="() => convert('yml-to-json')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-2" />
YML JSON
</button>
<button
@click="() => convert('json-to-yml')"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'file-code']" class="mr-2" />
JSON YML
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清空
</button>
</div>
</div>
<!-- 输入输出区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输入</h3>
<div class="flex space-x-2">
<select
v-model="inputFormat"
class="input-field text-sm py-1 px-2"
>
<option value="yml">YAML/YML</option>
<option value="properties">Properties</option>
<option value="json">JSON</option>
</select>
<button
@click="pasteFromClipboard"
class="p-2 rounded text-secondary hover:text-primary transition-colors"
title="粘贴"
>
<FontAwesomeIcon :icon="['fas', 'clipboard']" />
</button>
</div>
</div>
<textarea
v-model="inputText"
:placeholder="getInputPlaceholder()"
class="textarea-field h-80 font-mono text-sm"
/>
</div>
<!-- 输出区域 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-primary">输出</h3>
<div class="flex space-x-2">
<select
v-model="outputFormat"
class="input-field text-sm py-1 px-2"
>
<option value="yml">YAML/YML</option>
<option value="properties">Properties</option>
<option value="json">JSON</option>
</select>
<button
@click="copyToClipboard"
:disabled="!outputText"
class="p-2 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50"
title="复制"
>
<FontAwesomeIcon
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
:class="copied && 'text-success'"
/>
</button>
</div>
</div>
<textarea
v-model="outputText"
placeholder="转换结果将显示在这里..."
class="textarea-field h-80 font-mono text-sm"
readonly
/>
</div>
</div>
<!-- 状态信息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusMessage.type === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusMessage.type === 'success' ? ['fas', 'check'] : ['fas', 'exclamation-triangle']"
/>
<span>{{ statusMessage.text }}</span>
</div>
</div>
<!-- 示例 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- YAML 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">YAML 示例</h3>
<button
@click="() => useExample(yamlExample)"
class="btn-secondary mb-3 w-full"
>
使用此示例
</button>
<pre class="bg-block p-3 rounded text-xs overflow-x-auto"><code>{{ yamlExample }}</code></pre>
</div>
<!-- Properties 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">Properties 示例</h3>
<button
@click="() => useExample(propertiesExample)"
class="btn-secondary mb-3 w-full"
>
使用此示例
</button>
<pre class="bg-block p-3 rounded text-xs overflow-x-auto"><code>{{ propertiesExample }}</code></pre>
</div>
<!-- JSON 示例 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">JSON 示例</h3>
<button
@click="() => useExample(jsonExample)"
class="btn-secondary mb-3 w-full"
>
使用此示例
</button>
<pre class="bg-block p-3 rounded text-xs overflow-x-auto"><code>{{ jsonExample }}</code></pre>
</div>
</div>
<!-- 格式说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">格式说明</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-block p-3 rounded">
<h4 class="font-medium text-secondary mb-2">YAML/YML</h4>
<ul class="text-sm space-y-1">
<li> 使用缩进表示层级关系</li>
<li> 支持列表和对象结构</li>
<li> 可读性强常用于配置文件</li>
<li> 不支持注释的转换</li>
</ul>
</div>
<div class="bg-block p-3 rounded">
<h4 class="font-medium text-secondary mb-2">Properties</h4>
<ul class="text-sm space-y-1">
<li> 键值对格式 key=value</li>
<li> 使用点号表示层级</li>
<li> Java 项目常用配置格式</li>
<li> 扁平化结构</li>
</ul>
</div>
<div class="bg-block p-3 rounded">
<h4 class="font-medium text-secondary mb-2">JSON</h4>
<ul class="text-sm space-y-1">
<li> JavaScript 对象标记法</li>
<li> 支持复杂数据结构</li>
<li> API 和数据交换常用格式</li>
<li> 严格的语法规则</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 响应式状态
const inputText = ref('')
const outputText = ref('')
const inputFormat = ref('yml')
const outputFormat = ref('properties')
const copied = ref(false)
const statusMessage = ref<{ type: 'success' | 'error', text: string } | null>(null)
// 示例数据
const yamlExample = `server:
port: 8080
host: localhost
database:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: secret
logging:
level:
root: INFO
com.example: DEBUG`
const propertiesExample = `server.port=8080
server.host=localhost
database.url=jdbc:mysql://localhost:3306/mydb
database.username=root
database.password=secret
logging.level.root=INFO
logging.level.com.example=DEBUG`
const jsonExample = `{
"server": {
"port": 8080,
"host": "localhost"
},
"database": {
"url": "jdbc:mysql://localhost:3306/mydb",
"username": "root",
"password": "secret"
},
"logging": {
"level": {
"root": "INFO",
"com.example": "DEBUG"
}
}
}`
// 获取输入提示
const getInputPlaceholder = (): string => {
switch (inputFormat.value) {
case 'yml':
return '输入YAML格式的配置...'
case 'properties':
return '输入Properties格式的配置...'
case 'json':
return '输入JSON格式的配置...'
default:
return '输入要转换的内容...'
}
}
// 简单的 YAML 解析器
const parseYaml = (yamlStr: string): any => {
const lines = yamlStr.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
const result: any = {}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.includes(':')) continue
const [key, ...valueParts] = trimmed.split(':')
const value = valueParts.join(':').trim()
const keys = key.trim().split('.')
let current = result
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {}
}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value || ''
}
return result
}
// 简单的 YAML 生成器
const generateYaml = (obj: any, indent: number = 0): string => {
const spaces = ' '.repeat(indent)
let result = ''
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null) {
result += `${spaces}${key}:\n`
result += generateYaml(value, indent + 1)
} else {
result += `${spaces}${key}: ${value}\n`
}
}
return result
}
// 对象转 Properties
const objectToProperties = (obj: any, prefix: string = ''): string => {
let result = ''
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'object' && value !== null) {
result += objectToProperties(value, fullKey)
} else {
result += `${fullKey}=${value}\n`
}
}
return result
}
// Properties 转对象
const propertiesToObject = (propertiesStr: string): any => {
const result: any = {}
const lines = propertiesStr.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
for (const line of lines) {
const [key, ...valueParts] = line.split('=')
if (!key || valueParts.length === 0) continue
const value = valueParts.join('=').trim()
const keys = key.trim().split('.')
let current = result
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {}
}
current = current[keys[i]]
}
current[keys[keys.length - 1]] = value
}
return result
}
// 转换函数
const convert = (type: string) => {
if (!inputText.value.trim()) {
statusMessage.value = { type: 'error', text: '请输入要转换的内容' }
return
}
try {
let result = ''
switch (type) {
case 'yml-to-properties': {
const obj = parseYaml(inputText.value)
result = objectToProperties(obj)
break
}
case 'properties-to-yml': {
const obj = propertiesToObject(inputText.value)
result = generateYaml(obj)
break
}
case 'yml-to-json': {
const obj = parseYaml(inputText.value)
result = JSON.stringify(obj, null, 2)
break
}
case 'json-to-yml': {
const obj = JSON.parse(inputText.value)
result = generateYaml(obj)
break
}
default:
throw new Error('不支持的转换类型')
}
outputText.value = result
statusMessage.value = { type: 'success', text: '转换成功' }
} catch (error) {
outputText.value = ''
statusMessage.value = {
type: 'error',
text: '转换失败: ' + (error instanceof Error ? error.message : '未知错误')
}
}
}
// 自动转换
const autoConvert = () => {
if (!inputText.value.trim()) {
outputText.value = ''
return
}
const conversionMap: Record<string, string> = {
'yml-properties': 'yml-to-properties',
'yml-json': 'yml-to-json',
'properties-yml': 'properties-to-yml',
'properties-json': 'properties-to-yml',
'json-yml': 'json-to-yml',
'json-properties': 'json-to-yml'
}
const key = `${inputFormat.value}-${outputFormat.value}`
const conversionType = conversionMap[key]
if (conversionType) {
convert(conversionType)
}
}
// 使用示例
const useExample = (example: string) => {
inputText.value = example
autoConvert()
}
// 清空内容
const clearAll = () => {
inputText.value = ''
outputText.value = ''
statusMessage.value = null
}
// 粘贴功能
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
inputText.value = text
autoConvert()
} catch (error) {
console.error('粘贴失败:', error)
}
}
// 复制功能
const copyToClipboard = async () => {
if (!outputText.value) return
try {
await navigator.clipboard.writeText(outputText.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
// 监听格式变化,自动转换
watch([inputFormat, outputFormat], () => {
if (inputText.value.trim()) {
autoConvert()
}
})
// 监听输入变化,自动转换
watch(inputText, () => {
if (inputText.value.trim()) {
autoConvert()
} else {
outputText.value = ''
statusMessage.value = null
}
}, { immediate: true })
</script>