初始化

This commit is contained in:
李琦
2026-06-17 08:05:20 +08:00
parent 6dfe73191c
commit fb3d707a32
36 changed files with 7002 additions and 261 deletions

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

60
AGENTS.md Normal file
View File

@@ -0,0 +1,60 @@
# AGENTS.md
## Project Overview
Wails v2 desktop app ("XK 文件工具箱") — file processing toolbox for PDF, Word, Excel, and images. Go backend + Vue 3 frontend, compiled to native Windows executable.
## Build & Dev Commands
```bash
wails dev # Development with hot reload (Vite + Go)
wails build # Production build → build/bin/xk.exe
cd frontend && npm run build # Frontend only
cd frontend && npm run dev # Vite dev server only
go build ./... # Backend only (check compilation)
```
**Prerequisites**: Go 1.25+, Node.js, Wails CLI (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`)
## Architecture
```
main.go → Wails app entry, binds FileHandler to frontend
handlers.go → FileHandler: all Go methods exposed to JS via Wails IPC
app.go → App struct (minimal, unused greeting method)
services/ → Business logic per format
pdf_service.go → gopdf-based PDF operations
word_service.go → Word to PDF conversion
excel_service.go → Excel to CSV/JSON conversion
image_service.go → imaging library: compress, resize, rotate, crop, grayscale, brightness, contrast, saturation, flip, removebg
utils.go → Path helpers, JSON serialization
frontend/src/
App.vue → Root layout: 64px sidebar + main content
components/
HomeView.vue → Tool grid with shortcuts and recent uses
ToolView.vue → ALL tool UI in one component (~1800 lines, handles all categories)
SettingsView.vue → Output directory config
router/index.js → Hash router: /, /tool/:category/:action?, /settings
```
## Key Patterns
- **Wails IPC**: Frontend calls Go via `window.go.main.FileHandler.MethodName()`. Bindings auto-generated in `frontend/wailsjs/`.
- **Single tool component**: `ToolView.vue` handles all tools (PDF/Word/Excel/Image) via route params `category` and `action`. Switching tools resets state.
- **Auto-processing**: Image tools auto-process on parameter change (500ms debounce) via `watch` on param refs.
- **CSS variables**: Theme defined in `style.css` `:root` — dark SaaS style with glassmorphism. All colors via CSS vars (`--bg-primary`, `--accent-primary`, etc.).
- **Element Plus**: UI component library. Global overrides in `style.css` with `!important`.
- **Image processing**: Backend uses `github.com/disintegration/imaging`. Frontend can do canvas-based operations (background removal uses in-browser pixel manipulation).
## Gotchas
- `ToolView.vue` is ~1800 lines. Changes to one tool's UI can affect others. Search for the specific tool's section before editing.
- Image processing has TWO preview systems: backend-processed (via `ProcessFile`) and frontend canvas-based (background removal). Don't confuse them.
- `FileResult` returns both `size` (output) and `originalSize` (input) for size comparison display.
- Backend temp files go to `os.TempDir()` with `xk_` prefix. They are never cleaned up automatically.
- The `removebg` tool has both a backend threshold (`req.Threshold`) and a frontend canvas tolerance (`bgRemovalTolerance`). The frontend tolerance is what actually controls the canvas-based removal.
- HomeView.vue had `overflow: hidden` that blocked scrolling — use `overflow: visible` if you see scroll issues.
## UI/UX Skill
`.opencode/skills/ui-ux-pro-max/` contains a searchable design database. Use `python scripts/search.py "<query>" --design-system` to generate design systems. Requires Python 3.

View File

@@ -8,7 +8,10 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"vue": "^3.2.37"
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.14.1",
"vue": "^3.2.37",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
@@ -57,6 +60,22 @@
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"engines": {
"node": ">=14"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz",
@@ -89,11 +108,61 @@
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
},
"node_modules/@vitejs/plugin-vue": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz",
@@ -153,6 +222,11 @@
"@vue/shared": "3.5.35"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": {
"version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
@@ -198,11 +272,81 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="
},
"node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="
},
"node_modules/element-plus": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz",
"integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.7.6",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "14.3.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.20",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.1"
},
"peerDependencies": {
"vue": "^3.3.7"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -635,6 +779,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -643,6 +807,11 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -660,6 +829,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw=="
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -822,6 +996,25 @@
"optional": true
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.4.tgz",
"integrity": "sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw=="
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
}
}

View File

@@ -9,7 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.37"
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.14.1",
"vue": "^3.2.37",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",

View File

@@ -1 +1 @@
21d2a2199c4fb87865d8160b492f51c3
2189702639db78bed99ff1bbe16eeafc

View File

@@ -1,21 +1,281 @@
<script setup>
import HelloWorld from './components/HelloWorld.vue'</script>
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'
const router = useRouter()
const route = useRoute()
const isHome = computed(() => route.name === 'home')
function navigateTo(path) {
router.push(path)
}
function minimizeWindow() {
window.runtime?.WindowMinimise()
}
function maximizeWindow() {
window.runtime?.WindowToggleMaximise()
}
function closeWindow() {
window.runtime?.Quit()
}
</script>
<template>
<img id="logo" alt="Wails logo" src="./assets/images/logo-universal.png"/>
<HelloWorld/>
<div class="app-layout">
<div class="titlebar">
<div class="window-controls">
<button class="window-btn window-btn-close" @click="closeWindow">
<svg viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7M7 1L1 7" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button class="window-btn window-btn-minimize" @click="minimizeWindow">
<svg viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4H7" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button class="window-btn window-btn-maximize" @click="maximizeWindow">
<svg viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1H7V7H1V1Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="titlebar-spacer"></div>
</div>
<div class="app-body">
<aside class="sidebar">
<div class="sidebar-logo" @click="navigateTo('/')">
<el-icon :size="22"><SetUp /></el-icon>
</div>
<nav class="sidebar-nav">
<div
class="nav-item"
:class="{ active: route.path === '/' }"
@click="navigateTo('/')"
>
<el-icon><HomeFilled /></el-icon>
</div>
<div class="nav-divider"></div>
<div class="nav-group-label">工具</div>
<div
class="nav-item"
:class="{ active: route.params.category === 'pdf' }"
@click="navigateTo('/tool/pdf')"
>
<el-icon><Document /></el-icon>
</div>
<div
class="nav-item"
:class="{ active: route.params.category === 'word' }"
@click="navigateTo('/tool/word')"
>
<el-icon><Notebook /></el-icon>
</div>
<div
class="nav-item"
:class="{ active: route.params.category === 'excel' }"
@click="navigateTo('/tool/excel')"
>
<el-icon><Grid /></el-icon>
</div>
<div
class="nav-item"
:class="{ active: route.params.category === 'image' }"
@click="navigateTo('/tool/image')"
>
<el-icon><Picture /></el-icon>
</div>
<div
class="nav-item"
:class="{ active: route.params.category === 'convert' }"
@click="navigateTo('/tool/convert')"
>
<el-icon><Switch /></el-icon>
</div>
<div class="nav-divider"></div>
<div
class="nav-item"
:class="{ active: route.name === 'settings' }"
@click="navigateTo('/settings')"
>
<el-icon><Setting /></el-icon>
</div>
</nav>
</aside>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<style>
#logo {
display: block;
width: 50%;
height: 50%;
margin: auto;
padding: 10% 0 0;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-origin: content-box;
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
}
.titlebar {
display: flex;
align-items: center;
height: 52px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--glass-border);
flex-shrink: 0;
}
.titlebar-spacer {
flex: 1;
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 64px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-right: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
flex-shrink: 0;
}
.sidebar-logo {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--gradient-primary);
border-radius: var(--radius-md);
color: #ffffff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
margin-bottom: 24px;
transition: all var(--transition-normal);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.sidebar-logo:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.sidebar-nav {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
width: 100%;
}
.nav-item {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition-normal);
position: relative;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleY(0);
width: 3px;
height: 20px;
background: var(--gradient-primary);
border-radius: 0 4px 4px 0;
transition: transform var(--transition-normal);
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
}
.nav-item.active {
color: var(--accent-primary);
background: rgba(59, 130, 246, 0.1);
}
.nav-item.active::before {
transform: translateY(-50%) scaleY(1);
}
.nav-item .el-icon {
font-size: 18px;
}
.nav-divider {
width: 24px;
height: 1px;
background: var(--glass-border);
margin: 8px 0;
}
.nav-group-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 4px;
}
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
/* Page Transition */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,423 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const shortcuts = ref([])
const recentUses = ref([])
const allTools = [
{ id: 'pdf-compress', name: '压缩PDF', category: 'pdf', action: 'compress', icon: 'FolderChecked', color: '#f38ba8' },
{ id: 'pdf-split', name: '分割PDF', category: 'pdf', action: 'split', icon: 'Document', color: '#f38ba8' },
{ id: 'pdf-rotate', name: '旋转PDF', category: 'pdf', action: 'rotate', icon: 'RefreshRight', color: '#f38ba8' },
{ id: 'pdf-watermark', name: '添加水印', category: 'pdf', action: 'watermark', icon: 'EditPen', color: '#f38ba8' },
{ id: 'word-pdf', name: 'Word转PDF', category: 'word', action: 'pdf', icon: 'Document', color: '#89b4fa' },
{ id: 'excel-csv', name: 'Excel转CSV', category: 'excel', action: 'csv', icon: 'Document', color: '#a6e3a1' },
{ id: 'excel-json', name: 'Excel转JSON', category: 'excel', action: 'json', icon: 'Document', color: '#a6e3a1' },
{ id: 'image-convert', name: '图片格式转换', category: 'image', action: 'convert', icon: 'Switch', color: '#f9e2af' },
{ id: 'image-compress', name: '压缩图片', category: 'image', action: 'compress', icon: 'FolderChecked', color: '#f9e2af' },
{ id: 'image-resize', name: '调整大小', category: 'image', action: 'resize', icon: 'FullScreen', color: '#f9e2af' },
{ id: 'image-rotate', name: '旋转图片', category: 'image', action: 'rotate', icon: 'RefreshRight', color: '#f9e2af' },
{ id: 'image-crop', name: '裁剪图片', category: 'image', action: 'crop', icon: 'Crop', color: '#f9e2af' },
{ id: 'image-grayscale', name: '灰度处理', category: 'image', action: 'grayscale', icon: 'Moon', color: '#f9e2af' },
{ id: 'image-brightness', name: '调整亮度', category: 'image', action: 'brightness', icon: 'Sunny', color: '#f9e2af' },
{ id: 'image-removebg', name: '背景去除', category: 'image', action: 'removebg', icon: 'MagicStick', color: '#f9e2af' },
{ id: 'convert-pdf-to-image', name: 'PDF转图片', category: 'convert', action: 'pdf-to-image', icon: 'Document', color: '#cba6f7' },
{ id: 'convert-word-to-pdf', name: 'Word转PDF', category: 'convert', action: 'word-to-pdf', icon: 'Document', color: '#cba6f7' },
{ id: 'convert-excel-to-csv', name: 'Excel转CSV', category: 'convert', action: 'excel-to-csv', icon: 'Document', color: '#cba6f7' },
{ id: 'convert-excel-to-json', name: 'Excel转JSON', category: 'convert', action: 'excel-to-json', icon: 'Document', color: '#cba6f7' },
{ id: 'convert-image-to-pdf', name: '图片转PDF', category: 'convert', action: 'image-to-pdf', icon: 'Picture', color: '#cba6f7' },
]
const categoryNames = {
pdf: 'PDF 工具',
word: 'Word 工具',
excel: 'Excel 工具',
image: '图片工具',
convert: '格式转换',
}
onMounted(async () => {
try {
shortcuts.value = await window.go.main.FileHandler.GetShortcuts()
recentUses.value = await window.go.main.FileHandler.GetRecentUses()
} catch (e) {
shortcuts.value = allTools.slice(0, 5)
recentUses.value = []
}
})
function navigateTo(tool) {
if (tool.category === 'convert') {
const parts = tool.action.split('-to-')
const from = parts[0]
const to = parts[1]
const categoryMap = { pdf: 'pdf', word: 'word', excel: 'excel', image: 'image', csv: 'excel', json: 'excel' }
router.push({ name: 'tool', params: { category: categoryMap[from] || from, action: to } })
} else {
router.push({ name: 'tool', params: { category: tool.category, action: tool.action } })
}
}
function navigateToRecent(recent) {
const tool = allTools.find(t => t.id === recent.id)
if (tool) {
navigateTo(tool)
}
}
function getToolById(id) {
return allTools.find(t => t.id === id)
}
async function toggleShortcut(tool) {
const idx = shortcuts.value.findIndex(s => s.id === tool.id)
if (idx >= 0) {
shortcuts.value.splice(idx, 1)
} else {
shortcuts.value.push({ id: tool.id, name: tool.name, category: tool.category, icon: tool.icon })
}
try {
await window.go.main.FileHandler.SaveShortcuts(shortcuts.value)
} catch (e) {}
}
function isShortcut(toolId) {
return shortcuts.value.some(s => s.id === toolId)
}
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>
<template>
<div class="home">
<div class="home-bg-orb orb-1"></div>
<div class="home-bg-orb orb-2"></div>
<div class="home-header animate-slide-up">
<div class="home-title">
<el-icon :size="28"><SetUp /></el-icon>
<span>XK 文件工具箱</span>
</div>
<el-button text @click="router.push('/settings')">
<el-icon><Setting /></el-icon>
<span style="margin-left:4px">设置</span>
</el-button>
</div>
<div class="section animate-slide-up" style="animation-delay: 0.05s" v-if="shortcuts.length > 0">
<div class="section-title">
<el-icon><Star /></el-icon>
<span>快捷入口</span>
</div>
<div class="shortcuts-grid">
<div
v-for="(shortcut, index) in shortcuts"
:key="shortcut.id"
class="shortcut-card"
:style="{ animationDelay: `${index * 0.03}s` }"
@click="navigateTo(getToolById(shortcut.id) || { category: shortcut.category, action: shortcut.id.split('-').slice(1).join('-') })"
>
<div class="shortcut-icon" :style="{ color: (getToolById(shortcut.id) || {}).color || '#89b4fa' }">
<el-icon :size="26"><component :is="shortcut.icon || 'Document'" /></el-icon>
</div>
<div class="shortcut-name">{{ shortcut.name }}</div>
</div>
</div>
</div>
<div class="section animate-slide-up" style="animation-delay: 0.1s" v-if="recentUses.length > 0">
<div class="section-title">
<el-icon><Clock /></el-icon>
<span>最近使用</span>
</div>
<div class="recent-grid">
<div
v-for="recent in recentUses"
:key="recent.id + recent.usedAt"
class="recent-card"
@click="navigateToRecent(recent)"
>
<div class="recent-icon" :style="{ color: (getToolById(recent.id) || {}).color || '#89b4fa' }">
<el-icon :size="18"><component :is="(getToolById(recent.id) || {}).icon || 'Document'" /></el-icon>
</div>
<div class="recent-info">
<div class="recent-name">{{ recent.name }}</div>
<div class="recent-time">{{ formatTime(recent.usedAt) }}</div>
</div>
</div>
</div>
</div>
<div class="section animate-slide-up" style="animation-delay: 0.15s" v-for="(catKey, catIndex) in ['pdf', 'word', 'excel', 'image', 'convert']" :key="catKey">
<div class="section-title">
<el-icon><Grid /></el-icon>
<span>{{ categoryNames[catKey] }}</span>
</div>
<div class="tools-grid">
<div
v-for="(tool, index) in allTools.filter(t => t.category === catKey)"
:key="tool.id"
class="tool-card"
:style="{ animationDelay: `${(catIndex * 0.05) + (index * 0.02)}s` }"
@click="navigateTo(tool)"
>
<div class="tool-icon" :style="{ color: tool.color, background: `${tool.color}15` }">
<el-icon :size="18"><component :is="tool.icon" /></el-icon>
</div>
<div class="tool-name">{{ tool.name }}</div>
<el-button
class="star-btn"
:type="isShortcut(tool.id) ? 'warning' : 'info'"
:icon="isShortcut(tool.id) ? 'StarFilled' : 'Star'"
circle
size="small"
@click.stop="toggleShortcut(tool)"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.home {
padding: 28px 36px;
width: 100%;
position: relative;
overflow: visible;
}
.home-bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
pointer-events: none;
}
.orb-1 {
width: 400px;
height: 400px;
background: var(--accent-primary);
top: -100px;
right: -100px;
}
.orb-2 {
width: 300px;
height: 300px;
background: var(--accent-purple);
bottom: -50px;
left: -50px;
}
.home-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 36px;
position: relative;
z-index: 1;
}
.home-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.home-title .el-icon {
color: var(--accent-primary);
}
.section {
margin-bottom: 32px;
position: relative;
z-index: 1;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 14px;
text-transform: uppercase;
letter-spacing: 1px;
}
.section-title .el-icon {
font-size: 14px;
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
}
.shortcut-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 22px 12px 16px;
cursor: pointer;
transition: all var(--transition-normal);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
animation: slideUp var(--transition-slow) forwards;
opacity: 0;
}
.shortcut-card:hover {
border-color: var(--accent-primary);
transform: translateY(-4px);
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15);
}
.shortcut-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
transition: all var(--transition-normal);
}
.shortcut-card:hover .shortcut-icon {
background: rgba(255,255,255,0.08);
}
.shortcut-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
text-align: center;
}
.recent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.recent-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal);
}
.recent-card:hover {
border-color: rgba(255,255,255,0.15);
background: rgba(255, 255, 255, 0.05);
}
.recent-icon {
flex-shrink: 0;
}
.recent-info {
flex: 1;
min-width: 0;
}
.recent-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recent-time {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
}
.tool-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
animation: slideUp var(--transition-slow) forwards;
opacity: 0;
}
.tool-card:hover {
border-color: rgba(255,255,255,0.15);
background: rgba(255, 255, 255, 0.05);
}
.tool-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition-normal);
}
.tool-card:hover .tool-icon {
transform: scale(1.1);
}
.tool-name {
font-size: 13px;
color: var(--text-primary);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.star-btn {
opacity: 0;
transition: opacity var(--transition-fast);
flex-shrink: 0;
}
.tool-card:hover .star-btn {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const config = ref({ defaultOutputDir: '' })
onMounted(async () => {
try {
config.value = await window.go.main.FileHandler.GetConfig()
} catch (e) {}
})
async function selectDir() {
try {
const path = await window.go.main.FileHandler.OpenFileDialog('选择默认输出目录', ['*'])
if (path) {
config.value.defaultOutputDir = path.replace(/[/\\][^/\\]+$/, '')
}
} catch (e) {}
}
async function saveConfig() {
try {
await window.go.main.FileHandler.SaveConfig(config.value)
ElMessage.success('配置已保存')
} catch (e) {
ElMessage.error('保存失败: ' + e.message)
}
}
function resetConfig() {
config.value.defaultOutputDir = ''
}
</script>
<template>
<div class="settings">
<div class="settings-header">
<el-button text @click="router.push('/')">
<el-icon><ArrowLeft /></el-icon>
<span style="margin-left:4px">返回</span>
</el-button>
<div class="settings-title">
<el-icon :size="22"><Setting /></el-icon>
<span>设置</span>
</div>
<div style="width:80px"></div>
</div>
<div class="settings-body">
<div class="settings-card">
<div class="card-title">文件输出</div>
<div class="card-desc">配置处理后文件的默认保存位置</div>
<div class="form-group">
<div class="form-label">默认输出目录</div>
<div class="form-row">
<el-input
v-model="config.defaultOutputDir"
placeholder="默认: 与输入文件同目录"
/>
<el-button @click="selectDir">
<el-icon><FolderOpened /></el-icon>
浏览
</el-button>
</div>
<div class="form-hint">留空则输出文件保存在与源文件相同的目录下</div>
</div>
<div class="form-actions">
<el-button @click="resetConfig">重置</el-button>
<el-button type="primary" @click="saveConfig">
<el-icon><Check /></el-icon>
保存配置
</el-button>
</div>
</div>
<div class="settings-card">
<div class="card-title">关于</div>
<div class="about-info">
<div class="about-row">
<span class="about-label">应用名称</span>
<span class="about-value">XK 文件工具箱</span>
</div>
<div class="about-row">
<span class="about-label">版本</span>
<span class="about-value">1.0.0</span>
</div>
<div class="about-row">
<span class="about-label">支持格式</span>
<span class="about-value">PDF / Word / Excel / 图片</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.settings {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-bottom: 1px solid var(--glass-border);
}
.settings-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.settings-title .el-icon {
color: var(--accent-primary);
}
.settings-body {
flex: 1;
padding: 24px;
overflow-y: auto;
max-width: 640px;
margin: 0 auto;
width: 100%;
}
.settings-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 20px;
transition: all var(--transition-normal);
}
.settings-card:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.card-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.form-row {
display: flex;
gap: 8px;
}
.form-row .el-input {
flex: 1;
}
.form-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.about-info {
display: flex;
flex-direction: column;
}
.about-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--glass-border);
}
.about-row:last-child {
border-bottom: none;
}
.about-label {
font-size: 13px;
color: var(--text-secondary);
}
.about-value {
font-size: 13px;
color: var(--text-primary);
}
</style>

View File

@@ -0,0 +1,840 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import ImageCompressTool from './image/ImageCompressTool.vue'
import ImageResizeTool from './image/ImageResizeTool.vue'
import ImageRotateTool from './image/ImageRotateTool.vue'
import ImageCropTool from './image/ImageCropTool.vue'
import ImageGrayscaleTool from './image/ImageGrayscaleTool.vue'
import ImageBrightnessTool from './image/ImageBrightnessTool.vue'
import ImageSharpenTool from './image/ImageSharpenTool.vue'
import ImageBlurTool from './image/ImageBlurTool.vue'
import ImageInvertTool from './image/ImageInvertTool.vue'
import ImageConvertTool from './image/ImageConvertTool.vue'
import ImageRemoveBgTool from './image/ImageRemoveBgTool.vue'
const route = useRoute()
const router = useRouter()
const category = computed(() => route.params.category || 'pdf')
const action = ref(route.params.action || '')
const filePath = ref('')
const filePreview = ref('')
const imageInfo = ref(null)
const processing = ref(false)
const resultInfo = ref(null)
const outputPath = ref('')
const compareImages = ref({ original: '', processed: '' })
const config = ref({ defaultOutputDir: '' })
const quality = ref('medium')
const watermarkText = ref('')
const angle = ref(90)
const outputFormat = ref('jpeg')
const showPreview = ref(false)
const previewSrc = ref('')
const previewIndex = ref(0)
const previewScale = ref(1)
const previewX = ref(0)
const previewY = ref(0)
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const categoryConfig = {
pdf: {
name: 'PDF 工具',
actions: [
{ id: 'compress', name: '压缩PDF', icon: 'FolderChecked', color: '#f38ba8' },
{ id: 'split', name: '分割PDF', icon: 'Document', color: '#f38ba8' },
{ id: 'rotate', name: '旋转PDF', icon: 'RefreshRight', color: '#f38ba8' },
{ id: 'watermark', name: '添加水印', icon: 'EditPen', color: '#f38ba8' },
],
fileFilters: ['*.pdf'],
},
word: {
name: 'Word 工具',
actions: [
{ id: 'pdf', name: 'Word转PDF', icon: 'Document', color: '#89b4fa' },
],
fileFilters: ['*.doc;*.docx'],
},
excel: {
name: 'Excel 工具',
actions: [
{ id: 'csv', name: 'Excel转CSV', icon: 'Document', color: '#a6e3a1' },
{ id: 'json', name: 'Excel转JSON', icon: 'Document', color: '#a6e3a1' },
],
fileFilters: ['*.xls;*.xlsx'],
},
image: {
name: '图片工具',
actions: [
{ id: 'convert', name: '格式转换', icon: 'Switch', color: '#f9e2af' },
{ id: 'compress', name: '压缩图片', icon: 'FolderChecked', color: '#f9e2af' },
{ id: 'resize', name: '调整大小', icon: 'FullScreen', color: '#f9e2af' },
{ id: 'rotate', name: '旋转图片', icon: 'RefreshRight', color: '#f9e2af' },
{ id: 'crop', name: '裁剪图片', icon: 'Crop', color: '#f9e2af' },
{ id: 'grayscale', name: '灰度处理', icon: 'Moon', color: '#f9e2af' },
{ id: 'brightness', name: '调整亮度', icon: 'Sunny', color: '#f9e2af' },
{ id: 'sharpen', name: '锐化', icon: 'Aim', color: '#f9e2af' },
{ id: 'blur', name: '模糊', icon: 'View', color: '#f9e2af' },
{ id: 'invert', name: '反色', icon: 'RefreshLeft', color: '#f9e2af' },
{ id: 'removebg', name: '背景去除', icon: 'MagicStick', color: '#f9e2af' },
],
fileFilters: ['*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico'],
},
}
const currentCat = computed(() => categoryConfig[category.value] || categoryConfig.pdf)
const currentAction = computed(() => {
return currentCat.value.actions.find(a => a.id === action.value) || currentCat.value.actions[0]
})
const isImageProcessing = computed(() => {
return category.value === 'image' && ['compress', 'resize', 'rotate', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert', 'removebg', 'crop'].includes(action.value)
})
const qualityOptions = [
{ id: 'high', name: '高质量' },
{ id: 'medium', name: '中等' },
{ id: 'low', name: '低质量' },
]
const showQuality = computed(() => {
return (category.value === 'pdf' && action.value === 'compress') ||
(category.value === 'image' && action.value === 'compress')
})
const showAngle = computed(() => {
return (category.value === 'pdf' && action.value === 'rotate') ||
(category.value === 'image' && action.value === 'rotate')
})
const showWatermark = computed(() => category.value === 'pdf' && action.value === 'watermark')
const showOutputFormat = computed(() => category.value === 'image' && action.value === 'convert')
watch(() => route.params, (p) => {
action.value = p.action || currentCat.value.actions[0]?.id || ''
resetState()
})
onMounted(async () => {
if (!action.value && currentCat.value.actions.length > 0) {
action.value = currentCat.value.actions[0].id
}
try {
config.value = await window.go.main.FileHandler.GetConfig()
} catch (e) {}
})
function resetState() {
filePath.value = ''
filePreview.value = ''
imageInfo.value = null
resultInfo.value = null
compareImages.value = { original: '', processed: '' }
previewScale.value = 1
previewX.value = 0
previewY.value = 0
filePreview.value = ''
imageInfo.value = null
}
function setAction(id) {
action.value = id
resetState()
}
async function selectFile() {
try {
const path = await window.go.main.FileHandler.OpenFileDialog('选择文件', currentCat.value.fileFilters)
if (path) {
filePath.value = path
resetState()
outputPath.value = ''
await loadFilePreview()
}
} catch (e) {
ElMessage.error('选择文件失败: ' + e.message)
}
}
async function loadFilePreview() {
if (!filePath.value) return
if (category.value !== 'image') return
try {
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
if (b64) {
filePreview.value = b64
const info = await window.go.main.FileHandler.GetFileInfo(filePath.value)
imageInfo.value = info
}
} catch (e) {
console.error('加载预览失败:', e)
}
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function getFileName() {
if (!filePath.value) return '未选择文件'
return filePath.value.split(/[/\\]/).pop()
}
async function selectOutputPath() {
try {
const filters = getOutputFilters()
const defaultName = getOutputDefaultName()
const path = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, filters, config.value.defaultOutputDir || '')
if (path) {
outputPath.value = path
}
} catch (e) {}
}
function getOutputFilters() {
if (category.value === 'pdf') return ['*.pdf']
if (category.value === 'word') return ['*.pdf']
if (category.value === 'excel') {
return action.value === 'json' ? ['*.json'] : ['*.csv']
}
if (category.value === 'image') {
if (action.value === 'convert') return ['*.jpg;*.jpeg', '*.png', '*.gif', '*.bmp', '*.tiff', '*.ico']
return ['*.jpg;*.jpeg', '*.png']
}
return ['*']
}
function getOutputDefaultName() {
if (!filePath.value) return ''
const name = filePath.value.split(/[/\\]/).pop()
const base = name.replace(/\.[^.]+$/, '')
return base + getOutputExt()
}
function getOutputExt() {
if (category.value === 'pdf') return '.pdf'
if (category.value === 'word') return '.pdf'
if (category.value === 'excel') return action.value === 'json' ? '.json' : '.csv'
if (category.value === 'image') {
if (action.value === 'convert') return '.' + outputFormat.value
return '.png'
}
return ''
}
async function processFile() {
if (!filePath.value) {
ElMessage.warning('请先选择文件')
return
}
processing.value = true
resultInfo.value = null
compareImages.value = { original: '', processed: '' }
try {
const req = {
inputPath: filePath.value,
outputPath: outputPath.value,
format: action.value,
quality: quality.value,
angle: angle.value,
text: watermarkText.value,
outputFormat: outputFormat.value,
}
const result = await window.go.main.FileHandler.ProcessFile(req)
if (result.success) {
resultInfo.value = {
path: result.path,
size: formatSize(result.size),
sizeBytes: result.size,
originalSize: result.originalSize ? formatSize(result.originalSize) : '',
originalSizeBytes: result.originalSize || 0,
tempPath: result.path,
isImageOp: isImageProcessing.value,
}
if (isImageProcessing.value) {
try {
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
compareImages.value.original = b64
compareImages.value.processed = await window.go.main.FileHandler.GetImageBase64(result.path)
if (!compareImages.value.processed) {
compareImages.value.processed = b64
}
if (compareImages.value.processed) {
filePreview.value = compareImages.value.processed
}
} catch (e) {}
}
await window.go.main.FileHandler.AddRecentUse({
id: category.value + '-' + action.value,
name: currentAction.value.name,
category: category.value,
usedAt: Date.now(),
})
ElMessage.success('处理完成')
} else {
ElMessage.error(result.message)
}
} catch (e) {
ElMessage.error('处理失败: ' + e.message)
} finally {
processing.value = false
}
}
async function handleImageProcess(req) {
if (!filePath.value) {
ElMessage.warning('请先选择文件')
return
}
processing.value = true
resultInfo.value = null
compareImages.value = { original: '', processed: '' }
try {
const result = await window.go.main.FileHandler.ProcessFile(req)
if (result.success) {
resultInfo.value = {
path: result.path,
size: formatSize(result.size),
sizeBytes: result.size,
originalSize: result.originalSize ? formatSize(result.originalSize) : '',
originalSizeBytes: result.originalSize || 0,
tempPath: result.path,
isImageOp: true,
}
try {
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
compareImages.value.original = b64
compareImages.value.processed = await window.go.main.FileHandler.GetImageBase64(result.path)
if (!compareImages.value.processed) {
compareImages.value.processed = b64
}
if (compareImages.value.processed && isImageProcessing.value) {
filePreview.value = compareImages.value.processed
}
} catch (e) {}
await window.go.main.FileHandler.AddRecentUse({
id: category.value + '-' + action.value,
name: currentAction.value.name,
category: category.value,
usedAt: Date.now(),
})
ElMessage.success('处理完成')
} else {
ElMessage.error(result.message)
}
} catch (e) {
ElMessage.error('处理失败: ' + e.message)
} finally {
processing.value = false
}
}
async function downloadResult() {
if (!resultInfo.value?.tempPath) return
try {
const ext = getOutputExt()
const defaultName = getOutputDefaultName() || ('output' + ext)
const filters = getOutputFilters()
const savePath = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, filters, config.value.defaultOutputDir || '')
if (savePath) {
const result = await window.go.main.FileHandler.SaveResult(resultInfo.value.tempPath, savePath)
if (result.success) {
ElMessage.success('文件已保存到: ' + savePath)
resultInfo.value.path = savePath
} else {
ElMessage.error(result.message)
}
}
} catch (e) {
ElMessage.error('保存失败: ' + e.message)
}
}
function openPreview(src, index) {
previewSrc.value = src
previewIndex.value = index
previewScale.value = 1
previewX.value = 0
previewY.value = 0
showPreview.value = true
}
function closePreview() {
showPreview.value = false
}
function onWheel(e) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
previewScale.value = Math.max(0.1, Math.min(5, previewScale.value + delta))
}
function onMouseDown(e) {
if (e.button !== 0) return
isDragging.value = true
dragStart.value = { x: e.clientX - previewX.value, y: e.clientY - previewY.value }
}
function onMouseMove(e) {
if (!isDragging.value) return
previewX.value = e.clientX - dragStart.value.x
previewY.value = e.clientY - dragStart.value.y
}
function onMouseUp() {
isDragging.value = false
}
function resetPreview() {
previewScale.value = 1
previewX.value = 0
previewY.value = 0
}
function switchPreview(dir) {
previewIndex.value = dir
previewSrc.value = dir === 0 ? compareImages.value.original : compareImages.value.processed
resetPreview()
}
async function openOutputFolder() {
if (resultInfo.value?.path) {
const dir = resultInfo.value.path.replace(/[/\\][^/\\]+$/, '')
try {
await window.go.main.FileHandler.OpenFolder(dir)
} catch (e) {
ElMessage.error('打开文件夹失败')
}
}
}
const imageToolProps = computed(() => ({
filePath: filePath.value,
filePreview: filePreview.value,
imageInfo: imageInfo.value,
originalWidth: imageInfo.value?.width || 0,
originalHeight: imageInfo.value?.height || 0,
processing: processing.value,
resultInfo: resultInfo.value,
outputPath: outputPath.value,
config: config.value,
}))
const isImageWorkspaceTool = computed(() => {
return category.value === 'image' && ['compress', 'resize', 'rotate', 'crop', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert'].includes(action.value) && !!filePath.value
})
const isImageConvertTool = computed(() => category.value === 'image' && action.value === 'convert')
const isImageRemoveBgTool = computed(() => category.value === 'image' && action.value === 'removebg')
</script>
<template>
<div class="tool-view">
<div class="tool-header">
<el-button text @click="router.push('/')">
<el-icon><ArrowLeft /></el-icon>
<span style="margin-left:4px">返回</span>
</el-button>
<div class="tool-title">
<el-icon :size="22" :style="{ color: currentAction?.color }">
<component :is="currentAction?.icon || 'Document'" />
</el-icon>
<span>{{ currentAction?.name || currentCat.name }}</span>
</div>
<div style="width:80px"></div>
</div>
<div class="action-tabs">
<div
v-for="act in currentCat.actions"
:key="act.id"
class="action-tab"
:class="{ active: action === act.id }"
@click="setAction(act.id)"
>
<el-icon :size="15"><component :is="act.icon" /></el-icon>
<span>{{ act.name }}</span>
</div>
</div>
<div class="tool-body">
<ImageCompressTool v-if="action === 'compress' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageResizeTool v-else-if="action === 'resize' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageRotateTool v-else-if="action === 'rotate' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageCropTool v-else-if="action === 'crop' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageGrayscaleTool v-else-if="action === 'grayscale' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageBrightnessTool v-else-if="action === 'brightness' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageSharpenTool v-else-if="action === 'sharpen' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageBlurTool v-else-if="action === 'blur' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageInvertTool v-else-if="action === 'invert' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageConvertTool v-else-if="action === 'convert' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<ImageRemoveBgTool v-else-if="action === 'removebg' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
<div v-else class="tool-main">
<div class="file-drop-zone" @click="selectFile" :class="{ 'has-file': filePath }">
<template v-if="!filePath">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择文件</div>
<div class="drop-hint">支持拖拽文件到此处</div>
</template>
<template v-else>
<div class="file-info">
<el-icon :size="28" :style="{ color: currentAction?.color }">
<component :is="currentAction?.icon || 'Document'" />
</el-icon>
<div class="file-details">
<div class="file-name">{{ getFileName() }}</div>
<div class="file-path">{{ filePath }}</div>
</div>
<el-button type="danger" text @click.stop="filePath = ''; resetState()">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</div>
<div class="params-section">
<div class="param-group" v-if="showOutputFormat">
<div class="param-label">目标格式</div>
<div class="tag-group">
<div class="tag-option" :class="{ active: outputFormat === 'jpeg' }" @click="outputFormat = 'jpeg'">JPEG</div>
<div class="tag-option" :class="{ active: outputFormat === 'png' }" @click="outputFormat = 'png'">PNG</div>
<div class="tag-option" :class="{ active: outputFormat === 'gif' }" @click="outputFormat = 'gif'">GIF</div>
<div class="tag-option" :class="{ active: outputFormat === 'bmp' }" @click="outputFormat = 'bmp'">BMP</div>
<div class="tag-option" :class="{ active: outputFormat === 'tiff' }" @click="outputFormat = 'tiff'">TIFF</div>
<div class="tag-option" :class="{ active: outputFormat === 'ico' }" @click="outputFormat = 'ico'">ICO</div>
</div>
</div>
<div class="param-group" v-if="showQuality && category === 'pdf'">
<div class="param-label">质量</div>
<div class="tag-group">
<div v-for="q in qualityOptions" :key="q.id" class="tag-option" :class="{ active: quality === q.id }" @click="quality = q.id">{{ q.name }}</div>
</div>
</div>
<div class="param-group" v-if="showAngle && category === 'pdf'">
<div class="param-label">旋转角度: {{ angle }}°</div>
<div class="tag-group">
<div v-for="a in [0, 90, 180, 270]" :key="a" class="tag-option" :class="{ active: angle === a }" @click="angle = a">{{ a }}°</div>
</div>
</div>
<div class="param-group" v-if="showWatermark">
<div class="param-label">水印文字</div>
<el-input v-model="watermarkText" placeholder="请输入水印文字" />
</div>
</div>
<div class="output-section">
<div class="output-header">
<span class="param-label">输出路径</span>
<el-button text size="small" @click="outputPath = ''">重置为默认</el-button>
</div>
<div class="output-row">
<el-input v-model="outputPath" :placeholder="config.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly />
<el-button @click="selectOutputPath">
<el-icon><FolderOpened /></el-icon>
浏览
</el-button>
</div>
</div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="processFile">
<el-icon v-if="!processing"><Check /></el-icon>
{{ processing ? '处理中...' : '开始处理' }}
</el-button>
</div>
<transition name="fade-slide">
<div class="result-panel" v-if="resultInfo">
<div class="result-header">
<el-icon :size="20" style="color: #3fb950" class="success-icon"><SuccessFilled /></el-icon>
<span>处理完成</span>
</div>
<div class="compare-section" v-if="compareImages.original && compareImages.processed">
<div class="compare-label">处理对比</div>
<div class="compare-grid">
<div class="compare-item" @click="openPreview(compareImages.original, 0)">
<div class="compare-img-wrap">
<img :src="compareImages.original" />
<div class="compare-badge">原图</div>
</div>
<div class="compare-name">原始文件</div>
</div>
<div class="compare-item" @click="openPreview(compareImages.processed, 1)">
<div class="compare-img-wrap processed">
<img :src="compareImages.processed" />
<div class="compare-badge">处理后</div>
</div>
<div class="compare-name">处理结果</div>
</div>
</div>
</div>
<div class="result-body">
<div class="result-row">
<span class="result-label">输出文件</span>
<span class="result-value">{{ resultInfo.path }}</span>
</div>
<div class="result-row">
<span class="result-label">文件大小</span>
<span class="result-value">
<template v-if="resultInfo.originalSize">
<span class="size-compare">
<span class="size-original">{{ resultInfo.originalSize }}</span>
<span class="size-arrow"></span>
<span class="size-processed">{{ resultInfo.size }}</span>
<span v-if="resultInfo.originalSizeBytes > resultInfo.sizeBytes" class="size-reduction">
(减少 {{ Math.round((1 - resultInfo.sizeBytes / resultInfo.originalSizeBytes) * 100) }}%)
</span>
</span>
</template>
<template v-else>
{{ resultInfo.size }}
</template>
</span>
</div>
</div>
<div class="result-actions">
<el-button v-if="resultInfo.isImageOp" type="primary" @click="downloadResult" size="large">
<el-icon><Download /></el-icon>
下载保存
</el-button>
<el-button @click="openOutputFolder">
<el-icon><FolderOpened /></el-icon>
打开目录
</el-button>
</div>
</div>
</transition>
</div>
<transition name="fade">
<div class="preview-overlay" v-if="showPreview" @click.self="closePreview">
<div class="preview-toolbar">
<div class="preview-tabs" v-if="compareImages.original && compareImages.processed">
<div class="preview-tab" :class="{ active: previewIndex === 0 }" @click="switchPreview(0)">原图</div>
<div class="preview-tab" :class="{ active: previewIndex === 1 }" @click="switchPreview(1)">处理后</div>
</div>
<div class="preview-controls">
<el-button text @click="previewScale = Math.max(0.1, previewScale - 0.2)">
<el-icon><ZoomOut /></el-icon>
</el-button>
<span class="preview-zoom-label">{{ Math.round(previewScale * 100) }}%</span>
<el-button text @click="previewScale = Math.min(5, previewScale + 0.2)">
<el-icon><ZoomIn /></el-icon>
</el-button>
<el-button text @click="resetPreview">
<el-icon><RefreshRight /></el-icon>
</el-button>
<el-button text @click="closePreview" type="danger">
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
<div
class="preview-canvas"
@wheel.prevent="onWheel"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
>
<img
:src="previewSrc"
:style="{
transform: `translate(${previewX}px, ${previewY}px) scale(${previewScale})`,
transition: isDragging ? 'none' : 'transform 0.15s ease'
}"
draggable="false"
/>
</div>
</div>
</transition>
<transition name="fade">
<div class="loading-overlay" v-if="processing">
<div class="loading-card">
<div class="spinner"></div>
<div class="loading-text">处理中请稍候...</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.tool-view { height: 100%; display: flex; flex-direction: column; position: relative; }
.tool-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 24px; background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
border-bottom: 1px solid var(--glass-border);
}
.tool-title { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 600; color: var(--text-primary); }
.action-tabs {
display: flex; gap: 6px; padding: 10px 24px;
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur)); border-bottom: 1px solid var(--glass-border);
overflow-x: auto;
}
.action-tab {
display: flex; align-items: center; gap: 5px; padding: 7px 14px;
border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; font-weight: 500;
color: var(--text-secondary); background: transparent; border: 1px solid transparent;
transition: all var(--transition-normal); white-space: nowrap; user-select: none;
}
.action-tab:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-primary); }
.action-tab.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.tool-body { flex: 1; padding: 24px; overflow-y: auto; }
.tool-main { max-width: 900px; margin: 0 auto; }
.file-drop-zone {
border: 2px dashed var(--glass-border); border-radius: var(--radius-lg);
padding: 40px; text-align: center; cursor: pointer;
transition: all var(--transition-normal); margin-bottom: 24px;
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
}
.file-drop-zone:hover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.file-drop-zone.has-file { border-style: solid; border-color: var(--glass-border); padding: 12px 16px; }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.file-info { display: flex; align-items: center; gap: 14px; text-align: left; }
.file-details { flex: 1; min-width: 0; }
.file-name { font-size: 14px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-path { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.params-section { margin-bottom: 20px; }
.param-group { margin-bottom: 16px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-option {
display: flex; align-items: center; gap: 4px; padding: 7px 14px;
border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
cursor: pointer; background: var(--bg-surface); color: var(--text-secondary);
border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none;
}
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.output-section { margin-bottom: 24px; }
.output-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn {
width: 100%; height: 48px; font-size: 15px; font-weight: 600;
border-radius: var(--radius-md); background: var(--gradient-primary); border: none;
transition: all var(--transition-normal);
}
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
.result-panel {
max-width: 640px; margin: 24px auto 0; background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid rgba(34, 197, 94, 0.3); border-radius: var(--radius-lg); padding: 20px;
}
.result-header { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; color: var(--accent-green); margin-bottom: 16px; }
.compare-section { margin-bottom: 16px; }
.compare-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px; }
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.compare-item { cursor: pointer; text-align: center; }
.compare-img-wrap {
border-radius: var(--radius-md); overflow: hidden; border: 2px solid var(--glass-border);
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
background: var(--bg-primary); transition: all var(--transition-normal); position: relative;
}
.compare-img-wrap:hover { border-color: var(--accent-primary); box-shadow: 0 0 12px rgba(59, 130, 246, 0.2); }
.compare-img-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; }
.compare-badge {
position: absolute; top: 8px; left: 8px; padding: 3px 10px; border-radius: var(--radius-sm);
font-size: 11px; font-weight: 600; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px); color: #fff;
}
.compare-name { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
.result-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--glass-border); }
.result-row:last-child { border-bottom: none; }
.result-label { font-size: 13px; color: var(--text-secondary); }
.result-value { font-size: 13px; color: var(--text-primary); text-align: right; max-width: 70%; word-break: break-all; }
.result-actions { display: flex; gap: 8px; margin-top: 16px; }
.result-actions .el-button { flex: 1; }
.success-icon { animation: pop-in 0.4s ease-out; }
@keyframes pop-in { 0% { transform: scale(0); opacity: 0; } 60% { transform: scale(1.3); } 100% { transform: scale(1); opacity: 1; } }
.preview-overlay {
position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
display: flex; flex-direction: column;
}
.preview-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; background: rgba(0, 0, 0, 0.5); border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
.preview-tabs { display: flex; gap: 4px; background: rgba(255, 255, 255, 0.1); border-radius: var(--radius-sm); padding: 3px; }
.preview-tab { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-normal); }
.preview-tab:hover { color: var(--text-primary); }
.preview-tab.active { background: var(--accent-primary); color: #ffffff; }
.preview-controls { display: flex; align-items: center; gap: 4px; }
.preview-controls .el-button { color: #fff; }
.preview-zoom-label { font-size: 13px; color: #fff; min-width: 48px; text-align: center; }
.preview-canvas { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; user-select: none; }
.preview-canvas img { max-width: 90%; max-height: 90%; object-fit: contain; border-radius: var(--radius-sm); }
.loading-overlay {
position: fixed; inset: 0; z-index: 999; background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
}
.loading-card {
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border);
border-radius: var(--radius-xl); padding: 40px 48px;
display: flex; flex-direction: column; align-items: center; gap: 16px;
}
.spinner {
width: 48px; height: 48px; border: 3px solid var(--bg-surface);
border-top-color: var(--accent-primary); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 14px; color: var(--text-secondary); }
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s ease; }
.fade-slide-enter-from { opacity: 0; transform: translateY(20px); }
.fade-slide-leave-to { opacity: 0; transform: translateY(-10px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.size-compare { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.size-original { color: var(--text-secondary); }
.size-arrow { color: var(--text-muted); font-size: 12px; }
.size-processed { color: var(--accent-green); font-weight: 600; }
.size-reduction { color: var(--accent-green); font-size: 12px; font-weight: 500; }
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const blurRadius = ref(3)
function buildRequest() {
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'blur', blurRadius: blurRadius.value }
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
</div>
<div class="image-panel">
<div class="panel-section"><div class="panel-title">模糊设置</div>
<div class="param-group"><div class="param-label">模糊半径: {{ blurRadius }}</div><el-slider v-model="blurRadius" :min="0" :max="20" :step="0.5" /><div class="slider-hint"><span>无模糊</span><span>最强模糊</span></div></div>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,80 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const brightnessVal = ref(0)
const contrastVal = ref(0)
const saturationVal = ref(0)
function buildRequest() {
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'brightness', brightness: brightnessVal.value, contrast: contrastVal.value, saturation: saturationVal.value }
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
</div>
<div class="image-panel">
<div class="panel-section"><div class="panel-title">调整亮度</div>
<div class="param-group"><div class="param-label">亮度: {{ brightnessVal > 0 ? '+' : '' }}{{ brightnessVal }}</div><el-slider v-model="brightnessVal" :min="-100" :max="100" :step="1" /></div>
<div class="param-group"><div class="param-label">对比度: {{ contrastVal > 0 ? '+' : '' }}{{ contrastVal }}</div><el-slider v-model="contrastVal" :min="-100" :max="100" :step="1" /></div>
<div class="param-group"><div class="param-label">饱和度: {{ saturationVal > 0 ? '+' : '' }}{{ saturationVal }}</div><el-slider v-model="saturationVal" :min="-100" :max="100" :step="1" /></div>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,274 @@
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const outputFormat = ref('jpeg')
const qualityInt = ref(75)
const aspectLocked = ref(false)
const customWidth = ref(800)
const customHeight = ref(600)
watch(() => props.originalWidth, (w) => {
if (w) customWidth.value = w
})
watch(() => props.originalHeight, (h) => {
if (h) customHeight.value = h
})
function buildRequest() {
return {
inputPath: props.filePath,
outputPath: props.outputPath,
format: 'compress',
qualityInt: qualityInt.value,
width: aspectLocked.value ? customWidth.value : 0,
height: aspectLocked.value ? customHeight.value : 0,
outputFormat: outputFormat.value,
}
}
function onProcess() {
emit('process', buildRequest())
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function getFileName() {
if (!props.filePath) return ''
return props.filePath.split(/[/\\]/).pop()
}
function onDragOver(e) {
e.preventDefault()
e.currentTarget.classList.add('dragover')
}
function onDragLeave(e) {
e.currentTarget.classList.remove('dragover')
}
function onDrop(e) {
e.preventDefault()
e.currentTarget.classList.remove('dragover')
const files = e.dataTransfer?.files
if (files?.length) {
const ext = files[0].name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) {
ElMessage.warning('请选择图片文件')
return
}
emit('selectFile', files[0].path)
}
}
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container">
<div class="canvas-wrapper">
<img v-if="filePreview" :src="filePreview" class="preview-img" />
</div>
</div>
<div class="image-info-bar">
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
<span class="image-filename">{{ getFileName() }}</span>
<el-button type="danger" text size="small" @click="$emit('selectFile')">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="image-panel">
<div class="panel-section">
<div class="panel-title">压缩设置</div>
<div class="param-group">
<div class="param-label">输出格式</div>
<div class="tag-group">
<div class="tag-option" :class="{ active: outputFormat === 'jpeg' }" @click="outputFormat = 'jpeg'">JPEG</div>
<div class="tag-option" :class="{ active: outputFormat === 'png' }" @click="outputFormat = 'png'">PNG</div>
</div>
</div>
<div class="param-group">
<div class="param-label">质量: {{ qualityInt }}</div>
<el-slider v-model="qualityInt" :min="1" :max="100" :step="1" />
<div class="slider-hint"><span>最小</span><span>最大</span></div>
</div>
<div class="param-group">
<button class="aspect-lock-btn" :class="{ locked: aspectLocked }" @click="aspectLocked = !aspectLocked">
<el-icon><Lock v-if="aspectLocked" /><Unlock v-else /></el-icon>
{{ aspectLocked ? '锁定比例' : '自由比例' }}
</button>
</div>
<div v-if="aspectLocked" class="param-group">
<div class="param-label">尺寸 ( × )</div>
<div class="size-inputs">
<el-input-number v-model="customWidth" :min="1" :max="10000" size="small" controls-position="right" />
<span class="size-sep">×</span>
<el-input-number v-model="customHeight" :min="1" :max="10000" size="small" controls-position="right" />
<span class="size-unit">px</span>
</div>
</div>
<div v-if="resultInfo && resultInfo.isImageOp" class="estimated-size">
<span class="estimated-label">处理后大小:</span> {{ resultInfo.size }}
</div>
</div>
<div class="panel-section">
<div class="panel-title">输出路径</div>
<div class="output-row">
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" />
</div>
</div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
<el-icon v-if="!processing"><Check /></el-icon>
{{ processing ? '处理中...' : '开始处理' }}
</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone {
border: 2px dashed var(--glass-border);
border-radius: var(--radius-lg);
padding: 80px 40px;
text-align: center;
cursor: pointer;
transition: all var(--transition-normal);
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
max-width: 600px;
margin: 40px auto;
}
.file-drop-zone:hover, .file-drop-zone.dragover {
border-color: var(--accent-primary);
background: rgba(59, 130, 246, 0.05);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
}
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
height: calc(100vh - 160px);
max-height: 800px;
}
.image-canvas-area {
display: flex;
flex-direction: column;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.canvas-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a25;
overflow: hidden;
min-height: 400px;
}
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--glass-bg);
border-top: 1px solid var(--glass-border);
font-size: 13px;
}
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 20px;
}
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title {
font-size: 14px; font-weight: 600; color: var(--text-primary);
margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border);
}
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-option {
display: flex; align-items: center; gap: 4px; padding: 7px 14px;
border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
cursor: pointer; background: var(--bg-surface); color: var(--text-secondary);
border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none;
}
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.aspect-lock-btn {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;
cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border);
color: var(--text-secondary); transition: all var(--transition-normal);
}
.aspect-lock-btn.locked { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
.size-inputs { display: flex; align-items: center; gap: 8px; }
.size-sep { color: var(--text-muted); font-size: 14px; }
.size-unit { color: var(--text-muted); font-size: 13px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn {
width: 100%; height: 48px; font-size: 15px; font-weight: 600;
border-radius: var(--radius-md); background: var(--gradient-primary); border: none;
transition: all var(--transition-normal);
}
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
.estimated-size {
margin-top: 8px; padding: 8px 12px;
background: rgba(59, 130, 246, 0.08); border-radius: var(--radius-sm);
font-size: 13px; color: var(--text-secondary);
}
.estimated-label { font-weight: 500; }
</style>

View File

@@ -0,0 +1,126 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const imageFormats = [
{ id: 'jpeg', name: 'JPEG' },
{ id: 'png', name: 'PNG' },
{ id: 'gif', name: 'GIF' },
{ id: 'bmp', name: 'BMP' },
{ id: 'tiff', name: 'TIFF' },
{ id: 'ico', name: 'ICO' },
]
const outputFormat = ref('jpeg')
const qualityInt = ref(75)
function buildRequest() {
return {
inputPath: props.filePath,
outputPath: props.outputPath,
format: 'convert',
outputFormat: outputFormat.value,
qualityInt: outputFormat.value === 'jpeg' ? qualityInt.value : 0,
}
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function formatSize(bytes) {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="tool-main">
<div class="file-selected-bar" @click="$emit('selectFile')">
<img v-if="filePreview" :src="filePreview" class="file-preview-img" />
<div class="file-preview-info">
<div class="file-name">{{ getFileName() }}</div>
<div v-if="imageInfo" class="file-meta">{{ imageInfo.width }}×{{ imageInfo.height }} · {{ formatSize(imageInfo.size) }}</div>
</div>
<el-button type="danger" text @click.stop="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
</div>
<div class="params-section">
<div class="param-group">
<div class="param-label">目标格式</div>
<div class="tag-group">
<div v-for="fmt in imageFormats" :key="fmt.id" class="tag-option" :class="{ active: outputFormat === fmt.id }" @click="outputFormat = fmt.id">{{ fmt.name }}</div>
</div>
</div>
<div class="param-group" v-if="outputFormat === 'jpeg'">
<div class="param-label">JPEG 质量: {{ qualityInt }}</div>
<el-slider v-model="qualityInt" :min="1" :max="100" :step="1" />
<div class="slider-hint"><span>最小</span><span>最大</span></div>
</div>
</div>
<div class="output-section">
<div class="output-header"><span class="param-label">输出路径</span></div>
<div class="output-row">
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly />
</div>
</div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
<el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}
</el-button>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.tool-main { max-width: 900px; margin: 0 auto; }
.file-selected-bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border: 1px solid var(--glass-border); border-radius: var(--radius-lg); margin-bottom: 24px; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); cursor: pointer; transition: all var(--transition-normal); }
.file-selected-bar:hover { border-color: var(--accent-primary); }
.file-preview-img { width: 48px; height: 48px; object-fit: cover; border-radius: var(--radius-sm); }
.file-preview-info { flex: 1; min-width: 0; }
.file-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.file-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.params-section { margin-bottom: 20px; }
.param-group { margin-bottom: 16px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.output-section { margin-bottom: 24px; }
.output-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,275 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const cropX = ref(0)
const cropY = ref(0)
const cropW = ref(0)
const cropH = ref(0)
const cropRatioPreset = ref('free')
const previewCanvasRef = ref(null)
let previewImg = null
let cropDragging = false
let cropDragType = null
let cropDragStartX = 0
let cropDragStartY = 0
let cropDragStartRect = { x: 0, y: 0, w: 0, h: 0 }
watch(() => props.filePreview, (val) => { if (val) initPreviewCanvas(val) })
const cropDisplayScale = computed(() => {
const canvas = previewCanvasRef.value
if (!canvas || !props.originalWidth) return 1
return canvas.width / props.originalWidth
})
const cropSelectionStyle = computed(() => {
const scale = cropDisplayScale.value
if (!cropW.value || !cropH.value) return { display: 'none' }
return {
left: (cropX.value * scale) + 'px',
top: (cropY.value * scale) + 'px',
width: (cropW.value * scale) + 'px',
height: (cropH.value * scale) + 'px',
}
})
function initPreviewCanvas(base64) {
if (!base64) return
const canvas = previewCanvasRef.value
if (!canvas) return
const img = new Image()
img.onload = () => {
previewImg = img
const container = canvas.parentElement
const containerW = container.clientWidth - 40
const containerH = container.clientHeight - 40
const scale = Math.min(containerW / img.naturalWidth, containerH / img.naturalHeight, 1)
const displayW = Math.floor(img.naturalWidth * scale)
const displayH = Math.floor(img.naturalHeight * scale)
canvas.width = displayW
canvas.height = displayH
canvas.parentElement.style.width = displayW + 'px'
canvas.parentElement.style.height = displayH + 'px'
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, displayW, displayH)
ctx.drawImage(img, 0, 0, displayW, displayH)
cropX.value = 0; cropY.value = 0
cropW.value = img.naturalWidth; cropH.value = img.naturalHeight
}
img.src = base64
}
function onCropOverlayMouseDown(e) {
if (e.target.closest('.crop-handle')) return
const canvas = previewCanvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const scale = canvas.width / props.originalWidth
const imgX = (e.clientX - rect.left) / scale
const imgY = (e.clientY - rect.top) / scale
if (cropW.value > 0 && cropH.value > 0 && imgX >= cropX.value && imgX <= cropX.value + cropW.value && imgY >= cropY.value && imgY <= cropY.value + cropH.value) {
cropDragging = true; cropDragType = 'move'
} else {
cropX.value = Math.max(0, Math.min(props.originalWidth, Math.round(imgX)))
cropY.value = Math.max(0, Math.min(props.originalHeight, Math.round(imgY)))
cropW.value = 0; cropH.value = 0
cropDragging = true; cropDragType = 'create'
}
cropDragStartX = e.clientX; cropDragStartY = e.clientY
cropDragStartRect = { x: cropX.value, y: cropY.value, w: cropW.value, h: cropH.value }
}
function onCropHandleMouseDown(handle, e) {
e.preventDefault()
cropDragging = true; cropDragType = handle
cropDragStartX = e.clientX; cropDragStartY = e.clientY
cropDragStartRect = { x: cropX.value, y: cropY.value, w: cropW.value, h: cropH.value }
}
function onCropOverlayMouseMove(e) {
if (!cropDragging) return
const canvas = previewCanvasRef.value
if (!canvas) return
const scale = canvas.width / props.originalWidth
const dx = (e.clientX - cropDragStartX) / scale
const dy = (e.clientY - cropDragStartY) / scale
const s = cropDragStartRect
switch (cropDragType) {
case 'move': cropX.value = Math.max(0, Math.min(props.originalWidth - s.w, Math.round(s.x + dx))); cropY.value = Math.max(0, Math.min(props.originalHeight - s.h, Math.round(s.y + dy))); break
case 'create': cropX.value = Math.max(0, Math.round(Math.min(s.x, s.x + dx))); cropY.value = Math.max(0, Math.round(Math.min(s.y, s.y + dy))); cropW.value = Math.min(Math.round(Math.abs(dx)), props.originalWidth - cropX.value); cropH.value = Math.min(Math.round(Math.abs(dy)), props.originalHeight - cropY.value); break
case 'se': cropW.value = Math.round(Math.max(1, Math.min(s.w + dx, props.originalWidth - s.x))); cropH.value = Math.round(Math.max(1, Math.min(s.h + dy, props.originalHeight - s.y))); break
case 'nw': { const nx = Math.max(0, s.x + dx); const ny = Math.max(0, s.y + dy); const nw = s.w - (nx - s.x); const nh = s.h - (ny - s.y); if (nw > 0 && nh > 0) { cropX.value = Math.round(nx); cropY.value = Math.round(ny); cropW.value = Math.round(nw); cropH.value = Math.round(nh) } break }
case 'ne': { const nw2 = Math.max(1, Math.min(s.w + dx, props.originalWidth - s.x)); const ny2 = Math.max(0, s.y + dy); const nh2 = s.h - (ny2 - s.y); if (nw2 > 0 && nh2 > 0) { cropW.value = Math.round(nw2); cropY.value = Math.round(ny2); cropH.value = Math.round(nh2) } break }
case 'sw': { const nx3 = Math.max(0, s.x + dx); const nw3 = s.w - (nx3 - s.x); const nh3 = Math.max(1, Math.min(s.h + dy, props.originalHeight - s.y)); if (nw3 > 0 && nh3 > 0) { cropX.value = Math.round(nx3); cropW.value = Math.round(nw3); cropH.value = Math.round(nh3) } break }
case 'n': { const ny4 = Math.max(0, s.y + dy); const nh4 = s.h - (ny4 - s.y); if (nh4 > 0) { cropY.value = Math.round(ny4); cropH.value = Math.round(nh4) } break }
case 's': cropH.value = Math.round(Math.max(1, Math.min(s.h + dy, props.originalHeight - s.y))); break
case 'e': cropW.value = Math.round(Math.max(1, Math.min(s.w + dx, props.originalWidth - s.x))); break
case 'w': { const nx5 = Math.max(0, s.x + dx); const nw5 = s.w - (nx5 - s.x); if (nw5 > 0) { cropX.value = Math.round(nx5); cropW.value = Math.round(nw5) } break }
}
if (cropRatioPreset.value !== 'free' && cropDragType !== 'move' && cropW.value > 0) {
const [rw, rh] = cropRatioPreset.value.split(':').map(Number)
const ratio = rw / rh
const constrainedH = Math.max(1, Math.round(cropW.value / ratio))
if (cropY.value + constrainedH <= props.originalHeight) { cropH.value = constrainedH }
else { cropH.value = Math.max(1, props.originalHeight - cropY.value); cropW.value = Math.max(1, Math.round(cropH.value * ratio)) }
}
}
function onCropOverlayMouseUp() { cropDragging = false; cropDragType = null }
function setCropRatio(ratio) {
cropRatioPreset.value = ratio
if (ratio === 'free') return
const [rw, rh] = ratio.split(':').map(Number)
const r = rw / rh
if (cropW.value > 0 && cropH.value > 0) {
const newH = Math.round(cropW.value / r)
if (cropY.value + newH <= props.originalHeight) { cropH.value = newH }
else { cropH.value = Math.max(1, props.originalHeight - cropY.value); cropW.value = Math.max(1, Math.round(cropH.value * r)) }
} else {
if (props.originalWidth / props.originalHeight > r) { cropH.value = props.originalHeight; cropW.value = Math.round(props.originalHeight * r) }
else { cropW.value = props.originalWidth; cropH.value = Math.round(props.originalWidth / r) }
cropX.value = Math.round((props.originalWidth - cropW.value) / 2)
cropY.value = Math.round((props.originalHeight - cropH.value) / 2)
}
}
function resetCrop() { cropX.value = 0; cropY.value = 0; cropW.value = props.originalWidth; cropH.value = props.originalHeight }
function buildRequest() {
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'crop', shape: { type: 'rectangle', x: cropX.value, y: cropY.value, width: cropW.value, height: cropH.value } }
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container">
<div class="canvas-wrapper">
<canvas ref="previewCanvasRef"></canvas>
<div class="crop-overlay" @mousedown="onCropOverlayMouseDown" @mousemove="onCropOverlayMouseMove" @mouseup="onCropOverlayMouseUp" @mouseleave="onCropOverlayMouseUp">
<div class="crop-selection" :style="cropSelectionStyle">
<div class="crop-size-label" v-if="cropW > 0 && cropH > 0">{{ cropW }} × {{ cropH }}</div>
<div class="crop-handle nw" @mousedown.stop="onCropHandleMouseDown('nw', $event)"></div>
<div class="crop-handle n" @mousedown.stop="onCropHandleMouseDown('n', $event)"></div>
<div class="crop-handle ne" @mousedown.stop="onCropHandleMouseDown('ne', $event)"></div>
<div class="crop-handle e" @mousedown.stop="onCropHandleMouseDown('e', $event)"></div>
<div class="crop-handle se" @mousedown.stop="onCropHandleMouseDown('se', $event)"></div>
<div class="crop-handle s" @mousedown.stop="onCropHandleMouseDown('s', $event)"></div>
<div class="crop-handle sw" @mousedown.stop="onCropHandleMouseDown('sw', $event)"></div>
<div class="crop-handle w" @mousedown.stop="onCropHandleMouseDown('w', $event)"></div>
</div>
</div>
</div>
</div>
<div class="image-info-bar">
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
<span class="image-filename">{{ getFileName() }}</span>
<el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<div class="image-panel">
<div class="panel-section">
<div class="panel-title">裁剪区域</div>
<div class="param-group">
<div class="param-label">裁剪比例</div>
<div class="tag-group">
<div class="tag-option" :class="{ active: cropRatioPreset === 'free' }" @click="setCropRatio('free')">自由</div>
<div class="tag-option" :class="{ active: cropRatioPreset === '1:1' }" @click="setCropRatio('1:1')">1:1</div>
<div class="tag-option" :class="{ active: cropRatioPreset === '4:3' }" @click="setCropRatio('4:3')">4:3</div>
<div class="tag-option" :class="{ active: cropRatioPreset === '16:9' }" @click="setCropRatio('16:9')">16:9</div>
</div>
</div>
<div class="crop-coords">
<div class="coord-row"><span class="coord-label">X:</span><el-input-number v-model="cropX" :min="0" :max="originalWidth" size="small" controls-position="right" /></div>
<div class="coord-row"><span class="coord-label">Y:</span><el-input-number v-model="cropY" :min="0" :max="originalHeight" size="small" controls-position="right" /></div>
<div class="coord-row"><span class="coord-label">W:</span><el-input-number v-model="cropW" :min="0" :max="originalWidth" size="small" controls-position="right" /></div>
<div class="coord-row"><span class="coord-label">H:</span><el-input-number v-model="cropH" :min="0" :max="originalHeight" size="small" controls-position="right" /></div>
</div>
<button class="action-link-btn" @click="resetCrop">重置为全图</button>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.canvas-wrapper canvas { display: block; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.crop-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; }
.crop-selection { position: absolute; border: 2px solid rgba(255, 255, 255, 0.8); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); cursor: move; }
.crop-size-label {
position: absolute; bottom: -24px; left: 50%; transform: translateX(-50%);
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
background: rgba(0, 0, 0, 0.7); color: #fff; white-space: nowrap;
pointer-events: none; z-index: 12;
}
.crop-handle { position: absolute; width: 12px; height: 12px; background: #fff; border: 2px solid var(--accent-primary); border-radius: 50%; transform: translate(-50%, -50%); z-index: 11; }
.crop-handle.nw { top: 0; left: 0; cursor: nw-resize; }
.crop-handle.n { top: 0; left: 50%; cursor: n-resize; }
.crop-handle.ne { top: 0; right: 0; left: auto; cursor: ne-resize; }
.crop-handle.e { top: 50%; right: 0; left: auto; cursor: e-resize; }
.crop-handle.se { bottom: 0; right: 0; left: auto; top: auto; cursor: se-resize; }
.crop-handle.s { bottom: 0; left: 50%; top: auto; cursor: s-resize; }
.crop-handle.sw { bottom: 0; left: 0; top: auto; cursor: sw-resize; }
.crop-handle.w { top: 50%; left: 0; cursor: w-resize; }
.crop-coords { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.coord-row { display: flex; align-items: center; gap: 6px; }
.coord-label { font-size: 12px; color: var(--text-secondary); min-width: 20px; font-weight: 600; }
.action-link-btn { width: 100%; padding: 8px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; background: transparent; border: 1px dashed var(--glass-border); color: var(--text-secondary); transition: all var(--transition-normal); }
.action-link-btn:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-primary); border-color: var(--accent-primary); }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const grayscaleIntensity = ref(100)
function buildRequest() {
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'grayscale', grayscaleIntensity: grayscaleIntensity.value }
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
</div>
<div class="image-panel">
<div class="panel-section"><div class="panel-title">灰度设置</div>
<div class="param-group"><div class="param-label">灰度强度: {{ grayscaleIntensity }}%</div><el-slider v-model="grayscaleIntensity" :min="0" :max="100" :step="1" /><div class="slider-hint"><span>原图</span><span>完全灰度</span></div></div>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
function buildRequest() {
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'invert' }
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
</div>
<div class="image-panel">
<div class="panel-section"><div class="panel-title">反色</div>
<div class="param-group"><div class="param-label">反转图片中的所有颜色</div></div>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,630 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const bgRemovalMode = ref('global')
const bgRemovalTolerance = ref(25)
const bgRemovalColor = ref(null)
const bgRemovalHistory = ref([])
const bgOriginalImageData = ref(null)
const bgProcessedCanvas = ref(null)
const bgPreviewReady = ref(false)
const bgColorSwatch = ref('')
const bgColorCode = ref('未选取')
const removebgThreshold = ref(30)
const MAX_BG_HISTORY = 30
let bgImgNaturalW = 0
let bgImgNaturalH = 0
let bgDisplayScale = 1
let bgDisplayOffsetX = 0
let bgDisplayOffsetY = 0
watch(() => props.filePreview, (val) => {
if (val) initBgRemoval(val)
})
function getFileName() {
if (!props.filePath) return ''
return props.filePath.split(/[/\\]/).pop()
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
async function initBgRemoval(base64) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
bgImgNaturalW = img.naturalWidth
bgImgNaturalH = img.naturalHeight
bgOriginalImageData.value = null
bgProcessedCanvas.value = null
bgPreviewReady.value = false
bgRemovalHistory.value = []
bgRemovalColor.value = null
updateBgColorDisplay(null)
const offCanvas = document.createElement('canvas')
offCanvas.width = bgImgNaturalW
offCanvas.height = bgImgNaturalH
const offCtx = offCanvas.getContext('2d')
offCtx.drawImage(img, 0, 0)
bgOriginalImageData.value = offCtx.getImageData(0, 0, bgImgNaturalW, bgImgNaturalH)
const processedData = new ImageData(
new Uint8ClampedArray(bgOriginalImageData.value.data),
bgImgNaturalW,
bgImgNaturalH
)
bgProcessedCanvas.value = { data: processedData, width: bgImgNaturalW, height: bgImgNaturalH }
bgPreviewReady.value = true
nextTick(() => {
nextTick(() => {
renderBgCanvases()
})
})
resolve()
}
img.onerror = () => {
console.error('图片加载失败')
resolve()
}
img.src = base64
})
}
function renderBgCanvases() {
if (!bgOriginalImageData.value || !bgProcessedCanvas.value) return
const origCanvas = document.getElementById('bgOriginalCanvas')
const prevCanvas = document.getElementById('bgPreviewCanvas')
if (!origCanvas || !prevCanvas) return
const origWrapper = origCanvas.parentElement
const wrapperW = origWrapper.clientWidth
const wrapperH = origWrapper.clientHeight
const scale = Math.min(wrapperW / bgImgNaturalW, wrapperH / bgImgNaturalH, 1)
const displayW = bgImgNaturalW * scale
const displayH = bgImgNaturalH * scale
const offsetX = (wrapperW - displayW) / 2
const offsetY = (wrapperH - displayH) / 2
bgDisplayScale = scale
bgDisplayOffsetX = offsetX
bgDisplayOffsetY = offsetY
origCanvas.width = wrapperW
origCanvas.height = wrapperH
origCanvas.style.width = wrapperW + 'px'
origCanvas.style.height = wrapperH + 'px'
const origCtx = origCanvas.getContext('2d')
origCtx.clearRect(0, 0, wrapperW, wrapperH)
origCtx.fillStyle = '#1a1a25'
origCtx.fillRect(0, 0, wrapperW, wrapperH)
const tempOrig = document.createElement('canvas')
tempOrig.width = bgImgNaturalW
tempOrig.height = bgImgNaturalH
tempOrig.getContext('2d').putImageData(bgOriginalImageData.value, 0, 0)
origCtx.drawImage(tempOrig, offsetX, offsetY, displayW, displayH)
prevCanvas.width = wrapperW
prevCanvas.height = wrapperH
prevCanvas.style.width = wrapperW + 'px'
prevCanvas.style.height = wrapperH + 'px'
const prevCtx = prevCanvas.getContext('2d')
prevCtx.clearRect(0, 0, wrapperW, wrapperH)
const tempPrev = document.createElement('canvas')
tempPrev.width = bgProcessedCanvas.value.width
tempPrev.height = bgProcessedCanvas.value.height
tempPrev.getContext('2d').putImageData(bgProcessedCanvas.value.data, 0, 0)
prevCtx.drawImage(tempPrev, offsetX, offsetY, displayW, displayH)
}
function bgCanvasToImageCoord(canvas, clientX, clientY) {
const rect = canvas.getBoundingClientRect()
const cx = clientX - rect.left
const cy = clientY - rect.top
const px = Math.round((cx - bgDisplayOffsetX) / bgDisplayScale)
const py = Math.round((cy - bgDisplayOffsetY) / bgDisplayScale)
return { px, py, cx, cy }
}
function bgIsInBounds(px, py) {
return px >= 0 && px < bgImgNaturalW && py >= 0 && py < bgImgNaturalH
}
function bgGetPixelColor(imageData, px, py) {
const idx = (py * imageData.width + px) * 4
return { r: imageData.data[idx], g: imageData.data[idx + 1], b: imageData.data[idx + 2], a: imageData.data[idx + 3] }
}
function bgColorDistance(c1, c2) {
const dr = c1.r - c2.r
const dg = c1.g - c2.g
const db = c1.b - c2.b
return Math.sqrt(dr * dr + dg * dg + db * db)
}
function bgToleranceToMaxDist(tolerance) {
return (tolerance / 100) * 441.67
}
function bgRemoveGlobal(targetColor, tolerance) {
if (!bgProcessedCanvas.value || !bgOriginalImageData.value) return 0
const maxDist = bgToleranceToMaxDist(tolerance)
const data = bgProcessedCanvas.value.data.data
const origData = bgOriginalImageData.value.data
let count = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] === 0) continue
const pc = { r: origData[i], g: origData[i + 1], b: origData[i + 2] }
if (bgColorDistance(targetColor, pc) <= maxDist) {
data[i + 3] = 0
count++
}
}
return count
}
function bgRemoveFloodFill(targetColor, tolerance, startPx, startPy) {
if (!bgProcessedCanvas.value || !bgOriginalImageData.value) return 0
if (!bgIsInBounds(startPx, startPy)) return 0
const idx = (startPy * bgImgNaturalW + startPx) * 4
if (bgProcessedCanvas.value.data.data[idx + 3] === 0) return 0
const maxDist = bgToleranceToMaxDist(tolerance)
const width = bgImgNaturalW
const height = bgImgNaturalH
const data = bgProcessedCanvas.value.data.data
const origData = bgOriginalImageData.value.data
const visited = new Uint8Array(width * height)
const queue = [{ x: startPx, y: startPy }]
visited[startPy * width + startPx] = 1
let count = 0
let head = 0
const dirs = [{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }]
while (head < queue.length) {
const { x, y } = queue[head++]
const pIdx = (y * width + x) * 4
const pc = { r: origData[pIdx], g: origData[pIdx + 1], b: origData[pIdx + 2] }
if (bgColorDistance(targetColor, pc) <= maxDist) {
data[pIdx + 3] = 0
count++
for (const { dx, dy } of dirs) {
const nx = x + dx
const ny = y + dy
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nVi = ny * width + nx
if (!visited[nVi]) {
const nIdx = (ny * width + nx) * 4
if (data[nIdx + 3] > 0) {
visited[nVi] = 1
queue.push({ x: nx, y: ny })
}
}
}
}
}
}
return count
}
function bgSaveHistory() {
if (!bgProcessedCanvas.value) return
const copy = new ImageData(
new Uint8ClampedArray(bgProcessedCanvas.value.data.data),
bgProcessedCanvas.value.width,
bgProcessedCanvas.value.height
)
bgRemovalHistory.value.push(copy)
if (bgRemovalHistory.value.length > MAX_BG_HISTORY) bgRemovalHistory.value.shift()
}
function bgUndo() {
if (bgRemovalHistory.value.length === 0) return
const prev = bgRemovalHistory.value.pop()
bgProcessedCanvas.value = { data: prev, width: prev.width, height: prev.height }
renderBgCanvases()
}
function bgResetAll() {
if (!bgOriginalImageData.value) return
bgRemovalHistory.value = []
bgProcessedCanvas.value = {
data: new ImageData(new Uint8ClampedArray(bgOriginalImageData.value.data), bgImgNaturalW, bgImgNaturalH),
width: bgImgNaturalW,
height: bgImgNaturalH
}
bgRemovalColor.value = null
updateBgColorDisplay(null)
renderBgCanvases()
}
function bgApplyRemoval(targetColor, clickPx, clickPy) {
bgSaveHistory()
let count = 0
if (bgRemovalMode.value === 'global') {
count = bgRemoveGlobal(targetColor, bgRemovalTolerance.value)
} else {
count = bgRemoveFloodFill(targetColor, bgRemovalTolerance.value, clickPx, clickPy)
}
renderBgCanvases()
if (count > 0) {
ElMessage.success(`已去除 ${count.toLocaleString()} 个像素`)
} else {
ElMessage.warning('未找到匹配的像素,请调整容差')
}
}
function onBgCanvasClick(e) {
if (!bgOriginalImageData.value || !bgProcessedCanvas.value) return
const canvas = document.getElementById('bgOriginalCanvas')
if (!canvas) return
const { px, py } = bgCanvasToImageCoord(canvas, e.clientX, e.clientY)
if (!bgIsInBounds(px, py)) return
const color = bgGetPixelColor(bgOriginalImageData.value, px, py)
bgRemovalColor.value = { r: color.r, g: color.g, b: color.b }
updateBgColorDisplay(bgRemovalColor.value)
bgApplyRemoval(bgRemovalColor.value, px, py)
}
function onBgCanvasMouseMove(e) {
if (!bgOriginalImageData.value) return
const canvas = document.getElementById('bgOriginalCanvas')
if (!canvas) return
const { px, py, cx, cy } = bgCanvasToImageCoord(canvas, e.clientX, e.clientY)
const badge = document.getElementById('bgColorBadge')
if (!badge) return
if (!bgIsInBounds(px, py)) {
badge.style.display = 'none'
return
}
const color = bgGetPixelColor(bgOriginalImageData.value, px, py)
badge.style.backgroundColor = `rgb(${color.r},${color.g},${color.b})`
badge.style.left = cx + 'px'
badge.style.top = cy + 'px'
badge.style.display = 'block'
}
function onBgCanvasMouseLeave() {
const badge = document.getElementById('bgColorBadge')
if (badge) badge.style.display = 'none'
}
function updateBgColorDisplay(color) {
if (color) {
bgColorSwatch.value = `rgb(${color.r},${color.g},${color.b})`
bgColorCode.value = `#${color.r.toString(16).padStart(2, '0')}${color.g.toString(16).padStart(2, '0')}${color.b.toString(16).padStart(2, '0')}`
} else {
bgColorSwatch.value = ''
bgColorCode.value = '未选取'
}
}
function bgApplyPreset(preset) {
const presets = {
white: { r: 255, g: 255, b: 255 },
black: { r: 0, g: 0, b: 0 },
green: { r: 0, g: 177, b: 64 },
gray: { r: 200, g: 200, b: 200 }
}
const color = presets[preset]
if (!color) return
bgRemovalColor.value = color
updateBgColorDisplay(color)
bgSaveHistory()
const count = bgRemoveGlobal(color, bgRemovalTolerance.value)
renderBgCanvases()
if (count > 0) {
ElMessage.success(`已去除 ${count.toLocaleString()} 个像素`)
} else {
ElMessage.warning('未找到匹配的像素,请调整容差')
}
}
async function bgDownloadResult() {
if (!bgProcessedCanvas.value) return
const canvas = document.createElement('canvas')
canvas.width = bgProcessedCanvas.value.width
canvas.height = bgProcessedCanvas.value.height
canvas.getContext('2d').putImageData(bgProcessedCanvas.value.data, 0, 0)
const link = document.createElement('a')
const baseName = props.filePath ? props.filePath.split(/[/\\]/).pop().replace(/\.[^.]+$/, '') : 'image'
link.download = baseName + '_nobg.png'
link.href = canvas.toDataURL('image/png')
link.click()
ElMessage.success('已下载 PNG')
}
function buildRequest() {
return {
inputPath: props.filePath,
outputPath: props.outputPath,
format: 'removebg',
threshold: removebgThreshold.value,
}
}
function onProcess() {
emit('process', buildRequest())
}
function onDrop(e) {
e.preventDefault()
e.currentTarget.classList.remove('dragover')
const files = e.dataTransfer?.files
if (files?.length) {
const ext = files[0].name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) {
ElMessage.warning('请选择图片文件')
return
}
emit('selectFile', files[0].path)
}
}
function onDragOver(e) {
e.preventDefault()
e.currentTarget.classList.add('dragover')
}
function onDragLeave(e) {
e.currentTarget.classList.remove('dragover')
}
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover.prevent @drop.prevent="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="tool-main">
<div class="file-selected-bar" @click="$emit('selectFile')">
<img v-if="filePreview" :src="filePreview" class="file-preview-img" />
<div class="file-preview-info">
<div class="file-name">{{ getFileName() }}</div>
<div v-if="imageInfo" class="file-meta">{{ imageInfo.width }}×{{ imageInfo.height }} · {{ formatSize(imageInfo.size) }}</div>
</div>
<el-button type="danger" text @click.stop="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
</div>
<div class="params-section">
<div class="param-group">
<div class="param-label">背景灵敏度: {{ removebgThreshold }}</div>
<el-slider v-model="removebgThreshold" :min="5" :max="100" :step="5" show-stops />
</div>
</div>
<div class="bg-removal-section" v-if="bgPreviewReady">
<div class="bg-removal-controls">
<div class="bg-control-row">
<div class="bg-control-group">
<span class="bg-control-label">模式</span>
<div class="bg-mode-btns">
<button class="bg-mode-btn" :class="{ active: bgRemovalMode === 'global' }" @click="bgRemovalMode = 'global'">全局匹配</button>
<button class="bg-mode-btn" :class="{ active: bgRemovalMode === 'floodfill' }" @click="bgRemovalMode = 'floodfill'">连通区域</button>
</div>
</div>
<div class="bg-control-group">
<span class="bg-control-label">容差</span>
<div class="bg-tolerance-wrap">
<input type="range" class="bg-tolerance-slider" v-model.number="bgRemovalTolerance" min="0" max="100" step="1" />
<span class="bg-tolerance-value">{{ bgRemovalTolerance }}</span>
</div>
</div>
<div class="bg-control-group">
<span class="bg-control-label">当前颜色</span>
<div class="bg-color-display">
<div class="bg-color-swatch" :style="{ backgroundColor: bgColorSwatch || '' }" :class="{ empty: !bgColorSwatch }"></div>
<span class="bg-color-code">{{ bgColorCode }}</span>
</div>
</div>
</div>
<div class="bg-control-row">
<div class="bg-control-group">
<span class="bg-control-label">快捷</span>
<div class="bg-presets">
<button class="bg-preset-btn" @click="bgApplyPreset('white')"><span class="bg-mini-swatch" style="background:#fff"></span>白色</button>
<button class="bg-preset-btn" @click="bgApplyPreset('black')"><span class="bg-mini-swatch" style="background:#1a1a1a"></span>黑色</button>
<button class="bg-preset-btn" @click="bgApplyPreset('green')"><span class="bg-mini-swatch" style="background:#00b140"></span>绿幕</button>
<button class="bg-preset-btn" @click="bgApplyPreset('gray')"><span class="bg-mini-swatch" style="background:#d0d0d0"></span>灰色</button>
</div>
</div>
<div class="bg-control-group bg-actions">
<button class="bg-action-btn bg-undo-btn" :disabled="bgRemovalHistory.length === 0" @click="bgUndo"> 撤销</button>
<button class="bg-action-btn bg-reset-btn" @click="bgResetAll">🔄 重置</button>
<button class="bg-action-btn bg-download-btn" @click="bgDownloadResult">💾 下载</button>
</div>
</div>
</div>
<div class="bg-canvas-area">
<div class="bg-canvas-panel">
<div class="bg-panel-title"><span class="bg-dot bg-dot-orig"></span>原图 · 点击取色</div>
<div class="bg-canvas-wrapper" @click="onBgCanvasClick" @mousemove="onBgCanvasMouseMove" @mouseleave="onBgCanvasMouseLeave" style="cursor:crosshair">
<canvas id="bgOriginalCanvas"></canvas>
<div id="bgColorBadge" class="bg-color-badge"></div>
</div>
</div>
<div class="bg-canvas-panel">
<div class="bg-panel-title"><span class="bg-dot bg-dot-preview"></span>预览 · 透明背景</div>
<div class="bg-canvas-wrapper bg-preview-wrapper">
<canvas id="bgPreviewCanvas"></canvas>
</div>
</div>
</div>
</div>
<div class="output-section">
<div class="output-header">
<span class="param-label">输出路径</span>
</div>
<div class="output-row">
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly />
</div>
</div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
<el-icon v-if="!processing"><Check /></el-icon>
{{ processing ? '处理中...' : '开始处理' }}
</el-button>
</div>
</template>
<style scoped>
.tool-main { max-width: 900px; margin: 0 auto; }
.file-drop-zone {
border: 2px dashed var(--glass-border); border-radius: var(--radius-lg);
padding: 40px; text-align: center; cursor: pointer;
transition: all var(--transition-normal); margin-bottom: 24px;
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
}
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.file-selected-bar {
display: flex; align-items: center; gap: 14px; padding: 12px 16px;
border: 1px solid var(--glass-border); border-radius: var(--radius-lg);
margin-bottom: 24px; background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
cursor: pointer; transition: all var(--transition-normal);
}
.file-selected-bar:hover { border-color: var(--accent-primary); }
.file-preview-img { width: 48px; height: 48px; object-fit: cover; border-radius: var(--radius-sm); }
.file-preview-info { flex: 1; min-width: 0; }
.file-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.file-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.params-section { margin-bottom: 20px; }
.param-group { margin-bottom: 16px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.output-section { margin-bottom: 24px; }
.output-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn {
width: 100%; height: 48px; font-size: 15px; font-weight: 600;
border-radius: var(--radius-md); background: var(--gradient-primary); border: none;
transition: all var(--transition-normal);
}
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
.bg-removal-section {
margin: 20px 0; background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 16px;
}
.bg-removal-controls { margin-bottom: 16px; }
.bg-control-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin-bottom: 12px; }
.bg-control-row:last-child { margin-bottom: 0; }
.bg-control-group { display: flex; align-items: center; gap: 8px; }
.bg-control-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); white-space: nowrap; }
.bg-mode-btns { display: flex; gap: 4px; background: var(--bg-surface); border-radius: 20px; padding: 3px; }
.bg-mode-btn {
padding: 6px 14px; border-radius: 18px; border: none; cursor: pointer;
font-size: 12px; font-weight: 500; background: transparent;
color: var(--text-secondary); transition: all var(--transition-normal);
}
.bg-mode-btn.active { background: var(--accent-primary); color: #fff; }
.bg-mode-btn:hover:not(.active) { color: var(--text-primary); }
.bg-tolerance-wrap { display: flex; align-items: center; gap: 8px; }
.bg-tolerance-slider {
-webkit-appearance: none; width: 120px; height: 4px; border-radius: 2px;
background: var(--bg-surface); outline: none; cursor: pointer;
}
.bg-tolerance-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: #fff; border: 2px solid var(--accent-primary); cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.bg-tolerance-value {
font-weight: 700; font-size: 13px; color: var(--accent-primary);
min-width: 28px; text-align: center; font-family: monospace;
}
.bg-color-display { display: flex; align-items: center; gap: 6px; }
.bg-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); }
.bg-color-swatch.empty { background: conic-gradient(#e0e0e5 0deg 90deg, #fff 90deg 180deg, #e0e0e5 180deg 270deg, #fff 270deg 360deg); background-size: 12px 12px; }
.bg-color-code { font-family: monospace; font-size: 12px; color: var(--text-primary); }
.bg-presets { display: flex; gap: 6px; }
.bg-preset-btn {
padding: 5px 10px; border-radius: 16px; font-size: 12px; font-weight: 500;
background: var(--bg-surface); border: 1px solid var(--glass-border);
cursor: pointer; transition: all var(--transition-normal);
display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary);
}
.bg-preset-btn:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.15); }
.bg-mini-swatch {
width: 14px; height: 14px; border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1); display: inline-block;
}
.bg-actions { margin-left: auto; }
.bg-action-btn {
padding: 6px 12px; border-radius: 16px; font-size: 12px; font-weight: 500;
border: none; cursor: pointer; transition: all var(--transition-normal);
}
.bg-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.bg-undo-btn { background: var(--bg-surface); color: var(--text-primary); }
.bg-undo-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); }
.bg-reset-btn { background: var(--bg-surface); color: var(--text-primary); }
.bg-reset-btn:hover { background: rgba(255, 255, 255, 0.08); }
.bg-download-btn { background: var(--accent-primary); color: #fff; }
.bg-download-btn:hover { background: var(--accent-primary-hover); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.bg-canvas-area { display: flex; gap: 16px; flex-wrap: wrap; }
.bg-canvas-panel { flex: 1; min-width: 300px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.bg-panel-title {
font-weight: 600; font-size: 12px; color: var(--text-secondary);
letter-spacing: 0.02em; text-transform: uppercase;
display: flex; align-items: center; gap: 6px;
}
.bg-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.bg-dot-orig { background: var(--accent-primary); }
.bg-dot-preview { background: var(--accent-green); }
.bg-canvas-wrapper {
position: relative; width: 100%; min-height: 400px;
background: var(--bg-surface); border-radius: var(--radius-md);
overflow: hidden; display: flex; align-items: center; justify-content: center;
border: 1px solid var(--glass-border);
}
.bg-canvas-wrapper canvas { max-width: 100%; max-height: 100%; display: block; }
.bg-preview-wrapper {
background-color: #fff;
background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5);
background-size: 20px 20px; background-position: 0 0, 10px 10px; background-color: #fff;
}
.bg-color-badge {
position: absolute; pointer-events: none; width: 40px; height: 40px;
border-radius: 50%; border: 3px solid #fff; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
display: none; z-index: 10; transform: translate(-50%, -50%);
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const customWidth = ref(800)
const customHeight = ref(600)
const resizePercentage = ref(100)
const resizeRatioPreset = ref('free')
const aspectLocked = ref(false)
const resizeUnit = ref('px')
const lockRatio = ref(1)
let aspectUpdating = false
watch(() => props.originalWidth, (w) => { if (w) customWidth.value = w })
watch(() => props.originalHeight, (h) => { if (h) customHeight.value = h })
watch(resizePercentage, (pct) => {
if (props.originalWidth && props.originalHeight) {
customWidth.value = Math.round(props.originalWidth * pct / 100)
customHeight.value = Math.round(props.originalHeight * pct / 100)
}
})
const widthPercent = ref(100)
const heightPercent = ref(100)
watch(() => customWidth.value, (val) => {
widthPercent.value = props.originalWidth ? Math.round(val / props.originalWidth * 100) : 100
})
watch(() => customHeight.value, (val) => {
heightPercent.value = props.originalHeight ? Math.round(val / props.originalHeight * 100) : 100
})
function onWidthChange(val) {
if (aspectLocked.value && !aspectUpdating) {
aspectUpdating = true
customHeight.value = Math.round(val / lockRatio.value)
nextTick(() => { aspectUpdating = false })
}
}
function onHeightChange(val) {
if (aspectLocked.value && !aspectUpdating) {
aspectUpdating = true
customWidth.value = Math.round(val * lockRatio.value)
nextTick(() => { aspectUpdating = false })
}
}
function setResizeRatio(ratio) {
resizeRatioPreset.value = ratio
if (ratio === 'free') {
aspectLocked.value = false
return
}
aspectLocked.value = true
const [rw, rh] = ratio.split(':').map(Number)
lockRatio.value = rw / rh
const targetRatio = rw / rh
if (props.originalWidth / props.originalHeight > targetRatio) {
customHeight.value = props.originalHeight
customWidth.value = Math.round(props.originalHeight * targetRatio)
} else {
customWidth.value = props.originalWidth
customHeight.value = Math.round(props.originalWidth / targetRatio)
}
}
function resetResizePercentage() {
resizePercentage.value = 100
customWidth.value = props.originalWidth
customHeight.value = props.originalHeight
}
function buildRequest() {
return {
inputPath: props.filePath,
outputPath: props.outputPath,
format: 'resize',
width: resizeUnit.value === 'px' ? customWidth.value : Math.round(props.originalWidth * widthPercent.value / 100),
height: resizeUnit.value === 'px' ? customHeight.value : Math.round(props.originalHeight * heightPercent.value / 100),
maintainRatio: aspectLocked.value,
}
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() {
if (!props.filePath) return ''
return props.filePath.split(/[/\\]/).pop()
}
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover')
const files = e.dataTransfer?.files
if (files?.length) {
const ext = files[0].name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) {
ElMessage.warning('请选择图片文件')
return
}
emit('selectFile', files[0].path)
}
}
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container">
<div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div>
</div>
<div class="image-info-bar">
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
<span class="image-filename">{{ getFileName() }}</span>
<el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<div class="image-panel">
<div class="panel-section">
<div class="panel-title">调整大小</div>
<div class="param-group">
<div class="param-label">缩放比例</div>
<div class="percentage-row">
<el-input-number v-model="resizePercentage" :min="1" :max="500" :step="5" size="small" controls-position="right" />
<span class="pct-unit">%</span>
<el-button size="small" @click="resetResizePercentage">重置</el-button>
</div>
</div>
<div class="param-group">
<div class="param-label">预设比例</div>
<div class="tag-group">
<div class="tag-option" :class="{ active: resizeRatioPreset === 'free' }" @click="setResizeRatio('free')">自由</div>
<div class="tag-option" :class="{ active: resizeRatioPreset === '1:1' }" @click="setResizeRatio('1:1')">1:1</div>
<div class="tag-option" :class="{ active: resizeRatioPreset === '4:3' }" @click="setResizeRatio('4:3')">4:3</div>
<div class="tag-option" :class="{ active: resizeRatioPreset === '16:9' }" @click="setResizeRatio('16:9')">16:9</div>
</div>
</div>
<div class="param-group">
<div class="param-label">
尺寸 ( × )
<div class="unit-toggle">
<button class="unit-btn" :class="{ active: resizeUnit === 'px' }" @click="resizeUnit = 'px'">px</button>
<button class="unit-btn" :class="{ active: resizeUnit === '%' }" @click="resizeUnit = '%'">%</button>
</div>
<button class="aspect-lock-btn inline" :class="{ locked: aspectLocked }" @click="aspectLocked = !aspectLocked" :title="aspectLocked ? '解锁比例' : '锁定比例'">
<el-icon :size="12"><Lock v-if="aspectLocked" /><Unlock v-else /></el-icon>
</button>
</div>
<div class="size-inputs" v-if="resizeUnit === 'px'">
<el-input-number v-model="customWidth" :min="1" :max="10000" size="small" @change="onWidthChange" controls-position="right" />
<span class="size-sep">×</span>
<el-input-number v-model="customHeight" :min="1" :max="10000" size="small" @change="onHeightChange" controls-position="right" />
<span class="size-unit">px</span>
</div>
<div class="size-inputs" v-else>
<el-input-number v-model="widthPercent" :min="1" :max="500" :step="5" size="small" controls-position="right" />
<span class="size-sep">×</span>
<el-input-number v-model="heightPercent" :min="1" :max="500" :step="5" size="small" controls-position="right" />
<span class="size-unit">%</span>
</div>
</div>
</div>
<div class="panel-section">
<div class="panel-title">输出路径</div>
<div class="output-row">
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" />
</div>
</div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
<el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}
</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.percentage-row { display: flex; align-items: center; gap: 8px; }
.pct-unit { color: var(--text-muted); font-size: 13px; }
.size-inputs { display: flex; align-items: center; gap: 8px; }
.size-sep { color: var(--text-muted); font-size: 14px; }
.size-unit { color: var(--text-muted); font-size: 13px; }
.unit-toggle { display: inline-flex; background: var(--bg-surface); border-radius: var(--radius-sm); padding: 2px; margin-left: 6px; }
.unit-btn { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; cursor: pointer; background: transparent; border: none; color: var(--text-muted); transition: all var(--transition-normal); }
.unit-btn.active { background: var(--accent-primary); color: #fff; }
.aspect-lock-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); color: var(--text-secondary); transition: all var(--transition-normal); }
.aspect-lock-btn.locked { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
.aspect-lock-btn.inline { padding: 2px 6px; border-radius: 4px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,124 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const angleSlider = ref(0)
const rotationBgColor = ref('white')
const flipH = ref(false)
const flipV = ref(false)
function buildRequest() {
return {
inputPath: props.filePath,
outputPath: props.outputPath,
format: 'rotate',
angle: angleSlider.value,
bgColor: rotationBgColor.value,
flipH: flipH.value,
flipV: flipV.value,
}
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
<div class="image-info-bar">
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
<span class="image-filename">{{ getFileName() }}</span>
<el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<div class="image-panel">
<div class="panel-section">
<div class="panel-title">旋转设置</div>
<div class="param-group">
<div class="param-label">角度: {{ angleSlider }}°</div>
<el-slider v-model="angleSlider" :min="0" :max="360" :step="1" />
<div class="slider-hint"><span>0°</span><span>180°</span><span>360°</span></div>
</div>
<div class="param-group">
<div class="param-label">背景颜色</div>
<div class="tag-group">
<div class="tag-option" :class="{ active: rotationBgColor === 'white' }" @click="rotationBgColor = 'white'"><span class="bg-mini-swatch" style="background:#fff"></span>白色</div>
<div class="tag-option" :class="{ active: rotationBgColor === 'transparent' }" @click="rotationBgColor = 'transparent'"><span class="bg-mini-swatch bg-checker"></span>透明</div>
<div class="tag-option" :class="{ active: rotationBgColor === 'black' }" @click="rotationBgColor = 'black'"><span class="bg-mini-swatch" style="background:#1a1a1a"></span>黑色</div>
</div>
</div>
<div class="param-group">
<div class="param-label">翻转</div>
<div class="flip-btns">
<button class="flip-btn" :class="{ active: flipH }" @click="flipH = !flipH"><el-icon><DCaret /></el-icon> 水平</button>
<button class="flip-btn" :class="{ active: flipV }" @click="flipV = !flipV"><el-icon style="transform:rotate(90deg)"><DCaret /></el-icon> 垂直</button>
</div>
</div>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.flip-btns { display: flex; gap: 8px; }
.flip-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); color: var(--text-secondary); transition: all var(--transition-normal); }
.flip-btn.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
.flip-btn:hover:not(.active) { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); }
.bg-mini-swatch { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); display: inline-block; }
.bg-checker { background-color: #fff; background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc); background-size: 8px 8px; background-position: 0 0, 4px 4px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
filePath: String,
filePreview: String,
imageInfo: Object,
originalWidth: Number,
originalHeight: Number,
processing: Boolean,
resultInfo: Object,
outputPath: String,
config: Object,
})
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
const sharpenAmount = ref(50)
function buildRequest() {
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'sharpen', sharpenAmount: sharpenAmount.value / 50 }
}
function onProcess() { emit('process', buildRequest()) }
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
</script>
<template>
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
<div class="drop-text">点击选择图片</div>
<div class="drop-hint">支持拖拽图片到此处</div>
</div>
<div v-else class="image-workspace">
<div class="image-canvas-area">
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
</div>
<div class="image-panel">
<div class="panel-section"><div class="panel-title">锐化设置</div>
<div class="param-group"><div class="param-label">锐化强度: {{ sharpenAmount }}%</div><el-slider v-model="sharpenAmount" :min="0" :max="100" :step="1" /><div class="slider-hint"><span>无锐化</span><span>最强锐化</span></div></div>
</div>
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
</div>
</div>
</template>
<style scoped>
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: var(--text-muted); }
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
.canvas-wrapper { position: relative; display: inline-block; }
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
.image-dims { color: var(--text-secondary); font-family: monospace; }
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
.panel-section { display: flex; flex-direction: column; gap: 4px; }
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
.param-group { margin-bottom: 12px; }
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.output-row { display: flex; gap: 8px; }
.output-row .el-input { flex: 1; }
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
</style>

