Files
file-tool-wails/frontend/src/components/image/ImageCompressTool.vue
2026-06-17 08:05:20 +08:00

275 lines
9.9 KiB
Vue
Raw Blame History

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