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',
|
name: 'Tools',
|
||||||
component: () => import('../../views/tools/Tools.vue')
|
component: () => import('../../views/tools/Tools.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tools/remove-background',
|
||||||
|
name: 'RemoveBackground',
|
||||||
|
component: () => import('../../views/tools/RemoveBackground.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
name: 'SearchResults',
|
name: 'SearchResults',
|
||||||
|
|||||||
@ -1082,5 +1082,40 @@
|
|||||||
"Tool added successfully": "工具添加成功",
|
"Tool added successfully": "工具添加成功",
|
||||||
"Are you sure you want to delete tool": "确定要删除工具",
|
"Are you sure you want to delete tool": "确定要删除工具",
|
||||||
"Tool deleted successfully": "工具删除成功",
|
"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 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">
|
<div class="empty-icon">
|
||||||
<i class="fa fa-toolbox"></i>
|
<i class="fa fa-toolbox"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -59,6 +59,31 @@
|
|||||||
<i class="fa fa-spinner fa-spin"></i>
|
<i class="fa fa-spinner fa-spin"></i>
|
||||||
<span>{{ t('Loading tools...') }}</span>
|
<span>{{ t('Loading tools...') }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 添加/编辑工具对话框 -->
|
<!-- 添加/编辑工具对话框 -->
|
||||||
@ -138,21 +163,7 @@ import { NModal, NForm, NFormItem, NInput, NSelect, NAutoComplete, NColorPicker,
|
|||||||
import { t } from '../../shared/i18n'
|
import { t } from '../../shared/i18n'
|
||||||
import DynamicIcon from '../../core/components/DynamicIcon.vue'
|
import DynamicIcon from '../../core/components/DynamicIcon.vue'
|
||||||
import IconPicker from '../../core/components/IconPicker.vue'
|
import IconPicker from '../../core/components/IconPicker.vue'
|
||||||
|
import { useToolsStore, type Tool } from '../../shared/stores/tools'
|
||||||
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'
|
|
||||||
|
|
||||||
// 生成 UUID(兼容所有环境)
|
// 生成 UUID(兼容所有环境)
|
||||||
function generateUUID(): string {
|
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 router = useRouter()
|
||||||
|
const toolsStore = useToolsStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tools = ref<Tool[]>(loadTools())
|
|
||||||
const showToolModal = ref(false)
|
const showToolModal = ref(false)
|
||||||
const editingTool = ref<Tool | null>(null)
|
const editingTool = ref<Tool | null>(null)
|
||||||
const toolFormRef = ref<FormInst | null>(null)
|
const toolFormRef = ref<FormInst | null>(null)
|
||||||
@ -271,13 +264,13 @@ function onToolTypeChange() {
|
|||||||
toolForm.value.url = ''
|
toolForm.value.url = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 order 排序显示
|
// 使用 store 中的工具列表
|
||||||
const displayTools = computed(() => {
|
const displayTools = computed(() => toolsStore.allTools)
|
||||||
return [...tools.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
||||||
})
|
// 隐藏的工具列表
|
||||||
|
const hiddenTools = computed(() => toolsStore.hiddenTools)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 可以在这里加载远程数据
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -309,17 +302,34 @@ function handleDrop(event: DragEvent, dropIndexValue: number) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!draggedTool.value || draggedIndex.value === -1) return
|
if (!draggedTool.value || draggedIndex.value === -1) return
|
||||||
|
|
||||||
const newTools = [...tools.value]
|
const dragged = draggedTool.value
|
||||||
const dragged = newTools.splice(draggedIndex.value, 1)[0]
|
|
||||||
newTools.splice(dropIndexValue, 0, dragged)
|
// 默认工具不能拖拽排序
|
||||||
|
if (dragged.isDefault) {
|
||||||
|
resetDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 重新分配 order
|
// 只对用户工具进行排序
|
||||||
newTools.forEach((tool, index) => {
|
const newUserTools = [...toolsStore.userTools]
|
||||||
tool.order = index + 1
|
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()
|
resetDragState()
|
||||||
}
|
}
|
||||||
@ -386,22 +396,23 @@ function handleSaveTool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editingTool.value) {
|
if (editingTool.value) {
|
||||||
// 更新
|
// 更新(只能更新用户工具,默认工具不能编辑)
|
||||||
const index = tools.value.findIndex(t => t.id === editingTool.value!.id)
|
if (editingTool.value.isDefault) {
|
||||||
if (index >= 0) {
|
message.warning(t('Default tools cannot be edited'))
|
||||||
const updatedTool: Tool = {
|
return
|
||||||
...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'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
// 新增
|
// 新增用户工具
|
||||||
const newTool: Tool = {
|
const newTool: Tool = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
name: toolForm.value.name!,
|
name: toolForm.value.name!,
|
||||||
@ -412,10 +423,10 @@ function handleSaveTool() {
|
|||||||
type: toolForm.value.type || 'route',
|
type: toolForm.value.type || 'route',
|
||||||
routeName: toolForm.value.type === 'route' ? toolForm.value.routeName : undefined,
|
routeName: toolForm.value.type === 'route' ? toolForm.value.routeName : undefined,
|
||||||
url: toolForm.value.type === 'url' ? toolForm.value.url : 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'))
|
message.success(t('Tool added successfully'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,23 +435,40 @@ function handleSaveTool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteTool(tool: Tool) {
|
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({
|
dialog.warning({
|
||||||
title: t('Confirm Delete'),
|
title: t('Confirm Delete'),
|
||||||
content: `${t('Are you sure you want to delete tool')} "${tool.name}"?`,
|
content: `${t('Are you sure you want to delete tool')} "${tool.name}"?`,
|
||||||
positiveText: t('Delete'),
|
positiveText: t('Delete'),
|
||||||
negativeText: t('Cancel'),
|
negativeText: t('Cancel'),
|
||||||
onPositiveClick: () => {
|
onPositiveClick: () => {
|
||||||
tools.value = tools.value.filter(t => t.id !== tool.id)
|
toolsStore.deleteUserTool(tool.id)
|
||||||
// 重新分配 order
|
|
||||||
tools.value.forEach((t, index) => {
|
|
||||||
t.order = index + 1
|
|
||||||
})
|
|
||||||
saveTools(tools.value)
|
|
||||||
message.success(t('Tool deleted successfully'))
|
message.success(t('Tool deleted successfully'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示隐藏的默认工具
|
||||||
|
function handleShowDefaultTool(toolId: string) {
|
||||||
|
toolsStore.showDefaultTool(toolId)
|
||||||
|
message.success(t('Tool shown successfully'))
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenTool(tool: Tool) {
|
function handleOpenTool(tool: Tool) {
|
||||||
if (tool.type === 'route' && tool.routeName) {
|
if (tool.type === 'route' && tool.routeName) {
|
||||||
// 使用路由名导航(最佳实践)
|
// 使用路由名导航(最佳实践)
|
||||||
@ -469,19 +497,43 @@ function handleOpenTool(tool: Tool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取工具菜单选项
|
// 获取工具菜单选项
|
||||||
function getToolMenuOptions(_tool: Tool): DropdownOption[] {
|
function getToolMenuOptions(tool: Tool): DropdownOption[] {
|
||||||
return [
|
const options: DropdownOption[] = []
|
||||||
{
|
|
||||||
label: t('Edit'),
|
// 默认工具:显示"隐藏"选项
|
||||||
key: 'edit',
|
if (tool.isDefault) {
|
||||||
icon: () => h('i', { class: 'fa fa-edit' })
|
if (toolsStore.hiddenDefaultToolIds.includes(tool.id)) {
|
||||||
},
|
// 已隐藏,显示"显示"选项
|
||||||
{
|
options.push({
|
||||||
label: t('Delete'),
|
label: t('Show'),
|
||||||
key: 'delete',
|
key: 'show',
|
||||||
icon: () => h('i', { class: 'fa fa-trash' })
|
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)
|
handleEditTool(tool)
|
||||||
} else if (key === 'delete') {
|
} else if (key === 'delete') {
|
||||||
handleDeleteTool(tool)
|
handleDeleteTool(tool)
|
||||||
|
} else if (key === 'hide') {
|
||||||
|
// 隐藏默认工具
|
||||||
|
toolsStore.hideDefaultTool(tool.id)
|
||||||
|
message.success(t('Tool hidden successfully'))
|
||||||
|
} else if (key === 'show') {
|
||||||
|
// 显示隐藏的默认工具
|
||||||
|
handleShowDefaultTool(tool.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -766,6 +825,100 @@ function handleMenuSelect(key: string, tool: Tool) {
|
|||||||
margin-right: 12px;
|
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) {
|
@media (max-width: 1920px) {
|
||||||
.tools-grid {
|
.tools-grid {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ class Settings(BaseSettings):
|
|||||||
"""应用配置"""
|
"""应用配置"""
|
||||||
|
|
||||||
# Jingrow API配置
|
# Jingrow API配置
|
||||||
jingrow_server_url: str = ''
|
jingrow_server_url: str = 'https://cloud.jingrow.com'
|
||||||
jingrow_api_key: str = ''
|
jingrow_api_key: str = ''
|
||||||
jingrow_api_secret: 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