627 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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:check' : 'tabler:download'" />
</n-icon>
</template>
{{ isCurrentNodeInstalled ? t('Installed') : 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>