feat: 会议提交剩余时间+解冻 + 会务/劳务材料批量下载 + 多角色人员/账号模块完善

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-25 06:53:18 +08:00
co-authored by Claude
parent 0c882ae846
commit 591a43fe61
126 changed files with 3789 additions and 1334 deletions
+289 -190
View File
@@ -8,8 +8,6 @@
<!-- 页标题 -->
<div class="page-title">
<span>会议详情</span>
<!-- admin / manager 角色专用: 删除会议 (软删, 后端强校验) -->
<el-button v-if="canDeleteMeeting" type="danger" plain size="small" class="admin-delete-btn" :loading="deleting" @click="onAdminDelete">删除会议</el-button>
</div>
<div class="cols-row">
@@ -29,30 +27,31 @@
<div class="info-row"><span class="info-label">会议结束时间:</span><span class="info-value">{{ fmtDateTime(row.endTime) }}</span></div>
<div class="info-row"><span class="info-label">材料审核状态:</span><span class="info-value">{{ fmtAuditStage(row.materialAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建时间:</span><span class="info-value">{{ fmtDateTime(row.createTime) }}</span></div>
<div class="info-row"><span class="info-label">凭证审核状态:</span><span class="info-value">{{ fmtAuditStage(row.voucherAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建人员:</span><span class="info-value">{{ row.createBy || '-' }}</span></div>
<div class="info-row"><span class="info-label">劳务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.laborFee) }}</span></div>
<div class="info-row"><span class="info-label">会务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.meetingFee) }}</span></div>
<div class="info-row"><span class="info-label">总费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.totalFee) }}</span>
<el-tag v-if="row.feeCalcStatus === 0" type="info" size="small" effect="plain">统计中</el-tag>
</div>
<!-- 监察员 + 执行人员 (各占整行, 2 ) -->
<!-- 监察员 + 执行人员 (各占整行, 2 ). 临时: manager 详情页隐掉 -->
<template v-if="!isManager">
<div class="info-row info-row-full">
<span class="info-label">监察员:</span>
<div class="tag-list">
<el-tag v-for="u in supervisors" :key="u.userId" type="info" size="small">{{ u.userName }}</el-tag>
<el-tag v-for="u in supervisors" :key="u.sponsorOrgId" type="info" size="small">{{ u.orgName }}</el-tag>
<span v-if="!supervisors.length" class="text-muted">- 未分配 -</span>
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
<el-button v-if="isManager && !isReadonly" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
</div>
</div>
<div class="info-row info-row-full">
<span class="info-label">执行人员:</span>
<div class="tag-list">
<el-tag v-for="u in executors" :key="u.userId" type="success" size="small">{{ u.userName }}</el-tag>
<el-tag v-for="u in executors" :key="u.executorOrgId" type="success" size="small">{{ u.orgName }}</el-tag>
<span v-if="!executors.length" class="text-muted">- 未分配 -</span>
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
<el-button v-if="isManager && !isReadonly" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
</div>
</div>
</template>
</div>
</div>
@@ -63,13 +62,12 @@
<el-tab-pane label="劳务材料" name="labor">
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) -->
<div class="attendee-section">
<div v-if="!isSponsor" class="attendee-toolbar">
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" @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="esignSending" @click="onBatchEsign">推送电子签</el-button>
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
<span class="toolbar-hint">勾选参会人后点"邀请参会"发站内信 / "推送电子签"发短信+站内信; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
</div>
<!--
参会人表有 13 , min-width 累加 ~1680px, 直接 el-table width:100% 会撑爆 left-col
@@ -79,7 +77,8 @@
-->
<div class="attendee-table-wrap">
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange">
<el-table-column v-if="!isSponsor" type="selection" width="42" />
<el-table-column v-if="!isSponsor && !isReadonly" type="selection" width="42" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column prop="name" label="医生" min-width="90" />
<el-table-column label="联系电话" width="120">
<template #default="{ row }">{{ isSponsor ? maskPhone(row.phone) : row.phone }}</template>
@@ -148,7 +147,7 @@
<el-tag v-else type="info" size="small">未推送</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!isSponsor" label="操作" width="100" align="center" fixed="right">
<el-table-column v-if="!isSponsor && !isReadonly" label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
@@ -175,19 +174,45 @@
</div>
<div class="file-list" style="margin-top: 16px;">
<!-- 劳务协议: 改为"按参会人逐个回填"的收发闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly" class="file-row">
<span class="file-label">劳务协议:</span>
<div class="agreement-actions">
<el-button size="small" :loading="agreementTplLoading" @click="onDownloadAgreementTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="agreementSubmitting" @click="onPickAgreementZip">上传协议</el-button>
<input ref="agreementZipInput" type="file" accept=".zip" style="display:none" @change="onAgreementZipChange" />
</div>
<div class="file-hint">下载空目录模板 (劳务协议/序号_姓名/), 放入各参会人协议后压缩上传回填</div>
</div>
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
<!-- 专家照片: 与劳务协议同构的"下载目录 + 回填"闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly" class="file-row">
<span class="file-label">专家照片:</span>
<div class="agreement-actions">
<el-button size="small" :loading="expertPhotoTplLoading" @click="onDownloadExpertPhotoTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="expertPhotoSubmitting" @click="onPickExpertPhotoZip">上传照片</el-button>
<input ref="expertPhotoZipInput" type="file" accept=".zip" style="display:none" @change="onExpertPhotoZipChange" />
</div>
<div class="file-hint">下载空目录模板 (专家照片/序号_姓名/), 放入各参会人照片后压缩上传回填</div>
</div>
<div class="file-row">
<span class="file-label">日程海报:</span>
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor" />
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
<div class="file-hint">会议日程海报</div>
</div>
<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>
</div>
<!-- 临时: manager 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isReadonly" class="file-row">
<span class="file-label">海报生成:</span>
<div class="poster-actions">
<el-button v-if="!isSponsor" size="small" type="primary" :loading="genPosterLoading" @click="onGeneratePoster">生成海报</el-button>
@@ -200,7 +225,7 @@
<div class="file-list">
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
@@ -209,51 +234,53 @@
<span class="fee-value">¥ {{ meetingFee.total.toFixed(2) }}</span>
<el-tag v-if="meetingFee.useMain" type="warning" size="small" effect="plain">已上传总发票, 以总发票为准</el-tag>
</div>
<!-- 打包上传: 下载空目录模板 + 上传会务材料 (单目录多文件自动打 zip) -->
<div v-if="!isSponsor && !isReadonly" class="file-row">
<span class="file-label">打包上传:</span>
<div class="agreement-actions">
<el-button size="small" :loading="serviceTplLoading" @click="onDownloadServiceTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="serviceSubmitting" @click="onPickServiceZip">上传会务材料</el-button>
<input ref="serviceZipInput" type="file" accept=".zip" style="display:none" @change="onServiceZipChange" />
</div>
<div class="file-hint">下载空目录模板 (会务材料/物料制作·酒店·大交通·小交通·执行费·设计费·其他·总结算单·总发票/), 放入材料文件后压缩上传回填; 单个目录多文件会自动打包为一个 zip</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="劳务凭证" name="laborVoucher">
<el-tab-pane v-if="canSeeVouchers" label="劳务凭证" name="laborVoucher">
<div class="file-list">
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="会务凭证" name="serviceVoucher">
<el-tab-pane v-if="canSeeVouchers" label="会务凭证" name="serviceVoucher">
<div class="file-list">
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="tab-actions">
<el-button v-if="!isSponsor" @click="onPackUpload">打包上传</el-button>
<a class="file-link inline-link" href="javascript:void(0)" @click="onDownloadTemplate">会务材料文件夹模板下载</a>
<span style="flex:1"></span>
<!-- 执行人员: 提交材料 / 提交凭证 -->
<el-button v-if="canSubmitMaterial" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
<el-button v-if="canSubmitVoucher" type="warning" :loading="busy.submitVoucher" @click="onSubmitVoucher">提交凭证</el-button>
<!-- 合规 / 监察审核 -->
<el-button v-if="canComplianceAudit('MATERIAL')" type="success" @click="openAuditDialog('COMPLIANCE','MATERIAL')">合规审核 (材料)</el-button>
<el-button v-if="canComplianceAudit('VOUCHER')" type="success" @click="openAuditDialog('COMPLIANCE','VOUCHER')">合规审核 (凭证)</el-button>
<el-button v-if="canSupervisionAudit('MATERIAL')" type="primary" @click="openAuditDialog('SUPERVISION','MATERIAL')">监察审核 (材料)</el-button>
<el-button v-if="canSupervisionAudit('VOUCHER')" type="primary" @click="openAuditDialog('SUPERVISION','VOUCHER')">监察审核 (凭证)</el-button>
<!-- 执行人员: 提交材料 -->
<el-button v-if="canSubmitMaterial && !isReadonly" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
<!-- 合规 / 监察审核 (材料) -->
<el-button v-if="canComplianceAudit() && !isReadonly" type="success" @click="openAuditDialog('COMPLIANCE')">合规审核 (材料)</el-button>
<el-button v-if="canSupervisionAudit() && !isReadonly" type="primary" @click="openAuditDialog('SUPERVISION')">监察审核 (材料)</el-button>
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<el-button v-if="canSettle" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
<el-button v-if="canFinish" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
<el-button v-if="!isSponsor" type="primary" :loading="saving" @click="onSave">保存</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>
<el-button v-if="!isSponsor && !isReadonly" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
<p class="hint-text">*结算后, 已完结状态, 执行方不能再进行编辑</p>
</div>
</div>
<!-- 右列: 材料/凭证 双时间轴 (时间轴 + 审核轨迹合并) -->
<!-- 右列: 材料审核时间轴 -->
<div class="audit-columns">
<div class="card audit-column">
<div class="section-title">材料审核</div>
@@ -312,68 +339,13 @@
</div>
</div>
</div>
<div class="card audit-column">
<div class="section-title">凭证审核</div>
<div class="timeline">
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<template v-for="(round, idx) in displayVoucherRounds" :key="`vch-r${idx}`">
<div :class="['timeline-item', partStatus(round.submit)]">
<div class="timeline-title">执行方提交凭证</div>
<div v-if="round.submit" class="timeline-meta">
<span>{{ round.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.submit.auditTime) }}</span>
</div>
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
</div>
<div :class="['timeline-item', partStatus(round.compliance)]">
<div class="timeline-title">合规审核</div>
<div v-if="round.compliance" class="timeline-meta">
<span>{{ round.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span>
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.supervision.auditTime) }}</span>
<el-tag size="small" :type="round.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待监察审核</div>
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
</div>
</template>
<div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
</div>
<div :class="['timeline-item', fixedNodeStatus('DONE')]">
<div class="timeline-title">会议完结</div>
<div class="timeline-desc">{{ nodeDesc('DONE') }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分配 dialog -->
<el-dialog v-model="assignDialog.show" :title="assignDialog.title" width="500px">
<el-select v-model="assignDialog.selectedIds" multiple filterable :placeholder="`选择${assignDialog.roleLabel}`" style="width:100%" :loading="assignDialog.loading">
<el-option v-for="u in assignDialog.candidates" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
<el-option v-for="u in assignDialog.candidates" :key="u.orgId" :label="u.orgName" :value="u.orgId" />
</el-select>
<template #footer>
<el-button @click="assignDialog.show = false">取消</el-button>
@@ -381,15 +353,9 @@
</template>
</el-dialog>
<!-- 审核 dialog (合规/监察通用, 支持材料+凭证多选) -->
<!-- 审核 dialog (合规/监察通用, 材料审核) -->
<el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px">
<el-form label-width="80px">
<el-form-item label="审核类型">
<el-checkbox-group v-model="auditDialog.types">
<el-checkbox label="MATERIAL">材料</el-checkbox>
<el-checkbox label="VOUCHER">凭证</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="意见">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" />
</el-form-item>
@@ -543,7 +509,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch, nextTick }
import { useRoute, useRouter } from 'vue-router'
import request from '@/utils/request'
import { bizGet } from '@/api/public'
import { listSupporters, listExecutor } from '@/api/system'
import { listSponsorOrgs, listExecutorOrgs } from '@/api/system'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import OssFileUploader from '@/components/OssFileUploader.vue'
@@ -567,6 +533,8 @@ const meetingId = ref('')
const row = ref({})
/** 日程海报 URL (暂存本地, 点"保存"随材料一起落库到 biz_meeting.schedule_url) */
const scheduleUrl = ref('')
/** 邀请函 URL (暂存本地, 点"保存"随材料一起落库到 biz_meeting.invitation_url) */
const invitationUrl = ref('')
/** 生成海报 loading */
const genPosterLoading = ref(false)
/** 生成海报配置 dialog 显隐 */
@@ -581,16 +549,18 @@ const saving = ref(false)
const supervisors = ref([])
const executors = ref([])
const auditTrail = ref([])
const busy = ref({ submitMaterial: false, submitVoucher: false, settle: false, finish: false })
const busy = ref({ submitMaterial: false, settle: false, finish: false })
const currentUserId = computed(() => userStore.user?.userId)
const currentRole = computed(() => userStore.role)
const isManager = computed(() => currentRole.value === 'manager')
const isAdmin = computed(() => currentRole.value === 'admin')
const isSponsor = computed(() => currentRole.value === 'sponsor')
/** 软删除会议: admin / manager 可调 (后端 BizMeetingController.DELETE 强校验) */
const canDeleteMeeting = computed(() => isAdmin.value || isManager.value)
const deleting = ref(false)
/** 只读模式: 从会议列表「查看」跳入 (route.meta.readonly), 仅展示内容 + 返回, 无任何操作按钮 */
const isReadonly = computed(() => route.meta.readonly === true)
/** 合规/管理员 可随时上传付款凭证 (结算前必须先已保存); 其他角色只读查看 */
const canUploadVoucher = computed(() => (isManager.value || isAdmin.value) && !isReadonly.value)
/** 凭证 tab 可见性: 合规/管理员随时可见 (需上传); 执行方/支持方等结算后 (is_settled=1) 才可见 */
const canSeeVouchers = computed(() => (isManager.value || isAdmin.value) || isOne(row.value.isSettled))
// ===================== 工具 =====================
function pad(n) { return String(n).padStart(2, '0') }
@@ -649,19 +619,16 @@ const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '设计稿, PPT' },
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '总发票单独推送至OA, 单独下载' },
{ type: 'LABOR', subType: 'L_DETAIL', label: '劳务明细表', hint: '劳务明细表下载' },
{ type: 'LABOR', subType: 'L_AGREEMENT', label: '劳务协议', hint: '劳务协议模板' },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' },
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' },
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
{ type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' },
{ type: 'LABOR', subType: 'L_PANORAMA_BACK', label: '后全景', hint: '会议现场后全景 (不带定位框)' },
{ type: 'LABOR', subType: 'L_EXPERT_PHOTO', label: '专家照片', hint: '专家现场照片' },
{ type: 'LABOR_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
]
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK', 'L_EXPERT_PHOTO'])
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
/** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */
const CAMERA_SUBTYPES = new Set(['L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
function isCameraSubType(subType) { return CAMERA_SUBTYPES.has(subType) }
@@ -700,15 +667,11 @@ function parseRounds(auditType) {
}
const materialRounds = computed(() => parseRounds('MATERIAL'))
const voucherRounds = computed(() => parseRounds('VOUCHER'))
/** 至少保证 1 轮 (空轮 = 三个节点都待提交), 保证 2/3/4 始终渲染 */
const displayMaterialRounds = computed(() =>
materialRounds.value.length ? materialRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
const displayVoucherRounds = computed(() =>
voucherRounds.value.length ? voucherRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
/**
* 节点状态视觉:
@@ -746,9 +709,10 @@ function nodeDesc(slot) {
// ===================== 按钮显隐 =====================
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
// 后端 submit-material/submit-voucher 用 isExecutorOfProject 做同等强校验.
// 后端 submit-material 用 isExecutorOfProject 做同等强校验.
const isExecutor = computed(() => currentRole.value === 'executor')
const isAssignedSupervisor = computed(() => supervisors.value.some(u => u.userId === currentUserId.value))
// 监察员判定: 后端 audit-supervision 用 org_id 强校验 (前端无 org_id, role=sponsor 兜底显隐, 后端兜底拒绝)
const isAssignedSupervisor = computed(() => isSponsor.value)
// 支持方 MAIN 账号 (role=sponsor 且 account_type=MAIN) 也可审 (后端 audit-supervision 同样放行 sponsor_admin_user_id)
const isSponsorMain = computed(() => currentRole.value === 'sponsor' && userStore.isMain)
const executed = computed(() => isOne(row.value.isExecuted))
@@ -756,23 +720,17 @@ const frozen = computed(() => isOne(row.value.isFrozen))
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
const canSubmitVoucher = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.voucherAuditStage))
function canComplianceAudit(type) {
function canComplianceAudit() {
if (!isManager.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
return s === 'SUBMITTED' && !isOne(c)
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
}
function canSupervisionAudit(type) {
function canSupervisionAudit() {
if (!isAssignedSupervisor.value && !isSponsorMain.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
return s === 'SUBMITTED' && isOne(c)
return row.value.materialAuditStage === 'SUBMITTED' && isOne(row.value.materialComplianceApproved)
}
// 结算/完结 (合规/管理员 手动点击, 后端强校验 role)
const canSettle = computed(() => (isManager.value || isAdmin.value)
&& row.value.materialAuditStage === 'APPROVED' && row.value.voucherAuditStage === 'APPROVED'
&& row.value.materialAuditStage === 'APPROVED'
&& !isOne(row.value.isSettled))
const canFinish = computed(() => (isManager.value || isAdmin.value)
&& isOne(row.value.isSettled) && !isOne(row.value.isFinished))
@@ -786,7 +744,7 @@ async function loadMaterials() {
const resp = await request.get(`/business/meetingMaterial/${meetingId.value}`)
const list = (resp && (resp.data || resp)) || []
materialsLoaded.value = Array.isArray(list) ? list : []
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...laborVoucherRows.value, ...serviceVoucherRows.value]
all.forEach(r => { r.url = ''; r.fileName = '' })
list.forEach(item => {
const target = all.find(r => r.subType === item.subType)
@@ -1216,6 +1174,114 @@ async function onExportAttendees() {
}
}
// ===================== 劳务协议 批量收发 (下载空目录模板 + 上传回填) =====================
const agreementTplLoading = ref(false)
const agreementSubmitting = ref(false)
const agreementZipInput = ref(null)
/** 下载"劳务协议"空目录模板 zip (劳务协议/{序号}_{姓名}/ 全空目录), 走 fetch 直接流式下载 */
async function onDownloadAgreementTemplate() {
agreementTplLoading.value = true
try {
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingAttendee/agreementTemplate/${meetingId.value}`,
{ headers: importHeaders.value }
)
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '劳务协议模板.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error('模板下载失败')
} finally {
agreementTplLoading.value = false
}
}
function onPickAgreementZip() {
agreementZipInput.value?.click()
}
async function onAgreementZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许重复选择同一文件
if (!file) return
const fd = new FormData()
fd.append('file', file)
agreementSubmitting.value = true
try {
const resp = await request.post(
`/business/meetingAttendee/uploadAgreements?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 位参会人的劳务协议`)
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
agreementSubmitting.value = false
}
}
// ===================== 专家照片 批量收发 (下载空目录模板 + 上传回填 on_site_photos) =====================
const expertPhotoTplLoading = ref(false)
const expertPhotoSubmitting = ref(false)
const expertPhotoZipInput = ref(null)
/** 下载"专家照片"空目录模板 zip (专家照片/{序号}_{姓名}/ 全空目录) */
async function onDownloadExpertPhotoTemplate() {
expertPhotoTplLoading.value = true
try {
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingAttendee/expertPhotoTemplate/${meetingId.value}`,
{ headers: importHeaders.value }
)
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '专家照片目录.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error('模板下载失败')
} finally {
expertPhotoTplLoading.value = false
}
}
function onPickExpertPhotoZip() {
expertPhotoZipInput.value?.click()
}
async function onExpertPhotoZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许重复选择同一文件
if (!file) return
const fd = new FormData()
fd.append('file', file)
expertPhotoSubmitting.value = true
try {
const resp = await request.post(
`/business/meetingAttendee/uploadExpertPhotos?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 位参会人的专家照片`)
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
expertPhotoSubmitting.value = false
}
}
function submitImportFile() {
importUploadRef.value?.submit()
}
@@ -1275,8 +1341,14 @@ async function load() {
const resp = await bizGet('meeting', meetingId.value)
row.value = (resp && (resp.data || resp)) || {}
scheduleUrl.value = row.value.scheduleUrl || ''
invitationUrl.value = row.value.invitationUrl || ''
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles()])
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
if (route.query.settle === '1' && canSettle.value) {
router.replace(route.path)
onSettle()
}
} catch (e) {
console.error('[meeting-detail] load failed', e)
row.value = {}
@@ -1309,12 +1381,12 @@ async function loadProjectRoles() {
// ===================== 保存 (材料) =====================
/**
* 把当前 4 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
* 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
* onSave (保存按钮) 与 onSubmitMaterial/onSubmitVoucher (提交前自动落库) 共用.
* onSave (保存按钮) 与 onSubmitMaterial (提交前自动落库) 共用.
*/
async function saveMaterials() {
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value]
// 1. 快照保存前的 material (subType -> {id, ossUrl})
// 用于保存后区分: 新增 / 替换 / 未变
@@ -1369,6 +1441,17 @@ async function saveMaterials() {
return { saved, ocrCount: toOcr.length }
}
/**
* 保存付款凭证 (LV_PAYMENT + SV_PAYMENT) — 独立于材料保存, 只清/插这两个 subType,
* 不触发 OCR / 不影响会议费用. 合规/管理员 可随时上传保存; 结算接口只校验凭证是否已存在.
*/
async function saveVouchers() {
const payload = [...laborVoucherRows.value, ...serviceVoucherRows.value]
.filter(r => r.url && r.url.trim())
.map(r => ({ materialType: r.type, subType: r.subType, ossUrl: r.url, fileName: r.fileName || '' }))
await request.post(`/business/meetingMaterial/${meetingId.value}/saveVouchers`, payload, { __silentError: true })
}
/** 保存日程海报到 biz_meeting.schedule_url (与材料一致: 上传暂存本地, 点"保存"才落库) */
async function saveSchedulePoster() {
const cur = row.value.scheduleUrl || ''
@@ -1377,6 +1460,14 @@ async function saveSchedulePoster() {
row.value.scheduleUrl = scheduleUrl.value
}
/** 保存邀请函到 biz_meeting.invitation_url (与日程海报一致) */
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 })
row.value.invitationUrl = invitationUrl.value
}
/** 点击"生成海报": 弹出配置 dialog (直接使用背景 or 自定义文字颜色) */
function onGeneratePoster() {
if (!row.value.scheduleUrl) {
@@ -1416,6 +1507,8 @@ async function onSave() {
try {
const { saved, ocrCount } = await saveMaterials()
await saveSchedulePoster()
await saveInvitation()
if (canUploadVoucher.value) await saveVouchers()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
refreshFee()
ElMessage.success(`保存成功 (${saved.length} 条)`)
@@ -1485,7 +1578,7 @@ function submitOcrForMaterials(items) {
})
}
// ===================== 提交材料 / 提交凭证 =====================
// ===================== 提交材料 =====================
// 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表,
// 只上传不保存时表为空, 会误报「未上传」.
async function onSubmitMaterial() {
@@ -1500,18 +1593,6 @@ async function onSubmitMaterial() {
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitMaterial = false }
}
async function onSubmitVoucher() {
busy.value.submitVoucher = true
try {
await saveMaterials()
// __silentError: 同上, 避免双 toast
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-voucher`, null, { __silentError: true })
ElMessage.success('凭证已提交, 待合规审核')
row.value.voucherAuditStage = (resp && resp.data) || 'SUBMITTED'
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitVoucher = false }
}
// ===================== 分配 dialog =====================
const assignDialog = ref({
@@ -1525,17 +1606,15 @@ async function openAssignDialog(role) {
roleLabel: role === 'supervisor' ? '监察员' : '执行人员',
title: `分配${role === 'supervisor' ? '监察员' : '执行人员'}`,
candidates: [],
selectedIds: (role === 'supervisor' ? supervisors.value : executors.value).map(u => u.userId),
selectedIds: (role === 'supervisor' ? supervisors.value : executors.value).map(u => role === 'supervisor' ? u.sponsorOrgId : u.executorOrgId),
loading: false,
saving: false
}
assignDialog.value.loading = true
try {
const fn = role === 'supervisor' ? listSupporters : listExecutor
const resp = await fn({ status: '0', accountType: 'MAIN' })
// 兼容不同返回结构
const rows = resp?.rows || resp?.data?.rows || (Array.isArray(resp) ? resp : [])
assignDialog.value.candidates = rows
const fn = role === 'supervisor' ? listSponsorOrgs : listExecutorOrgs
const resp = await fn({})
assignDialog.value.candidates = resp?.data || []
} catch (e) { ElMessage.error('加载候选人失败') }
finally { assignDialog.value.loading = false }
}
@@ -1546,7 +1625,7 @@ async function confirmAssign() {
: `/business/meeting/executor/${meetingId.value}`
assignDialog.value.saving = true
try {
await request.put(url, { userIds: selectedIds })
await request.put(url, { orgIds: selectedIds })
ElMessage.success('分配成功')
assignDialog.value.show = false
await loadStaff()
@@ -1554,36 +1633,29 @@ async function confirmAssign() {
finally { assignDialog.value.saving = false }
}
// ===================== 审核 dialog (材料+凭证可多选) =====================
// ===================== 审核 dialog (材料审核) =====================
const auditDialog = ref({
show: false, action: '', types: [], title: '',
show: false, action: '', title: '',
opinion: '', approved: true, saving: false
})
function openAuditDialog(action, auditType) {
const types = [auditType]
const other = auditType === 'MATERIAL' ? 'VOUCHER' : 'MATERIAL'
const eligible = action === 'COMPLIANCE' ? canComplianceAudit : canSupervisionAudit
if (eligible(other)) types.push(other)
function openAuditDialog(action) {
auditDialog.value = {
show: true,
action,
types,
title: action === 'COMPLIANCE' ? '合规审核' : '监察审核',
title: action === 'COMPLIANCE' ? '合规审核 (材料)' : '监察审核 (材料)',
opinion: '',
approved: true,
saving: false
}
}
async function confirmAudit() {
const { action, types, opinion, approved } = auditDialog.value
if (!types || !types.length) { ElMessage.warning('请选择审核类型'); return }
const auditType = types.length === 2 ? 'BOTH' : types[0]
const { action, opinion, approved } = auditDialog.value
const url = action === 'COMPLIANCE'
? `/business/meeting/${meetingId.value}/audit-compliance`
: `/business/meeting/${meetingId.value}/audit-supervision`
auditDialog.value.saving = true
try {
await request.post(url, { auditType, approved, opinion }, { __silentError: true })
await request.post(url, { approved, opinion }, { __silentError: true })
ElMessage.success(approved ? '审核通过' : '已拒绝')
auditDialog.value.show = false
await refreshMeeting()
@@ -1592,15 +1664,17 @@ async function confirmAudit() {
}
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
// 结算前必须先已上传保存付款凭证 (劳务/会务凭证 tab), 后端 settle 校验凭证存在, 缺失则报错.
async function onSettle() {
try {
await ElMessageBox.confirm('确认结算? 结算后将锁定材料与凭证 (仅可继续点「完结」)', '结算确认', { type: 'warning' })
await ElMessageBox.confirm('确认结算? 结算前请确保已上传劳务/会务付款凭证。', '结算确认', { type: 'warning' })
} catch { return }
busy.value.settle = true
try {
await request.post(`/business/meeting/${meetingId.value}/settle`, null, { __silentError: true })
ElMessage.success('已结算')
await refreshMeeting()
await loadMaterials()
} catch (e) { ElMessage.error(e?.msg || e?.message || '结算失败') }
finally { busy.value.settle = false }
}
@@ -1644,31 +1718,58 @@ function scheduleFeePoll() {
feePollTimer = setInterval(refreshFee, 15000)
}
// ===================== 占位 =====================
function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') }
function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') }
// ===================== 会务材料 打包上传 (下载空目录模板 + 上传回填 biz_meeting_material) =====================
const serviceTplLoading = ref(false)
const serviceSubmitting = ref(false)
const serviceZipInput = ref(null)
// ===================== admin / manager 删除会议 (软删) =====================
/**
* 后端 BizMeetingController.DELETE 强校验 role in (admin, manager).
* 详情页删除后跳回当前角色对应的会议列表 (避免停在已删会议的空页).
*/
async function onAdminDelete() {
/** 下载"会务材料"空目录模板 zip (会务材料/物料制作·酒店·…/ 全空目录) */
async function onDownloadServiceTemplate() {
serviceTplLoading.value = true
try {
await ElMessageBox.confirm(
`确定要删除会议「${row.value.meetingName || ''}」吗?\n删除为软删除, 数据保留审计追溯, 医生/参会人将不再看到该会议.`,
'删除确认', { type: 'warning' }
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingMaterial/serviceTemplate/${meetingId.value}`,
{ headers: importHeaders.value }
)
} catch { return }
deleting.value = true
try {
await request.delete(`/business/meeting/${meetingId.value}`)
ElMessage.success('删除成功')
router.push(isAdmin.value ? '/admin/meetings' : '/manager/meetings')
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '会务材料目录.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error(e?.msg || '删除失败')
ElMessage.error('模板下载失败')
} finally {
deleting.value = false
serviceTplLoading.value = false
}
}
function onPickServiceZip() {
serviceZipInput.value?.click()
}
async function onServiceZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许重复选择同一文件
if (!file) return
const fd = new FormData()
fd.append('file', file)
serviceSubmitting.value = true
try {
const resp = await request.post(
`/business/meetingMaterial/uploadServiceMaterials?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 类会务材料`)
loadMaterials()
refreshFee()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
serviceSubmitting.value = false
}
}
@@ -1689,7 +1790,6 @@ onBeforeUnmount(stopFeePolling)
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.breadcrumb .current { color: #262626; font-weight: 500; }
.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
.admin-delete-btn { font-size: 13px; }
.card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; min-width: 0; }
.section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
.text-muted { color: #8c8c8c; font-size: 13px; }
@@ -1725,13 +1825,13 @@ onBeforeUnmount(stopFeePolling)
.fee-label { font-size: 14px; color: #595959; }
.fee-value { font-size: 16px; font-weight: 600; color: #f5222d; }.file-uploader { width: 100%; min-width: 0; }
.file-hint { grid-column: 2; font-size: 11px; color: #8c8c8c; line-height: 1.5; }
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
.invoice-amount { font-size: 13px; font-weight: 600; color: #f56c6c; padding-left: 8px; white-space: nowrap; }
.file-link { color: var(--brand-primary); text-decoration: none; font-size: 14px; }
.file-link:hover { text-decoration: underline; }
.file-link.inline-link { font-size: 13px; }
.tab-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.timeline { position: relative; padding-left: 24px; border-left: 2px solid #e8e8e8; }
.timeline-item { padding: 6px 0 12px 16px; position: relative; }
@@ -1747,7 +1847,7 @@ onBeforeUnmount(stopFeePolling)
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; min-width: 0; }
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; }
.audit-column { margin-bottom: 0; padding: 16px 18px; min-width: 0; }
.audit-column .section-title { font-size: 14px; margin-bottom: 12px; padding-left: 8px; }
@@ -1756,7 +1856,6 @@ onBeforeUnmount(stopFeePolling)
/* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */
.attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; }
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.toolbar-hint { font-size: 12px; color: #909399; line-height: 1.5; }
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }
/* 参会人 dialog 2 列布局: el-form 用 grid, 每个 el-form-item 占 1 列, form-hint 横跨 2 列 */