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

416 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

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

<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>