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
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目
|
# 克隆项目
|
||||||
git clone https://github.com/your-username/image-optimizer.git
|
git clone https://ckk.photo8.site/Snowz/image-optimizer.git
|
||||||
|
|
||||||
# 进入项目目录
|
# 进入项目目录
|
||||||
cd image-optimizer
|
cd image-optimizer
|
||||||
|
3778
package-lock.json
generated
3778
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",
|
"name": "image-optimizer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A modern online image optimization and processing tool that runs entirely in the browser",
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"build-only": "vite build",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^10.3.0",
|
"vue": "^3.3.11",
|
||||||
"autoprefixer": "^10.4.14",
|
"vue-router": "^4.2.5",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"jszip": "^3.10.1",
|
"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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.4.5",
|
"@tsconfig/node18": "^18.2.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@types/node": "^18.19.3",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@vue/tsconfig": "^0.5.0",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.46.0",
|
"npm-run-all": "^4.1.5",
|
||||||
"eslint-plugin-vue": "^9.15.1",
|
"postcss": "^8.4.32",
|
||||||
"typescript": "^5.1.6",
|
"tailwindcss": "^3.3.6",
|
||||||
"vite": "^4.4.7",
|
"typescript": "~5.3.0",
|
||||||
"vitest": "^0.33.0",
|
"vite": "^4.5.1",
|
||||||
"vue-tsc": "^1.8.8"
|
"vue-tsc": "^1.8.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
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">
|
<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">
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
@ -1,32 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center py-12">
|
<div class="min-h-screen flex items-center justify-center bg-light-50 dark:bg-dark-900">
|
||||||
<h1 class="text-6xl font-bold text-gray-900 mb-4">404</h1>
|
<div class="text-center space-y-8">
|
||||||
<p class="text-xl text-gray-600 mb-8">{{ $t('notFound.message') }}</p>
|
<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">
|
||||||
<router-link to="/" class="btn btn-primary">
|
404
|
||||||
{{ $t('notFound.goHome') }}
|
</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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
// 无需任何脚本代码
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<i18n>
|
|
||||||
{
|
|
||||||
"en": {
|
|
||||||
"notFound": {
|
|
||||||
"message": "Oops! The page you're looking for doesn't exist.",
|
|
||||||
"goHome": "Go Home"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh": {
|
|
||||||
"notFound": {
|
|
||||||
"message": "抱歉!您访问的页面不存在。",
|
|
||||||
"goHome": "返回首页"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</i18n>
|
|
@ -1,97 +1,92 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
<div class="space-y-8">
|
<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
|
<div
|
||||||
class="border-2 border-dashed rounded-xl p-8 text-center mx-6 my-6 transition-all duration-200"
|
class="border-2 border-dashed border-primary-300 dark:border-primary-700 rounded-xl p-8 text-center"
|
||||||
:class="[
|
:class="{
|
||||||
isDragging
|
'bg-primary-50 dark:bg-primary-900/20': isDragging,
|
||||||
? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-500/5'
|
'bg-white dark:bg-dark-800': !isDragging
|
||||||
: '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"
|
@drop.prevent="handleDrop"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
class="hidden"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
|
class="hidden"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-dark-600 dark:text-dark-300">
|
<div>
|
||||||
<p class="mb-2 text-lg">拖放图片到这里</p>
|
|
||||||
<p class="text-dark-400 dark:text-dark-500">或</p>
|
|
||||||
<button
|
<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"
|
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
|
||||||
@click="$refs.fileInput.click()"
|
@click="(fileInput as HTMLInputElement).click()"
|
||||||
>
|
>
|
||||||
选择文件
|
选择图片
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-dark-600 dark:text-dark-300">或将图片拖放到此处</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-dark-500 dark:text-dark-400">
|
||||||
|
支持 JPG、PNG、WebP 等格式
|
||||||
|
</p>
|
||||||
</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"
|
<div v-if="files.length > 0" class="space-y-4">
|
||||||
class="flex items-center space-x-4 p-4 rounded-lg transition-all duration-200"
|
<div v-for="(file, index) in files" :key="index" class="bg-white dark:bg-dark-800 rounded-lg p-4 shadow-sm">
|
||||||
:class="[
|
<div class="flex items-center justify-between">
|
||||||
file.processed
|
<div class="flex-1 min-w-0 pr-4">
|
||||||
? 'bg-green-50 dark:bg-green-500/5'
|
<p class="font-medium text-dark-900 dark:text-dark-50 truncate">{{ file.originalFile.name }}</p>
|
||||||
: 'bg-dark-50 dark:bg-dark-700/50'
|
<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">
|
||||||
<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" />
|
- 压缩率 {{ Math.round((1 - file.processedSize / file.originalFile.size) * 100) }}%
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
<button
|
<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"
|
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)"
|
@click="removeFile(index)"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="options.format"
|
v-model="outputFormat"
|
||||||
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"
|
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="jpeg">JPEG</option>
|
||||||
<option value="png">PNG</option>
|
<option value="png">PNG</option>
|
||||||
@ -99,51 +94,38 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</label>
|
||||||
<div class="space-y-2">
|
<div class="flex items-center space-x-4">
|
||||||
<input
|
<input
|
||||||
|
v-model="quality"
|
||||||
type="range"
|
type="range"
|
||||||
v-model="options.quality"
|
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="1"
|
||||||
class="w-full accent-primary-500"
|
step="0.1"
|
||||||
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between text-sm text-dark-500 dark:text-dark-400">
|
<span class="text-dark-900 dark:text-dark-50 w-12 text-center">
|
||||||
<span>压缩</span>
|
{{ Math.round(quality * 100) }}%
|
||||||
<span>{{ options.quality }}%</span>
|
</span>
|
||||||
<span>原图</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-4 pt-4">
|
<div class="flex justify-between items-center">
|
||||||
<button
|
<button
|
||||||
v-if="hasProcessedFiles"
|
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"
|
||||||
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="processAllFiles"
|
||||||
@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>
|
||||||
<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"
|
v-if="hasProcessedFiles"
|
||||||
:disabled="isProcessing"
|
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="processFiles"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -152,127 +134,110 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import imageCompression from 'browser-image-compression'
|
import imageCompression from 'browser-image-compression'
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
|
|
||||||
interface ProcessedFile {
|
interface ProcessedFile {
|
||||||
file: File
|
originalFile: File
|
||||||
preview: string
|
|
||||||
processed?: boolean
|
|
||||||
processedSize?: number
|
|
||||||
processedBlob?: Blob
|
processedBlob?: Blob
|
||||||
|
processedSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const isProcessing = ref(false)
|
|
||||||
const files = ref<ProcessedFile[]>([])
|
const files = ref<ProcessedFile[]>([])
|
||||||
|
const outputFormat = ref<'jpeg' | 'png' | 'webp'>('jpeg')
|
||||||
const options = reactive({
|
const quality = ref(0.8)
|
||||||
format: 'jpeg',
|
|
||||||
quality: 80
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasProcessedFiles = computed(() => {
|
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
|
isDragging.value = false
|
||||||
if (e.dataTransfer?.files) {
|
if (!e.dataTransfer) return
|
||||||
handleFiles(e.dataTransfer.files)
|
|
||||||
}
|
const droppedFiles = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'))
|
||||||
|
addFiles(droppedFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileSelect = (e: Event) => {
|
function handleFileSelect(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
if (input.files) {
|
if (!target.files) return
|
||||||
handleFiles(input.files)
|
|
||||||
}
|
const selectedFiles = Array.from(target.files).filter(file => file.type.startsWith('image/'))
|
||||||
|
addFiles(selectedFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFiles = (fileList: FileList) => {
|
function addFiles(newFiles: File[]) {
|
||||||
for (let i = 0; i < fileList.length; i++) {
|
const processedFiles: ProcessedFile[] = newFiles.map(file => ({
|
||||||
const file = fileList[i]
|
originalFile: file
|
||||||
if (file.type.startsWith('image/')) {
|
}))
|
||||||
const reader = new FileReader()
|
files.value.push(...processedFiles)
|
||||||
reader.onload = (e) => {
|
|
||||||
files.value.push({
|
|
||||||
file,
|
|
||||||
preview: e.target?.result as string
|
|
||||||
})
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
function removeFile(index: number) {
|
||||||
files.value.splice(index, 1)
|
files.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes'
|
if (bytes === 0) return '0 B'
|
||||||
const k = 1024
|
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))
|
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 () => {
|
async function processFile(file: ProcessedFile) {
|
||||||
isProcessing.value = true
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < files.value.length; i++) {
|
|
||||||
const file = files.value[i]
|
|
||||||
if (!file.processed) {
|
|
||||||
const options = {
|
const options = {
|
||||||
maxSizeMB: 1,
|
maxSizeMB: 1,
|
||||||
maxWidthOrHeight: 1920,
|
maxWidthOrHeight: 1920,
|
||||||
useWebWorker: true,
|
useWebWorker: true,
|
||||||
fileType: `image/${file.file.type.split('/')[1]}`,
|
fileType: `image/${outputFormat.value}`,
|
||||||
initialQuality: 0.8
|
initialQuality: quality.value
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const compressedBlob = await imageCompression(file.originalFile, options)
|
||||||
const compressedFile = await imageCompression(file.file, options)
|
file.processedBlob = compressedBlob
|
||||||
const processedBlob = new Blob([compressedFile], { type: `image/${file.file.type.split('/')[1]}` })
|
file.processedSize = compressedBlob.size
|
||||||
|
|
||||||
files.value[i] = {
|
|
||||||
...file,
|
|
||||||
processed: true,
|
|
||||||
processedSize: compressedFile.size,
|
|
||||||
processedBlob
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing file:', error)
|
console.error('Error processing file:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} finally {
|
async function processAllFiles() {
|
||||||
isProcessing.value = false
|
for (const file of files.value) {
|
||||||
|
await processFile(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadAll = async () => {
|
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()
|
const zip = new JSZip()
|
||||||
|
|
||||||
for (const file of files.value) {
|
for (const file of files.value) {
|
||||||
if (file.processed && file.processedBlob) {
|
if (file.processedBlob) {
|
||||||
const extension = file.file.name.split('.').pop()
|
const fileName = `optimized_${file.originalFile.name.replace(/\.[^/.]+$/, '')}.${outputFormat.value}`
|
||||||
const newFileName = `${file.file.name.split('.')[0]}_optimized.${extension}`
|
zip.file(fileName, file.processedBlob)
|
||||||
zip.file(newFileName, file.processedBlob)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: 'blob' })
|
const content = await zip.generateAsync({ type: 'blob' })
|
||||||
const url = URL.createObjectURL(content)
|
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = URL.createObjectURL(content)
|
||||||
link.download = 'optimized_images.zip'
|
link.download = 'optimized_images.zip'
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
URL.revokeObjectURL(link.href)
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
}
|
||||||
</script>
|
</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",
|
"files": [],
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
"references": [
|
||||||
"exclude": ["src/**/__tests__/*"],
|
{
|
||||||
"compilerOptions": {
|
"path": "./tsconfig.node.json"
|
||||||
"composite": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
},
|
||||||
"types": ["vite/client", "node"],
|
{
|
||||||
"target": "ESNext",
|
"path": "./tsconfig.app.json"
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
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 { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/image-optimizer/',
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
@ -21,10 +23,9 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
'vue-vendor': ['vue', 'vue-router', 'pinia', 'vue-i18n'],
|
'vue-vendor': ['vue', 'vue-router']
|
||||||
'ui-vendor': ['tailwindcss'],
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
Loading…
x
Reference in New Issue
Block a user