优化节点详情页一键发布到节点市场的功能

This commit is contained in:
jingrow 2025-11-03 02:08:11 +08:00
parent 953a166f1c
commit 2ae47a041a
4 changed files with 320 additions and 30 deletions

View File

@ -150,7 +150,7 @@ export const importLocalNodes = async (): Promise<{ success: boolean; matched: n
}
// 打包节点为zip文件
export const packageNode = async (nodeType: string): Promise<Blob> => {
export const packageNode = async (nodeType: string): Promise<{ blob: Blob; filename: string }> => {
try {
const response = await axios.post(
`/jingrow/node/package/${encodeURIComponent(nodeType)}`,
@ -161,7 +161,27 @@ export const packageNode = async (nodeType: string): Promise<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) {
console.error('打包节点失败:', error)
throw error

View File

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

View File

@ -88,6 +88,15 @@
@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">
<template #icon>
@ -121,6 +130,7 @@ import { NButton, NSpace, NIcon, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '@/shared/i18n'
import SchemaEditorModal from '@/core/components/SchemaEditorModal.vue'
import PublishProgressModal, { type Step } from './PublishProgressModal.vue'
import axios from 'axios'
import { get_session_api_headers } from '@/shared/api/auth'
import { packageNode, publishNodeToMarketplace } from '@/shared/api/nodes'
@ -165,6 +175,22 @@ const nodeRecord = computed(() => props.record || {})
//
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() {
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() {
if (isNew.value || !nodeRecord.value.node_type) {
message.warning(t('Please save the node first'))
@ -218,23 +251,40 @@ async function handlePublishToMarketplace() {
const nodeLabel = nodeRecord.value.node_label || nodeType
const nodeDescription = nodeRecord.value.node_description || ''
//
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 {
// 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 zipFile = new File([zipBlob], `${nodeType}.zip`, { type: 'application/zip' })
const { blob: zipBlob, filename: zipFilename } = await packageNode(nodeType)
const zipFile = new File([zipBlob], zipFilename, { type: 'application/zip' })
//
if (loadingMessage) {
loadingMessage.destroy()
}
loadingMessage = message.loading(t('Uploading package...'), { duration: 0 })
updateStep(0, {
status: 'success',
message: t('Package created successfully')
})
// 2. zip
updateStep(1, { status: 'processing', message: t('Uploading to server...') })
const uploadResult = await uploadFileToJingrow(
zipFile,
'Local Ai Node',
@ -248,13 +298,14 @@ async function handlePublishToMarketplace() {
const fileUrl = uploadResult.file_url
//
if (loadingMessage) {
loadingMessage.destroy()
}
loadingMessage = message.loading(t('Publishing to marketplace...'), { duration: 0 })
updateStep(1, {
status: 'success',
message: t('Upload successful')
})
// 3.
updateStep(2, { status: 'processing', message: t('Publishing to marketplace...') })
const publishResult = await publishNodeToMarketplace({
node_type: nodeType,
title: nodeLabel,
@ -264,23 +315,30 @@ async function handlePublishToMarketplace() {
node_image: nodeRecord.value.node_image
})
if (loadingMessage) {
loadingMessage.destroy()
}
if (publishResult.success) {
message.success(t('Node published to marketplace successfully'))
updateStep(2, {
status: 'success',
message: t('Published successfully')
})
publishStatus.value = 'success'
} 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)
//
const currentStepIndex = publishSteps.value.findIndex(step => step.status === 'processing')
if (currentStepIndex >= 0) {
updateStep(currentStepIndex, {
status: 'error',
error: errorMsg
})
}
publishStatus.value = 'error'
} finally {
publishing.value = false
}

View File

@ -599,9 +599,12 @@ async def package_node(node_type: str):
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}"
# 创建临时目录用于打包,文件夹名称保持为节点类型(不加时间戳)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
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)
try:
@ -615,10 +618,11 @@ async def package_node(node_type: str):
else:
shutil.copy2(item, dst)
# 打包为 ZIP
# 打包为 ZIPzip文件名格式{node_type}-{timestamp}.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}")
# 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}"