初始版本
This commit is contained in:
610
app/pages/market/[id].vue
Normal file
610
app/pages/market/[id].vue
Normal file
@@ -0,0 +1,610 @@
|
||||
<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
|
||||
v-if="isPurchased"
|
||||
size="large"
|
||||
block
|
||||
disabled
|
||||
>
|
||||
<template #icon><CheckCircleOutlined /></template>
|
||||
已购买
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
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/app/appProduct/model'
|
||||
import { getMarketApp, getMarketApps } from '@/api/app/appProduct'
|
||||
import { createSubscription, checkPurchased } from '@/api/app/subscription'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
const route = useRoute()
|
||||
const id = computed(() => {
|
||||
const raw = Number(route.params.id)
|
||||
return isNaN(raw) ? 0 : raw
|
||||
})
|
||||
|
||||
// 数据状态
|
||||
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')
|
||||
|
||||
// 用户状态(响应式,避免 SSR 时 localStorage 未就绪导致误判未登录)
|
||||
const userId = ref<string | null>(null)
|
||||
const isLoggedIn = computed(() => !!userId.value)
|
||||
if (import.meta.client) {
|
||||
userId.value = localStorage.getItem('UserId')
|
||||
}
|
||||
const isPurchased = ref(false)
|
||||
|
||||
// 截图列表
|
||||
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() {
|
||||
if (!app.value?.productId) {
|
||||
message.warning('应用信息缺失')
|
||||
return
|
||||
}
|
||||
buyLoading.value = true
|
||||
try {
|
||||
const result = await createSubscription({
|
||||
productId: app.value.productId,
|
||||
priceType: (app.value.priceType as 'free' | 'one_time' | 'subscription') || 'free',
|
||||
subscriptionPeriod: subscriptionPeriod.value,
|
||||
})
|
||||
|
||||
if (result.status === 'active') {
|
||||
// 免费应用,直接激活成功
|
||||
message.success(`${app.value.productName} 订阅成功!`)
|
||||
showBuyModal.value = false
|
||||
isPurchased.value = true
|
||||
navigateTo('/console/apps?tab=purchased')
|
||||
} else if (result.subscriptionNo) {
|
||||
// 付费应用,跳转支付
|
||||
showBuyModal.value = false
|
||||
message.info('正在跳转支付页面...')
|
||||
navigateTo('/console/pay/' + result.subscriptionNo)
|
||||
} else {
|
||||
message.warning(result.message || '订阅创建异常')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '订阅失败,请稍后重试')
|
||||
} finally {
|
||||
buyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 数据加载(走 Product API,无需登录即可查看)
|
||||
async function fetchApp() {
|
||||
if (!id.value) {
|
||||
loading.value = false
|
||||
app.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
// 加载当前应用详情
|
||||
app.value = await getMarketApp(id.value)
|
||||
|
||||
// 加载同类应用(用于推荐)—— 错误静默,不影响主应用展示
|
||||
try {
|
||||
const allResult = await getMarketApps({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
market: 1,
|
||||
})
|
||||
allApps.value = allResult?.list || []
|
||||
} catch {
|
||||
allApps.value = []
|
||||
}
|
||||
|
||||
// 登录用户检测是否已购买
|
||||
if (isLoggedIn.value && app.value?.productId) {
|
||||
try {
|
||||
isPurchased.value = await checkPurchased(app.value.productId)
|
||||
} catch {
|
||||
// 接口未实现时静默处理
|
||||
}
|
||||
}
|
||||
} 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}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 监听路由参数变化(仅在客户端加载数据,避免 SSR hydration 问题)
|
||||
if (import.meta.client) {
|
||||
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>
|
||||
904
app/pages/market/index.vue
Normal file
904
app/pages/market/index.vue
Normal file
@@ -0,0 +1,904 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero 区域 -->
|
||||
<section class="relative overflow-hidden bg-gradient-to-b from-indigo-950 via-indigo-900 to-indigo-950 text-white">
|
||||
<div class="pointer-events-none absolute left-1/2 top-[-100px] h-[350px] w-[800px] -translate-x-1/2 rounded-full bg-indigo-500/20 blur-3xl" />
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-16 relative">
|
||||
<div class="text-center mb-8">
|
||||
<a-typography-title :level="1" class="!text-white !mb-4">
|
||||
应用 / 模板市场
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph class="!text-gray-300 !text-lg !mb-0">
|
||||
精选行业应用与模板,一键体验、快速部署,开启你的数字化业务
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据 -->
|
||||
<div class="flex justify-center gap-8 flex-wrap">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-white">{{ totalCount }}</div>
|
||||
<div class="text-sm text-gray-400">精选应用</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-white">{{ industryCount }}</div>
|
||||
<div class="text-sm text-gray-400">覆盖行业</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-white">{{ recommendCount }}</div>
|
||||
<div class="text-sm text-gray-400">官方推荐</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mx-auto max-w-screen-xl px-4 py-8">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="mb-8 p-4 bg-white rounded-xl shadow-sm">
|
||||
<!-- 第一行:类型筛选和搜索 -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-4">
|
||||
<!-- 应用/插件 Tab -->
|
||||
<a-radio-group v-model:value="activeType" button-style="solid" @change="handleTypeChange" class="flex-shrink-0">
|
||||
<a-radio-button value="all">全部</a-radio-button>
|
||||
<a-radio-button :value="0">应用</a-radio-button>
|
||||
<a-radio-button :value="1">插件</a-radio-button>
|
||||
</a-radio-group>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索应用名称..."
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
@change="handleKeywordChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button @click="fetchData" :loading="loading" class="flex-shrink-0">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:行业和筛选选项 -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- 行业分类筛选 -->
|
||||
<a-select
|
||||
v-model:value="selectedIndustry"
|
||||
placeholder="选择行业"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
@change="handleIndustryChange"
|
||||
>
|
||||
<a-select-option v-for="ind in industryOptions" :key="ind.value" :value="ind.value">
|
||||
{{ ind.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<!-- 官方/推荐筛选 -->
|
||||
<a-space>
|
||||
<a-checkbox v-model:checked="showOfficial" @change="handleFilterChange">官方</a-checkbox>
|
||||
<a-checkbox v-model:checked="showRecommend" @change="handleFilterChange">推荐</a-checkbox>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 精选推荐 -->
|
||||
<div v-if="recommendApps.length > 0 && !showRecommend" class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">⭐</span>
|
||||
<a-typography-title :level="4" class="!mb-0">精选推荐</a-typography-title>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||
<div
|
||||
v-for="app in recommendApps"
|
||||
:key="app.productId"
|
||||
class="flex-shrink-0 w-72 bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl overflow-hidden shadow-md hover:shadow-lg transition-shadow cursor-pointer border border-amber-100"
|
||||
@click="handleAppClick(app)"
|
||||
>
|
||||
<div class="h-40 bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="getFirstImage(app.screenshots)"
|
||||
:src="getFirstImage(app.screenshots)"
|
||||
:alt="app.productName"
|
||||
:data-industry="app.industryParent"
|
||||
@error="handleImageError"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-4xl">
|
||||
{{ getEmoji(app.industryParent) }}
|
||||
</div>
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<a-tag v-if="app.official" color="gold" size="small">官方</a-tag>
|
||||
<a-tag color="red" size="small">推荐</a-tag>
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<a-tag :color="app.appType === 100 ? 'purple' : 'blue'" size="small">
|
||||
{{ app.appType === 100 ? '插件' : '应用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="font-semibold text-gray-900 mb-1 truncate">{{ app.productName }}</div>
|
||||
<div class="text-sm text-gray-500 mb-2">{{ app.industryParent || '通用行业' }}</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
|
||||
<div v-if="app.rating" class="flex items-center gap-1">
|
||||
<StarOutlined class="text-amber-400" />
|
||||
<span>{{ app.rating }}</span>
|
||||
</div>
|
||||
<div v-if="app.installs" class="flex items-center gap-1">
|
||||
<DownloadOutlined />
|
||||
<span>{{ formatNumber(app.installs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<a-tag v-if="app.industryChild" size="small">{{ app.industryChild }}</a-tag>
|
||||
<span class="text-xs text-gray-400">查看详情 →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用列表 -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a-typography-title :level="4" class="!mb-0">
|
||||
{{ activeType === 'all' ? '全部应用' : activeType === 0 ? '应用列表' : '插件列表' }}
|
||||
<span class="text-sm font-normal text-gray-400 ml-2">({{ filteredApps.length }} 个)</span>
|
||||
</a-typography-title>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-for="i in 8" :key="i" class="bg-white rounded-xl overflow-hidden border border-gray-100">
|
||||
<a-skeleton :active="true" class="!p-0">
|
||||
<template #template>
|
||||
<div class="h-44 bg-gray-100"></div>
|
||||
<div class="p-4">
|
||||
<a-skeleton-input active style="width: 80%" />
|
||||
<a-skeleton-input active style="width: 60%; margin-top: 8px" />
|
||||
<a-skeleton-input active style="width: 40%; margin-top: 12px" />
|
||||
</div>
|
||||
</template>
|
||||
</a-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="filteredApps.length === 0" class="text-center py-16 bg-white rounded-xl border-2 border-dashed border-gray-200">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<div class="text-lg text-gray-500 mb-2">暂无找到符合条件的应用</div>
|
||||
<div class="text-sm text-gray-400 mb-6">尝试调整筛选条件或搜索关键词</div>
|
||||
<a-space>
|
||||
<a-button @click="resetFilters">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置筛选
|
||||
</a-button>
|
||||
<a-button @click="fetchData" :loading="loading">
|
||||
刷新列表
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 应用网格 -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="app in paginatedApps"
|
||||
:key="app.productId"
|
||||
class="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all cursor-pointer border border-gray-100 group"
|
||||
@click="handleAppClick(app)"
|
||||
>
|
||||
<div class="h-44 bg-gray-50 relative overflow-hidden">
|
||||
<img
|
||||
v-if="getFirstImage(app.screenshots)"
|
||||
:src="getFirstImage(app.screenshots)"
|
||||
:alt="app.productName"
|
||||
:data-industry="app.industryParent"
|
||||
@error="handleImageError"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-5xl bg-gradient-to-br from-indigo-50 to-purple-50">
|
||||
{{ getEmoji(app.industryParent) }}
|
||||
</div>
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
<a-tag v-if="app.official" color="gold" size="small">官方</a-tag>
|
||||
<a-tag v-if="app.recommend === 1" color="red" size="small">推荐</a-tag>
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<a-tag :color="app.appType === 100 ? 'purple' : 'blue'" size="small">
|
||||
{{ app.appType === 100 ? '插件' : '应用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
class="w-6 h-6 rounded mt-0.5"
|
||||
/>
|
||||
<div class="font-semibold text-gray-900 truncate flex-1">{{ app.productName }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<a-tag v-if="app.industryParent" size="small" color="cyan">{{ app.industryParent }}</a-tag>
|
||||
<a-tag v-if="app.industryChild" size="small">{{ app.industryChild }}</a-tag>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-2">
|
||||
<div v-if="app.rating" class="flex items-center gap-1">
|
||||
<StarOutlined class="text-amber-400" />
|
||||
<span class="font-medium">{{ app.rating }}</span>
|
||||
</div>
|
||||
<div v-if="app.installs" class="flex items-center gap-1">
|
||||
<DownloadOutlined />
|
||||
<span>{{ formatNumber(app.installs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-gray-400">
|
||||
<ClockCircleOutlined /> {{ app.running === 1 ? '运行中' : '维护中' }}
|
||||
</div>
|
||||
<span class="text-sm text-indigo-600 group-hover:text-indigo-700 font-medium flex items-center gap-1">
|
||||
查看详情 <RightOutlined class="text-xs" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="flex justify-center mt-8">
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:pageSize="pagination.pageSize"
|
||||
:total="filteredApps.length"
|
||||
:show-size-changer="true"
|
||||
:show-quick-jumper="true"
|
||||
:page-size-options="['8', '12', '16', '24', '32']"
|
||||
:show-total="(total: number) => `共 ${total} 条`"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</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-2xl font-bold mb-2">准备好开始了吗?</div>
|
||||
<div class="text-indigo-100">成为模板开发者,共享生态红利</div>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button size="large" @click="navigateTo('/products')">看产品矩阵</a-button>
|
||||
<a-button type="primary" size="large" ghost @click="navigateTo('/contact')">
|
||||
咨询合作/上架
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 购买/订阅弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showBuyModal"
|
||||
:title="buyForm.priceType === 'free' ? '免费订阅' : '购买应用'"
|
||||
width="500px"
|
||||
:confirm-loading="buyLoading"
|
||||
@ok="confirmBuy"
|
||||
@cancel="showBuyModal = false"
|
||||
>
|
||||
<div v-if="buyForm.appId" class="buy-modal-content">
|
||||
<div class="buy-app-info">
|
||||
<div class="buy-app-name">{{ buyForm.appName }}</div>
|
||||
<div class="buy-app-price">
|
||||
<span v-if="buyForm.priceType === 'free'" class="price-free">免费</span>
|
||||
<span v-else>
|
||||
<span class="price-amount">¥{{ buyForm.price / 100 }}</span>
|
||||
<span class="price-unit">/{{ priceTypeUnit(buyForm.priceType) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div v-if="buyForm.priceType === 'subscription'" class="buy-options">
|
||||
<div class="option-label">订阅周期:</div>
|
||||
<a-radio-group v-model:value="buyForm.subscriptionPeriod">
|
||||
<a-radio-button value="month">按月订阅</a-radio-button>
|
||||
<a-radio-button value="year">按年订阅(省2个月)</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="buy-options">
|
||||
<div class="option-label">购买数量:</div>
|
||||
<a-input-number v-model:value="buyForm.quantity" :min="1" :max="99" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="buy-total">
|
||||
<span>合计:</span>
|
||||
<span class="total-price">
|
||||
<span v-if="buyForm.priceType === 'free'">¥0</span>
|
||||
<span v-else>¥{{ buyTotalPrice / 100 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a-alert type="info" show-icon class="mt-4">
|
||||
<template #message>
|
||||
购买后将添加到您的控制台应用列表中
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 应用详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
:title="selectedApp?.productName"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="selectedApp">
|
||||
<div class="mb-4 rounded-lg overflow-hidden bg-gray-100 h-64">
|
||||
<img
|
||||
v-if="getFirstImage(selectedApp.screenshots)"
|
||||
:src="getFirstImage(selectedApp.screenshots)"
|
||||
:alt="selectedApp.productName"
|
||||
:data-industry="selectedApp.industryParent"
|
||||
@error="handleImageError"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-6xl">
|
||||
{{ getEmoji(selectedApp.industryParent) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="类型">
|
||||
<a-tag :color="selectedApp.appType === 100 ? 'purple' : 'blue'">
|
||||
{{ selectedApp.appType === 100 ? '插件' : '应用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="行业">
|
||||
{{ selectedApp.industryParent || '通用' }}
|
||||
<template v-if="selectedApp.industryChild"> / {{ selectedApp.industryChild }}</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="官方认证">
|
||||
<a-tag v-if="selectedApp.official" color="gold">官方</a-tag>
|
||||
<span v-else class="text-gray-400">普通</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="推荐">
|
||||
<a-tag v-if="selectedApp.recommend === 1" color="red">精选推荐</a-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="价格" :span="2">
|
||||
<span v-if="selectedApp.priceType === 'free'" class="text-green-500 font-medium">免费</span>
|
||||
<span v-else>
|
||||
<span class="text-amber-500 font-medium">¥{{ selectedApp.price / 100 }}</span>
|
||||
<span class="text-gray-400 text-sm"> / {{ priceTypeUnit(selectedApp.priceType || 'one_time') }}</span>
|
||||
</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="评分">
|
||||
<div v-if="selectedApp.rating" class="flex items-center gap-2">
|
||||
<StarOutlined class="text-amber-400" />
|
||||
<span class="font-medium">{{ selectedApp.rating }}</span>
|
||||
<span class="text-gray-400 text-sm">({{ selectedApp.reviewCount || 0 }} 评价)</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">暂无评分</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="安装量">
|
||||
<div v-if="selectedApp.installs" class="flex items-center gap-1">
|
||||
<DownloadOutlined />
|
||||
<span>{{ formatNumber(selectedApp.installs) }}</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="关键词" :span="2">
|
||||
<a-tag v-for="kw in getKeywords(selectedApp.keywords)" :key="kw" class="mr-1">{{ kw }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="网站地址" :span="2">
|
||||
<a :href="selectedApp.domain" target="_blank" class="text-indigo-600">
|
||||
{{ selectedApp.domain || '未配置' }}
|
||||
</a>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="后台管理" :span="2">
|
||||
<a :href="selectedApp.adminUrl" target="_blank" class="text-indigo-600">
|
||||
{{ selectedApp.adminUrl || '未配置' }}
|
||||
</a>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<a-button @click="detailVisible = false">关闭</a-button>
|
||||
<a-button v-if="selectedApp.adminUrl" @click="goToApp(selectedApp)">
|
||||
立即体验
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="selectedApp.priceType !== 'free'"
|
||||
type="primary"
|
||||
@click="handleBuy(selectedApp)"
|
||||
>
|
||||
立即购买
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="handleSubscribe(selectedApp)"
|
||||
>
|
||||
免费订阅
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { AppProduct, AppProductParam } from '@/api/app/appProduct/model'
|
||||
import { getMarketApps } from '@/api/app/appProduct'
|
||||
import { createSubscription } from '@/api/app/subscription'
|
||||
import { ReloadOutlined, ClockCircleOutlined, StarOutlined, DownloadOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
|
||||
usePageSeo({
|
||||
title: '应用/模板市场 - 精选行业应用与模板',
|
||||
description: '精选行业应用与模板,一键体验、快速部署。涵盖电商、官网、小程序、CRM 等多种业务场景,支持官方认证与推荐标记。',
|
||||
path: '/market'
|
||||
})
|
||||
|
||||
// 登录状态(响应式,避免 SSR 时 localStorage 未就绪导致误判未登录)
|
||||
const userId = ref<string | null>(null)
|
||||
const isLoggedIn = computed(() => !!userId.value)
|
||||
if (import.meta.client) {
|
||||
userId.value = localStorage.getItem('UserId')
|
||||
}
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const apps = ref<AppProduct[]>([])
|
||||
const activeType = ref<'all' | 0 | 1>('all')
|
||||
const selectedIndustry = ref<string>()
|
||||
const showOfficial = ref(false)
|
||||
const showRecommend = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const selectedApp = ref<AppProduct>()
|
||||
|
||||
// 分页状态
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 12,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 行业选项
|
||||
const industryOptions = [
|
||||
{ value: '电商零售', label: '电商零售' },
|
||||
{ value: '企业官网', label: '企业官网' },
|
||||
{ value: '教育培训', label: '教育培训' },
|
||||
{ value: '医疗服务', label: '医疗服务' },
|
||||
{ value: '餐饮美食', label: '餐饮美食' },
|
||||
{ value: '旅游出行', label: '旅游出行' },
|
||||
{ value: '金融服务', label: '金融服务' },
|
||||
{ value: '房产家居', label: '房产家居' },
|
||||
{ value: '社区社交', label: '社区社交' },
|
||||
{ value: '工具服务', label: '工具服务' }
|
||||
]
|
||||
|
||||
// 统计数据
|
||||
const totalCount = computed(() => apps.value.length)
|
||||
const industryCount = computed(() => {
|
||||
const industries = new Set(apps.value.map(a => a.industryParent).filter(Boolean))
|
||||
return industries.size || 0
|
||||
})
|
||||
const recommendCount = computed(() => apps.value.filter(a => a.official || a.recommend === 1).length)
|
||||
|
||||
// 精选推荐(只取前 4 个推荐的)
|
||||
const recommendApps = computed(() => {
|
||||
return apps.value
|
||||
.filter(a => a.recommend === 1 || a.official)
|
||||
.slice(0, 4)
|
||||
})
|
||||
|
||||
// 筛选后的应用列表
|
||||
const filteredApps = computed(() => {
|
||||
let result = [...apps.value]
|
||||
|
||||
// 类型筛选
|
||||
if (activeType.value !== 'all') {
|
||||
result = result.filter(a => a.appType === (activeType.value === 1 ? 100 : 10))
|
||||
}
|
||||
|
||||
// 行业筛选
|
||||
if (selectedIndustry.value) {
|
||||
result = result.filter(a =>
|
||||
a.industryParent === selectedIndustry.value ||
|
||||
a.industryChild === selectedIndustry.value
|
||||
)
|
||||
}
|
||||
|
||||
// 官方筛选
|
||||
if (showOfficial.value) {
|
||||
result = result.filter(a => a.official)
|
||||
}
|
||||
|
||||
// 推荐筛选
|
||||
if (showRecommend.value) {
|
||||
result = result.filter(a => a.recommend === 1)
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchKeyword.value) {
|
||||
const kw = searchKeyword.value.toLowerCase()
|
||||
result = result.filter(a =>
|
||||
a.productName?.toLowerCase().includes(kw) ||
|
||||
a.keywords?.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 分页后的应用列表
|
||||
const paginatedApps = computed(() => {
|
||||
const start = (pagination.value.current - 1) * pagination.value.pageSize
|
||||
const end = start + pagination.value.pageSize
|
||||
return filteredApps.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 获取首张图片
|
||||
const 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 {
|
||||
// 如果是普通 URL 字符串
|
||||
if (files.startsWith('http')) return files
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 图片加载错误处理
|
||||
const handleImageError = (e: Event) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
// 使用行业 emoji 作为占位
|
||||
const industry = img.getAttribute('data-industry') || ''
|
||||
const emoji = getEmoji(industry)
|
||||
// 创建一个包含 emoji 的 canvas 作为占位图
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 200
|
||||
canvas.height = 200
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#f3f4f6'
|
||||
ctx.fillRect(0, 0, 200, 200)
|
||||
ctx.font = '80px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(emoji, 100, 100)
|
||||
}
|
||||
img.src = canvas.toDataURL()
|
||||
}
|
||||
|
||||
// 获取关键词数组
|
||||
const getKeywords = (keywords?: string): string[] => {
|
||||
if (!keywords) return []
|
||||
return keywords.split(/[,,]/).map(k => k.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// 根据行业获取表情
|
||||
const getEmoji = (industry?: string): string => {
|
||||
const emojiMap: Record<string, string> = {
|
||||
'电商零售': '🛒',
|
||||
'企业官网': '🏢',
|
||||
'教育培训': '📚',
|
||||
'医疗服务': '🏥',
|
||||
'餐饮美食': '🍜',
|
||||
'旅游出行': '✈️',
|
||||
'金融服务': '💰',
|
||||
'房产家居': '🏠',
|
||||
'社区社交': '👥',
|
||||
'工具服务': '🛠️'
|
||||
}
|
||||
return emojiMap[industry || ''] || '📱'
|
||||
}
|
||||
|
||||
// 获取应用分类数量
|
||||
const getTypeCount = (type: 'all' | 0 | 1): number => {
|
||||
if (type === 'all') return apps.value.length
|
||||
return apps.value.filter(a => a.appType === (type === 1 ? 100 : 10)).length
|
||||
}
|
||||
|
||||
// 价格类型单位
|
||||
function priceTypeUnit(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
free: '永久',
|
||||
one_time: '永久',
|
||||
subscription: '月',
|
||||
}
|
||||
return map[type] || ''
|
||||
}
|
||||
|
||||
// 格式化数字(如 1234 → 1.2k)
|
||||
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()
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 调用 appProduct 市场接口
|
||||
const params: AppProductParam = {
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
status: 1,
|
||||
market: 1,
|
||||
publishStatus: 'published'
|
||||
}
|
||||
const result = await getMarketApps(params)
|
||||
apps.value = result?.list || []
|
||||
pagination.value.total = apps.value.length
|
||||
} catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
message.error('获取应用列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleTypeChange = () => {
|
||||
pagination.value.current = 1
|
||||
// 前端筛选,无需重新请求
|
||||
}
|
||||
|
||||
const handleIndustryChange = () => {
|
||||
pagination.value.current = 1
|
||||
// 前端筛选,无需重新请求
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
pagination.value.current = 1
|
||||
// 前端筛选,无需重新请求
|
||||
}
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
// 前端筛选,无需重新请求
|
||||
}
|
||||
|
||||
const handleKeywordChange = () => {
|
||||
// 使用防抖,延迟 300ms 后自动搜索
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
pagination.value.current = 1
|
||||
// 前端筛选,无需重新请求
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.value.current = page
|
||||
pagination.value.pageSize = pageSize
|
||||
// 前端分页,无需重新请求
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
activeType.value = 'all'
|
||||
selectedIndustry.value = undefined
|
||||
showOfficial.value = false
|
||||
showRecommend.value = false
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
|
||||
const handleAppClick = (app: AppProduct) => {
|
||||
if (app.productId) {
|
||||
navigateTo(`/market/${app.productId}`)
|
||||
} else {
|
||||
selectedApp.value = app
|
||||
detailVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const goToApp = (app: AppProduct) => {
|
||||
// 检查登录状态
|
||||
if (!isLoggedIn.value) {
|
||||
message.warning('请先登录后再体验应用')
|
||||
navigateTo('/login?redirect=' + encodeURIComponent('/market'))
|
||||
return
|
||||
}
|
||||
if (app.adminUrl) {
|
||||
window.open(app.adminUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
// 购买/订阅处理
|
||||
const showBuyModal = ref(false)
|
||||
const buyLoading = ref(false)
|
||||
const buyForm = reactive({
|
||||
appId: undefined as number | undefined,
|
||||
appName: '',
|
||||
priceType: 'free' as 'free' | 'one_time' | 'subscription',
|
||||
price: 0,
|
||||
subscriptionPeriod: 'month' as 'month' | 'year',
|
||||
quantity: 1,
|
||||
})
|
||||
|
||||
const buyTotalPrice = computed(() => {
|
||||
let total = buyForm.price * buyForm.quantity
|
||||
if (buyForm.priceType === 'subscription' && buyForm.subscriptionPeriod === 'year') {
|
||||
total = total * 10 // 年付优惠
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
function handleBuy(app: AppProduct) {
|
||||
// 检查登录状态
|
||||
if (!isLoggedIn.value) {
|
||||
message.warning('请先登录后再购买应用')
|
||||
navigateTo('/login?redirect=' + encodeURIComponent('/market'))
|
||||
return
|
||||
}
|
||||
buyForm.appId = app.productId
|
||||
buyForm.appName = app.productName || ''
|
||||
buyForm.priceType = (app.priceType as any) || 'one_time'
|
||||
buyForm.price = app.price || 0
|
||||
showBuyModal.value = true
|
||||
detailVisible.value = false
|
||||
}
|
||||
|
||||
function handleSubscribe(app: AppProduct) {
|
||||
// 检查登录状态
|
||||
if (!isLoggedIn.value) {
|
||||
message.warning('请先登录后再订阅应用')
|
||||
navigateTo('/login?redirect=' + encodeURIComponent('/market'))
|
||||
return
|
||||
}
|
||||
buyForm.appId = app.productId
|
||||
buyForm.appName = app.productName || ''
|
||||
buyForm.priceType = 'free'
|
||||
buyForm.price = 0
|
||||
showBuyModal.value = true
|
||||
detailVisible.value = false
|
||||
}
|
||||
|
||||
async function confirmBuy() {
|
||||
if (!buyForm.appId) {
|
||||
message.warning('应用信息缺失')
|
||||
return
|
||||
}
|
||||
buyLoading.value = true
|
||||
try {
|
||||
const result = await createSubscription({
|
||||
productId: buyForm.appId,
|
||||
priceType: buyForm.priceType,
|
||||
subscriptionPeriod: buyForm.priceType === 'subscription' ? buyForm.subscriptionPeriod : undefined,
|
||||
quantity: buyForm.quantity,
|
||||
})
|
||||
|
||||
if (result.status === 'active') {
|
||||
// 免费应用,直接激活成功
|
||||
message.success(`${buyForm.appName} 订阅成功!`)
|
||||
showBuyModal.value = false
|
||||
navigateTo('/console/apps?tab=purchased')
|
||||
} else if (result.subscriptionNo) {
|
||||
// 付费应用,需要支付
|
||||
showBuyModal.value = false
|
||||
message.info('正在跳转支付页面...')
|
||||
navigateTo('/console/pay/' + result.subscriptionNo)
|
||||
} else {
|
||||
message.warning(result.message || '订阅创建异常')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '订阅失败,请稍后重试')
|
||||
} finally {
|
||||
buyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 购买弹窗样式 */
|
||||
.buy-modal-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.buy-app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.buy-app-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.buy-app-price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price-free {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.buy-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.buy-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.total-price {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user