实现节点市场节点详情页
This commit is contained in:
parent
0a54e1500e
commit
953a166f1c
@ -140,6 +140,11 @@ const router = createRouter({
|
||||
name: 'NodeMarketplace',
|
||||
component: () => import('../../views/dev/NodeMarketplace.vue')
|
||||
},
|
||||
{
|
||||
path: 'node-marketplace/:name',
|
||||
name: 'NodeDetail',
|
||||
component: () => import('../../views/dev/NodeDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'app-marketplace/:name',
|
||||
name: 'AppDetail',
|
||||
|
||||
626
apps/jingrow/frontend/src/views/dev/NodeDetail.vue
Normal file
626
apps/jingrow/frontend/src/views/dev/NodeDetail.vue
Normal file
@ -0,0 +1,626 @@
|
||||
<template>
|
||||
<div class="node-detail">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-text">
|
||||
<h1>{{ node?.title || node?.node_type || t('Node Details') }}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button @click="goBack" size="medium">
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
|
||||
</template>
|
||||
{{ t('Back') }}
|
||||
</n-button>
|
||||
<n-button
|
||||
:type="isCurrentNodeInstalled ? 'warning' : 'primary'"
|
||||
@click="installNode"
|
||||
size="medium"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<Icon :icon="isCurrentNodeInstalled ? 'tabler:refresh' : 'tabler:download'" />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ isCurrentNodeInstalled ? t('Update') : t('Install') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<n-spin size="large">
|
||||
<template #description>
|
||||
{{ t('Loading node details...') }}
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<n-empty :description="error">
|
||||
<template #icon>
|
||||
<n-icon><Icon icon="tabler:alert-circle" /></n-icon>
|
||||
</template>
|
||||
</n-empty>
|
||||
</div>
|
||||
|
||||
<div v-else-if="node" class="node-content">
|
||||
<!-- 整体卡片布局 -->
|
||||
<div class="node-card">
|
||||
<!-- 上部分:节点信息 -->
|
||||
<div class="node-info-section">
|
||||
<div class="node-content-layout">
|
||||
<!-- 左侧:节点图标 -->
|
||||
<div class="node-image-section">
|
||||
<div class="node-image">
|
||||
<div v-if="node.icon || node.node_icon" class="node-icon-container">
|
||||
<Icon
|
||||
:icon="node.icon || node.node_icon"
|
||||
:width="120"
|
||||
:height="120"
|
||||
:style="{ color: node.color || node.node_color || '#6b7280' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="placeholder-image">
|
||||
<n-icon size="80"><Icon icon="tabler:cube" /></n-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:节点信息 -->
|
||||
<div class="node-info-content">
|
||||
<div class="node-header">
|
||||
<h2 class="node-title">{{ node.title || node.node_type || t('Untitled Node') }}</h2>
|
||||
<div v-if="node.subtitle" class="node-subtitle">{{ node.subtitle }}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-list">
|
||||
<div v-if="node.node_type" class="info-item">
|
||||
<span class="label">{{ t('Node Type') }}:</span>
|
||||
<span class="value">{{ node.node_type }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="node.group || node.node_group" class="info-item">
|
||||
<span class="label">{{ t('Group') }}:</span>
|
||||
<span class="value">{{ node.group || node.node_group }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="node.repository_url" class="info-item">
|
||||
<span class="label">{{ t('Repository URL') }}:</span>
|
||||
<a :href="node.repository_url" target="_blank" class="link">
|
||||
{{ node.repository_url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="node.file_url" class="info-item">
|
||||
<span class="label">{{ t('File URL') }}:</span>
|
||||
<a :href="node.file_url" target="_blank" class="link">
|
||||
{{ node.file_url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="node.creation" class="info-item">
|
||||
<span class="label">{{ t('Created') }}:</span>
|
||||
<span class="value">{{ formatDate(node.creation) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="node.modified" class="info-item">
|
||||
<span class="label">{{ t('Last Updated') }}:</span>
|
||||
<span class="value">{{ formatDate(node.modified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下部分:描述内容 -->
|
||||
<div v-if="node.description || node.node_description" class="description-section">
|
||||
<h3>{{ t('Description') }}</h3>
|
||||
<div class="description-content" v-html="node.description || node.node_description"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安装进度弹窗 -->
|
||||
<InstallProgressModal
|
||||
v-model="showProgressModal"
|
||||
:progress="installProgress"
|
||||
:message="installMessage"
|
||||
:status="installStatus"
|
||||
:installing="installing"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { get_session_api_headers } from '@/shared/api/auth'
|
||||
import { NButton, NIcon, NSpin, NEmpty, useMessage, useDialog } from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import axios from 'axios'
|
||||
import { t } from '@/shared/i18n'
|
||||
import InstallProgressModal from './InstallProgressModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const node = ref<any>(null)
|
||||
|
||||
// 安装相关状态
|
||||
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 nodeName = computed(() => route.params.name as string)
|
||||
|
||||
// 检查当前节点是否已安装
|
||||
const isCurrentNodeInstalled = computed(() => {
|
||||
if (!node.value) return false
|
||||
return isNodeInstalled(node.value.node_type || node.value.name || '')
|
||||
})
|
||||
|
||||
async function loadNodeDetail() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/jingrow/node-marketplace/${nodeName.value}`)
|
||||
node.value = response.data
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load node detail:', err)
|
||||
error.value = err.response?.data?.detail || t('Failed to load node details')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
// 从查询参数获取返回路径
|
||||
const returnTo = route.query.returnTo as string
|
||||
if (returnTo) {
|
||||
router.push(returnTo)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查路由历史
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
// 默认返回节点市场
|
||||
router.push('/node-marketplace')
|
||||
}
|
||||
}
|
||||
|
||||
async function installNode() {
|
||||
if (!node.value?.file_url && !node.value?.repository_url) {
|
||||
message.error(t('Node file URL or repository address does not exist'))
|
||||
return
|
||||
}
|
||||
|
||||
// 先检查节点是否已存在
|
||||
try {
|
||||
const nodeType = node.value.node_type || node.value.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()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check node exists error:', error)
|
||||
}
|
||||
|
||||
performInstall()
|
||||
}
|
||||
|
||||
async function performInstall() {
|
||||
try {
|
||||
installing.value = true
|
||||
installProgress.value = 0
|
||||
installMessage.value = t('Preparing installation...')
|
||||
installStatus.value = 'info'
|
||||
showProgressModal.value = true
|
||||
|
||||
let response
|
||||
|
||||
// 优先使用文件URL,否则使用git仓库
|
||||
if (node.value.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.value.file_url
|
||||
}), {
|
||||
headers: {
|
||||
...get_session_api_headers(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
} else if (node.value.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.value.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)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载已安装节点列表
|
||||
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.map((t: string) => t.toLowerCase()))
|
||||
}
|
||||
} 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(() => {
|
||||
loadNodeDetail()
|
||||
loadInstalledNodes()
|
||||
|
||||
// 监听全局事件
|
||||
window.addEventListener('installedNodesUpdated', () => {
|
||||
loadInstalledNodes()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-detail {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
/* 整体卡片布局 */
|
||||
.node-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 上部分:节点信息 */
|
||||
|
||||
.node-content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 50px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.node-image-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-image {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node-icon-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.placeholder-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.node-subtitle {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 下部分:描述内容 */
|
||||
.description-section {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.description-section h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.description-content :deep(h1),
|
||||
.description-content :deep(h2),
|
||||
.description-content :deep(h3),
|
||||
.description-content :deep(h4),
|
||||
.description-content :deep(h5),
|
||||
.description-content :deep(h6) {
|
||||
margin: 16px 0 8px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.description-content :deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.description-content :deep(ul),
|
||||
.description-content :deep(ol) {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.description-content :deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.description-content :deep(code) {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.description-content :deep(pre) {
|
||||
background: #f3f4f6;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.description-content :deep(blockquote) {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.node-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.node-content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.node-image-section {
|
||||
order: 2;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node-image {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -233,8 +233,11 @@ function handlePageSizeChange(newPageSize: number) {
|
||||
}
|
||||
|
||||
function viewNodeDetail(node: any) {
|
||||
// 跳转到节点详情页面
|
||||
router.push(`/node-marketplace/${node.name}`)
|
||||
// 跳转到节点详情页面,传递返回路径
|
||||
router.push({
|
||||
path: `/node-marketplace/${node.name}`,
|
||||
query: { returnTo: '/node-marketplace' }
|
||||
})
|
||||
}
|
||||
|
||||
async function installNode(node: any) {
|
||||
|
||||
@ -266,6 +266,25 @@ async def get_node_marketplace(
|
||||
raise HTTPException(status_code=500, detail=f"获取节点市场数据失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/jingrow/node-marketplace/{name}")
|
||||
async def get_node_detail(name: str):
|
||||
"""获取节点详情"""
|
||||
try:
|
||||
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.jlocal.get_local_node"
|
||||
params = {"name": name}
|
||||
|
||||
headers = get_jingrow_cloud_api_headers()
|
||||
response = requests.get(url, params=params, headers=headers, timeout=20)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get('message')
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="节点不存在")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取节点详情失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/jingrow/check-node/{node_type}")
|
||||
async def check_node_exists(node_type: str):
|
||||
"""检查节点是否已安装"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user