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

602 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="apps-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" v-for="stat in stats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: filterStatus === stat.key }]"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ 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="filterStatus" style="width: 130px" @change="handleSearch">
<a-select-option value="">全部状态</a-select-option>
<a-select-option :value="0">未开通</a-select-option>
<a-select-option :value="1">运行中</a-select-option>
<a-select-option :value="2">维护中</a-select-option>
<a-select-option :value="3">已关闭</a-select-option>
</a-select>
<a-select v-model:value="filterType" style="width: 140px" allow-clear placeholder="全部类型" @change="handleSearch">
<a-select-option v-for="(name, key) in APP_TYPE_NAME" :key="key" :value="Number(key)">
{{ name }}
</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索应用名称/标识"
style="width: 210px"
@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 }}</div>
<div class="app-code">{{ record.productCode }}</div>
<div class="app-developer" v-if="record.developer || record.username">
<UserOutlined style="font-size:11px;margin-right:3px" />{{ record.developer || record.username }}
</div>
</div>
</div>
</template>
<!-- 类型 -->
<template v-if="column.key === 'type'">
<a-tag color="blue">{{ APP_TYPE_NAME[record.appType ?? 10] || '未知' }}</a-tag>
<a-tag v-if="record.official === 1" color="gold" style="margin-left:4px">官方</a-tag>
</template>
<!-- 状态 -->
<template v-if="column.key === 'status'">
<a-badge :status="statusBadge(record.status)" :text="statusText(record.status)" />
</template>
<!-- 发布状态 -->
<template v-if="column.key === 'publishStatus'">
<a-tag v-if="record.publishStatus" :color="pubStatusColor(record.publishStatus)">
{{ pubStatusText(record.publishStatus) }}
</a-tag>
<span v-else class="text-gray-400">-</span>
</template>
<!-- 域名 -->
<template v-if="column.key === 'domain'">
<a v-if="record.domain" :href="'https://' + record.domain" target="_blank" class="domain-link">
{{ record.domain }}
</a>
<span v-else class="text-gray-400">-</span>
</template>
<!-- 创建时间 -->
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.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-dropdown :trigger="['click']">
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
<template #overlay>
<a-menu @click="({ key }) => handleMoreAction(key as string, record)">
<a-menu-item key="toggle-status">
{{ record.status === 1 ? '🔒 暂停运行' : '▶️ 恢复运行' }}
</a-menu-item>
<a-menu-item key="toggle-official">
{{ record.official === 1 ? '取消官方' : '设为官方' }}
</a-menu-item>
<a-menu-item key="toggle-market">
{{ record.market === 1 ? '下架市场' : '上架市场' }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" class="danger-item">🗑 删除应用</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</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="开发者">{{ currentApp.developer || '-' }}</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="运行状态">
<a-badge :status="statusBadge(currentApp.status)" :text="statusText(currentApp.status)" />
</a-descriptions-item>
<a-descriptions-item label="发布状态">
<a-tag v-if="currentApp.publishStatus" :color="pubStatusColor(currentApp.publishStatus)">
{{ pubStatusText(currentApp.publishStatus) }}
</a-tag>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="绑定域名" :span="2">
<a v-if="currentApp.domain" :href="'https://' + currentApp.domain" target="_blank">{{ currentApp.domain }}</a>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="ICP备案">{{ currentApp.icpNo || '-' }}</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="到期时间">{{ currentApp.expirationTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ currentApp.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentApp.description" label="应用简介" :span="2">
{{ currentApp.description }}
</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, UserOutlined, DownOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { pageAppProduct, updateAppProduct, removeAppProduct } 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 filterStatus = ref<number | ''>('')
const filterType = ref<number | ''>('')
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const stats = reactive([
{ key: 1, icon: '✅', label: '运行中', value: 0, color: 'green' },
{ key: 2, icon: '🔧', label: '维护中', value: 0, color: 'orange' },
{ key: 3, icon: '⛔', label: '已关闭', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部应用', value: 0, color: 'blue' },
])
const columns = [
{ title: '应用信息', key: 'appInfo', width: 250 },
{ title: '类型', key: 'type', width: 120 },
{ title: '运行状态', key: 'status', width: 110 },
{ title: '发布状态', key: 'publishStatus', width: 110 },
{ title: '绑定域名', key: 'domain', width: 180 },
{ title: '创建时间', key: 'createTime', width: 110 },
{ title: '操作', key: 'action', width: 160 },
]
const showDetailModal = ref(false)
const currentApp = ref<AppProduct | null>(null)
async function loadApps() {
loading.value = true
try {
const res = await pageAppProduct({
current: pagination.current,
size: pagination.pageSize,
status: filterStatus.value !== '' ? (filterStatus.value as number) : undefined,
appType: filterType.value !== '' ? (filterType.value as number) : undefined,
keywords: searchKeyword.value || undefined,
})
apps.value = res?.list || []
pagination.total = res?.count || 0
updateStats()
} catch {
message.error('加载应用列表失败')
} finally {
loading.value = false
}
}
async function updateStats() {
try {
const [runningRes, maintenanceRes, closedRes, allRes] = await Promise.allSettled([
pageAppProduct({ current: 1, size: 1, status: 1 }),
pageAppProduct({ current: 1, size: 1, status: 2 }),
pageAppProduct({ current: 1, size: 1, status: 3 }),
pageAppProduct({ current: 1, size: 1 }),
])
if (runningRes.status === 'fulfilled') stats[0].value = runningRes.value?.count || 0
if (maintenanceRes.status === 'fulfilled') stats[1].value = maintenanceRes.value?.count || 0
if (closedRes.status === 'fulfilled') stats[2].value = closedRes.value?.count || 0
if (allRes.status === 'fulfilled') stats[3].value = allRes.value?.count || 0
} catch { /* ignore */ }
}
function handleStatFilter(key: number | '') {
filterStatus.value = key
pagination.current = 1
loadApps()
}
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 handleMoreAction(key: string, record: AppProduct) {
if (key === 'toggle-status') {
const newStatus = record.status === 1 ? 3 : 1
try {
await updateAppProduct({ productId: record.productId, status: newStatus })
message.success('状态已更新')
loadApps()
} catch (e: any) { message.error(e?.message || '操作失败') }
}
if (key === 'toggle-official') {
try {
await updateAppProduct({ productId: record.productId, official: !record.official })
message.success(record.official ? '已取消官方标记' : '已设为官方应用')
loadApps()
} catch (e: any) { message.error(e?.message || '操作失败') }
}
if (key === 'toggle-market') {
try {
await updateAppProduct({ productId: record.productId, market: !record.market })
message.success(record.market ? '已从市场下架' : '已上架至应用市场')
loadApps()
} catch (e: any) { message.error(e?.message || '操作失败') }
}
if (key === 'delete') {
Modal.confirm({
title: '确认删除',
content: `确定要删除应用「${record.productName}」吗?此操作不可恢复!`,
okType: 'danger',
onOk: async () => {
try {
await removeAppProduct(record.productId)
message.success('应用已删除')
loadApps()
} catch (e: any) { message.error(e?.message || '删除失败') }
},
})
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭', 4: '已欠费', 5: '违规停止' }
return map[status ?? -1] || '未知'
}
function statusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
if (status === 1) return 'success'
if (status === 2) return 'warning'
if (status === 3 || status === 5) return 'error'
return 'default'
}
function pubStatusColor(status?: string) {
const map: Record<string, string> = {
developing: 'default',
pending_review: 'orange',
published: 'success',
rejected: 'error',
deprecated: 'default',
}
return map[status || ''] || 'default'
}
function pubStatusText(status?: string) {
const map: Record<string, string> = {
developing: '开发中',
pending_review: '待审核',
published: '已上架',
rejected: '已拒绝',
deprecated: '已下架',
}
return map[status || ''] || '-'
}
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>
.apps-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;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.red { border-color: #ef4444; }
.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: 44px; height: 44px;
border-radius: 8px; object-fit: cover; flex-shrink: 0;
}
.app-icon-placeholder {
width: 44px; height: 44px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.app-info-text { flex: 1; min-width: 0; }
.app-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.app-code { font-size: 12px; color: rgba(0,0,0,0.45); }
.app-developer { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.domain-link { font-size: 13px; color: #4f46e5; text-decoration: none; }
.domain-link:hover { text-decoration: underline; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.text-gray-400 { color: #9ca3af; }
.danger-item { color: #ff4d4f !important; }
.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>