708 lines
18 KiB
Vue
708 lines
18 KiB
Vue
<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>
|
||
<n-button type="primary" @click="publishTool">
|
||
<template #icon>
|
||
<n-icon><Icon icon="tabler:plus" /></n-icon>
|
||
</template>
|
||
{{ t('Publish Tool') }}
|
||
</n-button>
|
||
</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-image" @click="viewToolDetail(tool)">
|
||
<img
|
||
v-if="tool.tool_image"
|
||
:src="getImageUrl(tool.tool_image)"
|
||
:alt="tool.title || tool.name"
|
||
@error="handleImageError"
|
||
/>
|
||
<div v-else class="tool-image-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>
|
||
<div class="tool-name" v-if="tool.tool_name || tool.name">
|
||
{{ tool.tool_name || tool.name }}
|
||
</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>
|
||
|
||
<!-- 安装进度弹窗 -->
|
||
<InstallProgressModal
|
||
v-model="showProgressModal"
|
||
:progress="installProgress"
|
||
:message="installMessage"
|
||
:status="installStatus"
|
||
:installing="installing"
|
||
/>
|
||
</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'
|
||
import InstallProgressModal from './InstallProgressModal.vue'
|
||
|
||
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 installing = ref(false)
|
||
const installProgress = ref(0)
|
||
const installMessage = ref('')
|
||
const installStatus = ref<'success' | 'error' | 'info'>('info')
|
||
const showProgressModal = ref(false)
|
||
|
||
// 已安装工具集合
|
||
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 publishTool() {
|
||
router.push('/publish-tool')
|
||
}
|
||
|
||
function viewToolDetail(tool: any) {
|
||
// 跳转到工具详情页面,传递返回路径
|
||
router.push({
|
||
path: `/tool-marketplace/${tool.name}`,
|
||
query: { returnTo: '/tool-marketplace' }
|
||
})
|
||
}
|
||
|
||
async function installTool(tool: any) {
|
||
if (!tool.file_url && !tool.repository_url) {
|
||
message.error(t('工具文件URL或仓库地址不存在'))
|
||
return
|
||
}
|
||
|
||
performInstall(tool)
|
||
}
|
||
|
||
async function performInstall(tool: any) {
|
||
try {
|
||
installing.value = true
|
||
installProgress.value = 0
|
||
installMessage.value = t('正在准备安装...')
|
||
installStatus.value = 'info'
|
||
showProgressModal.value = true
|
||
|
||
let response
|
||
|
||
// 优先使用文件URL,否则使用git仓库
|
||
if (tool.file_url) {
|
||
installMessage.value = t('正在下载工具包...')
|
||
installProgress.value = 20
|
||
|
||
installMessage.value = t('正在安装工具...')
|
||
installProgress.value = 30
|
||
|
||
response = await axios.post('/jingrow/install-tool-from-url', new URLSearchParams({
|
||
url: tool.file_url,
|
||
tool_name: tool.name || tool.tool_name
|
||
}), {
|
||
headers: {
|
||
...get_session_api_headers(),
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
}
|
||
})
|
||
} else if (tool.repository_url) {
|
||
installMessage.value = t('正在克隆仓库...')
|
||
installProgress.value = 20
|
||
|
||
installMessage.value = t('正在安装工具...')
|
||
installProgress.value = 30
|
||
|
||
// 注意:目前后端可能还没有 install-tool-from-git API,需要检查
|
||
message.warning(t('从Git仓库安装工具功能暂未实现'))
|
||
installing.value = false
|
||
installStatus.value = 'error'
|
||
installMessage.value = t('从Git仓库安装工具功能暂未实现')
|
||
setTimeout(() => {
|
||
showProgressModal.value = false
|
||
}, 3000)
|
||
return
|
||
}
|
||
|
||
if (!response) {
|
||
throw new Error(t('无法确定安装方式'))
|
||
}
|
||
|
||
// 更新进度到安装完成
|
||
installProgress.value = 100
|
||
|
||
if (response.data.success) {
|
||
// 所有步骤完成后才显示成功
|
||
installing.value = false
|
||
installStatus.value = 'success'
|
||
installMessage.value = t('工具安装成功!')
|
||
message.success(t('工具安装成功'))
|
||
|
||
// 将工具添加到 toolsStore
|
||
// 重要:tool_name 是实际的工具名称(用于文件夹),tool.name 是市场的唯一ID
|
||
const toolName = response.data.tool_name || tool.tool_name // 实际的 tool_name(用于文件夹)
|
||
const toolTitle = response.data.tool_title || tool.title || tool.name
|
||
const marketplaceId = tool.name // 市场的唯一ID(用于识别)
|
||
|
||
if (!toolName) {
|
||
console.error('无法获取工具名称 (tool_name)')
|
||
throw new Error('无法获取工具名称')
|
||
}
|
||
|
||
// 检查工具是否已存在于 store 中(使用实际的 tool_name 检查)
|
||
const existingTool = toolsStore.userTools.find(
|
||
t => t.marketplaceId === marketplaceId && t.fromMarketplace
|
||
)
|
||
|
||
if (!existingTool) {
|
||
// 构建工具数据
|
||
const toolData = {
|
||
id: marketplaceId || `tool-${Date.now()}`, // 使用市场唯一ID作为id
|
||
name: toolTitle,
|
||
description: tool.description,
|
||
category: tool.category,
|
||
icon: tool.icon,
|
||
color: tool.color || undefined, // 如果为空,不设置默认值,使用 undefined
|
||
type: 'route' as const,
|
||
routeName: tool.route_name, // 可能不存在,会在 addUserTool 中自动生成
|
||
isDefault: false,
|
||
fromMarketplace: true,
|
||
marketplaceId: marketplaceId, // 市场的唯一ID
|
||
toolName: toolName, // 实际的 tool_name(用于删除文件)
|
||
componentPath: `tools/${toolName}/${toolName}.vue` // 组件路径使用实际的 tool_name
|
||
}
|
||
|
||
// 添加到 store 并注册路由
|
||
toolsStore.addUserTool(toolData, router, toolData.componentPath)
|
||
}
|
||
|
||
// 刷新已安装工具列表
|
||
loadInstalledTools()
|
||
|
||
// 触发全局事件,通知工具列表页刷新
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new Event('installedToolsUpdated'))
|
||
}
|
||
|
||
setTimeout(() => {
|
||
showProgressModal.value = false
|
||
}, 2000)
|
||
} else {
|
||
throw new Error(response.data.error || t('安装失败'))
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Install tool error:', error)
|
||
installing.value = false
|
||
installStatus.value = 'error'
|
||
installMessage.value = error.response?.data?.detail || error.message || t('安装失败')
|
||
message.error(error.response?.data?.detail || t('安装失败'))
|
||
|
||
setTimeout(() => {
|
||
showProgressModal.value = false
|
||
}, 3000)
|
||
}
|
||
}
|
||
|
||
function getImageUrl(imageUrl: string): string {
|
||
if (!imageUrl) return ''
|
||
if (imageUrl.startsWith('http')) {
|
||
return imageUrl
|
||
}
|
||
// 使用云端URL拼接
|
||
const cloudUrl = 'https://console.jingrow.com'
|
||
return `${cloudUrl}${imageUrl.startsWith('/') ? '' : '/'}${imageUrl}`
|
||
}
|
||
|
||
function handleImageError(event: Event) {
|
||
const img = event.target as HTMLImageElement
|
||
img.style.display = 'none'
|
||
const placeholder = img.parentElement?.querySelector('.tool-image-placeholder')
|
||
if (placeholder) {
|
||
placeholder.classList.add('show')
|
||
}
|
||
}
|
||
|
||
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([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-image {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 200px;
|
||
overflow: hidden;
|
||
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
|
||
cursor: pointer;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.tool-image:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.tool-image img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.tool-card:hover .tool-image img {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.tool-image-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
color: #9ca3af;
|
||
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
|
||
}
|
||
|
||
.tool-image-placeholder.show {
|
||
display: flex;
|
||
}
|
||
|
||
.tool-content {
|
||
padding: 20px;
|
||
}
|
||
|
||
.tool-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tool-title-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
flex: 1;
|
||
min-width: 0;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.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-name {
|
||
color: #6b7280;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||
background: transparent;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 12px;
|
||
padding: 4px 10px;
|
||
text-align: center;
|
||
min-width: 70px;
|
||
letter-spacing: 0.3px;
|
||
transition: all 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tool-name:hover {
|
||
border-color: #9ca3af;
|
||
color: #374151;
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.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>
|
||
|