Files
jczxw-pc/app/pages/admin/members/index.vue
2026-04-23 17:14:29 +08:00

393 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

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