631 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="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>