278 lines
11 KiB
Vue
278 lines
11 KiB
Vue
|
<template>
|
||
|
<div class="space-y-8">
|
||
|
<div class="text-center space-y-4">
|
||
|
<h1 class="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-primary-500 dark:from-primary-400 dark:to-primary-300">
|
||
|
优化图片
|
||
|
</h1>
|
||
|
<p class="text-xl text-dark-600 dark:text-dark-300">
|
||
|
上传并优化您的图片
|
||
|
</p>
|
||
|
</div>
|
||
|
|
||
|
<div class="bg-white dark:bg-dark-800 rounded-xl shadow-soft transition-all duration-300 hover:shadow-soft-lg">
|
||
|
<div
|
||
|
class="border-2 border-dashed rounded-xl p-8 text-center mx-6 my-6 transition-all duration-200"
|
||
|
:class="[
|
||
|
isDragging
|
||
|
? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-500/5'
|
||
|
: 'border-dark-200 dark:border-dark-700 hover:border-primary-300 dark:hover:border-primary-600'
|
||
|
]"
|
||
|
@dragenter.prevent="isDragging = true"
|
||
|
@dragleave.prevent="isDragging = false"
|
||
|
@dragover.prevent
|
||
|
@drop.prevent="handleDrop"
|
||
|
>
|
||
|
<input
|
||
|
type="file"
|
||
|
ref="fileInput"
|
||
|
class="hidden"
|
||
|
accept="image/*"
|
||
|
multiple
|
||
|
@change="handleFileSelect"
|
||
|
/>
|
||
|
<div class="space-y-4">
|
||
|
<div class="w-16 h-16 mx-auto rounded-xl bg-primary-100 dark:bg-primary-500/10 flex items-center justify-center">
|
||
|
<svg class="w-8 h-8 text-primary-500 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
|
</svg>
|
||
|
</div>
|
||
|
<div class="text-dark-600 dark:text-dark-300">
|
||
|
<p class="mb-2 text-lg">拖放图片到这里</p>
|
||
|
<p class="text-dark-400 dark:text-dark-500">或</p>
|
||
|
<button
|
||
|
class="mt-4 px-6 py-2.5 bg-primary-500 hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-700 text-white rounded-lg shadow-sm hover:shadow transition-all duration-200"
|
||
|
@click="$refs.fileInput.click()"
|
||
|
>
|
||
|
选择文件
|
||
|
</button>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<div v-if="files.length > 0" class="border-t border-dark-100 dark:border-dark-700 px-6 py-6 space-y-4">
|
||
|
<div v-for="(file, index) in files" :key="index"
|
||
|
class="flex items-center space-x-4 p-4 rounded-lg transition-all duration-200"
|
||
|
:class="[
|
||
|
file.processed
|
||
|
? 'bg-green-50 dark:bg-green-500/5'
|
||
|
: 'bg-dark-50 dark:bg-dark-700/50'
|
||
|
]"
|
||
|
>
|
||
|
<div class="w-16 h-16 rounded-lg overflow-hidden bg-dark-100 dark:bg-dark-600">
|
||
|
<img :src="file.preview" class="w-full h-full object-cover" />
|
||
|
</div>
|
||
|
<div class="flex-1 min-w-0">
|
||
|
<p class="font-medium text-dark-900 dark:text-dark-50 truncate">{{ file.name }}</p>
|
||
|
<p class="text-sm text-dark-500 dark:text-dark-400">{{ formatFileSize(file.size) }}</p>
|
||
|
<div v-if="file.processed" class="text-sm text-green-600 dark:text-green-400 flex items-center space-x-1">
|
||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
|
</svg>
|
||
|
<span>已优化 ({{ formatFileSize(file.processedSize) }})</span>
|
||
|
<span class="text-dark-400 dark:text-dark-500">
|
||
|
- 压缩率 {{ Math.round((1 - file.processedSize! / file.size) * 100) }}%
|
||
|
</span>
|
||
|
</div>
|
||
|
</div>
|
||
|
<button
|
||
|
class="p-2 text-dark-400 hover:text-red-500 dark:text-dark-500 dark:hover:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors duration-200"
|
||
|
@click="removeFile(index)"
|
||
|
>
|
||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
|
</svg>
|
||
|
</button>
|
||
|
</div>
|
||
|
|
||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
|
||
|
<div>
|
||
|
<label class="block text-sm font-medium text-dark-700 dark:text-dark-300 mb-2">
|
||
|
输出格式
|
||
|
</label>
|
||
|
<select
|
||
|
v-model="options.format"
|
||
|
class="w-full px-4 py-2.5 rounded-lg bg-dark-50 dark:bg-dark-700 border border-dark-200 dark:border-dark-600 text-dark-900 dark:text-dark-100 focus:ring-2 focus:ring-primary-500/20 dark:focus:ring-primary-500/10 focus:border-primary-500 dark:focus:border-primary-500 transition-all duration-200"
|
||
|
>
|
||
|
<option value="jpeg">JPEG</option>
|
||
|
<option value="png">PNG</option>
|
||
|
<option value="webp">WebP</option>
|
||
|
</select>
|
||
|
</div>
|
||
|
<div>
|
||
|
<label class="block text-sm font-medium text-dark-700 dark:text-dark-300 mb-2">
|
||
|
质量
|
||
|
</label>
|
||
|
<div class="space-y-2">
|
||
|
<input
|
||
|
type="range"
|
||
|
v-model="options.quality"
|
||
|
min="0"
|
||
|
max="100"
|
||
|
class="w-full accent-primary-500"
|
||
|
/>
|
||
|
<div class="flex justify-between text-sm text-dark-500 dark:text-dark-400">
|
||
|
<span>压缩</span>
|
||
|
<span>{{ options.quality }}%</span>
|
||
|
<span>原图</span>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<div class="flex justify-end space-x-4 pt-4">
|
||
|
<button
|
||
|
v-if="hasProcessedFiles"
|
||
|
class="px-6 py-2.5 bg-dark-100 hover:bg-dark-200 dark:bg-dark-700 dark:hover:bg-dark-600 text-dark-700 dark:text-dark-200 rounded-lg shadow-sm hover:shadow transition-all duration-200"
|
||
|
@click="downloadAll"
|
||
|
>
|
||
|
<span class="flex items-center space-x-2">
|
||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
|
</svg>
|
||
|
<span>下载全部</span>
|
||
|
</span>
|
||
|
</button>
|
||
|
<button
|
||
|
class="px-6 py-2.5 bg-primary-500 hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-700 text-white rounded-lg shadow-sm hover:shadow transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
|
:disabled="isProcessing"
|
||
|
@click="processFiles"
|
||
|
>
|
||
|
<span class="flex items-center space-x-2">
|
||
|
<svg v-if="isProcessing" class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
|
</svg>
|
||
|
<span>{{ isProcessing ? '处理中...' : '处理图片' }}</span>
|
||
|
</span>
|
||
|
</button>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</template>
|
||
|
|
||
|
<script setup lang="ts">
|
||
|
import { ref, reactive, computed } from 'vue'
|
||
|
import imageCompression from 'browser-image-compression'
|
||
|
import JSZip from 'jszip'
|
||
|
|
||
|
interface ProcessedFile {
|
||
|
file: File
|
||
|
preview: string
|
||
|
processed?: boolean
|
||
|
processedSize?: number
|
||
|
processedBlob?: Blob
|
||
|
}
|
||
|
|
||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||
|
const isDragging = ref(false)
|
||
|
const isProcessing = ref(false)
|
||
|
const files = ref<ProcessedFile[]>([])
|
||
|
|
||
|
const options = reactive({
|
||
|
format: 'jpeg',
|
||
|
quality: 80
|
||
|
})
|
||
|
|
||
|
const hasProcessedFiles = computed(() => {
|
||
|
return files.value.some(file => file.processed)
|
||
|
})
|
||
|
|
||
|
const handleDrop = (e: DragEvent) => {
|
||
|
isDragging.value = false
|
||
|
if (e.dataTransfer?.files) {
|
||
|
handleFiles(e.dataTransfer.files)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const handleFileSelect = (e: Event) => {
|
||
|
const input = e.target as HTMLInputElement
|
||
|
if (input.files) {
|
||
|
handleFiles(input.files)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const handleFiles = (fileList: FileList) => {
|
||
|
for (let i = 0; i < fileList.length; i++) {
|
||
|
const file = fileList[i]
|
||
|
if (file.type.startsWith('image/')) {
|
||
|
const reader = new FileReader()
|
||
|
reader.onload = (e) => {
|
||
|
files.value.push({
|
||
|
file,
|
||
|
preview: e.target?.result as string
|
||
|
})
|
||
|
}
|
||
|
reader.readAsDataURL(file)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const removeFile = (index: number) => {
|
||
|
files.value.splice(index, 1)
|
||
|
}
|
||
|
|
||
|
const formatFileSize = (bytes: number) => {
|
||
|
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]
|
||
|
}
|
||
|
|
||
|
const processFiles = async () => {
|
||
|
isProcessing.value = true
|
||
|
try {
|
||
|
for (let i = 0; i < files.value.length; i++) {
|
||
|
const file = files.value[i]
|
||
|
if (!file.processed) {
|
||
|
const options = {
|
||
|
maxSizeMB: 1,
|
||
|
maxWidthOrHeight: 1920,
|
||
|
useWebWorker: true,
|
||
|
fileType: `image/${file.file.type.split('/')[1]}`,
|
||
|
initialQuality: 0.8
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
const compressedFile = await imageCompression(file.file, options)
|
||
|
const processedBlob = new Blob([compressedFile], { type: `image/${file.file.type.split('/')[1]}` })
|
||
|
|
||
|
files.value[i] = {
|
||
|
...file,
|
||
|
processed: true,
|
||
|
processedSize: compressedFile.size,
|
||
|
processedBlob
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.error('Error processing file:', error)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
} finally {
|
||
|
isProcessing.value = false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const downloadAll = async () => {
|
||
|
const zip = new JSZip()
|
||
|
|
||
|
for (const file of files.value) {
|
||
|
if (file.processed && file.processedBlob) {
|
||
|
const extension = file.file.name.split('.').pop()
|
||
|
const newFileName = `${file.file.name.split('.')[0]}_optimized.${extension}`
|
||
|
zip.file(newFileName, file.processedBlob)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const content = await zip.generateAsync({ type: 'blob' })
|
||
|
const url = URL.createObjectURL(content)
|
||
|
const link = document.createElement('a')
|
||
|
link.href = url
|
||
|
link.download = 'optimized_images.zip'
|
||
|
document.body.appendChild(link)
|
||
|
link.click()
|
||
|
document.body.removeChild(link)
|
||
|
URL.revokeObjectURL(url)
|
||
|
}
|
||
|
</script>
|