优化节点详情页一键发布到节点市场的功能
This commit is contained in:
parent
953a166f1c
commit
2ae47a041a
@ -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
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
<!-- 发布进度弹窗 -->
|
||||
<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
|
||||
}
|
||||
|
||||
@ -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
|
||||
# 打包为 ZIP,zip文件名格式:{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}"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user