Initialize Image Optimizer project with essential files and configurations. Added .gitignore, README.md, and basic project structure including Vue components, routing, and image processing functionality. Configured Vite for development and build processes, and set up Tailwind CSS for styling.
This commit is contained in:
commit
0d68dc6e35
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Testing
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Production
|
||||
/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
70
README.md
Normal file
70
README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# 图片优化工具 (Image Optimizer)
|
||||
|
||||
一个完全在浏览器中运行的强大图片优化工具,无需服务器支持,保护您的隐私。
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🖼️ **多格式支持**:支持 JPG、PNG、WebP 等多种图片格式之间的转换
|
||||
- 🚀 **智能优化**:在保持图片质量的同时有效减小文件大小
|
||||
- 🔒 **隐私优先**:所有处理都在浏览器中完成,不会将数据发送到服务器
|
||||
- 🌓 **深色模式**:支持浅色/深色主题切换,提供舒适的使用体验
|
||||
- 📱 **响应式设计**:完美适配桌面端和移动端设备
|
||||
- ⚡ **快速处理**:采用现代 Web 技术,提供快速的图片处理体验
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 16
|
||||
- npm >= 8
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-username/image-optimizer.git
|
||||
|
||||
# 进入项目目录
|
||||
cd image-optimizer
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- [Vue 3](https://vuejs.org/) - 渐进式 JavaScript 框架
|
||||
- [Vite](https://vitejs.dev/) - 下一代前端构建工具
|
||||
- [TypeScript](https://www.typescriptlang.org/) - JavaScript 的超集
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - 实用优先的 CSS 框架
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
1. 打开应用后,您可以通过拖拽或点击上传按钮来选择需要处理的图片
|
||||
2. 选择目标格式和压缩质量
|
||||
3. 点击"开始处理"按钮
|
||||
4. 处理完成后,可以预览和下载优化后的图片
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交 Pull Request 或创建 Issue!
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建您的特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交您的更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开一个 Pull Request
|
||||
|
||||
## 📄 开源协议
|
||||
|
||||
本项目基于 MIT 协议开源 - 查看 [LICENSE](LICENSE) 文件了解更多细节
|
16
env.d.ts
vendored
Normal file
16
env.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Optimizer</title>
|
||||
<meta name="description" content="A modern online image optimization tool that runs entirely in your browser">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
39
nginx.conf
Normal file
39
nginx.conf
Normal file
@ -0,0 +1,39 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.photo8.site;
|
||||
root /wwwroot/api.photo8.site/image-optimizer/dist;
|
||||
index index.html;
|
||||
|
||||
# 启用 gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1000;
|
||||
|
||||
# 缓存静态资源
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# 处理 Vue Router 的路由
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 安全相关配置
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'";
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
}
|
4643
package-lock.json
generated
Normal file
4643
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "image-optimizer",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern online image optimization and processing tool that runs entirely in the browser",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
101
src/App.vue
Normal file
101
src/App.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-gray-50 dark:bg-dark-900" :class="{ 'dark': isDark }">
|
||||
<header class="bg-white dark:bg-dark-800 shadow-soft">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<router-link to="/" class="flex items-center space-x-2 group">
|
||||
<div class="w-10 h-10 bg-primary-500 dark:bg-primary-600 rounded-lg flex items-center justify-center shadow-soft transition-all duration-300 group-hover:shadow-soft-lg">
|
||||
<svg class="w-6 h-6 text-white" 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>
|
||||
<span class="text-xl 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">
|
||||
图片优化器
|
||||
</span>
|
||||
</router-link>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="p-2.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-dark-700 dark:hover:bg-dark-600 transition-colors duration-200"
|
||||
:title="isDark ? '切换到浅色模式' : '切换到深色模式'"
|
||||
>
|
||||
<svg v-if="isDark" class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 text-dark-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 container mx-auto px-4 py-8">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark')
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
})
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
isDark.value = e.matches
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
27
src/assets/main.css
Normal file
27
src/assets/main.css
Normal file
@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
body {
|
||||
@apply bg-gray-50 min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-md font-medium transition-colors duration-200;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-primary-500 text-white hover:bg-primary-600;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-700 hover:bg-gray-300;
|
||||
}
|
||||
.input {
|
||||
@apply px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500;
|
||||
}
|
||||
}
|
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@ -0,0 +1,12 @@
|
||||
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')
|
30
src/router/index.ts
Normal file
30
src/router/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/optimize',
|
||||
name: 'optimize',
|
||||
component: () => import('../views/OptimizeView.vue')
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/AboutView.vue')
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('../views/NotFoundView.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
83
src/utils/imageProcessor.ts
Normal file
83
src/utils/imageProcessor.ts
Normal file
@ -0,0 +1,83 @@
|
||||
export interface ImageProcessorOptions {
|
||||
format: 'jpeg' | 'png' | 'webp'
|
||||
quality: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export class ImageProcessor {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
|
||||
constructor() {
|
||||
this.canvas = document.createElement('canvas')
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas context not supported')
|
||||
}
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
async processImage(file: File, options: ImageProcessorOptions): Promise<Blob> {
|
||||
const image = await this.loadImage(file)
|
||||
this.setCanvasSize(image, options)
|
||||
this.ctx.drawImage(image, 0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to process image'))
|
||||
}
|
||||
},
|
||||
`image/${options.format}`,
|
||||
options.quality / 100
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = reject
|
||||
image.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
private setCanvasSize(image: HTMLImageElement, options: ImageProcessorOptions) {
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
|
||||
if (options.width && options.height) {
|
||||
width = options.width
|
||||
height = options.height
|
||||
} else if (options.width) {
|
||||
height = (options.width / image.width) * image.height
|
||||
width = options.width
|
||||
} else if (options.height) {
|
||||
width = (options.height / image.height) * image.width
|
||||
height = options.height
|
||||
}
|
||||
|
||||
this.canvas.width = width
|
||||
this.canvas.height = height
|
||||
}
|
||||
|
||||
async getImageInfo(file: File): Promise<{
|
||||
width: number
|
||||
height: number
|
||||
size: number
|
||||
type: string
|
||||
}> {
|
||||
const image = await this.loadImage(file)
|
||||
return {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}
|
||||
}
|
||||
}
|
47
src/views/AboutView.vue
Normal file
47
src/views/AboutView.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
关于图片优化器
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600">
|
||||
一个现代化的图片优化和转换工具
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm space-y-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-4">主要特点</h2>
|
||||
<ul class="space-y-4">
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-primary-500 mr-2 mt-1" 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>所有处理都在浏览器中完成,确保您的隐私安全</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-primary-500 mr-2 mt-1" 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>支持多种图片格式,包括 JPG、PNG、WebP 等</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-primary-500 mr-2 mt-1" 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>智能优化算法,在保持质量的同时减小文件大小</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-4">技术栈</h2>
|
||||
<p class="text-gray-600">使用 Vue 3、TypeScript 和现代 Web 技术构建</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 移除 i18n 相关代码
|
||||
</script>
|
60
src/views/HomeView.vue
Normal file
60
src/views/HomeView.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white dark:bg-dark-800 p-6 rounded-xl shadow-soft hover:shadow-soft-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-500/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 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>
|
||||
<h3 class="text-xl font-semibold text-dark-900 dark:text-dark-50 mb-2">格式转换</h3>
|
||||
<p class="text-dark-600 dark:text-dark-300">支持 JPG、PNG、WebP 等多种图片格式之间的转换</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-dark-800 p-6 rounded-xl shadow-soft hover:shadow-soft-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-500/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-dark-900 dark:text-dark-50 mb-2">智能优化</h3>
|
||||
<p class="text-dark-600 dark:text-dark-300">在保持图片质量的同时减小文件大小</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-dark-800 p-6 rounded-xl shadow-soft hover:shadow-soft-lg transition-all duration-300">
|
||||
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-500/10 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-dark-900 dark:text-dark-50 mb-2">隐私优先</h3>
|
||||
<p class="text-dark-600 dark:text-dark-300">所有处理都在浏览器中完成,不会将数据发送到服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<router-link
|
||||
to="/optimize"
|
||||
class="inline-flex items-center px-8 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="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 无需任何脚本代码
|
||||
</script>
|
32
src/views/NotFoundView.vue
Normal file
32
src/views/NotFoundView.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<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>
|
||||
</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>
|
278
src/views/OptimizeView.vue
Normal file
278
src/views/OptimizeView.vue
Normal file
@ -0,0 +1,278 @@
|
||||
<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>
|
44
tailwind.config.js
Normal file
44
tailwind.config.js
Normal file
@ -0,0 +1,44 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
dark: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
'soft-lg': '0 10px 30px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
30
vite.config.ts
Normal file
30
vite.config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia', 'vue-i18n'],
|
||||
'ui-vendor': ['tailwindcss'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user