526 lines
18 KiB
Vue
526 lines
18 KiB
Vue
<template>
|
||
<div class="market-page">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<div>
|
||
<h2 class="page-title">🛒 应用市场管理</h2>
|
||
<p class="page-desc">管理上架至应用市场的应用,调整推荐权重和展示顺序</p>
|
||
</div>
|
||
<a-space>
|
||
<a-button @click="loadApps" :loading="loading">
|
||
<template #icon><ReloadOutlined /></template>
|
||
刷新
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<!-- 汇总统计 -->
|
||
<a-row :gutter="[16, 16]" class="mb-6">
|
||
<a-col :xs="12" :md="6">
|
||
<div class="stat-card blue">
|
||
<div class="stat-icon">🛒</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ totalMarket }}</div>
|
||
<div class="stat-label">市场总应用数</div>
|
||
</div>
|
||
</div>
|
||
</a-col>
|
||
<a-col :xs="12" :md="6">
|
||
<div class="stat-card gold">
|
||
<div class="stat-icon">⭐</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ totalRecommend }}</div>
|
||
<div class="stat-label">推荐应用数</div>
|
||
</div>
|
||
</div>
|
||
</a-col>
|
||
<a-col :xs="12" :md="6">
|
||
<div class="stat-card green">
|
||
<div class="stat-icon">🏅</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ totalOfficial }}</div>
|
||
<div class="stat-label">官方应用数</div>
|
||
</div>
|
||
</div>
|
||
</a-col>
|
||
<a-col :xs="12" :md="6">
|
||
<div class="stat-card purple">
|
||
<div class="stat-icon">🔌</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ totalPlugin }}</div>
|
||
<div class="stat-label">插件数</div>
|
||
</div>
|
||
</div>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<!-- 市场应用列表 -->
|
||
<div class="panel">
|
||
<div class="panel-header">
|
||
<span class="panel-title">🛒 市场应用列表</span>
|
||
<a-space wrap>
|
||
<a-select v-model:value="filterPublish" style="width: 130px" @change="handleSearch">
|
||
<a-select-option value="">全部状态</a-select-option>
|
||
<a-select-option value="published">已上架</a-select-option>
|
||
<a-select-option value="deprecated">已下架</a-select-option>
|
||
</a-select>
|
||
<a-select v-model:value="filterOfficial" style="width: 120px" @change="handleSearch">
|
||
<a-select-option value="">全部类型</a-select-option>
|
||
<a-select-option value="official">官方</a-select-option>
|
||
<a-select-option value="third">第三方</a-select-option>
|
||
</a-select>
|
||
<a-input-search
|
||
v-model:value="searchKeyword"
|
||
placeholder="搜索应用"
|
||
style="width: 200px"
|
||
@search="handleSearch"
|
||
/>
|
||
</a-space>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="columns"
|
||
:data-source="apps"
|
||
:loading="loading"
|
||
:pagination="pagination"
|
||
row-key="productId"
|
||
@change="handleTableChange"
|
||
size="middle"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<!-- 应用信息 -->
|
||
<template v-if="column.key === 'appInfo'">
|
||
<div class="app-info-cell">
|
||
<img v-if="record.icon" :src="record.icon" class="app-icon" />
|
||
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(record.productName) }">
|
||
{{ (record.productName || 'A').charAt(0).toUpperCase() }}
|
||
</div>
|
||
<div class="app-info-text">
|
||
<div class="app-name">
|
||
{{ record.productName }}
|
||
<a-tag v-if="record.official" color="gold" style="margin-left:6px;font-size:11px">官方</a-tag>
|
||
<a-tag color="blue" style="margin-left:4px;font-size:11px">{{ APP_TYPE_NAME[record.appType ?? 10] || '网站' }}</a-tag>
|
||
</div>
|
||
<div class="app-desc">{{ record.description || '-' }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 定价 -->
|
||
<template v-if="column.key === 'price'">
|
||
<span v-if="record.priceType === 'free' || !record.priceType" class="price-free">免费</span>
|
||
<span v-else class="price-paid">¥{{ ((record.price || 0) / 100).toFixed(2) }}
|
||
<span class="price-period">{{ subscriptionText(record.subscriptionPeriod) }}</span>
|
||
</span>
|
||
</template>
|
||
|
||
<!-- 安装/评分 -->
|
||
<template v-if="column.key === 'metrics'">
|
||
<div style="font-size:13px">
|
||
<div>📥 {{ record.installs ?? 0 }} 次安装</div>
|
||
<div style="color:#f59e0b">{{ record.rating ? '⭐ ' + record.rating : '暂无评分' }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 推荐 -->
|
||
<template v-if="column.key === 'recommend'">
|
||
<a-switch
|
||
:checked="!!record.recommend"
|
||
size="small"
|
||
@change="(val: boolean) => handleToggleRecommend(record, val)"
|
||
/>
|
||
</template>
|
||
|
||
<!-- 发布状态 -->
|
||
<template v-if="column.key === 'publishStatus'">
|
||
<a-tag :color="pubStatusColor(record.publishStatus)">{{ pubStatusText(record.publishStatus) }}</a-tag>
|
||
</template>
|
||
|
||
<!-- 上架时间 -->
|
||
<template v-if="column.key === 'publishTime'">
|
||
<span class="text-sm text-gray">{{ record.publishTime?.substring(0, 10) || '-' }}</span>
|
||
</template>
|
||
|
||
<!-- 操作 -->
|
||
<template v-if="column.key === 'action'">
|
||
<a-space>
|
||
<a-button type="link" size="small" @click="handleView(record)">详情</a-button>
|
||
<a-popconfirm
|
||
v-if="record.publishStatus === 'published'"
|
||
title="确认从应用市场下架此应用?"
|
||
@confirm="handleUnpublish(record)"
|
||
>
|
||
<a-button type="link" size="small" danger>下架</a-button>
|
||
</a-popconfirm>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
|
||
<!-- 详情弹窗 -->
|
||
<a-modal
|
||
v-model:open="showDetailModal"
|
||
:title="`市场详情:${currentApp?.productName || ''}`"
|
||
width="720px"
|
||
:footer="null"
|
||
>
|
||
<template v-if="currentApp">
|
||
<a-descriptions :column="2" bordered size="small">
|
||
<a-descriptions-item label="应用名称">{{ currentApp.productName }}</a-descriptions-item>
|
||
<a-descriptions-item label="应用标识">{{ currentApp.productCode }}</a-descriptions-item>
|
||
<a-descriptions-item label="应用类型">
|
||
<a-tag color="blue">{{ APP_TYPE_NAME[currentApp.appType ?? 10] || '未知' }}</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="开发者">{{ currentApp.developer || currentApp.username || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="安装次数">{{ currentApp.installs ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="评分">{{ currentApp.rating ? currentApp.rating + ' ⭐' : '暂无' }}</a-descriptions-item>
|
||
<a-descriptions-item label="定价">
|
||
<span v-if="currentApp.priceType === 'free' || !currentApp.priceType" class="price-free">免费</span>
|
||
<span v-else class="price-paid">¥{{ ((currentApp.price || 0) / 100).toFixed(2) }}</span>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="上架时间">{{ currentApp.publishTime || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="应用简介" :span="2">{{ currentApp.description || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="详细说明" :span="2">
|
||
<div class="detail-desc">{{ currentApp.content || '-' }}</div>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<!-- 应用入口信息 -->
|
||
<div v-if="currentAppEntries.length > 0" class="entry-section">
|
||
<h4 class="entry-title">🚀 应用入口</h4>
|
||
<div class="entry-list">
|
||
<div
|
||
v-for="entry in currentAppEntries"
|
||
:key="entry.type"
|
||
class="entry-item"
|
||
:class="{ disabled: !entry.available }"
|
||
>
|
||
<div class="entry-info">
|
||
<component :is="entryIcon(entry.icon)" style="font-size: 18px; margin-right: 8px;" />
|
||
<div>
|
||
<div class="entry-label">{{ entry.label }}</div>
|
||
<div class="entry-url" v-if="entry.url">{{ entry.type === 'scan-qr' ? '已配置小程序码' : entry.url }}</div>
|
||
<div class="entry-url text-gray-400" v-else>未配置</div>
|
||
</div>
|
||
</div>
|
||
<a-button
|
||
v-if="entry.available"
|
||
type="primary"
|
||
size="small"
|
||
ghost
|
||
@click="handleEntryClick(entry)"
|
||
>
|
||
{{ entry.type === 'visit-site' ? '访问' : entry.type === 'download' ? '下载' : entry.type === 'scan-qr' ? '扫码' : '进入' }}
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</a-modal>
|
||
|
||
<!-- 扫码弹窗 -->
|
||
<QrCodeModal
|
||
v-model:open="showQrModal"
|
||
:title="qrModalTitle"
|
||
:tip="qrModalTip"
|
||
:image-url="qrModalUrl"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ReloadOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { pageAppProductAll, updateAppProduct, unpublishAppProduct } from '@/api/app/appProduct'
|
||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||
import { APP_TYPE, APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
|
||
import type { AppEntry } from '@/utils/appEntry'
|
||
import QrCodeModal from '@/components/QrCodeModal.vue'
|
||
|
||
definePageMeta({ layout: 'admin' })
|
||
useHead({ title: '应用市场管理 - 平台管理' })
|
||
|
||
const loading = ref(false)
|
||
const apps = ref<AppProduct[]>([])
|
||
const filterPublish = ref('')
|
||
const filterOfficial = ref('')
|
||
const searchKeyword = ref('')
|
||
|
||
const totalMarket = ref(0)
|
||
const totalRecommend = ref(0)
|
||
const totalOfficial = ref(0)
|
||
const totalPlugin = ref(0)
|
||
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
})
|
||
|
||
const columns = [
|
||
{ title: '应用信息', key: 'appInfo', width: 280 },
|
||
{ title: '定价', key: 'price', width: 130 },
|
||
{ title: '安装/评分', key: 'metrics', width: 130 },
|
||
{ title: '推荐', key: 'recommend', width: 80 },
|
||
{ title: '发布状态', key: 'publishStatus', width: 100 },
|
||
{ title: '上架时间', key: 'publishTime', width: 110 },
|
||
{ title: '操作', key: 'action', width: 130 },
|
||
]
|
||
|
||
const showDetailModal = ref(false)
|
||
const currentApp = ref<AppProduct | null>(null)
|
||
|
||
async function loadApps() {
|
||
loading.value = true
|
||
try {
|
||
const params: any = {
|
||
current: pagination.current,
|
||
size: pagination.pageSize,
|
||
market: true,
|
||
keywords: searchKeyword.value || undefined,
|
||
}
|
||
if (filterPublish.value) params.publishStatus = filterPublish.value
|
||
if (filterOfficial.value === 'official') params.official = true
|
||
if (filterOfficial.value === 'third') params.official = false
|
||
|
||
const res = await pageAppProductAll(params)
|
||
apps.value = res?.list || []
|
||
pagination.total = res?.count || 0
|
||
loadSummary()
|
||
} catch {
|
||
message.error('加载市场列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadSummary() {
|
||
try {
|
||
const [allRes, offRes, plugRes] = await Promise.allSettled([
|
||
pageAppProductAll({ current: 1, size: 1, market: true }),
|
||
pageAppProductAll({ current: 1, size: 1, market: true, official: true }),
|
||
pageAppProductAll({ current: 1, size: 1, market: true, plugin: true }),
|
||
])
|
||
if (allRes.status === 'fulfilled') totalMarket.value = allRes.value?.count || 0
|
||
if (offRes.status === 'fulfilled') totalOfficial.value = offRes.value?.count || 0
|
||
if (plugRes.status === 'fulfilled') totalPlugin.value = plugRes.value?.count || 0
|
||
// 推荐数从当前列表里统计(recommend字段为 1 的数量)
|
||
totalRecommend.value = apps.value.filter(a => !!a.recommend).length
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.current = 1
|
||
loadApps()
|
||
}
|
||
|
||
function handleTableChange(pag: any) {
|
||
pagination.current = pag.current
|
||
pagination.pageSize = pag.pageSize
|
||
loadApps()
|
||
}
|
||
|
||
function handleView(record: AppProduct) {
|
||
currentApp.value = record
|
||
showDetailModal.value = true
|
||
}
|
||
|
||
async function handleToggleRecommend(record: AppProduct, val: boolean) {
|
||
try {
|
||
await updateAppProduct({ productId: record.productId, recommend: val ? 1 : 0 })
|
||
message.success(val ? '已加入推荐' : '已取消推荐')
|
||
loadApps()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
async function handleUnpublish(record: AppProduct) {
|
||
try {
|
||
await unpublishAppProduct(record.productId!)
|
||
message.success(`「${record.productName}」已从市场下架`)
|
||
loadApps()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
function pubStatusColor(status?: string) {
|
||
const map: Record<string, string> = {
|
||
pending_review: 'orange', published: 'success', rejected: 'error', deprecated: 'default',
|
||
}
|
||
return map[status || ''] || 'default'
|
||
}
|
||
|
||
function pubStatusText(status?: string) {
|
||
const map: Record<string, string> = {
|
||
pending_review: '待审核', published: '已上架', rejected: '已拒绝', deprecated: '已下架',
|
||
}
|
||
return map[status || ''] || '-'
|
||
}
|
||
|
||
function subscriptionText(period?: string) {
|
||
if (period === 'month') return '/月'
|
||
if (period === 'year') return '/年'
|
||
return ''
|
||
}
|
||
|
||
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#457b9d']
|
||
function iconBgColor(name?: string) {
|
||
if (!name) return PALETTE[0]
|
||
let h = 0
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
|
||
return PALETTE[Math.abs(h) % PALETTE.length]
|
||
}
|
||
|
||
const showQrModal = ref(false)
|
||
const qrModalTitle = ref('')
|
||
const qrModalTip = ref('')
|
||
const qrModalUrl = ref('')
|
||
|
||
const currentAppEntries = computed(() => {
|
||
if (!currentApp.value) return []
|
||
return getAppEntries(currentApp.value)
|
||
})
|
||
|
||
function entryIcon(iconName: string) {
|
||
const map: Record<string, any> = {
|
||
GlobalOutlined,
|
||
QrcodeOutlined,
|
||
DownloadOutlined,
|
||
SettingOutlined,
|
||
}
|
||
return map[iconName] || GlobalOutlined
|
||
}
|
||
|
||
function handleEntryClick(entry: AppEntry) {
|
||
const needQr = executeEntry(entry)
|
||
if (needQr && entry.type === 'scan-qr' && currentApp.value) {
|
||
qrModalTitle.value = currentApp.value.productName || '小程序码'
|
||
qrModalTip.value = getScanTip(currentApp.value.appType || 20)
|
||
qrModalUrl.value = entry.url || ''
|
||
showQrModal.value = true
|
||
}
|
||
}
|
||
|
||
onMounted(() => loadApps())
|
||
</script>
|
||
|
||
<style scoped>
|
||
.market-page { min-height: 100%; }
|
||
|
||
.page-header {
|
||
display: flex; align-items: center;
|
||
justify-content: space-between; margin-bottom: 20px;
|
||
}
|
||
|
||
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
|
||
|
||
.stat-card {
|
||
display: flex; align-items: center;
|
||
gap: 12px; padding: 16px;
|
||
border-radius: 12px; border: 2px solid transparent;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||
.stat-card.gold { background: #fffbeb; border-color: #fde68a; }
|
||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||
.stat-card.purple { background: #faf5ff; border-color: #e9d5ff; }
|
||
.stat-icon { font-size: 28px; flex-shrink: 0; }
|
||
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
|
||
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
|
||
|
||
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
|
||
.panel-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 18px; border-bottom: 1px solid #f5f5f5; flex-wrap: wrap; gap: 10px;
|
||
}
|
||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||
|
||
.app-info-cell { display: flex; align-items: center; gap: 12px; }
|
||
.app-icon { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; flex-shrink: 0; }
|
||
.app-icon-placeholder {
|
||
width: 48px; height: 48px; border-radius: 10px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 20px; font-weight: 600; color: #fff; flex-shrink: 0;
|
||
}
|
||
.app-info-text { flex: 1; min-width: 0; }
|
||
.app-name { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); display: flex; align-items: center; }
|
||
.app-desc { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||
|
||
.price-free { color: #22c55e; font-weight: 500; font-size: 13px; }
|
||
.price-paid { color: #f59e0b; font-weight: 600; font-size: 14px; }
|
||
.price-period { font-size: 11px; color: rgba(0,0,0,0.45); font-weight: 400; margin-left: 2px; }
|
||
|
||
.detail-desc { max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
|
||
.text-sm { font-size: 12px; }
|
||
.text-gray { color: rgba(0,0,0,0.45); }
|
||
.text-gray-400 { color: #9ca3af; }
|
||
.mb-6 { margin-bottom: 24px; }
|
||
|
||
.entry-section {
|
||
margin-top: 20px;
|
||
padding: 16px;
|
||
background: #fafafa;
|
||
border-radius: 8px;
|
||
border: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.entry-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: rgba(0,0,0,0.85);
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
.entry-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.entry-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 14px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
border: 1px solid #f0f0f0;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.entry-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||
.entry-item.disabled { opacity: 0.5; }
|
||
|
||
.entry-info {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.entry-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: rgba(0,0,0,0.85);
|
||
}
|
||
|
||
.entry-url {
|
||
font-size: 12px;
|
||
color: rgba(0,0,0,0.45);
|
||
margin-top: 2px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 400px;
|
||
}
|
||
</style>
|