Files
guoju0808/ry-vue3/src/views/expert/Experts.vue
T
郭庆泰 1e86865a40 feat: 合并 admin/manager 重复页 + 公示意向 + 劳务签署
页合并 (admin/manager 共用, route.path 角色感知):
- expert: Experts.vue + ExpertNew.vue (list/new/edit/view 一套)
- sponsor-orgs: SponsorOrgs.vue
- sponsor-people: SponsorPeople.vue + SponsorPersonNew.vue + SponsorPersonDetail.vue
- executor-orgs: ExecutorOrgs.vue
- executor-people: ExecutorPeople.vue + ExecutorPersonNew.vue + ExecutorPersonDetail.vue
- 给 SponsorPeople / ExecutorPeople op 列补上 查看/编辑 按钮 (潜在 bug 修复)
- 详情弹窗统一用 el-descriptions 风格 (替代 admin 旧版 ElMessageBox.alert)

后端:
- BizSignController + BizSignServiceImpl + PdfService (劳务签署 PDF 流程)
- BizPublicityIntentController (公示意向: 支持/执行)
- BizPublicitySupportIntent / BizPublicityExecutionIntent ExportVo
- BizMeetingAttendee 字段合并 (bank_region 替代 bankProvince/bankCity)
- BizExpert 字段合并 (id_card_attachments CSV, bank_region)
- Excel 导入模板精简 (开户行 1 列)

前端:
- doctor/SignFill + SignContract + SignSuccess (劳务签署 3 页流程)
- ImportResultDialog 通用组件
- IdCardUploader 重构 (单 v-model:idCardAttachments, CSV 格式)
- AreaCascader 调整
- Login.vue + AdminLayout.vue + PortalShell.vue + Home.vue 适配

DB: 字段合并 (id_card_front/back → id_card_attachments, bank_province/city → bank_region)
2026-08-20 21:02:56 +08:00

