新版官网模板

This commit is contained in:
2026-04-29 01:33:33 +08:00
commit 0d82386f8f
341 changed files with 64526 additions and 0 deletions

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

View File

@@ -0,0 +1,337 @@
<template>
<div class="admin-experts-review">
<div class="page-header">
<h3>专家审核</h3>
<span class="pending-count">待审核{{ pendingCount }} </span>
</div>
<!-- 搜索过滤 -->
<div class="filter-bar">
<a-space wrap>
<a-input v-model:value="filters.keyword" allow-clear placeholder="搜索专家姓名/单位" style="width: 200px" @press-enter="loadData" />
<a-select v-model:value="filters.status" style="width: 130px" @change="loadData">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
<a-button type="primary" @click="loadData">搜索</a-button>
</a-space>
</div>
<!-- 审核列表 -->
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize, current: currentPage, onChange: handlePageChange, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'applicant'">
<div class="applicant-info">
<a-avatar :size="36" :src="record.avatar">{{ record.name?.charAt(0) }}</a-avatar>
<div class="applicant-detail">
<div class="applicant-name">{{ record.name }}</div>
<div class="applicant-org">{{ record.organization }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'materials'">
<a-space>
<a-button size="small" @click="previewFile(record, 'resume')">简历</a-button>
<a-button size="small" @click="previewFile(record, 'id')">身份证</a-button>
<a-button size="small" @click="previewFile(record, 'cert')">证书</a-button>
</a-space>
</template>
<template v-if="column.key === 'action'">
<a-space v-if="record.status === 'pending'">
<a-button size="small" type="primary" @click="handleApprove(record)">通过</a-button>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
<a-space v-else>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="rejectModal"
:confirm-loading="saving"
title="填写拒绝原因"
@ok="confirmReject"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea v-model:value="rejectReason" :rows="4" placeholder="请说明拒绝原因(将通知申请人)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailModal"
:footer="null"
:title="`${currentRecord?.name} - 申请详情`"
width="700px"
>
<div v-if="currentRecord" class="detail-content">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="姓名">{{ currentRecord.name }}</a-descriptions-item>
<a-descriptions-item label="职称">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="所在单位">{{ currentRecord.organization }}</a-descriptions-item>
<a-descriptions-item label="研究领域">{{ currentRecord.researchArea }}</a-descriptions-item>
<a-descriptions-item label="学历">{{ currentRecord.education }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentRecord.phone }}</a-descriptions-item>
<a-descriptions-item :span="2" label="电子邮箱">{{ currentRecord.email }}</a-descriptions-item>
<a-descriptions-item :span="2" label="个人简介">{{ currentRecord.intro }}</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentRecord.applyTime }}</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="getStatusColor(currentRecord.status)">{{ getStatusText(currentRecord.status) }}</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="materials-section" style="margin-top:16px">
<h4 style="margin-bottom:12px">申请材料</h4>
<a-space wrap>
<a-button icon="📄" @click="previewFile(currentRecord, 'resume')">查看简历/研究成果</a-button>
<a-button icon="🪪" @click="previewFile(currentRecord, 'id')">查看身份证</a-button>
<a-button icon="🏆" @click="previewFile(currentRecord, 'cert')">查看职称证书/学历证书</a-button>
</a-space>
</div>
<div v-if="currentRecord.status === 'pending'" class="action-area" style="margin-top:16px">
<a-space>
<a-button type="primary" @click="handleApprove(currentRecord); detailModal = false">通过申请</a-button>
<a-button danger @click="handleReject(currentRecord); detailModal = false">拒绝申请</a-button>
</a-space>
</div>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '专家审核' })
const loading = ref(false)
const saving = ref(false)
const rejectModal = ref(false)
const detailModal = ref(false)
const rejectReason = ref('')
const currentRecord = ref<any>(null)
const pendingCount = ref(3)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(15)
const filters = reactive({
keyword: '',
status: '',
})
const columns = [
{ title: '申请人', key: 'applicant', width: 200 },
{ title: '职称', dataIndex: 'title', key: 'title' },
{ title: '研究领域', dataIndex: 'researchArea', key: 'researchArea' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime', width: 150 },
{ title: '状态', key: 'status', width: 100 },
{ title: '材料', key: 'materials', width: 160 },
{ title: '操作', key: 'action', width: 160 },
]
const dataSource = ref<any[]>([
{
id: 1,
name: '张某某',
avatar: '',
organization: '广西大学',
title: '教授',
researchArea: '区域经济',
education: '博士',
phone: '138****0001',
email: 'zhang@gxu.edu.cn',
intro: '长期从事区域经济研究...',
applyTime: '2024-12-18 10:30',
status: 'pending',
},
{
id: 2,
name: '李某某',
avatar: '',
organization: '广西社科院',
title: '研究员',
researchArea: '产业政策',
education: '博士',
phone: '139****0002',
email: 'li@gxss.org',
intro: '专注产业政策研究...',
applyTime: '2024-12-17 15:00',
status: 'pending',
},
{
id: 3,
name: '王某某',
avatar: '',
organization: '广西师范大学',
title: '副教授',
researchArea: '金融经济',
education: '博士',
phone: '137****0003',
email: 'wang@gxnu.edu.cn',
intro: '从事金融经济研究...',
applyTime: '2024-12-15 09:00',
status: 'approved',
},
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
async function handleApprove(record: any) {
try {
// TODO: 调用API
record.status = 'approved'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success(`已通过 ${record.name} 的专家申请`)
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function handleReject(record: any) {
currentRecord.value = record
rejectReason.value = ''
rejectModal.value = true
}
async function confirmReject() {
if (!rejectReason.value.trim()) {
message.warning('请填写拒绝原因')
return
}
saving.value = true
try {
// TODO: 调用API
currentRecord.value.status = 'rejected'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success('已拒绝申请并通知申请人')
rejectModal.value = false
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
saving.value = false
}
}
function viewDetail(record: any) {
currentRecord.value = record
detailModal.value = true
}
function previewFile(record: any, type: string) {
message.info(`预览 ${record.name}${type === 'resume' ? '简历' : type === 'id' ? '身份证' : '证书'}材料`)
// TODO: 打开文件预览
}
function handlePageChange(page: number) {
currentPage.value = page
loadData()
}
async function loadData() {
loading.value = true
try {
// TODO: 接入实际API
} finally {
loading.value = false
}
}
onMounted(() => {
total.value = dataSource.value.length
})
</script>
<style scoped>
.admin-experts-review {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.pending-count {
padding: 4px 12px;
background: #fef3c7;
color: #b45309;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.filter-bar {
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.applicant-info {
display: flex;
align-items: center;
gap: 10px;
}
.applicant-name {
font-weight: 600;
font-size: 14px;
color: #1f2937;
}
.applicant-org {
font-size: 12px;
color: #9ca3af;
}
</style>