Update project for 2025 release: modified LICENSE year, updated dependencies in package.json and package-lock.json, improved README with new clone URL, adjusted TypeScript configuration, and refined Vue components for better user experience.
This commit is contained in:
parent
0d68dc6e35
commit
a15eb98b18
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Image Optimizer
|
||||
Copyright (c) 2025 Image Optimizer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-username/image-optimizer.git
|
||||
git clone https://ckk.photo8.site/Snowz/image-optimizer.git
|
||||
|
||||
# 进入项目目录
|
||||
cd image-optimizer
|
||||
|
3780
package-lock.json
generated
3780
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@ -1,36 +1,32 @@
|
||||
{
|
||||
"name": "image-optimizer",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern online image optimization and processing tool that runs entirely in the browser",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"vue": "^3.3.11",
|
||||
"vue-router": "^4.2.5",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"pinia": "^2.1.6",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4"
|
||||
"jszip": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.7",
|
||||
"vitest": "^0.33.0",
|
||||
"vue-tsc": "^1.8.8"
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/node": "^18.19.3",
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^4.5.1",
|
||||
"vue-tsc": "^1.8.25"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
9
public/.htaccess
Normal file
9
public/.htaccess
Normal file
@ -0,0 +1,9 @@
|
||||
# Handle History mode in Vue Router
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /image-optimizer/
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /image-optimizer/index.html [L]
|
||||
</IfModule>
|
@ -45,7 +45,7 @@
|
||||
|
||||
<footer class="bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700">
|
||||
<div class="container mx-auto px-4 py-6 text-center text-dark-500 dark:text-dark-400">
|
||||
<p>© 2023 图片优化器. 保留所有权利。</p>
|
||||
<p>© 2025 图片优化器. 保留所有权利。</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
@ -1,32 +1,30 @@
|
||||
<template>
|
||||
<div class="text-center py-12">
|
||||
<h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">{{ $t('notFound.message') }}</p>
|
||||
<router-link to="/" class="btn btn-primary">
|
||||
{{ $t('notFound.goHome') }}
|
||||
</router-link>
|
||||
<div class="min-h-screen flex items-center justify-center bg-light-50 dark:bg-dark-900">
|
||||
<div class="text-center space-y-8">
|
||||
<div class="text-9xl 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">
|
||||
404
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-dark-900 dark:text-dark-50">
|
||||
抱歉!您访问的页面不存在。
|
||||
</h1>
|
||||
<p class="text-dark-600 dark:text-dark-300">
|
||||
该页面可能已被移动、删除或暂时无法访问。
|
||||
</p>
|
||||
<div>
|
||||
<router-link
|
||||
to="/"
|
||||
class="inline-flex items-center px-6 py-3 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"
|
||||
>
|
||||
<span>返回首页</span>
|
||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"notFound": {
|
||||
"message": "Oops! The page you're looking for doesn't exist.",
|
||||
"goHome": "Go Home"
|
||||
}
|
||||
},
|
||||
"zh": {
|
||||
"notFound": {
|
||||
"message": "抱歉!您访问的页面不存在。",
|
||||
"goHome": "返回首页"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
// 无需任何脚本代码
|
||||
</script>
|
@ -1,97 +1,92 @@
|
||||
<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="container mx-auto px-4 py-8">
|
||||
<div class="space-y-8">
|
||||
<!-- 上传区域 -->
|
||||
<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
|
||||
class="border-2 border-dashed border-primary-300 dark:border-primary-700 rounded-xl p-8 text-center"
|
||||
:class="{
|
||||
'bg-primary-50 dark:bg-primary-900/20': isDragging,
|
||||
'bg-white dark:bg-dark-800': !isDragging
|
||||
}"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@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">
|
||||
<div class="w-16 h-16 mx-auto bg-primary-100 dark:bg-primary-500/10 rounded-lg 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>
|
||||
<div>
|
||||
<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()"
|
||||
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
|
||||
@click="(fileInput as HTMLInputElement).click()"
|
||||
>
|
||||
选择文件
|
||||
选择图片
|
||||
</button>
|
||||
<span class="text-dark-600 dark:text-dark-300">或将图片拖放到此处</span>
|
||||
</div>
|
||||
<p class="text-sm text-dark-500 dark:text-dark-400">
|
||||
支持 JPG、PNG、WebP 等格式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="files.length > 0" class="space-y-4">
|
||||
<div v-for="(file, index) in files" :key="index" class="bg-white dark:bg-dark-800 rounded-lg p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0 pr-4">
|
||||
<p class="font-medium text-dark-900 dark:text-dark-50 truncate">{{ file.originalFile.name }}</p>
|
||||
<p class="text-sm text-dark-500 dark:text-dark-400">{{ formatFileSize(file.originalFile.size) }}</p>
|
||||
<div v-if="file.processedSize !== undefined" class="mt-1 text-sm">
|
||||
<span class="text-primary-600 dark:text-primary-400">
|
||||
处理完成
|
||||
- 压缩率 {{ Math.round((1 - file.processedSize / file.originalFile.size) * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-if="file.processedSize !== undefined"
|
||||
class="px-3 py-1 bg-primary-500 hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-700 text-white rounded"
|
||||
@click="downloadFile(file)"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-dark-500 hover:text-dark-700 dark:text-dark-400 dark:hover:text-dark-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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</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 v-if="files.length > 0" class="bg-white dark:bg-dark-800 rounded-lg p-6 shadow-sm space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-dark-700 dark:text-dark-300 mb-2">
|
||||
<label class="block text-sm font-medium text-dark-900 dark:text-dark-50 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"
|
||||
<select
|
||||
v-model="outputFormat"
|
||||
class="w-full bg-light-50 dark:bg-dark-700 border border-dark-200 dark:border-dark-600 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400"
|
||||
>
|
||||
<option value="jpeg">JPEG</option>
|
||||
<option value="png">PNG</option>
|
||||
@ -99,51 +94,38 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-dark-700 dark:text-dark-300 mb-2">
|
||||
质量
|
||||
<label class="block text-sm font-medium text-dark-900 dark:text-dark-50 mb-2">
|
||||
压缩质量
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-4">
|
||||
<input
|
||||
v-model="quality"
|
||||
type="range"
|
||||
v-model="options.quality"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full accent-primary-500"
|
||||
max="1"
|
||||
step="0.1"
|
||||
class="flex-1"
|
||||
/>
|
||||
<div class="flex justify-between text-sm text-dark-500 dark:text-dark-400">
|
||||
<span>压缩</span>
|
||||
<span>{{ options.quality }}%</span>
|
||||
<span>原图</span>
|
||||
</div>
|
||||
<span class="text-dark-900 dark:text-dark-50 w-12 text-center">
|
||||
{{ Math.round(quality * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4 pt-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<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"
|
||||
class="px-6 py-2 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="processAllFiles"
|
||||
>
|
||||
<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"
|
||||
v-if="hasProcessedFiles"
|
||||
class="px-6 py-2 border border-primary-500 dark:border-primary-400 text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg transition-colors duration-200"
|
||||
@click="downloadAllFiles"
|
||||
>
|
||||
<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>
|
||||
@ -152,127 +134,110 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
interface ProcessedFile {
|
||||
file: File
|
||||
preview: string
|
||||
processed?: boolean
|
||||
processedSize?: number
|
||||
originalFile: File
|
||||
processedBlob?: Blob
|
||||
processedSize?: number
|
||||
}
|
||||
|
||||
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 outputFormat = ref<'jpeg' | 'png' | 'webp'>('jpeg')
|
||||
const quality = ref(0.8)
|
||||
|
||||
const hasProcessedFiles = computed(() => {
|
||||
return files.value.some(file => file.processed)
|
||||
return files.value.some(file => file.processedSize !== undefined)
|
||||
})
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
function handleDrop(e: DragEvent) {
|
||||
isDragging.value = false
|
||||
if (e.dataTransfer?.files) {
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
if (!e.dataTransfer) return
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'))
|
||||
addFiles(droppedFiles)
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
handleFiles(input.files)
|
||||
}
|
||||
function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (!target.files) return
|
||||
|
||||
const selectedFiles = Array.from(target.files).filter(file => file.type.startsWith('image/'))
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
function addFiles(newFiles: File[]) {
|
||||
const processedFiles: ProcessedFile[] = newFiles.map(file => ({
|
||||
originalFile: file
|
||||
}))
|
||||
files.value.push(...processedFiles)
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
function removeFile(index: number) {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
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]
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const processFiles = async () => {
|
||||
isProcessing.value = true
|
||||
async function processFile(file: ProcessedFile) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
const options = {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true,
|
||||
fileType: `image/${outputFormat.value}`,
|
||||
initialQuality: quality.value
|
||||
}
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
|
||||
const compressedBlob = await imageCompression(file.originalFile, options)
|
||||
file.processedBlob = compressedBlob
|
||||
file.processedSize = compressedBlob.size
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadAll = async () => {
|
||||
async function processAllFiles() {
|
||||
for (const file of files.value) {
|
||||
await processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(file: ProcessedFile) {
|
||||
if (!file.processedBlob) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(file.processedBlob)
|
||||
link.download = `optimized_${file.originalFile.name.replace(/\.[^/.]+$/, '')}.${outputFormat.value}`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
async function downloadAllFiles() {
|
||||
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)
|
||||
if (file.processedBlob) {
|
||||
const fileName = `optimized_${file.originalFile.name.replace(/\.[^/.]+$/, '')}.${outputFormat.value}`
|
||||
zip.file(fileName, file.processedBlob)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const content = await zip.generateAsync({ type: 'blob' })
|
||||
const url = URL.createObjectURL(content)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.href = URL.createObjectURL(content)
|
||||
link.download = 'optimized_images.zip'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
</script>
|
12
tsconfig.app.json
Normal file
12
tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +1,11 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
"types": ["vite/client", "node"],
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
@ -1,13 +1,15 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/image-optimizer/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@ -21,10 +23,9 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia', 'vue-i18n'],
|
||||
'ui-vendor': ['tailwindcss'],
|
||||
},
|
||||
},
|
||||
'vue-vendor': ['vue', 'vue-router']
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user