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:
郭庆泰
2026-09-03 20:12:15 +08:00
parent beb9799a06
commit 56615f9806
55 changed files with 3296 additions and 516 deletions
+247 -85
View File
@@ -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>