优化节点详情页一键发布到节点市场的功能
This commit is contained in:
parent
953a166f1c
commit
2ae47a041a
@ -150,7 +150,7 @@ export const importLocalNodes = async (): Promise<{ success: boolean; matched: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 打包节点为zip文件
|
// 打包节点为zip文件
|
||||||
export const packageNode = async (nodeType: string): Promise<Blob> => {
|
export const packageNode = async (nodeType: string): Promise<{ blob: Blob; filename: string }> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`/jingrow/node/package/${encodeURIComponent(nodeType)}`,
|
`/jingrow/node/package/${encodeURIComponent(nodeType)}`,
|
||||||
@ -161,7 +161,27 @@ export const packageNode = async (nodeType: string): Promise<Blob> => {
|
|||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return response.data
|
|
||||||
|
// 从 Content-Disposition header 中提取文件名
|
||||||
|
let filename = `${nodeType}.zip`
|
||||||
|
const contentDisposition = response.headers['content-disposition']
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
filename = filenameMatch[1].replace(/['"]/g, '')
|
||||||
|
// 处理 URL 编码的文件名
|
||||||
|
try {
|
||||||
|
filename = decodeURIComponent(filename)
|
||||||
|
} catch {
|
||||||
|
// 如果解码失败,使用原始值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: response.data,
|
||||||
|
filename
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('打包节点失败:', error)
|
console.error('打包节点失败:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@ -0,0 +1,208 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
preset="card"
|
||||||
|
style="width: 600px"
|
||||||
|
:mask-closable="false"
|
||||||
|
:close-on-esc="false"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<n-icon v-if="status === 'processing'" :size="20">
|
||||||
|
<Icon icon="tabler:hourglass" />
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else-if="status === 'success'" :size="20" color="#10b981">
|
||||||
|
<Icon icon="tabler:check-circle" />
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else-if="status === 'error'" :size="20" color="#ef4444">
|
||||||
|
<Icon icon="tabler:x-circle" />
|
||||||
|
</n-icon>
|
||||||
|
<h3 style="margin: 0">{{ title }}</h3>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="progress-content">
|
||||||
|
<!-- 步骤列表 -->
|
||||||
|
<div class="steps-container">
|
||||||
|
<div
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="index"
|
||||||
|
class="step-item"
|
||||||
|
:class="{
|
||||||
|
'step-active': step.status === 'processing',
|
||||||
|
'step-success': step.status === 'success',
|
||||||
|
'step-error': step.status === 'error',
|
||||||
|
'step-pending': step.status === 'pending'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="step-icon">
|
||||||
|
<n-icon v-if="step.status === 'processing'" :size="20">
|
||||||
|
<Icon icon="tabler:loader-2" class="rotating" />
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else-if="step.status === 'success'" :size="20" color="#10b981">
|
||||||
|
<Icon icon="tabler:check" />
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else-if="step.status === 'error'" :size="20" color="#ef4444">
|
||||||
|
<Icon icon="tabler:x" />
|
||||||
|
</n-icon>
|
||||||
|
<n-icon v-else :size="20" color="#94a3b8">
|
||||||
|
<Icon icon="tabler:circle" />
|
||||||
|
</n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<div class="step-title">{{ step.title }}</div>
|
||||||
|
<div v-if="step.message" class="step-message">{{ step.message }}</div>
|
||||||
|
<div v-if="step.error" class="step-error-message">{{ step.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #action>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
v-if="status === 'error' || status === 'success'"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
{{ t('Close') }}
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="status === 'error'"
|
||||||
|
type="primary"
|
||||||
|
@click="handleRetry"
|
||||||
|
>
|
||||||
|
{{ t('Retry') }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { NModal, NIcon, NButton, NSpace } from 'naive-ui'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { t } from '@/shared/i18n'
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
title: string
|
||||||
|
status: 'pending' | 'processing' | 'success' | 'error'
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
title: string
|
||||||
|
steps: Step[]
|
||||||
|
status: 'processing' | 'success' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'retry': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const show = ref(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
show.value = newVal
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(show, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
if (props.status !== 'processing') {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
emit('retry')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-content {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pending {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-active {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-error-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotating {
|
||||||
|
animation: rotate 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@ -88,6 +88,15 @@
|
|||||||
@close="showSchemaEditor = false"
|
@close="showSchemaEditor = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 发布进度弹窗 -->
|
||||||
|
<PublishProgressModal
|
||||||
|
v-model="showPublishModal"
|
||||||
|
:title="t('Publishing to Node Marketplace')"
|
||||||
|
:steps="publishSteps"
|
||||||
|
:status="publishStatus"
|
||||||
|
@retry="handlePublishToMarketplace"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 返回按钮 -->
|
<!-- 返回按钮 -->
|
||||||
<n-button type="default" size="medium" @click="$emit('go-back')" :disabled="loading">
|
<n-button type="default" size="medium" @click="$emit('go-back')" :disabled="loading">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@ -121,6 +130,7 @@ import { NButton, NSpace, NIcon, useMessage } from 'naive-ui'
|
|||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { t } from '@/shared/i18n'
|
import { t } from '@/shared/i18n'
|
||||||
import SchemaEditorModal from '@/core/components/SchemaEditorModal.vue'
|
import SchemaEditorModal from '@/core/components/SchemaEditorModal.vue'
|
||||||
|
import PublishProgressModal, { type Step } from './PublishProgressModal.vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { get_session_api_headers } from '@/shared/api/auth'
|
import { get_session_api_headers } from '@/shared/api/auth'
|
||||||
import { packageNode, publishNodeToMarketplace } from '@/shared/api/nodes'
|
import { packageNode, publishNodeToMarketplace } from '@/shared/api/nodes'
|
||||||
@ -165,6 +175,22 @@ const nodeRecord = computed(() => props.record || {})
|
|||||||
|
|
||||||
// 发布到节点市场相关
|
// 发布到节点市场相关
|
||||||
const publishing = ref(false)
|
const publishing = ref(false)
|
||||||
|
const showPublishModal = ref(false)
|
||||||
|
const publishStatus = ref<'processing' | 'success' | 'error'>('processing')
|
||||||
|
const publishSteps = ref<Step[]>([
|
||||||
|
{
|
||||||
|
title: t('Packaging node'),
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Uploading package'),
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Publishing to marketplace'),
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
function openSchemaEditor() {
|
function openSchemaEditor() {
|
||||||
if (isNew.value) return
|
if (isNew.value) return
|
||||||
@ -208,6 +234,13 @@ async function handleSchemaSave(schemaData: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateStep(index: number, updates: Partial<Step>) {
|
||||||
|
publishSteps.value[index] = {
|
||||||
|
...publishSteps.value[index],
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePublishToMarketplace() {
|
async function handlePublishToMarketplace() {
|
||||||
if (isNew.value || !nodeRecord.value.node_type) {
|
if (isNew.value || !nodeRecord.value.node_type) {
|
||||||
message.warning(t('Please save the node first'))
|
message.warning(t('Please save the node first'))
|
||||||
@ -218,23 +251,40 @@ async function handlePublishToMarketplace() {
|
|||||||
const nodeLabel = nodeRecord.value.node_label || nodeType
|
const nodeLabel = nodeRecord.value.node_label || nodeType
|
||||||
const nodeDescription = nodeRecord.value.node_description || ''
|
const nodeDescription = nodeRecord.value.node_description || ''
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
publishing.value = true
|
publishing.value = true
|
||||||
let loadingMessage: any = null
|
showPublishModal.value = true
|
||||||
|
publishStatus.value = 'processing'
|
||||||
|
publishSteps.value = [
|
||||||
|
{
|
||||||
|
title: t('Packaging node'),
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Uploading package'),
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Publishing to marketplace'),
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 打包节点为zip
|
// 1. 打包节点为zip
|
||||||
loadingMessage = message.loading(t('Packaging node...'), { duration: 0 })
|
updateStep(0, { status: 'processing', message: t('Packaging node folder...') })
|
||||||
|
|
||||||
const zipBlob = await packageNode(nodeType)
|
const { blob: zipBlob, filename: zipFilename } = await packageNode(nodeType)
|
||||||
const zipFile = new File([zipBlob], `${nodeType}.zip`, { type: 'application/zip' })
|
const zipFile = new File([zipBlob], zipFilename, { type: 'application/zip' })
|
||||||
|
|
||||||
// 更新消息
|
updateStep(0, {
|
||||||
if (loadingMessage) {
|
status: 'success',
|
||||||
loadingMessage.destroy()
|
message: t('Package created successfully')
|
||||||
}
|
})
|
||||||
loadingMessage = message.loading(t('Uploading package...'), { duration: 0 })
|
|
||||||
|
|
||||||
// 2. 上传zip文件到服务器
|
// 2. 上传zip文件到服务器
|
||||||
|
updateStep(1, { status: 'processing', message: t('Uploading to server...') })
|
||||||
|
|
||||||
const uploadResult = await uploadFileToJingrow(
|
const uploadResult = await uploadFileToJingrow(
|
||||||
zipFile,
|
zipFile,
|
||||||
'Local Ai Node',
|
'Local Ai Node',
|
||||||
@ -248,13 +298,14 @@ async function handlePublishToMarketplace() {
|
|||||||
|
|
||||||
const fileUrl = uploadResult.file_url
|
const fileUrl = uploadResult.file_url
|
||||||
|
|
||||||
// 更新消息
|
updateStep(1, {
|
||||||
if (loadingMessage) {
|
status: 'success',
|
||||||
loadingMessage.destroy()
|
message: t('Upload successful')
|
||||||
}
|
})
|
||||||
loadingMessage = message.loading(t('Publishing to marketplace...'), { duration: 0 })
|
|
||||||
|
|
||||||
// 3. 发布到节点市场
|
// 3. 发布到节点市场
|
||||||
|
updateStep(2, { status: 'processing', message: t('Publishing to marketplace...') })
|
||||||
|
|
||||||
const publishResult = await publishNodeToMarketplace({
|
const publishResult = await publishNodeToMarketplace({
|
||||||
node_type: nodeType,
|
node_type: nodeType,
|
||||||
title: nodeLabel,
|
title: nodeLabel,
|
||||||
@ -264,23 +315,30 @@ async function handlePublishToMarketplace() {
|
|||||||
node_image: nodeRecord.value.node_image
|
node_image: nodeRecord.value.node_image
|
||||||
})
|
})
|
||||||
|
|
||||||
if (loadingMessage) {
|
|
||||||
loadingMessage.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (publishResult.success) {
|
if (publishResult.success) {
|
||||||
message.success(t('Node published to marketplace successfully'))
|
updateStep(2, {
|
||||||
|
status: 'success',
|
||||||
|
message: t('Published successfully')
|
||||||
|
})
|
||||||
|
publishStatus.value = 'success'
|
||||||
} else {
|
} else {
|
||||||
throw new Error(publishResult.message || t('Publish failed'))
|
throw new Error(publishResult.message || t('Publish failed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (loadingMessage) {
|
|
||||||
loadingMessage.destroy()
|
|
||||||
}
|
|
||||||
console.error('发布节点失败:', error)
|
console.error('发布节点失败:', error)
|
||||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || t('Publish failed, please check permission and server logs')
|
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || t('Publish failed, please check permission and server logs')
|
||||||
message.error(errorMsg)
|
|
||||||
|
// 找到当前正在处理的步骤,标记为错误
|
||||||
|
const currentStepIndex = publishSteps.value.findIndex(step => step.status === 'processing')
|
||||||
|
if (currentStepIndex >= 0) {
|
||||||
|
updateStep(currentStepIndex, {
|
||||||
|
status: 'error',
|
||||||
|
error: errorMsg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
publishStatus.value = 'error'
|
||||||
} finally {
|
} finally {
|
||||||
publishing.value = false
|
publishing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -599,9 +599,12 @@ async def package_node(node_type: str):
|
|||||||
tmp_dir = root / "tmp"
|
tmp_dir = root / "tmp"
|
||||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 创建临时目录用于打包
|
# 创建临时目录用于打包,文件夹名称保持为节点类型(不加时间戳)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
temp_package_dir = tmp_dir / f"{node_type}-{timestamp}"
|
temp_package_dir = tmp_dir / node_type
|
||||||
|
if temp_package_dir.exists():
|
||||||
|
# 如果临时目录已存在,先删除
|
||||||
|
shutil.rmtree(temp_package_dir)
|
||||||
temp_package_dir.mkdir(parents=True, exist_ok=True)
|
temp_package_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -615,10 +618,11 @@ async def package_node(node_type: str):
|
|||||||
else:
|
else:
|
||||||
shutil.copy2(item, dst)
|
shutil.copy2(item, dst)
|
||||||
|
|
||||||
# 打包为 ZIP
|
# 打包为 ZIP,zip文件名格式:{node_type}-{timestamp}.zip,但内部文件夹名称保持为节点类型
|
||||||
zip_filename = f"{node_type}-{timestamp}.zip"
|
zip_filename = f"{node_type}-{timestamp}.zip"
|
||||||
zip_base_name = tmp_dir / f"{node_type}-{timestamp}"
|
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}")
|
# base_dir 使用 node_type,这样 zip 内部文件夹名称就是 node_type
|
||||||
|
shutil.make_archive(str(zip_base_name), 'zip', root_dir=str(tmp_dir), base_dir=node_type)
|
||||||
|
|
||||||
zip_path = tmp_dir / f"{zip_filename}"
|
zip_path = tmp_dir / f"{zip_filename}"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user