Add tools management system with image background removal

This commit is contained in:
jingrow 2025-11-19 03:14:36 +08:00
parent bb93543abc
commit bf9d52a2db
8 changed files with 1431 additions and 86 deletions

View File

@ -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',

View File

@ -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": "点击显示隐藏的工具"
}

View 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
}
})

View 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 {
// base64blob
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>

View File

@ -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 // typeroute使
url?: string // URLtypeurl使
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 {

View File

@ -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 = ''

View File

View 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
}