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

526 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="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>