Files
jczxw-pc/app/pages/market/index.vue
2026-04-23 16:30:57 +08:00

905 lines
30 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>
<!-- 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>