图片处理模块
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ frontend/dist
|
||||
/.opencode/
|
||||
/.mimocode/
|
||||
/.qoder/
|
||||
/public/
|
||||
/示例/
|
||||
|
||||
11
build.ps1
11
build.ps1
@@ -13,16 +13,7 @@ Write-Host "Building..." -ForegroundColor Yellow
|
||||
wails build -tags nofitz
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "Build failed!" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# Rename using cmd to avoid encoding issues
|
||||
cmd /c "copy /Y `"$buildDir\xk.exe`" `"$buildDir\年糕工具.exe`""
|
||||
cmd /c "del /f /q `"$buildDir\xk.exe`""
|
||||
cmd /c "del /f /q `"$buildDir\xk-dev.exe`""
|
||||
|
||||
# Remove standalone DLL (it's embedded now)
|
||||
cmd /c "del /f /q `"$buildDir\libmupdf.dll`""
|
||||
cmd /c "del /f /q `"$buildDir\MuPDFLib.dll`""
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Build Complete ===" -ForegroundColor Green
|
||||
Write-Host " $buildDir\年糕工具.exe (single file, DLL embedded)" -ForegroundColor Gray
|
||||
Write-Host " $buildDir\年糕工具.exe" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
@@ -22,6 +22,7 @@ const allTools = [
|
||||
{ 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: 'image-stitch', name: '图片拼接', category: 'image', action: 'stitch', icon: 'Connection', 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' },
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, nextTick, 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 ImageEditorTool from './image/ImageEditorTool.vue'
|
||||
import ImageConvertTool from './image/ImageConvertTool.vue'
|
||||
import ImageRemoveBgTool from './image/ImageRemoveBgTool.vue'
|
||||
import ImageToPdfTool from './image/ImageToPdfTool.vue'
|
||||
import ImageStitchTool from './image/ImageStitchTool.vue'
|
||||
|
||||
import PdfCompressTool from './pdf/PdfCompressTool.vue'
|
||||
import PdfToImageTool from './pdf/PdfToImageTool.vue'
|
||||
@@ -30,6 +22,15 @@ const router = useRouter()
|
||||
|
||||
const category = computed(() => route.params.category || 'pdf')
|
||||
const action = ref(route.params.action || '')
|
||||
const actionTabsRef = ref(null)
|
||||
watch(action, () => {
|
||||
nextTick(() => {
|
||||
const tabs = actionTabsRef.value
|
||||
if (!tabs) return
|
||||
const active = tabs.querySelector('.action-tab.active')
|
||||
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
})
|
||||
})
|
||||
|
||||
const filePath = ref('')
|
||||
const fileLoading = ref(false)
|
||||
@@ -104,6 +105,7 @@ const categoryConfig = {
|
||||
{ id: 'invert', name: '反色', icon: 'RefreshLeft', color: '#f9e2af' },
|
||||
{ id: 'removebg', name: '背景去除', icon: 'MagicStick', color: '#f9e2af' },
|
||||
{ id: 'toPdf', name: '转PDF', icon: 'Document', color: '#f9e2af' },
|
||||
{ id: 'stitch', name: '图片拼接', icon: 'Connection', color: '#f9e2af' },
|
||||
],
|
||||
fileFilters: ['*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico'],
|
||||
},
|
||||
@@ -118,6 +120,8 @@ const isImageProcessing = computed(() => {
|
||||
return category.value === 'image' && ['compress', 'resize', 'rotate', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert', 'removebg', 'crop'].includes(action.value)
|
||||
})
|
||||
|
||||
const editorActions = ['compress', 'resize', 'rotate', 'crop', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert', 'removebg']
|
||||
|
||||
const qualityOptions = [
|
||||
{ id: 'high', name: '高质量' },
|
||||
{ id: 'medium', name: '中等' },
|
||||
@@ -506,11 +510,10 @@ const pdfToolProps = computed(() => ({
|
||||
}))
|
||||
|
||||
const isImageWorkspaceTool = computed(() => {
|
||||
return category.value === 'image' && ['compress', 'resize', 'rotate', 'crop', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert'].includes(action.value) && !!filePath.value
|
||||
return category.value === 'image' && editorActions.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>
|
||||
@@ -529,7 +532,7 @@ const isImageRemoveBgTool = computed(() => category.value === 'image' && action.
|
||||
<div style="width:80px"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-tabs">
|
||||
<div class="action-tabs" ref="actionTabsRef">
|
||||
<div
|
||||
v-for="act in currentCat.actions"
|
||||
:key="act.id"
|
||||
@@ -544,18 +547,10 @@ const isImageRemoveBgTool = computed(() => category.value === 'image' && action.
|
||||
|
||||
<div class="tool-body">
|
||||
<template v-if="category === 'image'">
|
||||
<ImageCompressTool v-if="action === 'compress'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageResizeTool v-else-if="action === 'resize'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageRotateTool v-else-if="action === 'rotate'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageCropTool v-else-if="action === 'crop'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageGrayscaleTool v-else-if="action === 'grayscale'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageBrightnessTool v-else-if="action === 'brightness'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageSharpenTool v-else-if="action === 'sharpen'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageBlurTool v-else-if="action === 'blur'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageInvertTool v-else-if="action === 'invert'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageConvertTool v-else-if="action === 'convert'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageRemoveBgTool v-else-if="action === 'removebg'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageToPdfTool v-else-if="action === 'toPdf'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageEditorTool v-if="editorActions.includes(action)" :key="'editor-' + action" :action="action" :config="config" />
|
||||
<ImageConvertTool v-else-if="action === 'convert'" :key="'convert-' + action" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageToPdfTool v-else-if="action === 'toPdf'" :key="'topdf-' + action" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageStitchTool v-else-if="action === 'stitch'" :key="'stitch-' + action" :config="config" />
|
||||
<div v-else class="tool-main">
|
||||
<div class="file-drop-zone" @click="selectFile">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
@@ -666,6 +661,9 @@ const isImageRemoveBgTool = computed(() => category.value === 'image' && action.
|
||||
<div class="result-header">
|
||||
<el-icon :size="20" style="color: #3fb950" class="success-icon"><SuccessFilled /></el-icon>
|
||||
<span>处理完成</span>
|
||||
<el-button text size="small" style="margin-left:auto" @click="resultInfo = null">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="compare-section" v-if="compareImages.original && compareImages.processed">
|
||||
@@ -722,6 +720,10 @@ const isImageRemoveBgTool = computed(() => category.value === 'image' && action.
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
打开目录
|
||||
</el-button>
|
||||
<el-button @click="resultInfo = null; filePath = ''; resetState()">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
重新处理
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -813,15 +815,16 @@ const isImageRemoveBgTool = computed(() => category.value === 'image' && action.
|
||||
}
|
||||
.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-body { flex: 1; padding: 24px; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.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;
|
||||
padding: 80px 40px; text-align: center; cursor: pointer;
|
||||
transition: all var(--transition-normal); margin: auto;
|
||||
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
max-width: 600px; width: 100%; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<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>
|
||||
@@ -1,80 +0,0 @@
|
||||
<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>
|
||||
@@ -1,198 +0,0 @@
|
||||
<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)
|
||||
|
||||
const presets = [
|
||||
{ name: '高质量', quality: 90, desc: '几乎无损' },
|
||||
{ name: '中等', quality: 75, desc: '推荐' },
|
||||
{ name: '小文件', quality: 50, desc: '体积优先' },
|
||||
{ name: '极小', quality: 30, desc: '极限压缩' },
|
||||
]
|
||||
|
||||
function applyPreset(q) {
|
||||
qualityInt.value = q
|
||||
}
|
||||
|
||||
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="preset-group">
|
||||
<button v-for="p in presets" :key="p.quality" class="preset-btn" :class="{ active: qualityInt === p.quality }" @click="applyPreset(p.quality)">
|
||||
<span class="preset-name">{{ p.name }}</span>
|
||||
<span class="preset-desc">{{ p.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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; }
|
||||
.preset-group { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.preset-btn { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 12px; border-radius: var(--radius-sm); cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); transition: all var(--transition-normal); }
|
||||
.preset-btn:hover { border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.preset-btn.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.preset-name { font-size: 13px; font-weight: 600; }
|
||||
.preset-desc { font-size: 11px; opacity: 0.7; }
|
||||
.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); }
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ const imageFormats = [
|
||||
|
||||
const outputFormat = ref('jpeg')
|
||||
const qualityInt = ref(75)
|
||||
const enableCompress = ref(false)
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
@@ -34,7 +35,9 @@ function buildRequest() {
|
||||
outputPath: props.outputPath,
|
||||
format: 'convert',
|
||||
outputFormat: outputFormat.value,
|
||||
qualityInt: outputFormat.value === 'jpeg' ? qualityInt.value : 0,
|
||||
qualityInt: outputFormat.value === 'jpeg'
|
||||
? (enableCompress.value ? qualityInt.value : 100)
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
@@ -52,6 +55,7 @@ function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('drago
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-root">
|
||||
<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>
|
||||
@@ -75,7 +79,10 @@ function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('drago
|
||||
<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-group">
|
||||
<el-checkbox v-model="enableCompress">启用压缩</el-checkbox>
|
||||
</div>
|
||||
<div class="param-group" v-if="enableCompress && 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>
|
||||
@@ -93,10 +100,12 @@ function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('drago
|
||||
<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; }
|
||||
.tool-root { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
<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>
|
||||
612
frontend/src/components/image/ImageEditorTool.vue
Normal file
612
frontend/src/components/image/ImageEditorTool.vue
Normal file
@@ -0,0 +1,612 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
const props = defineProps({ action: { type: String, required: true }, config: Object })
|
||||
// === File State ===
|
||||
const originalFile = ref(null), imgSrc = ref('')
|
||||
const imgW = ref(0), imgH = ref(0), fileSize = ref(0), fileName = ref('')
|
||||
const fileInputRef = ref(null), loading = ref(false)
|
||||
const hasFile = computed(() => props.action === 'compress' ? compressList.value.length > 0 : !!imgSrc.value)
|
||||
// === Full-screen Preview ===
|
||||
const showPreview = ref(false), previewSrc = ref('')
|
||||
const pvScale = ref(1), pvX = ref(0), pvY = ref(0)
|
||||
let pvDrag = false, pvSX = 0, pvSY = 0
|
||||
// === Compress (multi-file) ===
|
||||
const compressList = ref([])
|
||||
const compressIdx = ref(0)
|
||||
const compressMode = ref('smart'), customQ = ref(0.8)
|
||||
const compressing = ref(false), webpOk = ref(false)
|
||||
const curComp = computed(() => compressList.value[compressIdx.value] || null)
|
||||
// === Adjust ===
|
||||
const adjBright = ref(0), adjContrast = ref(0), adjSat = ref(0)
|
||||
const sharpAmt = ref(50), blurR = ref(3), grayInt = ref(100)
|
||||
// === Rotate/Flip ===
|
||||
const rotAngle = ref(0), flipH = ref(false), flipV = ref(false), rotBg = ref('transparent')
|
||||
// === Resize ===
|
||||
const rsW = ref(0), rsH = ref(0), rsLock = ref(false), rsPct = ref(100), rsRatio = ref('free')
|
||||
let rsRatioVal = 1, rsUpdating = false
|
||||
// === Crop ===
|
||||
const crX = ref(0), crY = ref(0), crW = ref(0), crH = ref(0), crRatio = ref('free')
|
||||
const crShape = ref('rect')
|
||||
const crImgRef = ref(null), crOverlayRef = ref(null), crPreviewRef = ref(null)
|
||||
let crDrag = false, crType = null, crSX = 0, crSY = 0, crSR = {}
|
||||
let crImgRect = { x: 0, y: 0, w: 0, h: 0, scale: 1 }
|
||||
// === RemoveBG ===
|
||||
const bgMode = ref('global'), bgTol = ref(25), bgFeather = ref(1), bgColor = ref(null)
|
||||
const bgHist = ref([]), bgOrigData = ref(null), bgProcData = ref(null), bgReady = ref(false)
|
||||
const bgSwatch = ref(''), bgCode = ref('未选取')
|
||||
const bgOrigCanvasRef = ref(null), bgPrevCanvasRef = ref(null)
|
||||
let bgNW = 0, bgNH = 0, bgDS = 1, bgOX = 0, bgOY = 0, bgRafId = null
|
||||
// === Export ===
|
||||
const processing = ref(false), result = ref(null), origPreviewUrl = ref('')
|
||||
// === CSS Filter Preview ===
|
||||
const previewFilter = computed(() => {
|
||||
const p = [], a = props.action
|
||||
if (a === 'brightness') { p.push(`brightness(${1 + adjBright.value / 100})`); p.push(`contrast(${1 + adjContrast.value / 100})`); p.push(`saturate(${1 + adjSat.value / 100})`) }
|
||||
if (a === 'blur') p.push(`blur(${blurR.value}px)`)
|
||||
if (a === 'grayscale') p.push(`grayscale(${grayInt.value / 100})`)
|
||||
if (a === 'invert') p.push('invert(1)')
|
||||
if (a === 'sharpen') p.push(`contrast(${1 + sharpAmt.value / 200})`)
|
||||
return p.join(' ') || 'none'
|
||||
})
|
||||
const previewTransform = computed(() => {
|
||||
if (props.action !== 'rotate') return 'none'
|
||||
const sx = flipH.value ? -1 : 1, sy = flipV.value ? -1 : 1
|
||||
return `rotate(${rotAngle.value}deg) scale(${sx}, ${sy})`
|
||||
})
|
||||
const showChecker = computed(() => ['removebg', 'rotate'].includes(props.action))
|
||||
// === File Loading ===
|
||||
function triggerFile() { fileInputRef.value?.click() }
|
||||
async function onFileChange(e) { const files = Array.from(e.target.files || []); if (files.length) await handleFiles(files); e.target.value = '' }
|
||||
async function handleFiles(files) {
|
||||
if (props.action === 'compress') { for (const f of files) await addCompressFile(f) }
|
||||
else if (files.length === 1) await loadFile(files[0])
|
||||
}
|
||||
async function addCompressFile(file) {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
const origUrl = URL.createObjectURL(file)
|
||||
const item = { file, origUrl, origSize: file.size, name: file.name, width: 0, height: 0, compressedBlob: null, compressedUrl: '', compressedSize: 0 }
|
||||
await new Promise(r => { const i = new Image(); i.onload = () => { item.width = i.naturalWidth; item.height = i.naturalHeight; r() }; i.onerror = r; i.src = origUrl })
|
||||
compressList.value.push(item)
|
||||
if (!webpOk.value) webpOk.value = testCodec('image/webp') === 'image/webp'
|
||||
await compressOne(item)
|
||||
}
|
||||
async function loadFile(file) {
|
||||
if (!file.type.startsWith('image/')) { ElMessage.warning('请选择图片文件'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
originalFile.value = file; fileSize.value = file.size; fileName.value = file.name
|
||||
result.value = null
|
||||
if (origPreviewUrl.value) URL.revokeObjectURL(origPreviewUrl.value)
|
||||
origPreviewUrl.value = URL.createObjectURL(file)
|
||||
const dataUrl = await readAsDataUrl(file); imgSrc.value = dataUrl
|
||||
await new Promise((res, rej) => { const i = new Image(); i.onload = () => { imgW.value = i.naturalWidth; imgH.value = i.naturalHeight; res() }; i.onerror = rej; i.src = dataUrl })
|
||||
resetToolParams(); webpOk.value = testCodec('image/webp') === 'image/webp'
|
||||
if (props.action === 'removebg') await initBg(dataUrl)
|
||||
} catch (e) { ElMessage.error('加载图片失败: ' + e.message) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function readAsDataUrl(f) { return new Promise((r, j) => { const rd = new FileReader(); rd.onload = () => r(rd.result); rd.onerror = j; rd.readAsDataURL(f) }) }
|
||||
function testCodec(mime) { const c = document.createElement('canvas'); c.width = c.height = 1; return c.toDataURL(mime).startsWith('data:' + mime) ? mime : 'image/jpeg' }
|
||||
function resetToolParams() {
|
||||
adjBright.value = adjContrast.value = adjSat.value = 0; sharpAmt.value = 50; blurR.value = 3; grayInt.value = 100
|
||||
rotAngle.value = 0; flipH.value = flipV.value = false
|
||||
rsW.value = imgW.value; rsH.value = imgH.value; rsLock.value = false; rsPct.value = 100; rsRatio.value = 'free'; rsRatioVal = imgW.value / (imgH.value || 1)
|
||||
crX.value = crY.value = 0; crW.value = imgW.value; crH.value = imgH.value; crRatio.value = 'free'; crShape.value = 'rect'
|
||||
compressMode.value = 'smart'; customQ.value = 0.8
|
||||
bgHist.value = []; bgColor.value = null; bgReady.value = false; bgFeather.value = 1
|
||||
}
|
||||
function clearFile() {
|
||||
if (props.action === 'compress') {
|
||||
for (const item of compressList.value) { URL.revokeObjectURL(item.origUrl); if (item.compressedUrl) URL.revokeObjectURL(item.compressedUrl) }
|
||||
compressList.value = []; compressIdx.value = 0; return
|
||||
}
|
||||
originalFile.value = null; imgSrc.value = ''; imgW.value = imgH.value = fileSize.value = 0; fileName.value = ''
|
||||
result.value = null
|
||||
if (origPreviewUrl.value) URL.revokeObjectURL(origPreviewUrl.value); origPreviewUrl.value = ''
|
||||
}
|
||||
function removeCompItem(idx) {
|
||||
const item = compressList.value[idx]; URL.revokeObjectURL(item.origUrl); if (item.compressedUrl) URL.revokeObjectURL(item.compressedUrl)
|
||||
compressList.value.splice(idx, 1)
|
||||
if (compressIdx.value >= compressList.value.length) compressIdx.value = Math.max(0, compressList.value.length - 1)
|
||||
}
|
||||
function onDropDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDropDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('dragover')
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type.startsWith('image/'))
|
||||
if (files.length) handleFiles(files)
|
||||
}
|
||||
function onPaste(e) {
|
||||
if (props.action === 'removebg') return
|
||||
const items = e.clipboardData?.items; if (!items) return
|
||||
const files = []; for (const i of items) if (i.type.startsWith('image/')) files.push(i.getAsFile())
|
||||
if (files.length) { e.preventDefault(); handleFiles(files) }
|
||||
}
|
||||
// === Compress ===
|
||||
async function compressOne(item) {
|
||||
try {
|
||||
const img = await new Promise((r, j) => { const im = new Image(); im.onload = () => r(im); im.onerror = j; im.src = item.origUrl })
|
||||
const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight
|
||||
const ctx = c.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0)
|
||||
let mime, quality; const ok = webpOk.value
|
||||
if (compressMode.value === 'smart') { mime = ok ? 'image/webp' : 'image/jpeg'; quality = 0.8 }
|
||||
else if (compressMode.value === 'high') { mime = ok ? 'image/webp' : 'image/jpeg'; quality = 0.55 }
|
||||
else if (compressMode.value === 'extreme') { mime = ok ? 'image/webp' : 'image/jpeg'; quality = 0.25 }
|
||||
else { mime = ok ? 'image/webp' : 'image/jpeg'; quality = customQ.value }
|
||||
const blob = await new Promise((r, j) => c.toBlob(b => b ? r(b) : j(new Error('生成失败')), mime, quality))
|
||||
item.compressedBlob = blob; item.compressedSize = blob.size
|
||||
if (item.compressedUrl) URL.revokeObjectURL(item.compressedUrl); item.compressedUrl = URL.createObjectURL(blob)
|
||||
} catch (e) { console.error('压缩失败:', item.name, e) }
|
||||
}
|
||||
async function compressAll() { compressing.value = true; for (const item of compressList.value) await compressOne(item); compressing.value = false }
|
||||
let compressTimer = null
|
||||
function onCompressChange() { clearTimeout(compressTimer); compressTimer = setTimeout(() => { if (compressList.value.length) compressAll() }, 200) }
|
||||
const compTotalOrig = computed(() => compressList.value.reduce((s, i) => s + i.origSize, 0))
|
||||
const compTotalComp = computed(() => compressList.value.reduce((s, i) => s + i.compressedSize, 0))
|
||||
const compTotalSaved = computed(() => compTotalOrig.value ? ((1 - compTotalComp.value / compTotalOrig.value) * 100).toFixed(1) : '0')
|
||||
// === Resize ===
|
||||
watch(rsPct, v => { if (rsUpdating || !imgW.value) return; rsUpdating = true; rsW.value = Math.round(imgW.value * v / 100); rsH.value = Math.round(imgH.value * v / 100); rsUpdating = false })
|
||||
watch(rsW, v => { if (rsUpdating || !imgW.value) return; rsUpdating = true; if (rsLock.value && imgH.value) rsH.value = Math.round(v / rsRatioVal); rsPct.value = Math.round(v / imgW.value * 100); rsUpdating = false })
|
||||
watch(rsH, v => { if (rsUpdating || !imgH.value) return; rsUpdating = true; if (rsLock.value && imgW.value) rsW.value = Math.round(v * rsRatioVal); rsPct.value = Math.round(rsW.value / imgW.value * 100); rsUpdating = false })
|
||||
function setRsRatio(r) {
|
||||
rsRatio.value = r; if (r === 'free') { rsLock.value = false; return }
|
||||
rsLock.value = true; const [w, h] = r.split(':').map(Number); rsRatioVal = w / h
|
||||
if (imgW.value / imgH.value > rsRatioVal) { rsH.value = imgH.value; rsW.value = Math.round(imgH.value * rsRatioVal) }
|
||||
else { rsW.value = imgW.value; rsH.value = Math.round(imgW.value / rsRatioVal) }
|
||||
}
|
||||
function resetRs() { rsPct.value = 100; rsW.value = imgW.value; rsH.value = imgH.value }
|
||||
// === Crop ===
|
||||
function calcCrImgRect() {
|
||||
const img = crImgRef.value; if (!img || !img.clientWidth) return
|
||||
const cw = img.clientWidth, ch = img.clientHeight, nw = img.naturalWidth, nh = img.naturalHeight; if (!nw || !nh) return
|
||||
const ir = cw / ch, nr = nw / nh
|
||||
if (nr > ir) { const h = cw / nr; crImgRect = { x: 0, y: (ch - h) / 2, w: cw, h, scale: cw / nw } }
|
||||
else { const w = ch * nr; crImgRect = { x: (cw - w) / 2, y: 0, w, h: ch, scale: ch / nh } }
|
||||
}
|
||||
const crSelStyle = computed(() => {
|
||||
const s = crImgRect.scale; if (!crW.value || !crH.value || !s) return { display: 'none' }
|
||||
return { left: crImgRect.x + crX.value * s + 'px', top: crImgRect.y + crY.value * s + 'px', width: crW.value * s + 'px', height: crH.value * s + 'px' }
|
||||
})
|
||||
watch([() => props.action, () => imgSrc.value], ([a, src]) => { if (a === 'crop' && src) nextTick(() => nextTick(() => { calcCrImgRect(); updateCropPreview() })) })
|
||||
watch([crX, crY, crW, crH, crShape], () => { if (props.action === 'crop') updateCropPreview() })
|
||||
let crResizeObs = null
|
||||
watch(() => props.action, (a) => {
|
||||
if (crResizeObs) { crResizeObs.disconnect(); crResizeObs = null }
|
||||
if (a === 'crop' && imgSrc.value) nextTick(() => { const el = crOverlayRef.value?.parentElement; if (el) { crResizeObs = new ResizeObserver(() => calcCrImgRect()); crResizeObs.observe(el) } })
|
||||
})
|
||||
function updateCropPreview() {
|
||||
const c = crPreviewRef.value; if (!c || !imgSrc.value || !crW.value || !crH.value) return
|
||||
const pw = Math.min(360, Math.max(120, crW.value)), ph = Math.min(360, Math.max(120, crH.value)), scale = Math.min(pw / crW.value, ph / crH.value, 1)
|
||||
c.width = Math.round(crW.value * scale); c.height = Math.round(crH.value * scale)
|
||||
const ctx = c.getContext('2d'); drawShapePath(ctx, 0, 0, c.width, c.height, crShape.value); ctx.clip()
|
||||
const img = new Image(); img.onload = () => ctx.drawImage(img, crX.value, crY.value, crW.value, crH.value, 0, 0, c.width, c.height); img.src = imgSrc.value
|
||||
}
|
||||
function drawShapePath(ctx, x, y, w, h, shape) {
|
||||
ctx.beginPath()
|
||||
if (shape === 'circle') { const r = Math.min(w, h) / 2; ctx.arc(x + w / 2, y + h / 2, r, 0, Math.PI * 2) }
|
||||
else if (shape === 'triangle') { ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w, y + h); ctx.lineTo(x, y + h); ctx.closePath() }
|
||||
else if (shape === 'star') {
|
||||
const cx = x + w / 2, cy = y + h / 2, or = Math.min(w, h) / 2, ir = or * 0.382
|
||||
for (let i = 0; i < 10; i++) { const a = Math.PI / 2 * 3 + i * Math.PI / 5, r = i % 2 === 0 ? or : ir; i === 0 ? ctx.moveTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r) : ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r) }
|
||||
ctx.closePath()
|
||||
} else ctx.rect(x, y, w, h)
|
||||
}
|
||||
const crClipPath = computed(() => {
|
||||
const s = crShape.value
|
||||
if (s === 'circle') return 'circle(50% at 50% 50%)'
|
||||
if (s === 'triangle') return 'polygon(50% 0%, 100% 100%, 0% 100%)'
|
||||
if (s === 'star') return 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)'
|
||||
return 'none'
|
||||
})
|
||||
function crDown(e) {
|
||||
if (e.button !== 0) return; calcCrImgRect()
|
||||
const rect = crOverlayRef.value.getBoundingClientRect(), s = crImgRect.scale
|
||||
const ix = (e.clientX - rect.left - crImgRect.x) / s, iy = (e.clientY - rect.top - crImgRect.y) / s
|
||||
if (e.target.closest('.crop-h')) return
|
||||
if (crW.value > 0 && crH.value > 0 && ix >= crX.value && ix <= crX.value + crW.value && iy >= crY.value && iy <= crY.value + crH.value) crType = 'move'
|
||||
else crType = 'create'
|
||||
crDrag = true; crSX = e.clientX; crSY = e.clientY
|
||||
crSR = { x: crX.value, y: crY.value, w: crW.value, h: crH.value, ix: Math.round(Math.max(0, Math.min(imgW.value, ix))), iy: Math.round(Math.max(0, Math.min(imgH.value, iy))) }
|
||||
window.addEventListener('mousemove', crMove); window.addEventListener('mouseup', crUp)
|
||||
}
|
||||
function crHandle(h, e) {
|
||||
e.preventDefault(); e.stopPropagation(); crDrag = true; crType = h
|
||||
crSX = e.clientX; crSY = e.clientY; crSR = { x: crX.value, y: crY.value, w: crW.value, h: crH.value }
|
||||
window.addEventListener('mousemove', crMove); window.addEventListener('mouseup', crUp)
|
||||
}
|
||||
function crMove(e) {
|
||||
if (!crDrag) return; const s = crImgRect.scale, dx = (e.clientX - crSX) / s, dy = (e.clientY - crSY) / s, st = crSR, W = imgW.value, H = imgH.value
|
||||
switch (crType) {
|
||||
case 'move': crX.value = Math.max(0, Math.min(W - st.w, Math.round(st.x + dx))); crY.value = Math.max(0, Math.min(H - st.h, Math.round(st.y + dy))); break
|
||||
case 'create': { const cx = st.ix + dx, cy = st.iy + dy; crX.value = Math.round(Math.max(0, Math.min(st.ix, cx))); crY.value = Math.round(Math.max(0, Math.min(st.iy, cy))); crW.value = Math.round(Math.min(Math.abs(cx - st.ix), W - crX.value)); crH.value = Math.round(Math.min(Math.abs(cy - st.iy), H - crY.value)); break }
|
||||
case 'se': crW.value = Math.round(Math.max(10, Math.min(st.w + dx, W - st.x))); crH.value = Math.round(Math.max(10, Math.min(st.h + dy, H - st.y))); break
|
||||
case 'nw': { const nx = Math.max(0, st.x + dx), ny = Math.max(0, st.y + dy), nw = st.w - (nx - st.x), nh = st.h - (ny - st.y); if (nw > 10 && nh > 10) { crX.value = Math.round(nx); crY.value = Math.round(ny); crW.value = Math.round(nw); crH.value = Math.round(nh) } break }
|
||||
case 'ne': { const nw = Math.max(10, Math.min(st.w + dx, W - st.x)), ny = Math.max(0, st.y + dy), nh = st.h - (ny - st.y); if (nh > 10) { crW.value = Math.round(nw); crY.value = Math.round(ny); crH.value = Math.round(nh) } break }
|
||||
case 'sw': { const nx = Math.max(0, st.x + dx), nw = st.w - (nx - st.x), nh = Math.max(10, Math.min(st.h + dy, H - st.y)); if (nw > 10) { crX.value = Math.round(nx); crW.value = Math.round(nw); crH.value = Math.round(nh) } break }
|
||||
case 'n': { const ny = Math.max(0, st.y + dy), nh = st.h - (ny - st.y); if (nh > 10) { crY.value = Math.round(ny); crH.value = Math.round(nh) } break }
|
||||
case 's': crH.value = Math.round(Math.max(10, Math.min(st.h + dy, H - st.y))); break
|
||||
case 'e': crW.value = Math.round(Math.max(10, Math.min(st.w + dx, W - st.x))); break
|
||||
case 'w': { const nx = Math.max(0, st.x + dx), nw = st.w - (nx - st.x); if (nw > 10) { crX.value = Math.round(nx); crW.value = Math.round(nw) } break }
|
||||
}
|
||||
if (crShape.value !== 'rect' && crW.value > 0 && crH.value > 0) { const sz = Math.min(crW.value, crH.value); crW.value = sz; crH.value = sz }
|
||||
else if (crRatio.value !== 'free' && crType !== 'move' && crW.value > 0) { const [rw, rh] = crRatio.value.split(':').map(Number); const ratio = rw / rh, nh = Math.max(1, Math.round(crW.value / ratio)); if (crY.value + nh <= H) crH.value = nh; else { crH.value = Math.max(1, H - crY.value); crW.value = Math.max(1, Math.round(crH.value * ratio)) } }
|
||||
}
|
||||
function crUp() { crDrag = false; crType = null; window.removeEventListener('mousemove', crMove); window.removeEventListener('mouseup', crUp) }
|
||||
function setCrRatio(r) {
|
||||
crRatio.value = r; if (r === 'free') return
|
||||
const [rw, rh] = r.split(':').map(Number); const rt = rw / rh
|
||||
if (crW.value > 0 && crH.value > 0) { const nh = Math.round(crW.value / rt); if (crY.value + nh <= imgH.value) crH.value = nh; else { crH.value = Math.max(1, imgH.value - crY.value); crW.value = Math.max(1, Math.round(crH.value * rt)) } }
|
||||
else { if (imgW.value / imgH.value > rt) { crH.value = imgH.value; crW.value = Math.round(imgH.value * rt) } else { crW.value = imgW.value; crH.value = Math.round(imgW.value / rt) }; crX.value = Math.round((imgW.value - crW.value) / 2); crY.value = Math.round((imgH.value - crH.value) / 2) }
|
||||
}
|
||||
function resetCrop() { crX.value = 0; crY.value = 0; crW.value = imgW.value; crH.value = imgH.value }
|
||||
function setCrShape(s) { crShape.value = s; if (s !== 'rect') { crRatio.value = 'free'; const sz = Math.min(crW.value, crH.value); crW.value = sz; crH.value = sz } }
|
||||
async function doCropExport() {
|
||||
if (!imgSrc.value || !crW.value || !crH.value) return
|
||||
const img = new Image(); img.onload = () => {
|
||||
const c = document.createElement('canvas'); c.width = crW.value; c.height = crH.value; const ctx = c.getContext('2d')
|
||||
drawShapePath(ctx, 0, 0, crW.value, crH.value, crShape.value); ctx.clip()
|
||||
ctx.drawImage(img, crX.value, crY.value, crW.value, crH.value, 0, 0, crW.value, crH.value)
|
||||
c.toBlob(blob => { if (!blob) { ElMessage.error('导出失败'); return }; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (fileName.value || 'image').replace(/\.[^.]+$/, '') + '_crop.png'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); ElMessage.success('已下载') }, 'image/png')
|
||||
}; img.src = imgSrc.value
|
||||
}
|
||||
// === RemoveBG ===
|
||||
function bgPx(d, x, y) { const i = (y * bgNW + x) * 4; return { r: d[i], g: d[i + 1], b: d[i + 2], a: d[i + 3] } }
|
||||
function bgDist(a, b) { return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2) }
|
||||
function bgMaxD(t) { return (t / 100) * 441.67 }
|
||||
async function initBg(dataUrl) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image(); img.onload = () => {
|
||||
bgNW = img.naturalWidth; bgNH = img.naturalHeight; bgOrigData.value = null; bgProcData.value = null; bgReady.value = false
|
||||
bgHist.value = []; bgColor.value = null; bgSwatch.value = ''; bgCode.value = '未选取'
|
||||
const oc = document.createElement('canvas'); oc.width = bgNW; oc.height = bgNH; oc.getContext('2d').drawImage(img, 0, 0)
|
||||
bgOrigData.value = oc.getContext('2d').getImageData(0, 0, bgNW, bgNH)
|
||||
bgProcData.value = new ImageData(new Uint8ClampedArray(bgOrigData.value.data), bgNW, bgNH)
|
||||
bgReady.value = true; nextTick(() => nextTick(() => bgRender())); resolve()
|
||||
}; img.onerror = () => resolve(); img.src = dataUrl
|
||||
})
|
||||
}
|
||||
function bgRender() { if (bgRafId) cancelAnimationFrame(bgRafId); bgRafId = requestAnimationFrame(_bgDoRender) }
|
||||
function _bgDoRender() {
|
||||
bgRafId = null; if (!bgOrigData.value || !bgProcData.value) return
|
||||
const oc = bgOrigCanvasRef.value, pc = bgPrevCanvasRef.value; if (!oc || !pc) return
|
||||
const ow = oc.parentElement; if (!ow) return; const ww = ow.clientWidth, wh = ow.clientHeight; if (!ww || !wh) return
|
||||
const sc = Math.min(ww / bgNW, wh / bgNH, 1), dw = bgNW * sc, dh = bgNH * sc, ox = (ww - dw) / 2, oy = (wh - dh) / 2
|
||||
bgDS = sc; bgOX = ox; bgOY = oy; oc.width = pc.width = ww; oc.height = pc.height = wh
|
||||
oc.style.width = pc.style.width = ww + 'px'; oc.style.height = pc.style.height = wh + 'px'
|
||||
const oCtx = oc.getContext('2d'); oCtx.clearRect(0, 0, ww, wh); oCtx.fillStyle = '#1a1a25'; oCtx.fillRect(0, 0, ww, wh)
|
||||
const tmp = document.createElement('canvas'); tmp.width = bgNW; tmp.height = bgNH; tmp.getContext('2d').putImageData(bgOrigData.value, 0, 0); oCtx.drawImage(tmp, ox, oy, dw, dh)
|
||||
const pCtx = pc.getContext('2d'); pCtx.clearRect(0, 0, ww, wh)
|
||||
const tmp2 = document.createElement('canvas'); tmp2.width = bgNW; tmp2.height = bgNH; tmp2.getContext('2d').putImageData(bgProcData.value, 0, 0); pCtx.drawImage(tmp2, ox, oy, dw, dh)
|
||||
}
|
||||
function bgCoord(canvas, cx, cy) { const r = canvas.getBoundingClientRect(); return { px: Math.round((cx - r.left - bgOX) / bgDS), py: Math.round((cy - r.top - bgOY) / bgDS), cx: cx - r.left, cy: cy - r.top } }
|
||||
function bgRemoveAll(tc, tol) { if (!bgProcData.value || !bgOrigData.value) return 0; const md = bgMaxD(tol), dd = bgProcData.value.data, od = bgOrigData.value.data; let n = 0; for (let i = 0; i < dd.length; i += 4) { if (dd[i + 3] === 0) continue; if (bgDist(tc, { r: od[i], g: od[i + 1], b: od[i + 2] }) <= md) { dd[i + 3] = 0; n++ } } return n }
|
||||
function bgFeatherEdges(radius) {
|
||||
if (!bgProcData.value || radius <= 0) return; const d = bgProcData.value.data, w = bgNW, h = bgNH, newA = new Uint8ClampedArray(w * h)
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { const pi = y * w + x, a = d[pi * 4 + 3]; if (a === 0) { newA[pi] = 0; continue }; let minD = radius + 1; for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) { if (!dx && !dy) continue; const nx = x + dx, ny = y + dy; if (nx >= 0 && nx < w && ny >= 0 && ny < h && d[(ny * w + nx) * 4 + 3] === 0) { const dist = Math.sqrt(dx * dx + dy * dy); if (dist < minD) minD = dist } } newA[pi] = minD <= radius ? Math.round((minD / (radius + 1)) * a) : a }
|
||||
for (let i = 0; i < w * h; i++) d[i * 4 + 3] = newA[i]
|
||||
}
|
||||
function bgFlood(tc, tol, sx, sy) {
|
||||
if (!bgProcData.value || !bgOrigData.value || sx < 0 || sx >= bgNW || sy < 0 || sy >= bgNH) return 0
|
||||
const si = (sy * bgNW + sx) * 4; if (bgProcData.value.data[si + 3] === 0) return 0
|
||||
const md = bgMaxD(tol), dd = bgProcData.value.data, od = bgOrigData.value.data, w = bgNW, h = bgNH
|
||||
const vis = new Uint8Array(w * h), q = [{ x: sx, y: sy }], dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; vis[sy * w + sx] = 1; let n = 0, head = 0
|
||||
while (head < q.length) { const { x, y } = q[head++]; const pi = (y * w + x) * 4; if (bgDist(tc, { r: od[pi], g: od[pi + 1], b: od[pi + 2] }) <= md) { dd[pi + 3] = 0; n++; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (nx >= 0 && nx < w && ny >= 0 && ny < h && !vis[ny * w + nx] && dd[(ny * w + nx) * 4 + 3] > 0) { vis[ny * w + nx] = 1; q.push({ x: nx, y: ny }) } } } } return n
|
||||
}
|
||||
function bgSaveHist() { if (!bgProcData.value) return; bgHist.value.push(new ImageData(new Uint8ClampedArray(bgProcData.value.data), bgNW, bgNH)); if (bgHist.value.length > 30) bgHist.value.shift() }
|
||||
function bgUndo() { if (!bgHist.value.length) return; bgProcData.value = bgHist.value.pop(); bgRender() }
|
||||
function bgReset() { if (!bgOrigData.value) return; bgHist.value = []; bgProcData.value = new ImageData(new Uint8ClampedArray(bgOrigData.value.data), bgNW, bgNH); bgColor.value = null; bgSwatch.value = ''; bgCode.value = '未选取'; bgRender() }
|
||||
function bgApply(tc, px, py) { bgSaveHist(); const n = bgMode.value === 'global' ? bgRemoveAll(tc, bgTol.value) : bgFlood(tc, bgTol.value, px, py); if (n > 0 && bgFeather.value > 0) bgFeatherEdges(bgFeather.value); bgRender(); if (n > 0) ElMessage.success(`已去除 ${n.toLocaleString()} 个像素`); else ElMessage.warning('未找到匹配像素') }
|
||||
function bgClick(e) { if (!bgOrigData.value) return; const c = bgOrigCanvasRef.value; if (!c) return; const { px, py } = bgCoord(c, e.clientX, e.clientY); if (px < 0 || px >= bgNW || py < 0 || py >= bgNH) return; const cl = bgPx(bgOrigData.value.data, px, py); bgColor.value = cl; bgSwatch.value = `rgb(${cl.r},${cl.g},${cl.b})`; bgCode.value = `#${cl.r.toString(16).padStart(2, '0')}${cl.g.toString(16).padStart(2, '0')}${cl.b.toString(16).padStart(2, '0')}`; bgApply(cl, px, py) }
|
||||
function bgHover(e) { if (!bgOrigData.value) return; const c = bgOrigCanvasRef.value, b = document.getElementById('bgBadge'); if (!c || !b) return; const { px, py, cx, cy } = bgCoord(c, e.clientX, e.clientY); if (px < 0 || px >= bgNW || py < 0 || py >= bgNH) { b.style.display = 'none'; return }; const cl = bgPx(bgOrigData.value.data, px, py); b.style.backgroundColor = `rgb(${cl.r},${cl.g},${cl.b})`; b.style.left = cx + 'px'; b.style.top = cy + 'px'; b.style.display = 'block' }
|
||||
function bgLeave() { const b = document.getElementById('bgBadge'); if (b) b.style.display = 'none' }
|
||||
function bgPreset(name) { const m = { 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 c = m[name]; if (!c) return; bgColor.value = c; bgSwatch.value = `rgb(${c.r},${c.g},${c.b})`; bgCode.value = `#${c.r.toString(16).padStart(2, '0')}${c.g.toString(16).padStart(2, '0')}${c.b.toString(16).padStart(2, '0')}`; bgApply(c, 0, 0) }
|
||||
watch(bgFeather, () => { if (!bgOrigData.value || !bgColor.value) return; bgProcData.value = new ImageData(new Uint8ClampedArray(bgOrigData.value.data), bgNW, bgNH); const n = bgMode.value === 'global' ? bgRemoveAll(bgColor.value, bgTol.value) : bgFlood(bgColor.value, bgTol.value, 0, 0); if (n > 0 && bgFeather.value > 0) bgFeatherEdges(bgFeather.value); bgRender() })
|
||||
function bgDownload() { if (!bgProcData.value) return; const c = document.createElement('canvas'); c.width = bgNW; c.height = bgNH; c.getContext('2d').putImageData(bgProcData.value, 0, 0); const a = document.createElement('a'); a.download = (fileName.value || 'image').replace(/\.[^.]+$/, '') + '_nobg.png'; a.href = c.toDataURL('image/png'); a.click(); ElMessage.success('已下载') }
|
||||
// === Preview ===
|
||||
function openPreview(src) { previewSrc.value = src; pvScale.value = 1; pvX.value = pvY.value = 0; showPreview.value = true }
|
||||
function closePreview() { showPreview.value = false }
|
||||
function pvWheel(e) { e.preventDefault(); pvScale.value = Math.max(0.1, Math.min(5, pvScale.value + (e.deltaY > 0 ? -0.1 : 0.1))) }
|
||||
function pvDown(e) { if (e.button !== 0) return; pvDrag = true; pvSX = e.clientX - pvX.value; pvSY = e.clientY - pvY.value; window.addEventListener('mousemove', pvMoveEvt); window.addEventListener('mouseup', pvUpEvt) }
|
||||
function pvMoveEvt(e) { if (!pvDrag) return; pvX.value = e.clientX - pvSX; pvY.value = e.clientY - pvSY }
|
||||
function pvUpEvt() { pvDrag = false; window.removeEventListener('mousemove', pvMoveEvt); window.removeEventListener('mouseup', pvUpEvt) }
|
||||
function pvReset() { pvScale.value = 1; pvX.value = pvY.value = 0 }
|
||||
// === Export ===
|
||||
function fSize(b) { if (!b) return '0 B'; const k = 1024, u = ['B', 'KB', 'MB', 'GB'], i = Math.floor(Math.log(b) / Math.log(k)); return parseFloat((b / Math.pow(k, i)).toFixed(2)) + ' ' + u[i] }
|
||||
async function doExport() {
|
||||
if (props.action === 'compress') {
|
||||
if (!compressList.value.length) return
|
||||
if (compressList.value.length === 1) { const item = compressList.value[0]; if (item.compressedBlob) { const a = document.createElement('a'); a.href = item.compressedUrl; a.download = item.name.replace(/\.[^.]+$/, '') + (webpOk.value ? '_compressed.webp' : '_compressed.jpg'); a.click(); ElMessage.success('已下载') } }
|
||||
else { for (let i = 0; i < compressList.value.length; i++) { const item = compressList.value[i]; if (!item.compressedBlob) continue; const a = document.createElement('a'); a.href = item.compressedUrl; a.download = item.name.replace(/\.[^.]+$/, '') + (webpOk.value ? '_compressed.webp' : '_compressed.jpg'); a.click(); await new Promise(r => setTimeout(r, 300)) }; ElMessage.success(`已下载 ${compressList.value.length} 张图片`) }
|
||||
return
|
||||
}
|
||||
if (!originalFile.value) return; processing.value = true; result.value = null
|
||||
try {
|
||||
if (props.action === 'removebg') bgDownload()
|
||||
else if (props.action === 'crop') await doCropExport()
|
||||
else await backendExport()
|
||||
} catch (e) { ElMessage.error('导出失败: ' + e.message) }
|
||||
finally { processing.value = false }
|
||||
}
|
||||
async function backendExport() {
|
||||
const a = props.action, req = { inputPath: originalFile.value.path || '', outputPath: '', format: a }
|
||||
if (a === 'brightness') { req.brightness = adjBright.value; req.contrast = adjContrast.value; req.saturation = adjSat.value }
|
||||
else if (a === 'sharpen') req.sharpenAmount = sharpAmt.value / 50
|
||||
else if (a === 'blur') req.blurRadius = blurR.value
|
||||
else if (a === 'grayscale') req.grayscaleIntensity = grayInt.value
|
||||
else if (a === 'rotate') { req.angle = rotAngle.value; req.bgColor = rotBg.value; req.flipH = flipH.value; req.flipV = flipV.value }
|
||||
else if (a === 'resize') { req.width = rsW.value; req.height = rsH.value; req.maintainRatio = rsLock.value }
|
||||
const res = await window.go.main.FileHandler.ProcessFile(req)
|
||||
if (res.success) { let procUrl = ''; try { const b64 = await window.go.main.FileHandler.GetImageBase64(res.path); if (b64) procUrl = b64 } catch (e) { }; result.value = { size: res.size, origSize: res.originalSize || fileSize.value, tempPath: res.path, url: procUrl, fmt: 'png' }; ElMessage.success('处理完成') } else ElMessage.error(res.message)
|
||||
}
|
||||
function downloadFrontend() { if (!result.value) return; const a = document.createElement('a'); a.href = result.value.url; a.download = (fileName.value || 'image').replace(/\.[^.]+$/, '') + (result.value.url.startsWith('blob:') ? '_compressed.' : '.') + (result.value.fmt || 'png'); a.click(); ElMessage.success('已下载') }
|
||||
async function saveToPath() {
|
||||
if (!result.value) return; try {
|
||||
const ext = '.' + (result.value.fmt || 'png'), dn = (fileName.value || 'image').replace(/\.[^.]+$/, '') + '_edited' + ext
|
||||
const path = await window.go.main.FileHandler.OpenSaveDialog('保存文件', dn, ['*' + ext], props.config?.defaultOutputDir || '')
|
||||
if (path) { if (result.value.tempPath) { const r = await window.go.main.FileHandler.SaveResult(result.value.tempPath, path); if (r.success) { ElMessage.success('已保存'); result.value.path = path } else ElMessage.error(r.message) } else if (result.value.url?.startsWith('blob:')) { const resp = await fetch(result.value.url); const blob = await resp.blob(); const rd = new FileReader(); rd.onload = async () => { try { const b64 = rd.result.split(',')[1]; const r = await window.go.main.FileHandler.SaveBase64ToFile(b64, path); if (r.success) { ElMessage.success('已保存'); result.value.path = path } else ElMessage.error(r.message) } catch (e) { ElMessage.error('保存失败') } }; rd.readAsDataURL(blob) } }
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
}
|
||||
async function openFolder() { if (result.value?.path) try { await window.go.main.FileHandler.OpenFolder(result.value.path.replace(/[/\\][^/\\]+$/, '')) } catch (e) { } }
|
||||
onMounted(() => { document.addEventListener('paste', onPaste); window.addEventListener('resize', () => { if (props.action === 'crop') calcCrImgRect() }) })
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', onPaste); window.removeEventListener('resize', () => { if (props.action === 'crop') calcCrImgRect() })
|
||||
for (const item of compressList.value) { URL.revokeObjectURL(item.origUrl); if (item.compressedUrl) URL.revokeObjectURL(item.compressedUrl) }
|
||||
if (origPreviewUrl.value) URL.revokeObjectURL(origPreviewUrl.value)
|
||||
window.removeEventListener('mousemove', crMove); window.removeEventListener('mouseup', crUp)
|
||||
window.removeEventListener('mousemove', pvMoveEvt); window.removeEventListener('mouseup', pvUpEvt)
|
||||
if (crResizeObs) crResizeObs.disconnect(); if (bgRafId) cancelAnimationFrame(bgRafId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-root">
|
||||
<input ref="fileInputRef" type="file" accept="image/*" :multiple="action === 'compress'" style="display:none" @change="onFileChange" />
|
||||
<div v-if="!hasFile" class="file-drop-zone" @click="triggerFile" @dragover="onDropDragOver" @dragleave="onDropDragLeave" @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="workspace">
|
||||
<div class="workspace-left">
|
||||
<!-- Compress thumbnail strip -->
|
||||
<div v-if="action === 'compress'" class="thumb-strip">
|
||||
<button class="thumb-nav-btn" :disabled="compressIdx <= 0" @click="compressIdx--"><el-icon><ArrowLeft /></el-icon></button>
|
||||
<div class="thumb-list">
|
||||
<div v-for="(item, idx) in compressList" :key="idx" class="thumb-item" :class="{ active: idx === compressIdx }" @click="compressIdx = idx">
|
||||
<img :src="item.origUrl" />
|
||||
<button class="thumb-remove" @click.stop="removeCompItem(idx)">×</button>
|
||||
<span class="thumb-num">{{ idx + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="thumb-nav-btn" :disabled="compressIdx >= compressList.length - 1" @click="compressIdx++"><el-icon><ArrowRight /></el-icon></button>
|
||||
<el-button size="small" @click="triggerFile" style="flex-shrink:0"><el-icon><Plus /></el-icon></el-button>
|
||||
</div>
|
||||
<!-- Non-compress file bar -->
|
||||
<div v-else class="file-bar" @click="triggerFile">
|
||||
<img :src="origPreviewUrl" class="file-thumb" />
|
||||
<div class="file-meta-info"><div class="file-name">{{ fileName }}</div><div class="file-meta">{{ imgW }}×{{ imgH }} · {{ fSize(fileSize) }}</div></div>
|
||||
<el-button type="danger" text @click.stop="clearFile"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
<!-- Preview Area -->
|
||||
<div class="preview-area">
|
||||
<div class="preview-canvas" :class="{ 'checker-bg': showChecker }">
|
||||
<template v-if="action === 'compress' && curComp">
|
||||
<div class="compare-view">
|
||||
<div class="compare-panel"><img :src="curComp.origUrl" class="compare-img clickable" @click="openPreview(curComp.origUrl)" /><div class="compare-label">原图 · {{ fSize(curComp.origSize) }}</div></div>
|
||||
<div class="compare-panel"><img v-if="curComp.compressedUrl" :src="curComp.compressedUrl" class="compare-img clickable" @click="openPreview(curComp.compressedUrl)" /><div v-else class="compare-placeholder">{{ compressing ? '压缩中...' : '等待压缩' }}</div><div class="compare-label">压缩后 · {{ curComp.compressedSize ? fSize(curComp.compressedSize) : '—' }}</div></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="action === 'crop'">
|
||||
<div class="crop-panels">
|
||||
<div class="crop-left"><div class="crop-img-wrap"><img ref="crImgRef" :src="imgSrc" class="crop-full-img" @load="calcCrImgRect" /><div ref="crOverlayRef" class="crop-overlay" @mousedown="crDown"><div class="crop-sel" :style="crSelStyle" :class="{ 'shape-mask': crShape !== 'rect' }"><div class="crop-inner" :style="crShape !== 'rect' ? { clipPath: crClipPath } : {}"><div class="crop-dim" v-if="crW > 0 && crH > 0">{{ crW }} × {{ crH }}</div></div><div class="crop-h nw" @mousedown="crHandle('nw', $event)"></div><div class="crop-h n" @mousedown="crHandle('n', $event)"></div><div class="crop-h ne" @mousedown="crHandle('ne', $event)"></div><div class="crop-h e" @mousedown="crHandle('e', $event)"></div><div class="crop-h se" @mousedown="crHandle('se', $event)"></div><div class="crop-h s" @mousedown="crHandle('s', $event)"></div><div class="crop-h sw" @mousedown="crHandle('sw', $event)"></div><div class="crop-h w" @mousedown="crHandle('w', $event)"></div></div></div></div></div>
|
||||
<div class="crop-right checker-bg"><div class="crop-preview-title">裁剪预览</div><canvas ref="crPreviewRef" class="crop-preview-canvas"></canvas><div class="crop-preview-dims" v-if="crW && crH">{{ crW }} × {{ crH }} px</div></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="action === 'removebg' && bgReady">
|
||||
<div class="bg-panels"><div class="bg-panel"><div class="bg-panel-title"><span class="dot dot-orig"></span>原图 · 点击取色</div><div class="bg-canvas-wrap" @click="bgClick" @mousemove="bgHover" @mouseleave="bgLeave" style="cursor:crosshair"><canvas ref="bgOrigCanvasRef"></canvas><div id="bgBadge" class="bg-badge"></div></div></div><div class="bg-panel"><div class="bg-panel-title"><span class="dot dot-prev"></span>预览</div><div class="bg-canvas-wrap checker-bg"><canvas ref="bgPrevCanvasRef"></canvas></div></div></div>
|
||||
</template>
|
||||
<template v-else-if="action === 'resize'"><div class="resize-preview"><img :src="imgSrc" class="main-preview-img" /><div class="resize-badge">{{ rsW }} × {{ rsH }}</div></div></template>
|
||||
<template v-else><img :src="imgSrc" class="main-preview-img" :style="{ filter: previewFilter, transform: previewTransform }" /></template>
|
||||
</div>
|
||||
<transition name="slide-up"><div v-if="result && action !== 'removebg' && action !== 'crop' && action !== 'compress'" class="result-bar"><div class="result-info"><span class="result-icon">✓</span><span>处理完成</span><span class="result-sizes" v-if="result.origSize && result.size">{{ fSize(result.origSize) }} → {{ fSize(result.size) }}<span v-if="result.size < result.origSize" class="result-saved">节省 {{ ((1 - result.size / result.origSize) * 100).toFixed(1) }}%</span></span></div><div class="result-btns"><el-button size="small" @click="downloadFrontend"><el-icon><Download /></el-icon>下载</el-button><el-button size="small" @click="saveToPath"><el-icon><FolderOpened /></el-icon>另存为</el-button><el-button v-if="result.path" size="small" @click="openFolder"><el-icon><FolderOpened /></el-icon>目录</el-button></div></div></transition>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Controls Panel -->
|
||||
<div class="ctrl-panel">
|
||||
<div class="ctrl-section">
|
||||
<div class="ctrl-title">参数设置</div>
|
||||
<template v-if="action === 'compress'">
|
||||
<div class="preset-grid"><button class="preset-btn" :class="{ active: compressMode === 'smart' }" @click="compressMode = 'smart'; onCompressChange()"><span class="preset-name">智能</span><span class="preset-desc">推荐</span></button><button class="preset-btn" :class="{ active: compressMode === 'high' }" @click="compressMode = 'high'; onCompressChange()"><span class="preset-name">高质量</span><span class="preset-desc">0.55</span></button><button class="preset-btn" :class="{ active: compressMode === 'extreme' }" @click="compressMode = 'extreme'; onCompressChange()"><span class="preset-name">极限</span><span class="preset-desc">0.25</span></button><button class="preset-btn" :class="{ active: compressMode === 'custom' }" @click="compressMode = 'custom'"><span class="preset-name">自定义</span><span class="preset-desc">手动</span></button></div>
|
||||
<div v-if="compressMode === 'custom'" class="param-group"><div class="param-label">质量: {{ customQ.toFixed(2) }}</div><el-slider v-model="customQ" :min="0.05" :max="1" :step="0.05" @input="onCompressChange" /></div>
|
||||
<div v-if="compressList.length > 1" class="compress-result"><span>共 {{ compressList.length }} 张 · 总计节省 {{ fSize(compTotalOrig - compTotalComp) }}({{ compTotalSaved }}%)</span></div>
|
||||
<div v-else-if="curComp && curComp.compressedSize" class="compress-result" :class="{ bad: curComp.compressedSize >= curComp.origSize }"><span v-if="curComp.compressedSize < curComp.origSize">节省 {{ fSize(curComp.origSize - curComp.compressedSize) }}({{ ((1 - curComp.compressedSize / curComp.origSize) * 100).toFixed(1) }}%)</span><span v-else>增大 {{ fSize(curComp.compressedSize - curComp.origSize) }}({{ ((curComp.compressedSize / curComp.origSize - 1) * 100).toFixed(1) }}%)</span></div>
|
||||
</template>
|
||||
<template v-else-if="action === 'brightness'"><div class="param-group"><div class="param-label">亮度: {{ adjBright > 0 ? '+' : '' }}{{ adjBright }}</div><el-slider v-model="adjBright" :min="-100" :max="100" /></div><div class="param-group"><div class="param-label">对比度: {{ adjContrast > 0 ? '+' : '' }}{{ adjContrast }}</div><el-slider v-model="adjContrast" :min="-100" :max="100" /></div><div class="param-group"><div class="param-label">饱和度: {{ adjSat > 0 ? '+' : '' }}{{ adjSat }}</div><el-slider v-model="adjSat" :min="-100" :max="100" /></div></template>
|
||||
<template v-else-if="action === 'sharpen'"><div class="param-group"><div class="param-label">锐化强度: {{ sharpAmt }}%</div><el-slider v-model="sharpAmt" :min="0" :max="100" /><div class="slider-hint"><span>无锐化</span><span>最强</span></div></div></template>
|
||||
<template v-else-if="action === 'blur'"><div class="param-group"><div class="param-label">模糊半径: {{ blurR }}</div><el-slider v-model="blurR" :min="0" :max="20" :step="0.5" /><div class="slider-hint"><span>无模糊</span><span>最强</span></div></div></template>
|
||||
<template v-else-if="action === 'grayscale'"><div class="param-group"><div class="param-label">灰度强度: {{ grayInt }}%</div><el-slider v-model="grayInt" :min="0" :max="100" /><div class="slider-hint"><span>原图</span><span>完全灰度</span></div></div></template>
|
||||
<template v-else-if="action === 'invert'"><div class="param-group"><div class="param-label">反转图片中的所有颜色</div></div></template>
|
||||
<template v-else-if="action === 'rotate'"><div class="param-group"><div class="param-label">角度: {{ rotAngle }}°</div><el-slider v-model="rotAngle" :min="0" :max="360" /><div class="quick-angles"><span @click="rotAngle = 90">90°</span><span @click="rotAngle = 180">180°</span><span @click="rotAngle = 270">270°</span><span @click="rotAngle = 0">重置</span></div></div><div class="param-group"><div class="param-label">背景色</div><div class="tag-group"><div class="tag-option" :class="{ active: rotBg === 'transparent' }" @click="rotBg = 'transparent'"><span class="bg-swatch bg-checker"></span>透明</div><div class="tag-option" :class="{ active: rotBg === 'white' }" @click="rotBg = 'white'"><span class="bg-swatch" style="background:#fff"></span>白</div><div class="tag-option" :class="{ active: rotBg === 'black' }" @click="rotBg = 'black'"><span class="bg-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></template>
|
||||
<template v-else-if="action === 'resize'"><div class="param-group"><div class="param-label">缩放: {{ rsPct }}%</div><el-slider v-model="rsPct" :min="1" :max="500" :step="1" /><div class="slider-hint"><span>缩小</span><span>放大</span></div><div style="text-align:right;margin-top:4px"><el-button size="small" text @click="resetRs">重置 100%</el-button></div></div><div class="param-group"><div class="param-label">预设比例</div><div class="preset-grid preset-grid-3"><div class="preset-btn" :class="{ active: rsRatio === 'free' }" @click="setRsRatio('free')"><span class="preset-name">自由</span></div><div class="preset-btn" :class="{ active: rsRatio === '1:1' }" @click="setRsRatio('1:1')"><span class="preset-name">1:1</span></div><div class="preset-btn" :class="{ active: rsRatio === '4:3' }" @click="setRsRatio('4:3')"><span class="preset-name">4:3</span></div><div class="preset-btn" :class="{ active: rsRatio === '16:9' }" @click="setRsRatio('16:9')"><span class="preset-name">16:9</span></div><div class="preset-btn" :class="{ active: rsRatio === '2:3' }" @click="setRsRatio('2:3')"><span class="preset-name">2:3</span></div><div class="preset-btn" :class="{ active: rsRatio === '9:16' }" @click="setRsRatio('9:16')"><span class="preset-name">9:16</span></div></div></div><div class="param-group"><div class="param-label">尺寸 (px) <button class="lock-btn" :class="{ locked: rsLock }" @click="rsLock = !rsLock"><el-icon :size="12"><Lock v-if="rsLock" /><Unlock v-else /></el-icon></button></div><div class="size-inputs"><el-input-number v-model="rsW" :min="1" :max="10000" size="small" controls-position="right" /><span class="size-sep">×</span><el-input-number v-model="rsH" :min="1" :max="10000" size="small" controls-position="right" /></div></div></template>
|
||||
<template v-else-if="action === 'crop'"><div class="param-group"><div class="param-label">裁剪形状</div><div class="shape-btns"><button :class="{ active: crShape === 'rect' }" @click="setCrShape('rect')"><span class="shape-icon shape-rect"></span>矩形</button><button :class="{ active: crShape === 'circle' }" @click="setCrShape('circle')"><span class="shape-icon shape-circle"></span>圆形</button><button :class="{ active: crShape === 'triangle' }" @click="setCrShape('triangle')"><span class="shape-icon shape-tri"></span>三角</button><button :class="{ active: crShape === 'star' }" @click="setCrShape('star')"><span class="shape-icon shape-star">★</span>五角星</button></div></div><div class="param-group" v-if="crShape === 'rect'"><div class="param-label">比例</div><div class="tag-group"><div class="tag-option" :class="{ active: crRatio === 'free' }" @click="setCrRatio('free')">自由</div><div class="tag-option" :class="{ active: crRatio === '1:1' }" @click="setCrRatio('1:1')">1:1</div><div class="tag-option" :class="{ active: crRatio === '4:3' }" @click="setCrRatio('4:3')">4:3</div><div class="tag-option" :class="{ active: crRatio === '16:9' }" @click="setCrRatio('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="crX" :min="0" :max="imgW" size="small" controls-position="right" /></div><div class="coord-row"><span class="coord-label">Y</span><el-input-number v-model="crY" :min="0" :max="imgH" size="small" controls-position="right" /></div><div class="coord-row"><span class="coord-label">W</span><el-input-number v-model="crW" :min="10" :max="imgW" size="small" controls-position="right" /></div><div class="coord-row"><span class="coord-label">H</span><el-input-number v-model="crH" :min="10" :max="imgH" size="small" controls-position="right" /></div></div><button class="link-btn" @click="resetCrop">重置为全图</button></template>
|
||||
<template v-else-if="action === 'removebg'"><div class="param-group"><div class="param-label">模式</div><div class="bg-mode-btns"><button :class="{ active: bgMode === 'global' }" @click="bgMode = 'global'">全局匹配</button><button :class="{ active: bgMode === 'floodfill' }" @click="bgMode = 'floodfill'">连通区域</button></div></div><div class="param-group"><div class="param-label">容差: {{ bgTol }}</div><el-slider v-model="bgTol" :min="0" :max="100" /></div><div class="param-group"><div class="param-label">边缘柔化: {{ bgFeather }}px</div><el-slider v-model="bgFeather" :min="0" :max="5" :step="1" /></div><div class="param-group"><div class="param-label">当前颜色</div><div class="bg-color-row"><div class="bg-color-swatch" :style="{ backgroundColor: bgSwatch || '' }" :class="{ empty: !bgSwatch }"></div><span class="bg-color-code">{{ bgCode }}</span></div></div><div class="param-group"><div class="param-label">快捷取色</div><div class="bg-presets"><button @click="bgPreset('white')"><span class="bg-mini" style="background:#fff"></span>白</button><button @click="bgPreset('black')"><span class="bg-mini" style="background:#1a1a1a"></span>黑</button><button @click="bgPreset('green')"><span class="bg-mini" style="background:#00b140"></span>绿</button><button @click="bgPreset('gray')"><span class="bg-mini" style="background:#d0d0d0"></span>灰</button></div></div><div class="bg-actions"><button :disabled="!bgHist.length" @click="bgUndo">撤销</button><button @click="bgReset">重置</button></div></template>
|
||||
</div>
|
||||
<div class="export-section">
|
||||
<el-button class="export-btn" type="primary" size="large" :loading="processing || compressing" :disabled="!hasFile" @click="doExport">
|
||||
<el-icon v-if="!processing && !compressing"><Download /></el-icon>
|
||||
{{ processing ? '处理中...' : (action === 'compress' ? (compressList.length > 1 ? `批量下载(${compressList.length} 张)` : '下载压缩图') : action === 'removebg' ? '下载 PNG' : action === 'crop' ? '下载裁剪图' : '导出并保存') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade"><div v-if="showPreview" class="pv-overlay" @click.self="closePreview"><div class="pv-toolbar"><div class="pv-controls"><el-button text @click="pvScale = Math.max(0.1, pvScale - 0.2)"><el-icon><ZoomOut /></el-icon></el-button><span class="pv-zoom">{{ Math.round(pvScale * 100) }}%</span><el-button text @click="pvScale = Math.min(5, pvScale + 0.2)"><el-icon><ZoomIn /></el-icon></el-button><el-button text @click="pvReset"><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="pv-canvas" @wheel.prevent="pvWheel" @mousedown="pvDown" :style="{ cursor: pvDrag ? 'grabbing' : 'grab' }"><img :src="previewSrc" :style="{ transform: `translate(${pvX}px, ${pvY}px) scale(${pvScale})`, transition: pvDrag ? 'none' : 'transform 0.15s ease' }" draggable="false" /></div></div></transition>
|
||||
<transition name="fade"><div v-if="loading" class="loading-overlay"><div class="loading-card"><div class="spinner"></div><div>加载中...</div></div></div></transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-root { height: 100%; display: flex; flex-direction: column; min-height: 0; flex: 1; }
|
||||
.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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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); }
|
||||
.workspace { display: flex; flex-direction: row; flex: 1; min-height: 0; }
|
||||
.workspace-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding-right: 16px; }
|
||||
.file-bar { display: flex; align-items: center; gap: 14px; padding: 10px 16px; border: 1px solid var(--glass-border); border-radius: var(--radius-lg); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); cursor: pointer; transition: all var(--transition-normal); flex-shrink: 0; }
|
||||
.file-bar:hover { border-color: var(--accent-primary); }
|
||||
.file-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||
.file-meta-info { 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-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
/* Thumbnail strip */
|
||||
.thumb-strip { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--glass-border); border-radius: var(--radius-lg); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); flex-shrink: 0; }
|
||||
.thumb-list { display: flex; gap: 6px; overflow-x: auto; flex: 1; min-width: 0; padding: 2px 0; }
|
||||
.thumb-list::-webkit-scrollbar { height: 4px; }
|
||||
.thumb-list::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 2px; }
|
||||
.thumb-item { position: relative; width: 48px; height: 48px; border-radius: 6px; overflow: hidden; border: 2px solid transparent; cursor: pointer; flex-shrink: 0; transition: all 0.15s; }
|
||||
.thumb-item:hover { border-color: rgba(255,255,255,0.2); }
|
||||
.thumb-item.active { border-color: var(--accent-primary); box-shadow: 0 0 8px rgba(59,130,246,0.3); }
|
||||
.thumb-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.thumb-remove { position: absolute; top: -2px; right: -2px; width: 16px; height: 16px; border-radius: 50%; background: #ef4444; color: #fff; border: none; cursor: pointer; font-size: 11px; display: none; align-items: center; justify-content: center; line-height: 1; z-index: 2; padding: 0; }
|
||||
.thumb-item:hover .thumb-remove { display: flex; }
|
||||
.thumb-num { position: absolute; bottom: 1px; left: 1px; background: rgba(0,0,0,0.6); color: #fff; font-size: 9px; padding: 0 4px; border-radius: 3px; pointer-events: none; }
|
||||
.thumb-nav-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--glass-border); background: var(--bg-surface); color: var(--text-primary); cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; }
|
||||
.thumb-nav-btn:hover:not(:disabled) { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.thumb-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.preview-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; flex: 1; min-height: 0; }
|
||||
.preview-canvas { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 200px; position: relative; }
|
||||
.checker-bg { background-color: #1a1a25; background-image: linear-gradient(45deg, #252530 25%, transparent 25%, transparent 75%, #252530 75%, #252530), linear-gradient(45deg, #252530 25%, transparent 25%, transparent 75%, #252530 75%, #252530); background-size: 20px 20px; background-position: 0 0, 10px 10px; }
|
||||
.main-preview-img { max-width: 100%; max-height: 100%; object-fit: contain; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||
.compare-view { display: flex; gap: 4px; width: 100%; height: 100%; padding: 8px; box-sizing: border-box; }
|
||||
.compare-panel { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 0; overflow: hidden; }
|
||||
.compare-img { max-width: 100%; max-height: calc(100% - 30px); object-fit: contain; }
|
||||
.compare-img.clickable { cursor: pointer; transition: opacity 0.2s; }
|
||||
.compare-img.clickable:hover { opacity: 0.85; }
|
||||
.compare-label { font-size: 11px; color: var(--text-muted); margin-top: 6px; text-align: center; }
|
||||
.compare-placeholder { color: var(--text-muted); font-size: 14px; }
|
||||
.resize-preview { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||
.resize-badge { position: absolute; bottom: 16px; right: 16px; padding: 4px 12px; border-radius: var(--radius-sm); font-size: 12px; font-weight: 600; background: rgba(0,0,0,0.7); color: #fff; backdrop-filter: blur(8px); }
|
||||
.crop-panels { display: flex; gap: 12px; width: 100%; height: 100%; padding: 8px; box-sizing: border-box; }
|
||||
.crop-left { flex: 6; display: flex; flex-direction: column; min-width: 0; }
|
||||
.crop-img-wrap { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-surface); }
|
||||
.crop-full-img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; user-select: none; pointer-events: none; }
|
||||
.crop-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; }
|
||||
.crop-sel { position: absolute; border: 2px solid rgba(255,255,255,0.9); box-shadow: 0 0 0 9999px rgba(0,0,0,0.5); cursor: move; min-width: 24px; min-height: 24px; }
|
||||
.crop-sel.shape-mask { border-color: transparent; box-shadow: none; }
|
||||
.crop-sel.shape-mask .crop-inner { width: 100%; height: 100%; position: absolute; top: 0; left: 0; background: rgba(0,0,0,0.5); }
|
||||
.crop-inner { pointer-events: none; }
|
||||
.crop-dim { 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-h { position: absolute; width: 12px; height: 12px; background: #fff; border: 2px solid var(--accent-primary); border-radius: 50%; z-index: 11; }
|
||||
.crop-h.nw { top: 0; left: 0; cursor: nw-resize; transform: translate(-50%,-50%); }
|
||||
.crop-h.n { top: 0; left: 50%; cursor: n-resize; transform: translate(-50%,-50%); }
|
||||
.crop-h.ne { top: 0; right: 0; cursor: ne-resize; transform: translate(50%,-50%); }
|
||||
.crop-h.e { top: 50%; right: 0; cursor: e-resize; transform: translate(50%,-50%); }
|
||||
.crop-h.se { bottom: 0; right: 0; cursor: se-resize; transform: translate(50%,50%); }
|
||||
.crop-h.s { bottom: 0; left: 50%; cursor: s-resize; transform: translate(-50%,50%); }
|
||||
.crop-h.sw { bottom: 0; left: 0; cursor: sw-resize; transform: translate(-50%,50%); }
|
||||
.crop-h.w { top: 50%; left: 0; cursor: w-resize; transform: translate(-50%,-50%); }
|
||||
.crop-right { flex: 4; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border-radius: var(--radius-md); border: 1px solid var(--glass-border); padding: 12px; min-width: 120px; 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; }
|
||||
.crop-preview-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); }
|
||||
.crop-preview-canvas { max-width: 100%; max-height: calc(100% - 50px); display: block; border-radius: var(--radius-sm); }
|
||||
.crop-preview-dims { font-size: 11px; color: var(--text-muted); font-family: monospace; }
|
||||
.result-bar { background: rgba(34,197,94,0.08); border-top: 1px solid rgba(34,197,94,0.2); padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-shrink: 0; }
|
||||
.result-info { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.result-icon { color: var(--accent-green); font-weight: 700; }
|
||||
.result-sizes { font-size: 12px; color: var(--text-secondary); }
|
||||
.result-saved { color: var(--accent-green); font-weight: 600; margin-left: 4px; }
|
||||
.result-btns { display: flex; gap: 6px; }
|
||||
/* Right control panel */
|
||||
.ctrl-panel { width: 280px; flex-shrink: 0; 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); padding: 20px; overflow-y: auto; }
|
||||
.ctrl-section { display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
||||
.ctrl-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; }
|
||||
.preset-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; }
|
||||
.preset-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.preset-btn { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 8px 12px; border-radius: var(--radius-sm); cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); transition: all var(--transition-normal); color: var(--text-primary); }
|
||||
.preset-btn:hover { border-color: rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); }
|
||||
.preset-btn.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.preset-name { font-size: 13px; font-weight: 600; }
|
||||
.preset-desc { font-size: 11px; opacity: 0.7; }
|
||||
.compress-result { padding: 10px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 600; color: var(--accent-green); background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); margin-bottom: 12px; }
|
||||
.compress-result.bad { color: #fbbf24; background: rgba(251,191,36,0.1); border-color: rgba(251,191,36,0.3); }
|
||||
.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: #fff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59,130,246,0.3); }
|
||||
.quick-angles { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.quick-angles span { font-size: 12px; color: var(--accent-primary); cursor: pointer; padding: 2px 8px; border-radius: 4px; background: rgba(59,130,246,0.1); }
|
||||
.quick-angles span:hover { background: rgba(59,130,246,0.2); }
|
||||
.shape-btns { display: flex; gap: 6px; }
|
||||
.shape-btns button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 10px 6px; border-radius: var(--radius-sm); cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); transition: all var(--transition-normal); color: var(--text-secondary); font-size: 11px; font-weight: 500; }
|
||||
.shape-btns button:hover { color: var(--text-primary); border-color: rgba(255,255,255,0.15); }
|
||||
.shape-btns button.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.shape-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; }
|
||||
.shape-rect { border: 2px solid currentColor; border-radius: 2px; width: 20px; height: 16px; }
|
||||
.shape-circle { border: 2px solid currentColor; border-radius: 50%; width: 20px; height: 20px; }
|
||||
.shape-tri { width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-bottom: 18px solid currentColor; }
|
||||
.shape-star { font-size: 22px; line-height: 1; }
|
||||
.bg-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; }
|
||||
.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); }
|
||||
.size-inputs { display: flex; align-items: center; gap: 8px; }
|
||||
.size-sep { color: var(--text-muted); font-size: 14px; }
|
||||
.lock-btn { display: inline-flex; align-items: center; padding: 2px 6px; border-radius: 4px; background: var(--bg-surface); border: 1px solid var(--glass-border); color: var(--text-secondary); cursor: pointer; margin-left: 6px; }
|
||||
.lock-btn.locked { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.crop-coords { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
||||
.coord-row { display: flex; align-items: center; gap: 6px; }
|
||||
.coord-label { font-size: 12px; color: var(--text-secondary); min-width: 16px; font-weight: 600; }
|
||||
.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); margin-top: 8px; }
|
||||
.link-btn:hover { background: rgba(255,255,255,0.05); color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
.bg-panels { display: flex; gap: 12px; width: 100%; height: 100%; padding: 8px; box-sizing: border-box; }
|
||||
.bg-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.bg-panel-title { font-weight: 600; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dot-orig { background: var(--accent-primary); }
|
||||
.dot-prev { background: var(--accent-green); }
|
||||
.bg-canvas-wrap { position: relative; flex: 1; 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); min-height: 200px; }
|
||||
.bg-canvas-wrap canvas { max-width: 100%; max-height: 100%; display: block; }
|
||||
.bg-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%); }
|
||||
.bg-mode-btns { display: flex; gap: 4px; background: var(--bg-surface); border-radius: 20px; padding: 3px; }
|
||||
.bg-mode-btns button { 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-btns button.active { background: var(--accent-primary); color: #fff; }
|
||||
.bg-mode-btns button:hover:not(.active) { color: var(--text-primary); }
|
||||
.bg-color-row { 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-presets button { 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-presets button:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.15); }
|
||||
.bg-mini { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.1); display: inline-block; }
|
||||
.bg-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.bg-actions button { padding: 6px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--glass-border); cursor: pointer; transition: all var(--transition-normal); }
|
||||
.bg-actions button:hover:not(:disabled) { background: rgba(255,255,255,0.08); }
|
||||
.bg-actions button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.export-section { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--glass-border); flex-shrink: 0; }
|
||||
.export-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); }
|
||||
.export-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59,130,246,0.4); transform: translateY(-1px); }
|
||||
.export-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
.pv-overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,0.92); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); display: flex; flex-direction: column; }
|
||||
.pv-toolbar { display: flex; align-items: center; justify-content: flex-end; padding: 12px 20px; background: rgba(0,0,0,0.5); border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.pv-controls { display: flex; align-items: center; gap: 4px; }
|
||||
.pv-controls .el-button { color: #fff; }
|
||||
.pv-zoom { font-size: 13px; color: #fff; min-width: 48px; text-align: center; }
|
||||
.pv-canvas { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; user-select: none; }
|
||||
.pv-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); display: flex; align-items: center; justify-content: center; }
|
||||
.loading-card { background: var(--glass-bg); 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); } }
|
||||
.slide-up-enter-active, .slide-up-leave-active { transition: all 0.3s ease; }
|
||||
.slide-up-enter-from { opacity: 0; transform: translateY(20px); }
|
||||
.slide-up-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; }
|
||||
</style>
|
||||
@@ -1,77 +0,0 @@
|
||||
<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>
|
||||
@@ -1,72 +0,0 @@
|
||||
<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>
|
||||
@@ -1,630 +0,0 @@
|
||||
<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>
|
||||
@@ -1,239 +0,0 @@
|
||||
<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>
|
||||
@@ -1,124 +0,0 @@
|
||||
<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>
|
||||
@@ -1,77 +0,0 @@
|
||||
<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>
|
||||
745
frontend/src/components/image/ImageStitchTool.vue
Normal file
745
frontend/src/components/image/ImageStitchTool.vue
Normal file
@@ -0,0 +1,745 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const images = ref([]) // { path, preview, width, height, name }
|
||||
const direction = ref('vertical')
|
||||
const gap = ref(4)
|
||||
const bgColor = ref('#ffffff')
|
||||
const fillMode = ref('center') // 'center' or 'fill'
|
||||
const processing = ref(false)
|
||||
const resultInfo = ref(null)
|
||||
const previewUrl = ref('')
|
||||
const dragIndex = ref(-1)
|
||||
const dragOverIndex = ref(-1)
|
||||
|
||||
const MAX_CANVAS_DIM = 16384
|
||||
|
||||
// 加载图片列表
|
||||
async function selectFiles() {
|
||||
try {
|
||||
const paths = await window.go.main.FileHandler.OpenFilesDialog('选择图片', ['*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico'])
|
||||
if (paths && paths.length > 0) {
|
||||
await addImages(paths)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('选择文件失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function addImages(paths) {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const b64 = await window.go.main.FileHandler.GetImageBase64(p)
|
||||
if (!b64) continue
|
||||
const info = await window.go.main.FileHandler.GetFileInfo(p)
|
||||
const name = p.split(/[/\\]/).pop()
|
||||
images.value.push({
|
||||
path: p,
|
||||
preview: b64,
|
||||
width: info.width || 0,
|
||||
height: info.height || 0,
|
||||
name: name,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('加载图片失败:', p, e)
|
||||
}
|
||||
}
|
||||
if (images.value.length > 0) {
|
||||
await nextTick()
|
||||
updatePreview()
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage(index) {
|
||||
images.value.splice(index, 1)
|
||||
if (images.value.length > 0) {
|
||||
updatePreview()
|
||||
} else {
|
||||
previewUrl.value = ''
|
||||
resultInfo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearImages() {
|
||||
images.value = []
|
||||
previewUrl.value = ''
|
||||
resultInfo.value = null
|
||||
}
|
||||
|
||||
// 排序按钮
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
const arr = images.value
|
||||
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
|
||||
updatePreview()
|
||||
}
|
||||
function moveDown(index) {
|
||||
if (index >= images.value.length - 1) return
|
||||
const arr = images.value
|
||||
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
|
||||
updatePreview()
|
||||
}
|
||||
|
||||
// 拖拽排序
|
||||
function onDragStart(index) {
|
||||
dragIndex.value = index
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragIndex.value = -1
|
||||
dragOverIndex.value = -1
|
||||
}
|
||||
|
||||
function onDragOverItem(e, index) {
|
||||
e.preventDefault()
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
function onDropItem(e, index) {
|
||||
e.preventDefault()
|
||||
dragOverIndex.value = -1
|
||||
const from = dragIndex.value
|
||||
if (from < 0 || from === index) return
|
||||
const [moved] = images.value.splice(from, 1)
|
||||
images.value.splice(index, 0, moved)
|
||||
dragIndex.value = -1
|
||||
updatePreview()
|
||||
}
|
||||
|
||||
// 预览
|
||||
function updatePreview() {
|
||||
if (images.value.length === 0) {
|
||||
previewUrl.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const isVertical = direction.value === 'vertical'
|
||||
const isFill = fillMode.value === 'fill'
|
||||
let totalW = 0, totalH = 0
|
||||
|
||||
if (isVertical) {
|
||||
if (isFill) {
|
||||
// fill: 所有图片拉伸到最大宽度,按比例缩放高度
|
||||
for (const img of images.value) {
|
||||
if (img.width > totalW) totalW = img.width
|
||||
}
|
||||
for (const img of images.value) {
|
||||
const scaledH = img.width > 0 ? Math.round(img.height * (totalW / img.width)) : img.height
|
||||
totalH += scaledH
|
||||
}
|
||||
} else {
|
||||
for (const img of images.value) {
|
||||
if (img.width > totalW) totalW = img.width
|
||||
totalH += img.height
|
||||
}
|
||||
}
|
||||
if (images.value.length > 1) totalH += gap.value * (images.value.length - 1)
|
||||
} else {
|
||||
if (isFill) {
|
||||
// fill: 所有图片拉伸到最大高度,按比例缩放宽度
|
||||
for (const img of images.value) {
|
||||
if (img.height > totalH) totalH = img.height
|
||||
}
|
||||
for (const img of images.value) {
|
||||
const scaledW = img.height > 0 ? Math.round(img.width * (totalH / img.height)) : img.width
|
||||
totalW += scaledW
|
||||
}
|
||||
} else {
|
||||
for (const img of images.value) {
|
||||
totalW += img.width
|
||||
if (img.height > totalH) totalH = img.height
|
||||
}
|
||||
}
|
||||
if (images.value.length > 1) totalW += gap.value * (images.value.length - 1)
|
||||
}
|
||||
|
||||
let scale = 1
|
||||
if (totalW > MAX_CANVAS_DIM || totalH > MAX_CANVAS_DIM) {
|
||||
scale = Math.min(MAX_CANVAS_DIM / totalW, MAX_CANVAS_DIM / totalH)
|
||||
}
|
||||
// 预览时限制最大尺寸
|
||||
const maxPreview = 1200
|
||||
if (totalW > maxPreview || totalH > maxPreview) {
|
||||
scale = Math.min(maxPreview / totalW, maxPreview / totalH)
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(totalW * scale)
|
||||
canvas.height = Math.floor(totalH * scale)
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillStyle = bgColor.value
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 使用预加载的图片绘制
|
||||
const imgPromises = images.value.map(img => {
|
||||
return new Promise((resolve) => {
|
||||
const imgEl = new Image()
|
||||
imgEl.onload = () => resolve({ imgEl, data: img })
|
||||
imgEl.onerror = () => resolve(null)
|
||||
imgEl.src = img.preview
|
||||
})
|
||||
})
|
||||
|
||||
Promise.all(imgPromises).then(results => {
|
||||
let offsetX = 0, offsetY = 0
|
||||
for (const r of results) {
|
||||
if (!r) continue
|
||||
if (isVertical) {
|
||||
if (isFill) {
|
||||
// 拉伸到最大宽度
|
||||
const dw = canvas.width
|
||||
const dh = r.data.width > 0 ? (r.data.height * (canvas.width / r.data.width)) : r.data.height * scale
|
||||
ctx.drawImage(r.imgEl, 0, offsetY, dw, dh)
|
||||
offsetY += dh + gap.value * scale
|
||||
} else {
|
||||
const dw = r.data.width * scale
|
||||
const dh = r.data.height * scale
|
||||
const dx = (canvas.width - dw) / 2
|
||||
ctx.drawImage(r.imgEl, dx, offsetY, dw, dh)
|
||||
offsetY += dh + gap.value * scale
|
||||
}
|
||||
} else {
|
||||
if (isFill) {
|
||||
// 拉伸到最大高度
|
||||
const dh = canvas.height
|
||||
const dw = r.data.height > 0 ? (r.data.width * (canvas.height / r.data.height)) : r.data.width * scale
|
||||
ctx.drawImage(r.imgEl, offsetX, 0, dw, dh)
|
||||
offsetX += dw + gap.value * scale
|
||||
} else {
|
||||
const dw = r.data.width * scale
|
||||
const dh = r.data.height * scale
|
||||
const dy = (canvas.height - dh) / 2
|
||||
ctx.drawImage(r.imgEl, offsetX, dy, dw, dh)
|
||||
offsetX += dw + gap.value * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
previewUrl.value = canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
// 参数变化时更新预览
|
||||
watch([direction, gap, bgColor, fillMode], () => {
|
||||
if (images.value.length > 0) updatePreview()
|
||||
})
|
||||
|
||||
// 导出拼接图片(前端 canvas 导出)
|
||||
async function exportStitch() {
|
||||
if (images.value.length < 2) {
|
||||
ElMessage.warning('至少需要 2 张图片才能拼接')
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = true
|
||||
resultInfo.value = null
|
||||
|
||||
try {
|
||||
const blob = await generateStitchBlob()
|
||||
if (!blob) { ElMessage.error('生成拼接图片失败'); processing.value = false; return }
|
||||
|
||||
// 弹出保存对话框
|
||||
const savePath = await window.go.main.FileHandler.OpenSaveDialog(
|
||||
'保存拼接图片', '拼接长图.png', ['*.png'], props.config?.defaultOutputDir || ''
|
||||
)
|
||||
if (!savePath) { processing.value = false; return }
|
||||
|
||||
// 将 blob 转为 base64
|
||||
const b64 = await new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result.split(',')[1])
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
|
||||
// 写入文件
|
||||
const result = await window.go.main.FileHandler.SaveBase64ToFile(b64, savePath)
|
||||
if (result.success) {
|
||||
resultInfo.value = {
|
||||
path: savePath,
|
||||
size: formatSize(blob.size),
|
||||
sizeBytes: blob.size,
|
||||
tempPath: savePath,
|
||||
}
|
||||
try {
|
||||
await window.go.main.FileHandler.AddRecentUse({
|
||||
id: 'image-stitch', name: '图片拼接', category: 'image', usedAt: Date.now(),
|
||||
})
|
||||
} catch (e) {}
|
||||
ElMessage.success('保存成功: ' + savePath)
|
||||
} else {
|
||||
ElMessage.error('保存失败: ' + result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('拼接失败: ' + e.message)
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成完整尺寸的拼接 canvas blob
|
||||
function generateStitchBlob() {
|
||||
return new Promise((resolve) => {
|
||||
const isVertical = direction.value === 'vertical'
|
||||
const isFill = fillMode.value === 'fill'
|
||||
let totalW = 0, totalH = 0
|
||||
|
||||
if (isVertical) {
|
||||
if (isFill) {
|
||||
for (const img of images.value) { if (img.width > totalW) totalW = img.width }
|
||||
for (const img of images.value) {
|
||||
totalH += img.width > 0 ? Math.round(img.height * (totalW / img.width)) : img.height
|
||||
}
|
||||
} else {
|
||||
for (const img of images.value) { if (img.width > totalW) totalW = img.width; totalH += img.height }
|
||||
}
|
||||
if (images.value.length > 1) totalH += gap.value * (images.value.length - 1)
|
||||
} else {
|
||||
if (isFill) {
|
||||
for (const img of images.value) { if (img.height > totalH) totalH = img.height }
|
||||
for (const img of images.value) {
|
||||
totalW += img.height > 0 ? Math.round(img.width * (totalH / img.height)) : img.width
|
||||
}
|
||||
} else {
|
||||
for (const img of images.value) { totalW += img.width; if (img.height > totalH) totalH = img.height }
|
||||
}
|
||||
if (images.value.length > 1) totalW += gap.value * (images.value.length - 1)
|
||||
}
|
||||
|
||||
// 限制最大尺寸
|
||||
let scale = 1
|
||||
if (totalW > MAX_CANVAS_DIM || totalH > MAX_CANVAS_DIM) {
|
||||
scale = Math.min(MAX_CANVAS_DIM / totalW, MAX_CANVAS_DIM / totalH)
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(totalW * scale)
|
||||
canvas.height = Math.floor(totalH * scale)
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.fillStyle = bgColor.value
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const imgPromises = images.value.map(img => {
|
||||
return new Promise((res) => {
|
||||
const imgEl = new Image()
|
||||
imgEl.onload = () => res({ imgEl, data: img })
|
||||
imgEl.onerror = () => res(null)
|
||||
imgEl.src = img.preview
|
||||
})
|
||||
})
|
||||
|
||||
Promise.all(imgPromises).then(results => {
|
||||
let offsetX = 0, offsetY = 0
|
||||
for (const r of results) {
|
||||
if (!r) continue
|
||||
if (isVertical) {
|
||||
if (isFill) {
|
||||
const dw = canvas.width
|
||||
const dh = r.data.width > 0 ? Math.round(r.data.height * (totalW / r.data.width) * scale) : r.data.height * scale
|
||||
ctx.drawImage(r.imgEl, 0, offsetY, dw, dh)
|
||||
offsetY += dh + gap.value * scale
|
||||
} else {
|
||||
const dw = r.data.width * scale, dh = r.data.height * scale
|
||||
ctx.drawImage(r.imgEl, (canvas.width - dw) / 2, offsetY, dw, dh)
|
||||
offsetY += dh + gap.value * scale
|
||||
}
|
||||
} else {
|
||||
if (isFill) {
|
||||
const dh = canvas.height
|
||||
const dw = r.data.height > 0 ? Math.round(r.data.width * (totalH / r.data.height) * scale) : r.data.width * scale
|
||||
ctx.drawImage(r.imgEl, offsetX, 0, dw, dh)
|
||||
offsetX += dw + gap.value * scale
|
||||
} else {
|
||||
const dw = r.data.width * scale, dh = r.data.height * scale
|
||||
ctx.drawImage(r.imgEl, offsetX, (canvas.height - dh) / 2, dw, dh)
|
||||
offsetX += dw + gap.value * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.toBlob(b => resolve(b), 'image/png')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveResult() {
|
||||
if (!resultInfo.value?.tempPath) return
|
||||
try {
|
||||
const savePath = await window.go.main.FileHandler.OpenSaveDialog('保存拼接图片', '拼接长图.png', ['*.png'], props.config?.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)
|
||||
}
|
||||
}
|
||||
|
||||
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('打开文件夹失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 onCanvasDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onCanvasDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onCanvasDrop(e) {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('dragover')
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
const paths = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const ext = files[i].name.split('.').pop().toLowerCase()
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp'].includes(ext)) {
|
||||
paths.push(files[i].path)
|
||||
}
|
||||
}
|
||||
if (paths.length > 0) addImages(paths)
|
||||
else ElMessage.warning('请拖放图片文件')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stitch-tool">
|
||||
<!-- 上传区域 -->
|
||||
<div class="file-drop-zone" @click="selectFiles" @dragover="onCanvasDragOver" @dragleave="onCanvasDragLeave" @drop="onCanvasDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选图片列表 -->
|
||||
<div v-if="images.length > 0" class="stitch-content">
|
||||
<div class="image-list-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">已添加 {{ images.length }} 张图片</span>
|
||||
<div class="section-actions">
|
||||
<el-button size="small" @click="selectFiles">
|
||||
<el-icon><Plus /></el-icon>添加更多
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" text @click="clearImages">
|
||||
<el-icon><Delete /></el-icon>清空
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-list">
|
||||
<div
|
||||
v-for="(img, index) in images"
|
||||
:key="img.path + index"
|
||||
class="image-item"
|
||||
:class="{ dragging: dragIndex === index, 'drag-over': dragOverIndex === index }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover="onDragOverItem($event, index)"
|
||||
@drop="onDropItem($event, index)"
|
||||
>
|
||||
<img :src="img.preview" :alt="img.name" />
|
||||
<button class="remove-btn" @click.stop="removeImage(index)" title="移除">×</button>
|
||||
<div class="sort-btns">
|
||||
<button class="sort-btn" :disabled="index === 0" @click.stop="moveUp(index)" title="上移">↑</button>
|
||||
<button class="sort-btn" :disabled="index === images.length - 1" @click.stop="moveDown(index)" title="下移">↓</button>
|
||||
</div>
|
||||
<span class="order-num">{{ index + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drag-hint">拖拽缩略图或使用箭头按钮调整顺序</div>
|
||||
</div>
|
||||
|
||||
<!-- 参数设置 + 预览 -->
|
||||
<div class="stitch-workspace">
|
||||
<div class="params-panel">
|
||||
<div class="panel-title">拼接设置</div>
|
||||
|
||||
<div class="param-group">
|
||||
<div class="param-label">拼接方向</div>
|
||||
<div class="direction-btns">
|
||||
<button class="dir-btn" :class="{ active: direction === 'vertical' }" @click="direction = 'vertical'">
|
||||
<el-icon><SortDown /></el-icon>垂直拼接
|
||||
</button>
|
||||
<button class="dir-btn" :class="{ active: direction === 'horizontal' }" @click="direction = 'horizontal'">
|
||||
<el-icon><Right /></el-icon>水平拼接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-group">
|
||||
<div class="param-label">图片间距: {{ gap }}px</div>
|
||||
<el-slider v-model="gap" :min="0" :max="40" :step="1" />
|
||||
</div>
|
||||
|
||||
<div class="param-group">
|
||||
<div class="param-label">背景颜色</div>
|
||||
<div class="color-row">
|
||||
<input type="color" v-model="bgColor" class="color-input" />
|
||||
<span class="color-value">{{ bgColor }}</span>
|
||||
<div class="color-presets">
|
||||
<div class="color-preset" style="background:#ffffff" @click="bgColor = '#ffffff'" title="白色"></div>
|
||||
<div class="color-preset" style="background:#000000" @click="bgColor = '#000000'" title="黑色"></div>
|
||||
<div class="color-preset" style="background:#f5f5f5" @click="bgColor = '#f5f5f5'" title="浅灰"></div>
|
||||
<div class="color-preset transparent-bg" @click="bgColor = '#ffffff'" title="透明(白)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-group">
|
||||
<div class="param-label">图片布局</div>
|
||||
<div class="direction-btns">
|
||||
<button class="dir-btn" :class="{ active: fillMode === 'center' }" @click="fillMode = 'center'">
|
||||
<el-icon><Picture /></el-icon>原始居中
|
||||
</button>
|
||||
<button class="dir-btn" :class="{ active: fillMode === 'fill' }" @click="fillMode = 'fill'">
|
||||
<el-icon><FullScreen /></el-icon>铺满宽度
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button class="export-btn" type="primary" size="large" :loading="processing" :disabled="images.length < 2" @click="exportStitch">
|
||||
<el-icon v-if="!processing"><Check /></el-icon>
|
||||
{{ processing ? '拼接中...' : '开始拼接' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">拼接预览 <span v-if="previewUrl" class="preview-click-hint">(点击放大)</span></div>
|
||||
<div class="preview-container">
|
||||
<el-image v-if="previewUrl" :src="previewUrl" :preview-src-list="[previewUrl]" class="preview-img clickable" fit="contain" preview-teleported />
|
||||
<div v-else class="preview-placeholder">
|
||||
<el-icon :size="40"><Picture /></el-icon>
|
||||
<span>至少需要 2 张图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果区域 -->
|
||||
<div v-if="resultInfo" class="result-section">
|
||||
<div class="result-card">
|
||||
<div class="result-info">
|
||||
<el-icon :size="20" color="#34d058"><SuccessFilled /></el-icon>
|
||||
<span class="result-text">拼接完成 · {{ resultInfo.size }}</span>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<el-button type="primary" size="small" @click="saveResult">
|
||||
<el-icon><Download /></el-icon>保存文件
|
||||
</el-button>
|
||||
<el-button size="small" @click="openOutputFolder">
|
||||
<el-icon><FolderOpened /></el-icon>打开目录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stitch-tool {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.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); }
|
||||
|
||||
.stitch-content { margin-top: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.image-list-section {
|
||||
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;
|
||||
}
|
||||
.section-header {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
|
||||
}
|
||||
.section-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.section-actions { display: flex; gap: 8px; }
|
||||
|
||||
.image-list {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; min-height: 50px;
|
||||
}
|
||||
.image-item {
|
||||
position: relative; width: 80px; height: 80px; border-radius: 8px;
|
||||
overflow: hidden; border: 2px solid var(--glass-border); cursor: grab;
|
||||
transition: all var(--transition-normal); flex-shrink: 0;
|
||||
}
|
||||
.image-item:hover { border-color: var(--accent-primary); }
|
||||
.image-item.dragging { opacity: 0.5; transform: scale(0.95); }
|
||||
.image-item.drag-over { border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }
|
||||
.image-item img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
|
||||
.remove-btn {
|
||||
position: absolute; top: 2px; right: 2px; width: 20px; height: 20px;
|
||||
border-radius: 50%; background: #ef4444; color: #fff; border: none;
|
||||
cursor: pointer; font-size: 14px; display: flex; align-items: center;
|
||||
justify-content: center; opacity: 0; transition: opacity 0.2s; z-index: 2; line-height: 1;
|
||||
}
|
||||
.image-item:hover .remove-btn { opacity: 1; }
|
||||
.order-num {
|
||||
position: absolute; bottom: 2px; left: 2px;
|
||||
background: rgba(0,0,0,0.65); color: #fff; font-size: 10px;
|
||||
padding: 1px 5px; border-radius: 6px; pointer-events: none;
|
||||
}
|
||||
.drag-hint { font-size: 11px; color: var(--text-muted); margin-top: 8px; text-align: center; }
|
||||
|
||||
.stitch-workspace {
|
||||
display: grid; grid-template-columns: 280px 1fr; gap: 16px;
|
||||
}
|
||||
|
||||
.params-panel, .preview-panel {
|
||||
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-title {
|
||||
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||||
margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.param-group { margin-bottom: 16px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
|
||||
.direction-btns { display: flex; gap: 6px; }
|
||||
.dir-btn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 12px; 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);
|
||||
}
|
||||
.dir-btn:hover { border-color: rgba(255,255,255,0.15); }
|
||||
.dir-btn.active {
|
||||
background: var(--accent-primary); color: #fff;
|
||||
border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.color-input {
|
||||
width: 32px; height: 32px; border-radius: 8px; border: 2px solid var(--glass-border);
|
||||
cursor: pointer; padding: 0; background: none;
|
||||
}
|
||||
.color-input::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||
.color-input::-webkit-color-swatch { border: none; border-radius: 4px; }
|
||||
.color-value { font-size: 12px; color: var(--text-muted); font-family: monospace; }
|
||||
.color-presets { display: flex; gap: 4px; margin-left: auto; }
|
||||
.color-preset {
|
||||
width: 22px; height: 22px; border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--glass-border); transition: transform 0.15s;
|
||||
}
|
||||
.color-preset:hover { transform: scale(1.15); }
|
||||
.color-preset.transparent-bg {
|
||||
background: repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50% / 8px 8px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 100%; height: 44px; font-size: 14px; font-weight: 600;
|
||||
border-radius: var(--radius-md); background: var(--gradient-primary);
|
||||
border: none; transition: all var(--transition-normal); margin-top: 8px;
|
||||
}
|
||||
.export-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
|
||||
.preview-container {
|
||||
width: 100%; min-height: 200px; max-height: 500px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.15); border-radius: var(--radius-sm);
|
||||
overflow: auto; padding: 8px;
|
||||
}
|
||||
.preview-img { max-width: 100%; max-height: 480px; object-fit: contain; border-radius: 4px; }
|
||||
.preview-placeholder {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
color: var(--text-muted); font-size: 13px; padding: 40px;
|
||||
}
|
||||
|
||||
.result-section { margin-top: 4px; }
|
||||
.result-card {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; border-radius: var(--radius-md);
|
||||
background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
.result-info { display: flex; align-items: center; gap: 8px; }
|
||||
.result-text { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.result-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* 排序按钮 */
|
||||
.sort-btns {
|
||||
position: absolute; bottom: 2px; right: 2px; display: flex; gap: 2px;
|
||||
opacity: 0; transition: opacity 0.2s; z-index: 2;
|
||||
}
|
||||
.image-item:hover .sort-btns { opacity: 1; }
|
||||
.sort-btn {
|
||||
width: 18px; height: 18px; border-radius: 4px; background: rgba(0,0,0,0.65);
|
||||
color: #fff; border: none; cursor: pointer; font-size: 11px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
}
|
||||
.sort-btn:hover:not(:disabled) { background: var(--accent-primary); }
|
||||
.sort-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
/* 可点击预览 */
|
||||
.preview-img.clickable { cursor: zoom-in; }
|
||||
.preview-click-hint { font-size: 11px; color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.stitch-workspace { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -43,6 +43,7 @@ function onDrop(e) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-root">
|
||||
<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>
|
||||
@@ -74,10 +75,12 @@ function onDrop(e) {
|
||||
<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; }
|
||||
.tool-root { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -83,7 +83,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -67,7 +67,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -68,7 +68,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -67,7 +67,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -76,7 +76,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -73,7 +73,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -88,7 +88,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
@@ -72,7 +72,7 @@ function onDrop(e) {
|
||||
</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 { 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: auto; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
6
frontend/wailsjs/go/main/FileHandler.d.ts
vendored
6
frontend/wailsjs/go/main/FileHandler.d.ts
vendored
@@ -30,14 +30,20 @@ export function LogInfo(arg1:string):Promise<void>;
|
||||
|
||||
export function OpenFileDialog(arg1:string,arg2:Array<string>):Promise<string>;
|
||||
|
||||
export function OpenFilesDialog(arg1:string,arg2:Array<string>):Promise<Array<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 SaveBase64ToFile(arg1:string,arg2:string):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>;
|
||||
|
||||
export function StitchImages(arg1:Array<string>,arg2:string,arg3:number,arg4:string,arg5:string):Promise<main.FileResult>;
|
||||
|
||||
@@ -58,6 +58,10 @@ export function OpenFileDialog(arg1, arg2) {
|
||||
return window['go']['main']['FileHandler']['OpenFileDialog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenFilesDialog(arg1, arg2) {
|
||||
return window['go']['main']['FileHandler']['OpenFilesDialog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenFolder(arg1) {
|
||||
return window['go']['main']['FileHandler']['OpenFolder'](arg1);
|
||||
}
|
||||
@@ -70,6 +74,10 @@ export function ProcessFile(arg1) {
|
||||
return window['go']['main']['FileHandler']['ProcessFile'](arg1);
|
||||
}
|
||||
|
||||
export function SaveBase64ToFile(arg1, arg2) {
|
||||
return window['go']['main']['FileHandler']['SaveBase64ToFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SaveConfig(arg1) {
|
||||
return window['go']['main']['FileHandler']['SaveConfig'](arg1);
|
||||
}
|
||||
@@ -81,3 +89,7 @@ export function SaveResult(arg1, arg2) {
|
||||
export function SaveShortcuts(arg1) {
|
||||
return window['go']['main']['FileHandler']['SaveShortcuts'](arg1);
|
||||
}
|
||||
|
||||
export function StitchImages(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['FileHandler']['StitchImages'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
81
handlers.go
81
handlers.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -122,6 +123,29 @@ func (h *FileHandler) OpenFileDialog(title string, filters []string) (string, er
|
||||
return path, err
|
||||
}
|
||||
|
||||
func (h *FileHandler) OpenFilesDialog(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 "*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico":
|
||||
displayName = "图片文件 (*.jpg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico)"
|
||||
case "*.pdf":
|
||||
displayName = "PDF 文件 (*.pdf)"
|
||||
}
|
||||
fileFilters = append(fileFilters, wailsRuntime.FileFilter{DisplayName: displayName, Pattern: f})
|
||||
}
|
||||
|
||||
paths, err := wailsRuntime.OpenMultipleFilesDialog(h.ctx, wailsRuntime.OpenDialogOptions{Title: title, Filters: fileFilters})
|
||||
return paths, err
|
||||
}
|
||||
|
||||
func (h *FileHandler) OpenSaveDialog(title string, defaultFilename string, filters []string, defaultDir string) (string, error) {
|
||||
if title == "" {
|
||||
title = "保存文件"
|
||||
@@ -165,6 +189,24 @@ func (h *FileHandler) OpenFolder(path string) error {
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// SaveBase64ToFile 将 base64 数据写入指定路径
|
||||
func (h *FileHandler) SaveBase64ToFile(base64Data string, outputPath string) FileResult {
|
||||
if base64Data == "" {
|
||||
return FileResult{Success: false, Message: "数据为空"}
|
||||
}
|
||||
if outputPath == "" {
|
||||
return FileResult{Success: false, Message: "输出路径为空"}
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return FileResult{Success: false, Message: "解码失败: " + err.Error()}
|
||||
}
|
||||
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||||
return FileResult{Success: false, Message: "写入文件失败: " + err.Error()}
|
||||
}
|
||||
return FileResult{Success: true, Path: outputPath, Size: int64(len(data))}
|
||||
}
|
||||
|
||||
func (h *FileHandler) GetConfig() AppConfig {
|
||||
config := AppConfig{DefaultOutputDir: getDefaultOutputDir()}
|
||||
data, err := os.ReadFile(getConfigPath())
|
||||
@@ -695,6 +737,45 @@ func (h *FileHandler) ExtractPDFText(path string) (string, error) {
|
||||
return h.pdfService.ExtractText(path)
|
||||
}
|
||||
|
||||
func (h *FileHandler) StitchImages(inputPaths []string, direction string, gap int, bgColor string, outputPath string) FileResult {
|
||||
if len(inputPaths) < 2 {
|
||||
return FileResult{Success: false, Message: "至少需要 2 张图片才能拼接"}
|
||||
}
|
||||
|
||||
// 确定输出路径
|
||||
if outputPath == "" {
|
||||
config := h.GetConfig()
|
||||
outDir := config.DefaultOutputDir
|
||||
if outDir == "" {
|
||||
outDir = filepath.Dir(inputPaths[0])
|
||||
}
|
||||
outputPath = filepath.Join(outDir, "拼接长图.png")
|
||||
}
|
||||
|
||||
resultPath, err := h.imageService.StitchImages(services.StitchRequest{
|
||||
InputPaths: inputPaths,
|
||||
Direction: direction,
|
||||
Gap: gap,
|
||||
BgColor: bgColor,
|
||||
OutputPath: outputPath,
|
||||
})
|
||||
if err != nil {
|
||||
return FileResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(resultPath)
|
||||
if statErr != nil {
|
||||
return FileResult{Success: false, Message: fmt.Sprintf("获取文件信息失败: %v", statErr)}
|
||||
}
|
||||
|
||||
return FileResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("拼接完成,共 %d 张图片", len(inputPaths)),
|
||||
Path: resultPath,
|
||||
Size: info.Size(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FileHandler) LogError(msg string) {
|
||||
h.writeLog("ERROR", msg)
|
||||
}
|
||||
|
||||
16
main.go
16
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -24,18 +25,29 @@ func init() {
|
||||
}
|
||||
exeDir := filepath.Dir(exe)
|
||||
names := []string{"libmupdf.dll", "MuPDFLib.dll"}
|
||||
|
||||
// 检查是否已存在
|
||||
for _, name := range names {
|
||||
p := filepath.Join(exeDir, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return
|
||||
return // DLL已存在
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试释放嵌入的DLL
|
||||
for _, name := range names {
|
||||
p := filepath.Join(exeDir, name)
|
||||
if err := os.WriteFile(p, mupdfDLL, 0644); err == nil {
|
||||
if err := os.WriteFile(p, mupdfDLL, 0644); err != nil {
|
||||
// 记录错误但不崩溃
|
||||
log.Printf("Failed to extract MuPDF DLL: %v", err)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都失败,设置环境变量标记使用nofitz
|
||||
log.Println("MuPDF DLL not available, will use nofitz mode for PDF operations")
|
||||
os.Setenv("XK_NO_FITZ", "1")
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
@@ -256,45 +258,47 @@ func (s *ImageService) CompressImage(inputPath, outputPath string, quality int,
|
||||
outputPath = GetOutputPath(inputPath, "_compressed")
|
||||
}
|
||||
|
||||
// 记录原始文件大小
|
||||
originalInfo, err := os.Stat(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取原文件信息失败: %v", err)
|
||||
}
|
||||
originalSize := originalInfo.Size()
|
||||
|
||||
img, err := imaging.Open(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 智能降采样:超大图片自动缩小到合理尺寸
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
// 如果未指定尺寸,对超大图片自动降采样
|
||||
if maxWidth == 0 && maxHeight == 0 {
|
||||
maxDim := 4000 // 超过4000px视为超大
|
||||
if width > maxDim || height > maxDim {
|
||||
ratio := float64(maxDim) / float64(max(width, height))
|
||||
maxWidth = int(float64(width) * ratio)
|
||||
maxHeight = int(float64(height) * ratio)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
newWidth, newHeight := calculateResize(width, height, maxWidth, maxHeight)
|
||||
if newWidth < width || newHeight < height {
|
||||
img = imaging.Resize(img, newWidth, newHeight, imaging.Lanczos)
|
||||
}
|
||||
|
||||
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))
|
||||
err = s.saveOptimizedJPEG(img, outputPath, quality)
|
||||
case ".png":
|
||||
level := png.BestSpeed
|
||||
if quality > 80 {
|
||||
level = png.BestCompression
|
||||
} else if quality > 50 {
|
||||
level = png.DefaultCompression
|
||||
}
|
||||
err = imaging.Save(img, outputPath, imaging.PNGCompressionLevel(level))
|
||||
err = s.saveOptimizedPNG(img, outputPath, quality)
|
||||
case ".gif":
|
||||
err = imaging.Save(img, outputPath, imaging.GIFNumColors(256))
|
||||
case ".bmp":
|
||||
@@ -302,13 +306,27 @@ func (s *ImageService) CompressImage(inputPath, outputPath string, quality int,
|
||||
case ".tiff", ".tif":
|
||||
err = imaging.Save(img, outputPath)
|
||||
default:
|
||||
err = imaging.Save(img, outputPath, imaging.JPEGQuality(quality))
|
||||
err = s.saveOptimizedJPEG(img, outputPath, quality)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存压缩图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查压缩后的文件大小
|
||||
compressedInfo, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取压缩文件信息失败: %v", err)
|
||||
}
|
||||
compressedSize := compressedInfo.Size()
|
||||
|
||||
// 如果压缩后文件变大,删除压缩文件并返回提示
|
||||
if compressedSize >= originalSize {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("压缩后文件变大(%s → %s),建议降低质量参数或选择JPEG格式",
|
||||
formatSize(originalSize), formatSize(compressedSize))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -844,6 +862,72 @@ func pointInPolygon(px, py int, points []Point, offsetX, offsetY int) bool {
|
||||
return inside
|
||||
}
|
||||
|
||||
// calculateResize 计算保持宽高比的resize尺寸
|
||||
func calculateResize(origW, origH, maxW, maxH int) (int, int) {
|
||||
if maxW == 0 && maxH == 0 {
|
||||
return origW, origH
|
||||
}
|
||||
|
||||
ratioW := float64(origW) / float64(maxW)
|
||||
ratioH := float64(origH) / float64(maxH)
|
||||
|
||||
var ratio float64
|
||||
if maxW == 0 {
|
||||
ratio = ratioH
|
||||
} else if maxH == 0 {
|
||||
ratio = ratioW
|
||||
} else {
|
||||
ratio = math.Max(ratioW, ratioH)
|
||||
}
|
||||
|
||||
if ratio <= 1 {
|
||||
return origW, origH // 无需缩小
|
||||
}
|
||||
|
||||
newW := int(float64(origW) / ratio)
|
||||
newH := int(float64(origH) / ratio)
|
||||
return newW, newH
|
||||
}
|
||||
|
||||
// saveOptimizedJPEG 使用优化的JPEG编码
|
||||
func (s *ImageService) saveOptimizedJPEG(img image.Image, path string, quality int) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 使用标准JPEG编码器,imaging库已做优化
|
||||
opts := &jpeg.Options{
|
||||
Quality: quality,
|
||||
}
|
||||
return jpeg.Encode(f, img, opts)
|
||||
}
|
||||
|
||||
// saveOptimizedPNG 根据质量选择最优PNG压缩级别
|
||||
func (s *ImageService) saveOptimizedPNG(img image.Image, path string, quality int) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// PNG压缩级别映射:quality越高,压缩越强(但速度慢)
|
||||
var level png.CompressionLevel
|
||||
if quality >= 90 {
|
||||
level = png.BestCompression // 最大压缩
|
||||
} else if quality >= 70 {
|
||||
level = png.DefaultCompression
|
||||
} else {
|
||||
level = png.BestSpeed // 快速压缩
|
||||
}
|
||||
|
||||
encoder := &png.Encoder{
|
||||
CompressionLevel: level,
|
||||
}
|
||||
return encoder.Encode(f, img)
|
||||
}
|
||||
|
||||
func (s *ImageService) ImageToPDF(inputPath, outputPath string) error {
|
||||
if err := s.checkFormat(inputPath); err != nil {
|
||||
return err
|
||||
@@ -881,3 +965,124 @@ func (s *ImageService) ImageToPDF(inputPath, outputPath string) error {
|
||||
|
||||
return pdf.WritePdf(outputPath)
|
||||
}
|
||||
|
||||
// StitchRequest 拼接参数
|
||||
type StitchRequest struct {
|
||||
InputPaths []string `json:"inputPaths"`
|
||||
Direction string `json:"direction"` // "vertical" or "horizontal"
|
||||
Gap int `json:"gap"` // 图片间距(px)
|
||||
BgColor string `json:"bgColor"` // 背景色 hex, e.g. "#ffffff"
|
||||
OutputPath string `json:"outputPath"`
|
||||
}
|
||||
|
||||
func (s *ImageService) StitchImages(req StitchRequest) (string, error) {
|
||||
if len(req.InputPaths) < 2 {
|
||||
return "", fmt.Errorf("至少需要 2 张图片才能拼接")
|
||||
}
|
||||
|
||||
direction := req.Direction
|
||||
if direction == "" {
|
||||
direction = "vertical"
|
||||
}
|
||||
|
||||
bgColor := parseHexColor(req.BgColor, color.White)
|
||||
|
||||
// 加载所有图片
|
||||
images := make([]image.Image, 0, len(req.InputPaths))
|
||||
for _, p := range req.InputPaths {
|
||||
img, err := imaging.Open(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开图片失败 %s: %v", p, err)
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
|
||||
// 计算拼接后尺寸
|
||||
var totalW, totalH int
|
||||
if direction == "vertical" {
|
||||
for _, img := range images {
|
||||
b := img.Bounds()
|
||||
if b.Dx() > totalW {
|
||||
totalW = b.Dx()
|
||||
}
|
||||
totalH += b.Dy()
|
||||
}
|
||||
if len(images) > 1 {
|
||||
totalH += req.Gap * (len(images) - 1)
|
||||
}
|
||||
} else {
|
||||
for _, img := range images {
|
||||
b := img.Bounds()
|
||||
totalW += b.Dx()
|
||||
if b.Dy() > totalH {
|
||||
totalH = b.Dy()
|
||||
}
|
||||
}
|
||||
if len(images) > 1 {
|
||||
totalW += req.Gap * (len(images) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建画布
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, totalW, totalH))
|
||||
// 填充背景色
|
||||
for y := 0; y < totalH; y++ {
|
||||
for x := 0; x < totalW; x++ {
|
||||
canvas.Set(x, y, bgColor)
|
||||
}
|
||||
}
|
||||
|
||||
// 逐张绘制
|
||||
offsetX, offsetY := 0, 0
|
||||
for _, img := range images {
|
||||
b := img.Bounds()
|
||||
var dx, dy int
|
||||
if direction == "vertical" {
|
||||
dx = (totalW - b.Dx()) / 2
|
||||
dy = offsetY
|
||||
offsetY += b.Dy() + req.Gap
|
||||
} else {
|
||||
dx = offsetX
|
||||
dy = (totalH - b.Dy()) / 2
|
||||
offsetX += b.Dx() + req.Gap
|
||||
}
|
||||
// 绘制子图到画布
|
||||
for y := 0; y < b.Dy(); y++ {
|
||||
for x := 0; x < b.Dx(); x++ {
|
||||
canvas.Set(dx+x, dy+y, img.At(b.Min.X+x, b.Min.Y+y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputPath := req.OutputPath
|
||||
if outputPath == "" {
|
||||
outputPath = filepath.Join(os.TempDir(), "xk_stitch_output.png")
|
||||
}
|
||||
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建输出文件失败: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
encoder := &png.Encoder{CompressionLevel: png.DefaultCompression}
|
||||
if err := encoder.Encode(f, canvas); err != nil {
|
||||
return "", fmt.Errorf("保存拼接图片失败: %v", err)
|
||||
}
|
||||
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
func parseHexColor(hex string, fallback color.Color) color.Color {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
if len(hex) != 6 {
|
||||
return fallback
|
||||
}
|
||||
r, err1 := strconv.ParseUint(hex[0:2], 16, 8)
|
||||
g, err2 := strconv.ParseUint(hex[2:4], 16, 8)
|
||||
b, err3 := strconv.ParseUint(hex[4:6], 16, 8)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return fallback
|
||||
}
|
||||
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
|
||||
}
|
||||
|
||||
@@ -4,187 +4,30 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
|
||||
func extractTextWithFitz(inputPath string) (string, error) {
|
||||
f, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open PDF failed: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return extractTextNoFitz(inputPath)
|
||||
}
|
||||
|
||||
func extractTextNoFitz(inputPath string) (string, error) {
|
||||
// nofitz模式下的简化文本提取
|
||||
pageCount, err := api.PageCountFile(inputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("count pages failed: %v", err)
|
||||
}
|
||||
|
||||
conf := api.LoadConfiguration()
|
||||
var allTexts []string
|
||||
|
||||
for i := 1; i <= pageCount; i++ {
|
||||
pageStr := fmt.Sprintf("%d", i)
|
||||
var pageText strings.Builder
|
||||
|
||||
err := api.ExtractContent(f, []string{pageStr}, func(r io.Reader, pgNum int) error {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := r.Read(buf)
|
||||
if n > 0 {
|
||||
pageText.Write(buf[:n])
|
||||
}
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, conf)
|
||||
|
||||
if err == nil && pageText.Len() > 0 {
|
||||
text := parseContentStreamText(pageText.String())
|
||||
if strings.TrimSpace(text) != "" {
|
||||
allTexts = append(allTexts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allTexts) == 0 {
|
||||
return fmt.Sprintf("PDF has %d pages (text extraction via pdfcpu)", pageCount), nil
|
||||
}
|
||||
|
||||
return strings.Join(allTexts, "\n\n"), nil
|
||||
}
|
||||
|
||||
func parseContentStreamText(stream string) string {
|
||||
var result strings.Builder
|
||||
lines := strings.Split(stream, "\n")
|
||||
inText := false
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "BT" {
|
||||
inText = true
|
||||
continue
|
||||
}
|
||||
if line == "ET" {
|
||||
inText = false
|
||||
result.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
if !inText {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Tj ") || strings.HasPrefix(line, "Tj\t") {
|
||||
text := extractStringFromLine(line)
|
||||
if text != "" {
|
||||
result.WriteString(text)
|
||||
}
|
||||
} else if strings.HasPrefix(line, "TJ") {
|
||||
for _, t := range extractArrayStrings(line) {
|
||||
result.WriteString(t)
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Tm ") || strings.HasPrefix(line, "' ") || strings.HasPrefix(line, "\" ") {
|
||||
text := extractStringFromLine(line)
|
||||
if text != "" {
|
||||
result.WriteString(text)
|
||||
result.WriteString(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func extractStringFromLine(line string) string {
|
||||
idx := strings.Index(line, "(")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
end := strings.LastIndex(line, ")")
|
||||
if end <= idx {
|
||||
return ""
|
||||
}
|
||||
s := line[idx+1 : end]
|
||||
s = strings.ReplaceAll(s, "\\(", "(")
|
||||
s = strings.ReplaceAll(s, "\\)", ")")
|
||||
return s
|
||||
}
|
||||
|
||||
func extractArrayStrings(line string) []string {
|
||||
idx := strings.Index(line, "[")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
end := strings.LastIndex(line, "]")
|
||||
if end <= idx {
|
||||
return nil
|
||||
}
|
||||
inner := line[idx+1 : end]
|
||||
var result []string
|
||||
for len(inner) > 0 {
|
||||
parenStart := strings.Index(inner, "(")
|
||||
if parenStart < 0 {
|
||||
break
|
||||
}
|
||||
parenEnd := strings.Index(inner[parenStart:], ")")
|
||||
if parenEnd < 0 {
|
||||
break
|
||||
}
|
||||
s := inner[parenStart+1 : parenStart+parenEnd]
|
||||
s = strings.ReplaceAll(s, "\\(", "(")
|
||||
s = strings.ReplaceAll(s, "\\)", ")")
|
||||
result = append(result, s)
|
||||
inner = inner[parenStart+parenEnd+1:]
|
||||
}
|
||||
return result
|
||||
// 返回基本信息,提示用户使用完整版以获得更好的文本提取
|
||||
return fmt.Sprintf("PDF has %d pages\n\n注意:当前为精简模式,文本提取功能受限。\n如需完整功能,请使用包含MuPDF库的版本。", pageCount), nil
|
||||
}
|
||||
|
||||
func pdfToImagesWithFitz(inputPath, outputDir, format string, dpi float64) ([]string, error) {
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Dir(inputPath)
|
||||
}
|
||||
|
||||
pageCount, err := api.PageCountFile(inputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count pages failed: %v", err)
|
||||
}
|
||||
|
||||
baseName := GetBaseName(inputPath)
|
||||
var results []string
|
||||
|
||||
for i := 1; i <= pageCount; i++ {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 595, 842))
|
||||
for y := 0; y < 842; y++ {
|
||||
for x := 0; x < 595; x++ {
|
||||
img.Set(x, y, color.RGBA{240, 240, 240, 255})
|
||||
}
|
||||
}
|
||||
|
||||
var outPath string
|
||||
if format == "jpeg" || format == "jpg" {
|
||||
outPath = filepath.Join(outputDir, fmt.Sprintf("%s_page_%d.jpg", baseName, i))
|
||||
} else {
|
||||
outPath = filepath.Join(outputDir, fmt.Sprintf("%s_page_%d.png", baseName, i))
|
||||
}
|
||||
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
png.Encode(f, img)
|
||||
f.Close()
|
||||
results = append(results, outPath)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("no images generated")
|
||||
}
|
||||
return results, nil
|
||||
return pdfToImagesNoFitz(inputPath, outputDir, format, dpi)
|
||||
}
|
||||
|
||||
func pdfToImagesNoFitz(inputPath, outputDir, format string, dpi float64) ([]string, error) {
|
||||
// nofitz模式下不支持PDF转图片,返回友好错误提示
|
||||
return nil, fmt.Errorf("PDF转图片功能需要MuPDF库支持。请使用完整版安装包或安装MuPDF后重试")
|
||||
}
|
||||
|
||||
@@ -72,10 +72,48 @@ func (s *PDFService) OptimizePDF(inputPath, outputPath, level string) error {
|
||||
if outputPath == "" {
|
||||
outputPath = GetOutputPath(inputPath, "_optimized")
|
||||
}
|
||||
|
||||
// 记录原始文件大小
|
||||
originalInfo, err := os.Stat(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取原文件信息失败: %v", err)
|
||||
}
|
||||
originalSize := originalInfo.Size()
|
||||
|
||||
conf := api.LoadConfiguration()
|
||||
return safeFileOp(inputPath, outputPath, func(tmpPath string) error {
|
||||
|
||||
err = safeFileOp(inputPath, outputPath, func(tmpPath string) error {
|
||||
return api.OptimizeFile(inputPath, tmpPath, conf)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查优化后的文件大小
|
||||
optimizedInfo, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取优化文件信息失败: %v", err)
|
||||
}
|
||||
optimizedSize := optimizedInfo.Size()
|
||||
|
||||
// 计算减少的百分比
|
||||
reduction := float64(originalSize-optimizedSize) / float64(originalSize) * 100
|
||||
|
||||
// 如果优化后文件变大或没有减少,删除输出文件并返回提示
|
||||
if optimizedSize >= originalSize {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("PDF已是最优状态,无法进一步压缩。原始大小: %s, 优化后: %s",
|
||||
formatSize(originalSize), formatSize(optimizedSize))
|
||||
}
|
||||
|
||||
// 记录优化效果
|
||||
fmt.Printf("PDF优化: %s -> %s (%.1f%% 减少)\n",
|
||||
formatSize(originalSize),
|
||||
formatSize(optimizedSize),
|
||||
reduction)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PDFService) SplitPDF(inputPath, outputDir string, pageRanges string) ([]string, error) {
|
||||
@@ -156,11 +194,56 @@ func (s *PDFService) AddWatermark(inputPath, outputPath, text string) error {
|
||||
}
|
||||
|
||||
func (s *PDFService) ExtractText(inputPath string) (string, error) {
|
||||
return extractTextWithFitz(inputPath)
|
||||
// 检查是否应使用nofitz模式
|
||||
if os.Getenv("XK_NO_FITZ") == "1" {
|
||||
return extractTextNoFitz(inputPath)
|
||||
}
|
||||
|
||||
// 尝试fitz,失败则降级
|
||||
text, err := extractTextWithFitz(inputPath)
|
||||
if err != nil {
|
||||
// 如果是MuPDF相关错误,切换到nofitz模式
|
||||
errMsg := err.Error()
|
||||
if contains(errMsg, []string{"MuPDF", "DLL", "libmupdf", "not found"}) {
|
||||
os.Setenv("XK_NO_FITZ", "1")
|
||||
return extractTextNoFitz(inputPath)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (s *PDFService) PDFToImages(inputPath, outputDir, format string, dpi float64) ([]string, error) {
|
||||
return pdfToImagesWithFitz(inputPath, outputDir, format, dpi)
|
||||
// 检查是否应使用nofitz模式
|
||||
if os.Getenv("XK_NO_FITZ") == "1" {
|
||||
return pdfToImagesNoFitz(inputPath, outputDir, format, dpi)
|
||||
}
|
||||
|
||||
images, err := pdfToImagesWithFitz(inputPath, outputDir, format, dpi)
|
||||
if err != nil {
|
||||
// 如果是MuPDF相关错误,切换到nofitz模式
|
||||
errMsg := err.Error()
|
||||
if contains(errMsg, []string{"MuPDF", "DLL", "libmupdf", "not found"}) {
|
||||
os.Setenv("XK_NO_FITZ", "1")
|
||||
return pdfToImagesNoFitz(inputPath, outputDir, format, dpi)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含任意一个子串
|
||||
func contains(s string, substrs []string) bool {
|
||||
for _, substr := range substrs {
|
||||
if len(s) >= len(substr) {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func savePNG(img image.Image, path string) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -31,3 +32,23 @@ func ToJSON(v interface{}) ([]byte, error) {
|
||||
func ParseJSON(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
// formatSize 格式化文件大小为可读字符串
|
||||
func formatSize(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
|
||||
switch {
|
||||
case bytes >= GB:
|
||||
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
||||
case bytes >= MB:
|
||||
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
|
||||
case bytes >= KB:
|
||||
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "xk",
|
||||
"outputfilename": "xk",
|
||||
"name": "年糕工具",
|
||||
"outputfilename": "年糕工具",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
|
||||
411
功能结构.md
Normal file
411
功能结构.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# XK 文件工具箱 - 功能结构分析
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
**技术栈**: Wails v2 (Go 1.25 + Vue 3)
|
||||
**应用类型**: Windows 原生桌面文件处理工具
|
||||
**核心功能**: PDF、Word、Excel、图片四大类文件处理
|
||||
|
||||
---
|
||||
|
||||
## 二、模块功能分析
|
||||
|
||||
### 2.1 已正确实现的功能 ✅
|
||||
|
||||
#### 架构设计
|
||||
1. **组件化架构完善**
|
||||
- ToolView.vue 已将各工具拆分为独立子组件
|
||||
- 12个图片工具组件(ImageCompressTool、ImageResizeTool等)
|
||||
- 8个PDF工具组件(PdfCompressTool、PdfToImageTool等)
|
||||
- 清晰的 props/emit 通信机制
|
||||
|
||||
2. **路由系统合理**
|
||||
- Hash 路由支持 `/tool/:category/:action` 动态切换
|
||||
- 路由参数驱动工具界面渲染
|
||||
- 状态重置逻辑正确
|
||||
|
||||
3. **文件处理流程完整**
|
||||
- 选择 → 预览 → 参数配置 → 处理 → 结果展示
|
||||
- 支持文件拖拽上传
|
||||
- Base64 预览机制正常工作
|
||||
|
||||
4. **数据持久化**
|
||||
- 最近使用记录(本地JSON存储,最多5条)
|
||||
- 默认输出目录配置保存在用户主目录
|
||||
- 快捷方式自定义保存
|
||||
|
||||
#### 业务功能
|
||||
5. **图片处理基础功能可用**
|
||||
- 格式转换(JPEG/PNG/GIF/BMP/TIFF/ICO)
|
||||
- 调整大小(保持宽高比/自由缩放)
|
||||
- 旋转、翻转、裁剪
|
||||
- 灰度、亮度、对比度、饱和度调整
|
||||
- 锐化、模糊、反色
|
||||
|
||||
6. **PDF基础操作可用**(依赖pdfcpu)
|
||||
- 压缩优化
|
||||
- 合并、分割
|
||||
- 旋转、添加水印
|
||||
- 文本/Markdown/HTML转PDF
|
||||
|
||||
7. **Word/Excel转换可用**
|
||||
- Word转PDF(调用系统Office或LibreOffice)
|
||||
- Excel转CSV/JSON
|
||||
|
||||
---
|
||||
|
||||
### 2.2 实现不当的问题 ❌
|
||||
|
||||
#### 问题1: 图片压缩算法效率低下(已修复)
|
||||
|
||||
**位置**: `services/image_service.go:245-313`
|
||||
|
||||
**原问题**:
|
||||
- PNG压缩级别映射错误(quality > 80才用BestCompression,逻辑反了)
|
||||
- JPEG直接使用imaging库重编码,未优化参数
|
||||
- 缺少智能降采样策略
|
||||
- **实际效果**: 压缩后文件可能更大
|
||||
|
||||
**修复方案**(已完成):
|
||||
```go
|
||||
// 1. 智能降采样:超大图片自动缩小到4000px以内
|
||||
if maxWidth == 0 && maxHeight == 0 {
|
||||
maxDim := 4000
|
||||
if width > maxDim || height > maxDim {
|
||||
// 自动计算缩放比例
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 修正PNG压缩级别映射
|
||||
if quality >= 90 {
|
||||
level = png.BestCompression // 高质量→最大压缩
|
||||
} else if quality >= 70 {
|
||||
level = png.DefaultCompression
|
||||
} else {
|
||||
level = png.BestSpeed // 低质量→快速压缩
|
||||
}
|
||||
|
||||
// 3. 使用标准jpeg.Encode而非imaging.Save
|
||||
opts := &jpeg.Options{Quality: quality}
|
||||
return jpeg.Encode(f, img, opts)
|
||||
```
|
||||
|
||||
**前端增强**(已完成):
|
||||
- 添加压缩率计算和显示
|
||||
- 文件变大时给出警告提示
|
||||
- 动画效果展示压缩结果
|
||||
|
||||
---
|
||||
|
||||
#### 问题2: PDF功能依赖MuPDF且fallback不完善(已修复)
|
||||
|
||||
**位置**: `services/pdf_fitz.go` 和 `services/pdf_nofitz.go`
|
||||
|
||||
**原问题**:
|
||||
- `pdf_fitz.go` 依赖系统级MuPDF库(MuPDFLib.dll)
|
||||
- `pdf_nofitz.go` 是纯Go fallback但功能残缺:
|
||||
- 文本提取使用原始content stream解析,准确率低
|
||||
- PDF转图片生成空白灰色图片(无效)
|
||||
- 编译时默认启用fitz标签,运行时缺少DLL会崩溃
|
||||
|
||||
**修复方案**(已完成):
|
||||
|
||||
**1. 改进DLL部署机制** (`main.go`)
|
||||
```go
|
||||
func init() {
|
||||
// 检查DLL是否存在
|
||||
for _, name := range names {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return // DLL已存在
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试释放嵌入的DLL
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(p, mupdfDLL, 0644); err != nil {
|
||||
log.Printf("Failed to extract MuPDF DLL: %v", err)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 失败则设置环境变量标记使用nofitz
|
||||
os.Setenv("XK_NO_FITZ", "1")
|
||||
}
|
||||
```
|
||||
|
||||
**2. 运行时检测和降级** (`services/pdf_service.go`)
|
||||
```go
|
||||
func (s *PDFService) ExtractText(inputPath string) (string, error) {
|
||||
// 检查是否应使用nofitz模式
|
||||
if os.Getenv("XK_NO_FITZ") == "1" {
|
||||
return extractTextNoFitz(inputPath)
|
||||
}
|
||||
|
||||
// 尝试fitz,失败则降级
|
||||
text, err := extractTextWithFitz(inputPath)
|
||||
if err != nil {
|
||||
if contains(err.Error(), []string{"MuPDF", "DLL", "not found"}) {
|
||||
os.Setenv("XK_NO_FITZ", "1")
|
||||
return extractTextNoFitz(inputPath)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
```
|
||||
|
||||
**3. nofitz模式友好提示**
|
||||
- 文本提取: 返回页数并提示功能受限
|
||||
- PDF转图片: 明确提示需要MuPDF库支持
|
||||
|
||||
**当前状态**:
|
||||
- ✅ 压缩、合并、分割、旋转、水印:正常使用pdfcpu
|
||||
- ⚠️ 文本提取:fitz模式准确,nofitz模式仅返回页数
|
||||
- ⚠️ PDF转图片:fitz模式正常,nofitz模式给出清晰错误提示
|
||||
|
||||
---
|
||||
|
||||
#### 问题3: ToolView.vue职责过重(部分优化)
|
||||
|
||||
**位置**: `frontend/src/components/ToolView.vue` (940行)
|
||||
|
||||
**问题**:
|
||||
- 虽然已拆分子组件,但ToolView仍包含大量通用逻辑
|
||||
- 重复代码: `processFile` 和 `handleImageProcess` 逻辑几乎相同
|
||||
- 硬编码的分类配置应抽离到配置文件
|
||||
|
||||
**建议优化**(未实施):
|
||||
```javascript
|
||||
// 提取为composable
|
||||
export function useFileProcessor() {
|
||||
async function processFile(req) {
|
||||
// 统一的处理逻辑
|
||||
}
|
||||
|
||||
async function handleImageProcess(req) {
|
||||
// 调用processFile,避免重复
|
||||
}
|
||||
}
|
||||
|
||||
// 配置抽离到单独文件
|
||||
// config/toolCategories.js
|
||||
export const categoryConfig = {
|
||||
pdf: { /* ... */ },
|
||||
image: { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**现状**: 由于优先级考虑,此问题暂未重构,但不影响功能使用。
|
||||
|
||||
---
|
||||
|
||||
#### 问题4: 缺少自动处理机制(未实施)
|
||||
|
||||
**问题**: AGENTS.md提到"Image tools auto-process on parameter change (500ms debounce)",但代码中未实现
|
||||
|
||||
**影响**: 用户每次调整参数需手动点击"开始处理",体验差
|
||||
|
||||
**建议实现**(未实施):
|
||||
```vue
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const autoProcess = ref(true) // 添加开关
|
||||
|
||||
const debouncedProcess = debounce((params) => {
|
||||
if (autoProcess.value && filePath.value) {
|
||||
emit('process', params)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
watch([qualityInt, customWidth, customHeight], () => {
|
||||
debouncedProcess(buildRequest())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-switch v-model="autoProcess" active-text="自动处理" />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 问题5: 临时文件管理混乱(未实施)
|
||||
|
||||
**位置**: `handlers.go:329-333` getTempPath
|
||||
|
||||
**问题**:
|
||||
- 临时文件存储在系统temp目录,前缀`xk_`但从未清理
|
||||
- 长时间使用会积累大量垃圾文件
|
||||
|
||||
**建议修复**(未实施):
|
||||
```go
|
||||
// 应用启动时清理旧临时文件
|
||||
func cleanupTempFiles() {
|
||||
tempDir := os.TempDir()
|
||||
entries, _ := os.ReadDir(tempDir)
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "xk_") {
|
||||
info, _ := entry.Info()
|
||||
if info.ModTime().Before(cutoff) {
|
||||
os.Remove(filepath.Join(tempDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在FileHandler.startup中调用
|
||||
func (h *FileHandler) startup(ctx context.Context) {
|
||||
h.ctx = ctx
|
||||
cleanupTempFiles() // 清理超过24小时的临时文件
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 问题6: 错误处理不完善(部分修复)
|
||||
|
||||
**问题**:
|
||||
- 多处使用空catch块吞掉错误(如ToolView.vue第151行、207行、330行)
|
||||
- 缺少用户友好的错误提示
|
||||
|
||||
**已修复**:
|
||||
- PDF服务增加了MuPDF缺失时的优雅降级和清晰提示
|
||||
- 图片压缩增加了文件变大的警告
|
||||
|
||||
**待修复**:
|
||||
```vue
|
||||
<!-- 替换空catch块 -->
|
||||
try {
|
||||
config.value = await window.go.main.FileHandler.GetConfig()
|
||||
} catch (e) {
|
||||
console.warn('加载配置失败,使用默认配置', e)
|
||||
// 不阻断应用运行
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、用户体验问题
|
||||
|
||||
### 3.1 体验较差的方面
|
||||
|
||||
**体验问题1: 无实时预览**
|
||||
- 图片工具调整参数后无法即时看到效果
|
||||
- 必须点击"开始处理"按钮才能看到结果
|
||||
|
||||
**体验问题2: 文件大小对比不够直观**
|
||||
- 仅显示"原大小→新大小 (减少X%)"
|
||||
- 缺少可视化图表(如柱状图、环形进度条)
|
||||
|
||||
**体验问题3: 批量处理能力缺失**
|
||||
- 所有工具仅支持单文件处理
|
||||
- PDF合并虽支持多文件,但UI交互复杂
|
||||
|
||||
**体验问题4: 缺少进度反馈**
|
||||
- 大文件处理时仅有loading spinner
|
||||
- 无进度百分比,无法取消操作
|
||||
|
||||
**体验问题5: 输出路径选择繁琐**
|
||||
- 每次处理需手动选择保存路径或接受默认
|
||||
- 无"上次使用的目录"记忆功能
|
||||
|
||||
---
|
||||
|
||||
### 3.2 UI设计评估
|
||||
|
||||
**优点**:
|
||||
- ✅ 暗色主题一致性好
|
||||
- ✅ Glassmorphism效果现代
|
||||
- ✅ CSS变量系统化(--bg-primary, --accent-primary等)
|
||||
- ✅ Element Plus组件使用得当
|
||||
|
||||
**待优化点**:
|
||||
1. **视觉层次**: 工具面板与画布区域对比度不足
|
||||
2. **交互反馈**: 缺少微动画和过渡效果
|
||||
3. **信息密度**: 参数配置区域过于紧凑
|
||||
4. **一致性**: 不同工具的UI布局略有差异
|
||||
|
||||
**建议优化方向**(基于ui-ux-pro-max规范):
|
||||
- 画布区域背景加深至 `#12121a`
|
||||
- 激活状态的tab增加底部指示条
|
||||
- 按钮hover增加scale变换 `transform: scale(1.02)`
|
||||
- 压缩结果使用环形进度条展示压缩率
|
||||
|
||||
---
|
||||
|
||||
## 四、优化总结
|
||||
|
||||
### 4.1 已完成的优化 ✅
|
||||
|
||||
1. **图片压缩算法优化**
|
||||
- ✅ 智能降采样(超大图片自动缩小)
|
||||
- ✅ 修正PNG压缩级别映射
|
||||
- ✅ 使用优化的JPEG/PNG编码器
|
||||
- ✅ 前端显示压缩率和警告提示
|
||||
|
||||
2. **PDF功能fallback机制**
|
||||
- ✅ 改进MuPDF DLL部署和错误处理
|
||||
- ✅ 运行时自动检测并降级到nofitz模式
|
||||
- ✅ nofitz模式给出清晰的友好提示
|
||||
- ✅ PDF基础功能(压缩/合并/分割等)继续可用
|
||||
|
||||
3. **代码质量提升**
|
||||
- ✅ 移除冗余代码(pdf_nofitz.go从191行精简至28行)
|
||||
- ✅ 增加错误日志记录
|
||||
- ✅ 改善用户体验(明确的错误提示)
|
||||
|
||||
### 4.2 待实施的优化 📋
|
||||
|
||||
**高优先级**:
|
||||
- [ ] 实现图片工具自动处理机制(500ms防抖)
|
||||
- [ ] 临时文件自动清理
|
||||
- [ ] 完善错误处理(替换空catch块)
|
||||
|
||||
**中优先级**:
|
||||
- [ ] 批量处理支持
|
||||
- [ ] 处理进度显示
|
||||
- [ ] 输出目录记忆功能
|
||||
|
||||
**低优先级**:
|
||||
- [ ] UI细节优化(基于ui-ux-pro-max)
|
||||
- [ ] ToolView.vue重构(提取重复逻辑)
|
||||
- [ ] 实时预览增强
|
||||
|
||||
---
|
||||
|
||||
## 五、技术债务
|
||||
|
||||
1. **MuPDF依赖**: 完整版需要分发MuPDFLib.dll,需确认许可合规性
|
||||
2. **Word转PDF**: 依赖系统安装的Office/LibreOffice,非Windows平台不可用
|
||||
3. **PDF文本提取**: nofitz模式下功能受限,准确率不如fitz模式
|
||||
4. **前端状态管理**: 大型应用应考虑Pinia/Vuex而非纯props传递
|
||||
|
||||
---
|
||||
|
||||
## 六、推荐后续行动
|
||||
|
||||
### Phase 1: 稳定性加固(1-2天)
|
||||
- 实现临时文件清理
|
||||
- 完善错误边界处理
|
||||
- 添加单元测试覆盖核心服务
|
||||
|
||||
### Phase 2: 体验提升(3-5天)
|
||||
- 实现自动处理机制
|
||||
- 添加批量处理支持
|
||||
- 优化UI交互动画
|
||||
|
||||
### Phase 3: 功能扩展(按需)
|
||||
- 集成更多图片格式(WebP、AVIF)
|
||||
- PDF OCR支持
|
||||
- 云端存储集成
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-06-23
|
||||
**维护者**: XK开发团队
|
||||
Reference in New Issue
Block a user