631 lines
15 KiB
Vue
631 lines
15 KiB
Vue
<template>
|
||
<div class="app-detail">
|
||
<div class="page-header">
|
||
<div class="header-content">
|
||
<div class="header-text">
|
||
<h1>{{ app?.title || t('App 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="isCurrentAppInstalled ? 'warning' : 'primary'"
|
||
@click="installApp"
|
||
size="medium"
|
||
>
|
||
<template #icon>
|
||
<n-icon>
|
||
<Icon :icon="isCurrentAppInstalled ? 'tabler:check' : 'tabler:download'" />
|
||
</n-icon>
|
||
</template>
|
||
{{ isCurrentAppInstalled ? t('Installed') : t('Install') }}
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="loading-container">
|
||
<n-spin size="large">
|
||
<template #description>
|
||
{{ t('Loading application 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="app" class="app-content">
|
||
<!-- 整体卡片布局 -->
|
||
<div class="app-card">
|
||
<!-- 上部分:应用信息 -->
|
||
<div class="app-info-section">
|
||
<div class="app-content-layout">
|
||
<!-- 左侧:应用图片 -->
|
||
<div class="app-image-section">
|
||
<div class="app-image">
|
||
<img
|
||
v-if="app.app_image"
|
||
:src="getImageUrl(app.app_image)"
|
||
:alt="app.title"
|
||
@error="handleImageError"
|
||
/>
|
||
<div v-else class="placeholder-image">
|
||
<n-icon size="80"><Icon icon="tabler:apps" /></n-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:应用信息 -->
|
||
<div class="app-info-content">
|
||
<div class="app-header">
|
||
<h2 class="app-title">{{ app.title || t('Untitled App') }}</h2>
|
||
<div v-if="app.subtitle" class="app-subtitle">{{ app.subtitle }}</div>
|
||
</div>
|
||
|
||
<div class="info-list">
|
||
<div v-if="app.app_name" class="info-item">
|
||
<span class="label">{{ t('App Name') }}:</span>
|
||
<span class="value">{{ app.app_name }}</span>
|
||
</div>
|
||
|
||
<div v-if="app.team" class="info-item">
|
||
<span class="label">{{ t('Team') }}:</span>
|
||
<span class="value">{{ app.team }}</span>
|
||
</div>
|
||
|
||
<div v-if="app.repository_url" class="info-item">
|
||
<span class="label">{{ t('Repository URL') }}:</span>
|
||
<a :href="app.repository_url" target="_blank" class="link">
|
||
{{ app.repository_url }}
|
||
</a>
|
||
</div>
|
||
|
||
<div v-if="app.file_url" class="info-item">
|
||
<span class="label">{{ t('File URL') }}:</span>
|
||
<a :href="app.file_url" target="_blank" class="link">
|
||
{{ app.file_url }}
|
||
</a>
|
||
</div>
|
||
|
||
<div v-if="app.creation" class="info-item">
|
||
<span class="label">{{ t('Created') }}:</span>
|
||
<span class="value">{{ formatDate(app.creation) }}</span>
|
||
</div>
|
||
|
||
<div v-if="app.modified" class="info-item">
|
||
<span class="label">{{ t('Last Updated') }}:</span>
|
||
<span class="value">{{ formatDate(app.modified) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 下部分:描述内容 -->
|
||
<div v-if="app.description" class="description-section">
|
||
<h3>{{ t('Description') }}</h3>
|
||
<div class="description-content" v-html="app.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 app = 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 installedAppNames = ref<Set<string>>(new Set())
|
||
|
||
const appName = computed(() => route.params.name as string)
|
||
|
||
// 检查当前应用是否已安装
|
||
const isCurrentAppInstalled = computed(() => {
|
||
if (!app.value) return false
|
||
return isAppInstalled(app.value.app_name || app.value.name || '')
|
||
})
|
||
|
||
async function loadAppDetail() {
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
try {
|
||
const response = await axios.get(`/jingrow/app-marketplace/${appName.value}`)
|
||
app.value = response.data
|
||
} catch (err: any) {
|
||
console.error('Failed to load app detail:', err)
|
||
error.value = err.response?.data?.detail || t('Failed to load application 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 getImageUrl(url: string) {
|
||
if (!url) return ''
|
||
if (url.startsWith('http')) return url
|
||
return `https://cloud.jingrow.com${url}`
|
||
}
|
||
|
||
function handleImageError(event: Event) {
|
||
const img = event.target as HTMLImageElement
|
||
img.style.display = 'none'
|
||
}
|
||
|
||
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('/app-marketplace')
|
||
}
|
||
}
|
||
|
||
async function installApp() {
|
||
if (!app.value?.file_url && !app.value?.repository_url) {
|
||
message.error(t('应用文件URL或仓库地址不存在'))
|
||
return
|
||
}
|
||
|
||
// 先检查应用是否已存在
|
||
try {
|
||
const appName = app.value.app_name || app.value.name
|
||
if (appName) {
|
||
const checkResponse = await axios.get(`/jingrow/check-app/${appName}`)
|
||
|
||
if (checkResponse.data.exists) {
|
||
// 显示确认对话框
|
||
dialog.warning({
|
||
title: t('应用已存在'),
|
||
content: t('应用 "{0}" 已安装,是否覆盖安装?').replace('{0}', appName),
|
||
positiveText: t('确认覆盖'),
|
||
negativeText: t('取消'),
|
||
onPositiveClick: () => {
|
||
performInstall()
|
||
}
|
||
})
|
||
return
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Check app exists error:', error)
|
||
}
|
||
|
||
performInstall()
|
||
}
|
||
|
||
async function performInstall() {
|
||
try {
|
||
installing.value = true
|
||
installProgress.value = 0
|
||
installMessage.value = t('正在准备安装...')
|
||
installStatus.value = 'info'
|
||
showProgressModal.value = true
|
||
|
||
let response
|
||
|
||
// 优先使用文件URL,否则使用git仓库
|
||
if (app.value.file_url) {
|
||
installMessage.value = t('正在下载应用包...')
|
||
setTimeout(() => {
|
||
installProgress.value = 20
|
||
}, 300)
|
||
|
||
installProgress.value = 30
|
||
installMessage.value = t('正在安装应用...')
|
||
|
||
response = await axios.post('/jingrow/install-from-url', new URLSearchParams({
|
||
url: app.value.file_url
|
||
}), {
|
||
headers: {
|
||
...get_session_api_headers(),
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
}
|
||
})
|
||
} else if (app.value.repository_url) {
|
||
installMessage.value = t('正在克隆仓库...')
|
||
setTimeout(() => {
|
||
installProgress.value = 20
|
||
}, 300)
|
||
|
||
installProgress.value = 30
|
||
installMessage.value = t('正在安装应用...')
|
||
|
||
const params = new URLSearchParams({
|
||
repo_url: app.value.repository_url
|
||
})
|
||
|
||
response = await axios.post('/jingrow/install-from-git', params, {
|
||
headers: {
|
||
...get_session_api_headers(),
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
}
|
||
})
|
||
}
|
||
|
||
if (!response) {
|
||
throw new Error(t('无法确定安装方式'))
|
||
}
|
||
|
||
// 更新进度到安装完成
|
||
installProgress.value = 100
|
||
|
||
if (response.data.success) {
|
||
// 所有步骤完成后才显示成功
|
||
installing.value = false
|
||
installStatus.value = 'success'
|
||
installMessage.value = t('应用安装成功!')
|
||
message.success(t('应用安装成功'))
|
||
|
||
// 刷新已安装应用列表
|
||
loadInstalledApps()
|
||
|
||
setTimeout(() => {
|
||
showProgressModal.value = false
|
||
}, 2000)
|
||
} else {
|
||
throw new Error(response.data.error || t('安装失败'))
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Install app error:', error)
|
||
installing.value = false
|
||
installStatus.value = 'error'
|
||
installMessage.value = error.response?.data?.detail || error.message || t('安装失败')
|
||
message.error(error.response?.data?.detail || t('安装失败'))
|
||
|
||
setTimeout(() => {
|
||
showProgressModal.value = false
|
||
}, 3000)
|
||
}
|
||
}
|
||
|
||
// 加载已安装应用列表
|
||
async function loadInstalledApps() {
|
||
try {
|
||
const response = await axios.get('/jingrow/installed-app-names')
|
||
if (response.data.success) {
|
||
const apps = response.data.apps || []
|
||
installedAppNames.value = new Set(apps)
|
||
}
|
||
} catch (error) {
|
||
console.error('Load installed apps error:', error)
|
||
}
|
||
}
|
||
|
||
// 检查应用是否已安装
|
||
function isAppInstalled(appName: string): boolean {
|
||
if (!appName) return false
|
||
return installedAppNames.value.has(appName.toLowerCase())
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadAppDetail()
|
||
loadInstalledApps()
|
||
|
||
// 监听全局事件
|
||
window.addEventListener('installedAppsUpdated', () => {
|
||
loadInstalledApps()
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.app-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;
|
||
}
|
||
|
||
.app-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 32px;
|
||
}
|
||
|
||
/* 整体卡片布局 */
|
||
.app-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;
|
||
}
|
||
|
||
/* 上部分:应用信息 */
|
||
|
||
.app-content-layout {
|
||
display: grid;
|
||
grid-template-columns: 1fr 2fr;
|
||
gap: 50px;
|
||
align-items: start;
|
||
}
|
||
|
||
.app-image-section {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.app-image {
|
||
width: 100%;
|
||
min-height: 300px;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.app-image img {
|
||
width: 100%;
|
||
height: auto;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.placeholder-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.app-header {
|
||
margin-bottom: 20px;
|
||
padding-bottom: 16px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.app-title {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
.app-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) {
|
||
.app-card {
|
||
padding: 20px;
|
||
}
|
||
|
||
.app-content-layout {
|
||
grid-template-columns: 1fr;
|
||
gap: 30px;
|
||
}
|
||
|
||
.app-image-section {
|
||
order: 2;
|
||
justify-content: center;
|
||
}
|
||
|
||
.app-image {
|
||
max-width: 150px;
|
||
}
|
||
|
||
.app-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> |