feat: 专家执业证书/职称证明批量导入 (zip 按手机号/姓名匹配回填)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:07:26 +00:00
co-authored by Claude Code
parent fded62bedf
commit 58e4e0a9d0
5 changed files with 496 additions and 37 deletions
+38
View File
@@ -38,4 +38,42 @@ export function downloadExpertImportTpl() {
method: 'get',
responseType: 'blob'
})
}
/**
* 批量导入执业证书 (zip 压缩包或单文件): 后端异步处理, 返回 {jobId}.
* 文件名以手机号命名 (姓名唯一命中兜底), 后端匹配专家并回填 practice_cert_url.
* 上传成功后轮询 queryUploadProgress(jobId) 读进度与结果.
*/
export function importPracticeCert(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importPracticeCert',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 批量导入职称证明 (zip 压缩包或单文件): 同 importPracticeCert, 回填 title_cert_url.
*/
export function importTitleCert(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importTitleCert',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 查询批量上传任务进度 (后端异步处理, 前端轮询).
* @returns {Promise<{code,msg,data:{jobId,done,total,finished,error,message,result}}>}
*/
export function queryUploadProgress(jobId) {
return request({ url: `/business/upload/progress/${jobId}`, method: 'get' })
}
+159 -37
View File
@@ -179,22 +179,50 @@
<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 或选择文件" />
<el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px" append-to-body :close-on-click-modal="false">
<p class="cert-import-hint">上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg</p>
<el-upload
v-if="importCertOpen"
drag
:auto-upload="false"
:limit="1"
accept=".zip,.png,.jpg,.jpeg,.pdf,.webp"
:on-change="onPracticeCertChange"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将压缩包/文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="certSubmitting" class="cert-import-progress">
<el-progress :percentage="certProgress" :stroke-width="12" :show-text="false" />
<span class="cert-import-status">{{ certStatus || '上传中' }}</span>
</div>
<template #footer>
<el-button @click="importCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button>
<el-button type="primary" :loading="certSubmitting" @click="submitCertImport('practice')">确定</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 或选择文件" />
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px" append-to-body :close-on-click-modal="false">
<p class="cert-import-hint">上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg</p>
<el-upload
v-if="importTitleCertOpen"
drag
:auto-upload="false"
:limit="1"
accept=".zip,.png,.jpg,.jpeg,.pdf,.webp"
:on-change="onTitleCertChange"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将压缩包/文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="certSubmitting" class="cert-import-progress">
<el-progress :percentage="certProgress" :stroke-width="12" :show-text="false" />
<span class="cert-import-status">{{ certStatus || '上传中' }}</span>
</div>
<template #footer>
<el-button @click="importTitleCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button>
<el-button type="primary" :loading="certSubmitting" @click="submitCertImport('title')">确定</el-button>
</template>
</el-dialog>
@@ -204,7 +232,7 @@
<el-image
:src="previewUrl"
fit="contain"
:preview-src-list="[previewUrl]"
:preview-src-list="previewUrls"
style="width:100%;max-height:70vh"
loading="lazy"
>
@@ -222,11 +250,13 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import GrTable from '@/components/GrTable.vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { bizList, bizUpdate } from '@/api/public'
import request from '@/utils/request'
import { importPracticeCert, importTitleCert } from '@/api/business/expert'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue'
@@ -353,17 +383,25 @@ function onViewAuditOpinion(row) {
// ===== 证书查看/下载 =====
const previewOpen = ref(false)
const previewUrl = ref('')
const previewUrls = ref([])
function splitUrls(url) {
return (url || '').split(',').map(s => s.trim()).filter(Boolean)
}
function onPreviewCert(url) {
if (!url) return
previewUrl.value = url
const urls = splitUrls(url)
if (!urls.length) return
previewUrl.value = urls[0]
previewUrls.value = urls
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)
const urls = splitUrls(url)
urls.forEach((u, i) => {
const a = document.createElement('a')
a.href = u
a.download = urls.length > 1 ? `${phone || 'cert'}_${i + 1}_${Date.now()}` : `${phone || 'cert'}_${Date.now()}`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
})
}
// ===== 批量导入专家 (沿用 manager 最佳实践) =====
@@ -435,33 +473,112 @@ function onImportError(err) {
importResult.value = null
}
// ===== 批量导入执业证书 =====
// ===== 批量导入执业证书 / 职称证明 (zip 或单文件 → 后端异步回填, 前端轮询进度) =====
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 practiceCertFile = ref(null)
const titleCertFile = ref(null)
const certSubmitting = ref(false)
const certProgress = ref(0)
const certStatus = ref('')
let certPollTimer = null
function onPracticeCertChange(f) { practiceCertFile.value = (f && f.raw) || null }
function onTitleCertChange(f) { titleCertFile.value = (f && f.raw) || null }
function openImportPracticeCert() {
practiceCertFile.value = null
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
importCertOpen.value = true
}
function openImportTitleCert() {
titleCertFile.value = null
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
importTitleCertOpen.value = true
}
// ===== 批量导入职称证明 =====
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('已提交批量导入职称证明任务 — 待后端实现')
async function submitCertImport(type) {
const file = type === 'practice' ? practiceCertFile.value : titleCertFile.value
if (!file) return ElMessage.warning('请选择证书文件')
certSubmitting.value = true
certProgress.value = 0
certStatus.value = ''
let jobId = null
try {
const resp = type === 'practice' ? await importPracticeCert(file) : await importTitleCert(file)
jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
certSubmitting.value = false
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
certSubmitting.value = false
return
}
certProgress.value = 100
pollCertProgress(jobId, type)
}
function pollCertProgress(jobId, type) {
clearInterval(certPollTimer)
certPollTimer = setInterval(async () => {
let p = null
try {
const resp = await request.get(`/business/upload/progress/${jobId}`, { __silentError: true })
p = (resp && resp.data) || null
} catch (e) {
clearInterval(certPollTimer)
ElMessage.error(e?.msg || '查询进度失败')
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
return
}
if (!p) { certStatus.value = '服务器准备中…'; return }
if (p.finished) {
clearInterval(certPollTimer)
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
if (p.error) { ElMessage.error(p.message || '导入失败'); return }
showCertResult(type, p.result)
return
}
certStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
}, 800)
}
function showCertResult(type, result) {
const data = result || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
const label = type === 'practice' ? '执业证书' : '职称证明'
if (skipped.length) {
// 复用 ImportResultDialog 弹失败明细 (文件名 + 具体原因: 重名/手机号不存在/姓名不存在)
importResult.value = {
okNum: updated,
ngNum: skipped.length,
ngList: skipped.map((s, i) => ({ rowNum: i + 1, message: `${s.fileName || '未知文件'} —— ${s.reason || '未匹配'}` }))
}
importResultOpen.value = true
} else {
ElMessage.success(`${label}已回填 ${updated}/${total} 位专家`)
}
importCertOpen.value = false
importTitleCertOpen.value = false
load()
}
onMounted(load)
onUnmounted(() => { clearInterval(certPollTimer) })
// 从 ExpertNew 跳回来时刷新列表
watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load()
@@ -477,6 +594,11 @@ watch(() => route.fullPath, () => {
.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; }
.cert-import-hint { color: #606266; margin-bottom: 10px; font-size: 13px; line-height: 1.6; }
.cert-import-hint em { color: #e6a23c; font-style: normal; font-weight: 500; }
.cert-import-progress { margin-top: 12px; }
.cert-import-status { font-size: 13px; color: #909399; margin-top: 4px; display: inline-block; }
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