节点详情页工具栏增加一键发布节点到节点市场的功能

This commit is contained in:
jingrow 2025-11-03 01:24:29 +08:00
parent 4dcfcf3e10
commit c26391d874
3 changed files with 307 additions and 1 deletions

View File

@ -149,3 +149,60 @@ export const importLocalNodes = async (): Promise<{ success: boolean; matched: n
}
}
// 打包节点为zip文件
export const packageNode = async (nodeType: string): Promise<Blob> => {
try {
const response = await axios.post(
`/jingrow/node/package/${encodeURIComponent(nodeType)}`,
{},
{
headers: get_session_api_headers(),
withCredentials: true,
responseType: 'blob'
}
)
return response.data
} catch (error) {
console.error('打包节点失败:', error)
throw error
}
}
// 发布节点到节点市场
export const publishNodeToMarketplace = async (data: {
node_type: string
title: string
subtitle?: string
description?: string
file_url: string
repository_url?: string
node_image?: string
}): Promise<{ success: boolean; message?: string }> => {
try {
const formData = new FormData()
formData.append('node_type', data.node_type)
formData.append('title', data.title)
if (data.subtitle) formData.append('subtitle', data.subtitle)
if (data.description) formData.append('description', data.description)
formData.append('file_url', data.file_url)
if (data.repository_url) formData.append('repository_url', data.repository_url)
if (data.node_image) formData.append('node_image', data.node_image)
const response = await axios.post(
`/jingrow/node/publish`,
formData,
{
headers: {
...get_session_api_headers(),
'Content-Type': 'multipart/form-data'
},
withCredentials: true
}
)
return response.data
} catch (error: any) {
console.error('发布节点失败:', error)
throw error
}
}

View File

