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)
This commit is contained in:
郭庆泰
2026-08-20 21:02:56 +08:00
parent 3198c408b7
commit 1e86865a40
68 changed files with 3034 additions and 2617 deletions
+472
View File
@@ -0,0 +1,472 @@
<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>