Add tools management system with image background removal
This commit is contained in:
parent
bb93543abc
commit
bf9d52a2db
@ -89,6 +89,11 @@ const router = createRouter({
|
||||
name: 'Tools',
|
||||
component: () => import('../../views/tools/Tools.vue')
|
||||
},
|
||||
{
|
||||
path: 'tools/remove-background',
|
||||
name: 'RemoveBackground',
|
||||
component: () => import('../../views/tools/RemoveBackground.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'SearchResults',
|
||||
|
||||
@ -1082,5 +1082,40 @@
|
||||
"Tool added successfully": "工具添加成功",
|
||||
"Are you sure you want to delete tool": "确定要删除工具",
|
||||
"Tool deleted successfully": "工具删除成功",
|
||||
"Route not found: ": "路由未找到:"
|
||||
"Route not found: ": "路由未找到:",
|
||||
"Remove Background": "图片去背景",
|
||||
"Remove background from images using AI technology": "使用AI技术去除图片背景",
|
||||
"Upload Image": "上传图片",
|
||||
"Drag and drop your image here, or click to browse": "拖拽图片到此处,或点击浏览",
|
||||
"Supports JPG, PNG, WebP formats": "支持 JPG、PNG、WebP 格式",
|
||||
"Image Preview": "图片预览",
|
||||
"Change Image": "更换图片",
|
||||
"Original": "原图",
|
||||
"Background Removed": "去背景后",
|
||||
"Result will appear here": "处理结果将显示在这里",
|
||||
"Processing...": "处理中...",
|
||||
"Download": "下载",
|
||||
"How it works": "使用说明",
|
||||
"Upload an image with a clear subject": "上传一张主体清晰的图片",
|
||||
"Click \"Remove Background\" to process": "点击\"去背景\"按钮进行处理",
|
||||
"Download the result with transparent background": "下载透明背景的处理结果",
|
||||
"Please upload an image file": "请上传图片文件",
|
||||
"Unsupported image format. Please use JPG, PNG, or WebP": "不支持的图片格式,请使用 JPG、PNG 或 WebP 格式",
|
||||
"Image size exceeds 10MB limit": "图片大小超过 10MB 限制",
|
||||
"Please upload an image first": "请先上传图片",
|
||||
"Background removed successfully!": "背景移除成功!",
|
||||
"Failed to remove background": "背景移除失败",
|
||||
"Failed to upload image: ": "图片上传失败:",
|
||||
"Download started": "下载已开始",
|
||||
"Failed to download image": "下载失败",
|
||||
"Hide Default Tool": "隐藏默认工具",
|
||||
"Are you sure you want to hide default tool": "确定要隐藏默认工具",
|
||||
"You can show it again later": "您可以稍后再次显示",
|
||||
"Hide": "隐藏",
|
||||
"Show": "显示",
|
||||
"Tool hidden successfully": "工具已隐藏",
|
||||
"Tool shown successfully": "工具已显示",
|
||||
"Default tools cannot be edited": "默认工具无法编辑",
|
||||
"Hidden Tools": "已隐藏的工具",
|
||||
"Click to show hidden tools": "点击显示隐藏的工具"
|
||||
}
|
||||
|
||||
191
apps/jingrow/frontend/src/shared/stores/tools.ts
Normal file
191
apps/jingrow/frontend/src/shared/stores/tools.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { t } from '../i18n'
|
||||
|
||||
export interface Tool {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
type?: 'route' | 'url'
|
||||
routeName?: string
|
||||
url?: string
|
||||
order?: number
|
||||
isDefault?: boolean
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'tools.userItems'
|
||||
const HIDDEN_DEFAULT_TOOLS_KEY = 'tools.hiddenDefaultTools'
|
||||
|
||||
// 默认工具列表(硬编码,一行一个,方便添加)
|
||||
function getDefaultTools(): Tool[] {
|
||||
return [
|
||||
{
|
||||
id: 'remove-background',
|
||||
name: t('Remove Background'),
|
||||
description: t('Remove background from images using AI technology'),
|
||||
category: 'Image Processing',
|
||||
icon: 'photo-edit',
|
||||
color: '#1fc76f',
|
||||
type: 'route',
|
||||
routeName: 'RemoveBackground',
|
||||
order: 1,
|
||||
isDefault: true
|
||||
},
|
||||
// 在这里添加更多默认工具,每行一个:
|
||||
// {
|
||||
// id: 'tool-id-2',
|
||||
// name: t('Tool Name'),
|
||||
// description: t('Tool description'),
|
||||
// category: 'Category',
|
||||
// icon: 'icon-name',
|
||||
// color: '#1fc76f',
|
||||
// type: 'route',
|
||||
// routeName: 'RouteName',
|
||||
// order: 2,
|
||||
// isDefault: true
|
||||
// },
|
||||
]
|
||||
}
|
||||
|
||||
function loadUserTools(): Tool[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveUserTools(tools: Tool[]) {
|
||||
const userTools = tools.filter(t => !t.isDefault)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(userTools))
|
||||
}
|
||||
|
||||
function loadHiddenDefaultTools(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_DEFAULT_TOOLS_KEY)
|
||||
if (!raw) return []
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveHiddenDefaultTools(hiddenIds: string[]) {
|
||||
localStorage.setItem(HIDDEN_DEFAULT_TOOLS_KEY, JSON.stringify(hiddenIds))
|
||||
}
|
||||
|
||||
export const useToolsStore = defineStore('tools', () => {
|
||||
const userTools = ref<Tool[]>(loadUserTools())
|
||||
const hiddenDefaultToolIds = ref<string[]>(loadHiddenDefaultTools())
|
||||
|
||||
// 合并显示所有工具:默认工具 + 用户工具
|
||||
const allTools = computed(() => {
|
||||
// 获取默认工具(排除隐藏的)
|
||||
const defaultTools = getDefaultTools()
|
||||
.filter(tool => !hiddenDefaultToolIds.value.includes(tool.id))
|
||||
.map(tool => ({ ...tool, isDefault: true }))
|
||||
|
||||
// 用户工具
|
||||
const userToolsList = [...userTools.value]
|
||||
.map(tool => ({ ...tool, isDefault: false }))
|
||||
|
||||
// 合并并排序:默认工具在前,用户工具在后,各自按order排序
|
||||
return [
|
||||
...defaultTools.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
...userToolsList.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
]
|
||||
})
|
||||
|
||||
// 获取隐藏的默认工具列表
|
||||
const hiddenTools = computed(() => {
|
||||
return getDefaultTools()
|
||||
.filter(tool => hiddenDefaultToolIds.value.includes(tool.id))
|
||||
.map(tool => ({ ...tool, isDefault: true }))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
})
|
||||
|
||||
// 添加用户工具
|
||||
function addUserTool(tool: Tool) {
|
||||
const defaultToolsCount = getDefaultTools().filter(
|
||||
t => !hiddenDefaultToolIds.value.includes(t.id)
|
||||
).length
|
||||
|
||||
tool.order = defaultToolsCount + userTools.value.length + 1
|
||||
tool.isDefault = false
|
||||
userTools.value.push(tool)
|
||||
saveUserTools(userTools.value)
|
||||
}
|
||||
|
||||
// 更新用户工具
|
||||
function updateUserTool(toolId: string, updates: Partial<Tool>) {
|
||||
const index = userTools.value.findIndex(t => t.id === toolId)
|
||||
if (index >= 0) {
|
||||
userTools.value[index] = { ...userTools.value[index], ...updates, isDefault: false }
|
||||
saveUserTools(userTools.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户工具
|
||||
function deleteUserTool(toolId: string) {
|
||||
userTools.value = userTools.value.filter(t => t.id !== toolId)
|
||||
// 重新分配 order
|
||||
const defaultToolsCount = getDefaultTools().filter(
|
||||
t => !hiddenDefaultToolIds.value.includes(t.id)
|
||||
).length
|
||||
userTools.value.forEach((t, index) => {
|
||||
t.order = defaultToolsCount + index + 1
|
||||
})
|
||||
saveUserTools(userTools.value)
|
||||
}
|
||||
|
||||
// 隐藏默认工具
|
||||
function hideDefaultTool(toolId: string) {
|
||||
if (!hiddenDefaultToolIds.value.includes(toolId)) {
|
||||
hiddenDefaultToolIds.value.push(toolId)
|
||||
saveHiddenDefaultTools(hiddenDefaultToolIds.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示隐藏的默认工具
|
||||
function showDefaultTool(toolId: string) {
|
||||
hiddenDefaultToolIds.value = hiddenDefaultToolIds.value.filter(id => id !== toolId)
|
||||
saveHiddenDefaultTools(hiddenDefaultToolIds.value)
|
||||
}
|
||||
|
||||
// 更新用户工具顺序
|
||||
function updateUserToolsOrder(newOrder: Tool[]) {
|
||||
const defaultToolsCount = getDefaultTools().filter(
|
||||
t => !hiddenDefaultToolIds.value.includes(t.id)
|
||||
).length
|
||||
|
||||
newOrder.forEach((tool, index) => {
|
||||
tool.order = defaultToolsCount + index + 1
|
||||
})
|
||||
|
||||
userTools.value = newOrder
|
||||
saveUserTools(userTools.value)
|
||||
}
|
||||
|
||||
return {
|
||||
userTools,
|
||||
hiddenDefaultToolIds,
|
||||
allTools,
|
||||
hiddenTools,
|
||||
addUserTool,
|
||||
updateUserTool,
|
||||
deleteUserTool,
|
||||
hideDefaultTool,
|
||||
showDefaultTool,
|
||||
updateUserToolsOrder,
|
||||
getDefaultTools
|
||||
}
|
||||
})
|
||||
|
||||
804
apps/jingrow/frontend/src/views/tools/RemoveBackground.vue
Normal file
804
apps/jingrow/frontend/src/views/tools/RemoveBackground.vue
Normal file
@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="remove-background-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="page-icon">
|
||||
<i class="fa fa-magic"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1>{{ t('Remove Background') }}</h1>
|
||||
<p class="page-description">{{ t('Remove background from images using AI technology') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="tool-container">
|
||||
<!-- 上传区域 -->
|
||||
<div class="upload-section">
|
||||
<div
|
||||
v-if="!uploadedImage"
|
||||
class="upload-area"
|
||||
:class="{ 'dragging': isDragging }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div class="upload-content">
|
||||
<div class="upload-icon">
|
||||
<i class="fa fa-cloud-upload"></i>
|
||||
</div>
|
||||
<h3>{{ t('Upload Image') }}</h3>
|
||||
<p>{{ t('Drag and drop your image here, or click to browse') }}</p>
|
||||
<p class="upload-hint">{{ t('Supports JPG, PNG, WebP formats') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览和处理区域 -->
|
||||
<div v-else class="preview-section">
|
||||
<div class="image-preview-container">
|
||||
<div class="preview-header">
|
||||
<h3>{{ t('Image Preview') }}</h3>
|
||||
<button class="change-image-btn" @click="resetUpload">
|
||||
<i class="fa fa-refresh"></i>
|
||||
{{ t('Change Image') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-grid">
|
||||
<!-- 原图 -->
|
||||
<div class="preview-card">
|
||||
<div class="preview-label">{{ t('Original') }}</div>
|
||||
<div class="image-wrapper">
|
||||
<img :src="uploadedImageUrl" alt="Original" />
|
||||
<div v-if="processing" class="processing-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('Processing...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 去背景后的图片 -->
|
||||
<div class="preview-card">
|
||||
<div class="preview-label">{{ t('Background Removed') }}</div>
|
||||
<div class="image-wrapper">
|
||||
<div v-if="!resultImage && !processing" class="placeholder">
|
||||
<i class="fa fa-image"></i>
|
||||
<p>{{ t('Result will appear here') }}</p>
|
||||
</div>
|
||||
<img v-else-if="resultImage" :src="resultImageUrl" alt="Result" />
|
||||
<div v-else class="processing-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('Processing...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="processing"
|
||||
:disabled="!uploadedImage || processing"
|
||||
@click="handleRemoveBackground"
|
||||
class="process-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fa fa-magic"></i>
|
||||
</template>
|
||||
{{ processing ? t('Processing...') : t('Remove Background') }}
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="resultImage"
|
||||
size="large"
|
||||
@click="handleDownload"
|
||||
class="download-btn"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fa fa-download"></i>
|
||||
</template>
|
||||
{{ t('Download') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div class="info-section">
|
||||
<div class="info-card">
|
||||
<div class="info-icon">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<h4>{{ t('How it works') }}</h4>
|
||||
<ul>
|
||||
<li>{{ t('Upload an image with a clear subject') }}</li>
|
||||
<li>{{ t('Click \"Remove Background\" to process') }}</li>
|
||||
<li>{{ t('Download the result with transparent background') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import axios from 'axios'
|
||||
import { get_session_api_headers } from '@/shared/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
// 文件相关
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const uploadedImage = ref<File | null>(null)
|
||||
const uploadedImageUrl = ref<string>('')
|
||||
const resultImage = ref<string>('') // Base64编码的结果图片
|
||||
const resultImageUrl = computed(() => {
|
||||
if (!resultImage.value) return ''
|
||||
// 如果已经是data URL格式,直接返回
|
||||
if (resultImage.value.startsWith('data:')) {
|
||||
return resultImage.value
|
||||
}
|
||||
// 否则添加data URL前缀
|
||||
return `data:image/png;base64,${resultImage.value}`
|
||||
})
|
||||
|
||||
// 状态
|
||||
const isDragging = ref(false)
|
||||
const processing = ref(false)
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理拖拽
|
||||
const handleDragOver = () => {
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
processFile(file)
|
||||
} else {
|
||||
message.warning(t('Please upload an image file'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件
|
||||
const processFile = (file: File) => {
|
||||
// 验证文件类型
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
if (!validTypes.includes(file.type)) {
|
||||
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小(最大10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
message.warning(t('Image size exceeds 10MB limit'))
|
||||
return
|
||||
}
|
||||
|
||||
uploadedImage.value = file
|
||||
resultImage.value = ''
|
||||
|
||||
// 创建预览URL
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedImageUrl.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// 重置上传
|
||||
const resetUpload = () => {
|
||||
uploadedImage.value = null
|
||||
uploadedImageUrl.value = ''
|
||||
resultImage.value = ''
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 上传图片并获取URL
|
||||
const uploadImageToServer = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
// 将文件转换为base64,然后使用download_image_to_local API
|
||||
const reader = new FileReader()
|
||||
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string
|
||||
resolve(result)
|
||||
}
|
||||
reader.onerror = reject
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
const dataUrl = await base64Promise
|
||||
|
||||
// 使用download_image_to_local API上传图片
|
||||
const response = await axios.post(
|
||||
'/jingrow.utils.fs.download_image_to_local',
|
||||
{
|
||||
image_url: dataUrl,
|
||||
filename: file.name
|
||||
},
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true,
|
||||
timeout: 30000
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data?.data?.success && response.data?.data?.local_path) {
|
||||
// 如果是相对路径,需要转换为完整URL
|
||||
const fileUrl = response.data.data.local_path
|
||||
if (fileUrl.startsWith('/')) {
|
||||
return `${window.location.origin}${fileUrl}`
|
||||
}
|
||||
return fileUrl
|
||||
}
|
||||
return null
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error)
|
||||
message.error(t('Failed to upload image: ') + (error.response?.data?.detail || error.message))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理去背景
|
||||
const handleRemoveBackground = async () => {
|
||||
if (!uploadedImage.value) {
|
||||
message.warning(t('Please upload an image first'))
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = true
|
||||
resultImage.value = ''
|
||||
|
||||
try {
|
||||
// 先上传图片获取URL
|
||||
const imageUrl = await uploadImageToServer(uploadedImage.value)
|
||||
if (!imageUrl) {
|
||||
processing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 调用去背景API
|
||||
const response = await axios.post(
|
||||
'/jingrow.tools.tools.remove_background',
|
||||
{
|
||||
image_urls: [imageUrl]
|
||||
},
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true,
|
||||
timeout: 180000 // 3分钟超时
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data?.success && response.data?.data?.length > 0) {
|
||||
const result = response.data.data[0]
|
||||
if (result.success && result.image_content) {
|
||||
resultImage.value = result.image_content
|
||||
message.success(t('Background removed successfully!'))
|
||||
} else {
|
||||
message.error(result.error || t('Failed to remove background'))
|
||||
}
|
||||
} else {
|
||||
message.error(response.data?.error || t('Failed to remove background'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Remove background error:', error)
|
||||
const errorMessage = error.response?.data?.error ||
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
t('Failed to remove background')
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载结果
|
||||
const handleDownload = () => {
|
||||
if (!resultImage.value) return
|
||||
|
||||
try {
|
||||
// 将base64转换为blob
|
||||
const base64Data = resultImage.value.includes(',')
|
||||
? resultImage.value.split(',')[1]
|
||||
: resultImage.value
|
||||
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'image/png' })
|
||||
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `removed-background-${Date.now()}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
message.success(t('Download started'))
|
||||
} catch (error: any) {
|
||||
console.error('Download error:', error)
|
||||
message.error(t('Failed to download image'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.remove-background-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1fc76f 0%, #16a085 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(31, 199, 111, 0.25);
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tool-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 上传区域 */
|
||||
.upload-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 64px 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #fafbfc;
|
||||
|
||||
&:hover {
|
||||
border-color: #1fc76f;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
border-color: #1fc76f;
|
||||
background: #ecfdf5;
|
||||
border-style: solid;
|
||||
transform: scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #1fc76f 0%, #16a085 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 4px 12px rgba(31, 199, 111, 0.25);
|
||||
|
||||
i {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-area p {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 预览区域 */
|
||||
.preview-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.change-image-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1fc76f;
|
||||
color: #1fc76f;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(45deg, #f1f5f9 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #f1f5f9 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #f1f5f9 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #f1f5f9 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: #cbd5e1;
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.processing-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #1fc76f;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.process-btn,
|
||||
.download-btn {
|
||||
min-width: 180px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.2);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(31, 199, 111, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.process-btn {
|
||||
background: linear-gradient(135deg, #1fc76f 0%, #16a085 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #1dd87f 0%, #1ab894 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: white;
|
||||
border: 2px solid #1fc76f;
|
||||
color: #1fc76f;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #1dd87f;
|
||||
color: #1dd87f;
|
||||
}
|
||||
}
|
||||
|
||||
/* 信息卡片 */
|
||||
.info-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: #1fc76f;
|
||||
}
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.remove-background-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
i {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
|
||||
.process-btn,
|
||||
.download-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && tools.length === 0" class="empty-state">
|
||||
<div v-if="!loading && displayTools.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fa fa-toolbox"></i>
|
||||
</div>
|
||||
@ -59,6 +59,31 @@
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<span>{{ t('Loading tools...') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的工具区域 -->
|
||||
<div v-if="hiddenTools.length > 0" class="hidden-tools-section">
|
||||
<div class="hidden-tools-header">
|
||||
<h3>{{ t('Hidden Tools') }}</h3>
|
||||
<p class="hidden-tools-hint">{{ t('Click to show hidden tools') }}</p>
|
||||
</div>
|
||||
<div class="hidden-tools-list">
|
||||
<div
|
||||
v-for="tool in hiddenTools"
|
||||
:key="tool.id"
|
||||
class="hidden-tool-item"
|
||||
@click="handleShowDefaultTool(tool.id)"
|
||||
>
|
||||
<div class="hidden-tool-icon" :style="{ backgroundColor: tool.color || '#1fc76f' }">
|
||||
<DynamicIcon :name="tool.icon || 'tool'" :size="24" color="white" />
|
||||
</div>
|
||||
<div class="hidden-tool-name">{{ tool.name }}</div>
|
||||
<button class="show-tool-btn" @click.stop="handleShowDefaultTool(tool.id)">
|
||||
<i class="fa fa-eye"></i>
|
||||
{{ t('Show') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑工具对话框 -->
|
||||
@ -138,21 +163,7 @@ import { NModal, NForm, NFormItem, NInput, NSelect, NAutoComplete, NColorPicker,
|
||||
import { t } from '../../shared/i18n'
|
||||
import DynamicIcon from '../../core/components/DynamicIcon.vue'
|
||||
import IconPicker from '../../core/components/IconPicker.vue'
|
||||
|
||||
interface Tool {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
type?: 'route' | 'url' // 工具类型:内部路由或外部URL
|
||||
routeName?: string // 路由名(当type为route时使用)
|
||||
url?: string // URL路径(当type为url时使用)
|
||||
order?: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'tools.items'
|
||||
import { useToolsStore, type Tool } from '../../shared/stores/tools'
|
||||
|
||||
// 生成 UUID(兼容所有环境)
|
||||
function generateUUID(): string {
|
||||
@ -169,27 +180,9 @@ function generateUUID(): string {
|
||||
})
|
||||
}
|
||||
|
||||
// 加载工具列表
|
||||
function loadTools(): Tool[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 保存工具列表
|
||||
function saveTools(tools: Tool[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tools))
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const toolsStore = useToolsStore()
|
||||
const loading = ref(false)
|
||||
const tools = ref<Tool[]>(loadTools())
|
||||
const showToolModal = ref(false)
|
||||
const editingTool = ref<Tool | null>(null)
|
||||
const toolFormRef = ref<FormInst | null>(null)
|
||||
@ -271,13 +264,13 @@ function onToolTypeChange() {
|
||||
toolForm.value.url = ''
|
||||
}
|
||||
|
||||
// 按 order 排序显示
|
||||
const displayTools = computed(() => {
|
||||
return [...tools.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
})
|
||||
// 使用 store 中的工具列表
|
||||
const displayTools = computed(() => toolsStore.allTools)
|
||||
|
||||
// 隐藏的工具列表
|
||||
const hiddenTools = computed(() => toolsStore.hiddenTools)
|
||||
|
||||
onMounted(() => {
|
||||
// 可以在这里加载远程数据
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
@ -309,17 +302,34 @@ function handleDrop(event: DragEvent, dropIndexValue: number) {
|
||||
event.preventDefault()
|
||||
if (!draggedTool.value || draggedIndex.value === -1) return
|
||||
|
||||
const newTools = [...tools.value]
|
||||
const dragged = newTools.splice(draggedIndex.value, 1)[0]
|
||||
newTools.splice(dropIndexValue, 0, dragged)
|
||||
const dragged = draggedTool.value
|
||||
|
||||
// 默认工具不能拖拽排序
|
||||
if (dragged.isDefault) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
// 重新分配 order
|
||||
newTools.forEach((tool, index) => {
|
||||
tool.order = index + 1
|
||||
})
|
||||
// 只对用户工具进行排序
|
||||
const newUserTools = [...toolsStore.userTools]
|
||||
const draggedIndexInUser = newUserTools.findIndex(t => t.id === dragged.id)
|
||||
|
||||
if (draggedIndexInUser < 0) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
tools.value = newTools
|
||||
saveTools(tools.value)
|
||||
// 计算在用户工具中的新位置
|
||||
const defaultToolsCount = toolsStore.getDefaultTools().filter(
|
||||
t => !toolsStore.hiddenDefaultToolIds.includes(t.id)
|
||||
).length
|
||||
const newIndexInUser = Math.max(0, dropIndexValue - defaultToolsCount)
|
||||
|
||||
const draggedToolItem = newUserTools.splice(draggedIndexInUser, 1)[0]
|
||||
newUserTools.splice(newIndexInUser, 0, draggedToolItem)
|
||||
|
||||
// 更新顺序
|
||||
toolsStore.updateUserToolsOrder(newUserTools)
|
||||
|
||||
resetDragState()
|
||||
}
|
||||
@ -386,22 +396,23 @@ function handleSaveTool() {
|
||||
}
|
||||
|
||||
if (editingTool.value) {
|
||||
// 更新
|
||||
const index = tools.value.findIndex(t => t.id === editingTool.value!.id)
|
||||
if (index >= 0) {
|
||||
const updatedTool: Tool = {
|
||||
...editingTool.value,
|
||||
...toolForm.value,
|
||||
// 清理不需要的字段
|
||||
routeName: toolForm.value.type === 'route' ? toolForm.value.routeName : undefined,
|
||||
url: toolForm.value.type === 'url' ? toolForm.value.url : undefined
|
||||
}
|
||||
tools.value[index] = updatedTool
|
||||
saveTools(tools.value)
|
||||
message.success(t('Tool updated successfully'))
|
||||
// 更新(只能更新用户工具,默认工具不能编辑)
|
||||
if (editingTool.value.isDefault) {
|
||||
message.warning(t('Default tools cannot be edited'))
|
||||
return
|
||||
}
|
||||
|
||||
const updates: Partial<Tool> = {
|
||||
...toolForm.value,
|
||||
// 清理不需要的字段
|
||||
routeName: toolForm.value.type === 'route' ? toolForm.value.routeName : undefined,
|
||||
url: toolForm.value.type === 'url' ? toolForm.value.url : undefined
|
||||
}
|
||||
|
||||
toolsStore.updateUserTool(editingTool.value.id, updates)
|
||||
message.success(t('Tool updated successfully'))
|
||||
} else {
|
||||
// 新增
|
||||
// 新增用户工具
|
||||
const newTool: Tool = {
|
||||
id: generateUUID(),
|
||||
name: toolForm.value.name!,
|
||||
@ -412,10 +423,10 @@ function handleSaveTool() {
|
||||
type: toolForm.value.type || 'route',
|
||||
routeName: toolForm.value.type === 'route' ? toolForm.value.routeName : undefined,
|
||||
url: toolForm.value.type === 'url' ? toolForm.value.url : undefined,
|
||||
order: tools.value.length + 1
|
||||
isDefault: false
|
||||
}
|
||||
tools.value.push(newTool)
|
||||
saveTools(tools.value)
|
||||
|
||||
toolsStore.addUserTool(newTool)
|
||||
message.success(t('Tool added successfully'))
|
||||
}
|
||||
|
||||
@ -424,23 +435,40 @@ function handleSaveTool() {
|
||||
}
|
||||
|
||||
function handleDeleteTool(tool: Tool) {
|
||||
// 默认工具不能删除,只能隐藏
|
||||
if (tool.isDefault) {
|
||||
dialog.warning({
|
||||
title: t('Hide Default Tool'),
|
||||
content: `${t('Are you sure you want to hide default tool')} "${tool.name}"? ${t('You can show it again later')}.`,
|
||||
positiveText: t('Hide'),
|
||||
negativeText: t('Cancel'),
|
||||
onPositiveClick: () => {
|
||||
toolsStore.hideDefaultTool(tool.id)
|
||||
message.success(t('Tool hidden successfully'))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除用户工具
|
||||
dialog.warning({
|
||||
title: t('Confirm Delete'),
|
||||
content: `${t('Are you sure you want to delete tool')} "${tool.name}"?`,
|
||||
positiveText: t('Delete'),
|
||||
negativeText: t('Cancel'),
|
||||
onPositiveClick: () => {
|
||||
tools.value = tools.value.filter(t => t.id !== tool.id)
|
||||
// 重新分配 order
|
||||
tools.value.forEach((t, index) => {
|
||||
t.order = index + 1
|
||||
})
|
||||
saveTools(tools.value)
|
||||
toolsStore.deleteUserTool(tool.id)
|
||||
message.success(t('Tool deleted successfully'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示隐藏的默认工具
|
||||
function handleShowDefaultTool(toolId: string) {
|
||||
toolsStore.showDefaultTool(toolId)
|
||||
message.success(t('Tool shown successfully'))
|
||||
}
|
||||
|
||||
function handleOpenTool(tool: Tool) {
|
||||
if (tool.type === 'route' && tool.routeName) {
|
||||
// 使用路由名导航(最佳实践)
|
||||
@ -469,19 +497,43 @@ function handleOpenTool(tool: Tool) {
|
||||
}
|
||||
|
||||
// 获取工具菜单选项
|
||||
function getToolMenuOptions(_tool: Tool): DropdownOption[] {
|
||||
return [
|
||||
{
|
||||
label: t('Edit'),
|
||||
key: 'edit',
|
||||
icon: () => h('i', { class: 'fa fa-edit' })
|
||||
},
|
||||
{
|
||||
label: t('Delete'),
|
||||
key: 'delete',
|
||||
icon: () => h('i', { class: 'fa fa-trash' })
|
||||
function getToolMenuOptions(tool: Tool): DropdownOption[] {
|
||||
const options: DropdownOption[] = []
|
||||
|
||||
// 默认工具:显示"隐藏"选项
|
||||
if (tool.isDefault) {
|
||||
if (toolsStore.hiddenDefaultToolIds.includes(tool.id)) {
|
||||
// 已隐藏,显示"显示"选项
|
||||
options.push({
|
||||
label: t('Show'),
|
||||
key: 'show',
|
||||
icon: () => h('i', { class: 'fa fa-eye' })
|
||||
})
|
||||
} else {
|
||||
// 未隐藏,显示"隐藏"选项
|
||||
options.push({
|
||||
label: t('Hide'),
|
||||
key: 'hide',
|
||||
icon: () => h('i', { class: 'fa fa-eye-slash' })
|
||||
})
|
||||
}
|
||||
]
|
||||
} else {
|
||||
// 用户工具:显示"编辑"和"删除"选项
|
||||
options.push(
|
||||
{
|
||||
label: t('Edit'),
|
||||
key: 'edit',
|
||||
icon: () => h('i', { class: 'fa fa-edit' })
|
||||
},
|
||||
{
|
||||
label: t('Delete'),
|
||||
key: 'delete',
|
||||
icon: () => h('i', { class: 'fa fa-trash' })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// 处理菜单选择
|
||||
@ -490,6 +542,13 @@ function handleMenuSelect(key: string, tool: Tool) {
|
||||
handleEditTool(tool)
|
||||
} else if (key === 'delete') {
|
||||
handleDeleteTool(tool)
|
||||
} else if (key === 'hide') {
|
||||
// 隐藏默认工具
|
||||
toolsStore.hideDefaultTool(tool.id)
|
||||
message.success(t('Tool hidden successfully'))
|
||||
} else if (key === 'show') {
|
||||
// 显示隐藏的默认工具
|
||||
handleShowDefaultTool(tool.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -766,6 +825,100 @@ function handleMenuSelect(key: string, tool: Tool) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 隐藏的工具区域 */
|
||||
.hidden-tools-section {
|
||||
margin-top: 32px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.hidden-tools-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hidden-tools-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.hidden-tools-hint {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hidden-tools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hidden-tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hidden-tool-item:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hidden-tool-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hidden-tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.show-tool-btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #1fc76f;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #1fc76f;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.show-tool-btn:hover {
|
||||
background: #f0fdf4;
|
||||
border-color: #1dd87f;
|
||||
color: #1dd87f;
|
||||
}
|
||||
|
||||
.show-tool-btn i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1920px) {
|
||||
.tools-grid {
|
||||
|
||||
@ -7,7 +7,7 @@ class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# Jingrow API配置
|
||||
jingrow_server_url: str = ''
|
||||
jingrow_server_url: str = 'https://cloud.jingrow.com'
|
||||
jingrow_api_key: str = ''
|
||||
jingrow_api_secret: str = ''
|
||||
|
||||
|
||||
0
apps/jingrow/jingrow/tools/__init__.py
Normal file
0
apps/jingrow/jingrow/tools/__init__.py
Normal file
157
apps/jingrow/jingrow/tools/tools.py
Normal file
157
apps/jingrow/jingrow/tools/tools.py
Normal file
@ -0,0 +1,157 @@
|
||||
# Copyright (c) 2025, JINGROW and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""
|
||||
Jingrow Tools API
|
||||
提供各种工具服务的API端点
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Any, List, Union
|
||||
import logging
|
||||
|
||||
import jingrow
|
||||
from jingrow.utils.auth import get_jingrow_cloud_api_headers, get_jingrow_cloud_api_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@jingrow.whitelist()
|
||||
def remove_background(image_urls: Union[str, List[str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
图片去背景工具
|
||||
调用 Jingrow Cloud API 实现图片背景移除
|
||||
|
||||
Args:
|
||||
image_urls (str | List[str]): 图片URL(单个URL字符串或URL列表)
|
||||
|
||||
Returns:
|
||||
dict: 处理结果
|
||||
{
|
||||
'success': bool,
|
||||
'data': List[dict], # 处理结果列表(成功时)
|
||||
'error': str, # 错误信息(失败时)
|
||||
'total_processed': int, # 处理总数
|
||||
'total_success': int # 成功数量
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 获取API URL和认证头
|
||||
api_url = f"{get_jingrow_cloud_api_url()}/rmbg/batch"
|
||||
headers = get_jingrow_cloud_api_headers()
|
||||
|
||||
if not headers or not headers.get('Authorization'):
|
||||
error_message = "API密钥未设置,请在设置中配置 Jingrow Cloud Api Key 和 Jingrow Cloud Api Secret"
|
||||
logger.error(error_message)
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_message
|
||||
}
|
||||
|
||||
# 处理单个URL或URL列表
|
||||
if isinstance(image_urls, str):
|
||||
image_urls = [image_urls]
|
||||
|
||||
if not image_urls:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "未提供图片URL"
|
||||
}
|
||||
|
||||
# 准备请求数据
|
||||
request_data = {"urls": image_urls}
|
||||
|
||||
# 调用API并获取流式响应
|
||||
response = requests.post(
|
||||
api_url,
|
||||
json=request_data,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_message = error_data.get("message") or error_data.get("error") or f"API请求失败 (HTTP {response.status_code})"
|
||||
except:
|
||||
error_message = f"API请求失败 (HTTP {response.status_code})"
|
||||
|
||||
if response.status_code in [401, 403]:
|
||||
error_message = "API认证失败,请检查认证信息"
|
||||
|
||||
logger.error(f"图片去背景API调用失败: {error_message}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_message
|
||||
}
|
||||
|
||||
# 处理流式响应
|
||||
results = []
|
||||
total_processed = 0
|
||||
total_success = 0
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
result = json.loads(line)
|
||||
total_processed += 1
|
||||
|
||||
if result.get('status') == 'success' and 'image_content' in result:
|
||||
# 获取图片内容(base64编码)
|
||||
image_content = result.get('image_content', '')
|
||||
# 如果包含 data:image 前缀,提取base64部分
|
||||
if ',' in image_content:
|
||||
image_content = image_content.split(',')[1]
|
||||
|
||||
results.append({
|
||||
"success": True,
|
||||
"original_url": result.get('original_url', ''),
|
||||
"image_content": image_content, # Base64编码的图片数据
|
||||
"index": result.get('index', 0),
|
||||
"total": result.get('total', 1)
|
||||
})
|
||||
total_success += 1
|
||||
else:
|
||||
error_message = result.get('message', '未知错误')
|
||||
results.append({
|
||||
"success": False,
|
||||
"error": error_message,
|
||||
"original_url": result.get('original_url', ''),
|
||||
"index": result.get('index', 0),
|
||||
"total": result.get('total', 1)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"解析流式响应行失败: {e}")
|
||||
continue
|
||||
|
||||
if total_processed == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "未能获取到任何响应数据"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": results,
|
||||
"total_processed": total_processed,
|
||||
"total_success": total_success
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
error_message = "请求超时,请稍后重试"
|
||||
logger.error(f"图片去背景API调用超时: {error_message}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_message
|
||||
}
|
||||
except Exception as e:
|
||||
error_message = f"调用图片去背景API异常:{str(e)}"
|
||||
logger.error(error_message, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_message
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user