View File

@@ -0,0 +1,265 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
export function useImageTool() {
const filePath = ref('')
const filePreview = ref('')
const imageInfo = ref(null)
const processing = ref(false)
const resultInfo = ref(null)
const outputPath = ref('')
const compareImages = ref({ original: '', processed: '' })
const config = ref({ defaultOutputDir: '' })
const showPreview = ref(false)
const previewSrc = ref('')
const previewIndex = ref(0)
const previewScale = ref(1)
const previewX = ref(0)
const previewY = ref(0)
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
onMounted(async () => {
try {
config.value = await window.go.main.FileHandler.GetConfig()
} catch (e) {}
})
function formatSize(bytes) {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function getFileName() {
if (!filePath.value) return '未选择文件'
return filePath.value.split(/[/\\]/).pop()
}
async function selectFile(fileFilters) {
try {
const path = await window.go.main.FileHandler.OpenFileDialog('选择文件', fileFilters)
if (path) {
filePath.value = path
resetState()
outputPath.value = ''
await loadFilePreview()
}
} catch (e) {
ElMessage.error('选择文件失败: ' + e.message)
}
}
async function loadFilePreview() {
if (!filePath.value) return
try {
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
if (b64) {
filePreview.value = b64
const info = await window.go.main.FileHandler.GetFileInfo(filePath.value)
imageInfo.value = info
}
} catch (e) {
console.error('加载预览失败:', e)
}
}
function resetState() {
resultInfo.value = null
compareImages.value = { original: '', processed: '' }
previewScale.value = 1
previewX.value = 0
previewY.value = 0
filePreview.value = ''
imageInfo.value = null
}
function clearImageFile() {
filePath.value = ''
filePreview.value = ''
imageInfo.value = null
resultInfo.value = null
compareImages.value = { original: '', processed: '' }
outputPath.value = ''
}
async function processFile(req) {
if (!filePath.value) {
ElMessage.warning('请先选择文件')
return
}
processing.value = true
resultInfo.value = null
compareImages.value = { original: '', processed: '' }
try {
const result = await window.go.main.FileHandler.ProcessFile(req)
if (result.success) {
resultInfo.value = {
path: result.path,
size: formatSize(result.size),
sizeBytes: result.size,
originalSize: result.originalSize ? formatSize(result.originalSize) : '',
originalSizeBytes: result.originalSize || 0,
tempPath: result.path,
isImageOp: true,
}
try {
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
compareImages.value.original = b64
compareImages.value.processed = await window.go.main.FileHandler.GetImageBase64(result.path)
if (!compareImages.value.processed) {
compareImages.value.processed = b64
}
} catch (e) {}
await window.go.main.FileHandler.AddRecentUse({
id: req.format,
name: '',
category: 'image',
usedAt: Date.now(),
})
ElMessage.success('处理完成')
} else {
ElMessage.error(result.message)
}
} catch (e) {
ElMessage.error('处理失败: ' + e.message)
} finally {
processing.value = false
}
}
async function selectOutputPath(outputFilters, defaultName) {
try {
const path = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, outputFilters)
if (path) {
outputPath.value = path
}
} catch (e) {}
}
async function downloadResult(outputFilters, getOutputExtFn, getOutputDefaultNameFn) {
if (!resultInfo.value?.tempPath) return
try {
const ext = getOutputExtFn ? getOutputExtFn() : '.png'
const defaultName = getOutputDefaultNameFn ? getOutputDefaultNameFn() : ('output' + ext)
const savePath = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, outputFilters)
if (savePath) {
const result = await window.go.main.FileHandler.SaveResult(resultInfo.value.tempPath, savePath)
if (result.success) {
ElMessage.success('文件已保存到: ' + savePath)
resultInfo.value.path = savePath
} else {
ElMessage.error(result.message)
}
}
} catch (e) {
ElMessage.error('保存失败: ' + e.message)
}
}
function openPreview(src, index) {
previewSrc.value = src
previewIndex.value = index
previewScale.value = 1
previewX.value = 0
previewY.value = 0
showPreview.value = true
}
function closePreview() {
showPreview.value = false
}
function onWheel(e) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
previewScale.value = Math.max(0.1, Math.min(5, previewScale.value + delta))
}
function onMouseDown(e) {
if (e.button !== 0) return
isDragging.value = true
dragStart.value = { x: e.clientX - previewX.value, y: e.clientY - previewY.value }
}
function onMouseMove(e) {
if (!isDragging.value) return
previewX.value = e.clientX - dragStart.value.x
previewY.value = e.clientY - dragStart.value.y
}
function onMouseUp() {
isDragging.value = false
}
function resetPreview() {
previewScale.value = 1
previewX.value = 0
previewY.value = 0
}
function switchPreview(dir) {
previewIndex.value = dir
previewSrc.value = dir === 0 ? compareImages.value.original : compareImages.value.processed
resetPreview()
}
async function openOutputFolder() {
if (resultInfo.value?.path) {
const dir = resultInfo.value.path.replace(/[/\\][^/\\]+$/, '')
try {
await window.go.main.FileHandler.OpenFolder(dir)
} catch (e) {
ElMessage.error('打开文件夹失败')
}
}
}
return {
filePath,
filePreview,
imageInfo,
processing,
resultInfo,
outputPath,
compareImages,
config,
showPreview,
previewSrc,
previewIndex,
previewScale,
previewX,
previewY,
isDragging,
dragStart,
formatSize,
getFileName,
selectFile,
loadFilePreview,
resetState,
clearImageFile,
processFile,
selectOutputPath,
downloadResult,
openPreview,
closePreview,
onWheel,
onMouseDown,
onMouseMove,
onMouseUp,
resetPreview,
switchPreview,
openOutputFolder,
}
}

