Files
jczxw-pc/app/pages/admin/all-apps.vue
2026-04-23 16:30:57 +08:00

509 lines
18 KiB
Vue
Raw 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="all-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: filterTenantId === 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="filterTenantId"
style="width: 180px"
allow-clear
placeholder="全部租户"
:loading="loadingTenants"
@change="handleSearch"
>
<a-select-option v-for="t in tenantList" :key="t.tenantId" :value="t.tenantId">
{{ t.tenantName }}
</a-select-option>
</a-select>
<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: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="apps"
:loading="loading"
:pagination="pagination"
row-key="productId"
@change="handleTableChange"
size="middle"
:scroll="{ x: 1400 }"
>
<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>
</div>
</template>
<!-- 所属租户 -->
<template v-if="column.key === 'tenantName'">
<a-tag>{{ record.tenantId }}</a-tag>
</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="所属租户">
<a-tag color="purple">{{ getTenantName(currentApp.tenantId) }}</a-tag>
</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>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, DownOutlined } 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_NAME } from '@/api/app/appProduct/model'
import { listTenant } from '@/api/system/tenant/index'
import type { Tenant } from '@/api/system/tenant/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '全局应用管理 - 平台管理' })
const loading = ref(false)
const apps = ref<AppProduct[]>([])
const filterStatus = ref<number | ''>('')
const filterType = ref<number | ''>('')
const filterTenantId = ref<number | ''>('')
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
// 租户列表
const tenantList = ref<Tenant[]>([])
const loadingTenants = ref(false)
async function loadTenantList() {
loadingTenants.value = true
try {
const res = await listTenant()
tenantList.value = res || []
} catch {
// 静默失败
} finally {
loadingTenants.value = false
}
}
function getTenantName(tenantId?: number) {
if (!tenantId) return '-'
const tenant = tenantList.value.find(t => t.tenantId === tenantId)
return tenant?.tenantName || `租户${tenantId}`
}
// 统计数据
const stats = computed(() => {
const total = apps.value.length
const running = apps.value.filter(a => a.status === 1).length
const maintenance = apps.value.filter(a => a.status === 2).length
const closed = apps.value.filter(a => a.status === 3).length
return [
{ key: '', icon: '📦', label: '全部应用', value: pagination.total, color: 'blue' },
{ key: 1, icon: '✅', label: '运行中', value: running, color: 'green' },
{ key: 2, icon: '🔧', label: '维护中', value: maintenance, color: 'orange' },
{ key: 3, icon: '⛔', label: '已关闭', value: closed, color: 'red' },
]
})
const columns = [
{ title: '应用信息', key: 'appInfo', width: 250, fixed: 'left' },
{ title: '所属租户', key: 'tenantName', width: 150 },
{ title: '类型', key: 'type', width: 140 },
{ 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, fixed: 'right' },
]
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,
tenantId: filterTenantId.value !== '' ? (filterTenantId.value as number) : undefined,
keywords: searchKeyword.value || undefined,
})
apps.value = res?.list || []
pagination.total = res?.count || 0
} catch {
message.error('加载应用列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number | '') {
filterTenantId.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 ? 0 : 1 })
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 ? 0 : 1 })
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]
}
onMounted(() => {
loadTenantList()
loadApps()
})
</script>
<style scoped>
.all-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); }
.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; }
</style>