feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强
OA ecology: - BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档 - ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配 - application-prod/test.yml: ecology 只读库数据源 会议邀请函/分享: - InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享 - PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页 参会人/执行意向: - BizMeetingAttendee*: 参会人模块增强 - BizExecutionIntent*: 执行意向增强 签到/劳务/导入: - BizSignServiceImpl + SignFill.vue: 签到劳务 - FieldMismatch/ImportMismatch: 导入校验 VO 公示/会议详情: - PublicityDetail.vue / MeetingDetail.vue 等前端增强 配置: - test 短信 mock + 供应商/招标接口切生产域名
This commit is contained in:
@@ -68,7 +68,7 @@
|
||||
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
|
||||
<el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button>
|
||||
<el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">邀请参会</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">发送邀请函</el-button>
|
||||
<el-button size="small" :disabled="!selectedAttendees.length" :loading="esignSending" @click="onBatchEsign">推送电子签</el-button>
|
||||
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
|
||||
</div>
|
||||
@@ -152,7 +152,7 @@
|
||||
</el-link>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">邀请参会</el-dropdown-item>
|
||||
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">发送邀请函</el-dropdown-item>
|
||||
<el-dropdown-item command="esign" :disabled="esignLoadingId === row.id">推送电子签</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" :disabled="!attendeeEditable" divided><span style="color: #f56c6c;">删除</span></el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -205,7 +205,7 @@
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议日程海报</div>
|
||||
</div>
|
||||
<div v-if="!isExecutor" class="file-row">
|
||||
<div class="file-row">
|
||||
<span class="file-label">邀请函:</span>
|
||||
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议邀请函</div>
|
||||
@@ -294,13 +294,13 @@
|
||||
<el-button v-if="canSubmitLabor && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR'])">提交劳务</el-button>
|
||||
<el-button v-if="canSubmitService && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['SERVICE'])">提交会务</el-button>
|
||||
<el-button v-if="canSubmitAll && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR', 'SERVICE'])">提交全部</el-button>
|
||||
<!-- 合规 / 监察审核 (材料, 分轨三按钮) -->
|
||||
<el-button v-if="isManager && !isReadonly && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
|
||||
<el-button v-if="isManager && !isReadonly && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
|
||||
<el-button v-if="isManager && !isReadonly && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
|
||||
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
|
||||
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
|
||||
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
|
||||
<!-- 合规 / 监察审核 (材料, 分轨三按钮); 只读 view 页同样可审 (去掉 !isReadonly 挡板) -->
|
||||
<el-button v-if="isManager && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
|
||||
<el-button v-if="isManager && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
|
||||
<el-button v-if="isManager && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
|
||||
<el-button v-if="supervisorAuditor && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
|
||||
<el-button v-if="supervisorAuditor && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
|
||||
<el-button v-if="supervisorAuditor && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
|
||||
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
|
||||
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
|
||||
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
|
||||
@@ -410,41 +410,41 @@
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
|
||||
<el-form-item label="姓名" prop="name" :error="mismatchError('name')">
|
||||
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="医院" prop="workUnit">
|
||||
<el-form-item label="医院" prop="workUnit" :error="mismatchError('workUnit')">
|
||||
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号" prop="idCard">
|
||||
<el-form-item label="身份证号" prop="idCard" :error="mismatchError('idCard')">
|
||||
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证附件" prop="idCardAttachments" style="grid-column: 1 / -1">
|
||||
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
|
||||
</el-form-item>
|
||||
<el-form-item label="科室" prop="department">
|
||||
<el-form-item label="科室" prop="department" :error="mismatchError('department')">
|
||||
<doctor-dept-select v-model="attendeeDialog.form.department" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职称" prop="title">
|
||||
<el-form-item label="职称" prop="title" :error="mismatchError('title')">
|
||||
<doctor-title-select v-model="attendeeDialog.form.title" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行" prop="bankName">
|
||||
<el-form-item label="银行" prop="bankName" :error="mismatchError('bankName')">
|
||||
<el-input v-model="attendeeDialog.form.bankName" placeholder="如: 中国工商银行" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行卡号码" prop="bankCard">
|
||||
<el-form-item label="银行卡号码" prop="bankCard" :error="mismatchError('bankCard')">
|
||||
<el-input v-model="attendeeDialog.form.bankCard" placeholder="持卡人银行卡号" maxlength="25" />
|
||||
</el-form-item>
|
||||
<!-- Col 2: 财务 + 备注 (8 字段) -->
|
||||
<el-form-item label="开户行" prop="bankBranch">
|
||||
<el-form-item label="开户行" prop="bankBranch" :error="mismatchError('bankBranch')">
|
||||
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账户名称" prop="accountName">
|
||||
<el-input v-model="attendeeDialog.form.accountName" placeholder="持卡人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户行地址" prop="bankRegion">
|
||||
<el-form-item label="开户行地址" prop="bankRegion" :error="mismatchError('bankRegion')">
|
||||
<el-input v-model="attendeeDialog.form.bankRegion" placeholder="如: 北京市东城区" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行详细地址" prop="bankAddress">
|
||||
<el-form-item label="银行详细地址" prop="bankAddress" :error="mismatchError('bankAddress')">
|
||||
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="laborForm" style="grid-column: 1 / -1">
|
||||
@@ -480,18 +480,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 参会人 Excel 批量导入 (沿用 dialog-import SKILL: v-if + on-error + 模板下载在框外 + ImportResultDialog) -->
|
||||
<!-- 参会人 Excel 批量导入 (两段式: dry-run 比对 → 二次确认 → force 入库; 模板下载在框外 + ImportResultDialog) -->
|
||||
<el-dialog v-model="importOpen" title="批量导入参会人" width="400px" append-to-body :close-on-click-modal="false">
|
||||
<el-upload
|
||||
v-if="importOpen"
|
||||
ref="importUploadRef"
|
||||
:limit="1"
|
||||
:headers="importHeaders"
|
||||
accept=".xlsx, .xls"
|
||||
:action="importAction"
|
||||
:on-progress="onImportProgress"
|
||||
:on-success="onImportSuccess"
|
||||
:on-error="onImportError"
|
||||
:on-change="onImportFileChange"
|
||||
:on-remove="onImportFileRemove"
|
||||
:auto-upload="false"
|
||||
drag
|
||||
>
|
||||
@@ -532,6 +529,23 @@
|
||||
<div v-else class="text-muted">尚未生成海报</div>
|
||||
</el-dialog>
|
||||
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
|
||||
|
||||
<!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) -->
|
||||
<el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false">
|
||||
<div class="mismatch-intro">{{ mismatchDialog.intro }}</div>
|
||||
<div v-for="(entry, ei) in mismatchDialog.entries" :key="ei" class="mismatch-entry">
|
||||
<div class="mismatch-entry-title">{{ entry.title }}</div>
|
||||
<el-table :data="entry.fields" size="small" border>
|
||||
<el-table-column prop="label" label="字段" width="110" />
|
||||
<el-table-column prop="expertValue" label="专家档案" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="inputValue" label="您输入" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="mismatchDialog.show = false">{{ mismatchDialog.cancelText }}</el-button>
|
||||
<el-button type="primary" @click="confirmMismatch">{{ mismatchDialog.confirmText }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
|
||||
|
||||
<div class="form-actions">
|
||||
@@ -595,7 +609,7 @@ const currentRole = computed(() => userStore.role)
|
||||
const isManager = computed(() => currentRole.value === 'manager')
|
||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||
const isSponsor = computed(() => currentRole.value === 'sponsor')
|
||||
/** 只读模式: 从会议列表「查看」跳入 (route.meta.readonly), 仅展示内容 + 返回, 无任何操作按钮 */
|
||||
/** 只读模式: 从会议列表「查看」/「审核」跳入 (route.meta.readonly), 仅展示内容 + 返回, 除审核/监察按钮外无其他操作按钮 */
|
||||
const isReadonly = computed(() => route.meta.readonly === true)
|
||||
/** 合规/管理员 可随时上传付款凭证 (结算前必须先已保存); 其他角色只读查看 */
|
||||
const canUploadVoucher = computed(() => (isManager.value || isAdmin.value) && !isReadonly.value)
|
||||
@@ -882,6 +896,16 @@ async function loadMaterials() {
|
||||
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
|
||||
}
|
||||
|
||||
/** 只刷新 materialsLoaded 金额/fee_status (供 refreshFee 轮询用).
|
||||
* 与 loadMaterials 区别: 不重写各行 r.url/r.fileName, 避免轮询时覆盖「已上传未保存」的本地文件. */
|
||||
async function refreshMaterialAmounts() {
|
||||
try {
|
||||
const resp = await request.get(`/business/meetingMaterial/${meetingId.value}`, { __silentError: true })
|
||||
const list = (resp && (resp.data || resp)) || []
|
||||
materialsLoaded.value = Array.isArray(list) ? list : []
|
||||
} catch (e) { console.error('[meeting-detail] refreshMaterialAmounts failed', e) }
|
||||
}
|
||||
|
||||
// ===================== 未保存改动检测 (结算前拦截用) =====================
|
||||
/** 已落库快照 (load 后 + 保存后各刷新一次): 4 组上传行 url + 海报/邀请函 */
|
||||
const savedSnapshot = ref(null)
|
||||
@@ -990,7 +1014,10 @@ const attendeeRules = {
|
||||
phone: [{ required: true, message: '请填写联系方式', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
|
||||
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
|
||||
idCard: [{ required: true, message: '请填写身份证号', trigger: 'blur' }],
|
||||
idCard: [
|
||||
{ required: true, message: '请填写身份证号', trigger: 'blur' },
|
||||
{ pattern: /^\d{17}[\dXx]$/, message: '身份证号应为18位(末位可为X)', trigger: 'blur' }
|
||||
],
|
||||
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
|
||||
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
|
||||
title: [{ required: true, message: '请选择职称', trigger: 'change' }],
|
||||
@@ -1007,6 +1034,42 @@ const attendeeRules = {
|
||||
fee: [{ required: true, message: '请填写实发金额', trigger: 'change' }]
|
||||
}
|
||||
|
||||
/** 参会人表单字段与专家档案不一致标记 (field → {expertValue, inputValue}), 用于输入框标红 */
|
||||
const mismatchMap = reactive({})
|
||||
function mismatchError(field) {
|
||||
const m = mismatchMap[field]
|
||||
return m ? `专家档案为「${m.expertValue || '空'}」` : ''
|
||||
}
|
||||
function clearMismatchMap() {
|
||||
Object.keys(mismatchMap).forEach(k => delete mismatchMap[k])
|
||||
}
|
||||
|
||||
/** 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) */
|
||||
const mismatchDialog = reactive({
|
||||
show: false,
|
||||
title: '检测到与专家档案不一致',
|
||||
intro: '',
|
||||
entries: [],
|
||||
confirmText: '确认提交',
|
||||
cancelText: '返回修改',
|
||||
onConfirm: null
|
||||
})
|
||||
function openMismatchDialog(entries, opts = {}) {
|
||||
mismatchDialog.entries = entries || []
|
||||
mismatchDialog.title = opts.title || '检测到与专家档案不一致'
|
||||
mismatchDialog.intro = opts.intro || '以下字段与专家档案不一致,请确认是否仍按当前输入提交。'
|
||||
mismatchDialog.confirmText = opts.confirmText || '确认提交'
|
||||
mismatchDialog.cancelText = opts.cancelText || '返回修改'
|
||||
mismatchDialog.onConfirm = opts.onConfirm || null
|
||||
mismatchDialog.show = true
|
||||
}
|
||||
function confirmMismatch() {
|
||||
const fn = mismatchDialog.onConfirm
|
||||
mismatchDialog.show = false
|
||||
mismatchDialog.onConfirm = null
|
||||
if (fn) fn()
|
||||
}
|
||||
|
||||
function resetAttendeeForm() {
|
||||
attendeeDialog.value = {
|
||||
show: false, title: '', editing: false,
|
||||
@@ -1014,6 +1077,7 @@ function resetAttendeeForm() {
|
||||
}
|
||||
// 清掉上次的校验错误 (防止关掉后重开还残留"实发金额不能超过..."红字)
|
||||
attendeeFormRef.value?.clearValidate()
|
||||
clearMismatchMap()
|
||||
}
|
||||
|
||||
/** 参会人表单 ref (el-form validate 用) */
|
||||
@@ -1238,6 +1302,7 @@ function onBatchInvite() {
|
||||
/** 打开新增/编辑 dialog; row=null → 新增 */
|
||||
function openAttendeeDialog(row) {
|
||||
suppressFeeLink = true
|
||||
clearMismatchMap()
|
||||
if (row) {
|
||||
attendeeDialog.value = {
|
||||
show: true,
|
||||
@@ -1320,23 +1385,9 @@ async function lookupExpertByPhone() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
|
||||
async function confirmAttendee() {
|
||||
/** 真正落库 (新增 → POST; 编辑 → PUT), 二次确认通过后调用 */
|
||||
async function doSubmitAttendee() {
|
||||
const { editing, form } = attendeeDialog.value
|
||||
if (!form.phone || !form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.phone.trim().match(/^1\d{10}$/)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
// 校验实发金额不超所选角色劳务金额合计 (未选角色时跳过)
|
||||
try {
|
||||
await attendeeFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
attendeeDialog.value.saving = true
|
||||
try {
|
||||
if (editing) {
|
||||
@@ -1355,6 +1406,44 @@ async function confirmAttendee() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交 dialog: 校验 → 预检比对专家表 → 无差异直接提交 / 有差异标红 + 二次确认 */
|
||||
async function confirmAttendee() {
|
||||
const { form } = attendeeDialog.value
|
||||
if (!form.phone || !form.phone.trim()) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!form.phone.trim().match(/^1\d{10}$/)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await attendeeFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await request.post('/business/meetingAttendee/diff', form, { __silentError: true })
|
||||
const mismatches = Array.isArray(resp && resp.data) ? resp.data : []
|
||||
if (!mismatches.length) {
|
||||
doSubmitAttendee()
|
||||
return
|
||||
}
|
||||
// 标红 + 二次确认
|
||||
clearMismatchMap()
|
||||
mismatches.forEach(m => { if (m && m.field) mismatchMap[m.field] = m })
|
||||
openMismatchDialog(
|
||||
[{
|
||||
title: `${form.name || ''} ${form.phone || ''}`.trim() || '参会人',
|
||||
fields: mismatches.map(f => ({ label: f.label, expertValue: f.expertValue, inputValue: f.inputValue }))
|
||||
}],
|
||||
{ confirmText: '确认提交', cancelText: '返回修改', onConfirm: doSubmitAttendee }
|
||||
)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '校验失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除参会人 (单条确认) */
|
||||
async function confirmDeleteAttendee(row) {
|
||||
try {
|
||||
@@ -1376,20 +1465,21 @@ function onAttendeeCommand(cmd, row) {
|
||||
else if (cmd === 'delete') confirmDeleteAttendee(row)
|
||||
}
|
||||
|
||||
// ===================== 参会人 Excel 批量导入 (沿用 dialog-import SKILL) =====================
|
||||
// ===================== 参会人 Excel 批量导入 (两段式: dry-run 比对专家表 → 二次确认 → force 入库) =====================
|
||||
const importOpen = ref(false)
|
||||
const importing = ref(false)
|
||||
const importResultOpen = ref(false)
|
||||
const importResult = ref(null)
|
||||
const exporting = ref(false)
|
||||
const importUploadRef = ref(null)
|
||||
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
|
||||
// 下载模板用 (fetch 流式下载, 不走 axios, 需显式带 Authorization)
|
||||
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
|
||||
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action; meetingId 走 query 串
|
||||
const importAction = computed(() => `${import.meta.env.VITE_APP_BASE_API}/business/meetingAttendee/importData?meetingId=${meetingId.value}`)
|
||||
/** 待导入的 Excel 文件 (on-change 捕获, 同一文件 dry-run + force 各发一次) */
|
||||
const importFile = ref(null)
|
||||
|
||||
function openAttendeeImport() {
|
||||
importResult.value = null
|
||||
importFile.value = null
|
||||
importOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1472,8 +1562,17 @@ async function onAgreementZipChange(ev) {
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 位参会人的劳务协议`)
|
||||
const data = (resp && resp.data) || {}
|
||||
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)
|
||||
if (skipped.length) {
|
||||
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
|
||||
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
|
||||
} else {
|
||||
ElMessage.success(`已回填 ${updated}/${total} 位参会人的劳务协议`)
|
||||
}
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
@@ -1526,8 +1625,17 @@ async function onExpertPhotoZipChange(ev) {
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const n = (resp && resp.data != null) ? resp.data : 0
|
||||
ElMessage.success(`已回填 ${n} 位参会人的专家照片`)
|
||||
const data = (resp && resp.data) || {}
|
||||
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)
|
||||
if (skipped.length) {
|
||||
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
|
||||
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
|
||||
} else {
|
||||
ElMessage.success(`已回填 ${updated}/${total} 位参会人的专家照片`)
|
||||
}
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '上传失败')
|
||||
@@ -1536,40 +1644,74 @@ async function onExpertPhotoZipChange(ev) {
|
||||
}
|
||||
}
|
||||
|
||||
function submitImportFile() {
|
||||
importUploadRef.value?.submit()
|
||||
function onImportFileChange(uploadFile) {
|
||||
importFile.value = uploadFile.raw || null
|
||||
}
|
||||
function onImportFileRemove() {
|
||||
importFile.value = null
|
||||
}
|
||||
|
||||
function onImportProgress() { importing.value = true }
|
||||
|
||||
function onImportSuccess(res) {
|
||||
importing.value = false
|
||||
const result = (res && res.data) ? res.data : res
|
||||
if (!result || typeof result.okNum !== 'number') {
|
||||
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
|
||||
importResult.value = null
|
||||
/** 确定导入: 先 dry-run 比对专家表, 有冲突弹二次确认, 确认后 force 入库 */
|
||||
async function submitImportFile() {
|
||||
const file = importFile.value
|
||||
if (!file) {
|
||||
ElMessage.warning('请先选择要导入的文件')
|
||||
return
|
||||
}
|
||||
importResult.value = result
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
// 刷新列表 (新参会人会出现)
|
||||
loadAttendees()
|
||||
importing.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// 1. dry-run (缺省 force=false): 只解析 + 比对专家表, 不落库
|
||||
const resp = await request.post(
|
||||
`/business/meetingAttendee/importData?meetingId=${meetingId.value}`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const data = (resp && resp.data) || {}
|
||||
const mismatches = Array.isArray(data.mismatches) ? data.mismatches : []
|
||||
if (!mismatches.length) {
|
||||
await doForceImport(file)
|
||||
return
|
||||
}
|
||||
// 2. 有冲突 → 二次确认
|
||||
openMismatchDialog(
|
||||
mismatches.map(m => ({
|
||||
title: `第${m.rowNo}行 ${m.name || ''} ${m.phone || ''}`.trim(),
|
||||
fields: (m.fields || []).map(f => ({ label: f.label, expertValue: f.expertValue, inputValue: f.inputValue }))
|
||||
})),
|
||||
{ confirmText: '确认导入', cancelText: '取消', onConfirm: () => doForceImport(file) }
|
||||
)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onImportError(err) {
|
||||
importing.value = false
|
||||
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
|
||||
/** force=true 真正入库, 成功展示 ImportResultDialog */
|
||||
async function doForceImport(file) {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const resp = await request.post(
|
||||
`/business/meetingAttendee/importData?meetingId=${meetingId.value}&force=true`,
|
||||
fd,
|
||||
{ timeout: 120000, __silentError: true }
|
||||
)
|
||||
const result = (resp && resp.data) ? resp.data : null
|
||||
if (!result || typeof result.okNum !== 'number') {
|
||||
ElMessage.error(resp?.msg || '导入失败, 返回数据异常')
|
||||
importResult.value = null
|
||||
return
|
||||
}
|
||||
importResult.value = result
|
||||
importOpen.value = false
|
||||
importResultOpen.value = true
|
||||
loadAttendees()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.msg || e?.message || '导入失败')
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
importResult.value = null
|
||||
}
|
||||
async function loadStaff() {
|
||||
try {
|
||||
@@ -1598,6 +1740,11 @@ async function load() {
|
||||
invitationUrl.value = row.value.invitationUrl || ''
|
||||
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles(), loadInvoices()])
|
||||
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
|
||||
// 从列表跳入带 ?tab=xxx: 结算→laborVoucher, 审核→labor/service; 不传则保持默认 (劳务材料)
|
||||
const targetTab = route.query.tab
|
||||
if (['labor', 'service', 'laborVoucher'].includes(targetTab)) {
|
||||
activeTab.value = targetTab
|
||||
}
|
||||
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
|
||||
if (route.query.settle === '1' && canSettle.value) {
|
||||
router.replace(route.path)
|
||||
@@ -1686,8 +1833,10 @@ async function saveMaterials() {
|
||||
submitOcrForMaterials(toOcr)
|
||||
}
|
||||
|
||||
// 5. 保存即刷新发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id,
|
||||
// 移除材料后立即可见减少 (doSave 走 refreshFee, onSubmitMaterials 走这里)
|
||||
// 5. 保存即刷新材料 + 发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id.
|
||||
// 会务费合计(meetingFee)与单行金额(materialAmount)都读 materialsLoaded, 移除材料时 toOcr=0,
|
||||
// 若不重拉 loadMaterials 会残留旧金额/旧行 → 这里无条件重拉 (doSave 走 refreshFee, onSubmitMaterials 走这里)
|
||||
loadMaterials()
|
||||
loadInvoices()
|
||||
|
||||
// 6. 1.5s 后重拉 materials + 发票表, 让 OCR 回写的 amount/发票号 更新可见
|
||||
@@ -1713,7 +1862,7 @@ async function saveVouchers() {
|
||||
async function saveSchedulePoster() {
|
||||
const cur = row.value.scheduleUrl || ''
|
||||
if (scheduleUrl.value === cur) return
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), scheduleUrl: scheduleUrl.value }, { __silentError: true })
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, scheduleUrl: scheduleUrl.value }, { __silentError: true })
|
||||
row.value.scheduleUrl = scheduleUrl.value
|
||||
}
|
||||
|
||||
@@ -1721,7 +1870,7 @@ async function saveSchedulePoster() {
|
||||
async function saveInvitation() {
|
||||
const cur = row.value.invitationUrl || ''
|
||||
if (invitationUrl.value === cur) return
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), invitationUrl: invitationUrl.value }, { __silentError: true })
|
||||
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, invitationUrl: invitationUrl.value }, { __silentError: true })
|
||||
row.value.invitationUrl = invitationUrl.value
|
||||
}
|
||||
|
||||
@@ -2045,7 +2194,12 @@ async function refreshFee() {
|
||||
row.value = { ...row.value, laborFee: m.laborFee, meetingFee: m.meetingFee, totalFee: m.totalFee, feeCalcStatus: m.feeCalcStatus }
|
||||
} catch (e) { /* 拉取失败忽略, 下轮再试 */ }
|
||||
loadInvoices()
|
||||
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
|
||||
// 会务费合计(tab)读 materialsLoaded.amount — OCR 回写金额后必须刷新, 否则 zip 上传后金额不更新
|
||||
await refreshMaterialAmounts()
|
||||
// 继续轮询条件: 费用未汇总完(feeCalcStatus=0) 或 仍有材料待 OCR(feeStatus=0).
|
||||
// 后者覆盖「已算过的会议重传 zip」场景: 上传瞬间 feeCalcStatus 仍是 1, 但材料 feeStatus=0 在等 OCR.
|
||||
const ocrPending = (materialsLoaded.value || []).some(m => m.feeStatus === 0)
|
||||
if (row.value.feeCalcStatus === 0 || ocrPending) scheduleFeePoll()
|
||||
else stopFeePolling()
|
||||
}
|
||||
function scheduleFeePoll() {
|
||||
@@ -2438,3 +2592,11 @@ onBeforeUnmount(stopFeePolling)
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 专家档案不一致 二次确认 dialog (append-to-body 脱离 scoped, 用全局样式) */
|
||||
.mismatch-intro { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.mismatch-entry { margin-bottom: 16px; }
|
||||
.mismatch-entry:last-child { margin-bottom: 0; }
|
||||
.mismatch-entry-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user