jingrow/apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue

626 lines
15 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="node-marketplace">
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1>{{ t('Node Marketplace') }}</h1>
<p>{{ t('Browse and install nodes from Jingrow Node 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 nodes...')"
clearable
size="large"
@keyup.enter="loadNodes"
class="search-input"
>
<template #prefix>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
</n-input>
<n-button type="primary" size="large" @click="loadNodes" class="search-button">
<template #icon>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
{{ t('Search') }}
</n-button>
</div>
</div>
<div class="nodes-section" v-if="!loading && nodes.length > 0">
<!-- 排序控件 -->
<div class="nodes-header">
<div class="nodes-title">
</div>
<div class="sort-controls">
<n-select
v-model:value="sortBy"
:options="sortOptions"
:placeholder="t('Sort by')"
style="width: 150px"
@update:value="loadNodes"
/>
</div>
</div>
<div class="nodes-grid">
<div v-for="node in nodes" :key="node.name" class="node-card">
<!-- 节点图标 -->
<div class="node-icon" @click="viewNodeDetail(node)">
<Icon
v-if="node.icon"
:icon="node.icon"
:width="48"
:height="48"
:style="{ color: node.color || '#6b7280' }"
/>
<div v-else class="node-icon-placeholder">
<n-icon size="48"><Icon icon="tabler:cube" /></n-icon>
</div>
</div>
<!-- 节点信息 -->
<div class="node-content">
<div class="node-header">
<div class="node-title-section">
<h3 @click="viewNodeDetail(node)" class="clickable-title">{{ node.title || node.name }}</h3>
<div class="node-type" v-if="node.node_type">
{{ node.node_type }}
</div>
</div>
</div>
<div class="node-description" v-if="node.description">
{{ truncateText(node.description, 80) }}
</div>
<div class="node-meta" v-if="node.group">
<n-icon><Icon icon="tabler:category" /></n-icon>
<span>{{ node.group }}</span>
</div>
</div>
<div class="node-actions">
<n-button type="default" @click="viewNodeDetail(node)">
{{ t('View Details') }}
</n-button>
<n-button
v-if="isNodeInstalled(node.node_type || node.name)"
type="warning"
@click="installNode(node)"
>
{{ t('Installed') }}
</n-button>
<n-button
v-else
type="primary"
@click="installNode(node)"
>
{{ 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="loadNodes"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<div v-if="loading" class="loading">
<n-spin size="large">
<template #description>{{ t('Loading nodes...') }}</template>
</n-spin>
</div>
<div v-if="!loading && nodes.length === 0" class="empty">
<n-empty :description="t('No nodes found')">
<template #icon>
<n-icon><Icon icon="tabler:cube" /></n-icon>
</template>
</n-empty>
</div>
</div>
<!-- 安装进度弹窗 -->
<InstallProgressModal
v-model="showProgressModal"
:progress="installProgress"
:message="installMessage"
:status="installStatus"
:installing="installing"
:title="t('Installing Node')"
/>
</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, useDialog } from 'naive-ui'
import { Icon } from '@iconify/vue'
import axios from 'axios'
import { t } from '@/shared/i18n'
import InstallProgressModal from './InstallProgressModal.vue'
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
const searchQuery = ref('')
const loading = ref(false)
const nodes = 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 installedNodeTypes = 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: 'node_type asc' },
{ label: t('Name Z-A'), value: 'node_type desc' },
{ label: t('Most Popular'), value: 'modified desc' }
])
// 计算总页数
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
async function loadNodes() {
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/node-marketplace?${params}`)
const data = response.data
// 如果API返回分页数据
if (data.items) {
nodes.value = data.items
total.value = data.total || 0
} else {
// 兼容旧API格式
nodes.value = data || []
total.value = nodes.value.length
}
} catch (error) {
console.error('Failed to load nodes:', error)
message.error(t('Failed to load nodes'))
nodes.value = []
total.value = 0
} finally {
loading.value = false
}
}
function handlePageSizeChange(newPageSize: number) {
pageSize.value = newPageSize
page.value = 1
localStorage.setItem('itemsPerPage', newPageSize.toString())
loadNodes()
}
function viewNodeDetail(node: any) {
// 跳转到节点详情页面,传递返回路径
router.push({
path: `/node-marketplace/${node.name}`,
query: { returnTo: '/node-marketplace' }
})
}
async function installNode(node: any) {
if (!node.file_url && !node.repository_url) {
message.error(t('Node file URL or repository address does not exist'))
return
}
// 先检查节点是否已存在
try {
const nodeType = node.node_type || node.name
if (nodeType) {
const checkResponse = await axios.get(`/jingrow/check-node/${nodeType}`)
if (checkResponse.data.exists) {
// 显示确认对话框
dialog.warning({
title: t('Node already exists'),
content: t('Node "{0}" is already installed, do you want to overwrite?').replace('{0}', nodeType),
positiveText: t('Confirm Overwrite'),
negativeText: t('Cancel'),
onPositiveClick: () => {
performInstall(node)
}
})
return
}
}
} catch (error) {
console.error('Check node exists error:', error)
}
performInstall(node)
}
async function performInstall(node: any) {
try {
installing.value = true
installProgress.value = 0
installMessage.value = t('Preparing installation...')
installStatus.value = 'info'
showProgressModal.value = true
let response
// 优先使用文件URL否则使用git仓库
if (node.file_url) {
installMessage.value = t('Downloading node package...')
installProgress.value = 20
installMessage.value = t('Installing node...')
installProgress.value = 30
response = await axios.post('/jingrow/install-node-from-url', new URLSearchParams({
url: node.file_url
}), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
} else if (node.repository_url) {
installMessage.value = t('Cloning repository...')
installProgress.value = 20
installMessage.value = t('Installing node...')
installProgress.value = 30
const params = new URLSearchParams({
repo_url: node.repository_url
})
response = await axios.post('/jingrow/install-node-from-git', params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
}
if (!response) {
throw new Error(t('Unable to determine installation method'))
}
// 更新进度到安装完成
installProgress.value = 100
if (response.data.success) {
// 所有步骤完成后才显示成功
installing.value = false
installStatus.value = 'success'
installMessage.value = t('Node installed successfully!')
message.success(t('Node installed successfully'))
// 刷新已安装节点列表
loadInstalledNodes()
// 触发全局事件,通知流程编排界面刷新节点列表
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('nodeMetadataUpdated'))
}
setTimeout(() => {
showProgressModal.value = false
}, 2000)
} else {
throw new Error(response.data.error || t('Installation failed'))
}
} catch (error: any) {
console.error('Install node error:', error)
installing.value = false
installStatus.value = 'error'
installMessage.value = error.response?.data?.detail || error.message || t('Installation failed')
message.error(error.response?.data?.detail || t('Installation failed'))
setTimeout(() => {
showProgressModal.value = false
}, 3000)
}
}
function truncateText(text: string, maxLength: number): string {
if (!text) return ''
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
// 加载已安装节点列表
async function loadInstalledNodes() {
try {
const response = await axios.get('/jingrow/installed-node-types')
if (response.data.success) {
const nodeTypes = response.data.node_types || []
// 存储为小写以便不区分大小写匹配
installedNodeTypes.value = new Set(nodeTypes.map((t: string) => t.toLowerCase()))
}
} catch (error) {
console.error('Load installed nodes error:', error)
}
}
// 检查节点是否已安装
function isNodeInstalled(nodeType: string): boolean {
if (!nodeType) return false
return installedNodeTypes.value.has(nodeType.toLowerCase())
}
onMounted(() => {
loadNodes()
loadInstalledNodes()
// 监听全局事件
window.addEventListener('installedNodesUpdated', () => {
loadInstalledNodes()
})
})
// 监听排序变化(搜索改为手动触发)
watch([sortBy], () => {
page.value = 1 // 重置到第一页
loadNodes()
}, { deep: true })
// 监听分页变化
watch([page], () => {
loadNodes()
})
// 监听每页数量变化(从系统设置)
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
if (newValue) {
pageSize.value = parseInt(newValue)
page.value = 1 // 重置到第一页
loadNodes()
}
})
</script>
<style scoped>
.node-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;
}
.nodes-section {
margin-bottom: 32px;
}
.nodes-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;
}
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.node-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);
}
.node-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #d1d5db;
}
.node-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;
}
.node-icon-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #9ca3af;
}
.node-content {
padding: 20px;
}
.node-header {
margin-bottom: 12px;
}
.node-title-section {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.node-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;
}
.node-type {
color: #6b7280;
font-size: 11px;
font-weight: 500;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 4px 10px;
display: inline-block;
white-space: nowrap;
}
.node-description {
color: #6b7280;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
}
.node-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
}
.node-actions {
padding: 0 20px 20px;
display: flex;
gap: 12px;
}
.node-actions .n-button {
flex: 1;
}
.loading, .empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
@media (max-width: 768px) {
.nodes-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>