473 lines
20 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>
<!--
共享专家列表页 (admin/manager 共用, 通过 route.path 区分角色)
- 面包屑: admin 专家管理 / manager 专家审核
- 状态筛选: admin 显示
- 审批时间/审批人 : 两角色都显示 (审计追溯)
- 查看按钮: admin (admin 有独立详情页 /admin/experts/view/:id)
- 编辑入口: 两角色都跳独立页 (admin/manager/experts/edit/:id), route 决定回跳路径
- 批量导入: 沿用 manager 版本 (v-if importOpen + onError + 模板下载在框外 + ImportResultDialog)
-->
<div class="page-card experts-list">
<div class="breadcrumb">首页 / {{ isAdmin ? '专家管理' : '专家审核' }}</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable style="width:160px" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="q.phone" placeholder="输入手机号" clearable maxlength="11" style="width:160px" /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="q.workUnit" placeholder="输入工作单位" clearable style="width:200px" /></el-form-item>
<el-form-item label="审核状态">
<el-select v-model="q.auditStatus" placeholder="请选择" clearable style="width:140px">
<el-option label="未提交" value="0" />
<el-option label="待审核" value="1" />
<el-option label="通过" value="2" />
<el-option label="拒绝" value="3" />
</el-select>
</el-form-item>
<el-form-item v-if="isAdmin" label="状态">
<el-select v-model="q.status" placeholder="全部状态" clearable style="width:120px">
<el-option label="正常" value="Y" />
<el-option label="禁用" value="N" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ isAdmin ? '查询' : '查找' }}</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ========== -->
<!--
角色分工:
- 批量导入: 两角色都有 (manager 也可批量入库, 走同一 /importData 端点)
- 批量审核: 两角色都有 (manager 主业, admin 可兜底)
- 批量导入执业证书/职称证明: admin (管理员上传, manager 不管证书)
-->
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建专家</el-button>
<el-button @click="openImport">批量导入</el-button>
<el-button :disabled="!selection.length" @click="onBatchAudit">批量审核</el-button>
<el-button v-if="isAdmin" @click="openImportPracticeCert">批量导入执业证书</el-button>
<el-button v-if="isAdmin" @click="openImportTitleCert">批量导入职称证明</el-button>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
<el-table-column type="selection" width="44" />
<el-table-column prop="name" label="姓名" width="100" fixed />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="科室" width="100" />
<el-table-column prop="title" label="职称" width="100" />
<el-table-column label="执业证书" width="170">
<template #default="{ row }">
<template v-if="row.practiceCertUrl">
<el-button link type="primary" @click="onPreviewCert(row.practiceCertUrl)">查看</el-button>
<el-button link type="primary" @click="onDownloadCert(row.practiceCertUrl, row.phone)">下载</el-button>
</template>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="职称证明" width="170">
<template #default="{ row }">
<template v-if="row.titleCertUrl">
<el-button link type="primary" @click="onPreviewCert(row.titleCertUrl)">查看</el-button>
<el-button link type="primary" @click="onDownloadCert(row.titleCertUrl, row.phone)">下载</el-button>
</template>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="审核状态" width="100" align="center">
<template #default="{ row }"><audit-status-tag :status="row.auditStatus" /></template>
</el-table-column>
<el-table-column label="启用状态" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'N' ? 'danger' : 'success'">
{{ row.status === 'N' ? '禁用' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="auditTime" label="审批时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.auditTime) }}</template>
</el-table-column>
<el-table-column prop="auditBy" label="审批人" width="120" align="center" />
<el-table-column label="操作" :width="isAdmin ? 320 : 240" fixed="right">
<template #default="{ row }">
<!-- 审核 (通过/拒绝): 两角色都有, manager 主业, admin 可兜底 -->
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
<!-- 修改: 两角色都有 (独立页编辑, route 自动决定回跳) -->
<el-button link type="primary" @click="goEdit(row)">修改</el-button>
<!-- 查看 (只读详情): admin, manager 直接在审核 dialog -->
<el-button v-if="isAdmin" link type="primary" @click="goView(row)">查看</el-button>
<!-- 启用/禁用: admin, 是账号管理动作 (同步 sys_user.status) -->
<el-button v-if="isAdmin" link :type="row.status === 'N' ? 'success' : 'danger'" @click="row.status === 'N' ? onEnable(row) : onDisable(row)">
{{ row.status === 'N' ? '恢复' : '禁用' }}
</el-button>
<!-- 已拒绝 (查意见): auditStatus=3 时显示, 两角色都可见 -->
<el-button v-if="row.auditStatus === '3'" link type="info" @click="onViewAuditOpinion(row)">已拒绝</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<!-- ========== 审核 dialog ========== -->
<el-dialog v-model="auditOpen" title="审核" width="520px">
<p style="margin-bottom:12px">专家: <b>{{ auditForm.name }}</b> ({{ auditForm.phone }})</p>
<el-form :model="auditForm" label-width="100px">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.result">
<el-radio value="2">通过</el-radio>
<el-radio value="3">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见">
<el-input v-model="auditForm.opinion" type="textarea" :rows="4" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditOpen=false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitAudit">确认</el-button>
</template>
</el-dialog>
<!-- ========== 批量导入专家 (沿用 manager 模式: v-if + onError + 模板下载在框外 + ImportResultDialog) ========== -->
<el-dialog v-model="importOpen" title="批量导入专家" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
v-if="importOpen"
ref="uploadRef"
:limit="1"
:headers="importHeaders"
accept=".xlsx, .xls"
:action="importAction"
:on-progress="onImportProgress"
:on-success="onImportSuccess"
:on-error="onImportError"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<!-- 模板下载: 必须放在 el-upload 框外(下面), 否则点击会被 drag 区域吞掉, 触发的是文件选择器 -->
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadImportTpl">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImportFile"> </el-button>
</template>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<!-- ========== 批量导入执业证书 ========== -->
<el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px">
<p style="color:#606266;margin-bottom:8px">提示: 需将证书文件名称修改为手机号进行导入</p>
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" />
<template #footer>
<el-button @click="importCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button>
</template>
</el-dialog>
<!-- ========== 批量导入职称证明 ========== -->
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px">
<p style="color:#606266;margin-bottom:8px">提示: 需将证明文件名称修改为手机号进行导入</p>
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" />
<template #footer>
<el-button @click="importTitleCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button>
</template>
</el-dialog>
<!-- ========== 证书图片预览 dialog ========== -->
<el-dialog v-model="previewOpen" title="证书预览" width="640px" :show-close="true" destroy-on-close>
<div class="cert-preview">
<el-image
:src="previewUrl"
fit="contain"
:preview-src-list="[previewUrl]"
style="width:100%;max-height:70vh"
loading="lazy"
>
<template #error>
<div class="cert-preview-error">图片加载失败, <a @click="onDownloadCert(previewUrl)">下载</a>查看</div>
</template>
</el-image>
</div>
<template #footer>
<el-button @click="onDownloadCert(previewUrl)">下载</el-button>
<el-button type="primary" @click="previewOpen=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { bizList, bizUpdate } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 角色判定: 用 route.path 前缀区分 admin/manager (不引入 role 字段, 路由本身就是角色来源)
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/experts' : '/manager/experts')
const currentUserName = computed(() => userStore.user?.userName || userStore.user?.nickName || (isAdmin.value ? 'admin' : 'manager'))
const q = reactive({ name: '', phone: '', workUnit: '', auditStatus: '', status: '' })
const rows = ref([])
const selection = ref([])
const loading = ref(false)
const submitting = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
function goNew() { router.push(listBasePath.value + '/new') }
function goEdit(row) { router.push(listBasePath.value + '/edit/' + row.expertId) }
function goView(row) { router.push(listBasePath.value + '/view/' + row.expertId) }
async function load() {
loading.value = true
try {
const { data } = await bizList('expert', { ...q, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = (data && data.rows) || []
page.total = (data && data.total) || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
Object.assign(q, { name: '', phone: '', workUnit: '', auditStatus: '', status: '' })
page.pageNum = 1
load()
}
function onSel(rs) { selection.value = rs }
function fmtTime(d) {
if (!d) return ''
const dt = new Date(d)
const pad = n => String(n).padStart(2, '0')
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
// ===== 审核 =====
const auditOpen = ref(false)
const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) {
auditForm.expertId = row.expertId
auditForm.name = row.name
auditForm.phone = row.phone
auditForm.result = '2'
auditForm.opinion = ''
auditOpen.value = true
}
async function submitAudit() {
submitting.value = true
try {
await bizUpdate('expert', {
expertId: auditForm.expertId,
auditStatus: auditForm.result,
auditOpinion: auditForm.opinion,
auditBy: currentUserName.value,
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
ElMessage.success(auditForm.result === '2' ? '审核通过' : '已拒绝')
auditOpen.value = false
load()
} catch { ElMessage.error('审核失败') }
finally { submitting.value = false }
}
// ===== 批量审核 =====
async function onBatchAudit() {
if (!selection.value.length) return ElMessage.warning('请先勾选行')
try {
await ElMessageBox.confirm(`确定批量审核选中的 ${selection.value.length} 位专家吗?`, '批量审核', { type: 'warning' })
} catch { return }
submitting.value = true
let ok = 0
for (const r of selection.value) {
try {
await bizUpdate('expert', {
expertId: r.expertId, auditStatus: '2', auditOpinion: '批量通过',
auditBy: currentUserName.value,
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
ok++
} catch {}
}
submitting.value = false
ElMessage.success(`批量审核通过 ${ok}/${selection.value.length} 条`)
selection.value = []
load()
}
// ===== 启用/禁用: bizUpdate 走 PUT /business/expert, status 是 updateByPrimaryKey 字段 =====
async function onDisable(row) {
try {
await bizUpdate('expert', { expertId: row.expertId, status: 'N' })
ElMessage.success('已禁用'); load()
} catch (e) { ElMessage.error(e?.msg || '禁用失败') }
}
async function onEnable(row) {
try {
await bizUpdate('expert', { expertId: row.expertId, status: 'Y' })
ElMessage.success('已恢复'); load()
} catch (e) { ElMessage.error(e?.msg || '恢复失败') }
}
// ===== 已拒绝: 查看拒绝意见 =====
function onViewAuditOpinion(row) {
ElMessageBox.alert(
`拒绝时间: ${fmtTime(row.auditTime) || '-'}\n审批人: ${row.auditBy || '-'}\n拒绝意见: ${row.auditOpinion || '(无)'}`,
`专家 ${row.name} 审核已拒绝`,
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
}
// ===== 证书查看/下载 =====
const previewOpen = ref(false)
const previewUrl = ref('')
function onPreviewCert(url) {
if (!url) return
previewUrl.value = url
previewOpen.value = true
}
function onDownloadCert(url, phone) {
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = `${phone || 'cert'}_${Date.now()}`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
}
// ===== 批量导入专家 (沿用 manager 最佳实践) =====
const importOpen = ref(false)
const importing = ref(false)
const importResultOpen = ref(false)
const importResult = ref(null)
const uploadRef = ref(null)
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action
const importAction = computed(() => '/dev-api/business/expert/importData?updateSupport=false')
function openImport() {
importResult.value = null
importOpen.value = true
}
// 模板下载: 3 行 fetch + blob (后端 /business/expert/importTemplate)
function downloadImportTpl() {
fetch('/dev-api/business/expert/importTemplate', { headers: importHeaders.value })
.then(r => r.blob())
.then(blob => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '专家批量导入模板.xlsx'
a.click()
})
.catch(() => ElMessage.error('模板下载失败'))
}
// 触发 el-upload 提交 (auto-upload=false 时手动调)
function submitImportFile() {
uploadRef.value?.submit()
}
function onImportProgress() { importing.value = true }
function onImportSuccess(res) {
importing.value = false
// 后端返回 {code,msg,data: ImportResult}; 兼容 res.data 兜底 (防拦截器 unwrap 缺失)
const result = (res && res.data) ? res.data : res
// 守卫: 非 ImportResult (后端异常但 HTTP 200, 或 401 重定向回包) 不开 dialog
if (!result || typeof result.okNum !== 'number') {
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
importResult.value = null
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
load()
}
function onImportError(err) {
importing.value = false
// el-upload 用原生 XHR, err.response 是后端返回的 JSON 字符串 (RuoYi 拦截器格式 {code,msg})
let msg = '上传失败, 请重试'
if (err && err.response != null) {
try {
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
msg = body.msg || msg
} catch { /* JSON 解析失败, 用兜底 */ }
} else if (err && err.message) {
msg = err.message
}
ElMessage.error(msg)
// 清残留, 防止上次成功 result 在空白 dialog 里显示
importResult.value = null
}
// ===== 批量导入执业证书 =====
const importCertOpen = ref(false)
const importCertUrl = ref('')
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true }
async function submitImportCert() {
if (!importCertUrl.value) return ElMessage.warning('请选择文件')
importing.value = true
await new Promise(r => setTimeout(r, 500))
importing.value = false
ElMessage.info('已提交批量导入执业证书任务 — 待后端实现')
importCertOpen.value = false
}
// ===== 批量导入职称证明 =====
const importTitleCertOpen = ref(false)
const importTitleCertUrl = ref('')
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true }
async function submitImportTitleCert() {
if (!importTitleCertUrl.value) return ElMessage.warning('请选择文件')
importing.value = true
await new Promise(r => setTimeout(r, 500))
importing.value = false
ElMessage.info('已提交批量导入职称证明任务 — 待后端实现')
importTitleCertOpen.value = false
}
onMounted(load)
// 从 ExpertNew 跳回来时刷新列表
watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load()
})
</script>
<style scoped>
.experts-list { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
.cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; }
.cert-preview-error { color: #909399; padding: 40px 0; }
</style>