增加节点市场
This commit is contained in:
parent
1bad5017a1
commit
e0096da893
@ -11,14 +11,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导航菜单 -->
|
<!-- 导航菜单 -->
|
||||||
<n-menu
|
<div class="menu-container">
|
||||||
:collapsed="collapsed"
|
<n-menu
|
||||||
:collapsed-width="64"
|
:collapsed="collapsed"
|
||||||
:collapsed-icon-size="24"
|
:collapsed-width="64"
|
||||||
:options="menuOptions"
|
:collapsed-icon-size="24"
|
||||||
:value="currentRoute"
|
:options="menuOptions"
|
||||||
@update:value="handleMenuSelect"
|
:value="currentRoute"
|
||||||
/>
|
@update:value="handleMenuSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -150,11 +152,20 @@ const handleMenuSelect = (key: string) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
|
|||||||
@ -135,6 +135,11 @@ const router = createRouter({
|
|||||||
name: 'AppMarketplace',
|
name: 'AppMarketplace',
|
||||||
component: () => import('../../views/dev/AppMarketplace.vue')
|
component: () => import('../../views/dev/AppMarketplace.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'node-marketplace',
|
||||||
|
name: 'NodeMarketplace',
|
||||||
|
component: () => import('../../views/dev/NodeMarketplace.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'app-marketplace/:name',
|
path: 'app-marketplace/:name',
|
||||||
name: 'AppDetail',
|
name: 'AppDetail',
|
||||||
|
|||||||
@ -869,7 +869,25 @@
|
|||||||
"Manage your locally installed applications": "管理您本地安装的应用",
|
"Manage your locally installed applications": "管理您本地安装的应用",
|
||||||
"App Marketplace": "应用市场",
|
"App Marketplace": "应用市场",
|
||||||
"Browse and install applications from Jingrow App Marketplace": "浏览和安装来自 Jingrow 应用市场的应用",
|
"Browse and install applications from Jingrow App Marketplace": "浏览和安装来自 Jingrow 应用市场的应用",
|
||||||
|
"Node Marketplace": "节点市场",
|
||||||
|
"Browse and install nodes from Jingrow Node Marketplace": "浏览和安装来自 Jingrow 节点市场的节点",
|
||||||
"Search applications...": "搜索应用...",
|
"Search applications...": "搜索应用...",
|
||||||
|
"Search nodes...": "搜索节点...",
|
||||||
|
"Loading nodes...": "正在加载节点...",
|
||||||
|
"No nodes found": "未找到节点",
|
||||||
|
"Failed to load nodes": "加载节点失败",
|
||||||
|
"Node file URL or repository address does not exist": "节点文件URL或仓库地址不存在",
|
||||||
|
"Node already exists": "节点已存在",
|
||||||
|
"Node \"{0}\" is already installed, do you want to overwrite?": "节点 \"{0}\" 已安装,是否覆盖安装?",
|
||||||
|
"Confirm Overwrite": "确认覆盖",
|
||||||
|
"Preparing installation...": "正在准备安装...",
|
||||||
|
"Downloading node package...": "正在下载节点包...",
|
||||||
|
"Installing node...": "正在安装节点...",
|
||||||
|
"Cloning repository...": "正在克隆仓库...",
|
||||||
|
"Unable to determine installation method": "无法确定安装方式",
|
||||||
|
"Node installed successfully!": "节点安装成功!",
|
||||||
|
"Node installed successfully": "节点安装成功",
|
||||||
|
"Installation failed": "安装失败",
|
||||||
"Team": "开发团队",
|
"Team": "开发团队",
|
||||||
"Repository": "仓库",
|
"Repository": "仓库",
|
||||||
"Install": "安装",
|
"Install": "安装",
|
||||||
|
|||||||
@ -62,6 +62,7 @@ function getDefaultMenus(): AppMenuItem[] {
|
|||||||
{ id: 'app-installer', key: 'AppInstaller', label: 'App Installer', icon: 'tabler:upload', type: 'route', routeName: 'AppInstaller', parentId: 'dev-group', order: 5 },
|
{ id: 'app-installer', key: 'AppInstaller', label: 'App Installer', icon: 'tabler:upload', type: 'route', routeName: 'AppInstaller', parentId: 'dev-group', order: 5 },
|
||||||
{ id: 'installed-apps', key: 'InstalledApps', label: 'Installed Apps', icon: 'tabler:apps', type: 'route', routeName: 'InstalledApps', parentId: 'dev-group', order: 6 },
|
{ id: 'installed-apps', key: 'InstalledApps', label: 'Installed Apps', icon: 'tabler:apps', type: 'route', routeName: 'InstalledApps', parentId: 'dev-group', order: 6 },
|
||||||
{ id: 'app-marketplace', key: 'AppMarketplace', label: 'App Marketplace', icon: 'tabler:shopping-cart', type: 'route', routeName: 'AppMarketplace', parentId: 'dev-group', order: 7 },
|
{ id: 'app-marketplace', key: 'AppMarketplace', label: 'App Marketplace', icon: 'tabler:shopping-cart', type: 'route', routeName: 'AppMarketplace', parentId: 'dev-group', order: 7 },
|
||||||
|
{ id: 'node-marketplace', key: 'NodeMarketplace', label: 'Node Marketplace', icon: 'carbon:add-child-node', type: 'route', routeName: 'NodeMarketplace', parentId: 'dev-group', order: 8 },
|
||||||
{ id: 'menuManager', key: 'MenuManager', label: 'Menu Management', icon: 'tabler:menu-2', type: 'route', routeName: 'MenuManager', order: 10 },
|
{ id: 'menuManager', key: 'MenuManager', label: 'Menu Management', icon: 'tabler:menu-2', type: 'route', routeName: 'MenuManager', order: 10 },
|
||||||
{ id: 'settings', key: 'Settings', label: 'Settings', icon: 'tabler:settings', routeName: 'Settings', order: 11, type: 'route' }
|
{ id: 'settings', key: 'Settings', label: 'Settings', icon: 'tabler:settings', routeName: 'Settings', order: 11, type: 'route' }
|
||||||
]
|
]
|
||||||
|
|||||||
613
apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue
Normal file
613
apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue
Normal file
@ -0,0 +1,613 @@
|
|||||||
|
<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('Update') }}
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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 { get_session_api_headers } from '@/shared/api/auth'
|
||||||
|
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(`/node-marketplace/${node.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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...')
|
||||||
|
setTimeout(() => {
|
||||||
|
installProgress.value = 20
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
installProgress.value = 30
|
||||||
|
installMessage.value = t('Installing node...')
|
||||||
|
|
||||||
|
response = await axios.post('/jingrow/install-node-from-url', new URLSearchParams({
|
||||||
|
url: node.file_url
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
...get_session_api_headers(),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (node.repository_url) {
|
||||||
|
installMessage.value = t('Cloning repository...')
|
||||||
|
setTimeout(() => {
|
||||||
|
installProgress.value = 20
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
installProgress.value = 30
|
||||||
|
installMessage.value = t('Installing node...')
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
repo_url: node.repository_url
|
||||||
|
})
|
||||||
|
|
||||||
|
response = await axios.post('/jingrow/install-node-from-git', params, {
|
||||||
|
headers: {
|
||||||
|
...get_session_api_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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
} 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([searchQuery, 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 h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@ -1,11 +1,20 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Form
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import requests
|
||||||
from jingrow.utils.fs import atomic_write_json
|
from jingrow.utils.fs import atomic_write_json
|
||||||
from jingrow.utils.jingrow_api import get_record_id, create_record
|
from jingrow.utils.jingrow_api import get_record_id, create_record, update_record, get_record_list
|
||||||
|
from jingrow.utils.auth import get_jingrow_cloud_url, get_jingrow_cloud_api_headers
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@ -187,3 +196,389 @@ async def get_node_schema(node_type: str):
|
|||||||
raise HTTPException(status_code=404, detail=f"节点类型 {node_type} 不存在")
|
raise HTTPException(status_code=404, detail=f"节点类型 {node_type} 不存在")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 节点市场 API ====================
|
||||||
|
|
||||||
|
@router.get("/jingrow/node-marketplace")
|
||||||
|
async def get_node_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.local_node.get_local_nodes"
|
||||||
|
|
||||||
|
# 构建过滤条件
|
||||||
|
filters = {"public": 1}
|
||||||
|
if search:
|
||||||
|
filters["title"] = ["like", f"%{search}%"]
|
||||||
|
filters["node_type"] = ["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()
|
||||||
|
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
nodes = data.get('message', [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": nodes,
|
||||||
|
"total": total_count,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 如果API不存在或失败,返回空列表
|
||||||
|
logger.warning(f"获取节点市场数据失败: HTTP {response.status_code}, 返回空列表")
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size
|
||||||
|
}
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
# 网络错误或API不存在时返回空列表
|
||||||
|
logger.warning(f"节点市场API请求失败: {str(e)}, 返回空列表")
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取节点市场数据异常: {str(e)}")
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
# 即使出错也返回空列表,而不是抛出500错误
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"total": 0,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jingrow/check-node/{node_type}")
|
||||||
|
async def check_node_exists(node_type: str):
|
||||||
|
"""检查节点是否已安装"""
|
||||||
|
try:
|
||||||
|
result = get_record_id(
|
||||||
|
pagetype="Local Ai Node",
|
||||||
|
field="node_type",
|
||||||
|
value=node_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"exists": result.get("success", False)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"检查节点失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jingrow/installed-node-types")
|
||||||
|
async def get_installed_node_types():
|
||||||
|
"""获取已安装的节点类型列表"""
|
||||||
|
try:
|
||||||
|
result = get_record_list(
|
||||||
|
pagetype="Local Ai Node",
|
||||||
|
fields=["node_type"],
|
||||||
|
filters=[],
|
||||||
|
limit_page_length=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
records = result.get("data", {}).get("data", [])
|
||||||
|
node_types = [record.get("node_type") for record in records if record.get("node_type")]
|
||||||
|
return {"success": True, "node_types": node_types}
|
||||||
|
else:
|
||||||
|
return {"success": True, "node_types": []}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取已安装节点类型失败: {str(e)}")
|
||||||
|
return {"success": True, "node_types": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jingrow/install-node-from-url")
|
||||||
|
async def install_node_from_url(url: str = Form(...)):
|
||||||
|
"""从URL安装节点"""
|
||||||
|
try:
|
||||||
|
# 下载文件
|
||||||
|
current = Path(__file__).resolve()
|
||||||
|
root = current.parents[4]
|
||||||
|
tmp_dir = root / "tmp"
|
||||||
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 创建临时文件
|
||||||
|
temp_filename = f"node_download_{uuid.uuid4().hex[:8]}{Path(url).suffix}"
|
||||||
|
temp_file_path = tmp_dir / temp_filename
|
||||||
|
|
||||||
|
# 下载文件
|
||||||
|
response = requests.get(url, stream=True, timeout=300)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(temp_file_path, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# 安装节点
|
||||||
|
result = _install_node_from_file(str(temp_file_path))
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
if temp_file_path.exists():
|
||||||
|
os.remove(temp_file_path)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从URL安装节点失败: {str(e)}")
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"安装节点失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jingrow/install-node-from-git")
|
||||||
|
async def install_node_from_git(repo_url: str = Form(...)):
|
||||||
|
"""从git仓库克隆并安装节点"""
|
||||||
|
try:
|
||||||
|
current = Path(__file__).resolve()
|
||||||
|
root = current.parents[4]
|
||||||
|
tmp_dir = root / "tmp"
|
||||||
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 创建临时目录用于克隆
|
||||||
|
clone_dir = tmp_dir / f"node_git_clone_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 git clone 克隆仓库
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'clone', repo_url, str(clone_dir)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Git 克隆失败: {result.stderr}")
|
||||||
|
|
||||||
|
# 查找节点目录(节点包应该包含一个或多个节点目录)
|
||||||
|
node_dirs = []
|
||||||
|
for item in clone_dir.iterdir():
|
||||||
|
if item.is_dir() and not item.name.startswith('.') and item.name != '__pycache__':
|
||||||
|
json_file = item / f"{item.name}.json"
|
||||||
|
if json_file.exists():
|
||||||
|
node_dirs.append(item)
|
||||||
|
|
||||||
|
if not node_dirs:
|
||||||
|
raise HTTPException(status_code=400, detail="仓库中没有找到节点定义文件")
|
||||||
|
|
||||||
|
# 安装所有找到的节点
|
||||||
|
installed_nodes = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for node_dir in node_dirs:
|
||||||
|
try:
|
||||||
|
result = _install_single_node_directory(str(node_dir))
|
||||||
|
if result.get('success'):
|
||||||
|
installed_nodes.append(node_dir.name)
|
||||||
|
else:
|
||||||
|
errors.append(f"{node_dir.name}: {result.get('error')}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{node_dir.name}: {str(e)}")
|
||||||
|
|
||||||
|
# 清理临时目录
|
||||||
|
shutil.rmtree(clone_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return {
|
||||||
|
'success': len(installed_nodes) > 0,
|
||||||
|
'installed': installed_nodes,
|
||||||
|
'errors': errors,
|
||||||
|
'message': f"成功安装 {len(installed_nodes)} 个节点,失败 {len(errors)} 个"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'installed': installed_nodes,
|
||||||
|
'message': f"成功安装 {len(installed_nodes)} 个节点"
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 确保清理临时目录
|
||||||
|
if clone_dir.exists():
|
||||||
|
shutil.rmtree(clone_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从Git安装节点失败: {str(e)}")
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"安装节点失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _install_node_from_file(file_path: str) -> Dict[str, Any]:
|
||||||
|
"""从文件安装节点(支持ZIP和TAR.GZ)"""
|
||||||
|
try:
|
||||||
|
from jingrow.utils.app_installer import extract_package, cleanup_temp_dir
|
||||||
|
|
||||||
|
# 解压文件
|
||||||
|
extract_result = extract_package(file_path)
|
||||||
|
if not extract_result.get('success'):
|
||||||
|
return extract_result
|
||||||
|
|
||||||
|
temp_dir = extract_result['temp_dir']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 查找节点目录
|
||||||
|
node_dirs = []
|
||||||
|
for item in Path(temp_dir).iterdir():
|
||||||
|
if item.is_dir() and not item.name.startswith('.') and item.name != '__pycache__':
|
||||||
|
json_file = item / f"{item.name}.json"
|
||||||
|
if json_file.exists():
|
||||||
|
node_dirs.append(item)
|
||||||
|
|
||||||
|
if not node_dirs:
|
||||||
|
return {'success': False, 'error': '压缩包中没有找到节点定义文件'}
|
||||||
|
|
||||||
|
# 安装所有找到的节点
|
||||||
|
installed_nodes = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for node_dir in node_dirs:
|
||||||
|
try:
|
||||||
|
result = _install_single_node_directory(str(node_dir))
|
||||||
|
if result.get('success'):
|
||||||
|
installed_nodes.append(node_dir.name)
|
||||||
|
else:
|
||||||
|
errors.append(f"{node_dir.name}: {result.get('error')}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{node_dir.name}: {str(e)}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return {
|
||||||
|
'success': len(installed_nodes) > 0,
|
||||||
|
'installed': installed_nodes,
|
||||||
|
'errors': errors,
|
||||||
|
'message': f"成功安装 {len(installed_nodes)} 个节点,失败 {len(errors)} 个"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'installed': installed_nodes,
|
||||||
|
'message': f"成功安装 {len(installed_nodes)} 个节点"
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cleanup_temp_dir(temp_dir)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从文件安装节点失败: {str(e)}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_single_node_directory(node_dir: str) -> Dict[str, Any]:
|
||||||
|
"""安装单个节点目录到 ai/nodes 并导入数据库"""
|
||||||
|
try:
|
||||||
|
node_dir_path = Path(node_dir)
|
||||||
|
node_name = node_dir_path.name
|
||||||
|
|
||||||
|
# 读取节点定义文件
|
||||||
|
json_file = node_dir_path / f"{node_name}.json"
|
||||||
|
if not json_file.exists():
|
||||||
|
return {'success': False, 'error': f'找不到节点定义文件: {json_file.name}'}
|
||||||
|
|
||||||
|
with open(json_file, 'r', encoding='utf-8') as f:
|
||||||
|
node_data = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(node_data, dict):
|
||||||
|
return {'success': False, 'error': '节点定义文件格式错误'}
|
||||||
|
|
||||||
|
metadata = node_data.get("metadata") or {}
|
||||||
|
node_type = metadata.get("type")
|
||||||
|
if not node_type:
|
||||||
|
return {'success': False, 'error': '节点定义中缺少 metadata.type'}
|
||||||
|
|
||||||
|
# 确定目标目录:apps/jingrow/jingrow/ai/nodes
|
||||||
|
current_file = Path(__file__).resolve()
|
||||||
|
# node_definitions.py 位于 jingrow/api/
|
||||||
|
# parents[0] = jingrow/api, parents[1] = jingrow
|
||||||
|
jingrow_root = current_file.parents[1] # jingrow
|
||||||
|
nodes_root = jingrow_root / "ai" / "nodes"
|
||||||
|
nodes_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
target_node_dir = nodes_root / node_type
|
||||||
|
|
||||||
|
# 如果目标目录已存在,先删除
|
||||||
|
if target_node_dir.exists():
|
||||||
|
shutil.rmtree(target_node_dir)
|
||||||
|
|
||||||
|
# 复制整个节点目录
|
||||||
|
shutil.copytree(node_dir_path, target_node_dir)
|
||||||
|
|
||||||
|
# 导入到数据库
|
||||||
|
# 检查是否已存在
|
||||||
|
exists_res = get_record_id(
|
||||||
|
pagetype="Local Ai Node",
|
||||||
|
field="node_type",
|
||||||
|
value=node_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成 schema(移除 metadata)
|
||||||
|
schema = dict(node_data)
|
||||||
|
schema.pop("metadata", None)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"node_type": node_type,
|
||||||
|
"node_label": metadata.get("label") or node_type,
|
||||||
|
"node_icon": metadata.get("icon") or "fa-cube",
|
||||||
|
"node_color": metadata.get("color") or "#6b7280",
|
||||||
|
"node_group": metadata.get("group") or "",
|
||||||
|
"node_component": metadata.get("component_type") or "GenericNode",
|
||||||
|
"node_description": metadata.get("description") or "",
|
||||||
|
"status": "Published",
|
||||||
|
"node_schema": schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists_res.get("success"):
|
||||||
|
# 更新现有记录,使用 node_type 作为 name
|
||||||
|
res = update_record("Local Ai Node", node_type, payload)
|
||||||
|
else:
|
||||||
|
# 创建新记录
|
||||||
|
res = create_record("Local Ai Node", payload)
|
||||||
|
|
||||||
|
if res.get("success"):
|
||||||
|
return {'success': True, 'node_type': node_type, 'message': f'节点 {node_type} 安装成功'}
|
||||||
|
else:
|
||||||
|
return {'success': False, 'error': res.get('error', '导入数据库失败')}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"安装节点目录失败: {str(e)}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user