jingrowtools/src/views/dev/ToolMarketplace.vue

708 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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