Files
tiantian-system/app/pages/market/[id].vue
2026-04-08 17:10:58 +08:00

548 lines
22 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>
<!-- 加载骨架屏 -->
<div v-if="loading" class="mx-auto max-w-screen-xl px-4 py-12">
<div class="flex gap-3 items-center mb-8 text-sm text-gray-400 cursor-pointer" @click="navigateTo('/market')">
<LeftOutlined /> 返回市场
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 space-y-6">
<a-skeleton active :paragraph="{ rows: 4 }" />
<a-skeleton active :paragraph="{ rows: 6 }" />
</div>
<div>
<a-skeleton active :paragraph="{ rows: 8 }" />
</div>
</div>
</div>
<!-- 404 状态 -->
<div v-else-if="!app" class="flex flex-col items-center justify-center py-32 text-center">
<div class="text-8xl mb-6">🔍</div>
<a-typography-title :level="3" class="!text-gray-700">应用不存在</a-typography-title>
<a-typography-paragraph class="!text-gray-400 !mb-6">该应用可能已下架或链接有误</a-typography-paragraph>
<a-button type="primary" @click="navigateTo('/market')">返回应用市场</a-button>
</div>
<!-- 正文内容 -->
<div v-else>
<!-- 顶部 Hero Banner -->
<section class="bg-gradient-to-b from-gray-950 to-gray-900 text-white">
<div class="mx-auto max-w-screen-xl px-4 py-10">
<!-- 面包屑 -->
<div class="flex items-center gap-2 text-sm text-gray-400 mb-8">
<span class="cursor-pointer hover:text-white transition-colors" @click="navigateTo('/')">首页</span>
<span>/</span>
<span class="cursor-pointer hover:text-white transition-colors" @click="navigateTo('/market')">应用市场</span>
<span>/</span>
<span class="text-gray-300">{{ app.productName }}</span>
</div>
<div class="flex flex-col sm:flex-row items-start gap-6">
<!-- 应用图标 -->
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center flex-shrink-0 overflow-hidden shadow-lg">
<img
v-if="app.icon"
:src="app.icon"
:alt="app.productName"
class="w-full h-full object-cover"
/>
<span v-else class="text-4xl">{{ getEmoji(app.industryParent) }}</span>
</div>
<!-- 应用基本信息 -->
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-3 mb-2">
<h1 class="text-2xl sm:text-3xl font-bold text-white">{{ app.productName }}</h1>
<a-tag v-if="app.official" color="gold">官方认证</a-tag>
<a-tag v-if="app.recommend === 1" color="red">精选推荐</a-tag>
<a-tag :color="app.plugin ? 'purple' : 'blue'">{{ app.plugin ? '插件' : '应用' }}</a-tag>
</div>
<p class="text-gray-300 text-base mb-4 line-clamp-2">
{{ app.description || app.description || '暂无介绍' }}
</p>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span v-if="app.industryParent" class="flex items-center gap-1">
<AppstoreOutlined /> {{ app.industryParent }}
<template v-if="app.industryChild"> / {{ app.industryChild }}</template>
</span>
<span v-if="app.rating" class="flex items-center gap-1">
<StarOutlined class="text-amber-400" />
<span class="text-white font-medium">{{ app.rating }}</span>
<span v-if="app.reviewCount">({{ app.reviewCount }} 评价)</span>
</span>
<span v-if="app.installs" class="flex items-center gap-1">
<DownloadOutlined /> {{ formatNumber(app.installs) }} 次安装
</span>
<span v-if="app.running === 1" class="flex items-center gap-1 text-green-400">
<CheckCircleOutlined /> 运行中
</span>
</div>
</div>
</div>
</div>
</section>
<!-- 截图轮播 -->
<div v-if="screenshots.length > 0" class="bg-gray-100 border-b border-gray-200">
<div class="mx-auto max-w-screen-xl px-4 py-6">
<div class="flex gap-4 overflow-x-auto pb-2 scrollbar-hide">
<div
v-for="(img, i) in screenshots"
:key="i"
class="flex-shrink-0 w-64 h-40 rounded-xl overflow-hidden bg-white shadow-sm cursor-pointer hover:shadow-md transition-shadow"
@click="openPreview(i)"
>
<img :src="img" :alt="`截图${i + 1}`" class="w-full h-full object-cover" />
</div>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="mx-auto max-w-screen-xl px-4 py-10">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-10">
<!-- 左侧详细信息 -->
<div class="lg:col-span-2 space-y-8">
<!-- 应用介绍 -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<InfoCircleOutlined class="text-indigo-500" /> 应用介绍
</h2>
<div
v-if="app.content"
class="prose prose-gray max-w-none text-gray-600 leading-relaxed"
v-html="app.content"
/>
<p v-else class="text-gray-500 text-sm">暂无详细介绍</p>
</section>
<!-- 关键词标签 -->
<section v-if="keywords.length > 0" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<TagsOutlined class="text-indigo-500" /> 标签
</h2>
<div class="flex flex-wrap gap-2">
<a-tag v-for="kw in keywords" :key="kw" class="text-sm">{{ kw }}</a-tag>
</div>
</section>
<!-- 应用信息表格 -->
<section class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<FileTextOutlined class="text-indigo-500" /> 基本信息
</h2>
<a-descriptions :column="{ xs: 1, sm: 2 }" size="small">
<a-descriptions-item label="应用标识">
<code class="bg-gray-100 px-2 py-0.5 rounded text-sm">{{ app.productCode || '-' }}</code>
</a-descriptions-item>
<a-descriptions-item label="应用类型">
<a-tag :color="app.plugin ? 'purple' : 'blue'" size="small">
{{ app.plugin ? '插件' : '应用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="所属行业">
{{ app.industryParent || '通用' }}
<template v-if="app.industryChild"> / {{ app.industryChild }}</template>
</a-descriptions-item>
<a-descriptions-item label="运行状态">
<span :class="app.running === 1 ? 'text-green-500' : 'text-gray-400'">
{{ app.running === 1 ? '运行中' : '维护中' }}
</span>
</a-descriptions-item>
<a-descriptions-item v-if="app.domain" label="网站地址" :span="2">
<a :href="app.domain" target="_blank" rel="noopener" class="text-indigo-600 hover:underline">
{{ app.domain }}
</a>
</a-descriptions-item>
<a-descriptions-item label="官方认证">
<a-tag v-if="app.official" color="gold" size="small">官方</a-tag>
<span v-else class="text-gray-400">普通</span>
</a-descriptions-item>
<a-descriptions-item label="精选推荐">
<a-tag v-if="app.recommend === 1" color="red" size="small">推荐</a-tag>
<span v-else class="text-gray-400">-</span>
</a-descriptions-item>
<a-descriptions-item v-if="app.createTime" label="上架时间">
{{ app.publishTime || app.createTime }}
</a-descriptions-item>
</a-descriptions>
</section>
</div>
<!-- 右侧购买/操作卡片 -->
<div class="space-y-6">
<!-- 价格与操作 -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 sticky top-6">
<!-- 价格展示 -->
<div class="mb-5">
<div v-if="app.priceType === 'free' || !app.priceType" class="flex items-baseline gap-2">
<span class="text-3xl font-bold text-green-500">免费</span>
</div>
<div v-else class="flex items-baseline gap-2">
<span class="text-3xl font-bold text-amber-500">¥{{ (app.price || 0) / 100 }}</span>
<span class="text-gray-400 text-sm">/ {{ priceTypeUnit(app.priceType) }}</span>
</div>
<p class="text-xs text-gray-400 mt-1">
{{ app.priceType === 'subscription' ? '按周期订阅,随时取消' : '一次购买,永久使用' }}
</p>
</div>
<a-space direction="vertical" class="w-full" size="small">
<!-- 立即体验 -->
<a-button
v-if="app.adminUrl || app.domain"
size="large"
block
@click="goToApp"
>
<template #icon><PlayCircleOutlined /></template>
立即体验
</a-button>
<!-- 购买 / 免费订阅 -->
<a-button
type="primary"
size="large"
block
@click="handleAction"
>
<template #icon><ShoppingCartOutlined /></template>
{{ app.priceType === 'free' || !app.priceType ? '免费订阅' : '立即购买' }}
</a-button>
<!-- 咨询 -->
<a-button size="large" block ghost @click="navigateTo('/contact')">
<template #icon><MessageOutlined /></template>
咨询 / 定制
</a-button>
</a-space>
<a-divider class="!my-4" />
<!-- 评分 -->
<div v-if="app.rating" class="flex items-center justify-between text-sm mb-3">
<span class="text-gray-500">用户评分</span>
<div class="flex items-center gap-1">
<a-rate :value="app.rating" disabled allow-half class="text-sm" />
<span class="font-medium ml-1">{{ app.rating }}</span>
<span class="text-gray-400 text-xs" v-if="app.reviewCount">({{ app.reviewCount }})</span>
</div>
</div>
<!-- 安装量 -->
<div v-if="app.installs" class="flex items-center justify-between text-sm mb-3">
<span class="text-gray-500">安装量</span>
<span class="font-medium text-gray-700">{{ formatNumber(app.installs) }}</span>
</div>
<!-- 认证标识 -->
<div v-if="app.official" class="flex items-center justify-between text-sm">
<span class="text-gray-500">认证</span>
<a-tag color="gold" size="small">官方认证</a-tag>
</div>
</div>
<!-- 开发者信息 -->
<div v-if="app.developer || app.companyName" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h3 class="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
<UserOutlined class="text-indigo-500" /> 开发者
</h3>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 font-semibold text-sm flex-shrink-0">
{{ (app.developer || app.companyName || '?')[0] }}
</div>
<div>
<div class="font-medium text-gray-800 text-sm">{{ app.developer || app.companyName }}</div>
<div v-if="app.official" class="text-xs text-amber-500 flex items-center gap-1 mt-0.5">
<SafetyCertificateOutlined /> 官方认证开发者
</div>
</div>
</div>
</div>
<!-- 相关应用 -->
<div v-if="relatedApps.length > 0" class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h3 class="text-sm font-semibold text-gray-900 mb-3">同类应用</h3>
<div class="space-y-3">
<div
v-for="related in relatedApps"
:key="related.productId"
class="flex items-center gap-3 cursor-pointer hover:bg-gray-50 rounded-lg p-2 -mx-2 transition-colors"
@click="navigateTo(`/market/${related.productId}`)"
>
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-50 to-purple-50 flex items-center justify-center text-xl flex-shrink-0 overflow-hidden">
<img v-if="getFirstImage(related.files)" :src="getFirstImage(related.files)!" class="w-full h-full object-cover" />
<span v-else>{{ getEmoji(related.industryParent) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate">{{ related.productName }}</div>
<div class="text-xs text-gray-400">{{ related.industryParent }}</div>
</div>
<RightOutlined class="text-gray-300 text-xs flex-shrink-0" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部 CTA -->
<section class="bg-gradient-to-r from-indigo-600 to-purple-600 text-white">
<div class="mx-auto max-w-screen-xl px-4 py-12">
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<div class="text-xl font-bold mb-1">还在寻找合适的应用</div>
<div class="text-indigo-100 text-sm">浏览更多精选应用与行业解决方案</div>
</div>
<a-space>
<a-button size="large" @click="navigateTo('/market')">返回市场</a-button>
<a-button type="primary" size="large" ghost @click="navigateTo('/contact')">咨询合作</a-button>
</a-space>
</div>
</div>
</section>
</div>
<!-- 图片预览 -->
<a-image-preview-group
:preview="{
visible: previewVisible,
onVisibleChange: (v: boolean) => previewVisible = v,
current: previewIndex
}"
>
<a-image
v-for="(img, i) in screenshots"
:key="i"
:src="img"
style="display: none"
/>
</a-image-preview-group>
<!-- 购买弹窗 -->
<a-modal
v-model:open="showBuyModal"
:title="app?.priceType === 'free' || !app?.priceType ? '免费订阅' : '购买应用'"
width="480px"
:confirm-loading="buyLoading"
ok-text="确认"
cancel-text="取消"
@ok="confirmBuy"
@cancel="showBuyModal = false"
>
<div v-if="app" class="py-2">
<div class="flex items-center justify-between mb-4">
<div class="font-semibold text-base text-gray-800">{{ app.productName }}</div>
<div class="text-2xl font-bold" :class="app.priceType === 'free' || !app.priceType ? 'text-green-500' : 'text-amber-500'">
{{ app.priceType === 'free' || !app.priceType ? '免费' : `¥${(app.price || 0) / 100}` }}
</div>
</div>
<a-divider class="!my-4" />
<div v-if="app.priceType === 'subscription'" class="flex items-center gap-4 mb-4">
<span class="text-gray-500 text-sm w-20 flex-shrink-0">订阅周期</span>
<a-radio-group v-model:value="subscriptionPeriod">
<a-radio-button value="month">按月订阅</a-radio-button>
<a-radio-button value="year">按年订阅省2个月</a-radio-button>
</a-radio-group>
</div>
<a-alert type="info" show-icon>
<template #message>购买后将自动添加到控制台应用列表</template>
</a-alert>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import {
LeftOutlined, RightOutlined, StarOutlined, DownloadOutlined, CheckCircleOutlined,
InfoCircleOutlined, TagsOutlined, FileTextOutlined, PlayCircleOutlined,
ShoppingCartOutlined, MessageOutlined, UserOutlined, SafetyCertificateOutlined,
AppstoreOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import type { AppProduct } from '@/api/cms/cmsWebsite/model'
import request from '@/utils/request'
import type { ApiResult, PageResult } from '@/api'
import { usePageSeo } from '@/composables/usePageSeo'
definePageMeta({ layout: 'default' })
const route = useRoute()
const id = computed(() => Number(route.params.id))
// 数据状态
const loading = ref(true)
const app = ref<AppProduct | null>(null)
const allApps = ref<AppProduct[]>([])
// 预览状态
const previewVisible = ref(false)
const previewIndex = ref(0)
// 购买弹窗
const showBuyModal = ref(false)
const buyLoading = ref(false)
const subscriptionPeriod = ref<'month' | 'year'>('month')
// 用户状态
const userId = import.meta.client ? localStorage.getItem('UserId') : null
const isLoggedIn = computed(() => !!userId)
// 截图列表
const screenshots = computed((): string[] => {
const src = app.value?.screenshots || app.value?.files
if (!src) return []
try {
const parsed = JSON.parse(src)
if (Array.isArray(parsed)) {
return parsed.map((item: any) => item?.url || item).filter(Boolean)
}
if (typeof parsed === 'string') return [parsed]
} catch {
if (src.startsWith('http')) return [src]
}
return []
})
// 关键词列表
const keywords = computed(() => {
if (!app.value?.keywords) return []
return app.value.keywords.split(/[,]/).map((k: string) => k.trim()).filter(Boolean)
})
// 同类应用(排除当前)
const relatedApps = computed(() => {
if (!app.value?.industryParent) return []
return allApps.value
.filter(a =>
a.productId !== app.value!.productId &&
a.industryParent === app.value!.industryParent
)
.slice(0, 4)
})
// 工具函数
function getEmoji(industry?: string): string {
const map: Record<string, string> = {
'电商零售': '🛒', '企业官网': '🏢', '教育培训': '📚',
'医疗服务': '🏥', '餐饮美食': '🍜', '旅游出行': '✈️',
'金融服务': '💰', '房产家居': '🏠', '社区社交': '👥', '工具服务': '🛠️'
}
return map[industry || ''] || '📱'
}
function getFirstImage(files?: string): string | null {
if (!files) return null
try {
const parsed = JSON.parse(files)
if (Array.isArray(parsed) && parsed.length > 0) return parsed[0]?.url || parsed[0]
if (typeof parsed === 'string') return parsed
} catch {
if (files.startsWith('http')) return files
}
return null
}
function formatNumber(num: number): string {
if (num >= 10000) return (num / 10000).toFixed(1) + 'w'
if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
return num.toString()
}
function priceTypeUnit(type?: string): string {
const map: Record<string, string> = { free: '永久', one_time: '永久', subscription: '月' }
return map[type || ''] || '永久'
}
function openPreview(index: number) {
previewIndex.value = index
previewVisible.value = true
}
function goToApp() {
if (!isLoggedIn.value) {
message.warning('请先登录后再体验应用')
navigateTo('/login?redirect=' + encodeURIComponent(route.fullPath))
return
}
const url = app.value?.adminUrl || app.value?.domain
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
function handleAction() {
if (!isLoggedIn.value) {
message.warning('请先登录')
navigateTo('/login?redirect=' + encodeURIComponent(route.fullPath))
return
}
showBuyModal.value = true
}
async function confirmBuy() {
buyLoading.value = true
setTimeout(() => {
message.success(`${app.value?.productName} 订阅成功!`)
buyLoading.value = false
showBuyModal.value = false
navigateTo('/console/apps?tab=purchased')
}, 1200)
}
// 数据加载(走公开路径 /api/cms/cms-website/pageAll无需登录
async function fetchApp() {
loading.value = true
try {
const res = await request.get<ApiResult<PageResult<AppProduct>>>(
'/api/cms/cms-website/pageAll',
{ params: { market: true, status: 1, page: 1, limit: 1000 } }
)
const list: AppProduct[] = res?.data?.data?.list || []
allApps.value = list
// 从列表中找到当前应用
app.value = list.find(a => a.productId === id.value) ?? null
} catch (e) {
app.value = null
} finally {
loading.value = false
}
}
// SEO
watchEffect(() => {
if (app.value) {
usePageSeo({
title: `${app.value.productName} - 应用市场`,
description: app.value.description || app.value.description || '',
path: `/market/${id.value}`
})
}
})
// 监听路由参数变化
watch(id, () => fetchApp(), { immediate: true })
</script>
<style scoped>
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.prose {
font-size: 14px;
line-height: 1.8;
color: #4b5563;
}
.prose h1, .prose h2, .prose h3 { color: #1f2937; font-weight: 600; margin: 1em 0 0.5em; }
.prose p { margin: 0.75em 0; }
.prose ul, .prose ol { padding-left: 1.5em; margin: 0.75em 0; }
.prose li { margin: 0.25em 0; }
.prose a { color: #4f46e5; }
.prose code { background: #f3f4f6; padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }
</style>