forked from zguiy/utils
工具完成
This commit is contained in:
49
src/components/BackToTop.vue
Normal file
49
src/components/BackToTop.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<button
|
||||
v-if="isVisible"
|
||||
@click="scrollToTop"
|
||||
class="fixed bottom-8 right-8 p-3 bg-primary-500 hover:bg-primary-600 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 z-50"
|
||||
title="返回顶部"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'arrow-up']" class="w-5 h-5" />
|
||||
</button>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const isVisible = ref(false)
|
||||
|
||||
const checkScroll = () => {
|
||||
isVisible.value = window.pageYOffset > 300
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', checkScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', checkScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
72
src/components/LanguageToggle.vue
Normal file
72
src/components/LanguageToggle.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center space-x-1 p-2 rounded-lg bg-card hover:bg-block-hover text-secondary hover:text-primary transition-all duration-200"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ language.toUpperCase() }}</span>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'chevron-down']"
|
||||
:class="['w-3 h-3 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 mt-2 w-20 bg-card border border-opacity-15 border-primary-500 rounded-lg shadow-lg z-50"
|
||||
>
|
||||
<button
|
||||
@click="() => selectLanguage('zh')"
|
||||
:class="[
|
||||
'w-full px-3 py-2 text-left text-sm transition-colors duration-200 rounded-t-lg',
|
||||
language === 'zh' ? 'bg-primary-500 text-white' : 'text-secondary hover:bg-block-hover hover:text-primary'
|
||||
]"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
@click="() => selectLanguage('en')"
|
||||
:class="[
|
||||
'w-full px-3 py-2 text-left text-sm transition-colors duration-200 rounded-b-lg',
|
||||
language === 'en' ? 'bg-primary-500 text-white' : 'text-secondary hover:bg-block-hover hover:text-primary'
|
||||
]"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useLanguage } from '@/composables/useLanguage'
|
||||
import type { Language } from '@/types/tools'
|
||||
|
||||
const { language, switchLanguage } = useLanguage()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const selectLanguage = (lang: Language) => {
|
||||
switchLanguage(lang)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element
|
||||
if (!target.closest('.relative')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
68
src/components/ThemeToggle.vue
Normal file
68
src/components/ThemeToggle.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="relative w-10 h-10 rounded-full bg-card hover:bg-block-hover text-secondary hover:text-primary transition-all duration-300 shadow-md hover:shadow-lg border border-opacity-20 border-primary-500 hover:border-opacity-40 group"
|
||||
:title="theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'"
|
||||
>
|
||||
<!-- 图标容器 -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<!-- 太阳图标 (浅色模式) -->
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'sun']"
|
||||
:class="[
|
||||
'w-5 h-5 transition-all duration-300 transform',
|
||||
theme === 'dark'
|
||||
? 'opacity-100 scale-100 rotate-0'
|
||||
: 'opacity-0 scale-50 rotate-180'
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 月亮图标 (深色模式) -->
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'moon']"
|
||||
:class="[
|
||||
'w-5 h-5 transition-all duration-300 transform absolute',
|
||||
theme === 'dark'
|
||||
? 'opacity-0 scale-50 -rotate-180'
|
||||
: 'opacity-100 scale-100 rotate-0'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 悬停时的光晕效果 -->
|
||||
<div class="absolute inset-0 rounded-full bg-primary-500 opacity-0 group-hover:opacity-10 transition-opacity duration-300"></div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 确保图标切换动画流畅 */
|
||||
.fa-sun,
|
||||
.fa-moon {
|
||||
color: rgb(var(--color-text-secondary));
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 悬停时图标颜色变化 */
|
||||
button:hover .fa-sun,
|
||||
button:hover .fa-moon {
|
||||
color: rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
/* 为浅色主题的太阳图标添加特殊效果 */
|
||||
[data-theme='light'] .fa-sun {
|
||||
color: rgb(var(--color-warning));
|
||||
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.3));
|
||||
}
|
||||
|
||||
/* 为深色主题的月亮图标添加特殊效果 */
|
||||
[data-theme='dark'] .fa-moon {
|
||||
color: rgb(var(--color-primary-light));
|
||||
filter: drop-shadow(0 0 4px rgba(129, 140, 248, 0.3));
|
||||
}
|
||||
</style>
|
||||
23
src/components/ToolHeader.vue
Normal file
23
src/components/ToolHeader.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="tool-header mb-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-primary mb-2">{{ title }}</h1>
|
||||
<p class="text-secondary text-sm max-w-2xl mx-auto">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-header {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
402
src/components/tools/Base64ToImage.vue
Normal file
402
src/components/tools/Base64ToImage.vue
Normal 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>
|
||||
361
src/components/tools/ChromeBookmarkRecovery.vue
Normal file
361
src/components/tools/ChromeBookmarkRecovery.vue
Normal 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>
|
||||
565
src/components/tools/CodeFormatter.vue
Normal file
565
src/components/tools/CodeFormatter.vue
Normal 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>
|
||||
461
src/components/tools/ColorTools.vue
Normal file
461
src/components/tools/ColorTools.vue
Normal 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>
|
||||
621
src/components/tools/CronGenerator.vue
Normal file
621
src/components/tools/CronGenerator.vue
Normal 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>
|
||||
418
src/components/tools/CryptoTools.vue
Normal file
418
src/components/tools/CryptoTools.vue
Normal 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>
|
||||
332
src/components/tools/CssGradientGenerator.vue
Normal file
332
src/components/tools/CssGradientGenerator.vue
Normal 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>
|
||||
550
src/components/tools/DateCalculator.vue
Normal file
550
src/components/tools/DateCalculator.vue
Normal 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>
|
||||
306
src/components/tools/EncodingConverter.vue
Normal file
306
src/components/tools/EncodingConverter.vue
Normal 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>
|
||||
939
src/components/tools/HtmlMarkdownConverter.vue
Normal file
939
src/components/tools/HtmlMarkdownConverter.vue
Normal 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 ``
|
||||
|
||||
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)了解更多信息。
|
||||
|
||||
> 这是一个引用块的示例。
|
||||
|
||||
`
|
||||
}
|
||||
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>
|
||||
432
src/components/tools/HttpTester.vue
Normal file
432
src/components/tools/HttpTester.vue
Normal 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>
|
||||
502
src/components/tools/ImageCompressor.vue
Normal file
502
src/components/tools/ImageCompressor.vue
Normal 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>
|
||||
838
src/components/tools/ImageToIco.vue
Normal file
838
src/components/tools/ImageToIco.vue
Normal 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">
|
||||
支持 JPG、PNG、GIF、BMP、WebP 格式
|
||||
</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>
|
||||
773
src/components/tools/ImageWatermark.vue
Normal file
773
src/components/tools/ImageWatermark.vue
Normal 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>
|
||||
426
src/components/tools/IpLookup.vue
Normal file
426
src/components/tools/IpLookup.vue
Normal 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>
|
||||
1058
src/components/tools/JsonConverter.vue
Normal file
1058
src/components/tools/JsonConverter.vue
Normal file
File diff suppressed because it is too large
Load Diff
843
src/components/tools/JsonEditor.vue
Normal file
843
src/components/tools/JsonEditor.vue
Normal 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>
|
||||
847
src/components/tools/JsonFormatter.vue
Normal file
847
src/components/tools/JsonFormatter.vue
Normal 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>
|
||||
525
src/components/tools/JwtDecoder.vue
Normal file
525
src/components/tools/JwtDecoder.vue
Normal 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>• JWT(JSON 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>
|
||||
464
src/components/tools/NumberBaseConverter.vue
Normal file
464
src/components/tools/NumberBaseConverter.vue
Normal 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>
|
||||
289
src/components/tools/QrcodeGenerator.vue
Normal file
289
src/components/tools/QrcodeGenerator.vue
Normal 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>
|
||||
404
src/components/tools/RegexTester.vue
Normal file
404
src/components/tools/RegexTester.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// 计算高亮文本
|
||||
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>
|
||||
299
src/components/tools/TextCounter.vue
Normal file
299
src/components/tools/TextCounter.vue
Normal 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>
|
||||
711
src/components/tools/TextSpaceStripper.vue
Normal file
711
src/components/tools/TextSpaceStripper.vue
Normal 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">移除不间断空格 ( )</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>
|
||||
256
src/components/tools/TimestampConverter.vue
Normal file
256
src/components/tools/TimestampConverter.vue
Normal 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>
|
||||
654
src/components/tools/TimezoneConverter.vue
Normal file
654
src/components/tools/TimezoneConverter.vue
Normal 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>
|
||||
323
src/components/tools/UnicodeConverter.vue
Normal file
323
src/components/tools/UnicodeConverter.vue
Normal 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>
|
||||
中 (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>
|
||||
341
src/components/tools/UrlEncoder.vue
Normal file
341
src/components/tools/UrlEncoder.vue
Normal 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>
|
||||
489
src/components/tools/YmlPropertiesConverter.vue
Normal file
489
src/components/tools/YmlPropertiesConverter.vue
Normal 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>
|
||||
Reference in New Issue
Block a user