reorganize tool marketplace architecture

This commit is contained in:
jingrow 2025-11-21 12:32:45 +08:00
parent 366c654e9e
commit 2c030be331
8 changed files with 1220 additions and 13 deletions

View File

@ -87,7 +87,7 @@ const router = createRouter({
{
path: 'tools',
name: 'Tools',
component: () => import('../../views/tools/Tools.vue')
component: () => import('../../views/Tools.vue')
},
{
path: 'tools/remove-background',
@ -145,6 +145,16 @@ const router = createRouter({
name: 'AgentDetail',
component: () => import('../../views/dev/AgentDetail.vue')
},
{
path: 'tool-marketplace',
name: 'ToolMarketplace',
component: () => import('../../views/dev/ToolMarketplace.vue')
},
{
path: 'tool-marketplace/:name',
name: 'ToolDetail',
component: () => import('../../views/dev/ToolDetail.vue')
},
{
path: 'app-marketplace/:name',
name: 'AppDetail',

View File

@ -68,6 +68,7 @@ function getDefaultMenus(): AppMenuItem[] {
{ id: 'app-marketplace', key: 'AppMarketplace', label: 'App Marketplace', icon: 'tabler:shopping-cart', type: 'route', routeName: 'AppMarketplace', parentId: 'dev-group', order: 7.5 },
{ id: 'node-marketplace', key: 'NodeMarketplace', label: 'Node Marketplace', icon: 'carbon:add-child-node', type: 'route', routeName: 'NodeMarketplace', parentId: 'dev-group', order: 8 },
{ id: 'agent-marketplace', key: 'AgentMarketplace', label: 'Agent Marketplace', icon: 'hugeicons:robotic', type: 'route', routeName: 'AgentMarketplace', parentId: 'dev-group', order: 8.5 },
{ id: 'tool-marketplace', key: 'ToolMarketplace', label: 'Tool Marketplace', icon: 'tabler:tool', type: 'route', routeName: 'ToolMarketplace', parentId: 'dev-group', order: 8.7 },
{ id: 'menuManager', key: 'MenuManager', label: 'Menu Management', icon: 'tabler:menu-2', type: 'route', routeName: 'MenuManager', order: 11 },
{ id: 'settings', key: 'Settings', label: 'Settings', icon: 'tabler:settings', routeName: 'Settings', order: 12, type: 'route' }
]
@ -189,9 +190,9 @@ export const useMenuStore = defineStore('menu', () => {
return false
}
// 开发分组下只允许显示:应用市场、节点市场、智能体市场
// 开发分组下只允许显示:应用市场、节点市场、智能体市场、工具市场
if (m.parentId === 'dev-group') {
const allowedDevMenus = ['app-marketplace', 'node-marketplace', 'agent-marketplace']
const allowedDevMenus = ['app-marketplace', 'node-marketplace', 'agent-marketplace', 'tool-marketplace']
if (!allowedDevMenus.includes(m.id)) {
return false
}

View File

@ -15,6 +15,13 @@ export interface Tool {
order?: number
isDefault?: boolean
hidden?: boolean
// 市场工具相关
fromMarketplace?: boolean
marketplaceId?: string
version?: string
author?: string
rating?: number
downloads?: number
}
const STORAGE_KEY = 'tools.userItems'

View File

@ -2,10 +2,16 @@
<div class="tools-page">
<div class="page-header">
<h2>{{ t('Tools') }}</h2>
<button class="add-tool-btn" @click="handleAddTool">
<i class="fa fa-plus"></i>
{{ t('Add Tool') }}
</button>
<div class="header-actions">
<button class="marketplace-btn" @click="handleOpenMarketplace">
<i class="fa fa-store"></i>
{{ t('Marketplace') }}
</button>
<button class="add-tool-btn" @click="handleAddTool">
<i class="fa fa-plus"></i>
{{ t('Add Tool') }}
</button>
</div>
</div>
<div class="page-content">
@ -172,10 +178,10 @@
import { ref, computed, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
import { NModal, NForm, NFormItem, NInput, NSelect, NAutoComplete, NColorPicker, NButton, NSpace, NText, NDropdown, useDialog, useMessage, type FormInst, type FormRules, type DropdownOption } from 'naive-ui'
import { t } from '../../shared/i18n'
import DynamicIcon from '../../core/components/DynamicIcon.vue'
import IconPicker from '../../core/components/IconPicker.vue'
import { useToolsStore, type Tool } from '../../shared/stores/tools'
import { t } from '@/shared/i18n'
import DynamicIcon from '@/core/components/DynamicIcon.vue'
import IconPicker from '@/core/components/IconPicker.vue'
import { useToolsStore, type Tool } from '@/shared/stores/tools'
// UUID
function generateUUID(): string {
@ -481,6 +487,10 @@ function handleShowDefaultTool(toolId: string) {
message.success(t('Tool shown successfully'))
}
function handleOpenMarketplace() {
router.push({ name: 'ToolMarketplace' })
}
function handleOpenTool(tool: Tool) {
if (tool.type === 'route' && tool.routeName) {
// 使
@ -588,6 +598,36 @@ function handleMenuSelect(key: string, tool: Tool) {
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.marketplace-btn {
height: 36px;
padding: 0 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.marketplace-btn:hover {
background: #f9fafb;
border-color: #cbd5e1;
color: #475569;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.add-tool-btn {
height: 36px;
padding: 0 16px;

View File

@ -0,0 +1,521 @@
<template>
<div class="tool-detail">
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1>{{ tool?.title || tool?.name || t('Tool Details') }}</h1>
</div>
<div class="header-actions">
<n-button @click="goBack" size="medium">
<template #icon>
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
</template>
{{ t('Back') }}
</n-button>
<n-button
:type="isCurrentToolInstalled ? 'warning' : 'primary'"
@click="installTool"
size="medium"
>
<template #icon>
<n-icon>
<Icon :icon="isCurrentToolInstalled ? 'tabler:check' : 'tabler:download'" />
</n-icon>
</template>
{{ isCurrentToolInstalled ? t('Installed') : t('Install') }}
</n-button>
</div>
</div>
</div>
<div v-if="loading" class="loading-container">
<n-spin size="large">
<template #description>
{{ t('Loading tool details...') }}
</template>
</n-spin>
</div>
<div v-else-if="error" class="error-container">
<n-empty :description="error">
<template #icon>
<n-icon><Icon icon="tabler:alert-circle" /></n-icon>
</template>
</n-empty>
</div>
<div v-else-if="tool" class="tool-content">
<!-- 整体卡片布局 -->
<div class="tool-card">
<!-- 上部分工具信息 -->
<div class="tool-info-section">
<div class="tool-content-layout">
<!-- 左侧工具图标 -->
<div class="tool-image-section">
<div class="tool-image">
<div v-if="tool.icon" class="tool-icon-container">
<Icon
:icon="tool.icon"
:width="120"
:height="120"
:style="{ color: tool.color || '#64748b' }"
/>
</div>
<div v-else class="placeholder-image">
<n-icon size="80"><Icon icon="tabler:tool" /></n-icon>
</div>
</div>
</div>
<!-- 右侧工具信息 -->
<div class="tool-info-content">
<div class="tool-header">
<h2 class="tool-title">{{ tool.title || tool.name || t('Untitled Tool') }}</h2>
<div v-if="tool.subtitle" class="tool-subtitle">{{ tool.subtitle }}</div>
</div>
<div class="info-list">
<div v-if="tool.name" class="info-item">
<span class="label">{{ t('Tool Name') }}:</span>
<span class="value">{{ tool.name }}</span>
</div>
<div v-if="tool.category" class="info-item">
<span class="label">{{ t('Category') }}:</span>
<span class="value">{{ tool.category }}</span>
</div>
<div v-if="tool.author" class="info-item">
<span class="label">{{ t('Author') }}:</span>
<span class="value">{{ tool.author }}</span>
</div>
<div v-if="tool.version" class="info-item">
<span class="label">{{ t('Version') }}:</span>
<span class="value">{{ tool.version }}</span>
</div>
<div v-if="tool.type" class="info-item">
<span class="label">{{ t('Type') }}:</span>
<span class="value">{{ tool.type }}</span>
</div>
<div v-if="tool.route_name" class="info-item">
<span class="label">{{ t('Route Name') }}:</span>
<span class="value">{{ tool.route_name }}</span>
</div>
<div v-if="tool.url" class="info-item">
<span class="label">{{ t('URL') }}:</span>
<a :href="tool.url" target="_blank" class="link">
{{ tool.url }}
</a>
</div>
<div v-if="tool.creation" class="info-item">
<span class="label">{{ t('Created') }}:</span>
<span class="value">{{ formatDate(tool.creation) }}</span>
</div>
<div v-if="tool.modified" class="info-item">
<span class="label">{{ t('Last Updated') }}:</span>
<span class="value">{{ formatDate(tool.modified) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 下部分描述内容 -->
<div v-if="tool.description" class="description-section">
<h3>{{ t('Description') }}</h3>
<div class="description-content" v-html="tool.description"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_session_api_headers } from '@/shared/api/auth'
import { NButton, NIcon, NSpin, NEmpty, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import axios from 'axios'
import { t } from '@/shared/i18n'
import { useToolsStore } from '@/shared/stores/tools'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const toolsStore = useToolsStore()
const loading = ref(true)
const error = ref('')
const tool = ref<any>(null)
//
const installedToolNames = ref<Set<string>>(new Set())
const toolName = computed(() => route.params.name as string)
//
const isCurrentToolInstalled = computed(() => {
if (!tool.value) return false
return isToolInstalled(tool.value.name || '')
})
async function loadToolDetail() {
loading.value = true
error.value = ''
try {
const response = await axios.get(`/jingrow/tool-marketplace/${toolName.value}`)
tool.value = response.data
} catch (err: any) {
console.error('Failed to load tool detail:', err)
error.value = err.response?.data?.detail || t('Failed to load tool details')
} finally {
loading.value = false
}
}
function formatDate(dateString: string): string {
if (!dateString) return ''
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function goBack() {
//
const returnTo = route.query.returnTo as string
if (returnTo) {
router.push(returnTo)
return
}
//
if (window.history.length > 1) {
router.back()
} else {
//
router.push('/tool-marketplace')
}
}
async function installTool() {
try {
//
const toolData = {
id: tool.value.name || `tool-${Date.now()}`,
name: tool.value.title || tool.value.name,
description: tool.value.description,
category: tool.value.category,
icon: tool.value.icon,
color: tool.value.color || '#e5e7eb',
type: tool.value.type || 'route',
routeName: tool.value.route_name,
url: tool.value.url,
isDefault: false,
fromMarketplace: true,
marketplaceId: tool.value.name
}
toolsStore.addUserTool(toolData)
message.success(t('Tool installed successfully'))
//
loadInstalledTools()
} catch (error: any) {
console.error('Failed to install tool:', error)
message.error(error.response?.data?.detail || t('Failed to install tool'))
}
}
//
async function loadInstalledTools() {
try {
const userTools = toolsStore.userTools
// 便
installedToolNames.value = new Set(
userTools
.filter(t => t.fromMarketplace && t.marketplaceId)
.map(t => (t.marketplaceId || t.name).toLowerCase())
)
} catch (error) {
console.error('Load installed tools error:', error)
}
}
//
function isToolInstalled(toolName: string): boolean {
if (!toolName) return false
return installedToolNames.value.has(toolName.toLowerCase())
}
onMounted(() => {
loadToolDetail()
loadInstalledTools()
//
window.addEventListener('installedToolsUpdated', () => {
loadInstalledTools()
})
})
</script>
<style scoped>
.tool-detail {
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
align-items: center;
gap: 30px;
}
.header-text h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
}
.loading-container,
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.tool-content {
display: flex;
flex-direction: column;
gap: 32px;
}
.tool-card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 24px;
}
.tool-content-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 50px;
align-items: start;
}
.tool-image-section {
display: flex;
justify-content: center;
align-items: center;
}
.tool-image {
width: 100%;
min-height: 300px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.tool-icon-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.placeholder-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
}
.tool-header {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.tool-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #1a1a1a;
}
.tool-subtitle {
margin: 8px 0 0 0;
font-size: 14px;
color: #6b7280;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 12px;
}
.info-item .label {
font-weight: 500;
color: #374151;
font-size: 14px;
min-width: 80px;
}
.info-item .value {
color: #6b7280;
font-size: 14px;
}
.link {
color: #2563eb;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.description-section {
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.description-section h3 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.description-content {
color: #374151;
line-height: 1.6;
}
.description-content :deep(h1),
.description-content :deep(h2),
.description-content :deep(h3),
.description-content :deep(h4),
.description-content :deep(h5),
.description-content :deep(h6) {
margin: 16px 0 8px 0;
color: #1f2937;
}
.description-content :deep(p) {
margin: 8px 0;
}
.description-content :deep(ul),
.description-content :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.description-content :deep(li) {
margin: 4px 0;
}
.description-content :deep(code) {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.description-content :deep(pre) {
background: #f3f4f6;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
}
.description-content :deep(blockquote) {
border-left: 4px solid #e5e7eb;
padding-left: 16px;
margin: 16px 0;
color: #6b7280;
font-style: italic;
}
@media (max-width: 768px) {
.tool-card {
padding: 20px;
}
.tool-content-layout {
grid-template-columns: 1fr;
gap: 30px;
}
.tool-image-section {
order: 2;
justify-content: center;
}
.tool-image {
max-width: 150px;
}
.tool-title {
font-size: 20px;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.info-item .label {
min-width: auto;
}
.header-content {
flex-direction: column;
gap: 16px;
}
}
</style>

View File

@ -0,0 +1,522 @@
<template>
<div class="tool-marketplace">
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1>{{ t('Tool Marketplace') }}</h1>
<p>{{ t('Browse and install tools from Jingrow Tool Marketplace') }}</p>
</div>
</div>
</div>
<div class="content">
<div class="search-container">
<div class="search-bar">
<n-input
v-model:value="searchQuery"
:placeholder="t('Search tools...')"
clearable
size="large"
@keyup.enter="loadTools"
class="search-input"
>
<template #prefix>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
</n-input>
<n-button type="primary" size="large" @click="loadTools" class="search-button">
<template #icon>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
{{ t('Search') }}
</n-button>
</div>
</div>
<div class="tools-section" v-if="!loading && tools.length > 0">
<!-- 排序控件 -->
<div class="tools-header">
<div class="tools-title">
</div>
<div class="sort-controls">
<n-select
v-model:value="sortBy"
:options="sortOptions"
:placeholder="t('Sort by')"
style="width: 150px"
@update:value="loadTools"
/>
</div>
</div>
<div class="tools-grid">
<div v-for="tool in tools" :key="tool.name" class="tool-card">
<!-- 工具图标 -->
<div class="tool-icon" @click="viewToolDetail(tool)">
<Icon
v-if="tool.icon"
:icon="tool.icon"
:width="48"
:height="48"
:style="{ color: tool.color || '#6b7280' }"
/>
<div v-else class="tool-icon-placeholder">
<n-icon size="48"><Icon icon="tabler:tool" /></n-icon>
</div>
</div>
<!-- 工具信息 -->
<div class="tool-content">
<div class="tool-header">
<div class="tool-title-section">
<h3 @click="viewToolDetail(tool)" class="clickable-title">{{ tool.title || tool.name }}</h3>
<div class="tool-category" v-if="tool.category">
{{ tool.category }}
</div>
</div>
</div>
<div class="tool-description" v-if="tool.description">
{{ truncateText(tool.description, 80) }}
</div>
<div class="tool-meta" v-if="tool.author">
<n-icon><Icon icon="tabler:user" /></n-icon>
<span>{{ tool.author }}</span>
</div>
</div>
<div class="tool-actions">
<n-button type="default" @click="viewToolDetail(tool)">
{{ t('View Details') }}
</n-button>
<n-button
v-if="isToolInstalled(tool.name)"
type="warning"
@click="installTool(tool)"
>
{{ t('Installed') }}
</n-button>
<n-button
v-else
type="primary"
@click="installTool(tool)"
>
{{ t('Install') }}
</n-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<n-pagination
v-model:page="page"
:page-count="pageCount"
size="large"
show-size-picker
:page-sizes="[20, 50, 100]"
:page-size="pageSize"
@update:page="loadTools"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<div v-if="loading" class="loading">
<n-spin size="large">
<template #description>{{ t('Loading tools...') }}</template>
</n-spin>
</div>
<div v-if="!loading && tools.length === 0" class="empty">
<n-empty :description="t('No tools found')">
<template #icon>
<n-icon><Icon icon="tabler:tool" /></n-icon>
</template>
</n-empty>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NInput, NButton, NIcon, NSpin, NEmpty, NSelect, NPagination, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import axios from 'axios'
import { t } from '@/shared/i18n'
import { get_session_api_headers } from '@/shared/api/auth'
import { useToolsStore } from '@/shared/stores/tools'
const message = useMessage()
const router = useRouter()
const toolsStore = useToolsStore()
const searchQuery = ref('')
const loading = ref(false)
const tools = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '20'))
const sortBy = ref('creation desc')
//
const installedToolNames = ref<Set<string>>(new Set())
//
const sortOptions = computed(() => [
{ label: t('Latest'), value: 'creation desc' },
{ label: t('Oldest'), value: 'creation asc' },
{ label: t('Name A-Z'), value: 'name asc' },
{ label: t('Name Z-A'), value: 'name desc' },
{ label: t('Most Popular'), value: 'modified desc' }
])
//
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
async function loadTools() {
loading.value = true
try {
const params = new URLSearchParams({
page: page.value.toString(),
page_size: pageSize.value.toString(),
search: searchQuery.value,
sort_by: sortBy.value
})
const response = await axios.get(`/jingrow/tool-marketplace?${params}`)
const data = response.data
// API
if (data.items) {
tools.value = data.items
total.value = data.total || 0
} else {
// API
tools.value = data || []
total.value = tools.value.length
}
} catch (error) {
console.error('Failed to load tools:', error)
message.error(t('Failed to load tools'))
tools.value = []
total.value = 0
} finally {
loading.value = false
}
}
function handlePageSizeChange(newPageSize: number) {
pageSize.value = newPageSize
page.value = 1
localStorage.setItem('itemsPerPage', newPageSize.toString())
loadTools()
}
function viewToolDetail(tool: any) {
//
router.push({
path: `/tool-marketplace/${tool.name}`,
query: { returnTo: '/tool-marketplace' }
})
}
async function installTool(tool: any) {
try {
//
const toolData = {
id: tool.name || `tool-${Date.now()}`,
name: tool.title || tool.name,
description: tool.description,
category: tool.category,
icon: tool.icon,
color: tool.color || '#e5e7eb',
type: tool.type || 'route',
routeName: tool.route_name,
url: tool.url,
isDefault: false,
fromMarketplace: true,
marketplaceId: tool.name
}
toolsStore.addUserTool(toolData)
message.success(t('Tool installed successfully'))
//
loadInstalledTools()
} catch (error: any) {
console.error('Failed to install tool:', error)
message.error(error.response?.data?.detail || t('Failed to install tool'))
}
}
function truncateText(text: string, maxLength: number): string {
if (!text) return ''
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
//
async function loadInstalledTools() {
try {
const userTools = toolsStore.userTools
// 便
installedToolNames.value = new Set(
userTools
.filter(t => t.fromMarketplace && t.marketplaceId)
.map(t => (t.marketplaceId || t.name).toLowerCase())
)
} catch (error) {
console.error('Load installed tools error:', error)
}
}
//
function isToolInstalled(toolName: string): boolean {
if (!toolName) return false
return installedToolNames.value.has(toolName.toLowerCase())
}
onMounted(() => {
loadTools()
loadInstalledTools()
//
window.addEventListener('installedToolsUpdated', () => {
loadInstalledTools()
})
})
//
watch([searchQuery, sortBy], () => {
page.value = 1 //
loadTools()
}, { deep: true })
//
watch([page], () => {
loadTools()
})
//
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
if (newValue) {
pageSize.value = parseInt(newValue)
page.value = 1 //
loadTools()
}
})
</script>
<style scoped>
.tool-marketplace {
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header-text h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
}
.header-text p {
margin: 0;
color: #666;
font-size: 16px;
}
.search-container {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.tools-section {
margin-bottom: 32px;
}
.tools-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.sort-controls {
display: flex;
align-items: center;
gap: 12px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
}
.search-bar {
display: flex;
gap: 16px;
align-items: center;
max-width: 600px;
width: 100%;
padding: 20px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.search-input {
flex: 1;
min-width: 0;
}
.search-button {
border-radius: 12px;
font-weight: 600;
padding: 0 24px;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.tool-card {
border: 1px solid #e5e7eb;
border-radius: 16px;
background: white;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.tool-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #d1d5db;
}
.tool-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 120px;
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
cursor: pointer;
padding: 20px;
}
.tool-icon-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #9ca3af;
}
.tool-content {
padding: 20px;
}
.tool-header {
margin-bottom: 12px;
}
.tool-title-section {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tool-title-section h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
flex: 1;
min-width: 0;
}
.clickable-title {
cursor: pointer;
transition: color 0.2s ease;
}
.clickable-title:hover {
color: #10b981;
}
.tool-category {
color: #6b7280;
font-size: 11px;
font-weight: 500;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 4px 10px;
display: inline-block;
white-space: nowrap;
}
.tool-description {
color: #6b7280;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
}
.tool-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
}
.tool-actions {
padding: 0 20px 20px;
display: flex;
gap: 12px;
}
.tool-actions .n-button {
flex: 1;
}
.loading, .empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
@media (max-width: 768px) {
.tools-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>

View File

@ -180,9 +180,9 @@ const filteredItems = computed(() => {
return false
}
//
//
if (m.parentId === 'dev-group') {
const allowedDevMenus = ['app-marketplace', 'node-marketplace', 'agent-marketplace']
const allowedDevMenus = ['app-marketplace', 'node-marketplace', 'agent-marketplace', 'tool-marketplace']
if (!allowedDevMenus.includes(m.id)) {
return false
}

View File

@ -0,0 +1,106 @@
# Copyright (c) 2025, JINGROW and contributors
# For license information, please see license.txt
"""
Jingrow Tools API
工具相关的 FastAPI 路由
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any, Optional
import json
import requests
import logging
from jingrow.utils.auth import get_jingrow_cloud_url, get_jingrow_cloud_api_headers
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/jingrow/tool-marketplace")
async def get_tool_marketplace(
search: Optional[str] = None,
page: int = 1,
page_size: int = 20,
sort_by: Optional[str] = None
):
"""获取工具市场数据,支持搜索、分页和排序"""
try:
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.jlocal.get_local_tool_list"
# 构建过滤条件
filters = {"public": 1}
if search:
filters["name"] = ["like", f"%{search}%"]
filters["title"] = ["like", f"%{search}%"]
filters["description"] = ["like", f"%{search}%"]
# 1. 先获取总数(不分页)
total_params = {
'filters': json.dumps(filters, ensure_ascii=False),
'limit_start': 0,
'limit_page_length': 0
}
headers = get_jingrow_cloud_api_headers()
total_response = requests.get(url, params=total_params, headers=headers, timeout=20)
total_count = 0
if total_response.status_code == 200:
total_data = total_response.json()
total_count = len(total_data.get('message', []))
# 2. 获取分页数据
params = {
'filters': json.dumps(filters, ensure_ascii=False)
}
# 排序参数
if sort_by:
params['order_by'] = sort_by
# 分页参数
limit_start = (page - 1) * page_size
params['limit_start'] = limit_start
params['limit_page_length'] = page_size
response = requests.get(url, params=params, headers=headers, timeout=20)
if response.status_code == 200:
data = response.json()
tools = data.get('message', [])
return {
"items": tools,
"total": total_count,
"page": page,
"page_size": page_size
}
else:
raise HTTPException(status_code=response.status_code, detail="获取工具市场数据失败")
except Exception as e:
logger.error(f"获取工具市场数据失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"获取工具市场数据失败: {str(e)}")
@router.get("/jingrow/tool-marketplace/{name}")
async def get_tool_detail(name: str):
"""获取工具详情"""
try:
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.jlocal.get_local_tool"
params = {"name": name}
headers = get_jingrow_cloud_api_headers()
response = requests.get(url, params=params, headers=headers, timeout=20)
if response.status_code == 200:
data = response.json()
return data.get('message')
else:
raise HTTPException(status_code=404, detail="工具不存在")
except Exception as e:
logger.error(f"获取工具详情失败: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"获取工具详情失败: {str(e)}")