初始版本

This commit is contained in:
2026-04-23 17:14:29 +08:00
parent 0d0683a6e6
commit 6dca87b988
204 changed files with 3894 additions and 52759 deletions

View File

@@ -1,508 +0,0 @@
<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>

View File

@@ -1,763 +0,0 @@
<template>
<div class="review-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 reviewStats" :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>
<a-select
v-model:value="filterStatus"
style="width: 140px"
@change="loadApps"
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending_review">待审核</a-select-option>
<a-select-option value="published">已上架</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
<a-select-option value="deprecated">已下架</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索应用名称"
style="width: 200px"
@search="loadApps"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="apps"
:loading="loading"
:pagination="pagination"
row-key="productId"
@change="handleTableChange"
>
<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">
<UserOutlined style="font-size:11px;margin-right:3px" />{{ record.developer }}
</div>
</div>
</div>
</template>
<!-- 应用类型 -->
<template v-if="column.key === 'appType'">
<a-tag color="blue">{{ APP_TYPE_NAME[record.appType ?? 10] || '未知' }}</a-tag>
</template>
<!-- 发布状态 -->
<template v-if="column.key === 'publishStatus'">
<a-tag :color="statusColor(record.publishStatus)">
{{ statusText(record.publishStatus) }}
</a-tag>
</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 === 'applyTime'">
<span class="text-sm text-gray-500">{{ record.publishTime || record.updateTime || '-' }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">详情</a-button>
<!-- 待审核通过/拒绝 -->
<template v-if="record.publishStatus === 'pending_review'">
<a-popconfirm title="确认通过此应用上架申请?" @confirm="handleApprove(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
</template>
<!-- 已上架下架 -->
<a-popconfirm
v-if="record.publishStatus === 'published'"
title="确认下架此应用?"
@confirm="handleAdminUnpublish(record)"
>
<a-button danger size="small">下架</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 审核详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:title="`应用详情:${currentApp?.productName || ''}`"
width="700px"
: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 || '-' }}</a-descriptions-item>
<a-descriptions-item label="发布状态">
<a-tag :color="statusColor(currentApp.publishStatus)">{{ statusText(currentApp.publishStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="定价模式">{{ priceTypeText(currentApp.priceType) }}</a-descriptions-item>
<a-descriptions-item label="价格">
<span v-if="currentApp.priceType === 'free' || !currentApp.priceType">免费</span>
<span v-else>¥{{ ((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-item v-if="currentApp.rejectReason" label="拒绝原因" :span="2">
<a-alert type="error" :message="currentApp.rejectReason" show-icon />
</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>
<!-- 待审核时的操作区 -->
<div v-if="currentApp.publishStatus === 'pending_review'" class="detail-actions">
<a-popconfirm title="确认通过此应用上架申请?" @confirm="handleApprove(currentApp)">
<a-button type="primary" :loading="approving"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleReject(currentApp)"> 拒绝上架</a-button>
</div>
</template>
</a-modal>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="showRejectModal"
title="填写拒绝原因"
:confirm-loading="rejecting"
@ok="confirmReject"
@cancel="showRejectModal = false"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea
v-model:value="rejectReasonInput"
:rows="4"
placeholder="请填写具体的拒绝原因,以便开发者修改后重新提交"
:maxlength="500"
show-count
/>
</a-form-item>
<div class="reject-tips">
<p>💡 常见拒绝原因</p>
<a-space wrap>
<a-tag
v-for="tip in rejectTips"
:key="tip"
class="reject-tip-tag"
@click="rejectReasonInput = tip"
>{{ tip }}</a-tag>
</a-space>
</div>
</a-form>
</a-modal>
<!-- 扫码弹窗 -->
<QrCodeModal
v-model:open="showQrModal"
:title="qrModalTitle"
:tip="qrModalTip"
:image-url="qrModalUrl"
/>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pagePublishReviews,
approvePublishReview,
rejectPublishReview,
unpublishAppProduct,
} from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { 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('pending_review')
const searchKeyword = ref('')
// 分页
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
// 统计
const reviewStats = reactive([
{ key: 'pending_review', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'published', icon: '✅', label: '已上架', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部应用', value: 0, color: 'blue' },
])
// 表格列
const columns = [
{ title: '应用信息', key: 'appInfo', width: 260 },
{ title: '应用类型', key: 'appType', width: 120 },
{ title: '审核状态', key: 'publishStatus', width: 110 },
{ title: '定价', key: 'price', width: 130 },
{ title: '提交时间', key: 'applyTime', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showDetailModal = ref(false)
const currentApp = ref<AppProduct | null>(null)
const approving = ref(false)
// 拒绝弹窗
const showRejectModal = ref(false)
const rejectReasonInput = ref('')
const rejecting = ref(false)
const rejectTargetApp = ref<AppProduct | null>(null)
const rejectTips = [
'功能描述不完整,缺少使用说明文档',
'应用简介过于简单,请补充详细功能介绍',
'应用名称与实际功能不符',
'价格设置不合理,请重新评估',
'存在违规内容,请修改后重新提交',
'截图不清晰或与功能描述不符',
]
// 加载审核列表
async function loadApps() {
loading.value = true
try {
const res = await pagePublishReviews({
page: pagination.current,
limit: pagination.pageSize,
publishStatus: filterStatus.value || 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 [pendingRes, publishedRes, rejectedRes, allRes] = await Promise.allSettled([
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'pending_review' }),
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'published' }),
pagePublishReviews({ page: 1, limit: 1, publishStatus: 'rejected' }),
pagePublishReviews({ page: 1, limit: 1 }),
])
if (pendingRes.status === 'fulfilled') reviewStats[0].value = pendingRes.value?.count || 0
if (publishedRes.status === 'fulfilled') reviewStats[1].value = publishedRes.value?.count || 0
if (rejectedRes.status === 'fulfilled') reviewStats[2].value = rejectedRes.value?.count || 0
if (allRes.status === 'fulfilled') reviewStats[3].value = allRes.value?.count || 0
} catch { /* ignore */ }
}
// 统计卡片点击筛选
function handleStatFilter(key: string) {
filterStatus.value = key
pagination.current = 1
loadApps()
}
// 分页变化
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadApps()
}
// 查看详情
function handleViewDetail(record: AppProduct) {
currentApp.value = record
showDetailModal.value = true
}
// 审核通过
async function handleApprove(record: AppProduct) {
approving.value = true
try {
await approvePublishReview(record.productId!)
message.success(`${record.productName}」已通过审核并上架`)
showDetailModal.value = false
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
approving.value = false
}
}
// 打开拒绝弹窗
function handleReject(record: AppProduct) {
rejectTargetApp.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// 确认拒绝
async function confirmReject() {
if (!rejectReasonInput.value.trim()) {
message.warning('请填写拒绝原因')
return
}
if (!rejectTargetApp.value) return
rejecting.value = true
try {
await rejectPublishReview({
productId: rejectTargetApp.value.productId!,
rejectReason: rejectReasonInput.value,
})
message.success('已拒绝并通知开发者')
showRejectModal.value = false
showDetailModal.value = false
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
rejecting.value = false
}
}
// 管理员下架
async function handleAdminUnpublish(record: AppProduct) {
try {
await unpublishAppProduct(record.productId!)
message.success(`${record.productName}」已下架`)
loadApps()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
// 状态相关
function statusText(status?: string) {
const map: Record<string, string> = {
developing: '开发中',
pending_review: '待审核',
published: '已上架',
rejected: '已拒绝',
deprecated: '已下架',
}
return map[status || ''] || '开发中'
}
function statusColor(status?: string) {
const map: Record<string, string> = {
developing: 'default',
pending_review: 'orange',
published: 'success',
rejected: 'error',
deprecated: 'default',
}
return map[status || ''] || 'default'
}
function priceTypeText(type?: string) {
const map: Record<string, string> = {
free: '免费',
one_time: '一次性付费',
subscription: '订阅制',
}
return map[type || ''] || '免费'
}
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>
.review-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;
line-height: 1.4;
}
.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 { border-color: currentColor; 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;
}
.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;
}
/* 价格 */
.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-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.detail-desc {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* 拒绝原因提示 */
.reject-tips {
margin-top: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.reject-tips p {
font-size: 12px;
color: rgba(0,0,0,0.45);
margin: 0 0 8px;
}
.reject-tip-tag {
cursor: pointer;
transition: all 0.15s;
}
.reject-tip-tag:hover {
color: #4f46e5;
border-color: #4f46e5;
}
.mb-6 { margin-bottom: 24px; }
.text-sm { font-size: 12px; }
.text-gray-500 { color: rgba(0,0,0,0.45); }
.text-gray-400 { color: #9ca3af; }
.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>

View File

@@ -1,656 +0,0 @@
<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-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-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 === 'tenantName'">
<span class="tenant-name-cell" v-if="record.tenantId">
{{ getTenantName(record.tenantId) }}
</span>
<span v-else class="text-gray-400">-</span>
</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'
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 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 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: 'tenantName', width: 150 },
{ 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,
tenantId: filterTenantId.value !== '' ? (filterTenantId.value as number) : 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(() => {
loadTenantList()
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; }
.tenant-name-cell {
font-size: 13px;
color: rgba(0,0,0,0.75);
}
.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>

View File

@@ -1,415 +0,0 @@
<template>
<div class="developers-page">
<div class="page-header">
<div>
<h2 class="page-title">🧑💻 开发者管理</h2>
<p class="page-desc">管理平台上有应用发布记录的开发者账号</p>
</div>
<a-space>
<a-button @click="loadData" :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.label">
<div class="stat-card" :class="stat.color">
<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="filter-bar">
<a-radio-group v-model:value="filterType" button-style="solid" @change="handleFilterChange">
<a-radio-button :value="2">开发者用户</a-radio-button>
<a-radio-button :value="null">全部用户</a-radio-button>
</a-radio-group>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索用户名/昵称/手机号"
style="width: 240px"
allow-clear
@search="handleSearch"
/>
</div>
<!-- 开发者列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">🧑💻 用户列表</span>
<a-tag color="blue"> {{ pagination.total }} </a-tag>
</div>
<a-table
:columns="columns"
:data-source="developers"
:loading="loading"
:pagination="pagination"
row-key="userId"
@change="handleTableChange"
size="middle"
>
<template #bodyCell="{ column, record }">
<!-- 开发者信息 -->
<template v-if="column.key === 'devInfo'">
<div class="dev-info-cell">
<a-avatar :size="38" :src="record.avatar || record.avatarUrl">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="dev-info-text">
<div class="dev-name">{{ record.nickname || record.username || '-' }}</div>
<div class="dev-sub" v-if="record.username">@{{ record.username }}</div>
<div class="dev-sub" v-if="record.phone || record.mobile">
📱 {{ record.phone || record.mobile }}
</div>
</div>
</div>
</template>
<!-- 用户类型 -->
<template v-if="column.key === 'userType'">
<a-tag v-if="record.type === 2" color="purple">开发者</a-tag>
<a-tag v-else-if="record.type === 1" color="blue">企业用户</a-tag>
<a-tag v-else color="default">普通用户</a-tag>
</template>
<!-- 应用数量 -->
<template v-if="column.key === 'appCount'">
<a-space>
<a-tag color="blue">{{ appCountMap[record.userId!] ?? 0 }} 个应用</a-tag>
<a-tag color="success" v-if="publishedCountMap[record.userId!]">
{{ publishedCountMap[record.userId!] }} 已上架
</a-tag>
</a-space>
</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 === 'status'">
<a-tag :color="record.status === 0 ? 'success' : 'error'">
{{ record.status === 0 ? '正常' : '已冻结' }}
</a-tag>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDev(record)">查看应用</a-button>
<a-popconfirm
v-if="record.type !== 2"
title="确认将该用户设为开发者?"
ok-text="确认"
cancel-text="取消"
@confirm="handleSetDeveloper(record, 2)"
>
<a-button type="link" size="small">设为开发者</a-button>
</a-popconfirm>
<a-popconfirm
v-else
title="确认取消该用户的开发者资质?"
ok-text="确认"
cancel-text="取消"
@confirm="handleSetDeveloper(record, 0)"
>
<a-button type="link" size="small" danger>取消资质</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 开发者应用列表弹窗 -->
<a-modal
v-model:open="showAppsModal"
:title="`${currentDev?.nickname || currentDev?.username || '开发者'} 的应用`"
width="780px"
:footer="null"
>
<div v-if="loadingApps" class="modal-spin">
<a-spin />
</div>
<template v-else>
<a-empty v-if="devApps.length === 0" description="该开发者暂无应用" />
<div v-else class="dev-apps-grid">
<div v-for="app in devApps" :key="app.productId" class="dev-app-card">
<div class="dev-app-header">
<img v-if="app.icon" :src="app.icon" class="dev-app-icon" />
<div v-else class="dev-app-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
</div>
<div class="dev-app-info">
<div class="dev-app-name">
{{ app.productName }}
<a-tag color="blue" style="margin-left:6px;font-size:11px">{{ APP_TYPE_NAME[app.appType ?? 10] || '网站' }}</a-tag>
</div>
<div class="dev-app-code">{{ app.productCode }}</div>
</div>
<a-tag :color="pubStatusColor(app.publishStatus)" style="margin-left:auto">
{{ pubStatusText(app.publishStatus) }}
</a-tag>
</div>
<div class="dev-app-desc">{{ app.description || '暂无简介' }}</div>
</div>
</div>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { getUserAppStats, pageAppProductAll } from '@/api/app/appProduct'
import { pageUsers, updateUser } from '@/api/system/user/index'
import type { AppProduct } from '@/api/app/appProduct/model'
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '开发者管理 - 平台管理' })
const loading = ref(false)
const loadingApps = ref(false)
const developers = ref<User[]>([])
const searchKeyword = ref('')
const filterType = ref<number | null>(2) // 默认只看开发者
// 应用数量映射 userId -> count
const appCountMap = ref<Record<number, number>>({})
const publishedCountMap = ref<Record<number, number>>({})
const showAppsModal = ref(false)
const currentDev = ref<User | null>(null)
const devApps = ref<AppProduct[]>([])
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const stats = reactive([
{ icon: '🧑‍💻', label: '开发者总数', value: 0, color: 'blue' },
{ icon: '📦', label: '应用总数', value: 0, color: 'green' },
{ icon: '✅', label: '已上架应用', value: 0, color: 'orange' },
{ icon: '⏳', label: '待审核', value: 0, color: 'red' },
])
const columns = [
{ title: '用户', key: 'devInfo', width: 240 },
{ title: '类型', key: 'userType', width: 100 },
{ title: '应用数量', key: 'appCount', width: 180 },
{ title: '注册时间', key: 'createTime', width: 120 },
{ title: '状态', key: 'status', width: 90 },
{ title: '操作', key: 'action', width: 160 },
]
async function loadData() {
loading.value = true
try {
const res = await pageUsers({
page: pagination.current,
limit: pagination.pageSize,
keywords: searchKeyword.value || undefined,
type: filterType.value ?? undefined,
})
developers.value = res?.list || []
pagination.total = res?.count || 0
stats[0].value = pagination.total
// 加载完用户后,单次请求批量加载应用数量
loadAppCounts()
} catch {
message.error('加载用户列表失败')
} finally {
loading.value = false
}
}
async function loadAppCounts() {
if (!developers.value.length) return
try {
// 单次 POST 请求,一条 SQL 批量统计所有用户的应用数
const userIds = developers.value.map(u => u.userId!).filter(Boolean)
const rows = await getUserAppStats(userIds)
const countMap: Record<number, number> = {}
const pubMap: Record<number, number> = {}
let totalApps = 0
let totalPublished = 0
for (const row of rows) {
const uid = Number(row.userId)
const total = Number(row.totalCount) || 0
const pub = Number(row.publishedCount) || 0
countMap[uid] = total
if (pub > 0) pubMap[uid] = pub
totalApps += total
totalPublished += pub
}
appCountMap.value = countMap
publishedCountMap.value = pubMap
stats[1].value = totalApps
stats[2].value = totalPublished
} catch { /* ignore */ }
// 异步加载全局统计(待审核数)
loadPendingCount()
}
async function loadPendingCount() {
try {
const res = await pageAppProductAll({ current: 1, size: 1, publishStatus: 'pending_review' })
stats[3].value = res?.count ?? 0
} catch { /* ignore */ }
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleFilterChange() {
pagination.current = 1
loadData()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
async function handleSetDeveloper(record: User, type: number) {
try {
await updateUser({ userId: record.userId, type })
record.type = type
message.success(type === 2 ? '已设为开发者用户' : '已取消开发者资质')
// 如果当前只展示开发者,取消后刷新列表
if (filterType.value === 2 && type !== 2) loadData()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleViewDev(record: User) {
currentDev.value = record
showAppsModal.value = true
loadingApps.value = true
try {
const res = await pageAppProductAll({
current: 1,
size: 100,
userId: record.userId,
})
devApps.value = res?.list || []
} catch {
message.error('加载应用列表失败')
} finally {
loadingApps.value = false
}
}
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(() => loadData())
</script>
<style scoped>
.developers-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.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.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); }
.filter-bar {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
}
.dev-info-cell { display: flex; align-items: center; gap: 10px; }
.dev-info-text { flex: 1; min-width: 0; }
.dev-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.dev-sub { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 1px; }
/* 应用弹窗卡片 */
.dev-apps-grid { display: flex; flex-direction: column; gap: 12px; max-height: 520px; overflow-y: auto; padding-right: 4px; }
.dev-app-card {
border: 1px solid #f0f0f0; border-radius: 10px; padding: 14px;
transition: all 0.15s;
}
.dev-app-card:hover { border-color: #d0d0ff; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.dev-app-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.dev-app-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; }
.dev-app-icon-placeholder {
width: 40px; height: 40px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.dev-app-info { flex: 1; }
.dev-app-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.dev-app-code { font-size: 12px; color: rgba(0,0,0,0.45); }
.dev-app-desc { font-size: 12px; color: rgba(0,0,0,0.45); padding-left: 52px; }
.modal-spin { display: flex; align-items: center; justify-content: center; min-height: 200px; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div class="experts-page">
<div class="page-header">
<div>
<h2 class="page-title">🎓 专家管理</h2>
<p class="page-desc">管理平台认证专家信息支持专家审核与状态管理</p>
</div>
<a-space>
<a-button @click="loadExperts" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :sm="6" v-for="stat in statCards" :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: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索姓名 / 单位 / 研究领域"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedExperts"
:loading="loading"
:pagination="tablePagination"
row-key="id"
@change="handleTableChange"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="expert-info-cell">
<div class="expert-avatar">{{ record.name?.charAt(0) || '?' }}</div>
<div class="expert-info-text">
<div class="expert-name">{{ record.name }}</div>
<div class="expert-meta">
<span v-if="record.title">🏷 {{ record.title }}</span>
<span v-if="record.organization" class="meta-item">🏛 {{ record.organization }}</span>
</div>
</div>
</div>
</template>
<template v-if="column.key === 'contact'">
<div class="contact-cell">
<div v-if="record.email">📧 {{ record.email }}</div>
<div v-if="record.phone">📱 {{ record.phone }}</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</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-button type="link" size="small" @click="handleReview(record)" v-if="record.status === 0">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
title="专家详情"
width="700px"
:footer="null"
>
<template v-if="currentExpert">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ currentExpert.name }}</a-descriptions-item>
<a-descriptions-item label="职称">{{ currentExpert.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="单位">{{ currentExpert.organization || '-' }}</a-descriptions-item>
<a-descriptions-item label="研究领域">{{ currentExpert.researchArea || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentExpert.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="电话">{{ currentExpert.phone || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentExpert.status)">{{ statusText(currentExpert.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentExpert.createTime?.substring(0, 10) || '-' }}</a-descriptions-item>
<a-descriptions-item label="个人简介" :span="2">{{ currentExpert.bio || '-' }}</a-descriptions-item>
<a-descriptions-item label="研究成果" :span="2">{{ currentExpert.achievements || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentExpert.attachments?.length" class="attachments-section">
<h4>附件材料</h4>
<div class="attachment-list">
<a v-for="(file, idx) in currentExpert.attachments" :key="idx" :href="file.url" target="_blank">
📎 {{ file.name }}
</a>
</div>
</div>
<div v-if="currentExpert.status === 0" class="review-actions">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentExpert)">通过审核</a-button>
<a-button danger @click="handleReject(currentExpert)">拒绝</a-button>
</a-space>
</div>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '专家管理 - 后台管理' })
interface Expert {
id?: number
name?: string
title?: string
organization?: string
researchArea?: string
email?: string
phone?: string
bio?: string
achievements?: string
status?: number
createTime?: string
attachments?: { name: string; url: string }[]
}
const loading = ref(false)
const experts = ref<Expert[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 1, icon: '✅', label: '已认证', value: 0, color: 'green' },
{ key: 2, icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: -1, icon: '👥', label: '全部专家', value: 0, color: 'blue' },
])
const columns = [
{ title: '专家信息', key: 'info', width: 280 },
{ title: '联系方式', key: 'contact', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentExpert = ref<Expert | null>(null)
const filteredExperts = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return experts.value
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.name, item.organization, item.researchArea]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedExperts = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredExperts.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredExperts.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
statCards[0].value = experts.value.filter(i => i.status === 0).length
statCards[1].value = experts.value.filter(i => i.status === 1).length
statCards[2].value = experts.value.filter(i => i.status === 2).length
statCards[3].value = experts.value.length
}
async function loadExperts() {
loading.value = true
try {
// TODO: 接入实际API
// const res = await listExperts()
// experts.value = res || []
updateStats()
} catch (e: any) {
message.error(e?.message || '加载专家列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
}
function handleView(record: Expert) {
currentExpert.value = record
showDetailModal.value = true
}
function handleReview(record: Expert) {
currentExpert.value = record
showDetailModal.value = true
}
async function handleApprove(expert: Expert) {
try {
// TODO: 接入实际API
// await approveExpert(expert.id)
message.success('已通过审核')
showDetailModal.value = false
await loadExperts()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleReject(expert: Expert) {
try {
// TODO: 接入实际API
// await rejectExpert(expert.id)
message.success('已拒绝')
showDetailModal.value = false
await loadExperts()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待审核', 1: '已认证', 2: '已拒绝' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'success', 2: 'error' }
return map[status ?? -1] || 'default'
}
onMounted(() => {
loadExperts()
})
</script>
<style scoped>
.experts-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.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.orange { border-color: #f97316; }
.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); }
.expert-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.expert-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; font-size: 20px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.expert-info-text { flex: 1; min-width: 0; }
.expert-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.expert-meta { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 4px; }
.meta-item { margin-left: 8px; }
.contact-cell { font-size: 12px; color: rgba(0,0,0,0.65); line-height: 1.7; }
.attachments-section { margin-top: 16px; }
.attachments-section h4 { font-size: 14px; margin-bottom: 8px; }
.attachment-list { display: flex; flex-direction: column; gap: 8px; }
.attachment-list a { color: #1890ff; }
.review-actions { text-align: right; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -1,805 +0,0 @@
<template>
<div class="review-page">
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">🔧 Git 审核管理</h2>
<p class="page-desc">审核开发者的 Git 账号绑定与仓库权限申请</p>
</div>
<a-space>
<a-button @click="loadAll" :loading="loadingGit || loadingPerm">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<!-- Git 账号审核 -->
<a-tab-pane key="git-account" tab="Git 账号审核">
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="8" :md="6" v-for="stat in gitStats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: gitFilter.status === stat.key }]"
@click="handleGitStatFilter(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">📋 Git 账号绑定列表</span>
<a-space>
<a-input-search
v-model:value="gitFilter.keyword"
placeholder="搜索用户名/邮箱"
style="width: 200px"
@search="loadGitAccounts"
allow-clear
/>
</a-space>
</div>
<a-table
:columns="gitColumns"
:data-source="gitAccounts"
:loading="loadingGit"
:pagination="gitPagination"
row-key="id"
@change="handleGitTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userInfo'">
<div class="user-info-cell">
<a-avatar :size="36" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="user-info-text">
<div class="user-name">{{ record.username }}</div>
<div class="user-email" v-if="record.email">{{ record.email }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'remark'">
<span class="text-gray">{{ record.remark || '-' }}</span>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="gitStatusColor(record.status)">
{{ gitStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'time'">
<span class="text-sm text-gray">{{ formatTime(record.updateTime || record.createTime) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewGitDetail(record)">详情</a-button>
<template v-if="record.status === 'pending'">
<a-popconfirm title="确认通过此 Git 账号绑定?" @confirm="handleApproveGit(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleRejectGit(record)">拒绝</a-button>
</template>
</a-space>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
<!-- 仓库权限审核 -->
<a-tab-pane key="permission-request" tab="仓库权限审核">
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="8" :md="6" v-for="stat in permStats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: permFilter.status === stat.key }]"
@click="handlePermStatFilter(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>
<a-input-search
v-model:value="permFilter.keyword"
placeholder="搜索用户名/仓库名"
style="width: 200px"
@search="loadPermRequests"
allow-clear
/>
</a-space>
</div>
<a-table
:columns="permColumns"
:data-source="permRequests"
:loading="loadingPerm"
:pagination="permPagination"
row-key="id"
@change="handlePermTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'applicant'">
<div class="user-info-cell">
<a-avatar :size="32" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="user-name">{{ record.gitUsername }}</span>
</div>
</template>
<template v-if="column.key === 'repo'">
<a-tag color="blue">{{ record.repo }}</a-tag>
</template>
<template v-if="column.key === 'reason'">
<a-tooltip :title="record.reason">
<span class="reason-text">{{ record.reason }}</span>
</a-tooltip>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="permStatusColor(record.status)">
{{ permStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'time'">
<span class="text-sm text-gray">{{ formatTime(record.createdAt || record.reviewedAt) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewPermDetail(record)">详情</a-button>
<template v-if="record.status === 'pending'">
<a-popconfirm title="确认通过此仓库权限申请?" @confirm="handleApprovePerm(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleRejectPerm(record)">拒绝</a-button>
</template>
</a-space>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<!-- Git 账号详情弹窗 -->
<a-modal
v-model:open="showGitDetailModal"
:title="`Git 账号详情:${currentGit?.username || ''}`"
width="560px"
:footer="null"
>
<template v-if="currentGit">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="Gitea 用户名">{{ currentGit.username }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentGit.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="gitStatusColor(currentGit.status)">{{ gitStatusText(currentGit.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="用户ID">{{ currentGit.userId }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(currentGit.createTime) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(currentGit.updateTime) }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ currentGit.remark || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentGit.verificationNote" label="审核备注" :span="2">
<a-alert :type="currentGit.status === 'rejected' ? 'error' : 'success'" :message="currentGit.verificationNote" show-icon />
</a-descriptions-item>
</a-descriptions>
<div v-if="currentGit.status === 'pending'" class="detail-actions">
<a-popconfirm title="确认通过此 Git 账号绑定?" @confirm="handleApproveGit(currentGit)">
<a-button type="primary" :loading="approvingGit"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleRejectGit(currentGit)"> 拒绝绑定</a-button>
</div>
</template>
</a-modal>
<!-- 权限申请详情弹窗 -->
<a-modal
v-model:open="showPermDetailModal"
:title="`权限申请详情:${currentPerm?.repoName || ''}`"
width="560px"
:footer="null"
>
<template v-if="currentPerm">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="申请人">{{ currentPerm.gitUsername }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="permStatusColor(currentPerm.status)">{{ permStatusText(currentPerm.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="仓库" :span="2">
<a-tag color="blue">{{ currentPerm.repo }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请理由" :span="2">
<div class="detail-reason">{{ currentPerm.reason }}</div>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ formatTime(currentPerm.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="审核时间">{{ formatTime(currentPerm.reviewedAt) || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentPerm.reviewerName" label="审核人">{{ currentPerm.reviewerName }}</a-descriptions-item>
<a-descriptions-item v-if="currentPerm.rejectReason" label="拒绝原因" :span="2">
<a-alert type="error" :message="currentPerm.rejectReason" show-icon />
</a-descriptions-item>
</a-descriptions>
<div v-if="currentPerm.status === 'pending'" class="detail-actions">
<a-popconfirm title="确认通过此仓库权限申请?" @confirm="handleApprovePerm(currentPerm)">
<a-button type="primary" :loading="approvingPerm"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleRejectPerm(currentPerm)"> 拒绝申请</a-button>
</div>
</template>
</a-modal>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="showRejectModal"
:title="rejectModalTitle"
:confirm-loading="rejecting"
@ok="confirmReject"
@cancel="showRejectModal = false"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea
v-model:value="rejectReasonInput"
:rows="4"
:placeholder="rejectPlaceholder"
:maxlength="500"
show-count
/>
</a-form-item>
<div class="reject-tips">
<p>💡 常见拒绝原因</p>
<a-space wrap>
<a-tag
v-for="tip in rejectTips"
:key="tip"
class="reject-tip-tag"
@click="rejectReasonInput = tip"
>{{ tip }}</a-tag>
</a-space>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
pageGitAccounts,
approveGitAccount,
rejectGitAccount,
pagePermissionRequestsAdmin,
approvePermissionRequest,
rejectPermissionRequest,
type GitAccountItem,
type PermissionRequestItem,
} from '@/api/developer'
definePageMeta({ layout: 'admin' })
useHead({ title: 'Git 审核管理 - 平台管理' })
// ==================== 通用 ====================
const activeTab = ref('git-account')
function formatTime(time?: string) {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
}
// ==================== 拒绝弹窗 ====================
const showRejectModal = ref(false)
const rejectReasonInput = ref('')
const rejecting = ref(false)
const rejectType = ref<'git' | 'perm'>('git')
const rejectTargetId = ref<number>(0)
const rejectTargetRecord = ref<GitAccountItem | PermissionRequestItem | null>(null)
const rejectModalTitle = computed(() => rejectType.value === 'git' ? '拒绝 Git 账号绑定' : '拒绝仓库权限申请')
const rejectPlaceholder = computed(() => rejectType.value === 'git'
? '请填写拒绝原因,以便开发者了解问题并修改'
: '请填写拒绝原因,以便开发者了解问题')
const rejectTips = [
'用户名与 Gitea 平台不一致,请核实后重新提交',
'提交信息不完整,请补充后重新提交',
'该账号存在异常,请联系管理员核实',
'仓库暂时不对外开放权限申请',
'申请理由不充分,请详细说明使用场景',
]
function handleTabChange() {
// tab 切换时无需额外操作,数据已加载
}
// ==================== Git 账号审核 ====================
const loadingGit = ref(false)
const gitAccounts = ref<GitAccountItem[]>([])
const gitFilter = reactive({ status: '', keyword: '' })
const gitPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const gitStats = reactive([
{ key: 'pending', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'verified', icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部', value: 0, color: 'blue' },
])
const gitColumns = [
{ title: '用户信息', key: 'userInfo', width: 220 },
{ title: '备注', key: 'remark', ellipsis: true },
{ title: '状态', key: 'status', width: 100 },
{ title: '更新时间', key: 'time', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showGitDetailModal = ref(false)
const currentGit = ref<GitAccountItem | null>(null)
const approvingGit = ref(false)
async function loadGitAccounts() {
loadingGit.value = true
try {
const res = await pageGitAccounts({
page: gitPagination.current,
size: gitPagination.pageSize,
status: gitFilter.status || undefined,
keyword: gitFilter.keyword || undefined,
})
const listData = (res as any)?.data?.data
gitAccounts.value = listData?.records || []
gitPagination.total = listData?.total || 0
updateGitStats()
} catch {
message.error('加载 Git 账号列表失败')
} finally {
loadingGit.value = false
}
}
async function updateGitStats() {
try {
const [pendingRes, verifiedRes, rejectedRes, allRes] = await Promise.allSettled([
pageGitAccounts({ page: 1, size: 1, status: 'pending' }),
pageGitAccounts({ page: 1, size: 1, status: 'verified' }),
pageGitAccounts({ page: 1, size: 1, status: 'rejected' }),
pageGitAccounts({ page: 1, size: 1 }),
])
const extract = (r: any) => (r.status === 'fulfilled' ? (r.value as any)?.data?.data?.total || 0 : 0)
gitStats[0].value = extract(pendingRes)
gitStats[1].value = extract(verifiedRes)
gitStats[2].value = extract(rejectedRes)
gitStats[3].value = extract(allRes)
} catch { /* ignore */ }
}
function handleGitStatFilter(key: string) {
gitFilter.status = key
gitPagination.current = 1
loadGitAccounts()
}
function handleGitTableChange(pag: any) {
gitPagination.current = pag.current
gitPagination.pageSize = pag.pageSize
loadGitAccounts()
}
function handleViewGitDetail(record: GitAccountItem) {
currentGit.value = record
showGitDetailModal.value = true
}
async function handleApproveGit(record: GitAccountItem) {
approvingGit.value = true
try {
const res = await approveGitAccount(record.id) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success(`Git 账号「${record.username}」已通过审核`)
showGitDetailModal.value = false
loadGitAccounts()
} else {
message.error(res?.data?.message || '操作失败')
}
} catch (e: any) {
message.error(e?.data?.message || e?.message || '操作失败')
} finally {
approvingGit.value = false
}
}
function handleRejectGit(record: GitAccountItem) {
rejectType.value = 'git'
rejectTargetId.value = record.id
rejectTargetRecord.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// ==================== 权限申请审核 ====================
const loadingPerm = ref(false)
const permRequests = ref<PermissionRequestItem[]>([])
const permFilter = reactive({ status: '', keyword: '' })
const permPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const permStats = reactive([
{ key: 'pending', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'approved', icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部', value: 0, color: 'blue' },
])
const permColumns = [
{ title: '申请人', key: 'applicant', width: 160 },
{ title: '仓库', key: 'repo', width: 200 },
{ title: '申请理由', key: 'reason', ellipsis: true },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'time', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showPermDetailModal = ref(false)
const currentPerm = ref<PermissionRequestItem | null>(null)
const approvingPerm = ref(false)
async function loadPermRequests() {
loadingPerm.value = true
try {
const res = await pagePermissionRequestsAdmin({
page: permPagination.current,
size: permPagination.pageSize,
status: permFilter.status || undefined,
keyword: permFilter.keyword || undefined,
})
const listData = (res as any)?.data?.data
permRequests.value = listData?.records || []
permPagination.total = listData?.total || 0
updatePermStats()
} catch {
message.error('加载权限申请列表失败')
} finally {
loadingPerm.value = false
}
}
async function updatePermStats() {
try {
const [pendingRes, approvedRes, rejectedRes, allRes] = await Promise.allSettled([
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'pending' }),
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'approved' }),
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'rejected' }),
pagePermissionRequestsAdmin({ page: 1, size: 1 }),
])
const extract = (r: any) => (r.status === 'fulfilled' ? (r.value as any)?.data?.data?.total || 0 : 0)
permStats[0].value = extract(pendingRes)
permStats[1].value = extract(approvedRes)
permStats[2].value = extract(rejectedRes)
permStats[3].value = extract(allRes)
} catch { /* ignore */ }
}
function handlePermStatFilter(key: string) {
permFilter.status = key
permPagination.current = 1
loadPermRequests()
}
function handlePermTableChange(pag: any) {
permPagination.current = pag.current
permPagination.pageSize = pag.pageSize
loadPermRequests()
}
function handleViewPermDetail(record: PermissionRequestItem) {
currentPerm.value = record
showPermDetailModal.value = true
}
async function handleApprovePerm(record: PermissionRequestItem) {
approvingPerm.value = true
try {
const res = await approvePermissionRequest(record.id) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success(`仓库权限「${record.repo}」已通过审核`)
showPermDetailModal.value = false
loadPermRequests()
} else {
message.error(res?.data?.message || '操作失败')
}
} catch (e: any) {
message.error(e?.data?.message || e?.message || '操作失败')
} finally {
approvingPerm.value = false
}
}
function handleRejectPerm(record: PermissionRequestItem) {
rejectType.value = 'perm'
rejectTargetId.value = record.id
rejectTargetRecord.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// ==================== 确认拒绝 ====================
async function confirmReject() {
if (!rejectReasonInput.value.trim()) {
message.warning('请填写拒绝原因')
return
}
rejecting.value = true
try {
if (rejectType.value === 'git') {
const res = await rejectGitAccount(rejectTargetId.value, rejectReasonInput.value) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success('已拒绝 Git 账号绑定申请')
showGitDetailModal.value = false
showRejectModal.value = false
loadGitAccounts()
} else {
message.error(res?.data?.message || '操作失败')
}
} else {
const res = await rejectPermissionRequest(rejectTargetId.value, rejectReasonInput.value) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success('已拒绝仓库权限申请')
showPermDetailModal.value = false
showRejectModal.value = false
loadPermRequests()
} else {
message.error(res?.data?.message || '操作失败')
}
}
} catch (e: any) {
message.error(e?.data?.message || e?.message || '操作失败')
} finally {
rejecting.value = false
}
}
// ==================== 状态映射 ====================
function gitStatusText(status?: string) {
const map: Record<string, string> = { pending: '待审核', verified: '已通过', rejected: '已拒绝' }
return map[status || ''] || status || '-'
}
function gitStatusColor(status?: string) {
const map: Record<string, string> = { pending: 'orange', verified: 'success', rejected: 'error' }
return map[status || ''] || 'default'
}
function permStatusText(status?: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status || ''] || status || '-'
}
function permStatusColor(status?: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'success', rejected: 'error' }
return map[status || ''] || 'default'
}
// ==================== 加载全部 ====================
function loadAll() {
loadGitAccounts()
loadPermRequests()
}
onMounted(() => {
loadAll()
})
</script>
<style scoped>
.review-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;
line-height: 1.4;
}
.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 { border-color: currentColor; 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;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
/* 用户信息格 */
.user-info-cell {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
flex-shrink: 0;
background: #f0f0f0;
}
.user-info-text { flex: 1; min-width: 0; }
.user-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.user-email {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
.reason-text {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 详情弹窗 */
.detail-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.detail-reason {
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
}
/* 拒绝原因提示 */
.reject-tips {
margin-top: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.reject-tips p {
font-size: 12px;
color: rgba(0,0,0,0.45);
margin: 0 0 8px;
}
.reject-tip-tag {
cursor: pointer;
transition: all 0.15s;
}
.reject-tip-tag:hover {
color: #4f46e5;
border-color: #4f46e5;
}
.mb-6 { margin-bottom: 24px; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
</style>

View File

@@ -3,7 +3,7 @@
<!-- 欢迎横幅 -->
<div class="welcome-banner">
<div class="welcome-left">
<h2 class="welcome-title">🎛 平台管理中心</h2>
<h2 class="welcome-title">🎛 决策咨询网管理后台</h2>
<p class="welcome-sub">欢迎回来{{ adminName }}今日数据已更新</p>
</div>
<div class="welcome-right">
@@ -20,7 +20,7 @@
<!-- 核心数据统计 -->
<a-row :gutter="[16, 16]">
<a-col :xs="12" :sm="12" :md="6" v-for="stat in coreStats" :key="stat.label">
<div class="stat-block" :class="stat.color" @click="navigateTo(stat.to)" :style="{ cursor: stat.to ? 'pointer' : 'default' }">
<div class="stat-block" :class="stat.color">
<div class="stat-block-header">
<span class="stat-block-icon">{{ stat.icon }}</span>
<span class="stat-block-label">{{ stat.label }}</span>
@@ -55,7 +55,7 @@
<div class="todo-dot" :class="todo.dotColor"></div>
<div class="todo-content">
<span class="todo-label">{{ todo.label }}</span>
<a-tag :color="todo.tagColor" style="margin-left:8px">
<a-tag :color="todo.tagColor">
<template v-if="loadingStats">...</template>
<template v-else>{{ todo.value }}</template>
</a-tag>
@@ -96,78 +96,47 @@
import { ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { getUserInfo } from '@/api/layout'
import { getToken } from '@/utils/token-util'
import { pageAppProductAll } from '@/api/app/appProduct'
import { pageUsers } from '@/api/system/user'
import { listAppArticle as listCmsArticle } from '@/api/app/article'
import { pageGitAccounts } from '@/api/developer'
definePageMeta({ layout: 'admin' })
useHead({ title: '平台管理首页' })
useHead({ title: '管理后台首页' })
const adminName = ref('管理员')
const loadingStats = ref(false)
const coreStats = reactive([
{ icon: '📦', label: '应用总数', value: 0, desc: '全平台应用', color: 'blue', to: '/admin/apps' },
{ icon: '👥', label: '用户总数', value: 0, desc: '注册用户', color: 'green', to: '/admin/users' },
{ icon: '', label: '待审核应用', value: 0, desc: '等待审核中', color: 'orange', to: '/admin/app-review' },
{ icon: '🛒', label: '上架应用', value: 0, desc: '市场在售', color: 'purple', to: '/admin/market' },
{ icon: '📝', label: '文章总数', value: 0, desc: '全部文章', color: 'blue' },
{ icon: '👥', label: '用户总数', value: 0, desc: '注册用户', color: 'green' },
{ icon: '🎓', label: '专家总数', value: 0, desc: '认证专家', color: 'purple' },
{ icon: '💼', label: '会员总数', value: 0, desc: '企业/个人会员', color: 'orange' },
])
const todoItems = reactive([
{ label: '待审核应用', value: 0, to: '/admin/app-review', tagColor: 'orange', dotColor: 'dot-orange', urgent: false },
{ label: '待审核Git账号', value: 0, to: '/admin/git-review', tagColor: 'cyan', dotColor: 'dot-cyan', urgent: false },
{ label: '草稿文章', value: 0, to: '/admin/articles', tagColor: 'blue', dotColor: 'dot-blue', urgent: false },
{ label: '冻结用户', value: 0, to: '/admin/users', tagColor: 'red', dotColor: 'dot-red', urgent: false },
{ label: '待审核专家', value: 0, to: '/admin/experts/review', tagColor: 'orange', dotColor: 'dot-orange', urgent: false },
{ label: '待审核会员', value: 0, to: '/admin/members/review', tagColor: 'cyan', dotColor: 'dot-cyan', urgent: false },
{ label: '待处理建言', value: 0, to: '/admin/suggestions', tagColor: 'blue', dotColor: 'dot-blue', urgent: false },
{ label: '待审核文章', value: 0, to: '/admin/articles', tagColor: 'red', dotColor: 'dot-red', urgent: false },
])
const ANNOUNCE_MODEL = 'announcement'
const quickLinks = [
{ to: '/admin/app-review', icon: '🔍', label: '应用审核', bg: '#fff7ed' },
{ to: '/admin/git-review', icon: '🔧', label: 'Git 审核', bg: '#ecfdf5' },
{ to: '/admin/apps', icon: '📦', label: '应用管理', bg: '#eff6ff' },
{ to: '/admin/market', icon: '🛒', label: '应用市场', bg: '#faf5ff' },
{ to: '/admin/users', icon: '👥', label: '用户管理', bg: '#f0fdf4' },
{ to: '/admin/developers', icon: '🧑‍💻', label: '开发者', bg: '#f0f9ff' },
{ to: '/admin/tickets', icon: '🎫', label: '工单处理', bg: '#fdf4ff' },
{ to: '/admin/articles', icon: '📝', label: '文章管理', bg: '#fefce8' },
{ to: '/admin/article-categories', icon: '🗂️', label: '文章分类', bg: '#ecfeff' },
{ to: '/admin/articles', icon: '📝', label: '文章管理', bg: '#fff7ed' },
{ to: '/admin/categories', icon: '🗂️', label: '栏目管理', bg: '#eff6ff' },
{ to: '/admin/experts', icon: '🎓', label: '专家管理', bg: '#faf5ff' },
{ to: '/admin/members', icon: '💼', label: '会员管理', bg: '#f0fdf4' },
{ to: '/admin/suggestions', icon: '💬', label: '建言管理', bg: '#fdf4ff' },
{ to: '/admin/users', icon: '👥', label: '用户管理', bg: '#f0f9ff' },
{ to: '/admin/announcements', icon: '📢', label: '公告管理', bg: '#fff1f2' },
{ to: '/admin/settings', icon: '⚙️', label: '平台设置', bg: '#f9fafb' },
{ to: '/admin/settings', icon: '⚙️', label: '系统设置', bg: '#f9fafb' },
]
async function loadStats() {
loadingStats.value = true
try {
const [appsRes, usersRes, pendingRes, marketRes, draftRes, frozenRes, gitPendingRes] = await Promise.allSettled([
pageAppProductAll({ current: 1, size: 1 }),
pageUsers({ page: 1, limit: 1 }),
pageAppProductAll({ current: 1, size: 1, publishStatus: 'pending_review' }),
pageAppProductAll({ current: 1, size: 1, publishStatus: 'published' }),
listCmsArticle({ status: 1 }),
pageUsers({ page: 1, limit: 1, status: 1 }),
pageGitAccounts({ page: 1, size: 1, status: 'pending' }),
])
coreStats[0].value = appsRes.status === 'fulfilled' ? appsRes.value?.count || 0 : 0
coreStats[1].value = usersRes.status === 'fulfilled' ? usersRes.value?.count || 0 : 0
coreStats[2].value = pendingRes.status === 'fulfilled' ? pendingRes.value?.count || 0 : 0
coreStats[3].value = marketRes.status === 'fulfilled' ? marketRes.value?.count || 0 : 0
const pendingCount = pendingRes.status === 'fulfilled' ? pendingRes.value?.count || 0 : 0
const draftCount = draftRes.status === 'fulfilled'
? (draftRes.value || []).filter(item => (item.model || '').trim() !== ANNOUNCE_MODEL).length
: 0
const frozenCount = frozenRes.status === 'fulfilled' ? frozenRes.value?.count || 0 : 0
const gitPendingCount = gitPendingRes.status === 'fulfilled' ? (gitPendingRes.value as any)?.data?.data?.total || 0 : 0
todoItems[0].value = pendingCount
todoItems[0].urgent = pendingCount > 0
todoItems[1].value = gitPendingCount
todoItems[1].urgent = gitPendingCount > 0
todoItems[2].value = draftCount
todoItems[3].value = frozenCount
// TODO: 接入实际API获取统计数据
// 暂时使用模拟数据
todoItems[0].value = 0
todoItems[1].value = 0
todoItems[2].value = 0
todoItems[3].value = 0
} catch { /* ignore */ } finally {
loadingStats.value = false
}
@@ -176,7 +145,6 @@ async function loadStats() {
onMounted(async () => {
const token = getToken()
if (!token) return
// 并发加载用户信息和统计数据
Promise.allSettled([
getUserInfo().then(me => {
adminName.value = me?.nickname?.trim() || me?.username?.trim() || '管理员'
@@ -253,7 +221,7 @@ onMounted(async () => {
/* 快速入口九宫格 */
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: #f5f5f5;
}

View File

@@ -1,525 +0,0 @@
<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>

View File

@@ -0,0 +1,392 @@
<template>
<div class="members-page">
<div class="page-header">
<div>
<h2 class="page-title">💼 会员管理</h2>
<p class="page-desc">管理企业会员和个人会员支持入会申请审核</p>
</div>
<a-space>
<a-button @click="loadMembers" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :sm="6" v-for="stat in statCards" :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>
<!-- 会员类型切换 -->
<a-radio-group v-model:value="memberType" button-style="solid" class="mb-4">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="enterprise">企业会员</a-radio-button>
<a-radio-button value="personal">个人会员</a-radio-button>
</a-radio-group>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 会员列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索姓名 / 企业名称"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedMembers"
:loading="loading"
:pagination="tablePagination"
row-key="id"
@change="handleTableChange"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="member-info-cell">
<div class="member-avatar" :class="record.type === 1 ? 'enterprise' : 'personal'">
{{ record.type === 1 ? '🏢' : '👤' }}
</div>
<div class="member-info-text">
<div class="member-name">{{ record.name }}</div>
<div class="member-meta">
<a-tag :color="record.type === 1 ? 'blue' : 'green'" size="small">
{{ record.type === 1 ? '企业会员' : '个人会员' }}
</a-tag>
</div>
</div>
</div>
</template>
<template v-if="column.key === 'contact'">
<div class="contact-cell">
<div v-if="record.contact">📞 {{ record.contact }}</div>
<div v-if="record.phone">📱 {{ record.phone }}</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</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-button type="link" size="small" @click="handleReview(record)" v-if="record.status === 0">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
title="会员详情"
width="700px"
:footer="null"
>
<template v-if="currentMember">
<a-tag :color="currentMember.type === 1 ? 'blue' : 'green'" style="margin-bottom: 16px">
{{ currentMember.type === 1 ? '企业会员' : '个人会员' }}
</a-tag>
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名/企业名">{{ currentMember.name }}</a-descriptions-item>
<a-descriptions-item label="联系人">{{ currentMember.contact || '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentMember.phone || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentMember.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentMember.status)">{{ statusText(currentMember.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentMember.createTime?.substring(0, 10) || '-' }}</a-descriptions-item>
<a-descriptions-item label="简介" :span="2">{{ currentMember.bio || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentMember.attachments?.length" class="attachments-section">
<h4>附件材料</h4>
<div class="attachment-list">
<a v-for="(file, idx) in currentMember.attachments" :key="idx" :href="file.url" target="_blank">
📎 {{ file.name }}
</a>
</div>
</div>
<div v-if="currentMember.status === 0" class="review-actions">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentMember)">通过审核</a-button>
<a-button danger @click="handleReject(currentMember)">拒绝</a-button>
</a-space>
</div>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '会员管理 - 后台管理' })
interface Member {
id?: number
name?: string
type?: number // 1: 企业, 2: 个人
contact?: string
phone?: string
email?: string
bio?: string
status?: number
createTime?: string
attachments?: { name: string; url: string }[]
}
const loading = ref(false)
const members = ref<Member[]>([])
const memberType = ref('all')
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 1, icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 2, icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: -1, icon: '👥', label: '全部会员', value: 0, color: 'blue' },
])
const columns = [
{ title: '会员信息', key: 'info', width: 260 },
{ title: '联系方式', key: 'contact', width: 180 },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentMember = ref<Member | null>(null)
const filteredMembers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return members.value
.filter(item => {
if (memberType.value === 'enterprise') return item.type === 1
if (memberType.value === 'personal') return item.type === 2
return true
})
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.name, item.contact]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedMembers = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredMembers.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredMembers.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
const filtered = members.value.filter(item => {
if (memberType.value === 'enterprise') return item.type === 1
if (memberType.value === 'personal') return item.type === 2
return true
})
statCards[0].value = filtered.filter(i => i.status === 0).length
statCards[1].value = filtered.filter(i => i.status === 1).length
statCards[2].value = filtered.filter(i => i.status === 2).length
statCards[3].value = filtered.length
}
async function loadMembers() {
loading.value = true
try {
// TODO: 接入实际API
updateStats()
} catch (e: any) {
message.error(e?.message || '加载会员列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
}
function handleView(record: Member) {
currentMember.value = record
showDetailModal.value = true
}
function handleReview(record: Member) {
currentMember.value = record
showDetailModal.value = true
}
async function handleApprove(member: Member) {
try {
// TODO: 接入实际API
message.success('已通过审核')
showDetailModal.value = false
await loadMembers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleReject(member: Member) {
try {
// TODO: 接入实际API
message.success('已拒绝')
showDetailModal.value = false
await loadMembers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待审核', 1: '已通过', 2: '已拒绝' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'success', 2: 'error' }
return map[status ?? -1] || 'default'
}
watch(memberType, () => {
updateStats()
})
onMounted(() => {
loadMembers()
})
</script>
<style scoped>
.members-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.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.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); }
.member-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.member-avatar {
width: 48px; height: 48px; border-radius: 12px;
font-size: 24px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.member-avatar.enterprise { background: #eff6ff; }
.member-avatar.personal { background: #f0fdf4; }
.member-info-text { flex: 1; min-width: 0; }
.member-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.member-meta { margin-top: 4px; }
.contact-cell { font-size: 12px; color: rgba(0,0,0,0.65); line-height: 1.7; }
.attachments-section { margin-top: 16px; }
.attachments-section h4 { font-size: 14px; margin-bottom: 8px; }
.attachment-list { display: flex; flex-direction: column; gap: 8px; }
.attachment-list a { color: #1890ff; }
.review-actions { text-align: right; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 16px; }
</style>

View File

@@ -0,0 +1,344 @@
<template>
<div class="suggestions-page">
<div class="page-header">
<div>
<h2 class="page-title">💬 建言献策管理</h2>
<p class="page-desc">管理用户提交的建言献策支持审核与状态跟踪</p>
</div>
<a-space>
<a-button @click="loadSuggestions" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :sm="6" v-for="stat in statCards" :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: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</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>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索标题 / 内容"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedSuggestions"
:loading="loading"
:pagination="tablePagination"
row-key="id"
@change="handleTableChange"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="suggestion-info-cell">
<div class="suggestion-title">{{ record.title }}</div>
<div class="suggestion-meta">
<span>👤 {{ record.authorName || '匿名' }}</span>
<span class="meta-item">📅 {{ record.createTime?.substring(0, 10) || '-' }}</span>
</div>
</div>
</template>
<template v-if="column.key === 'content'">
<div class="content-preview">{{ record.content?.substring(0, 50) }}...</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
<a-button type="link" size="small" @click="handleProcess(record)" v-if="record.status === 0">处理</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
title="建言详情"
width="700px"
:footer="null"
>
<template v-if="currentSuggestion">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="标题" :span="2">{{ currentSuggestion.title }}</a-descriptions-item>
<a-descriptions-item label="提交人">{{ currentSuggestion.authorName || '匿名' }}</a-descriptions-item>
<a-descriptions-item label="联系方式">{{ currentSuggestion.contact || '-' }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ currentSuggestion.createTime?.substring(0, 16) || '-' }}</a-descriptions-item>
<a-descriptions-item label="当前状态">
<a-tag :color="statusColor(currentSuggestion.status)">{{ statusText(currentSuggestion.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="建言内容" :span="2">
<div class="full-content">{{ currentSuggestion.content }}</div>
</a-descriptions-item>
<a-descriptions-item label="处理备注" :span="2" v-if="currentSuggestion.reply">
{{ currentSuggestion.reply }}
</a-descriptions-item>
</a-descriptions>
<div v-if="currentSuggestion.status === 0" class="process-actions">
<a-divider />
<a-form :model="replyForm" layout="vertical">
<a-form-item label="处理备注">
<a-textarea v-model:value="replyForm.reply" :rows="3" placeholder="请输入处理备注..." />
</a-form-item>
<a-form-item label="处理结果">
<a-select v-model:value="replyForm.status" placeholder="请选择处理结果">
<a-select-option :value="1">已处理</a-select-option>
<a-select-option :value="2">已采纳</a-select-option>
</a-select>
</a-form-item>
<a-space>
<a-button type="primary" @click="handleSubmitReply">提交</a-button>
</a-space>
</a-form>
</div>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '建言管理 - 后台管理' })
interface Suggestion {
id?: number
title?: string
content?: string
authorName?: string
contact?: string
status?: number
reply?: string
createTime?: string
}
const loading = ref(false)
const suggestions = ref<Suggestion[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '⏳', label: '待处理', value: 0, color: 'orange' },
{ key: 1, icon: '✅', label: '已处理', value: 0, color: 'blue' },
{ key: 2, icon: '🎯', label: '已采纳', value: 0, color: 'green' },
{ key: -1, icon: '📝', label: '全部建言', value: 0, color: 'purple' },
])
const columns = [
{ title: '建言信息', key: 'info', width: 280 },
{ title: '内容预览', key: 'content', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentSuggestion = ref<Suggestion | null>(null)
const replyForm = reactive({
reply: '',
status: 1,
})
const filteredSuggestions = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return suggestions.value
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.content]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedSuggestions = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredSuggestions.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredSuggestions.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
statCards[0].value = suggestions.value.filter(i => i.status === 0).length
statCards[1].value = suggestions.value.filter(i => i.status === 1).length
statCards[2].value = suggestions.value.filter(i => i.status === 2).length
statCards[3].value = suggestions.value.length
}
async function loadSuggestions() {
loading.value = true
try {
// TODO: 接入实际API
updateStats()
} catch (e: any) {
message.error(e?.message || '加载建言列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
}
function handleView(record: Suggestion) {
currentSuggestion.value = record
replyForm.reply = ''
replyForm.status = 1
showDetailModal.value = true
}
function handleProcess(record: Suggestion) {
handleView(record)
}
async function handleSubmitReply() {
if (!currentSuggestion.value?.id) return
try {
// TODO: 接入实际API
// await processSuggestion(currentSuggestion.value.id, replyForm)
message.success('处理成功')
showDetailModal.value = false
await loadSuggestions()
} catch (e: any) {
message.error(e?.message || '处理失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待处理', 1: '已处理', 2: '已采纳' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'blue', 2: 'success' }
return map[status ?? -1] || 'default'
}
onMounted(() => {
loadSuggestions()
})
</script>
<style scoped>
.suggestions-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.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.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); }
.suggestion-info-cell { display: flex; flex-direction: column; gap: 4px; }
.suggestion-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.suggestion-meta { font-size: 12px; color: rgba(0,0,0,0.45); }
.meta-item { margin-left: 12px; }
.content-preview { font-size: 12px; color: rgba(0,0,0,0.65); }
.full-content {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.6;
}
.process-actions { margin-top: 16px; }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -1,601 +0,0 @@
<template>
<div class="tenants-page">
<div class="page-header">
<div>
<h2 class="page-title">🏢 租户管理</h2>
<p class="page-desc">管理平台所有租户</p>
</div>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增租户
</a-button>
<a-button @click="loadTenants" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 租户列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="1">正常</a-select-option>
<a-select-option :value="0">已停用</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="tenants"
:loading="loading"
:pagination="pagination"
row-key="tenantId"
@change="handleTableChange"
size="middle"
>
<template #bodyCell="{ column, record }">
<!-- 租户信息 -->
<template v-if="column.key === 'tenantInfo'">
<div class="tenant-info-cell">
<a-avatar :size="40" :src="record.logo" style="flex-shrink:0">
<template #icon><BankOutlined /></template>
</a-avatar>
<div class="tenant-info-text">
<div class="tenant-name">{{ record.tenantName }}</div>
<div class="tenant-sub">ID: {{ record.tenantId }}</div>
</div>
</div>
</template>
<!-- 企业名称 -->
<template v-if="column.key === 'companyName'">
{{ record.companyName || '-' }}
</template>
<!-- 客户名称昵称/真实姓名/企业名称 -->
<template v-if="column.key === 'customerName'">
<div class="customer-name-cell">
<div v-if="record.nickname || record.realName || record.companyName" class="customer-info">
<span v-if="record.nickname" class="customer-nickname">{{ record.nickname }}</span>
<span v-if="record.realName" class="customer-realname">{{ record.realName }}</span>
<span v-if="record.companyName" class="customer-company">{{ record.companyName }}</span>
</div>
<span v-else class="text-gray-400">-</span>
</div>
</template>
<!-- 账号 -->
<template v-if="column.key === 'username'">
<span class="mono-text">{{ record.username || '-' }}</span>
</template>
<!-- 超级管理员手机号 -->
<template v-if="column.key === 'phone'">
<span class="mono-text">{{ record.phone || '-' }}</span>
</template>
<!-- 状态 -->
<template v-if="column.key === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '正常' : '已停用'" />
</template>
<!-- 备注 -->
<template v-if="column.key === 'description'">
<a-tooltip :title="record.description">
<span class="desc-text">{{ record.description || '-' }}</span>
</a-tooltip>
</template>
<!-- 创建时间 -->
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ formatDate(record.createTime) }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewApps(record)">查看应用</a-button>
<a-button type="link" size="small" @click="handleTransfer(record)">转移</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm
:title="record.status === 1 ? '确认停用此租户?' : '确认启用此租户?'"
@confirm="handleToggleStatus(record)"
>
<a-button type="link" size="small" :danger="record.status === 1">
{{ record.status === 1 ? '停用' : '启用' }}
</a-button>
</a-popconfirm>
<a-popconfirm title="确认删除此租户?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 编辑/新增弹窗 -->
<a-modal
v-model:open="showModal"
:title="modalMode === 'add' ? '新增租户' : '编辑租户'"
width="560px"
@ok="handleSubmit"
:confirmLoading="submitLoading"
>
<a-form ref="formRef" :model="formData" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="租户名称" name="tenantName" :rules="[{ required: true, message: '请输入租户名称' }]">
<a-input v-model:value="formData.tenantName" placeholder="请输入租户名称" />
</a-form-item>
<a-form-item label="企业名称" name="companyName">
<a-input v-model:value="formData.companyName" placeholder="请输入企业名称" />
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-input v-model:value="formData.logo" placeholder="请输入Logo URL" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-select v-model:value="formData.status">
<a-select-option :value="1">正常</a-select-option>
<a-select-option :value="0">停用</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="备注" name="description">
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入备注" />
</a-form-item>
</a-form>
</a-modal>
<!-- 查看应用弹窗 -->
<a-modal
v-model:open="showAppsModal"
:title="`租户应用:${currentTenant?.tenantName || ''}`"
width="900px"
:footer="null"
>
<div v-if="tenantApps.length > 0" class="apps-grid">
<div v-for="app in tenantApps" :key="app.productId" class="app-card">
<div class="app-card-header">
<img v-if="app.icon" :src="app.icon" class="app-icon" />
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(app.productName) }">
{{ (app.productName || 'A').charAt(0).toUpperCase() }}
</div>
<div class="app-card-info">
<div class="app-name">{{ app.productName }}</div>
<div class="app-code">{{ app.productCode }}</div>
</div>
<a-badge :status="appStatusBadge(app.status)" :text="appStatusText(app.status)" />
</div>
<div class="app-card-meta">
<span>类型{{ APP_TYPE_NAME[app.appType ?? 10] || '未知' }}</span>
<span>创建{{ formatDate(app.createTime) }}</span>
</div>
</div>
</div>
<a-empty v-else description="该租户暂无应用" />
</a-modal>
<!-- 转移所有权弹窗 -->
<a-modal
v-model:open="showTransferModal"
title="转移租户所有权"
width="500px"
@ok="handleTransferSubmit"
:confirmLoading="transferLoading"
>
<div class="transfer-info">
<p>当前租户<strong>{{ currentTenant?.tenantName }}</strong></p>
<p>当前归属<span class="mono-text">{{ currentTenant?.username || '-' }}</span> ({{ currentTenant?.phone || '-' }})</p>
</div>
<a-divider>选择新归属用户</a-divider>
<a-select
v-model:value="transferUserId"
show-search
filter-option
placeholder="搜索用户账号/手机号/昵称"
style="width: 100%"
:loading="loadingUsers"
@search="handleSearchUsers"
>
<a-select-option v-for="u in userList" :key="u.userId" :value="u.userId">
<div class="user-option">
<span>{{ u.username || u.nickname || '用户' + u.userId }}</span>
<span class="user-phone">{{ u.phone }}</span>
</div>
</a-select-option>
</a-select>
</a-modal>
</div>
</template>
<script setup lang="ts">
import {
PlusOutlined,
ReloadOutlined,
BankOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { pageTenant, addTenant, updateTenant, removeTenant, transferTenantOwner, listUsers } from '@/api/system/tenant/index'
import { pageAppProduct } from '@/api/app/appProduct'
import { APP_TYPE_NAME } from '@/api/app/appProduct/model'
import type { Tenant } from '@/api/system/tenant/model'
import type { AppProduct } from '@/api/app/appProduct/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '租户管理 - 平台管理' })
const loading = ref(false)
const submitLoading = ref(false)
const tenants = ref<Tenant[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const columns = [
{ title: '租户信息', key: 'tenantInfo', width: 180 },
{ title: '客户名称', key: 'customerName', width: 180 },
{ title: '账号', key: 'username', width: 120 },
{ title: '手机号', key: 'phone', width: 130 },
{ title: '状态', key: 'status', width: 90 },
{ title: '创建时间', key: 'createTime', width: 110 },
{ title: '操作', key: 'action', width: 260 },
]
const showModal = ref(false)
const modalMode = ref<'add' | 'edit'>('add')
const formData = ref<Tenant>({ status: 1 })
const formRef = ref()
// 查看应用相关
const showAppsModal = ref(false)
const currentTenant = ref<Tenant | null>(null)
const tenantApps = ref<AppProduct[]>([])
const loadingApps = ref(false)
// 转移所有权相关
const showTransferModal = ref(false)
const transferUserId = ref<number | undefined>()
const transferLoading = ref(false)
const userList = ref<any[]>([])
const loadingUsers = ref(false)
async function loadUsers(keywords?: string) {
loadingUsers.value = true
try {
const res = await listUsers({ keywords })
userList.value = res || []
} catch {
userList.value = []
} finally {
loadingUsers.value = false
}
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
function handleSearchUsers(value: string) {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
loadUsers(value)
}, 300)
}
function handleTransfer(record: Tenant) {
currentTenant.value = record
transferUserId.value = undefined
loadUsers()
showTransferModal.value = true
}
async function handleTransferSubmit() {
if (!transferUserId.value) {
message.warning('请选择新归属用户')
return
}
if (!currentTenant.value?.tenantId) return
transferLoading.value = true
try {
await transferTenantOwner(currentTenant.value.tenantId, transferUserId.value)
message.success('所有权转移成功')
showTransferModal.value = false
loadTenants()
} catch (e: any) {
message.error(e?.message || '转移失败')
} finally {
transferLoading.value = false
}
}
async function loadTenants() {
loading.value = true
try {
const res = await pageTenant({
page: pagination.current,
limit: pagination.pageSize,
tenantName: searchKeyword.value || undefined,
companyName: searchKeyword.value || undefined,
})
let list = res?.list || []
if (filterStatus.value !== undefined) {
list = list.filter((t: Tenant) => t.status === filterStatus.value)
}
tenants.value = list
pagination.total = res?.count || 0
} catch {
message.error('加载租户列表失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadTenants()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadTenants()
}
function handleAdd() {
formData.value = { status: 1 }
modalMode.value = 'add'
showModal.value = true
}
function handleEdit(record: Tenant) {
formData.value = { ...record }
modalMode.value = 'edit'
showModal.value = true
}
async function handleSubmit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
submitLoading.value = true
try {
if (modalMode.value === 'add') {
await addTenant(formData.value)
message.success('租户创建成功')
} else {
await updateTenant(formData.value)
message.success('租户信息保存成功')
}
showModal.value = false
loadTenants()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
submitLoading.value = false
}
}
async function handleToggleStatus(record: Tenant) {
const newStatus = record.status === 1 ? 0 : 1
try {
await updateTenant({ ...record, status: newStatus })
message.success(newStatus === 1 ? '租户已启用' : '租户已停用')
loadTenants()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleDelete(record: Tenant) {
try {
await removeTenant(record.tenantId)
message.success('租户删除成功')
loadTenants()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
// 查看租户应用
async function handleViewApps(record: Tenant) {
currentTenant.value = record
showAppsModal.value = true
loadingApps.value = true
try {
const res = await pageAppProduct({
current: 1,
size: 100,
tenantId: record.tenantId,
})
tenantApps.value = res?.list || []
} catch {
message.error('加载应用列表失败')
tenantApps.value = []
} finally {
loadingApps.value = false
}
}
function appStatusText(status?: number) {
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭' }
return map[status ?? -1] || '未知'
}
function appStatusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
if (status === 1) return 'success'
if (status === 2) return 'warning'
if (status === 3) return 'error'
return 'default'
}
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]
}
function formatDate(dateStr?: string) {
return dateStr ? dateStr.substring(0, 10) : '-'
}
onMounted(() => loadTenants())
</script>
<style scoped>
.tenants-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; }
.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); }
.tenant-info-cell { display: flex; align-items: center; gap: 12px; }
.tenant-info-text { flex: 1; min-width: 0; }
.tenant-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.tenant-sub { font-size: 12px; color: rgba(0,0,0,0.45); }
.mono-text {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
.desc-text {
font-size: 13px;
cursor: pointer;
}
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
/* 应用卡片样式 */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
max-height: 500px;
overflow-y: auto;
padding: 8px;
}
.app-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.app-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.app-card-header {
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-card-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 14px;
font-weight: 500;
color: rgba(0,0,0,0.85);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-code {
font-size: 12px;
color: rgba(0,0,0,0.45);
}
.app-card-meta {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
font-size: 12px;
color: rgba(0,0,0,0.45);
}
/* 客户名称单元格 */
.customer-name-cell { line-height: 1.5; }
.customer-info { display: flex; flex-direction: column; gap: 2px; }
.customer-nickname { font-size: 13px; color: rgba(0,0,0,0.85); }
.customer-realname { font-size: 12px; color: rgba(0,0,0,0.65); }
.customer-company { font-size: 12px; color: rgba(0,0,0,0.45); }
/* 转移所有权弹窗 */
.transfer-info {
background: #fafafa;
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
line-height: 1.8;
}
.user-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
}
.user-phone {
font-size: 12px;
color: rgba(0,0,0,0.45);
}
</style>