@ -61,6 +61,23 @@
{{ t('Edit Schema') }}
</n-button>
<!-- 发布到节点市场按钮 -->
<n-button
type="default"
size="medium"
@click="handlePublishToMarketplace"
:disabled="loading || isNew || !nodeRecord.node_type"
:loading="publishing"
v-if="!isNew"
class="toolbar-btn publish-btn"
:title="t('Publish to Node Marketplace')"
>
<template #icon>
<n-icon><Icon icon="tabler:cloud-upload" /></n-icon>
</template>
{{ t('Publish to Marketplace') }}
</n-button>
<!-- Schema 编辑器模态框 -->
<SchemaEditorModal
v-model:visible="showSchemaEditor"
@ -106,6 +123,8 @@ import { t } from '@/shared/i18n'
import SchemaEditorModal from '@/core/components/SchemaEditorModal.vue'
import axios from 'axios'
import { get_session_api_headers } from '@/shared/api/auth'
import { packageNode, publishNodeToMarketplace } from '@/shared/api/nodes'
import { uploadFileToJingrow } from '@/shared/api/common'
interface Props {
entity: string
@ -144,6 +163,9 @@ const showSchemaEditor = ref(false)
const nodeName = computed(() => String(props.id))
const nodeRecord = computed(() => props.record || {})
//
const publishing = ref(false)
function openSchemaEditor() {
if (isNew.value) return
@ -186,6 +208,84 @@ async function handleSchemaSave(schemaData: any) {
}
}
async function handlePublishToMarketplace() {
if (isNew.value || !nodeRecord.value.node_type) {
message.warning(t('Please save the node first'))
return
}
const nodeType = nodeRecord.value.node_type
const nodeLabel = nodeRecord.value.node_label || nodeType
const nodeDescription = nodeRecord.value.node_description || ''
publishing.value = true
let loadingMessage: any = null
try {
// 1. zip
loadingMessage = message.loading(t('Packaging node...'), { duration: 0 })
const zipBlob = await packageNode(nodeType)
const zipFile = new File([zipBlob], `${nodeType}.zip`, { type: 'application/zip' })
//
if (loadingMessage) {
loadingMessage.destroy()
}
loadingMessage = message.loading(t('Uploading package...'), { duration: 0 })
// 2. zip
const uploadResult = await uploadFileToJingrow(
zipFile,
'Local Ai Node',
nodeName.value,
'file_url'
)
if (!uploadResult.success || !uploadResult.file_url) {
throw new Error(uploadResult.error || t('Upload failed'))
}
const fileUrl = uploadResult.file_url
//
if (loadingMessage) {
loadingMessage.destroy()
}
loadingMessage = message.loading(t('Publishing to marketplace...'), { duration: 0 })
// 3.
const publishResult = await publishNodeToMarketplace({
node_type: nodeType,
title: nodeLabel,
description: nodeDescription,
file_url: fileUrl,
repository_url: nodeRecord.value.repository_url,
node_image: nodeRecord.value.node_image
})
if (loadingMessage) {
loadingMessage.destroy()
}
if (publishResult.success) {
message.success(t('Node published to marketplace successfully'))
} else {
throw new Error(publishResult.message || t('Publish failed'))
}
} catch (error: any) {
if (loadingMessage) {
loadingMessage.destroy()
}
console.error('发布节点失败:', error)
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || t('Publish failed, please check permission and server logs')
message.error(errorMsg)
} finally {
publishing.value = false
}
}
// localStorage
const sidebarPosition = ref<'left' | 'right'>(props.sidebarPosition || 'left')
@ -341,6 +441,20 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(13, 104, 75, 0.15);
}
/* 发布到市场按钮 - 蓝色系 */
.publish-btn {
background: #eff6ff !important;
color: #1e40af !important;
border-color: rgba(30, 64, 175, 0.2) !important;
}
.publish-btn:hover:not(:disabled) {
background: #dbeafe !important;
color: #1e3a8a !important;
border-color: rgba(30, 64, 175, 0.3) !important;
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.15);
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Form
from fastapi import APIRouter, HTTPException, Form, Response
from typing import Dict, Any, List, Optional
from pathlib import Path
import json
@ -582,3 +582,138 @@ def _install_single_node_directory(node_dir: str) -> Dict[str, Any]:
except Exception as e:
logger.error(f"安装节点目录失败: {str(e)}")
return {'success': False, 'error': str(e)}
@router.post("/jingrow/node/package/{node_type}")
async def package_node(node_type: str):
"""
打包节点文件夹为zip文件返回文件路径用于后续上传
"""
try:
from datetime import datetime
current_file = Path(__file__).resolve()
jingrow_root = current_file.parents[1]
nodes_root = jingrow_root / "ai" / "nodes"
node_dir = nodes_root / node_type
if not node_dir.exists():
raise HTTPException(status_code=404, detail=f"节点目录不存在: {node_type}")
# 创建临时打包目录
root = current_file.parents[4]
tmp_dir = root / "tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
# 创建临时目录用于打包
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
temp_package_dir = tmp_dir / f"{node_type}-{timestamp}"
temp_package_dir.mkdir(parents=True, exist_ok=True)
try:
# 复制节点目录内容(排除不必要的文件)
for item in node_dir.iterdir():
if item.name in ['__pycache__', '.git', '.DS_Store', '.pytest_cache']:
continue
dst = temp_package_dir / item.name
if item.is_dir():
shutil.copytree(item, dst, dirs_exist_ok=True)
else:
shutil.copy2(item, dst)
# 打包为 ZIP
zip_filename = f"{node_type}-{timestamp}.zip"
zip_base_name = tmp_dir / f"{node_type}-{timestamp}"
shutil.make_archive(str(zip_base_name), 'zip', root_dir=str(tmp_dir), base_dir=f"{node_type}-{timestamp}")
zip_path = tmp_dir / f"{zip_filename}"
if not zip_path.exists():
raise HTTPException(status_code=500, detail="ZIP文件创建失败")
# 读取文件内容
with open(zip_path, 'rb') as f:
zip_content = f.read()
# 清理临时文件
if zip_path.exists():
os.remove(zip_path)
# 返回文件内容,前端可以直接使用
return Response(
content=zip_content,
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={zip_filename}",
"Content-Type": "application/zip"
}
)
finally:
# 清理临时目录
if temp_package_dir.exists():
shutil.rmtree(temp_package_dir, ignore_errors=True)
except HTTPException:
raise
except Exception as e:
logger.error(f"打包节点失败: {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"打包节点失败: {str(e)}")
@router.post("/jingrow/node/publish")
async def publish_node_to_marketplace(
node_type: str = Form(...),
title: str = Form(...),
subtitle: Optional[str] = Form(None),
description: Optional[str] = Form(None),
file_url: str = Form(...),
repository_url: Optional[str] = Form(None),
node_image: Optional[str] = Form(None)
):
"""
发布节点到Jingrow Cloud节点市场
"""
try:
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.jlocal.create_local_node"
headers = get_jingrow_cloud_api_headers()
headers['Content-Type'] = 'application/json'
response = requests.post(url, json={
"node_data": {
"node_type": node_type,
"title": title,
"subtitle": subtitle or "",
"description": description or "",
"file_url": file_url,
"repository_url": repository_url,
"node_image": node_image
}
}, headers=headers, timeout=20)
if response.status_code != 200:
error_detail = response.json().get('detail', f"HTTP {response.status_code}") if response.headers.get('content-type', '').startswith('application/json') else f"HTTP {response.status_code}"
raise HTTPException(status_code=response.status_code, detail=error_detail)
result = response.json()
# 检查错误
if isinstance(result, dict) and result.get('error'):
raise HTTPException(status_code=400, detail=result['error'])
message = result.get('message', {})
if isinstance(message, dict) and message.get('error'):
raise HTTPException(status_code=400, detail=message['error'])
# 成功响应
node_name = message.get('name', 'unknown') if isinstance(message, dict) else result.get('message', 'unknown')
return {"success": True, "message": f"节点发布成功,节点名称: {node_name}"}
except HTTPException:
raise
except Exception as e:
logger.error(f"发布节点失败: {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"发布节点失败: {str(e)}")