Files
2026-04-29 01:33:33 +08:00

377 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters

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

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

<template>
<div class="experts-page">
<div class="page-header">
<div>
<h2 class="page-title">🎓 专家管理</h2>
<p class="page-desc">管理平台认证专家信息支持专家审核与状态管理</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadExperts">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@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"
size="middle"
@change="handleTableChange"
>
<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 size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleReview(record)">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="专家详情"
width="700px"
>
<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 :span="2" label="个人简介">{{ currentExpert.bio || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="研究成果">{{ 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 lang="ts" setup>
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>