初始化
This commit is contained in:
423
frontend/src/components/HomeView.vue
Normal file
423
frontend/src/components/HomeView.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const shortcuts = ref([])
|
||||
const recentUses = ref([])
|
||||
|
||||
const allTools = [
|
||||
{ id: 'pdf-compress', name: '压缩PDF', category: 'pdf', action: 'compress', icon: 'FolderChecked', color: '#f38ba8' },
|
||||
{ id: 'pdf-split', name: '分割PDF', category: 'pdf', action: 'split', icon: 'Document', color: '#f38ba8' },
|
||||
{ id: 'pdf-rotate', name: '旋转PDF', category: 'pdf', action: 'rotate', icon: 'RefreshRight', color: '#f38ba8' },
|
||||
{ id: 'pdf-watermark', name: '添加水印', category: 'pdf', action: 'watermark', icon: 'EditPen', color: '#f38ba8' },
|
||||
{ id: 'word-pdf', name: 'Word转PDF', category: 'word', action: 'pdf', icon: 'Document', color: '#89b4fa' },
|
||||
{ id: 'excel-csv', name: 'Excel转CSV', category: 'excel', action: 'csv', icon: 'Document', color: '#a6e3a1' },
|
||||
{ id: 'excel-json', name: 'Excel转JSON', category: 'excel', action: 'json', icon: 'Document', color: '#a6e3a1' },
|
||||
{ id: 'image-convert', name: '图片格式转换', category: 'image', action: 'convert', icon: 'Switch', color: '#f9e2af' },
|
||||
{ id: 'image-compress', name: '压缩图片', category: 'image', action: 'compress', icon: 'FolderChecked', color: '#f9e2af' },
|
||||
{ id: 'image-resize', name: '调整大小', category: 'image', action: 'resize', icon: 'FullScreen', color: '#f9e2af' },
|
||||
{ id: 'image-rotate', name: '旋转图片', category: 'image', action: 'rotate', icon: 'RefreshRight', color: '#f9e2af' },
|
||||
{ id: 'image-crop', name: '裁剪图片', category: 'image', action: 'crop', icon: 'Crop', color: '#f9e2af' },
|
||||
{ id: 'image-grayscale', name: '灰度处理', category: 'image', action: 'grayscale', icon: 'Moon', color: '#f9e2af' },
|
||||
{ id: 'image-brightness', name: '调整亮度', category: 'image', action: 'brightness', icon: 'Sunny', color: '#f9e2af' },
|
||||
{ id: 'image-removebg', name: '背景去除', category: 'image', action: 'removebg', icon: 'MagicStick', color: '#f9e2af' },
|
||||
{ id: 'convert-pdf-to-image', name: 'PDF转图片', category: 'convert', action: 'pdf-to-image', icon: 'Document', color: '#cba6f7' },
|
||||
{ id: 'convert-word-to-pdf', name: 'Word转PDF', category: 'convert', action: 'word-to-pdf', icon: 'Document', color: '#cba6f7' },
|
||||
{ id: 'convert-excel-to-csv', name: 'Excel转CSV', category: 'convert', action: 'excel-to-csv', icon: 'Document', color: '#cba6f7' },
|
||||
{ id: 'convert-excel-to-json', name: 'Excel转JSON', category: 'convert', action: 'excel-to-json', icon: 'Document', color: '#cba6f7' },
|
||||
{ id: 'convert-image-to-pdf', name: '图片转PDF', category: 'convert', action: 'image-to-pdf', icon: 'Picture', color: '#cba6f7' },
|
||||
]
|
||||
|
||||
const categoryNames = {
|
||||
pdf: 'PDF 工具',
|
||||
word: 'Word 工具',
|
||||
excel: 'Excel 工具',
|
||||
image: '图片工具',
|
||||
convert: '格式转换',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
shortcuts.value = await window.go.main.FileHandler.GetShortcuts()
|
||||
recentUses.value = await window.go.main.FileHandler.GetRecentUses()
|
||||
} catch (e) {
|
||||
shortcuts.value = allTools.slice(0, 5)
|
||||
recentUses.value = []
|
||||
}
|
||||
})
|
||||
|
||||
function navigateTo(tool) {
|
||||
if (tool.category === 'convert') {
|
||||
const parts = tool.action.split('-to-')
|
||||
const from = parts[0]
|
||||
const to = parts[1]
|
||||
const categoryMap = { pdf: 'pdf', word: 'word', excel: 'excel', image: 'image', csv: 'excel', json: 'excel' }
|
||||
router.push({ name: 'tool', params: { category: categoryMap[from] || from, action: to } })
|
||||
} else {
|
||||
router.push({ name: 'tool', params: { category: tool.category, action: tool.action } })
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToRecent(recent) {
|
||||
const tool = allTools.find(t => t.id === recent.id)
|
||||
if (tool) {
|
||||
navigateTo(tool)
|
||||
}
|
||||
}
|
||||
|
||||
function getToolById(id) {
|
||||
return allTools.find(t => t.id === id)
|
||||
}
|
||||
|
||||
async function toggleShortcut(tool) {
|
||||
const idx = shortcuts.value.findIndex(s => s.id === tool.id)
|
||||
if (idx >= 0) {
|
||||
shortcuts.value.splice(idx, 1)
|
||||
} else {
|
||||
shortcuts.value.push({ id: tool.id, name: tool.name, category: tool.category, icon: tool.icon })
|
||||
}
|
||||
try {
|
||||
await window.go.main.FileHandler.SaveShortcuts(shortcuts.value)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function isShortcut(toolId) {
|
||||
return shortcuts.value.some(s => s.id === toolId)
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="home-bg-orb orb-1"></div>
|
||||
<div class="home-bg-orb orb-2"></div>
|
||||
|
||||
<div class="home-header animate-slide-up">
|
||||
<div class="home-title">
|
||||
<el-icon :size="28"><SetUp /></el-icon>
|
||||
<span>XK 文件工具箱</span>
|
||||
</div>
|
||||
<el-button text @click="router.push('/settings')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span style="margin-left:4px">设置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="section animate-slide-up" style="animation-delay: 0.05s" v-if="shortcuts.length > 0">
|
||||
<div class="section-title">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>快捷入口</span>
|
||||
</div>
|
||||
<div class="shortcuts-grid">
|
||||
<div
|
||||
v-for="(shortcut, index) in shortcuts"
|
||||
:key="shortcut.id"
|
||||
class="shortcut-card"
|
||||
:style="{ animationDelay: `${index * 0.03}s` }"
|
||||
@click="navigateTo(getToolById(shortcut.id) || { category: shortcut.category, action: shortcut.id.split('-').slice(1).join('-') })"
|
||||
>
|
||||
<div class="shortcut-icon" :style="{ color: (getToolById(shortcut.id) || {}).color || '#89b4fa' }">
|
||||
<el-icon :size="26"><component :is="shortcut.icon || 'Document'" /></el-icon>
|
||||
</div>
|
||||
<div class="shortcut-name">{{ shortcut.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section animate-slide-up" style="animation-delay: 0.1s" v-if="recentUses.length > 0">
|
||||
<div class="section-title">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>最近使用</span>
|
||||
</div>
|
||||
<div class="recent-grid">
|
||||
<div
|
||||
v-for="recent in recentUses"
|
||||
:key="recent.id + recent.usedAt"
|
||||
class="recent-card"
|
||||
@click="navigateToRecent(recent)"
|
||||
>
|
||||
<div class="recent-icon" :style="{ color: (getToolById(recent.id) || {}).color || '#89b4fa' }">
|
||||
<el-icon :size="18"><component :is="(getToolById(recent.id) || {}).icon || 'Document'" /></el-icon>
|
||||
</div>
|
||||
<div class="recent-info">
|
||||
<div class="recent-name">{{ recent.name }}</div>
|
||||
<div class="recent-time">{{ formatTime(recent.usedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section animate-slide-up" style="animation-delay: 0.15s" v-for="(catKey, catIndex) in ['pdf', 'word', 'excel', 'image', 'convert']" :key="catKey">
|
||||
<div class="section-title">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>{{ categoryNames[catKey] }}</span>
|
||||
</div>
|
||||
<div class="tools-grid">
|
||||
<div
|
||||
v-for="(tool, index) in allTools.filter(t => t.category === catKey)"
|
||||
:key="tool.id"
|
||||
class="tool-card"
|
||||
:style="{ animationDelay: `${(catIndex * 0.05) + (index * 0.02)}s` }"
|
||||
@click="navigateTo(tool)"
|
||||
>
|
||||
<div class="tool-icon" :style="{ color: tool.color, background: `${tool.color}15` }">
|
||||
<el-icon :size="18"><component :is="tool.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="tool-name">{{ tool.name }}</div>
|
||||
<el-button
|
||||
class="star-btn"
|
||||
:type="isShortcut(tool.id) ? 'warning' : 'info'"
|
||||
:icon="isShortcut(tool.id) ? 'StarFilled' : 'Star'"
|
||||
circle
|
||||
size="small"
|
||||
@click.stop="toggleShortcut(tool)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 28px 36px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.home-bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--accent-primary);
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--accent-purple);
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 36px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.home-title .el-icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.section-title .el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shortcut-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
animation: slideUp var(--transition-slow) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shortcut-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.shortcut-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255,255,255,0.05);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.shortcut-card:hover .shortcut-icon {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.shortcut-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recent-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.recent-card:hover {
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.recent-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recent-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recent-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recent-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
animation: slideUp var(--transition-slow) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.tool-card:hover .tool-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.star-btn {
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-card:hover .star-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
227
frontend/src/components/SettingsView.vue
Normal file
227
frontend/src/components/SettingsView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const config = ref({ defaultOutputDir: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
config.value = await window.go.main.FileHandler.GetConfig()
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
async function selectDir() {
|
||||
try {
|
||||
const path = await window.go.main.FileHandler.OpenFileDialog('选择默认输出目录', ['*'])
|
||||
if (path) {
|
||||
config.value.defaultOutputDir = path.replace(/[/\\][^/\\]+$/, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
await window.go.main.FileHandler.SaveConfig(config.value)
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function resetConfig() {
|
||||
config.value.defaultOutputDir = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<div class="settings-header">
|
||||
<el-button text @click="router.push('/')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span style="margin-left:4px">返回</span>
|
||||
</el-button>
|
||||
<div class="settings-title">
|
||||
<el-icon :size="22"><Setting /></el-icon>
|
||||
<span>设置</span>
|
||||
</div>
|
||||
<div style="width:80px"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<div class="settings-card">
|
||||
<div class="card-title">文件输出</div>
|
||||
<div class="card-desc">配置处理后文件的默认保存位置</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">默认输出目录</div>
|
||||
<div class="form-row">
|
||||
<el-input
|
||||
v-model="config.defaultOutputDir"
|
||||
placeholder="默认: 与输入文件同目录"
|
||||
/>
|
||||
<el-button @click="selectDir">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
浏览
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-hint">留空则输出文件保存在与源文件相同的目录下</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="resetConfig">重置</el-button>
|
||||
<el-button type="primary" @click="saveConfig">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-title">关于</div>
|
||||
<div class="about-info">
|
||||
<div class="about-row">
|
||||
<span class="about-label">应用名称</span>
|
||||
<span class="about-value">XK 文件工具箱</span>
|
||||
</div>
|
||||
<div class="about-row">
|
||||
<span class="about-label">版本</span>
|
||||
<span class="about-value">1.0.0</span>
|
||||
</div>
|
||||
<div class="about-row">
|
||||
<span class="about-label">支持格式</span>
|
||||
<span class="about-value">PDF / Word / Excel / 图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-title .el-icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.settings-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.about-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.about-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.about-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.about-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.about-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
840
frontend/src/components/ToolView.vue
Normal file
840
frontend/src/components/ToolView.vue
Normal file
@@ -0,0 +1,840 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import ImageCompressTool from './image/ImageCompressTool.vue'
|
||||
import ImageResizeTool from './image/ImageResizeTool.vue'
|
||||
import ImageRotateTool from './image/ImageRotateTool.vue'
|
||||
import ImageCropTool from './image/ImageCropTool.vue'
|
||||
import ImageGrayscaleTool from './image/ImageGrayscaleTool.vue'
|
||||
import ImageBrightnessTool from './image/ImageBrightnessTool.vue'
|
||||
import ImageSharpenTool from './image/ImageSharpenTool.vue'
|
||||
import ImageBlurTool from './image/ImageBlurTool.vue'
|
||||
import ImageInvertTool from './image/ImageInvertTool.vue'
|
||||
import ImageConvertTool from './image/ImageConvertTool.vue'
|
||||
import ImageRemoveBgTool from './image/ImageRemoveBgTool.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const category = computed(() => route.params.category || 'pdf')
|
||||
const action = ref(route.params.action || '')
|
||||
|
||||
const filePath = ref('')
|
||||
const filePreview = ref('')
|
||||
const imageInfo = ref(null)
|
||||
const processing = ref(false)
|
||||
const resultInfo = ref(null)
|
||||
const outputPath = ref('')
|
||||
const compareImages = ref({ original: '', processed: '' })
|
||||
const config = ref({ defaultOutputDir: '' })
|
||||
|
||||
const quality = ref('medium')
|
||||
const watermarkText = ref('')
|
||||
const angle = ref(90)
|
||||
const outputFormat = ref('jpeg')
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewSrc = ref('')
|
||||
const previewIndex = ref(0)
|
||||
const previewScale = ref(1)
|
||||
const previewX = ref(0)
|
||||
const previewY = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ x: 0, y: 0 })
|
||||
|
||||
const categoryConfig = {
|
||||
pdf: {
|
||||
name: 'PDF 工具',
|
||||
actions: [
|
||||
{ id: 'compress', name: '压缩PDF', icon: 'FolderChecked', color: '#f38ba8' },
|
||||
{ id: 'split', name: '分割PDF', icon: 'Document', color: '#f38ba8' },
|
||||
{ id: 'rotate', name: '旋转PDF', icon: 'RefreshRight', color: '#f38ba8' },
|
||||
{ id: 'watermark', name: '添加水印', icon: 'EditPen', color: '#f38ba8' },
|
||||
],
|
||||
fileFilters: ['*.pdf'],
|
||||
},
|
||||
word: {
|
||||
name: 'Word 工具',
|
||||
actions: [
|
||||
{ id: 'pdf', name: 'Word转PDF', icon: 'Document', color: '#89b4fa' },
|
||||
],
|
||||
fileFilters: ['*.doc;*.docx'],
|
||||
},
|
||||
excel: {
|
||||
name: 'Excel 工具',
|
||||
actions: [
|
||||
{ id: 'csv', name: 'Excel转CSV', icon: 'Document', color: '#a6e3a1' },
|
||||
{ id: 'json', name: 'Excel转JSON', icon: 'Document', color: '#a6e3a1' },
|
||||
],
|
||||
fileFilters: ['*.xls;*.xlsx'],
|
||||
},
|
||||
image: {
|
||||
name: '图片工具',
|
||||
actions: [
|
||||
{ id: 'convert', name: '格式转换', icon: 'Switch', color: '#f9e2af' },
|
||||
{ id: 'compress', name: '压缩图片', icon: 'FolderChecked', color: '#f9e2af' },
|
||||
{ id: 'resize', name: '调整大小', icon: 'FullScreen', color: '#f9e2af' },
|
||||
{ id: 'rotate', name: '旋转图片', icon: 'RefreshRight', color: '#f9e2af' },
|
||||
{ id: 'crop', name: '裁剪图片', icon: 'Crop', color: '#f9e2af' },
|
||||
{ id: 'grayscale', name: '灰度处理', icon: 'Moon', color: '#f9e2af' },
|
||||
{ id: 'brightness', name: '调整亮度', icon: 'Sunny', color: '#f9e2af' },
|
||||
{ id: 'sharpen', name: '锐化', icon: 'Aim', color: '#f9e2af' },
|
||||
{ id: 'blur', name: '模糊', icon: 'View', color: '#f9e2af' },
|
||||
{ id: 'invert', name: '反色', icon: 'RefreshLeft', color: '#f9e2af' },
|
||||
{ id: 'removebg', name: '背景去除', icon: 'MagicStick', color: '#f9e2af' },
|
||||
],
|
||||
fileFilters: ['*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.tiff;*.webp;*.ico'],
|
||||
},
|
||||
}
|
||||
|
||||
const currentCat = computed(() => categoryConfig[category.value] || categoryConfig.pdf)
|
||||
const currentAction = computed(() => {
|
||||
return currentCat.value.actions.find(a => a.id === action.value) || currentCat.value.actions[0]
|
||||
})
|
||||
|
||||
const isImageProcessing = computed(() => {
|
||||
return category.value === 'image' && ['compress', 'resize', 'rotate', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert', 'removebg', 'crop'].includes(action.value)
|
||||
})
|
||||
|
||||
const qualityOptions = [
|
||||
{ id: 'high', name: '高质量' },
|
||||
{ id: 'medium', name: '中等' },
|
||||
{ id: 'low', name: '低质量' },
|
||||
]
|
||||
|
||||
const showQuality = computed(() => {
|
||||
return (category.value === 'pdf' && action.value === 'compress') ||
|
||||
(category.value === 'image' && action.value === 'compress')
|
||||
})
|
||||
|
||||
const showAngle = computed(() => {
|
||||
return (category.value === 'pdf' && action.value === 'rotate') ||
|
||||
(category.value === 'image' && action.value === 'rotate')
|
||||
})
|
||||
|
||||
const showWatermark = computed(() => category.value === 'pdf' && action.value === 'watermark')
|
||||
const showOutputFormat = computed(() => category.value === 'image' && action.value === 'convert')
|
||||
|
||||
watch(() => route.params, (p) => {
|
||||
action.value = p.action || currentCat.value.actions[0]?.id || ''
|
||||
resetState()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!action.value && currentCat.value.actions.length > 0) {
|
||||
action.value = currentCat.value.actions[0].id
|
||||
}
|
||||
try {
|
||||
config.value = await window.go.main.FileHandler.GetConfig()
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
filePath.value = ''
|
||||
filePreview.value = ''
|
||||
imageInfo.value = null
|
||||
resultInfo.value = null
|
||||
compareImages.value = { original: '', processed: '' }
|
||||
previewScale.value = 1
|
||||
previewX.value = 0
|
||||
previewY.value = 0
|
||||
filePreview.value = ''
|
||||
imageInfo.value = null
|
||||
}
|
||||
|
||||
function setAction(id) {
|
||||
action.value = id
|
||||
resetState()
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
try {
|
||||
const path = await window.go.main.FileHandler.OpenFileDialog('选择文件', currentCat.value.fileFilters)
|
||||
if (path) {
|
||||
filePath.value = path
|
||||
resetState()
|
||||
outputPath.value = ''
|
||||
await loadFilePreview()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('选择文件失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilePreview() {
|
||||
if (!filePath.value) return
|
||||
if (category.value !== 'image') return
|
||||
try {
|
||||
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
|
||||
if (b64) {
|
||||
filePreview.value = b64
|
||||
const info = await window.go.main.FileHandler.GetFileInfo(filePath.value)
|
||||
imageInfo.value = info
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载预览失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function getFileName() {
|
||||
if (!filePath.value) return '未选择文件'
|
||||
return filePath.value.split(/[/\\]/).pop()
|
||||
}
|
||||
|
||||
async function selectOutputPath() {
|
||||
try {
|
||||
const filters = getOutputFilters()
|
||||
const defaultName = getOutputDefaultName()
|
||||
const path = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, filters, config.value.defaultOutputDir || '')
|
||||
if (path) {
|
||||
outputPath.value = path
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function getOutputFilters() {
|
||||
if (category.value === 'pdf') return ['*.pdf']
|
||||
if (category.value === 'word') return ['*.pdf']
|
||||
if (category.value === 'excel') {
|
||||
return action.value === 'json' ? ['*.json'] : ['*.csv']
|
||||
}
|
||||
if (category.value === 'image') {
|
||||
if (action.value === 'convert') return ['*.jpg;*.jpeg', '*.png', '*.gif', '*.bmp', '*.tiff', '*.ico']
|
||||
return ['*.jpg;*.jpeg', '*.png']
|
||||
}
|
||||
return ['*']
|
||||
}
|
||||
|
||||
function getOutputDefaultName() {
|
||||
if (!filePath.value) return ''
|
||||
const name = filePath.value.split(/[/\\]/).pop()
|
||||
const base = name.replace(/\.[^.]+$/, '')
|
||||
return base + getOutputExt()
|
||||
}
|
||||
|
||||
function getOutputExt() {
|
||||
if (category.value === 'pdf') return '.pdf'
|
||||
if (category.value === 'word') return '.pdf'
|
||||
if (category.value === 'excel') return action.value === 'json' ? '.json' : '.csv'
|
||||
if (category.value === 'image') {
|
||||
if (action.value === 'convert') return '.' + outputFormat.value
|
||||
return '.png'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function processFile() {
|
||||
if (!filePath.value) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = true
|
||||
resultInfo.value = null
|
||||
compareImages.value = { original: '', processed: '' }
|
||||
|
||||
try {
|
||||
const req = {
|
||||
inputPath: filePath.value,
|
||||
outputPath: outputPath.value,
|
||||
format: action.value,
|
||||
quality: quality.value,
|
||||
angle: angle.value,
|
||||
text: watermarkText.value,
|
||||
outputFormat: outputFormat.value,
|
||||
}
|
||||
|
||||
const result = await window.go.main.FileHandler.ProcessFile(req)
|
||||
|
||||
if (result.success) {
|
||||
resultInfo.value = {
|
||||
path: result.path,
|
||||
size: formatSize(result.size),
|
||||
sizeBytes: result.size,
|
||||
originalSize: result.originalSize ? formatSize(result.originalSize) : '',
|
||||
originalSizeBytes: result.originalSize || 0,
|
||||
tempPath: result.path,
|
||||
isImageOp: isImageProcessing.value,
|
||||
}
|
||||
|
||||
if (isImageProcessing.value) {
|
||||
try {
|
||||
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
|
||||
compareImages.value.original = b64
|
||||
compareImages.value.processed = await window.go.main.FileHandler.GetImageBase64(result.path)
|
||||
if (!compareImages.value.processed) {
|
||||
compareImages.value.processed = b64
|
||||
}
|
||||
if (compareImages.value.processed) {
|
||||
filePreview.value = compareImages.value.processed
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
await window.go.main.FileHandler.AddRecentUse({
|
||||
id: category.value + '-' + action.value,
|
||||
name: currentAction.value.name,
|
||||
category: category.value,
|
||||
usedAt: Date.now(),
|
||||
})
|
||||
|
||||
ElMessage.success('处理完成')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('处理失败: ' + e.message)
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageProcess(req) {
|
||||
if (!filePath.value) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = true
|
||||
resultInfo.value = null
|
||||
compareImages.value = { original: '', processed: '' }
|
||||
|
||||
try {
|
||||
const result = await window.go.main.FileHandler.ProcessFile(req)
|
||||
|
||||
if (result.success) {
|
||||
resultInfo.value = {
|
||||
path: result.path,
|
||||
size: formatSize(result.size),
|
||||
sizeBytes: result.size,
|
||||
originalSize: result.originalSize ? formatSize(result.originalSize) : '',
|
||||
originalSizeBytes: result.originalSize || 0,
|
||||
tempPath: result.path,
|
||||
isImageOp: true,
|
||||
}
|
||||
|
||||
try {
|
||||
const b64 = await window.go.main.FileHandler.GetImageBase64(filePath.value)
|
||||
compareImages.value.original = b64
|
||||
compareImages.value.processed = await window.go.main.FileHandler.GetImageBase64(result.path)
|
||||
if (!compareImages.value.processed) {
|
||||
compareImages.value.processed = b64
|
||||
}
|
||||
if (compareImages.value.processed && isImageProcessing.value) {
|
||||
filePreview.value = compareImages.value.processed
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
await window.go.main.FileHandler.AddRecentUse({
|
||||
id: category.value + '-' + action.value,
|
||||
name: currentAction.value.name,
|
||||
category: category.value,
|
||||
usedAt: Date.now(),
|
||||
})
|
||||
|
||||
ElMessage.success('处理完成')
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('处理失败: ' + e.message)
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadResult() {
|
||||
if (!resultInfo.value?.tempPath) return
|
||||
try {
|
||||
const ext = getOutputExt()
|
||||
const defaultName = getOutputDefaultName() || ('output' + ext)
|
||||
const filters = getOutputFilters()
|
||||
const savePath = await window.go.main.FileHandler.OpenSaveDialog('保存文件', defaultName, filters, config.value.defaultOutputDir || '')
|
||||
if (savePath) {
|
||||
const result = await window.go.main.FileHandler.SaveResult(resultInfo.value.tempPath, savePath)
|
||||
if (result.success) {
|
||||
ElMessage.success('文件已保存到: ' + savePath)
|
||||
resultInfo.value.path = savePath
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function openPreview(src, index) {
|
||||
previewSrc.value = src
|
||||
previewIndex.value = index
|
||||
previewScale.value = 1
|
||||
previewX.value = 0
|
||||
previewY.value = 0
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
showPreview.value = false
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
previewScale.value = Math.max(0.1, Math.min(5, previewScale.value + delta))
|
||||
}
|
||||
|
||||
function onMouseDown(e) {
|
||||
if (e.button !== 0) return
|
||||
isDragging.value = true
|
||||
dragStart.value = { x: e.clientX - previewX.value, y: e.clientY - previewY.value }
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!isDragging.value) return
|
||||
previewX.value = e.clientX - dragStart.value.x
|
||||
previewY.value = e.clientY - dragStart.value.y
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function resetPreview() {
|
||||
previewScale.value = 1
|
||||
previewX.value = 0
|
||||
previewY.value = 0
|
||||
}
|
||||
|
||||
function switchPreview(dir) {
|
||||
previewIndex.value = dir
|
||||
previewSrc.value = dir === 0 ? compareImages.value.original : compareImages.value.processed
|
||||
resetPreview()
|
||||
}
|
||||
|
||||
async function openOutputFolder() {
|
||||
if (resultInfo.value?.path) {
|
||||
const dir = resultInfo.value.path.replace(/[/\\][^/\\]+$/, '')
|
||||
try {
|
||||
await window.go.main.FileHandler.OpenFolder(dir)
|
||||
} catch (e) {
|
||||
ElMessage.error('打开文件夹失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageToolProps = computed(() => ({
|
||||
filePath: filePath.value,
|
||||
filePreview: filePreview.value,
|
||||
imageInfo: imageInfo.value,
|
||||
originalWidth: imageInfo.value?.width || 0,
|
||||
originalHeight: imageInfo.value?.height || 0,
|
||||
processing: processing.value,
|
||||
resultInfo: resultInfo.value,
|
||||
outputPath: outputPath.value,
|
||||
config: config.value,
|
||||
}))
|
||||
|
||||
const isImageWorkspaceTool = computed(() => {
|
||||
return category.value === 'image' && ['compress', 'resize', 'rotate', 'crop', 'grayscale', 'brightness', 'sharpen', 'blur', 'invert'].includes(action.value) && !!filePath.value
|
||||
})
|
||||
|
||||
const isImageConvertTool = computed(() => category.value === 'image' && action.value === 'convert')
|
||||
const isImageRemoveBgTool = computed(() => category.value === 'image' && action.value === 'removebg')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-view">
|
||||
<div class="tool-header">
|
||||
<el-button text @click="router.push('/')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span style="margin-left:4px">返回</span>
|
||||
</el-button>
|
||||
<div class="tool-title">
|
||||
<el-icon :size="22" :style="{ color: currentAction?.color }">
|
||||
<component :is="currentAction?.icon || 'Document'" />
|
||||
</el-icon>
|
||||
<span>{{ currentAction?.name || currentCat.name }}</span>
|
||||
</div>
|
||||
<div style="width:80px"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-tabs">
|
||||
<div
|
||||
v-for="act in currentCat.actions"
|
||||
:key="act.id"
|
||||
class="action-tab"
|
||||
:class="{ active: action === act.id }"
|
||||
@click="setAction(act.id)"
|
||||
>
|
||||
<el-icon :size="15"><component :is="act.icon" /></el-icon>
|
||||
<span>{{ act.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-body">
|
||||
<ImageCompressTool v-if="action === 'compress' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageResizeTool v-else-if="action === 'resize' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageRotateTool v-else-if="action === 'rotate' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageCropTool v-else-if="action === 'crop' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageGrayscaleTool v-else-if="action === 'grayscale' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageBrightnessTool v-else-if="action === 'brightness' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageSharpenTool v-else-if="action === 'sharpen' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageBlurTool v-else-if="action === 'blur' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageInvertTool v-else-if="action === 'invert' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageConvertTool v-else-if="action === 'convert' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
<ImageRemoveBgTool v-else-if="action === 'removebg' && category === 'image'" v-bind="imageToolProps" @process="handleImageProcess" @selectFile="selectFile" />
|
||||
|
||||
<div v-else class="tool-main">
|
||||
<div class="file-drop-zone" @click="selectFile" :class="{ 'has-file': filePath }">
|
||||
<template v-if="!filePath">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择文件</div>
|
||||
<div class="drop-hint">支持拖拽文件到此处</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-info">
|
||||
<el-icon :size="28" :style="{ color: currentAction?.color }">
|
||||
<component :is="currentAction?.icon || 'Document'" />
|
||||
</el-icon>
|
||||
<div class="file-details">
|
||||
<div class="file-name">{{ getFileName() }}</div>
|
||||
<div class="file-path">{{ filePath }}</div>
|
||||
</div>
|
||||
<el-button type="danger" text @click.stop="filePath = ''; resetState()">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="params-section">
|
||||
<div class="param-group" v-if="showOutputFormat">
|
||||
<div class="param-label">目标格式</div>
|
||||
<div class="tag-group">
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'jpeg' }" @click="outputFormat = 'jpeg'">JPEG</div>
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'png' }" @click="outputFormat = 'png'">PNG</div>
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'gif' }" @click="outputFormat = 'gif'">GIF</div>
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'bmp' }" @click="outputFormat = 'bmp'">BMP</div>
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'tiff' }" @click="outputFormat = 'tiff'">TIFF</div>
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'ico' }" @click="outputFormat = 'ico'">ICO</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-group" v-if="showQuality && category === 'pdf'">
|
||||
<div class="param-label">质量</div>
|
||||
<div class="tag-group">
|
||||
<div v-for="q in qualityOptions" :key="q.id" class="tag-option" :class="{ active: quality === q.id }" @click="quality = q.id">{{ q.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-group" v-if="showAngle && category === 'pdf'">
|
||||
<div class="param-label">旋转角度: {{ angle }}°</div>
|
||||
<div class="tag-group">
|
||||
<div v-for="a in [0, 90, 180, 270]" :key="a" class="tag-option" :class="{ active: angle === a }" @click="angle = a">{{ a }}°</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-group" v-if="showWatermark">
|
||||
<div class="param-label">水印文字</div>
|
||||
<el-input v-model="watermarkText" placeholder="请输入水印文字" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-section">
|
||||
<div class="output-header">
|
||||
<span class="param-label">输出路径</span>
|
||||
<el-button text size="small" @click="outputPath = ''">重置为默认</el-button>
|
||||
</div>
|
||||
<div class="output-row">
|
||||
<el-input v-model="outputPath" :placeholder="config.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly />
|
||||
<el-button @click="selectOutputPath">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
浏览
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="processFile">
|
||||
<el-icon v-if="!processing"><Check /></el-icon>
|
||||
{{ processing ? '处理中...' : '开始处理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<div class="result-panel" v-if="resultInfo">
|
||||
<div class="result-header">
|
||||
<el-icon :size="20" style="color: #3fb950" class="success-icon"><SuccessFilled /></el-icon>
|
||||
<span>处理完成</span>
|
||||
</div>
|
||||
|
||||
<div class="compare-section" v-if="compareImages.original && compareImages.processed">
|
||||
<div class="compare-label">处理对比</div>
|
||||
<div class="compare-grid">
|
||||
<div class="compare-item" @click="openPreview(compareImages.original, 0)">
|
||||
<div class="compare-img-wrap">
|
||||
<img :src="compareImages.original" />
|
||||
<div class="compare-badge">原图</div>
|
||||
</div>
|
||||
<div class="compare-name">原始文件</div>
|
||||
</div>
|
||||
<div class="compare-item" @click="openPreview(compareImages.processed, 1)">
|
||||
<div class="compare-img-wrap processed">
|
||||
<img :src="compareImages.processed" />
|
||||
<div class="compare-badge">处理后</div>
|
||||
</div>
|
||||
<div class="compare-name">处理结果</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-body">
|
||||
<div class="result-row">
|
||||
<span class="result-label">输出文件</span>
|
||||
<span class="result-value">{{ resultInfo.path }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">文件大小</span>
|
||||
<span class="result-value">
|
||||
<template v-if="resultInfo.originalSize">
|
||||
<span class="size-compare">
|
||||
<span class="size-original">{{ resultInfo.originalSize }}</span>
|
||||
<span class="size-arrow">→</span>
|
||||
<span class="size-processed">{{ resultInfo.size }}</span>
|
||||
<span v-if="resultInfo.originalSizeBytes > resultInfo.sizeBytes" class="size-reduction">
|
||||
(减少 {{ Math.round((1 - resultInfo.sizeBytes / resultInfo.originalSizeBytes) * 100) }}%)
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ resultInfo.size }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<el-button v-if="resultInfo.isImageOp" type="primary" @click="downloadResult" size="large">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载保存
|
||||
</el-button>
|
||||
<el-button @click="openOutputFolder">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
打开目录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="preview-overlay" v-if="showPreview" @click.self="closePreview">
|
||||
<div class="preview-toolbar">
|
||||
<div class="preview-tabs" v-if="compareImages.original && compareImages.processed">
|
||||
<div class="preview-tab" :class="{ active: previewIndex === 0 }" @click="switchPreview(0)">原图</div>
|
||||
<div class="preview-tab" :class="{ active: previewIndex === 1 }" @click="switchPreview(1)">处理后</div>
|
||||
</div>
|
||||
<div class="preview-controls">
|
||||
<el-button text @click="previewScale = Math.max(0.1, previewScale - 0.2)">
|
||||
<el-icon><ZoomOut /></el-icon>
|
||||
</el-button>
|
||||
<span class="preview-zoom-label">{{ Math.round(previewScale * 100) }}%</span>
|
||||
<el-button text @click="previewScale = Math.min(5, previewScale + 0.2)">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</el-button>
|
||||
<el-button text @click="resetPreview">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
</el-button>
|
||||
<el-button text @click="closePreview" type="danger">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="preview-canvas"
|
||||
@wheel.prevent="onWheel"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
|
||||
>
|
||||
<img
|
||||
:src="previewSrc"
|
||||
:style="{
|
||||
transform: `translate(${previewX}px, ${previewY}px) scale(${previewScale})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.15s ease'
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="loading-overlay" v-if="processing">
|
||||
<div class="loading-card">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">处理中,请稍候...</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-view { height: 100%; display: flex; flex-direction: column; position: relative; }
|
||||
.tool-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 24px; background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.tool-title { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 600; color: var(--text-primary); }
|
||||
.action-tabs {
|
||||
display: flex; gap: 6px; padding: 10px 24px;
|
||||
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)); border-bottom: 1px solid var(--glass-border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.action-tab {
|
||||
display: flex; align-items: center; gap: 5px; padding: 7px 14px;
|
||||
border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
color: var(--text-secondary); background: transparent; border: 1px solid transparent;
|
||||
transition: all var(--transition-normal); white-space: nowrap; user-select: none;
|
||||
}
|
||||
.action-tab:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-primary); }
|
||||
.action-tab.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.tool-body { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.tool-main { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.file-drop-zone {
|
||||
border: 2px dashed var(--glass-border); border-radius: var(--radius-lg);
|
||||
padding: 40px; text-align: center; cursor: pointer;
|
||||
transition: all var(--transition-normal); margin-bottom: 24px;
|
||||
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
}
|
||||
.file-drop-zone:hover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.file-drop-zone.has-file { border-style: solid; border-color: var(--glass-border); padding: 12px 16px; }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.file-info { display: flex; align-items: center; gap: 14px; text-align: left; }
|
||||
.file-details { flex: 1; min-width: 0; }
|
||||
.file-name { font-size: 14px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.file-path { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.params-section { margin-bottom: 20px; }
|
||||
.param-group { margin-bottom: 16px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag-option {
|
||||
display: flex; align-items: center; gap: 4px; padding: 7px 14px;
|
||||
border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; background: var(--bg-surface); color: var(--text-secondary);
|
||||
border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none;
|
||||
}
|
||||
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
|
||||
.output-section { margin-bottom: 24px; }
|
||||
.output-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
|
||||
.process-btn {
|
||||
width: 100%; height: 48px; font-size: 15px; font-weight: 600;
|
||||
border-radius: var(--radius-md); background: var(--gradient-primary); border: none;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
|
||||
.result-panel {
|
||||
max-width: 640px; margin: 24px auto 0; background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid rgba(34, 197, 94, 0.3); border-radius: var(--radius-lg); padding: 20px;
|
||||
}
|
||||
.result-header { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; color: var(--accent-green); margin-bottom: 16px; }
|
||||
.compare-section { margin-bottom: 16px; }
|
||||
.compare-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 10px; }
|
||||
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.compare-item { cursor: pointer; text-align: center; }
|
||||
.compare-img-wrap {
|
||||
border-radius: var(--radius-md); overflow: hidden; border: 2px solid var(--glass-border);
|
||||
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-primary); transition: all var(--transition-normal); position: relative;
|
||||
}
|
||||
.compare-img-wrap:hover { border-color: var(--accent-primary); box-shadow: 0 0 12px rgba(59, 130, 246, 0.2); }
|
||||
.compare-img-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.compare-badge {
|
||||
position: absolute; top: 8px; left: 8px; padding: 3px 10px; border-radius: var(--radius-sm);
|
||||
font-size: 11px; font-weight: 600; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px); color: #fff;
|
||||
}
|
||||
.compare-name { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||||
.result-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--glass-border); }
|
||||
.result-row:last-child { border-bottom: none; }
|
||||
.result-label { font-size: 13px; color: var(--text-secondary); }
|
||||
.result-value { font-size: 13px; color: var(--text-primary); text-align: right; max-width: 70%; word-break: break-all; }
|
||||
.result-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.result-actions .el-button { flex: 1; }
|
||||
.success-icon { animation: pop-in 0.4s ease-out; }
|
||||
@keyframes pop-in { 0% { transform: scale(0); opacity: 0; } 60% { transform: scale(1.3); } 100% { transform: scale(1); opacity: 1; } }
|
||||
|
||||
.preview-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.preview-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; background: rgba(0, 0, 0, 0.5); border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
|
||||
.preview-tabs { display: flex; gap: 4px; background: rgba(255, 255, 255, 0.1); border-radius: var(--radius-sm); padding: 3px; }
|
||||
.preview-tab { padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 500; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-normal); }
|
||||
.preview-tab:hover { color: var(--text-primary); }
|
||||
.preview-tab.active { background: var(--accent-primary); color: #ffffff; }
|
||||
.preview-controls { display: flex; align-items: center; gap: 4px; }
|
||||
.preview-controls .el-button { color: #fff; }
|
||||
.preview-zoom-label { font-size: 13px; color: #fff; min-width: 48px; text-align: center; }
|
||||
.preview-canvas { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; user-select: none; }
|
||||
.preview-canvas img { max-width: 90%; max-height: 90%; object-fit: contain; border-radius: var(--radius-sm); }
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed; inset: 0; z-index: 999; background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.loading-card {
|
||||
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl); padding: 40px 48px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px; height: 48px; border: 3px solid var(--bg-surface);
|
||||
border-top-color: var(--accent-primary); border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-text { font-size: 14px; color: var(--text-secondary); }
|
||||
|
||||
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s ease; }
|
||||
.fade-slide-enter-from { opacity: 0; transform: translateY(20px); }
|
||||
.fade-slide-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.size-compare { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.size-original { color: var(--text-secondary); }
|
||||
.size-arrow { color: var(--text-muted); font-size: 12px; }
|
||||
.size-processed { color: var(--accent-green); font-weight: 600; }
|
||||
.size-reduction { color: var(--accent-green); font-size: 12px; font-weight: 500; }
|
||||
</style>
|
||||
77
frontend/src/components/image/ImageBlurTool.vue
Normal file
77
frontend/src/components/image/ImageBlurTool.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const blurRadius = ref(3)
|
||||
|
||||
function buildRequest() {
|
||||
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'blur', blurRadius: blurRadius.value }
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
|
||||
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section"><div class="panel-title">模糊设置</div>
|
||||
<div class="param-group"><div class="param-label">模糊半径: {{ blurRadius }}</div><el-slider v-model="blurRadius" :min="0" :max="20" :step="0.5" /><div class="slider-hint"><span>无模糊</span><span>最强模糊</span></div></div>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
80
frontend/src/components/image/ImageBrightnessTool.vue
Normal file
80
frontend/src/components/image/ImageBrightnessTool.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const brightnessVal = ref(0)
|
||||
const contrastVal = ref(0)
|
||||
const saturationVal = ref(0)
|
||||
|
||||
function buildRequest() {
|
||||
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'brightness', brightness: brightnessVal.value, contrast: contrastVal.value, saturation: saturationVal.value }
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
|
||||
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section"><div class="panel-title">调整亮度</div>
|
||||
<div class="param-group"><div class="param-label">亮度: {{ brightnessVal > 0 ? '+' : '' }}{{ brightnessVal }}</div><el-slider v-model="brightnessVal" :min="-100" :max="100" :step="1" /></div>
|
||||
<div class="param-group"><div class="param-label">对比度: {{ contrastVal > 0 ? '+' : '' }}{{ contrastVal }}</div><el-slider v-model="contrastVal" :min="-100" :max="100" :step="1" /></div>
|
||||
<div class="param-group"><div class="param-label">饱和度: {{ saturationVal > 0 ? '+' : '' }}{{ saturationVal }}</div><el-slider v-model="saturationVal" :min="-100" :max="100" :step="1" /></div>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
274
frontend/src/components/image/ImageCompressTool.vue
Normal file
274
frontend/src/components/image/ImageCompressTool.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const outputFormat = ref('jpeg')
|
||||
const qualityInt = ref(75)
|
||||
const aspectLocked = ref(false)
|
||||
const customWidth = ref(800)
|
||||
const customHeight = ref(600)
|
||||
|
||||
watch(() => props.originalWidth, (w) => {
|
||||
if (w) customWidth.value = w
|
||||
})
|
||||
watch(() => props.originalHeight, (h) => {
|
||||
if (h) customHeight.value = h
|
||||
})
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
inputPath: props.filePath,
|
||||
outputPath: props.outputPath,
|
||||
format: 'compress',
|
||||
qualityInt: qualityInt.value,
|
||||
width: aspectLocked.value ? customWidth.value : 0,
|
||||
height: aspectLocked.value ? customHeight.value : 0,
|
||||
outputFormat: outputFormat.value,
|
||||
}
|
||||
}
|
||||
|
||||
function onProcess() {
|
||||
emit('process', buildRequest())
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function getFileName() {
|
||||
if (!props.filePath) return ''
|
||||
return props.filePath.split(/[/\\]/).pop()
|
||||
}
|
||||
|
||||
function onDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('dragover')
|
||||
}
|
||||
|
||||
function onDragLeave(e) {
|
||||
e.currentTarget.classList.remove('dragover')
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('dragover')
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
const ext = files[0].name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) {
|
||||
ElMessage.warning('请选择图片文件')
|
||||
return
|
||||
}
|
||||
emit('selectFile', files[0].path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-wrapper">
|
||||
<img v-if="filePreview" :src="filePreview" class="preview-img" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-info-bar">
|
||||
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
|
||||
<span class="image-filename">{{ getFileName() }}</span>
|
||||
<el-button type="danger" text size="small" @click="$emit('selectFile')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-panel">
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">压缩设置</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">输出格式</div>
|
||||
<div class="tag-group">
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'jpeg' }" @click="outputFormat = 'jpeg'">JPEG</div>
|
||||
<div class="tag-option" :class="{ active: outputFormat === 'png' }" @click="outputFormat = 'png'">PNG</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">质量: {{ qualityInt }}</div>
|
||||
<el-slider v-model="qualityInt" :min="1" :max="100" :step="1" />
|
||||
<div class="slider-hint"><span>最小</span><span>最大</span></div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<button class="aspect-lock-btn" :class="{ locked: aspectLocked }" @click="aspectLocked = !aspectLocked">
|
||||
<el-icon><Lock v-if="aspectLocked" /><Unlock v-else /></el-icon>
|
||||
{{ aspectLocked ? '锁定比例' : '自由比例' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="aspectLocked" class="param-group">
|
||||
<div class="param-label">尺寸 (宽 × 高)</div>
|
||||
<div class="size-inputs">
|
||||
<el-input-number v-model="customWidth" :min="1" :max="10000" size="small" controls-position="right" />
|
||||
<span class="size-sep">×</span>
|
||||
<el-input-number v-model="customHeight" :min="1" :max="10000" size="small" controls-position="right" />
|
||||
<span class="size-unit">px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resultInfo && resultInfo.isImageOp" class="estimated-size">
|
||||
<span class="estimated-label">处理后大小:</span> {{ resultInfo.size }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">输出路径</div>
|
||||
<div class="output-row">
|
||||
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
|
||||
<el-icon v-if="!processing"><Check /></el-icon>
|
||||
{{ processing ? '处理中...' : '开始处理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone {
|
||||
border: 2px dashed var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 80px 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.image-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 160px);
|
||||
max-height: 800px;
|
||||
}
|
||||
.image-canvas-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1a25;
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
}
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--glass-bg);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title {
|
||||
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||||
margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag-option {
|
||||
display: flex; align-items: center; gap: 4px; padding: 7px 14px;
|
||||
border-radius: var(--radius-sm); font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; background: var(--bg-surface); color: var(--text-secondary);
|
||||
border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none;
|
||||
}
|
||||
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.aspect-lock-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;
|
||||
cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border);
|
||||
color: var(--text-secondary); transition: all var(--transition-normal);
|
||||
}
|
||||
.aspect-lock-btn.locked { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.size-inputs { display: flex; align-items: center; gap: 8px; }
|
||||
.size-sep { color: var(--text-muted); font-size: 14px; }
|
||||
.size-unit { color: var(--text-muted); font-size: 13px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn {
|
||||
width: 100%; height: 48px; font-size: 15px; font-weight: 600;
|
||||
border-radius: var(--radius-md); background: var(--gradient-primary); border: none;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
.estimated-size {
|
||||
margin-top: 8px; padding: 8px 12px;
|
||||
background: rgba(59, 130, 246, 0.08); border-radius: var(--radius-sm);
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
}
|
||||
.estimated-label { font-weight: 500; }
|
||||
</style>
|
||||
126
frontend/src/components/image/ImageConvertTool.vue
Normal file
126
frontend/src/components/image/ImageConvertTool.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const imageFormats = [
|
||||
{ id: 'jpeg', name: 'JPEG' },
|
||||
{ id: 'png', name: 'PNG' },
|
||||
{ id: 'gif', name: 'GIF' },
|
||||
{ id: 'bmp', name: 'BMP' },
|
||||
{ id: 'tiff', name: 'TIFF' },
|
||||
{ id: 'ico', name: 'ICO' },
|
||||
]
|
||||
|
||||
const outputFormat = ref('jpeg')
|
||||
const qualityInt = ref(75)
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
inputPath: props.filePath,
|
||||
outputPath: props.outputPath,
|
||||
format: 'convert',
|
||||
outputFormat: outputFormat.value,
|
||||
qualityInt: outputFormat.value === 'jpeg' ? qualityInt.value : 0,
|
||||
}
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="tool-main">
|
||||
<div class="file-selected-bar" @click="$emit('selectFile')">
|
||||
<img v-if="filePreview" :src="filePreview" class="file-preview-img" />
|
||||
<div class="file-preview-info">
|
||||
<div class="file-name">{{ getFileName() }}</div>
|
||||
<div v-if="imageInfo" class="file-meta">{{ imageInfo.width }}×{{ imageInfo.height }} · {{ formatSize(imageInfo.size) }}</div>
|
||||
</div>
|
||||
<el-button type="danger" text @click.stop="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-section">
|
||||
<div class="param-group">
|
||||
<div class="param-label">目标格式</div>
|
||||
<div class="tag-group">
|
||||
<div v-for="fmt in imageFormats" :key="fmt.id" class="tag-option" :class="{ active: outputFormat === fmt.id }" @click="outputFormat = fmt.id">{{ fmt.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group" v-if="outputFormat === 'jpeg'">
|
||||
<div class="param-label">JPEG 质量: {{ qualityInt }}</div>
|
||||
<el-slider v-model="qualityInt" :min="1" :max="100" :step="1" />
|
||||
<div class="slider-hint"><span>最小</span><span>最大</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-section">
|
||||
<div class="output-header"><span class="param-label">输出路径</span></div>
|
||||
<div class="output-row">
|
||||
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
|
||||
<el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.tool-main { max-width: 900px; margin: 0 auto; }
|
||||
.file-selected-bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border: 1px solid var(--glass-border); border-radius: var(--radius-lg); margin-bottom: 24px; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); cursor: pointer; transition: all var(--transition-normal); }
|
||||
.file-selected-bar:hover { border-color: var(--accent-primary); }
|
||||
.file-preview-img { width: 48px; height: 48px; object-fit: cover; border-radius: var(--radius-sm); }
|
||||
.file-preview-info { flex: 1; min-width: 0; }
|
||||
.file-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||
.file-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.params-section { margin-bottom: 20px; }
|
||||
.param-group { margin-bottom: 16px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
|
||||
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.output-section { margin-bottom: 24px; }
|
||||
.output-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
275
frontend/src/components/image/ImageCropTool.vue
Normal file
275
frontend/src/components/image/ImageCropTool.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const cropX = ref(0)
|
||||
const cropY = ref(0)
|
||||
const cropW = ref(0)
|
||||
const cropH = ref(0)
|
||||
const cropRatioPreset = ref('free')
|
||||
const previewCanvasRef = ref(null)
|
||||
let previewImg = null
|
||||
|
||||
let cropDragging = false
|
||||
let cropDragType = null
|
||||
let cropDragStartX = 0
|
||||
let cropDragStartY = 0
|
||||
let cropDragStartRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
|
||||
watch(() => props.filePreview, (val) => { if (val) initPreviewCanvas(val) })
|
||||
|
||||
const cropDisplayScale = computed(() => {
|
||||
const canvas = previewCanvasRef.value
|
||||
if (!canvas || !props.originalWidth) return 1
|
||||
return canvas.width / props.originalWidth
|
||||
})
|
||||
|
||||
const cropSelectionStyle = computed(() => {
|
||||
const scale = cropDisplayScale.value
|
||||
if (!cropW.value || !cropH.value) return { display: 'none' }
|
||||
return {
|
||||
left: (cropX.value * scale) + 'px',
|
||||
top: (cropY.value * scale) + 'px',
|
||||
width: (cropW.value * scale) + 'px',
|
||||
height: (cropH.value * scale) + 'px',
|
||||
}
|
||||
})
|
||||
|
||||
function initPreviewCanvas(base64) {
|
||||
if (!base64) return
|
||||
const canvas = previewCanvasRef.value
|
||||
if (!canvas) return
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
previewImg = img
|
||||
const container = canvas.parentElement
|
||||
const containerW = container.clientWidth - 40
|
||||
const containerH = container.clientHeight - 40
|
||||
const scale = Math.min(containerW / img.naturalWidth, containerH / img.naturalHeight, 1)
|
||||
const displayW = Math.floor(img.naturalWidth * scale)
|
||||
const displayH = Math.floor(img.naturalHeight * scale)
|
||||
canvas.width = displayW
|
||||
canvas.height = displayH
|
||||
canvas.parentElement.style.width = displayW + 'px'
|
||||
canvas.parentElement.style.height = displayH + 'px'
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, displayW, displayH)
|
||||
ctx.drawImage(img, 0, 0, displayW, displayH)
|
||||
cropX.value = 0; cropY.value = 0
|
||||
cropW.value = img.naturalWidth; cropH.value = img.naturalHeight
|
||||
}
|
||||
img.src = base64
|
||||
}
|
||||
|
||||
function onCropOverlayMouseDown(e) {
|
||||
if (e.target.closest('.crop-handle')) return
|
||||
const canvas = previewCanvasRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scale = canvas.width / props.originalWidth
|
||||
const imgX = (e.clientX - rect.left) / scale
|
||||
const imgY = (e.clientY - rect.top) / scale
|
||||
if (cropW.value > 0 && cropH.value > 0 && imgX >= cropX.value && imgX <= cropX.value + cropW.value && imgY >= cropY.value && imgY <= cropY.value + cropH.value) {
|
||||
cropDragging = true; cropDragType = 'move'
|
||||
} else {
|
||||
cropX.value = Math.max(0, Math.min(props.originalWidth, Math.round(imgX)))
|
||||
cropY.value = Math.max(0, Math.min(props.originalHeight, Math.round(imgY)))
|
||||
cropW.value = 0; cropH.value = 0
|
||||
cropDragging = true; cropDragType = 'create'
|
||||
}
|
||||
cropDragStartX = e.clientX; cropDragStartY = e.clientY
|
||||
cropDragStartRect = { x: cropX.value, y: cropY.value, w: cropW.value, h: cropH.value }
|
||||
}
|
||||
|
||||
function onCropHandleMouseDown(handle, e) {
|
||||
e.preventDefault()
|
||||
cropDragging = true; cropDragType = handle
|
||||
cropDragStartX = e.clientX; cropDragStartY = e.clientY
|
||||
cropDragStartRect = { x: cropX.value, y: cropY.value, w: cropW.value, h: cropH.value }
|
||||
}
|
||||
|
||||
function onCropOverlayMouseMove(e) {
|
||||
if (!cropDragging) return
|
||||
const canvas = previewCanvasRef.value
|
||||
if (!canvas) return
|
||||
const scale = canvas.width / props.originalWidth
|
||||
const dx = (e.clientX - cropDragStartX) / scale
|
||||
const dy = (e.clientY - cropDragStartY) / scale
|
||||
const s = cropDragStartRect
|
||||
switch (cropDragType) {
|
||||
case 'move': cropX.value = Math.max(0, Math.min(props.originalWidth - s.w, Math.round(s.x + dx))); cropY.value = Math.max(0, Math.min(props.originalHeight - s.h, Math.round(s.y + dy))); break
|
||||
case 'create': cropX.value = Math.max(0, Math.round(Math.min(s.x, s.x + dx))); cropY.value = Math.max(0, Math.round(Math.min(s.y, s.y + dy))); cropW.value = Math.min(Math.round(Math.abs(dx)), props.originalWidth - cropX.value); cropH.value = Math.min(Math.round(Math.abs(dy)), props.originalHeight - cropY.value); break
|
||||
case 'se': cropW.value = Math.round(Math.max(1, Math.min(s.w + dx, props.originalWidth - s.x))); cropH.value = Math.round(Math.max(1, Math.min(s.h + dy, props.originalHeight - s.y))); break
|
||||
case 'nw': { const nx = Math.max(0, s.x + dx); const ny = Math.max(0, s.y + dy); const nw = s.w - (nx - s.x); const nh = s.h - (ny - s.y); if (nw > 0 && nh > 0) { cropX.value = Math.round(nx); cropY.value = Math.round(ny); cropW.value = Math.round(nw); cropH.value = Math.round(nh) } break }
|
||||
case 'ne': { const nw2 = Math.max(1, Math.min(s.w + dx, props.originalWidth - s.x)); const ny2 = Math.max(0, s.y + dy); const nh2 = s.h - (ny2 - s.y); if (nw2 > 0 && nh2 > 0) { cropW.value = Math.round(nw2); cropY.value = Math.round(ny2); cropH.value = Math.round(nh2) } break }
|
||||
case 'sw': { const nx3 = Math.max(0, s.x + dx); const nw3 = s.w - (nx3 - s.x); const nh3 = Math.max(1, Math.min(s.h + dy, props.originalHeight - s.y)); if (nw3 > 0 && nh3 > 0) { cropX.value = Math.round(nx3); cropW.value = Math.round(nw3); cropH.value = Math.round(nh3) } break }
|
||||
case 'n': { const ny4 = Math.max(0, s.y + dy); const nh4 = s.h - (ny4 - s.y); if (nh4 > 0) { cropY.value = Math.round(ny4); cropH.value = Math.round(nh4) } break }
|
||||
case 's': cropH.value = Math.round(Math.max(1, Math.min(s.h + dy, props.originalHeight - s.y))); break
|
||||
case 'e': cropW.value = Math.round(Math.max(1, Math.min(s.w + dx, props.originalWidth - s.x))); break
|
||||
case 'w': { const nx5 = Math.max(0, s.x + dx); const nw5 = s.w - (nx5 - s.x); if (nw5 > 0) { cropX.value = Math.round(nx5); cropW.value = Math.round(nw5) } break }
|
||||
}
|
||||
if (cropRatioPreset.value !== 'free' && cropDragType !== 'move' && cropW.value > 0) {
|
||||
const [rw, rh] = cropRatioPreset.value.split(':').map(Number)
|
||||
const ratio = rw / rh
|
||||
const constrainedH = Math.max(1, Math.round(cropW.value / ratio))
|
||||
if (cropY.value + constrainedH <= props.originalHeight) { cropH.value = constrainedH }
|
||||
else { cropH.value = Math.max(1, props.originalHeight - cropY.value); cropW.value = Math.max(1, Math.round(cropH.value * ratio)) }
|
||||
}
|
||||
}
|
||||
|
||||
function onCropOverlayMouseUp() { cropDragging = false; cropDragType = null }
|
||||
|
||||
function setCropRatio(ratio) {
|
||||
cropRatioPreset.value = ratio
|
||||
if (ratio === 'free') return
|
||||
const [rw, rh] = ratio.split(':').map(Number)
|
||||
const r = rw / rh
|
||||
if (cropW.value > 0 && cropH.value > 0) {
|
||||
const newH = Math.round(cropW.value / r)
|
||||
if (cropY.value + newH <= props.originalHeight) { cropH.value = newH }
|
||||
else { cropH.value = Math.max(1, props.originalHeight - cropY.value); cropW.value = Math.max(1, Math.round(cropH.value * r)) }
|
||||
} else {
|
||||
if (props.originalWidth / props.originalHeight > r) { cropH.value = props.originalHeight; cropW.value = Math.round(props.originalHeight * r) }
|
||||
else { cropW.value = props.originalWidth; cropH.value = Math.round(props.originalWidth / r) }
|
||||
cropX.value = Math.round((props.originalWidth - cropW.value) / 2)
|
||||
cropY.value = Math.round((props.originalHeight - cropH.value) / 2)
|
||||
}
|
||||
}
|
||||
|
||||
function resetCrop() { cropX.value = 0; cropY.value = 0; cropW.value = props.originalWidth; cropH.value = props.originalHeight }
|
||||
|
||||
function buildRequest() {
|
||||
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'crop', shape: { type: 'rectangle', x: cropX.value, y: cropY.value, width: cropW.value, height: cropH.value } }
|
||||
}
|
||||
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-wrapper">
|
||||
<canvas ref="previewCanvasRef"></canvas>
|
||||
<div class="crop-overlay" @mousedown="onCropOverlayMouseDown" @mousemove="onCropOverlayMouseMove" @mouseup="onCropOverlayMouseUp" @mouseleave="onCropOverlayMouseUp">
|
||||
<div class="crop-selection" :style="cropSelectionStyle">
|
||||
<div class="crop-size-label" v-if="cropW > 0 && cropH > 0">{{ cropW }} × {{ cropH }}</div>
|
||||
<div class="crop-handle nw" @mousedown.stop="onCropHandleMouseDown('nw', $event)"></div>
|
||||
<div class="crop-handle n" @mousedown.stop="onCropHandleMouseDown('n', $event)"></div>
|
||||
<div class="crop-handle ne" @mousedown.stop="onCropHandleMouseDown('ne', $event)"></div>
|
||||
<div class="crop-handle e" @mousedown.stop="onCropHandleMouseDown('e', $event)"></div>
|
||||
<div class="crop-handle se" @mousedown.stop="onCropHandleMouseDown('se', $event)"></div>
|
||||
<div class="crop-handle s" @mousedown.stop="onCropHandleMouseDown('s', $event)"></div>
|
||||
<div class="crop-handle sw" @mousedown.stop="onCropHandleMouseDown('sw', $event)"></div>
|
||||
<div class="crop-handle w" @mousedown.stop="onCropHandleMouseDown('w', $event)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-info-bar">
|
||||
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
|
||||
<span class="image-filename">{{ getFileName() }}</span>
|
||||
<el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">裁剪区域</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">裁剪比例</div>
|
||||
<div class="tag-group">
|
||||
<div class="tag-option" :class="{ active: cropRatioPreset === 'free' }" @click="setCropRatio('free')">自由</div>
|
||||
<div class="tag-option" :class="{ active: cropRatioPreset === '1:1' }" @click="setCropRatio('1:1')">1:1</div>
|
||||
<div class="tag-option" :class="{ active: cropRatioPreset === '4:3' }" @click="setCropRatio('4:3')">4:3</div>
|
||||
<div class="tag-option" :class="{ active: cropRatioPreset === '16:9' }" @click="setCropRatio('16:9')">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="crop-coords">
|
||||
<div class="coord-row"><span class="coord-label">X:</span><el-input-number v-model="cropX" :min="0" :max="originalWidth" size="small" controls-position="right" /></div>
|
||||
<div class="coord-row"><span class="coord-label">Y:</span><el-input-number v-model="cropY" :min="0" :max="originalHeight" size="small" controls-position="right" /></div>
|
||||
<div class="coord-row"><span class="coord-label">W:</span><el-input-number v-model="cropW" :min="0" :max="originalWidth" size="small" controls-position="right" /></div>
|
||||
<div class="coord-row"><span class="coord-label">H:</span><el-input-number v-model="cropH" :min="0" :max="originalHeight" size="small" controls-position="right" /></div>
|
||||
</div>
|
||||
<button class="action-link-btn" @click="resetCrop">重置为全图</button>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.canvas-wrapper canvas { display: block; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
|
||||
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.crop-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; }
|
||||
.crop-selection { position: absolute; border: 2px solid rgba(255, 255, 255, 0.8); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); cursor: move; }
|
||||
.crop-size-label {
|
||||
position: absolute; bottom: -24px; left: 50%; transform: translateX(-50%);
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.7); color: #fff; white-space: nowrap;
|
||||
pointer-events: none; z-index: 12;
|
||||
}
|
||||
.crop-handle { position: absolute; width: 12px; height: 12px; background: #fff; border: 2px solid var(--accent-primary); border-radius: 50%; transform: translate(-50%, -50%); z-index: 11; }
|
||||
.crop-handle.nw { top: 0; left: 0; cursor: nw-resize; }
|
||||
.crop-handle.n { top: 0; left: 50%; cursor: n-resize; }
|
||||
.crop-handle.ne { top: 0; right: 0; left: auto; cursor: ne-resize; }
|
||||
.crop-handle.e { top: 50%; right: 0; left: auto; cursor: e-resize; }
|
||||
.crop-handle.se { bottom: 0; right: 0; left: auto; top: auto; cursor: se-resize; }
|
||||
.crop-handle.s { bottom: 0; left: 50%; top: auto; cursor: s-resize; }
|
||||
.crop-handle.sw { bottom: 0; left: 0; top: auto; cursor: sw-resize; }
|
||||
.crop-handle.w { top: 50%; left: 0; cursor: w-resize; }
|
||||
.crop-coords { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.coord-row { display: flex; align-items: center; gap: 6px; }
|
||||
.coord-label { font-size: 12px; color: var(--text-secondary); min-width: 20px; font-weight: 600; }
|
||||
.action-link-btn { width: 100%; padding: 8px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; background: transparent; border: 1px dashed var(--glass-border); color: var(--text-secondary); transition: all var(--transition-normal); }
|
||||
.action-link-btn:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
77
frontend/src/components/image/ImageGrayscaleTool.vue
Normal file
77
frontend/src/components/image/ImageGrayscaleTool.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const grayscaleIntensity = ref(100)
|
||||
|
||||
function buildRequest() {
|
||||
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'grayscale', grayscaleIntensity: grayscaleIntensity.value }
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
|
||||
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section"><div class="panel-title">灰度设置</div>
|
||||
<div class="param-group"><div class="param-label">灰度强度: {{ grayscaleIntensity }}%</div><el-slider v-model="grayscaleIntensity" :min="0" :max="100" :step="1" /><div class="slider-hint"><span>原图</span><span>完全灰度</span></div></div>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
72
frontend/src/components/image/ImageInvertTool.vue
Normal file
72
frontend/src/components/image/ImageInvertTool.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
function buildRequest() {
|
||||
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'invert' }
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
|
||||
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section"><div class="panel-title">反色</div>
|
||||
<div class="param-group"><div class="param-label">反转图片中的所有颜色</div></div>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
630
frontend/src/components/image/ImageRemoveBgTool.vue
Normal file
630
frontend/src/components/image/ImageRemoveBgTool.vue
Normal file
@@ -0,0 +1,630 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const bgRemovalMode = ref('global')
|
||||
const bgRemovalTolerance = ref(25)
|
||||
const bgRemovalColor = ref(null)
|
||||
const bgRemovalHistory = ref([])
|
||||
const bgOriginalImageData = ref(null)
|
||||
const bgProcessedCanvas = ref(null)
|
||||
const bgPreviewReady = ref(false)
|
||||
const bgColorSwatch = ref('')
|
||||
const bgColorCode = ref('未选取')
|
||||
const removebgThreshold = ref(30)
|
||||
|
||||
const MAX_BG_HISTORY = 30
|
||||
let bgImgNaturalW = 0
|
||||
let bgImgNaturalH = 0
|
||||
let bgDisplayScale = 1
|
||||
let bgDisplayOffsetX = 0
|
||||
let bgDisplayOffsetY = 0
|
||||
|
||||
watch(() => props.filePreview, (val) => {
|
||||
if (val) initBgRemoval(val)
|
||||
})
|
||||
|
||||
function getFileName() {
|
||||
if (!props.filePath) return ''
|
||||
return props.filePath.split(/[/\\]/).pop()
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async function initBgRemoval(base64) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
bgImgNaturalW = img.naturalWidth
|
||||
bgImgNaturalH = img.naturalHeight
|
||||
bgOriginalImageData.value = null
|
||||
bgProcessedCanvas.value = null
|
||||
bgPreviewReady.value = false
|
||||
bgRemovalHistory.value = []
|
||||
bgRemovalColor.value = null
|
||||
updateBgColorDisplay(null)
|
||||
|
||||
const offCanvas = document.createElement('canvas')
|
||||
offCanvas.width = bgImgNaturalW
|
||||
offCanvas.height = bgImgNaturalH
|
||||
const offCtx = offCanvas.getContext('2d')
|
||||
offCtx.drawImage(img, 0, 0)
|
||||
bgOriginalImageData.value = offCtx.getImageData(0, 0, bgImgNaturalW, bgImgNaturalH)
|
||||
|
||||
const processedData = new ImageData(
|
||||
new Uint8ClampedArray(bgOriginalImageData.value.data),
|
||||
bgImgNaturalW,
|
||||
bgImgNaturalH
|
||||
)
|
||||
bgProcessedCanvas.value = { data: processedData, width: bgImgNaturalW, height: bgImgNaturalH }
|
||||
|
||||
bgPreviewReady.value = true
|
||||
nextTick(() => {
|
||||
nextTick(() => {
|
||||
renderBgCanvases()
|
||||
})
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
console.error('图片加载失败')
|
||||
resolve()
|
||||
}
|
||||
img.src = base64
|
||||
})
|
||||
}
|
||||
|
||||
function renderBgCanvases() {
|
||||
if (!bgOriginalImageData.value || !bgProcessedCanvas.value) return
|
||||
|
||||
const origCanvas = document.getElementById('bgOriginalCanvas')
|
||||
const prevCanvas = document.getElementById('bgPreviewCanvas')
|
||||
if (!origCanvas || !prevCanvas) return
|
||||
|
||||
const origWrapper = origCanvas.parentElement
|
||||
const wrapperW = origWrapper.clientWidth
|
||||
const wrapperH = origWrapper.clientHeight
|
||||
|
||||
const scale = Math.min(wrapperW / bgImgNaturalW, wrapperH / bgImgNaturalH, 1)
|
||||
const displayW = bgImgNaturalW * scale
|
||||
const displayH = bgImgNaturalH * scale
|
||||
const offsetX = (wrapperW - displayW) / 2
|
||||
const offsetY = (wrapperH - displayH) / 2
|
||||
|
||||
bgDisplayScale = scale
|
||||
bgDisplayOffsetX = offsetX
|
||||
bgDisplayOffsetY = offsetY
|
||||
|
||||
origCanvas.width = wrapperW
|
||||
origCanvas.height = wrapperH
|
||||
origCanvas.style.width = wrapperW + 'px'
|
||||
origCanvas.style.height = wrapperH + 'px'
|
||||
const origCtx = origCanvas.getContext('2d')
|
||||
origCtx.clearRect(0, 0, wrapperW, wrapperH)
|
||||
origCtx.fillStyle = '#1a1a25'
|
||||
origCtx.fillRect(0, 0, wrapperW, wrapperH)
|
||||
|
||||
const tempOrig = document.createElement('canvas')
|
||||
tempOrig.width = bgImgNaturalW
|
||||
tempOrig.height = bgImgNaturalH
|
||||
tempOrig.getContext('2d').putImageData(bgOriginalImageData.value, 0, 0)
|
||||
origCtx.drawImage(tempOrig, offsetX, offsetY, displayW, displayH)
|
||||
|
||||
prevCanvas.width = wrapperW
|
||||
prevCanvas.height = wrapperH
|
||||
prevCanvas.style.width = wrapperW + 'px'
|
||||
prevCanvas.style.height = wrapperH + 'px'
|
||||
const prevCtx = prevCanvas.getContext('2d')
|
||||
prevCtx.clearRect(0, 0, wrapperW, wrapperH)
|
||||
|
||||
const tempPrev = document.createElement('canvas')
|
||||
tempPrev.width = bgProcessedCanvas.value.width
|
||||
tempPrev.height = bgProcessedCanvas.value.height
|
||||
tempPrev.getContext('2d').putImageData(bgProcessedCanvas.value.data, 0, 0)
|
||||
prevCtx.drawImage(tempPrev, offsetX, offsetY, displayW, displayH)
|
||||
}
|
||||
|
||||
function bgCanvasToImageCoord(canvas, clientX, clientY) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const cx = clientX - rect.left
|
||||
const cy = clientY - rect.top
|
||||
const px = Math.round((cx - bgDisplayOffsetX) / bgDisplayScale)
|
||||
const py = Math.round((cy - bgDisplayOffsetY) / bgDisplayScale)
|
||||
return { px, py, cx, cy }
|
||||
}
|
||||
|
||||
function bgIsInBounds(px, py) {
|
||||
return px >= 0 && px < bgImgNaturalW && py >= 0 && py < bgImgNaturalH
|
||||
}
|
||||
|
||||
function bgGetPixelColor(imageData, px, py) {
|
||||
const idx = (py * imageData.width + px) * 4
|
||||
return { r: imageData.data[idx], g: imageData.data[idx + 1], b: imageData.data[idx + 2], a: imageData.data[idx + 3] }
|
||||
}
|
||||
|
||||
function bgColorDistance(c1, c2) {
|
||||
const dr = c1.r - c2.r
|
||||
const dg = c1.g - c2.g
|
||||
const db = c1.b - c2.b
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db)
|
||||
}
|
||||
|
||||
function bgToleranceToMaxDist(tolerance) {
|
||||
return (tolerance / 100) * 441.67
|
||||
}
|
||||
|
||||
function bgRemoveGlobal(targetColor, tolerance) {
|
||||
if (!bgProcessedCanvas.value || !bgOriginalImageData.value) return 0
|
||||
const maxDist = bgToleranceToMaxDist(tolerance)
|
||||
const data = bgProcessedCanvas.value.data.data
|
||||
const origData = bgOriginalImageData.value.data
|
||||
let count = 0
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] === 0) continue
|
||||
const pc = { r: origData[i], g: origData[i + 1], b: origData[i + 2] }
|
||||
if (bgColorDistance(targetColor, pc) <= maxDist) {
|
||||
data[i + 3] = 0
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function bgRemoveFloodFill(targetColor, tolerance, startPx, startPy) {
|
||||
if (!bgProcessedCanvas.value || !bgOriginalImageData.value) return 0
|
||||
if (!bgIsInBounds(startPx, startPy)) return 0
|
||||
|
||||
const idx = (startPy * bgImgNaturalW + startPx) * 4
|
||||
if (bgProcessedCanvas.value.data.data[idx + 3] === 0) return 0
|
||||
|
||||
const maxDist = bgToleranceToMaxDist(tolerance)
|
||||
const width = bgImgNaturalW
|
||||
const height = bgImgNaturalH
|
||||
const data = bgProcessedCanvas.value.data.data
|
||||
const origData = bgOriginalImageData.value.data
|
||||
const visited = new Uint8Array(width * height)
|
||||
const queue = [{ x: startPx, y: startPy }]
|
||||
visited[startPy * width + startPx] = 1
|
||||
let count = 0
|
||||
let head = 0
|
||||
const dirs = [{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }]
|
||||
|
||||
while (head < queue.length) {
|
||||
const { x, y } = queue[head++]
|
||||
const pIdx = (y * width + x) * 4
|
||||
const pc = { r: origData[pIdx], g: origData[pIdx + 1], b: origData[pIdx + 2] }
|
||||
if (bgColorDistance(targetColor, pc) <= maxDist) {
|
||||
data[pIdx + 3] = 0
|
||||
count++
|
||||
for (const { dx, dy } of dirs) {
|
||||
const nx = x + dx
|
||||
const ny = y + dy
|
||||
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||||
const nVi = ny * width + nx
|
||||
if (!visited[nVi]) {
|
||||
const nIdx = (ny * width + nx) * 4
|
||||
if (data[nIdx + 3] > 0) {
|
||||
visited[nVi] = 1
|
||||
queue.push({ x: nx, y: ny })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function bgSaveHistory() {
|
||||
if (!bgProcessedCanvas.value) return
|
||||
const copy = new ImageData(
|
||||
new Uint8ClampedArray(bgProcessedCanvas.value.data.data),
|
||||
bgProcessedCanvas.value.width,
|
||||
bgProcessedCanvas.value.height
|
||||
)
|
||||
bgRemovalHistory.value.push(copy)
|
||||
if (bgRemovalHistory.value.length > MAX_BG_HISTORY) bgRemovalHistory.value.shift()
|
||||
}
|
||||
|
||||
function bgUndo() {
|
||||
if (bgRemovalHistory.value.length === 0) return
|
||||
const prev = bgRemovalHistory.value.pop()
|
||||
bgProcessedCanvas.value = { data: prev, width: prev.width, height: prev.height }
|
||||
renderBgCanvases()
|
||||
}
|
||||
|
||||
function bgResetAll() {
|
||||
if (!bgOriginalImageData.value) return
|
||||
bgRemovalHistory.value = []
|
||||
bgProcessedCanvas.value = {
|
||||
data: new ImageData(new Uint8ClampedArray(bgOriginalImageData.value.data), bgImgNaturalW, bgImgNaturalH),
|
||||
width: bgImgNaturalW,
|
||||
height: bgImgNaturalH
|
||||
}
|
||||
bgRemovalColor.value = null
|
||||
updateBgColorDisplay(null)
|
||||
renderBgCanvases()
|
||||
}
|
||||
|
||||
function bgApplyRemoval(targetColor, clickPx, clickPy) {
|
||||
bgSaveHistory()
|
||||
let count = 0
|
||||
if (bgRemovalMode.value === 'global') {
|
||||
count = bgRemoveGlobal(targetColor, bgRemovalTolerance.value)
|
||||
} else {
|
||||
count = bgRemoveFloodFill(targetColor, bgRemovalTolerance.value, clickPx, clickPy)
|
||||
}
|
||||
renderBgCanvases()
|
||||
if (count > 0) {
|
||||
ElMessage.success(`已去除 ${count.toLocaleString()} 个像素`)
|
||||
} else {
|
||||
ElMessage.warning('未找到匹配的像素,请调整容差')
|
||||
}
|
||||
}
|
||||
|
||||
function onBgCanvasClick(e) {
|
||||
if (!bgOriginalImageData.value || !bgProcessedCanvas.value) return
|
||||
const canvas = document.getElementById('bgOriginalCanvas')
|
||||
if (!canvas) return
|
||||
const { px, py } = bgCanvasToImageCoord(canvas, e.clientX, e.clientY)
|
||||
if (!bgIsInBounds(px, py)) return
|
||||
|
||||
const color = bgGetPixelColor(bgOriginalImageData.value, px, py)
|
||||
bgRemovalColor.value = { r: color.r, g: color.g, b: color.b }
|
||||
updateBgColorDisplay(bgRemovalColor.value)
|
||||
bgApplyRemoval(bgRemovalColor.value, px, py)
|
||||
}
|
||||
|
||||
function onBgCanvasMouseMove(e) {
|
||||
if (!bgOriginalImageData.value) return
|
||||
const canvas = document.getElementById('bgOriginalCanvas')
|
||||
if (!canvas) return
|
||||
const { px, py, cx, cy } = bgCanvasToImageCoord(canvas, e.clientX, e.clientY)
|
||||
const badge = document.getElementById('bgColorBadge')
|
||||
if (!badge) return
|
||||
if (!bgIsInBounds(px, py)) {
|
||||
badge.style.display = 'none'
|
||||
return
|
||||
}
|
||||
const color = bgGetPixelColor(bgOriginalImageData.value, px, py)
|
||||
badge.style.backgroundColor = `rgb(${color.r},${color.g},${color.b})`
|
||||
badge.style.left = cx + 'px'
|
||||
badge.style.top = cy + 'px'
|
||||
badge.style.display = 'block'
|
||||
}
|
||||
|
||||
function onBgCanvasMouseLeave() {
|
||||
const badge = document.getElementById('bgColorBadge')
|
||||
if (badge) badge.style.display = 'none'
|
||||
}
|
||||
|
||||
function updateBgColorDisplay(color) {
|
||||
if (color) {
|
||||
bgColorSwatch.value = `rgb(${color.r},${color.g},${color.b})`
|
||||
bgColorCode.value = `#${color.r.toString(16).padStart(2, '0')}${color.g.toString(16).padStart(2, '0')}${color.b.toString(16).padStart(2, '0')}`
|
||||
} else {
|
||||
bgColorSwatch.value = ''
|
||||
bgColorCode.value = '未选取'
|
||||
}
|
||||
}
|
||||
|
||||
function bgApplyPreset(preset) {
|
||||
const presets = {
|
||||
white: { r: 255, g: 255, b: 255 },
|
||||
black: { r: 0, g: 0, b: 0 },
|
||||
green: { r: 0, g: 177, b: 64 },
|
||||
gray: { r: 200, g: 200, b: 200 }
|
||||
}
|
||||
const color = presets[preset]
|
||||
if (!color) return
|
||||
bgRemovalColor.value = color
|
||||
updateBgColorDisplay(color)
|
||||
bgSaveHistory()
|
||||
const count = bgRemoveGlobal(color, bgRemovalTolerance.value)
|
||||
renderBgCanvases()
|
||||
if (count > 0) {
|
||||
ElMessage.success(`已去除 ${count.toLocaleString()} 个像素`)
|
||||
} else {
|
||||
ElMessage.warning('未找到匹配的像素,请调整容差')
|
||||
}
|
||||
}
|
||||
|
||||
async function bgDownloadResult() {
|
||||
if (!bgProcessedCanvas.value) return
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bgProcessedCanvas.value.width
|
||||
canvas.height = bgProcessedCanvas.value.height
|
||||
canvas.getContext('2d').putImageData(bgProcessedCanvas.value.data, 0, 0)
|
||||
const link = document.createElement('a')
|
||||
const baseName = props.filePath ? props.filePath.split(/[/\\]/).pop().replace(/\.[^.]+$/, '') : 'image'
|
||||
link.download = baseName + '_nobg.png'
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.click()
|
||||
ElMessage.success('已下载 PNG')
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
inputPath: props.filePath,
|
||||
outputPath: props.outputPath,
|
||||
format: 'removebg',
|
||||
threshold: removebgThreshold.value,
|
||||
}
|
||||
}
|
||||
|
||||
function onProcess() {
|
||||
emit('process', buildRequest())
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('dragover')
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
const ext = files[0].name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) {
|
||||
ElMessage.warning('请选择图片文件')
|
||||
return
|
||||
}
|
||||
emit('selectFile', files[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('dragover')
|
||||
}
|
||||
|
||||
function onDragLeave(e) {
|
||||
e.currentTarget.classList.remove('dragover')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover.prevent @drop.prevent="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="tool-main">
|
||||
<div class="file-selected-bar" @click="$emit('selectFile')">
|
||||
<img v-if="filePreview" :src="filePreview" class="file-preview-img" />
|
||||
<div class="file-preview-info">
|
||||
<div class="file-name">{{ getFileName() }}</div>
|
||||
<div v-if="imageInfo" class="file-meta">{{ imageInfo.width }}×{{ imageInfo.height }} · {{ formatSize(imageInfo.size) }}</div>
|
||||
</div>
|
||||
<el-button type="danger" text @click.stop="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-section">
|
||||
<div class="param-group">
|
||||
<div class="param-label">背景灵敏度: {{ removebgThreshold }}</div>
|
||||
<el-slider v-model="removebgThreshold" :min="5" :max="100" :step="5" show-stops />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-removal-section" v-if="bgPreviewReady">
|
||||
<div class="bg-removal-controls">
|
||||
<div class="bg-control-row">
|
||||
<div class="bg-control-group">
|
||||
<span class="bg-control-label">模式</span>
|
||||
<div class="bg-mode-btns">
|
||||
<button class="bg-mode-btn" :class="{ active: bgRemovalMode === 'global' }" @click="bgRemovalMode = 'global'">全局匹配</button>
|
||||
<button class="bg-mode-btn" :class="{ active: bgRemovalMode === 'floodfill' }" @click="bgRemovalMode = 'floodfill'">连通区域</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-control-group">
|
||||
<span class="bg-control-label">容差</span>
|
||||
<div class="bg-tolerance-wrap">
|
||||
<input type="range" class="bg-tolerance-slider" v-model.number="bgRemovalTolerance" min="0" max="100" step="1" />
|
||||
<span class="bg-tolerance-value">{{ bgRemovalTolerance }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-control-group">
|
||||
<span class="bg-control-label">当前颜色</span>
|
||||
<div class="bg-color-display">
|
||||
<div class="bg-color-swatch" :style="{ backgroundColor: bgColorSwatch || '' }" :class="{ empty: !bgColorSwatch }"></div>
|
||||
<span class="bg-color-code">{{ bgColorCode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-control-row">
|
||||
<div class="bg-control-group">
|
||||
<span class="bg-control-label">快捷</span>
|
||||
<div class="bg-presets">
|
||||
<button class="bg-preset-btn" @click="bgApplyPreset('white')"><span class="bg-mini-swatch" style="background:#fff"></span>白色</button>
|
||||
<button class="bg-preset-btn" @click="bgApplyPreset('black')"><span class="bg-mini-swatch" style="background:#1a1a1a"></span>黑色</button>
|
||||
<button class="bg-preset-btn" @click="bgApplyPreset('green')"><span class="bg-mini-swatch" style="background:#00b140"></span>绿幕</button>
|
||||
<button class="bg-preset-btn" @click="bgApplyPreset('gray')"><span class="bg-mini-swatch" style="background:#d0d0d0"></span>灰色</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-control-group bg-actions">
|
||||
<button class="bg-action-btn bg-undo-btn" :disabled="bgRemovalHistory.length === 0" @click="bgUndo">↩ 撤销</button>
|
||||
<button class="bg-action-btn bg-reset-btn" @click="bgResetAll">🔄 重置</button>
|
||||
<button class="bg-action-btn bg-download-btn" @click="bgDownloadResult">💾 下载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-canvas-area">
|
||||
<div class="bg-canvas-panel">
|
||||
<div class="bg-panel-title"><span class="bg-dot bg-dot-orig"></span>原图 · 点击取色</div>
|
||||
<div class="bg-canvas-wrapper" @click="onBgCanvasClick" @mousemove="onBgCanvasMouseMove" @mouseleave="onBgCanvasMouseLeave" style="cursor:crosshair">
|
||||
<canvas id="bgOriginalCanvas"></canvas>
|
||||
<div id="bgColorBadge" class="bg-color-badge"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-canvas-panel">
|
||||
<div class="bg-panel-title"><span class="bg-dot bg-dot-preview"></span>预览 · 透明背景</div>
|
||||
<div class="bg-canvas-wrapper bg-preview-wrapper">
|
||||
<canvas id="bgPreviewCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-section">
|
||||
<div class="output-header">
|
||||
<span class="param-label">输出路径</span>
|
||||
</div>
|
||||
<div class="output-row">
|
||||
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
|
||||
<el-icon v-if="!processing"><Check /></el-icon>
|
||||
{{ processing ? '处理中...' : '开始处理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-main { max-width: 900px; margin: 0 auto; }
|
||||
.file-drop-zone {
|
||||
border: 2px dashed var(--glass-border); border-radius: var(--radius-lg);
|
||||
padding: 40px; text-align: center; cursor: pointer;
|
||||
transition: all var(--transition-normal); margin-bottom: 24px;
|
||||
background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
}
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.file-selected-bar {
|
||||
display: flex; align-items: center; gap: 14px; padding: 12px 16px;
|
||||
border: 1px solid var(--glass-border); border-radius: var(--radius-lg);
|
||||
margin-bottom: 24px; background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
cursor: pointer; transition: all var(--transition-normal);
|
||||
}
|
||||
.file-selected-bar:hover { border-color: var(--accent-primary); }
|
||||
.file-preview-img { width: 48px; height: 48px; object-fit: cover; border-radius: var(--radius-sm); }
|
||||
.file-preview-info { flex: 1; min-width: 0; }
|
||||
.file-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||
.file-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.params-section { margin-bottom: 20px; }
|
||||
.param-group { margin-bottom: 16px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.output-section { margin-bottom: 24px; }
|
||||
.output-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn {
|
||||
width: 100%; height: 48px; font-size: 15px; font-weight: 600;
|
||||
border-radius: var(--radius-md); background: var(--gradient-primary); border: none;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
|
||||
.bg-removal-section {
|
||||
margin: 20px 0; background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 16px;
|
||||
}
|
||||
.bg-removal-controls { margin-bottom: 16px; }
|
||||
.bg-control-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin-bottom: 12px; }
|
||||
.bg-control-row:last-child { margin-bottom: 0; }
|
||||
.bg-control-group { display: flex; align-items: center; gap: 8px; }
|
||||
.bg-control-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); white-space: nowrap; }
|
||||
.bg-mode-btns { display: flex; gap: 4px; background: var(--bg-surface); border-radius: 20px; padding: 3px; }
|
||||
.bg-mode-btn {
|
||||
padding: 6px 14px; border-radius: 18px; border: none; cursor: pointer;
|
||||
font-size: 12px; font-weight: 500; background: transparent;
|
||||
color: var(--text-secondary); transition: all var(--transition-normal);
|
||||
}
|
||||
.bg-mode-btn.active { background: var(--accent-primary); color: #fff; }
|
||||
.bg-mode-btn:hover:not(.active) { color: var(--text-primary); }
|
||||
.bg-tolerance-wrap { display: flex; align-items: center; gap: 8px; }
|
||||
.bg-tolerance-slider {
|
||||
-webkit-appearance: none; width: 120px; height: 4px; border-radius: 2px;
|
||||
background: var(--bg-surface); outline: none; cursor: pointer;
|
||||
}
|
||||
.bg-tolerance-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; border: 2px solid var(--accent-primary); cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.bg-tolerance-value {
|
||||
font-weight: 700; font-size: 13px; color: var(--accent-primary);
|
||||
min-width: 28px; text-align: center; font-family: monospace;
|
||||
}
|
||||
.bg-color-display { display: flex; align-items: center; gap: 6px; }
|
||||
.bg-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); }
|
||||
.bg-color-swatch.empty { background: conic-gradient(#e0e0e5 0deg 90deg, #fff 90deg 180deg, #e0e0e5 180deg 270deg, #fff 270deg 360deg); background-size: 12px 12px; }
|
||||
.bg-color-code { font-family: monospace; font-size: 12px; color: var(--text-primary); }
|
||||
.bg-presets { display: flex; gap: 6px; }
|
||||
.bg-preset-btn {
|
||||
padding: 5px 10px; border-radius: 16px; font-size: 12px; font-weight: 500;
|
||||
background: var(--bg-surface); border: 1px solid var(--glass-border);
|
||||
cursor: pointer; transition: all var(--transition-normal);
|
||||
display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary);
|
||||
}
|
||||
.bg-preset-btn:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.15); }
|
||||
.bg-mini-swatch {
|
||||
width: 14px; height: 14px; border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1); display: inline-block;
|
||||
}
|
||||
.bg-actions { margin-left: auto; }
|
||||
.bg-action-btn {
|
||||
padding: 6px 12px; border-radius: 16px; font-size: 12px; font-weight: 500;
|
||||
border: none; cursor: pointer; transition: all var(--transition-normal);
|
||||
}
|
||||
.bg-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.bg-undo-btn { background: var(--bg-surface); color: var(--text-primary); }
|
||||
.bg-undo-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); }
|
||||
.bg-reset-btn { background: var(--bg-surface); color: var(--text-primary); }
|
||||
.bg-reset-btn:hover { background: rgba(255, 255, 255, 0.08); }
|
||||
.bg-download-btn { background: var(--accent-primary); color: #fff; }
|
||||
.bg-download-btn:hover { background: var(--accent-primary-hover); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.bg-canvas-area { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.bg-canvas-panel { flex: 1; min-width: 300px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.bg-panel-title {
|
||||
font-weight: 600; font-size: 12px; color: var(--text-secondary);
|
||||
letter-spacing: 0.02em; text-transform: uppercase;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.bg-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.bg-dot-orig { background: var(--accent-primary); }
|
||||
.bg-dot-preview { background: var(--accent-green); }
|
||||
.bg-canvas-wrapper {
|
||||
position: relative; width: 100%; min-height: 400px;
|
||||
background: var(--bg-surface); border-radius: var(--radius-md);
|
||||
overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
.bg-canvas-wrapper canvas { max-width: 100%; max-height: 100%; display: block; }
|
||||
.bg-preview-wrapper {
|
||||
background-color: #fff;
|
||||
background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
|
||||
linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5);
|
||||
background-size: 20px 20px; background-position: 0 0, 10px 10px; background-color: #fff;
|
||||
}
|
||||
.bg-color-badge {
|
||||
position: absolute; pointer-events: none; width: 40px; height: 40px;
|
||||
border-radius: 50%; border: 3px solid #fff; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
display: none; z-index: 10; transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
239
frontend/src/components/image/ImageResizeTool.vue
Normal file
239
frontend/src/components/image/ImageResizeTool.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const customWidth = ref(800)
|
||||
const customHeight = ref(600)
|
||||
const resizePercentage = ref(100)
|
||||
const resizeRatioPreset = ref('free')
|
||||
const aspectLocked = ref(false)
|
||||
const resizeUnit = ref('px')
|
||||
const lockRatio = ref(1)
|
||||
let aspectUpdating = false
|
||||
|
||||
watch(() => props.originalWidth, (w) => { if (w) customWidth.value = w })
|
||||
watch(() => props.originalHeight, (h) => { if (h) customHeight.value = h })
|
||||
|
||||
watch(resizePercentage, (pct) => {
|
||||
if (props.originalWidth && props.originalHeight) {
|
||||
customWidth.value = Math.round(props.originalWidth * pct / 100)
|
||||
customHeight.value = Math.round(props.originalHeight * pct / 100)
|
||||
}
|
||||
})
|
||||
|
||||
const widthPercent = ref(100)
|
||||
const heightPercent = ref(100)
|
||||
|
||||
watch(() => customWidth.value, (val) => {
|
||||
widthPercent.value = props.originalWidth ? Math.round(val / props.originalWidth * 100) : 100
|
||||
})
|
||||
watch(() => customHeight.value, (val) => {
|
||||
heightPercent.value = props.originalHeight ? Math.round(val / props.originalHeight * 100) : 100
|
||||
})
|
||||
|
||||
function onWidthChange(val) {
|
||||
if (aspectLocked.value && !aspectUpdating) {
|
||||
aspectUpdating = true
|
||||
customHeight.value = Math.round(val / lockRatio.value)
|
||||
nextTick(() => { aspectUpdating = false })
|
||||
}
|
||||
}
|
||||
|
||||
function onHeightChange(val) {
|
||||
if (aspectLocked.value && !aspectUpdating) {
|
||||
aspectUpdating = true
|
||||
customWidth.value = Math.round(val * lockRatio.value)
|
||||
nextTick(() => { aspectUpdating = false })
|
||||
}
|
||||
}
|
||||
|
||||
function setResizeRatio(ratio) {
|
||||
resizeRatioPreset.value = ratio
|
||||
if (ratio === 'free') {
|
||||
aspectLocked.value = false
|
||||
return
|
||||
}
|
||||
aspectLocked.value = true
|
||||
const [rw, rh] = ratio.split(':').map(Number)
|
||||
lockRatio.value = rw / rh
|
||||
const targetRatio = rw / rh
|
||||
if (props.originalWidth / props.originalHeight > targetRatio) {
|
||||
customHeight.value = props.originalHeight
|
||||
customWidth.value = Math.round(props.originalHeight * targetRatio)
|
||||
} else {
|
||||
customWidth.value = props.originalWidth
|
||||
customHeight.value = Math.round(props.originalWidth / targetRatio)
|
||||
}
|
||||
}
|
||||
|
||||
function resetResizePercentage() {
|
||||
resizePercentage.value = 100
|
||||
customWidth.value = props.originalWidth
|
||||
customHeight.value = props.originalHeight
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
inputPath: props.filePath,
|
||||
outputPath: props.outputPath,
|
||||
format: 'resize',
|
||||
width: resizeUnit.value === 'px' ? customWidth.value : Math.round(props.originalWidth * widthPercent.value / 100),
|
||||
height: resizeUnit.value === 'px' ? customHeight.value : Math.round(props.originalHeight * heightPercent.value / 100),
|
||||
maintainRatio: aspectLocked.value,
|
||||
}
|
||||
}
|
||||
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
|
||||
function getFileName() {
|
||||
if (!props.filePath) return ''
|
||||
return props.filePath.split(/[/\\]/).pop()
|
||||
}
|
||||
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('dragover')
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
const ext = files[0].name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) {
|
||||
ElMessage.warning('请选择图片文件')
|
||||
return
|
||||
}
|
||||
emit('selectFile', files[0].path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div>
|
||||
</div>
|
||||
<div class="image-info-bar">
|
||||
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
|
||||
<span class="image-filename">{{ getFileName() }}</span>
|
||||
<el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-panel">
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">调整大小</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">缩放比例</div>
|
||||
<div class="percentage-row">
|
||||
<el-input-number v-model="resizePercentage" :min="1" :max="500" :step="5" size="small" controls-position="right" />
|
||||
<span class="pct-unit">%</span>
|
||||
<el-button size="small" @click="resetResizePercentage">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">预设比例</div>
|
||||
<div class="tag-group">
|
||||
<div class="tag-option" :class="{ active: resizeRatioPreset === 'free' }" @click="setResizeRatio('free')">自由</div>
|
||||
<div class="tag-option" :class="{ active: resizeRatioPreset === '1:1' }" @click="setResizeRatio('1:1')">1:1</div>
|
||||
<div class="tag-option" :class="{ active: resizeRatioPreset === '4:3' }" @click="setResizeRatio('4:3')">4:3</div>
|
||||
<div class="tag-option" :class="{ active: resizeRatioPreset === '16:9' }" @click="setResizeRatio('16:9')">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">
|
||||
尺寸 (宽 × 高)
|
||||
<div class="unit-toggle">
|
||||
<button class="unit-btn" :class="{ active: resizeUnit === 'px' }" @click="resizeUnit = 'px'">px</button>
|
||||
<button class="unit-btn" :class="{ active: resizeUnit === '%' }" @click="resizeUnit = '%'">%</button>
|
||||
</div>
|
||||
<button class="aspect-lock-btn inline" :class="{ locked: aspectLocked }" @click="aspectLocked = !aspectLocked" :title="aspectLocked ? '解锁比例' : '锁定比例'">
|
||||
<el-icon :size="12"><Lock v-if="aspectLocked" /><Unlock v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="size-inputs" v-if="resizeUnit === 'px'">
|
||||
<el-input-number v-model="customWidth" :min="1" :max="10000" size="small" @change="onWidthChange" controls-position="right" />
|
||||
<span class="size-sep">×</span>
|
||||
<el-input-number v-model="customHeight" :min="1" :max="10000" size="small" @change="onHeightChange" controls-position="right" />
|
||||
<span class="size-unit">px</span>
|
||||
</div>
|
||||
<div class="size-inputs" v-else>
|
||||
<el-input-number v-model="widthPercent" :min="1" :max="500" :step="5" size="small" controls-position="right" />
|
||||
<span class="size-sep">×</span>
|
||||
<el-input-number v-model="heightPercent" :min="1" :max="500" :step="5" size="small" controls-position="right" />
|
||||
<span class="size-unit">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">输出路径</div>
|
||||
<div class="output-row">
|
||||
<el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess">
|
||||
<el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
|
||||
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.percentage-row { display: flex; align-items: center; gap: 8px; }
|
||||
.pct-unit { color: var(--text-muted); font-size: 13px; }
|
||||
.size-inputs { display: flex; align-items: center; gap: 8px; }
|
||||
.size-sep { color: var(--text-muted); font-size: 14px; }
|
||||
.size-unit { color: var(--text-muted); font-size: 13px; }
|
||||
.unit-toggle { display: inline-flex; background: var(--bg-surface); border-radius: var(--radius-sm); padding: 2px; margin-left: 6px; }
|
||||
.unit-btn { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; cursor: pointer; background: transparent; border: none; color: var(--text-muted); transition: all var(--transition-normal); }
|
||||
.unit-btn.active { background: var(--accent-primary); color: #fff; }
|
||||
.aspect-lock-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); color: var(--text-secondary); transition: all var(--transition-normal); }
|
||||
.aspect-lock-btn.locked { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.aspect-lock-btn.inline { padding: 2px 6px; border-radius: 4px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
124
frontend/src/components/image/ImageRotateTool.vue
Normal file
124
frontend/src/components/image/ImageRotateTool.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const angleSlider = ref(0)
|
||||
const rotationBgColor = ref('white')
|
||||
const flipH = ref(false)
|
||||
const flipV = ref(false)
|
||||
|
||||
function buildRequest() {
|
||||
return {
|
||||
inputPath: props.filePath,
|
||||
outputPath: props.outputPath,
|
||||
format: 'rotate',
|
||||
angle: angleSlider.value,
|
||||
bgColor: rotationBgColor.value,
|
||||
flipH: flipH.value,
|
||||
flipV: flipV.value,
|
||||
}
|
||||
}
|
||||
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
|
||||
<div class="image-info-bar">
|
||||
<span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span>
|
||||
<span class="image-filename">{{ getFileName() }}</span>
|
||||
<el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">旋转设置</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">角度: {{ angleSlider }}°</div>
|
||||
<el-slider v-model="angleSlider" :min="0" :max="360" :step="1" />
|
||||
<div class="slider-hint"><span>0°</span><span>180°</span><span>360°</span></div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">背景颜色</div>
|
||||
<div class="tag-group">
|
||||
<div class="tag-option" :class="{ active: rotationBgColor === 'white' }" @click="rotationBgColor = 'white'"><span class="bg-mini-swatch" style="background:#fff"></span>白色</div>
|
||||
<div class="tag-option" :class="{ active: rotationBgColor === 'transparent' }" @click="rotationBgColor = 'transparent'"><span class="bg-mini-swatch bg-checker"></span>透明</div>
|
||||
<div class="tag-option" :class="{ active: rotationBgColor === 'black' }" @click="rotationBgColor = 'black'"><span class="bg-mini-swatch" style="background:#1a1a1a"></span>黑色</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">翻转</div>
|
||||
<div class="flip-btns">
|
||||
<button class="flip-btn" :class="{ active: flipH }" @click="flipH = !flipH"><el-icon><DCaret /></el-icon> 水平</button>
|
||||
<button class="flip-btn" :class="{ active: flipV }" @click="flipV = !flipV"><el-icon style="transform:rotate(90deg)"><DCaret /></el-icon> 垂直</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag-option { display: flex; align-items: center; gap: 4px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; cursor: pointer; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--glass-border); transition: all var(--transition-normal); user-select: none; }
|
||||
.tag-option:hover { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.05); }
|
||||
.tag-option.active { background: var(--accent-primary); color: #ffffff; border-color: var(--accent-primary); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); }
|
||||
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.flip-btns { display: flex; gap: 8px; }
|
||||
.flip-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--glass-border); color: var(--text-secondary); transition: all var(--transition-normal); }
|
||||
.flip-btn.active { background: var(--accent-primary); color: #fff; border-color: var(--accent-primary); }
|
||||
.flip-btn:hover:not(.active) { color: var(--text-primary); border-color: rgba(255, 255, 255, 0.15); }
|
||||
.bg-mini-swatch { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); display: inline-block; }
|
||||
.bg-checker { background-color: #fff; background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc); background-size: 8px 8px; background-position: 0 0, 4px 4px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
77
frontend/src/components/image/ImageSharpenTool.vue
Normal file
77
frontend/src/components/image/ImageSharpenTool.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: String,
|
||||
filePreview: String,
|
||||
imageInfo: Object,
|
||||
originalWidth: Number,
|
||||
originalHeight: Number,
|
||||
processing: Boolean,
|
||||
resultInfo: Object,
|
||||
outputPath: String,
|
||||
config: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:outputPath', 'process', 'selectFile'])
|
||||
|
||||
const sharpenAmount = ref(50)
|
||||
|
||||
function buildRequest() {
|
||||
return { inputPath: props.filePath, outputPath: props.outputPath, format: 'sharpen', sharpenAmount: sharpenAmount.value / 50 }
|
||||
}
|
||||
function onProcess() { emit('process', buildRequest()) }
|
||||
function getFileName() { return props.filePath ? props.filePath.split(/[/\\]/).pop() : '' }
|
||||
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover') }
|
||||
function onDragLeave(e) { e.currentTarget.classList.remove('dragover') }
|
||||
function onDrop(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); if (e.dataTransfer?.files?.length) { const ext = e.dataTransfer.files[0].name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'ico'].includes(ext)) { ElMessage.warning('请选择图片文件'); return } emit('selectFile', e.dataTransfer.files[0].path) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!filePath" class="file-drop-zone" @click="$emit('selectFile')" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<el-icon :size="40" class="drop-icon"><UploadFilled /></el-icon>
|
||||
<div class="drop-text">点击选择图片</div>
|
||||
<div class="drop-hint">支持拖拽图片到此处</div>
|
||||
</div>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-canvas-area">
|
||||
<div class="canvas-container"><div class="canvas-wrapper"><img v-if="filePreview" :src="filePreview" class="preview-img" /></div></div>
|
||||
<div class="image-info-bar"><span class="image-dims">{{ originalWidth }} × {{ originalHeight }} px</span><span class="image-filename">{{ getFileName() }}</span><el-button type="danger" text size="small" @click="$emit('selectFile')"><el-icon><Delete /></el-icon></el-button></div>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<div class="panel-section"><div class="panel-title">锐化设置</div>
|
||||
<div class="param-group"><div class="param-label">锐化强度: {{ sharpenAmount }}%</div><el-slider v-model="sharpenAmount" :min="0" :max="100" :step="1" /><div class="slider-hint"><span>无锐化</span><span>最强锐化</span></div></div>
|
||||
</div>
|
||||
<div class="panel-section"><div class="panel-title">输出路径</div><div class="output-row"><el-input :value="outputPath" :placeholder="config?.defaultOutputDir ? '默认: ' + config.defaultOutputDir : '默认: 与输入文件同目录'" readonly size="small" /></div></div>
|
||||
<el-button class="process-btn" type="primary" size="large" :loading="processing" :disabled="!filePath" @click="onProcess"><el-icon v-if="!processing"><Check /></el-icon>{{ processing ? '处理中...' : '开始处理' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone { border: 2px dashed var(--glass-border); border-radius: var(--radius-lg); padding: 80px 40px; text-align: center; cursor: pointer; transition: all var(--transition-normal); background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); max-width: 600px; margin: 40px auto; }
|
||||
.file-drop-zone:hover, .file-drop-zone.dragover { border-color: var(--accent-primary); background: rgba(59, 130, 246, 0.05); box-shadow: 0 0 20px rgba(59, 130, 246, 0.1); }
|
||||
.drop-icon { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.drop-text { font-size: 15px; color: var(--text-primary); margin-bottom: 4px; }
|
||||
.drop-hint { font-size: 12px; color: var(--text-muted); }
|
||||
.image-workspace { display: grid; grid-template-columns: 1fr 320px; gap: 20px; height: calc(100vh - 160px); max-height: 800px; }
|
||||
.image-canvas-area { display: flex; flex-direction: column; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a25; overflow: hidden; min-height: 400px; }
|
||||
.canvas-wrapper { position: relative; display: inline-block; }
|
||||
.preview-img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-info-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--glass-bg); border-top: 1px solid var(--glass-border); font-size: 13px; }
|
||||
.image-dims { color: var(--text-secondary); font-family: monospace; }
|
||||
.image-filename { color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-panel { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; background: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); padding: 20px; }
|
||||
.panel-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
.param-group { margin-bottom: 12px; }
|
||||
.param-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.slider-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.output-row { display: flex; gap: 8px; }
|
||||
.output-row .el-input { flex: 1; }
|
||||
.process-btn { width: 100%; height: 48px; font-size: 15px; font-weight: 600; border-radius: var(--radius-md); background: var(--gradient-primary); border: none; transition: all var(--transition-normal); }
|
||||
.process-btn:hover:not(:disabled) { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); transform: translateY(-1px); }
|
||||
.process-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user