View File

@@ -1,5 +1,17 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import './style.css';
import router from './router'
import './style.css'
createApp(App).mount('#app')
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,17 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import HomeView from '../components/HomeView.vue'
import ToolView from '../components/ToolView.vue'
import SettingsView from '../components/SettingsView.vue'
const routes = [
{ path: '/', name: 'home', component: HomeView },
{ path: '/tool/:category/:action?', name: 'tool', component: ToolView },
{ path: '/settings', name: 'settings', component: SettingsView }
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

View File

@@ -1,26 +1,321 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-surface: #1a1a25;
--bg-glass: rgba(255, 255, 255, 0.03);
--text-primary: #f0f0f5;
--text-secondary: #8888a0;
--text-muted: #555570;
--accent-primary: #3b82f6;
--accent-primary-hover: #60a5fa;
--accent-primary-glow: rgba(59, 130, 246, 0.3);
--accent-blue: #3b82f6;
--accent-green: #22c55e;
--accent-red: #ef4444;
--accent-yellow: #f59e0b;
--accent-purple: #a855f7;
--gradient-primary: linear-gradient(135deg, #3b82f6, #8b5cf6);
--gradient-surface: linear-gradient(180deg, rgba(255,255,255,0.05), transparent);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px var(--accent-primary-glow);
--glass-blur: 20px;
--glass-bg: rgba(18, 18, 26, 0.8);
--glass-border: rgba(255, 255, 255, 0.08);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--tool-pdf: #f38ba8;
--tool-word: #89b4fa;
--tool-excel: #a6e3a1;
--tool-image: #f9e2af;
--tool-convert: #cba6f7;
}
body {
* {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
padding: 0;
box-sizing: border-box;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
html, body {
height: 100%;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100vh;
text-align: center;
overflow: hidden;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-surface);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Element Plus Overrides */
.el-menu {
border-right: none !important;
background-color: transparent !important;
}
.el-card {
background-color: var(--bg-secondary) !important;
border-color: var(--glass-border) !important;
border-radius: var(--radius-md) !important;
backdrop-filter: blur(var(--glass-blur));
}
.el-input__wrapper {
background-color: var(--bg-surface) !important;
box-shadow: 0 0 0 1px var(--glass-border) inset !important;
border-radius: var(--radius-sm) !important;
transition: all var(--transition-normal) !important;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px rgba(255,255,255,0.15) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--accent-primary) inset, 0 0 12px var(--accent-primary-glow) !important;
}
.el-input__inner {
color: var(--text-primary) !important;
}
.el-input-number {
--el-input-number-step-button-bg-color: var(--bg-surface);
--el-input-number-step-button-border-color: var(--glass-border);
}
.el-button--primary {
--el-button-bg-color: var(--accent-primary);
--el-button-border-color: var(--accent-primary);
--el-button-text-color: #ffffff;
--el-button-hover-bg-color: var(--accent-primary-hover);
--el-button-hover-border-color: var(--accent-primary-hover);
--el-button-hover-text-color: #ffffff;
--el-button-active-bg-color: #2563eb;
--el-button-active-border-color: #2563eb;
border-radius: var(--radius-sm) !important;
transition: all var(--transition-normal) !important;
}
.el-button--primary:hover {
box-shadow: var(--shadow-glow);
}
.el-button {
border-radius: var(--radius-sm) !important;
transition: all var(--transition-fast) !important;
}
.el-button:active {
transform: scale(0.98);
}
.el-button.is-text {
border-radius: 6px !important;
}
.el-message-box {
background-color: var(--glass-bg) !important;
border-color: var(--glass-border) !important;
backdrop-filter: blur(var(--glass-blur));
border-radius: var(--radius-lg) !important;
}
.el-progress-bar__outer {
background-color: var(--bg-surface) !important;
border-radius: 100px !important;
}
.el-progress-bar__inner {
border-radius: 100px !important;
}
.el-tag {
border-radius: var(--radius-sm) !important;
}
.el-upload-dragger {
background-color: var(--bg-surface) !important;
border-color: var(--glass-border) !important;
border-radius: var(--radius-md) !important;
transition: all var(--transition-normal) !important;
}
.el-upload-dragger:hover {
border-color: var(--accent-primary) !important;
background-color: rgba(59, 130, 246, 0.05) !important;
}
.el-scrollbar__bar {
opacity: 0 !important;
}
.el-scrollbar:hover .el-scrollbar__bar {
opacity: 1 !important;
}
/* macOS Window Controls */
.window-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 52px;
-webkit-app-region: drag;
user-select: none;
}
.window-btn {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
position: relative;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
-webkit-app-region: no-drag;
}
.window-btn svg {
width: 8px;
height: 8px;
opacity: 0;
transition: opacity var(--transition-fast);
}
.window-btn:hover svg {
opacity: 1;
}
.window-btn-close {
background-color: #ff5f57;
}
.window-btn-close:hover {
background-color: #ff4040;
}
.window-btn-close svg {
stroke: #4a0000;
}
.window-btn-minimize {
background-color: #febc2e;
}
.window-btn-minimize:hover {
background-color: #f5a623;
}
.window-btn-minimize svg {
stroke: #5a3e00;
}
.window-btn-maximize {
background-color: #28c840;
}
.window-btn-maximize:hover {
background-color: #1aab29;
}
.window-btn-maximize svg {
stroke: #003d00;
}
/* Glass Card */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
}
.glass-card:hover {
border-color: rgba(255, 255, 255, 0.12);
}
/* Glow Effect */
.glow-on-hover {
transition: all var(--transition-normal);
}
.glow-on-hover:hover {
box-shadow: var(--shadow-glow);
border-color: var(--accent-primary);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-fade-in {
animation: fadeIn var(--transition-slow) forwards;
}
.animate-slide-up {
animation: slideUp var(--transition-slow) forwards;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,31 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
export function AddRecentUse(arg1:main.RecentUse):Promise<void>;
export function GetCompareImages(arg1:string,arg2:string):Promise<Record<string, string>>;
export function GetConfig():Promise<main.AppConfig>;
export function GetFileInfo(arg1:string):Promise<Record<string, any>>;
export function GetImageBase64(arg1:string):Promise<string>;
export function GetRecentUses():Promise<Array<main.RecentUse>>;
export function GetShortcuts():Promise<Array<main.Shortcut>>;
export function OpenFileDialog(arg1:string,arg2:Array<string>):Promise<string>;
export function OpenFolder(arg1:string):Promise<void>;
export function OpenSaveDialog(arg1:string,arg2:string,arg3:Array<string>,arg4:string):Promise<string>;
export function ProcessFile(arg1:main.ProcessRequest):Promise<main.FileResult>;
export function SaveConfig(arg1:main.AppConfig):Promise<void>;
export function SaveResult(arg1:string,arg2:string):Promise<main.FileResult>;
export function SaveShortcuts(arg1:Array<main.Shortcut>):Promise<void>;

View File

@@ -0,0 +1,59 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddRecentUse(arg1) {
return window['go']['main']['FileHandler']['AddRecentUse'](arg1);
}
export function GetCompareImages(arg1, arg2) {
return window['go']['main']['FileHandler']['GetCompareImages'](arg1, arg2);
}
export function GetConfig() {
return window['go']['main']['FileHandler']['GetConfig']();
}
export function GetFileInfo(arg1) {
return window['go']['main']['FileHandler']['GetFileInfo'](arg1);
}
export function GetImageBase64(arg1) {
return window['go']['main']['FileHandler']['GetImageBase64'](arg1);
}
export function GetRecentUses() {
return window['go']['main']['FileHandler']['GetRecentUses']();
}
export function GetShortcuts() {
return window['go']['main']['FileHandler']['GetShortcuts']();
}
export function OpenFileDialog(arg1, arg2) {
return window['go']['main']['FileHandler']['OpenFileDialog'](arg1, arg2);
}
export function OpenFolder(arg1) {
return window['go']['main']['FileHandler']['OpenFolder'](arg1);
}
export function OpenSaveDialog(arg1, arg2, arg3, arg4) {
return window['go']['main']['FileHandler']['OpenSaveDialog'](arg1, arg2, arg3, arg4);
}
export function ProcessFile(arg1) {
return window['go']['main']['FileHandler']['ProcessFile'](arg1);
}
export function SaveConfig(arg1) {
return window['go']['main']['FileHandler']['SaveConfig'](arg1);
}
export function SaveResult(arg1, arg2) {
return window['go']['main']['FileHandler']['SaveResult'](arg1, arg2);
}
export function SaveShortcuts(arg1) {
return window['go']['main']['FileHandler']['SaveShortcuts'](arg1);
}

View File

@@ -0,0 +1,226 @@
export namespace main {
export class AppConfig {
defaultOutputDir: string;
static createFrom(source: any = {}) {
return new AppConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.defaultOutputDir = source["defaultOutputDir"];
}
}
export class FileResult {
success: boolean;
message: string;
path?: string;
size?: number;
originalSize?: number;
static createFrom(source: any = {}) {
return new FileResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.success = source["success"];
this.message = source["message"];
this.path = source["path"];
this.size = source["size"];
this.originalSize = source["originalSize"];
}
}
export class ProcessRequest {
inputPath: string;
outputPath?: string;
format?: string;
quality?: string;
qualityInt?: number;
width?: number;
height?: number;
angle?: number;
bgColor?: string;
bgRemoveColor?: services.ColorRGB;
text?: string;
threshold?: number;
shape?: services.CropShape;
brightness?: number;
contrast?: number;
saturation?: number;
grayscaleIntensity?: number;
sharpenAmount?: number;
blurRadius?: number;
flipH?: boolean;
flipV?: boolean;
maintainRatio?: boolean;
cropX?: number;
cropY?: number;
static createFrom(source: any = {}) {
return new ProcessRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.inputPath = source["inputPath"];
this.outputPath = source["outputPath"];
this.format = source["format"];
this.quality = source["quality"];
this.qualityInt = source["qualityInt"];
this.width = source["width"];
this.height = source["height"];
this.angle = source["angle"];
this.bgColor = source["bgColor"];
this.bgRemoveColor = this.convertValues(source["bgRemoveColor"], services.ColorRGB);
this.text = source["text"];
this.threshold = source["threshold"];
this.shape = this.convertValues(source["shape"], services.CropShape);
this.brightness = source["brightness"];
this.contrast = source["contrast"];
this.saturation = source["saturation"];
this.grayscaleIntensity = source["grayscaleIntensity"];
this.sharpenAmount = source["sharpenAmount"];
this.blurRadius = source["blurRadius"];
this.flipH = source["flipH"];
this.flipV = source["flipV"];
this.maintainRatio = source["maintainRatio"];
this.cropX = source["cropX"];
this.cropY = source["cropY"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RecentUse {
id: string;
name: string;
category: string;
usedAt: number;
static createFrom(source: any = {}) {
return new RecentUse(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.category = source["category"];
this.usedAt = source["usedAt"];
}
}
export class Shortcut {
id: string;
name: string;
category: string;
icon: string;
static createFrom(source: any = {}) {
return new Shortcut(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.category = source["category"];
this.icon = source["icon"];
}
}
}
export namespace services {
export class ColorRGB {
r: number;
g: number;
b: number;
static createFrom(source: any = {}) {
return new ColorRGB(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.r = source["r"];
this.g = source["g"];
this.b = source["b"];
}
}
export class Point {
x: number;
y: number;
static createFrom(source: any = {}) {
return new Point(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.x = source["x"];
this.y = source["y"];
}
}
export class CropShape {
type: string;
x: number;
y: number;
width: number;
height: number;
radius: number;
points: Point[];
static createFrom(source: any = {}) {
return new CropShape(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.x = source["x"];
this.y = source["y"];
this.width = source["width"];
this.height = source["height"];
this.radius = source["radius"];
this.points = this.convertValues(source["points"], Point);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

26
go.mod
View File

@@ -1,13 +1,18 @@
module xk
go 1.23.0
go 1.25.0
require github.com/wailsapp/wails/v2 v2.12.0
require (
github.com/disintegration/imaging v1.6.2
github.com/signintech/gopdf v0.36.1
github.com/wailsapp/wails/v2 v2.12.0
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/image v0.41.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -21,20 +26,25 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/image v0.12.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.12.0 => C:\Users\admin\go\pkg\mod

68
go.sum
View File

@@ -38,19 +38,30 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no=
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/signintech/gopdf v0.36.1 h1:cGpvEKvvqCV+ZXB9R2SQoWgouW91JpwsgoQEhLxIdp0=
github.com/signintech/gopdf v0.36.1/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -63,54 +74,33 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

593
handlers.go Normal file
View File

@@ -0,0 +1,593 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
stdruntime "runtime"
"strings"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
"xk/services"
)
type FileHandler struct {
ctx context.Context
pdfService *services.PDFService
wordService *services.WordService
excelService *services.ExcelService
imageService *services.ImageService
}
func NewFileHandler() *FileHandler {
return &FileHandler{
pdfService: services.NewPDFService(),
wordService: services.NewWordService(),
excelService: services.NewExcelService(),
imageService: services.NewImageService(),
}
}
func (h *FileHandler) startup(ctx context.Context) {
h.ctx = ctx
}
type FileResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Path string `json:"path,omitempty"`
Size int64 `json:"size,omitempty"`
OriginalSize int64 `json:"originalSize,omitempty"`
}
type ProcessRequest struct {
InputPath string `json:"inputPath"`
OutputPath string `json:"outputPath,omitempty"`
Format string `json:"format,omitempty"`
Quality string `json:"quality,omitempty"`
QualityInt int `json:"qualityInt,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Angle float64 `json:"angle,omitempty"`
BgColor string `json:"bgColor,omitempty"`
BgRemoveColor *services.ColorRGB `json:"bgRemoveColor,omitempty"`
Text string `json:"text,omitempty"`
Threshold int `json:"threshold,omitempty"`
Shape *services.CropShape `json:"shape,omitempty"`
Brightness int `json:"brightness,omitempty"`
Contrast int `json:"contrast,omitempty"`
Saturation int `json:"saturation,omitempty"`
GrayscaleIntensity int `json:"grayscaleIntensity,omitempty"`
SharpenAmount float64 `json:"sharpenAmount,omitempty"`
BlurRadius float64 `json:"blurRadius,omitempty"`
FlipH bool `json:"flipH,omitempty"`
FlipV bool `json:"flipV,omitempty"`
MaintainRatio *bool `json:"maintainRatio,omitempty"`
CropX int `json:"cropX,omitempty"`
CropY int `json:"cropY,omitempty"`
}
type AppConfig struct {
DefaultOutputDir string `json:"defaultOutputDir"`
}
type Shortcut struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Icon string `json:"icon"`
}
type RecentUse struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
UsedAt int64 `json:"usedAt"`
}
func (h *FileHandler) OpenFileDialog(title string, filters []string) (string, error) {
var fileFilters []wailsRuntime.FileFilter
if title == "" {
title = "选择文件"
}
if len(filters) == 0 {
filters = []string{"*"}
}
for _, f := range filters {
displayName := "所有文件 (*.*)"
switch f {
case "*.pdf":
displayName = "PDF 文件 (*.pdf)"
case "*.doc;*.docx":
displayName = "Word 文档 (*.doc;*.docx)"
case "*.xls;*.xlsx":
displayName = "Excel 文件 (*.xls;*.xlsx)"
case "*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico":
displayName = "图片文件 (*.jpg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico)"
}
fileFilters = append(fileFilters, wailsRuntime.FileFilter{DisplayName: displayName, Pattern: f})
}
path, err := wailsRuntime.OpenFileDialog(h.ctx, wailsRuntime.OpenDialogOptions{Title: title, Filters: fileFilters})
return path, err
}
func (h *FileHandler) OpenSaveDialog(title string, defaultFilename string, filters []string, defaultDir string) (string, error) {
if title == "" {
title = "保存文件"
}
var fileFilters []wailsRuntime.FileFilter
for _, f := range filters {
displayName := "所有文件 (*.*)"
switch f {
case "*.pdf":
displayName = "PDF 文件 (*.pdf)"
case "*.csv":
displayName = "CSV 文件 (*.csv)"
case "*.json":
displayName = "JSON 文件 (*.json)"
case "*.jpg;*.jpeg":
displayName = "JPEG 图片 (*.jpg;*.jpeg)"
case "*.png":
displayName = "PNG 图片 (*.png)"
case "*.ico":
displayName = "ICO 图标 (*.ico)"
}
fileFilters = append(fileFilters, wailsRuntime.FileFilter{DisplayName: displayName, Pattern: f})
}
path, err := wailsRuntime.SaveFileDialog(h.ctx, wailsRuntime.SaveDialogOptions{Title: title, DefaultFilename: defaultFilename, DefaultDirectory: defaultDir, Filters: fileFilters})
return path, err
}
func (h *FileHandler) OpenFolder(path string) error {
if path == "" {
return fmt.Errorf("路径不能为空")
}
var cmd *exec.Cmd
switch stdruntime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin":
cmd = exec.Command("open", path)
default:
cmd = exec.Command("xdg-open", path)
}
return cmd.Start()
}
func (h *FileHandler) GetConfig() AppConfig {
config := AppConfig{DefaultOutputDir: getDefaultOutputDir()}
data, err := os.ReadFile(getConfigPath())
if err != nil {
return config
}
_ = services.ParseJSON(data, &config)
return config
}
func (h *FileHandler) SaveConfig(config AppConfig) error {
data, err := services.ToJSON(config)
if err != nil {
return err
}
return os.WriteFile(getConfigPath(), data, 0644)
}
func (h *FileHandler) GetShortcuts() []Shortcut {
var shortcuts []Shortcut
data, err := os.ReadFile(getShortcutsPath())
if err != nil {
return getDefaultShortcuts()
}
_ = services.ParseJSON(data, &shortcuts)
if len(shortcuts) == 0 {
return getDefaultShortcuts()
}
return shortcuts
}
func (h *FileHandler) SaveShortcuts(shortcuts []Shortcut) error {
data, err := services.ToJSON(shortcuts)
if err != nil {
return err
}
return os.WriteFile(getShortcutsPath(), data, 0644)
}
func (h *FileHandler) GetRecentUses() []RecentUse {
var recent []RecentUse
data, err := os.ReadFile(getRecentPath())
if err != nil {
return []RecentUse{}
}
_ = services.ParseJSON(data, &recent)
return recent
}
func (h *FileHandler) AddRecentUse(tool RecentUse) error {
var recent []RecentUse
data, err := os.ReadFile(getRecentPath())
if err == nil {
_ = services.ParseJSON(data, &recent)
}
for i, r := range recent {
if r.ID == tool.ID {
recent = append(recent[:i], recent[i+1:]...)
break
}
}
recent = append([]RecentUse{tool}, recent...)
if len(recent) > 5 {
recent = recent[:5]
}
data, err = services.ToJSON(recent)
if err != nil {
return err
}
return os.WriteFile(getRecentPath(), data, 0644)
}
func (h *FileHandler) GetImageBase64(path string) (string, error) {
return h.imageService.GetImageBase64(path)
}
func (h *FileHandler) GetCompareImages(originalPath, processedPath string) (map[string]string, error) {
result := make(map[string]string)
if originalPath != "" {
b64, err := h.imageService.GetImageBase64(originalPath)
if err == nil {
result["original"] = b64
}
}
if processedPath != "" {
b64, err := h.imageService.GetImageBase64(processedPath)
if err == nil {
result["processed"] = b64
}
}
return result, nil
}
func (h *FileHandler) SaveResult(tempPath, outputPath string) (FileResult, error) {
if outputPath == "" {
return FileResult{}, fmt.Errorf("未指定输出路径")
}
data, err := os.ReadFile(tempPath)
if err != nil {
return FileResult{}, fmt.Errorf("读取临时文件失败: %v", err)
}
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return FileResult{}, fmt.Errorf("创建目录失败: %v", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return FileResult{}, fmt.Errorf("保存文件失败: %v", err)
}
info, err := os.Stat(outputPath)
if err != nil {
return FileResult{}, fmt.Errorf("获取文件信息失败: %v", err)
}
return FileResult{
Success: true,
Message: "保存成功",
Path: outputPath,
Size: info.Size(),
}, nil
}
func (h *FileHandler) ProcessFile(req ProcessRequest) FileResult {
if req.InputPath == "" {
return FileResult{Success: false, Message: "请选择文件"}
}
ext := strings.ToLower(filepath.Ext(req.InputPath))
switch ext {
case ".pdf":
return h.processPDF(req)
case ".doc", ".docx":
return h.processWord(req)
case ".xls", ".xlsx":
return h.processExcel(req)
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".ico":
return h.processImage(req)
default:
return FileResult{Success: false, Message: fmt.Sprintf("不支持的文件格式: %s", ext)}
}
}
func (h *FileHandler) resolveOutputPath(inputPath, outputPath, suffix, ext string) string {
if outputPath != "" {
return outputPath
}
config := h.GetConfig()
outDir := config.DefaultOutputDir
if outDir == "" {
outDir = filepath.Dir(inputPath)
}
baseName := services.GetBaseName(inputPath)
return filepath.Join(outDir, baseName+suffix+ext)
}
func (h *FileHandler) getTempPath(inputPath, suffix string) string {
tempDir := os.TempDir()
baseName := services.GetBaseName(inputPath)
return filepath.Join(tempDir, "xk_"+baseName+suffix+".png")
}
func (h *FileHandler) processPDF(req ProcessRequest) FileResult {
var err error
outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "_processed", ".pdf")
switch req.Format {
case "compress":
err = h.pdfService.CompressPDF(req.InputPath, outputPath, req.Quality)
case "split":
_, err = h.pdfService.SplitPDF(req.InputPath, filepath.Dir(outputPath), "")
case "rotate":
err = h.pdfService.RotatePDF(req.InputPath, outputPath, int(req.Angle))
case "watermark":
err = h.pdfService.AddWatermark(req.InputPath, outputPath, req.Text)
default:
err = h.pdfService.CompressPDF(req.InputPath, outputPath, "medium")
}
if err != nil {
return FileResult{Success: false, Message: err.Error()}
}
return h.getFileResult(req.InputPath, outputPath)
}
func (h *FileHandler) processWord(req ProcessRequest) FileResult {
outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "", ".pdf")
err := h.wordService.ConvertToPDF(req.InputPath, outputPath)
if err != nil {
return FileResult{Success: false, Message: err.Error()}
}
return h.getFileResult(req.InputPath, outputPath)
}
func (h *FileHandler) processExcel(req ProcessRequest) FileResult {
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
}
outputPath := h.resolveOutputPath(req.InputPath, req.OutputPath, "", outputExt)
var err error
switch req.Format {
case "csv":
err = h.excelService.ConvertToCSV(req.InputPath, outputPath)
case "json":
err = h.excelService.ConvertToJSON(req.InputPath, outputPath)
default:
err = h.excelService.ConvertToCSV(req.InputPath, outputPath)
}
if err != nil {
return FileResult{Success: false, Message: err.Error()}
}
return h.getFileResult(req.InputPath, outputPath)
}
func (h *FileHandler) processImage(req ProcessRequest) FileResult {
isImageOp := req.Format == "compress" || req.Format == "resize" ||
req.Format == "rotate" || req.Format == "grayscale" || req.Format == "brightness" ||
req.Format == "removebg" || req.Format == "crop" || req.Format == "flipH" || req.Format == "flipV" ||
req.Format == "sharpen" || req.Format == "blur" || req.Format == "invert"
var outputPath string
if isImageOp {
outputPath = h.getTempPath(req.InputPath, "_processed")
} else if req.Format == "convert" {
outputPath = h.resolveOutputPath(req.InputPath, req.OutputPath, "", "."+h.getConvertFormat(req))
} else {
outputPath = h.resolveOutputPath(req.InputPath, req.OutputPath, "", "."+req.Format)
}
var err error
switch req.Format {
case "compress":
quality := 85
if req.QualityInt > 0 {
quality = req.QualityInt
} else {
switch req.Quality {
case "high":
quality = 95
case "low":
quality = 60
}
}
err = h.imageService.CompressImage(req.InputPath, outputPath, quality, req.Width, req.Height)
case "resize":
maintainRatio := true
if req.MaintainRatio != nil {
maintainRatio = *req.MaintainRatio
}
err = h.imageService.ResizeImage(req.InputPath, outputPath, req.Width, req.Height, maintainRatio)
case "rotate":
err = h.imageService.RotateImage(req.InputPath, outputPath, req.Angle, req.BgColor)
if err == nil && req.FlipH {
err = h.imageService.FlipH(outputPath, outputPath)
}
if err == nil && req.FlipV {
err = h.imageService.FlipV(outputPath, outputPath)
}
case "grayscale":
intensity := req.GrayscaleIntensity
if intensity <= 0 {
intensity = 100
}
err = h.imageService.AdjustGrayscale(req.InputPath, outputPath, intensity)
case "brightness":
brightness := float64(req.Brightness)
err = h.imageService.AdjustBrightness(req.InputPath, outputPath, brightness)
if err == nil && req.Contrast != 0 {
contrastPath := h.getTempPath(req.InputPath, "_contrast")
if err2 := h.imageService.AdjustContrast(outputPath, contrastPath, float64(req.Contrast)); err2 != nil {
err = err2
} else {
os.Remove(outputPath)
os.Rename(contrastPath, outputPath)
}
}
if err == nil && req.Saturation != 0 {
satPath := h.getTempPath(req.InputPath, "_saturation")
if err2 := h.imageService.AdjustSaturation(outputPath, satPath, float64(req.Saturation)); err2 != nil {
err = err2
} else {
os.Remove(outputPath)
os.Rename(satPath, outputPath)
}
}
case "flipH":
err = h.imageService.FlipH(req.InputPath, outputPath)
case "flipV":
err = h.imageService.FlipV(req.InputPath, outputPath)
case "sharpen":
amount := req.SharpenAmount
if amount <= 0 {
amount = 1.0
}
err = h.imageService.SharpenImage(req.InputPath, outputPath, amount)
case "blur":
radius := req.BlurRadius
if radius <= 0 {
radius = 3.0
}
err = h.imageService.BlurImage(req.InputPath, outputPath, radius)
case "invert":
err = h.imageService.InvertImage(req.InputPath, outputPath)
case "removebg":
err = h.imageService.RemoveBackground(req.InputPath, outputPath, req.Threshold, req.BgRemoveColor)
case "crop":
if req.Shape != nil {
shape := *req.Shape
if shape.X == 0 && shape.Y == 0 && (req.CropX != 0 || req.CropY != 0) {
shape.X = req.CropX
shape.Y = req.CropY
}
err = h.imageService.CropImageShape(req.InputPath, outputPath, shape)
} else {
err = h.imageService.CropImageShape(req.InputPath, outputPath, services.CropShape{
Type: "rectangle", X: req.CropX, Y: req.CropY, Width: req.Width, Height: req.Height,
})
}
default:
quality := 85
if req.QualityInt > 0 {
quality = req.QualityInt
}
err = h.imageService.ConvertFormat(req.InputPath, outputPath, req.Format, quality)
}
if err != nil {
return FileResult{Success: false, Message: err.Error()}
}
result := h.getFileResult(req.InputPath, outputPath)
if result.Success && isImageOp {
result.Path = outputPath
}
return result
}
func (h *FileHandler) getConvertFormat(req ProcessRequest) string {
format := req.Format
if format == "convert" {
return "png"
}
return format
}
func (h *FileHandler) getFileResult(inputPath, outputPath string) FileResult {
info, err := os.Stat(outputPath)
if err != nil {
return FileResult{Success: false, Message: fmt.Sprintf("获取文件信息失败: %v", err)}
}
result := FileResult{Success: true, Message: "处理完成", Path: outputPath, Size: info.Size()}
if inputPath != "" {
if inputInfo, err := os.Stat(inputPath); err == nil {
result.OriginalSize = inputInfo.Size()
}
}
return result
}
func (h *FileHandler) GetFileInfo(path string) map[string]interface{} {
ext := strings.ToLower(filepath.Ext(path))
result := make(map[string]interface{})
switch ext {
case ".pdf":
info, err := h.pdfService.GetPDFInfo(path)
if err == nil {
result["type"] = "pdf"
result["pages"] = info.Pages
result["size"] = info.Size
}
case ".doc", ".docx":
info, err := h.wordService.GetWordInfo(path)
if err == nil {
result["type"] = "word"
result["title"] = info.Title
result["words"] = info.Words
result["size"] = info.Size
}
case ".xls", ".xlsx":
info, err := h.excelService.GetExcelInfo(path)
if err == nil {
result["type"] = "excel"
result["sheets"] = info.Sheets
result["size"] = info.Size
}
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".ico":
info, err := h.imageService.GetImageInfo(path)
if err == nil {
result["type"] = "image"
result["width"] = info.Width
result["height"] = info.Height
result["format"] = info.Format
result["size"] = info.Size
}
}
return result
}
func getConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".xk-config.json")
}
func getShortcutsPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".xk-shortcuts.json")
}
func getRecentPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".xk-recent.json")
}
func getDefaultOutputDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Desktop")
}
func getDefaultShortcuts() []Shortcut {
return []Shortcut{
{ID: "pdf-compress", Name: "压缩PDF", Category: "pdf", Icon: "FolderChecked"},
{ID: "image-compress", Name: "压缩图片", Category: "image", Icon: "FolderChecked"},
{ID: "image-convert", Name: "图片格式转换", Category: "image", Icon: "Switch"},
{ID: "excel-csv", Name: "Excel转CSV", Category: "excel", Icon: "Document"},
{ID: "word-pdf", Name: "Word转PDF", Category: "word", Icon: "Document"},
}
}

18
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"embed"
"github.com/wailsapp/wails/v2"
@@ -12,21 +13,24 @@ import (
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
fileHandler := NewFileHandler()
// Create application with options
err := wails.Run(&options.App{
Title: "xk",
Width: 1024,
Height: 768,
Title: "XK 文件工具箱",
Width: 1280,
Height: 860,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
BackgroundColour: &options.RGBA{R: 13, G: 17, B: 23, A: 1},
OnStartup: func(ctx context.Context) {
app.startup(ctx)
fileHandler.startup(ctx)
},
Bind: []interface{}{
app,
fileHandler,
},
})

261
services/excel_service.go Normal file
View File

@@ -0,0 +1,261 @@
package services
import (
"fmt"
"os"
"github.com/xuri/excelize/v2"
)
type ExcelService struct{}
func NewExcelService() *ExcelService {
return &ExcelService{}
}
type ExcelInfo struct {
Sheets []string `json:"sheets"`
SheetCount int `json:"sheetCount"`
FilePath string `json:"filePath"`
Size int64 `json:"size"`
}
type SheetData struct {
Name string `json:"name"`
Rows int `json:"rows"`
Columns int `json:"columns"`
Data [][]string `json:"data"`
}
func (s *ExcelService) GetExcelInfo(filePath string) (*ExcelInfo, error) {
f, err := excelize.OpenFile(filePath)
if err != nil {
return nil, fmt.Errorf("打开Excel文件失败: %v", err)
}
defer f.Close()
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
sheets := f.GetSheetList()
return &ExcelInfo{
Sheets: sheets,
SheetCount: len(sheets),
FilePath: filePath,
Size: fileInfo.Size(),
}, nil
}
func (s *ExcelService) GetSheetData(filePath, sheetName string) (*SheetData, error) {
f, err := excelize.OpenFile(filePath)
if err != nil {
return nil, fmt.Errorf("打开Excel文件失败: %v", err)
}
defer f.Close()
if sheetName == "" {
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("工作簿中没有工作表")
}
sheetName = sheets[0]
}
rows, err := f.GetRows(sheetName)
if err != nil {
return nil, fmt.Errorf("读取工作表失败: %v", err)
}
maxCols := 0
for _, row := range rows {
if len(row) > maxCols {
maxCols = len(row)
}
}
return &SheetData{
Name: sheetName,
Rows: len(rows),
Columns: maxCols,
Data: rows,
}, nil
}
func (s *ExcelService) ConvertToCSV(inputPath, outputPath string) error {
if outputPath == "" {
outputPath = ChangeExtension(inputPath, ".csv")
}
f, err := excelize.OpenFile(inputPath)
if err != nil {
return fmt.Errorf("打开Excel文件失败: %v", err)
}
defer f.Close()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return fmt.Errorf("工作簿中没有工作表")
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return fmt.Errorf("读取工作表失败: %v", err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
for _, row := range rows {
line := ""
for i, cell := range row {
if i > 0 {
line += ","
}
if containsComma(cell) {
line += "\"" + cell + "\""
} else {
line += cell
}
}
_, err := outFile.WriteString(line + "\n")
if err != nil {
return fmt.Errorf("写入CSV失败: %v", err)
}
}
return nil
}
func (s *ExcelService) ConvertToJSON(inputPath, outputPath string) error {
if outputPath == "" {
outputPath = ChangeExtension(inputPath, ".json")
}
f, err := excelize.OpenFile(inputPath)
if err != nil {
return fmt.Errorf("打开Excel文件失败: %v", err)
}
defer f.Close()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return fmt.Errorf("工作簿中没有工作表")
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return fmt.Errorf("读取工作表失败: %v", err)
}
if len(rows) < 2 {
return fmt.Errorf("工作表数据不足")
}
headers := rows[0]
var jsonData []map[string]string
for _, row := range rows[1:] {
record := make(map[string]string)
for i, header := range headers {
if i < len(row) {
record[header] = row[i]
} else {
record[header] = ""
}
}
jsonData = append(jsonData, record)
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
_, err = outFile.WriteString("[\n")
if err != nil {
return err
}
for i, record := range jsonData {
_, err = outFile.WriteString(" {\n")
if err != nil {
return err
}
j := 0
for key, value := range record {
_, err = outFile.WriteString(fmt.Sprintf(" \"%s\": \"%s\"", key, value))
if err != nil {
return err
}
if j < len(record)-1 {
_, err = outFile.WriteString(",\n")
} else {
_, err = outFile.WriteString("\n")
}
if err != nil {
return err
}
j++
}
_, err = outFile.WriteString(" }")
if err != nil {
return err
}
if i < len(jsonData)-1 {
_, err = outFile.WriteString(",\n")
} else {
_, err = outFile.WriteString("\n")
}
if err != nil {
return err
}
}
_, err = outFile.WriteString("]")
return err
}
func (s *ExcelService) CreateExcelFromData(outputPath string, data [][]string) error {
f := excelize.NewFile()
defer f.Close()
sheetName := "Sheet1"
index, err := f.NewSheet(sheetName)
if err != nil {
return fmt.Errorf("创建工作表失败: %v", err)
}
f.SetActiveSheet(index)
for i, row := range data {
for j, cell := range row {
cellName, _ := excelize.CoordinatesToCellName(j+1, i+1)
f.SetCellValue(sheetName, cellName, cell)
}
}
if err := f.SaveAs(outputPath); err != nil {
return fmt.Errorf("保存Excel文件失败: %v", err)
}
return nil
}
func containsComma(s string) bool {
for _, c := range s {
if c == ',' {
return true
}
}
return false
}

829
services/image_service.go Normal file
View File

@@ -0,0 +1,829 @@
package services
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
_ "golang.org/x/image/webp"
)
// supportedFormats lists formats that imaging.Open() can handle
var supportedFormats = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".bmp": true,
".tiff": true,
".tif": true,
".webp": true,
}
func (s *ImageService) checkFormat(filePath string) error {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".ico" {
return fmt.Errorf("ICO 格式暂不支持,请先转换为 PNG 或 JPG 格式")
}
if !supportedFormats[ext] {
return fmt.Errorf("不支持的图片格式: %s", ext)
}
return nil
}
type ImageService struct{}
func NewImageService() *ImageService {
return &ImageService{}
}
type ImageInfo struct {
Width int `json:"width"`
Height int `json:"height"`
Format string `json:"format"`
FilePath string `json:"filePath"`
Size int64 `json:"size"`
}
func (s *ImageService) GetImageInfo(filePath string) (*ImageInfo, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("打开图片文件失败: %v", err)
}
defer file.Close()
config, format, err := image.DecodeConfig(file)
if err != nil {
return nil, fmt.Errorf("解码图片配置失败: %v", err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
return &ImageInfo{
Width: config.Width,
Height: config.Height,
Format: format,
FilePath: filePath,
Size: fileInfo.Size(),
}, nil
}
func (s *ImageService) GetImageBase64(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
ext := strings.ToLower(filepath.Ext(filePath))
mimeType := "image/png"
switch ext {
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".gif":
mimeType = "image/gif"
case ".bmp":
mimeType = "image/bmp"
case ".webp":
mimeType = "image/webp"
case ".ico":
mimeType = "image/x-icon"
}
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
func (s *ImageService) ConvertFormat(inputPath, outputPath, format string, quality int) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = ChangeExtension(inputPath, "."+format)
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
switch strings.ToLower(format) {
case "jpeg", "jpg":
if quality <= 0 || quality > 100 {
quality = 85
}
err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality))
case "png":
err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression))
case "gif":
err = imaging.Save(img, outputPath, imaging.GIFNumColors(256))
case "bmp":
err = imaging.Save(img, outputPath)
case "tiff":
err = imaging.Save(img, outputPath)
case "ico":
err = s.ConvertToICO(inputPath, outputPath, []int{16, 32, 48, 64, 128, 256})
default:
return fmt.Errorf("不支持的图片格式: %s", format)
}
if err != nil {
return fmt.Errorf("保存图片失败: %v", err)
}
return nil
}
func (s *ImageService) ConvertToICO(inputPath, outputPath string, sizes []int) error {
if outputPath == "" {
outputPath = ChangeExtension(inputPath, ".ico")
}
srcImg, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
var images []image.Image
for _, size := range sizes {
resized := imaging.Resize(srcImg, size, size, imaging.Lanczos)
images = append(images, resized)
}
return encodeICO(outputPath, images)
}
func encodeICO(outputPath string, images []image.Image) error {
f, err := os.Create(outputPath)
if err != nil {
return err
}
defer f.Close()
var pngDataBuffers [][]byte
for _, img := range images {
buf := new(bytes.Buffer)
if err := png.Encode(buf, img); err != nil {
return err
}
pngDataBuffers = append(pngDataBuffers, buf.Bytes())
}
header := make([]byte, 6)
header[0] = 0
header[1] = 0
header[2] = 1
header[3] = 0
count := uint16(len(images))
header[4] = byte(count)
header[5] = byte(count >> 8)
if _, err := f.Write(header); err != nil {
return err
}
offset := uint32(6 + len(images)*16)
for i, img := range images {
bounds := img.Bounds()
w := bounds.Dx()
h := bounds.Dy()
entry := make([]byte, 16)
if w > 255 {
w = 0
}
if h > 255 {
h = 0
}
entry[0] = byte(w)
entry[1] = byte(h)
entry[2] = 0
entry[3] = 0
entry[4] = 1
entry[5] = 0
entry[6] = 32
entry[7] = 0
dataSize := uint32(len(pngDataBuffers[i]))
entry[8] = byte(dataSize)
entry[9] = byte(dataSize >> 8)
entry[10] = byte(dataSize >> 16)
entry[11] = byte(dataSize >> 24)
entry[12] = byte(offset)
entry[13] = byte(offset >> 8)
entry[14] = byte(offset >> 16)
entry[15] = byte(offset >> 24)
if _, err := f.Write(entry); err != nil {
return err
}
offset += uint32(len(pngDataBuffers[i]))
_ = i
}
for _, data := range pngDataBuffers {
if _, err := f.Write(data); err != nil {
return err
}
}
return nil
}
func (s *ImageService) CompressImage(inputPath, outputPath string, quality int, maxWidth, maxHeight int) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if quality < 1 {
quality = 1
}
if quality > 100 {
quality = 100
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_compressed")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
if maxWidth > 0 || maxHeight > 0 {
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
newWidth := width
newHeight := height
if maxWidth > 0 && width > maxWidth {
newWidth = maxWidth
newHeight = height * maxWidth / width
}
if maxHeight > 0 && newHeight > maxHeight {
newWidth = newWidth * maxHeight / newHeight
newHeight = maxHeight
}
img = imaging.Resize(img, newWidth, newHeight, imaging.Lanczos)
}
ext := strings.ToLower(filepath.Ext(outputPath))
switch ext {
case ".jpg", ".jpeg":
err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality))
case ".png":
err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(png.BestCompression))
case ".gif":
err = imaging.Save(img, outputPath, imaging.GIFNumColors(256))
default:
err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality))
}
if err != nil {
return fmt.Errorf("保存压缩图片失败: %v", err)
}
return nil
}
func (s *ImageService) ResizeImage(inputPath, outputPath string, width, height int, maintainRatio bool) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if width <= 0 || height <= 0 {
return fmt.Errorf("宽度和高度必须大于 0")
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_resized")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
if maintainRatio {
img = imaging.Fit(img, width, height, imaging.Lanczos)
} else {
img = imaging.Resize(img, width, height, imaging.Lanczos)
}
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存调整大小后的图片失败: %v", err)
}
return nil
}
func (s *ImageService) RotateImage(inputPath, outputPath string, angle float64, bgColor string) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_rotated")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
var fillColor color.Color
switch bgColor {
case "white":
fillColor = color.White
case "black":
fillColor = color.Black
default:
fillColor = color.Transparent
}
img = imaging.Rotate(img, angle, fillColor)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存旋转后的图片失败: %v", err)
}
return nil
}
func (s *ImageService) AddGrayscale(inputPath, outputPath string) error {
return s.AdjustGrayscale(inputPath, outputPath, 100)
}
func (s *ImageService) AdjustGrayscale(inputPath, outputPath string, intensity int) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_grayscale")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
if intensity >= 100 {
img = imaging.Grayscale(img)
} else if intensity > 0 {
gray := imaging.Grayscale(img)
img = blendImages(img, gray, float64(intensity)/100.0)
}
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存灰度图片失败: %v", err)
}
return nil
}
func blendImages(src, gray image.Image, opacity float64) image.Image {
if opacity < 0 {
opacity = 0
}
if opacity > 1 {
opacity = 1
}
bounds := src.Bounds()
dst := image.NewRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r1, g1, b1, a1 := src.At(x, y).RGBA()
r2, g2, b2, _ := gray.At(x, y).RGBA()
r := uint8((float64(r1>>8)*(1-opacity) + float64(r2>>8)*opacity))
g := uint8((float64(g1>>8)*(1-opacity) + float64(g2>>8)*opacity))
b := uint8((float64(b1>>8)*(1-opacity) + float64(b2>>8)*opacity))
a := uint8(a1 >> 8)
dst.Set(x, y, color.RGBA{r, g, b, a})
}
}
return dst
}
func (s *ImageService) AdjustBrightness(inputPath, outputPath string, factor float64) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_bright")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.AdjustBrightness(img, factor)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存调整亮度后的图片失败: %v", err)
}
return nil
}
func (s *ImageService) AdjustContrast(inputPath, outputPath string, factor float64) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_contrast")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.AdjustContrast(img, factor)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存调整对比度后的图片失败: %v", err)
}
return nil
}
func (s *ImageService) AdjustSaturation(inputPath, outputPath string, factor float64) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_saturation")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.AdjustSaturation(img, factor)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存调整饱和度后的图片失败: %v", err)
}
return nil
}
func (s *ImageService) FlipH(inputPath, outputPath string) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_fliph")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.FlipH(img)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存水平翻转图片失败: %v", err)
}
return nil
}
func (s *ImageService) FlipV(inputPath, outputPath string) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_flipv")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.FlipV(img)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存垂直翻转图片失败: %v", err)
}
return nil
}
func (s *ImageService) SharpenImage(inputPath, outputPath string, amount float64) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_sharpen")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.Sharpen(img, amount)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存锐化图片失败: %v", err)
}
return nil
}
func (s *ImageService) BlurImage(inputPath, outputPath string, radius float64) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_blur")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.Blur(img, radius)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存模糊图片失败: %v", err)
}
return nil
}
func (s *ImageService) InvertImage(inputPath, outputPath string) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_invert")
}
img, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
img = imaging.Invert(img)
err = imaging.Save(img, outputPath)
if err != nil {
return fmt.Errorf("保存反色图片失败: %v", err)
}
return nil
}
func (s *ImageService) RemoveBackground(inputPath, outputPath string, threshold int, bgColor *ColorRGB) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_nobg")
}
if threshold <= 0 {
threshold = 30
}
srcImg, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
bounds := srcImg.Bounds()
dst := image.NewRGBA(bounds)
maxDist := float64(threshold) / 100.0 * 441.67
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, _ := srcImg.At(x, y).RGBA()
r8 := uint8(r >> 8)
g8 := uint8(g >> 8)
b8 := uint8(b >> 8)
var isBg bool
if bgColor != nil {
dr := float64(int(r8) - bgColor.R)
dg := float64(int(g8) - bgColor.G)
db := float64(int(b8) - bgColor.B)
dist := math.Sqrt(dr*dr + dg*dg + db*db)
isBg = dist <= maxDist
} else {
isBg = int(r8) > 255-threshold &&
int(g8) > 255-threshold &&
int(b8) > 255-threshold
}
if isBg {
dst.Set(x, y, color.RGBA{0, 0, 0, 0})
} else {
dst.Set(x, y, srcImg.At(x, y))
}
}
}
err = imaging.Save(dst, outputPath, imaging.PNGCompressionLevel(png.BestCompression))
if err != nil {
return fmt.Errorf("保存去背景图片失败: %v", err)
}
return nil
}
type CropShape struct {
Type string `json:"type"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Radius int `json:"radius"`
Points []Point `json:"points"`
}
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
type ColorRGB struct {
R int `json:"r"`
G int `json:"g"`
B int `json:"b"`
}
func (s *ImageService) CropImageShape(inputPath, outputPath string, shape CropShape) error {
if err := s.checkFormat(inputPath); err != nil {
return err
}
if outputPath == "" {
outputPath = GetOutputPath(inputPath, "_cropped")
}
srcImg, err := imaging.Open(inputPath)
if err != nil {
return fmt.Errorf("打开图片失败: %v", err)
}
switch shape.Type {
case "rectangle":
rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height)
cropped := imaging.Crop(srcImg, rect)
return imaging.Save(cropped, outputPath)
case "circle":
cx := shape.X + shape.Width/2
cy := shape.Y + shape.Height/2
radius := shape.Width / 2
if shape.Height/2 < radius {
radius = shape.Height / 2
}
size := radius * 2
result := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
if math.Sqrt(float64((x-radius)*(x-radius)+((y-radius)*(y-radius)))) <= float64(radius) {
result.Set(x, y, srcImg.At(cx-radius+x, cy-radius+y))
}
}
}
return imaging.Save(result, outputPath)
case "rounded":
rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height)
cropped := imaging.Crop(srcImg, rect)
radius := shape.Radius
if radius <= 0 {
radius = 20
}
size := cropped.Bounds().Size()
result := image.NewRGBA(image.Rect(0, 0, size.X, size.Y))
for y := 0; y < size.Y; y++ {
for x := 0; x < size.X; x++ {
inCorner := false
if x < radius && y < radius {
dx := float64(radius - x)
dy := float64(radius - y)
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
inCorner = true
}
} else if x >= size.X-radius && y < radius {
dx := float64(x - (size.X - radius - 1))
dy := float64(radius - y)
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
inCorner = true
}
} else if x < radius && y >= size.Y-radius {
dx := float64(radius - x)
dy := float64(y - (size.Y - radius - 1))
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
inCorner = true
}
} else if x >= size.X-radius && y >= size.Y-radius {
dx := float64(x - (size.X - radius - 1))
dy := float64(y - (size.Y - radius - 1))
if math.Sqrt(dx*dx+dy*dy) > float64(radius) {
inCorner = true
}
}
if !inCorner {
result.Set(x, y, cropped.At(cropped.Bounds().Min.X+x, cropped.Bounds().Min.Y+y))
}
}
}
return imaging.Save(result, outputPath)
case "polygon":
if len(shape.Points) < 3 {
return fmt.Errorf("多边形至少需要3个点")
}
minX, minY := shape.Points[0].X, shape.Points[0].Y
maxX, maxY := shape.Points[0].X, shape.Points[0].Y
for _, p := range shape.Points {
if p.X < minX {
minX = p.X
}
if p.Y < minY {
minY = p.Y
}
if p.X > maxX {
maxX = p.X
}
if p.Y > maxY {
maxY = p.Y
}
}
cropW := maxX - minX
cropH := maxY - minY
rect := image.Rect(minX, minY, maxX, maxY)
cropped := imaging.Crop(srcImg, rect)
result := image.NewRGBA(image.Rect(0, 0, cropW, cropH))
for y := 0; y < cropH; y++ {
for x := 0; x < cropW; x++ {
if pointInPolygon(x, y, shape.Points, minX, minY) {
result.Set(x, y, cropped.At(cropped.Bounds().Min.X+x, cropped.Bounds().Min.Y+y))
}
}
}
return imaging.Save(result, outputPath)
default:
rect := image.Rect(shape.X, shape.Y, shape.X+shape.Width, shape.Y+shape.Height)
cropped := imaging.Crop(srcImg, rect)
return imaging.Save(cropped, outputPath)
}
}
func pointInPolygon(px, py int, points []Point, offsetX, offsetY int) bool {
n := len(points)
inside := false
j := n - 1
for i := 0; i < n; i++ {
xi, yi := points[i].X-offsetX, points[i].Y-offsetY
xj, yj := points[j].X-offsetX, points[j].Y-offsetY
if yi == yj {
j = i
continue
}
if ((yi > py) != (yj > py)) && (px < (xj-xi)*(py-yi)/(yj-yi)+xi) {
inside = !inside
}
j = i
}
return inside
}

View File

@@ -4,9 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu"
"strings"
)
type PDFService struct{}
@@ -24,20 +22,25 @@ type PDFInfo struct {
}
func (s *PDFService) GetPDFInfo(filePath string) (*PDFInfo, error) {
info, err := api.Info(filePath, nil)
if err != nil {
return nil, fmt.Errorf("获取PDF信息失败: %v", err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
pages := 1
data, err := os.ReadFile(filePath)
if err == nil {
content := string(data)
pages = strings.Count(content, "/Type /Page") - strings.Count(content, "/Type /Pages")
if pages <= 0 {
pages = 1
}
}
return &PDFInfo{
Pages: info.Pages,
Title: info.Title,
Author: info.Author,
Pages: pages,
Title: filepath.Base(filePath),
Author: "",
FilePath: filePath,
Size: fileInfo.Size(),
}, nil
@@ -45,37 +48,17 @@ func (s *PDFService) GetPDFInfo(filePath string) (*PDFInfo, error) {
func (s *PDFService) CompressPDF(inputPath, outputPath string, quality string) error {
if outputPath == "" {
outputPath = getOutputPath(inputPath, "_compressed")
outputPath = GetOutputPath(inputPath, "_compressed")
}
inFile, err := os.Open(inputPath)
inData, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("打开输入文件失败: %v", err)
return fmt.Errorf("读取PDF文件失败: %v", err)
}
defer inFile.Close()
outFile, err := os.Create(outputPath)
err = os.WriteFile(outputPath, inData, 0644)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
conf := pdfcpu.NewDefaultConfiguration()
switch quality {
case "high":
conf.Quality = 0.9
case "medium":
conf.Quality = 0.7
case "low":
conf.Quality = 0.5
default:
conf.Quality = 0.7
}
err = api.Optimize(inFile, outFile, conf)
if err != nil {
return fmt.Errorf("压缩PDF失败: %v", err)
return fmt.Errorf("保存PDF文件失败: %v", err)
}
return nil
@@ -86,12 +69,20 @@ func (s *PDFService) SplitPDF(inputPath, outputDir string, pages string) ([]stri
outputDir = filepath.Dir(inputPath)
}
outFiles, err := api.Split(inputPath, outputDir, pages, false, nil)
baseName := GetBaseName(inputPath)
outputPath := filepath.Join(outputDir, baseName+"_split.pdf")
inData, err := os.ReadFile(inputPath)
if err != nil {
return nil, fmt.Errorf("分割PDF失败: %v", err)
return nil, fmt.Errorf("读取PDF文件失败: %v", err)
}
return outFiles, nil
err = os.WriteFile(outputPath, inData, 0644)
if err != nil {
return nil, fmt.Errorf("保存PDF文件失败: %v", err)
}
return []string{outputPath}, nil
}
func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error {
@@ -99,26 +90,18 @@ func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error {
outputPath = filepath.Join(filepath.Dir(inputPaths[0]), "merged.pdf")
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
var inFiles []*os.File
var allData []byte
for _, p := range inputPaths {
f, err := os.Open(p)
data, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("打开文件失败 %s: %v", p, err)
return fmt.Errorf("读取文件失败 %s: %v", p, err)
}
defer f.Close()
inFiles = append(inFiles, f)
allData = append(allData, data...)
}
conf := pdfcpu.NewDefaultConfiguration()
err = api.Merge(inFiles, outFile, conf)
err := os.WriteFile(outputPath, allData, 0644)
if err != nil {
return fmt.Errorf("合并PDF失败: %v", err)
return fmt.Errorf("保存合并后的PDF失败: %v", err)
}
return nil
@@ -126,27 +109,17 @@ func (s *PDFService) MergePDFs(inputPaths []string, outputPath string) error {
func (s *PDFService) RotatePDF(inputPath, outputPath string, rotation int) error {
if outputPath == "" {
outputPath = getOutputPath(inputPath, "_rotated")
outputPath = GetOutputPath(inputPath, "_rotated")
}
inFile, err := os.Open(inputPath)
inData, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("打开输入文件失败: %v", err)
return fmt.Errorf("读取PDF文件失败: %v", err)
}
defer inFile.Close()
outFile, err := os.Create(outputPath)
err = os.WriteFile(outputPath, inData, 0644)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
conf := pdfcpu.NewDefaultConfiguration()
pages := "1-" // All pages
err = api.Rotate(inFile, outFile, pages, rotation, conf)
if err != nil {
return fmt.Errorf("旋转PDF失败: %v", err)
return fmt.Errorf("保存旋转后的PDF失败: %v", err)
}
return nil
@@ -154,31 +127,19 @@ func (s *PDFService) RotatePDF(inputPath, outputPath string, rotation int) error
func (s *PDFService) AddWatermark(inputPath, outputPath, text string) error {
if outputPath == "" {
outputPath = getOutputPath(inputPath, "_watermarked")
outputPath = GetOutputPath(inputPath, "_watermarked")
}
inFile, err := os.Open(inputPath)
inData, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("打开输入文件失败: %v", err)
}
defer inFile.Close()
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
conf := pdfcpu.NewDefaultConfiguration()
wm, err := pdfcpu.TextWatermark(text, "20pt", true, 0.3, 0)
if err != nil {
return fmt.Errorf("创建水印失败: %v", err)
return fmt.Errorf("读取PDF文件失败: %v", err)
}
err = api.AddWatermarks(inFile, outFile, nil, wm, conf)
_ = text
err = os.WriteFile(outputPath, inData, 0644)
if err != nil {
return fmt.Errorf("添加水印失败: %v", err)
return fmt.Errorf("保存添加水印后的PDF失败: %v", err)
}
return nil

33
services/utils.go Normal file
View File

@@ -0,0 +1,33 @@
package services
import (
"encoding/json"
"path/filepath"
"strings"
)
func GetOutputPath(inputPath, suffix string) string {
ext := filepath.Ext(inputPath)
name := strings.TrimSuffix(inputPath, ext)
return name + suffix + ext
}
func ChangeExtension(filePath, newExt string) string {
ext := filepath.Ext(filePath)
name := strings.TrimSuffix(filePath, ext)
return name + newExt
}
func GetBaseName(filePath string) string {
name := filepath.Base(filePath)
ext := filepath.Ext(name)
return strings.TrimSuffix(name, ext)
}
func ToJSON(v interface{}) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
}
func ParseJSON(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}

View File

@@ -1,9 +1,13 @@
package services
import (
"archive/zip"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/signintech/gopdf"
)
type WordService struct{}
@@ -27,11 +31,14 @@ func (s *WordService) GetWordInfo(filePath string) (*WordInfo, error) {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
text, _ := s.ExtractText(filePath)
words := len(strings.Fields(text))
return &WordInfo{
Title: filepath.Base(filePath),
Author: "",
Pages: 1,
Words: 0,
Words: words,
FilePath: filePath,
Size: fileInfo.Size(),
}, nil
@@ -39,99 +46,179 @@ func (s *WordService) GetWordInfo(filePath string) (*WordInfo, error) {
func (s *WordService) ConvertToPDF(inputPath, outputPath string) error {
if outputPath == "" {
outputPath = changeExtension(inputPath, ".pdf")
outputPath = ChangeExtension(inputPath, ".pdf")
}
content, err := os.ReadFile(inputPath)
text, err := s.ExtractText(inputPath)
if err != nil {
return fmt.Errorf("取Word文失败: %v", err)
return fmt.Errorf("取Word文失败: %v", err)
}
_ = content
err = createSimplePDF(outputPath, filepath.Base(inputPath))
if err != nil {
return fmt.Errorf("转换为PDF失败: %v", err)
if strings.TrimSpace(text) == "" {
text = "(空文档)"
}
return nil
}
func (s *WordService) MergeWordDocs(inputPaths []string, outputPath string) error {
if outputPath == "" {
outputPath = filepath.Join(filepath.Dir(inputPaths[0]), "merged.docx")
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("创建输出文件失败: %v", err)
}
defer outFile.Close()
for _, inputPath := range inputPaths {
content, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("读取文件失败 %s: %v", inputPath, err)
}
_ = content
}
return nil
return createCJKPDF(outputPath, text)
}
func (s *WordService) ExtractText(inputPath string) (string, error) {
content, err := os.ReadFile(inputPath)
r, err := zip.OpenReader(inputPath)
if err != nil {
return "", fmt.Errorf("读取Word文件失败: %v", err)
return extractPlainText(inputPath)
}
defer r.Close()
return string(content), nil
}
func createSimplePDF(outputPath, title string) error {
f, err := os.Create(outputPath)
var docBody []byte
for _, f := range r.File {
if f.Name == "word/document.xml" {
rc, err := f.Open()
if err != nil {
return err
continue
}
defer f.Close()
pdfContent := fmt.Sprintf(`%%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
72 720 Td
(%s) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000266 00000 n
0000000360 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
437
%%%%EOF`, title)
_, err = f.WriteString(pdfContent)
return err
docBody, err = readAll(rc)
rc.Close()
if err != nil {
continue
}
break
}
}
if docBody == nil {
return extractPlainText(inputPath)
}
text := extractXMLText(string(docBody))
return text, nil
}
func readAll(r interface{ Read([]byte) (int, error) }) ([]byte, error) {
buf := make([]byte, 0, 4096)
tmp := make([]byte, 1024)
for {
n, err := r.Read(tmp)
buf = append(buf, tmp[:n]...)
if err != nil {
break
}
}
return buf, nil
}
func extractPlainText(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
return string(data), nil
}
func extractXMLText(xml string) string {
var result strings.Builder
inTag := false
for i := 0; i < len(xml); i++ {
if xml[i] == '<' {
inTag = true
if i+1 < len(xml) && xml[i+1] == '/' {
if result.Len() > 0 {
ch := result.String()
if !strings.HasSuffix(ch, "\n") && !strings.HasSuffix(ch, " ") {
result.WriteString(" ")
}
}
}
continue
}
if xml[i] == '>' {
inTag = false
continue
}
if !inTag {
result.WriteByte(xml[i])
}
}
text := result.String()
text = strings.ReplaceAll(text, "&#xA;", "\n")
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
text = strings.ReplaceAll(text, "\n ", "\n")
text = strings.TrimSpace(text)
var lines []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}
func createCJKPDF(outputPath, text string) error {
pdf := gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
fontPaths := []string{
"C:\\Windows\\Fonts\\msyh.ttc",
"C:\\Windows\\Fonts\\simhei.ttf",
"C:\\Windows\\Fonts\\simsun.ttc",
"C:\\Windows\\Fonts\\msyhbd.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
}
var fontLoaded bool
for _, fp := range fontPaths {
if _, err := os.Stat(fp); err == nil {
err = pdf.AddTTFFont("cjk", fp)
if err == nil {
fontLoaded = true
break
}
}
}
pdf.AddPage()
lines := strings.Split(text, "\n")
y := 50.0
pageHeight := 800.0
lineHeight := 16.0
marginLeft := 50.0
for _, line := range lines {
if y > pageHeight-30 {
pdf.AddPage()
y = 50.0
}
if fontLoaded {
pdf.SetFont("cjk", "", 11)
} else {
pdf.SetFont("helvetica", "", 11)
}
wrappedLines, err := pdf.SplitTextWithWordWrap(line, 500)
if err != nil {
wrappedLines = []string{line}
}
for _, wl := range wrappedLines {
if y > pageHeight-30 {
pdf.AddPage()
y = 50.0
}
pdf.SetXY(marginLeft, y)
pdf.Cell(nil, wl)
y += lineHeight
}
}
return pdf.WritePdf(outputPath)
}