Files
guoju0808/ry-vue3/src/views/meetings/MeetingDetail.vue
T
郭庆泰 56615f9806 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 + 供应商/招标接口切生产域名
2026-09-03 20:12:15 +08:00

2603 lines
125 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-card manager-meeting-detail">
<!-- 面包屑 -->
<div class="breadcrumb">
首页 / 会议管理 / <span class="current">会议详情</span>
</div>
<!-- 页标题 -->
<div class="page-title">
<span>会议详情</span>
</div>
<div class="cols-row">
<!-- 左列 -->
<div class="left-col">
<!-- 1. 会议基本信息 -->
<div class="card">
<div class="section-title">会议基本信息</div>
<div class="info-grid" v-loading="loading">
<div class="info-row"><span class="info-label">项目编号:</span><span class="info-value code">{{ row.projectNo || '-' }}</span></div>
<div class="info-row"><span class="info-label">会议名称:</span><span class="info-value">{{ row.meetingName || '-' }}</span></div>
<div class="info-row"><span class="info-label">项目名称:</span><span class="info-value">{{ row.projectName || '-' }}</span></div>
<div class="info-row"><span class="info-label">期数:</span><span class="info-value">{{ periodDisplay }}</span></div>
<div class="info-row"><span class="info-label">总场次/总期数:</span><span class="info-value">{{ displayTotalPeriods ?? '-' }}</span></div>
<div class="info-row"><span class="info-label">会议开始时间:</span><span class="info-value">{{ fmtDateTime(row.startTime) }}</span></div>
<div class="info-row"><span class="info-label">支持单位:</span><span class="info-value">{{ row.orgName || '-' }}</span></div>
<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.laborAuditStage) }} · 会务 {{ fmtAuditStage(row.serviceAuditStage) }}</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">{{ 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>
<!-- 监察员 + 执行人员: 暂时整体注释掉 (所有角色都不看, admin). 需要时取消注释恢复 -->
<!--
<template v-if="!isManager && !isExecutor && !isSponsor">
<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.sponsorOrgId" type="info" size="small">{{ u.orgName }}</el-tag>
<span v-if="!supervisors.length" class="text-muted">- 未分配 -</span>
<el-link :underline="false" v-if="isManager && !isReadonly" type="primary" @click="openAssignDialog('supervisor')">分配</el-link>
</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.executorOrgId" type="success" size="small">{{ u.orgName }}</el-tag>
<span v-if="!executors.length" class="text-muted">- 未分配 -</span>
<el-link :underline="false" v-if="isManager && !isReadonly" type="primary" @click="openAssignDialog('executor')">分配</el-link>
</div>
</div>
</template>
-->
</div>
</div>
<!-- 2. 材料管理 -->
<div class="card">
<div class="section-title">材料管理</div>
<el-tabs v-model="activeTab" class="material-tabs">
<el-tab-pane label="劳务材料" name="labor">
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) -->
<div class="attendee-section">
<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="esignSending" @click="onBatchEsign">推送电子签</el-button>
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
</div>
<!--
参会人表有 13 , min-width 累加 ~1680px, 直接 el-table width:100% 会撑爆 left-col
(CSS Grid item 默认 min-width:auto, 内容撑大, 导致 audit-column 被挤到 ~93px).
修复: left-col min-width:0 允许网格项缩小 el-table overflow-x:auto 容器
让内容超出时容器内出现横向滚动条, 不影响外层 grid 布局.
-->
<div class="attendee-table-wrap">
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" @selection-change="onAttendeeSelectionChange" class="attendee-table" :row-class-name="attendeeRowClass">
<el-table-column v-if="!isSponsor && !isReadonly" type="selection" width="42" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column label="医生" min-width="90">
<template #default="{ row }">
<span :style="(row.hasIntent === 1 || row.hasIntent === '1') ? {} : { color: '#f56c6c' }">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" width="120" />
<el-table-column prop="workUnit" label="医院" min-width="140" show-overflow-tooltip />
<el-table-column prop="department" label="科室" min-width="90" show-overflow-tooltip />
<el-table-column prop="title" label="职称" min-width="90" show-overflow-tooltip />
<el-table-column prop="bankName" label="银行" min-width="100" show-overflow-tooltip />
<el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip />
<el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip />
<el-table-column prop="accountName" label="账户名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="bankRegion" label="开户行地址" min-width="120" show-overflow-tooltip />
<el-table-column prop="bankAddress" label="银行详细地址" min-width="140" show-overflow-tooltip />
<el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip />
<el-table-column label="应发金额" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="个税税金" width="90" align="right">
<template #default="{ row }">{{ row.tax != null ? '¥ ' + Number(row.tax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="增值税及附加成本" width="130" align="right">
<template #default="{ row }">{{ row.vatAndSurcharge != null ? '¥ ' + Number(row.vatAndSurcharge).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="实发金额" width="110" align="right">
<template #default="{ row }">{{ row.fee != null ? '¥ ' + Number(row.fee).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip />
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<el-link :underline="false" v-if="protocolUrl(row)" type="primary" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-link>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="现场照片" min-width="90" align="center">
<template #default="{ row }">
<el-link :underline="false" v-if="onSitePhotoFiles(row).length" type="primary" @click="openOnSitePhotos(row)">查看</el-link>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="是否邀请" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.isInvited === 1 || row.isInvited === '1'" type="success" size="small">已邀请</el-tag>
<el-tag v-else type="info" size="small">未邀请</el-tag>
</template>
</el-table-column>
<el-table-column label="是否推送电子签" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.isEsigned === 1 || row.isEsigned === '1'" type="success" size="small">已推送</el-tag>
<el-tag v-else type="info" size="small">未推送</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!isSponsor && !isReadonly" label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-link :underline="false" type="primary" :disabled="!attendeeEditable" @click="openAttendeeDialog(row)">编辑</el-link>
<el-dropdown trigger="click" @command="(cmd) => onAttendeeCommand(cmd, row)">
<el-link :underline="false" type="primary" :disabled="inviteLoadingId === row.id || esignLoadingId === row.id">
更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-link>
<template #dropdown>
<el-dropdown-menu>
<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>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty>
<span class="text-muted">暂无参会人, 点击右上"新增参会人"按手机号添加</span>
</template>
</el-table>
</div>
<div class="labor-fee-summary">
<span class="fee-label">劳务费合计:</span>
<span class="fee-value">¥ {{ laborFeeTotal.toFixed(2) }}</span>
</div>
</div>
<div class="file-list" style="margin-top: 16px;">
<!-- 劳务协议: 改为"按参会人逐个回填"的收发闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly && laborEditable" 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 || isReadonly || !laborEditable" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable" />
<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 && laborEditable" 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 || 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 / executor 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isExecutor && !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>
<el-button size="small" :disabled="!row.posterUrl" @click="openPosterPreview">预览海报</el-button>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="会务材料" name="service">
<div class="file-list">
<!-- 发票表: 会议全部会务发票明细 (物料制作上方) -->
<div v-if="invoiceRows.length" class="invoice-table-wrap">
<div class="invoice-table-title">发票表</div>
<el-table :data="invoiceRows" size="small" border class="invoice-table">
<el-table-column label="材料类型" width="100">
<template #default="{ row }">{{ serviceLabel(row.materialSubType) }}</template>
</el-table-column>
<el-table-column label="文件名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ invoiceFileName(row) }}</template>
</el-table-column>
<el-table-column label="发票号" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.invoiceNo || '—' }}</template>
</el-table-column>
<el-table-column label="开票时间" width="110">
<template #default="{ row }">{{ row.invoiceDate || '—' }}</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.recognizeStatus === 'RECOGNIZED'" class="invoice-table-amount">¥ {{ Number(row.amount || 0).toFixed(2) }}</span>
<span v-else class="invoice-table-pending"></span>
</template>
</el-table-column>
</el-table>
</div>
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" accept=".pdf,.png,.jpg,.jpeg,.zip" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable" />
<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 class="fee-summary">
<span class="fee-label">会务费合计:</span>
<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 && serviceEditable" 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 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" v-model:name="r.fileName" :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 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" v-model:name="r.fileName" :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">
<span style="flex:1"></span>
<!-- 执行人员: 提交材料 (三按钮按轨) -->
<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>
<!-- 合规 / 监察审核 (材料, 分轨三按钮); 只读 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>
<!-- 保存: 合规方(manager) 永远可保存; 执行方/管理员仅材料可编辑(未提交/已退回)时可保存 -->
<el-button v-if="!isSponsor && !isReadonly && (isManager || materialsEditable)" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
</div>
</div>
<!-- 右列: 材料审核时间轴 -->
<div class="audit-columns">
<div class="card audit-column">
<div class="section-title">材料审核</div>
<div class="timeline">
<!-- 节点 1: 会议已执行 -->
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<!-- 材料审核: 单条时间轴, 按轮次循环 (提交 合规 监察 提交 ), 节点上标注 劳务/会务 -->
<div v-if="!timelineEvents.length && !pendingNode" class="timeline-item pending">
<div class="timeline-title">执行方提交材料</div>
<div class="timeline-desc pending-text">待执行人员提交</div>
</div>
<template v-for="(ev, idx) in timelineEvents" :key="'ev' + idx">
<div :class="['timeline-item', eventStatus(ev)]">
<div class="timeline-title">
{{ stepLabel(ev.step) }}
<span v-for="(l, i) in trackParts(ev)" :key="i" :class="['track-tag', l === '会务' ? 'track-service' : 'track-labor']">{{ l }}</span>
</div>
<div v-for="(e, i) in ev.entries" :key="i" class="timeline-meta">
<span v-if="ev.entries.length > 1" :class="['track-mini', e.label === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
<span>{{ e.auditor || '—' }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(e.auditTime) }}</span>
<el-tag v-if="e.auditResult" size="small" effect="dark" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</div>
<div v-for="(e, i) in ev.entries" :key="'op' + i">
<div v-if="e.opinion" :class="['timeline-opinion', e.auditResult === 'REJECTED' ? 'opinion-reject' : 'opinion-approve']">💬 {{ e.opinion }}</div>
</div>
</div>
</template>
<div v-if="pendingNode" class="timeline-item pending">
<div class="timeline-title">
{{ stepLabel(pendingNode.step) }}
<span v-for="(l, i) in trackParts(pendingNode)" :key="i" :class="['track-tag', l === '会务' ? 'track-service' : 'track-labor']">{{ l }}</span>
</div>
<div class="timeline-desc pending-text">{{ pendingDesc(pendingNode.step) }}</div>
</div>
<!-- 节点 3/4: 结算 / 完结 -->
<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.orgId" :label="u.orgName" :value="u.orgId" />
</el-select>
<template #footer>
<el-button @click="assignDialog.show = false">取消</el-button>
<el-button type="primary" :loading="assignDialog.saving" @click="confirmAssign">确定</el-button>
</template>
</el-dialog>
<!-- 审核 dialog (合规/监察通用, 材料审核, 分轨独立通过/拒绝) -->
<el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px">
<el-form label-width="80px">
<el-form-item label="审核项">
<div style="display:flex;flex-direction:column;gap:8px;width:100%">
<div v-for="t in auditDialog.tracks" :key="t.type" style="display:flex;align-items:center;gap:12px">
<span style="width:72px">{{ t.label }}</span>
<el-radio-group v-model="t.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
</div>
</div>
</el-form-item>
<el-form-item label="意见">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见 (两轨共用)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditDialog.show = false">取消</el-button>
<el-button type="primary" :loading="auditDialog.saving" @click="confirmAudit">确定</el-button>
</template>
</el-dialog>
<!-- 参会人 dialog (新增/编辑通用, 2 列布局) -->
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="760px" append-to-body :close-on-click-modal="false" @closed="resetAttendeeForm">
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" :rules="attendeeRules" label-width="100px" class="attendee-form-2col">
<!-- Col 1: 基础档案 (8 字段) -->
<el-form-item label="联系方式" prop="phone">
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11">
<template v-if="!attendeeDialog.editing" #suffix>
<el-icon class="phone-lookup" title="按手机号查专家并回填" @click="lookupExpertByPhone"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<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" :error="mismatchError('workUnit')">
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
<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" :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" :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" :error="mismatchError('bankName')">
<el-input v-model="attendeeDialog.form.bankName" placeholder="如: 中国工商银行" />
</el-form-item>
<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" :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" :error="mismatchError('bankRegion')">
<el-input v-model="attendeeDialog.form.bankRegion" placeholder="如: 北京市东城区" />
</el-form-item>
<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">
<el-radio-group v-model="attendeeDialog.form.laborForm">
<el-radio v-for="o in projectRoleOptions" :key="o.value" :value="o.value">{{ o.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="应发金额" prop="feePreTax">
<el-input-number v-model="attendeeDialog.form.feePreTax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="个税税金" prop="tax">
<el-input-number v-model="attendeeDialog.form.tax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="增值税附加" prop="vatAndSurcharge">
<el-input-number v-model="attendeeDialog.form.vatAndSurcharge" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发金额" prop="fee">
<div :class="{ 'fee-warn': feeDeviates }" style="width:100%">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</div>
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="attendeeDialog.form.summary" placeholder="备注/说明" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item v-if="attendeeDialog.editing" label="现场照片">
<OssImageUploader v-model="attendeeDialog.form.onSitePhotos" :dir="`ry8080/meeting/${meetingId}/onsite/`" placeholder="点击上传现场照片" />
</el-form-item>
<div v-if="attendeeDialog.editing" class="form-hint">编辑时不能改手机号; 若需改手机号, 请删除后重新添加</div>
</el-form>
<template #footer>
<el-button @click="attendeeDialog.show = false">取消</el-button>
<el-button type="primary" :loading="attendeeDialog.saving" @click="confirmAttendee">确定</el-button>
</template>
</el-dialog>
<!-- 参会人 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"
accept=".xlsx, .xls"
:on-change="onImportFileChange"
:on-remove="onImportFileRemove"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadAttendeeImportTpl">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImportFile"> </el-button>
</template>
</el-dialog>
<el-dialog v-model="genPosterVisible" title="生成海报" width="440px" :close-on-click-modal="false" append-to-body>
<el-form label-width="120px">
<el-form-item label="添加文字">
<el-switch v-model="genForm.addText" />
</el-form-item>
<el-form-item v-if="genForm.addText" label="文字颜色">
<el-color-picker v-model="genForm.textColor" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="genPosterVisible = false"> </el-button>
<el-button type="primary" :loading="genPosterLoading" @click="doGeneratePoster"> </el-button>
</template>
</el-dialog>
<el-dialog v-model="posterPreviewVisible" title="会议海报预览" width="640px" append-to-body>
<el-image
v-if="row.posterUrl"
:src="row.posterUrl"
:preview-src-list="[row.posterUrl]"
fit="contain"
style="width: 100%;"
/>
<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">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import request from '@/utils/request'
import { bizGet } from '@/api/public'
import { listSponsorOrgs, listExecutorOrgs } from '@/api/system'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import OssFileUploader from '@/components/OssFileUploader.vue'
import CameraQrUpload from '@/components/CameraQrUpload.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
import Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import { ElMessageBox } from 'element-plus'
import { Upload, ArrowDown, Search } from '@element-plus/icons-vue'
import { derivePhysicalStage } from '@/utils/meetingStage'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// ===================== 状态 =====================
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 显隐 */
const genPosterVisible = ref(false)
/** 生成海报配置: addText=是否添加文字(会议信息), textColor=文字颜色 */
const genForm = reactive({ addText: false, textColor: '#FFFFFF' })
/** 海报预览 dialog 显隐 */
const posterPreviewVisible = ref(false)
/** 通用附件预览 (身份证多图 / 劳务协议 PDF), 复用 components/Preview.vue */
const previewOpen = ref(false)
const previewUrls = ref([])
const previewTitle = ref('附件预览')
const loading = ref(false)
const activeTab = ref('labor')
const saving = ref(false)
const supervisors = ref([])
const executors = ref([])
const auditTrail = ref([])
const busy = ref({ submitMaterial: false, settle: false, finish: false })
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), 仅展示内容 + 返回, 除审核/监察按钮外无其他操作按钮 */
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') }
function fmtDateTime(v) {
if (!v) return '-'
const dt = new Date(v)
if (isNaN(dt.getTime())) return v
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
// 执行方看到的"总期数"= 分配给本执行方的场次 (assignedSessions), 而非项目总场次 (total_periods); 其他角色仍用项目总场次
const displayTotalPeriods = computed(() => {
if (currentRole.value === 'executor') {
return row.value.assignedSessions != null ? row.value.assignedSessions : row.value.totalPeriods
}
return row.value.totalPeriods
})
const periodDisplay = computed(() => {
const p = row.value.periodNo
const t = displayTotalPeriods.value
if (p == null && t == null) return '-'
if (p == null) return `${t}`
if (t == null) return `${p}`
return `${p}/${t}`
})
const AUDIT_STAGE_LABEL = { NOT_SUBMITTED: '未提交', SUBMITTED: '已提交', APPROVED: '审核通过', REJECTED: '审核驳回' }
function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v }
/** 金额格式化 (落库费用字段, null/空 → 0.00) */
function fmtMoney(v) {
const n = Number(v)
return (isNaN(n) ? 0 : n).toFixed(2)
}
function isOne(v) { return v === 1 || v === '1' || v === true }
/** sponsor 看脱敏 PDF, 其他角色看完整 PDF */
function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
return row.laborProtocol || ''
}
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
function idCardFiles(row) {
return (row.idCardAttachments || '').split(',').map(s => s.trim()).filter(Boolean)
}
/** 现场照片 CSV "url1,url2" 拆成非空数组 */
function onSitePhotoFiles(row) {
return (row.onSitePhotos || '').split(',').map(s => s.trim()).filter(Boolean)
}
/** OSS 图片压缩参数 (对齐 PosterService.download): 缩宽 + 压质量 + 转 JPEG, 减小预览体积 */
function ossThumb(url, width = 1200) {
if (!url || url.includes('x-oss-process')) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}x-oss-process=image/resize,w_${width}/quality,q_80/format,jpg`
}
/** 现场照片预览: 每张带 OSS 压缩参数 → Preview 组件平铺多图 */
function openOnSitePhotos(row) {
openPreview(onSitePhotoFiles(row).map(u => ossThumb(u)), '现场照片')
}
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '物料实物照片, 盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '酒店盖章版水单, 酒店发票(餐饮普票, 住宿场地费专票)' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '行程单/盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '行程单/盖章版结算单, 发票' },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '合同, 盖章版结算单, 发票, 其他材料' },
{ 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: '' },
{ 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_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'])
/** 扫码拍照 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) }
function makeRows(filter) { return ROW_CONFIG.filter(filter).map(r => ({ ...r, url: '', fileName: '' })) }
const serviceMaterialRows = ref(makeRows(r => r.type === 'SERVICE'))
const laborMaterialRows = ref(makeRows(r => r.type === 'LABOR'))
const laborVoucherRows = ref(makeRows(r => r.type === 'LABOR_VOUCHER'))
const serviceVoucherRows = ref(makeRows(r => r.type === 'SERVICE_VOUCHER'))
// ===================== 时间轴: 解析 audit_trail 为「轨道 × 轮次」 =====================
/**
* 解析指定材料轨 (materialType) 的审核日志为提交轮次数组 (保留重交历史).
* 每次 SUBMITTED 起一轮, 后续 APPROVED/REJECTED 顺序填入 compliance → supervision 槽.
* 方案 B: 同轨被驳回后重交, 产生第 2/3 轮, 时间轴逐轮渲染「第 N 次」.
*/
function parseTrackCycles(materialType) {
const rows = (auditTrail.value || [])
.filter(r => r.auditType === 'MATERIAL' && r.materialType === materialType)
.slice()
.sort((a, b) => new Date(a.auditTime).getTime() - new Date(b.auditTime).getTime())
const cycles = []
for (const row of rows) {
if (row.auditResult === 'SUBMITTED') {
cycles.push({ submit: row, compliance: null, supervision: null })
continue
}
const cur = cycles[cycles.length - 1]
if (!cur) continue
if (!cur.compliance) cur.compliance = row
else if (!cur.supervision) cur.supervision = row
}
return cycles
}
/**
* 轨道列表: 新数据 (两轨独立) 拆 劳务/会务 两轨; 历史数据 material_type=null
* 回退单轨 (无 label), 迁移后两轨同值, 时间轴不再按顺序错配.
*/
const materialTracks = computed(() => {
const labor = parseTrackCycles('LABOR')
const service = parseTrackCycles('SERVICE')
const legacy = parseTrackCycles(null)
if (labor.length || service.length) {
return [
{ key: 'labor', label: '劳务', cycles: labor },
{ key: 'service', label: '会务', cycles: service }
]
}
if (legacy.length) return [{ key: 'legacy', label: '', cycles: legacy }]
return []
})
/**
* 单条材料审核时间轴: 把两轨(劳务/会务)的 cycle 事件拍平成一条按时间升序的节点流.
* 节点 = { step: submit|compliance|supervision, entries: [{label, roundNo, auditor, auditTime, auditResult, opinion}] }
* - 同一步骤 + 同一 timestamp 的两轨事件合并成一个节点 (提交全部/审核全部会同时写两轨日志)
* - entries.length>1 时, 每行前置 track-mini 标注是哪一轨
*/
const timelineEvents = computed(() => {
const raw = []
for (const track of materialTracks.value) {
const label = track.label
track.cycles.forEach((c, i) => {
const roundNo = i + 1
if (c.submit) raw.push({ step: 'submit', time: c.submit.auditTime, entry: { label, roundNo, auditor: c.submit.auditor, auditTime: c.submit.auditTime } })
if (c.compliance) raw.push({ step: 'compliance', time: c.compliance.auditTime, entry: { label, roundNo, auditor: c.compliance.auditor, auditTime: c.compliance.auditTime, auditResult: c.compliance.auditResult, opinion: c.compliance.opinion } })
if (c.supervision) raw.push({ step: 'supervision', time: c.supervision.auditTime, entry: { label, roundNo, auditor: c.supervision.auditor, auditTime: c.supervision.auditTime, auditResult: c.supervision.auditResult, opinion: c.supervision.opinion } })
})
}
raw.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
const nodes = []
for (const ev of raw) {
const last = nodes[nodes.length - 1]
if (last && last.step === ev.step && last.time === ev.time) { last.entries.push(ev.entry); continue }
nodes.push({ step: ev.step, time: ev.time, entries: [ev.entry] })
}
return nodes
})
/** 当前"待办"节点 (时间轴末尾的灰色占位): 依各轨状态推导下一步该谁动. */
const pendingNode = computed(() => {
if (!executed.value || isOne(row.value.isSettled) || isOne(row.value.isFinished)) return null
const c0 = []; if (laborC0.value) c0.push('劳务'); if (serviceC0.value) c0.push('会务')
if (c0.length) return { step: 'compliance', entries: c0.map(l => ({ label: l })) }
const c1 = []; if (laborC1.value) c1.push('劳务'); if (serviceC1.value) c1.push('会务')
if (c1.length) return { step: 'supervision', entries: c1.map(l => ({ label: l })) }
const rej = []
if (row.value.laborAuditStage === 'REJECTED') rej.push('劳务')
if (row.value.serviceAuditStage === 'REJECTED') rej.push('会务')
if (rej.length) return { step: 'submit', entries: rej.map(l => ({ label: l })) }
return null
})
function stepLabel(step) {
if (step === 'submit') return '执行方提交材料'
if (step === 'compliance') return '合规审核'
return '监察意见'
}
/** 节点右侧轨标注: 每轨一个徽标 (劳务=primary, 会务=warning). */
function trackParts(node) {
return (node.entries || []).map(e => e && e.label).filter(Boolean)
}
function eventStatus(ev) {
return ev.entries.some(e => e.auditResult === 'REJECTED') ? 'rejected' : 'done'
}
function pendingDesc(step) {
if (step === 'submit') return '待重新提交'
if (step === 'compliance') return '待合规审核'
return '待监察审核'
}
/**
* 固定节点 (会议已执行/结算/完结) 状态 — 跟随 current_stage (BizMeetingStageEnum 10 值 code)
* slot: PRE = 会议已执行 = NOT_STARTED/FROZEN 之外都算已执行
* POST = 结算 = AWAITING_SETTLEMENT / SETTLED / FINISHED (已结算)
* DONE = 完结 = FINISHED (is_finished=1)
*/
function fixedNodeStatus(slot) {
const s = derivePhysicalStage(row.value)
if (slot === 'PRE') return s !== 'NOT_STARTED' && s !== 'FROZEN' ? 'done' : 'pending'
if (slot === 'POST') return s === 'AWAITING_SETTLEMENT' || s === 'SETTLED' || s === 'FINISHED' ? 'done' : 'pending'
if (slot === 'DONE') return isOne(row.value.isFinished) ? 'done' : 'pending'
return 'pending'
}
function nodeDesc(slot) {
const s = derivePhysicalStage(row.value)
if (slot === 'PRE') return s !== 'NOT_STARTED' && s !== 'FROZEN' ? '已执行' : '待执行'
if (slot === 'POST') {
if (s === 'SETTLED' || s === 'FINISHED') return '已结算'
if (s === 'AWAITING_SETTLEMENT') return '补充凭证并结算'
return '未结算'
}
if (slot === 'DONE') return isOne(row.value.isFinished) ? '已完结' : '未完结'
return '-'
}
// ===================== 按钮显隐 =====================
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
// 后端 submit-material 用 isExecutorOfProject 做同等强校验.
const isExecutor = computed(() => currentRole.value === 'executor')
// 监察员判定: 后端 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))
const frozen = computed(() => isOne(row.value.isFrozen))
/** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */
const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
// 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改
const attendeeEditable = computed(() => {
if (isSponsor.value || isReadonly.value) return false
if (isExecutor.value) return ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage)
return true
})
/** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */
function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) }
function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) }
// 材料可提交: 已执行 + 未冻结 + 对应轨可编辑 (三按钮按轨显隐)
const executorSubmitReady = computed(() => isExecutor.value && executed.value && !frozen.value)
const canSubmitLabor = computed(() => executorSubmitReady.value && laborEditable.value)
const canSubmitService = computed(() => executorSubmitReady.value && serviceEditable.value)
const canSubmitAll = computed(() => executorSubmitReady.value && laborEditable.value && serviceEditable.value)
// 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定
const materialsEditable = computed(() => laborEditable.value || serviceEditable.value)
// 合规审核 (合规方/manager): 三按钮按轨 C0 显隐
const laborC0 = computed(() => isC0(row.value.laborAuditStage, row.value.laborComplianceApproved))
const serviceC0 = computed(() => isC0(row.value.serviceAuditStage, row.value.serviceComplianceApproved))
// 监察审核 (支持方/监察员): 三按钮按轨 C1 显隐
const supervisorAuditor = computed(() => isAssignedSupervisor.value || isSponsorMain.value)
const laborC1 = computed(() => isC1(row.value.laborAuditStage, row.value.laborComplianceApproved))
const serviceC1 = computed(() => isC1(row.value.serviceAuditStage, row.value.serviceComplianceApproved))
// 结算 (合规/管理员 手动点击): 两轨均审核通过才可结算
const canSettle = computed(() => (isManager.value || isAdmin.value)
&& row.value.laborAuditStage === 'APPROVED'
&& row.value.serviceAuditStage === 'APPROVED'
&& !isOne(row.value.isSettled))
const canFinish = computed(() => (isManager.value || isAdmin.value)
&& isOne(row.value.isSettled) && !isOne(row.value.isFinished))
// ===================== 加载 =====================
/** 后端返回的完整 material 列表 (含 id/ossUrl/amount), 用于 onSave 时做"新增/替换/未变"分类 */
const materialsLoaded = ref([])
async function loadMaterials() {
try {
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, ...laborVoucherRows.value, ...serviceVoucherRows.value]
all.forEach(r => { r.url = ''; r.fileName = '' })
list.forEach(item => {
const target = all.find(r => r.subType === item.subType)
if (target) {
// sponsor 对签到表只看脱敏版 (高斯模糊) extra_oss_url, 隐藏照片里的手机号/身份证号
const masked = isSponsor.value && item.subType === 'L_SIGN_IN' && item.extraOssUrl
target.url = masked ? item.extraOssUrl : (item.ossUrl || '')
target.fileName = item.fileName || ''
}
})
captureSnapshot()
} 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)
function captureSnapshot() {
const urls = new Map()
;[...serviceMaterialRows.value, ...laborMaterialRows.value,
...laborVoucherRows.value, ...serviceVoucherRows.value]
.forEach(r => urls.set(r.subType, (r.url || '').trim()))
savedSnapshot.value = {
urls,
scheduleUrl: scheduleUrl.value || '',
invitationUrl: invitationUrl.value || '',
}
}
/** 是否有「已上传但未落库」的改动 (材料/凭证/海报/邀请函) */
const hasUnsavedChanges = computed(() => {
const s = savedSnapshot.value
if (!s) return false
if ((scheduleUrl.value || '') !== s.scheduleUrl) return true
if ((invitationUrl.value || '') !== s.invitationUrl) return true
return [...serviceMaterialRows.value, ...laborMaterialRows.value,
...laborVoucherRows.value, ...serviceVoucherRows.value]
.some(r => (r.url || '').trim() !== (s.urls.get(r.subType) || ''))
})
// ===================== 参会人 CRUD =====================
/** 会议下的参会人列表 (放在 劳务材料 tab 表格) */
const attendeeRows = ref([])
const attendeeLoading = ref(false)
/** 劳务费合计 = 参会人应发金额(feePreTax)之和 (劳务费用按人员应发金额算, 不按发票) */
const laborFeeTotal = computed(() =>
(attendeeRows.value || []).reduce((s, r) => s + (Number(r.feePreTax) || 0), 0)
)
/** 会务费合计: 有总发票(M_INVOICE=MAIN)则只算总发票; 否则各 SUB 子类发票金额之和 */
const meetingFee = computed(() => {
const mats = materialsLoaded.value || []
const main = mats.find(m => m.subType === 'M_INVOICE')
if (main && Number(main.amount) > 0) return { total: Number(main.amount), useMain: true }
const total = mats.reduce((s, m) => {
if (!m.subType || !m.subType.startsWith('M_')) return s
if (m.subType === 'M_INVOICE' || m.subType === 'M_SETTLEMENT') return s
return s + (Number(m.amount) || 0)
}, 0)
return { total, useMain: false }
})
/** 已勾选的参会人 (批量邀请/推送共用) */
const selectedAttendees = ref([])
/** 推送电子签中 (批量工具栏按钮 loading) */
const esignSending = ref(false)
/** 邀请参会中 (批量工具栏按钮 loading) */
const inviteSending = ref(false)
/** 推送电子签中 (单条行按钮 loading, 只转当前行) */
const esignLoadingId = ref(null)
/** 邀请参会中 (单条行按钮 loading, 只转当前行) */
const inviteLoadingId = ref(null)
/** 项目级角色 (来自 biz_project.role_labor JSON 解析), 用于参会人 dialog 角色下拉 */
const projectRoles = ref([])
/** 参会人角色单选选项 (来自 projectRoles, 去掉"其他"逃生口) */
const projectRoleOptions = computed(() => {
return (projectRoles.value || [])
.filter(r => r && r.role && r.role !== '其他')
.map(r => ({ value: r.role, label: r.role }))
})
/** 参会人 dialog 状态 (新增/编辑共用) */
const attendeeDialog = ref({
show: false,
title: '',
editing: false, // true=编辑 / false=新增
form: emptyAttendeeForm(),
saving: false
})
function emptyAttendeeForm() {
return {
id: null,
meetingId: null,
phone: '',
name: '',
workUnit: '',
department: '',
title: '',
idCard: '',
bankName: '',
bankCard: '',
bankBranch: '',
accountName: '',
bankRegion: '',
bankAddress: '',
idCardAttachments: '',
laborForm: '',
feePreTax: 0,
tax: 0,
vatAndSurcharge: 0,
fee: 0,
summary: '',
onSitePhotos: ''
}
}
/** 参会人表单必填规则 (除 摘要/现场照片 外全必填) */
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' },
{ 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' }],
bankName: [{ required: true, message: '请填写银行', trigger: 'blur' }],
bankCard: [{ required: true, message: '请填写银行卡号码', trigger: 'blur' }],
bankBranch: [{ required: true, message: '请填写开户行', trigger: 'blur' }],
accountName: [{ required: true, message: '请填写账户名称', trigger: 'blur' }],
bankRegion: [{ required: true, message: '请填写开户行地址', trigger: 'blur' }],
bankAddress: [{ required: true, message: '请填写银行详细地址', trigger: 'blur' }],
laborForm: [{ required: true, message: '请选择角色', trigger: 'change' }],
feePreTax: [{ required: true, message: '请填写应发金额', trigger: 'change' }],
tax: [{ required: true, message: '请填写个税税金', trigger: 'change' }],
vatAndSurcharge: [{ required: true, message: '请填写增值税附加', trigger: 'change' }],
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,
form: emptyAttendeeForm(), saving: false
}
// 清掉上次的校验错误 (防止关掉后重开还残留"实发金额不能超过..."红字)
attendeeFormRef.value?.clearValidate()
clearMismatchMap()
}
/** 参会人表单 ref (el-form validate 用) */
const attendeeFormRef = ref()
/** 计算所选角色(可多个, 逗号分隔)的项目劳务金额合计; 返回 { sum, matchedAny } */
function roleAmountSum(laborForm) {
const result = { sum: 0, matchedAny: false }
if (!laborForm) return result
const items = String(laborForm).split(',').map(s => s.trim()).filter(Boolean)
items.forEach(item => {
const matched = (projectRoles.value || []).find(r => {
if (!r) return false
const label = r.role === '其他' ? (r.customName || '').trim() : r.role
return label === item
})
if (matched && matched.amount != null) {
result.sum += Number(matched.amount) || 0
result.matchedAny = true
}
})
return result
}
/** 实发金额是否偏离配置值 (所选角色劳务金额合计); 少于或多于都算, 未选/未匹配到配置不提示 */
const feeDeviates = computed(() => {
const laborForm = attendeeDialog.value.form.laborForm
if (!laborForm || !String(laborForm).trim()) return false
const { sum, matchedAny } = roleAmountSum(laborForm)
if (!matchedAny) return false
const fee = Number(attendeeDialog.value.form.fee) || 0
return Number(fee.toFixed(2)) !== Number(sum.toFixed(2))
})
/** 参会人列表行: 实发金额偏离角色劳务配置值 (少/多都算), 未选/未匹配到配置不提示 */
function attendeeFeeDeviates(row) {
if (!row || !row.laborForm || !String(row.laborForm).trim()) return false
const { sum, matchedAny } = roleAmountSum(row.laborForm)
if (!matchedAny) return false
const fee = Number(row.fee) || 0
return Number(fee.toFixed(2)) !== Number(sum.toFixed(2))
}
/** el-table row-class-name: 偏离行加 attendee-fee-warn 淡红背景 */
function attendeeRowClass({ row }) {
return attendeeFeeDeviates(row) ? 'attendee-fee-warn' : ''
}
// ===================== 金额联动 (照搬 hwt guest.vue 单向链) =====================
// fee(实发) → tax(个税) → vatAndSurcharge(增值税附加) → feePreTax(应发)
// 每个 watcher 只改自己的下游, 不回写上游; suppressFeeLink 在编辑回显时抑制, 避免覆盖已存值
let suppressFeeLink = false
watch(
() => attendeeDialog.value.form.fee,
(val) => {
if (suppressFeeLink) return
if (val == null || val === '' || isNaN(val)) return
const f = Number(val)
let tax = 0
if (f > 800 && f <= 3360) tax = (f - 800) / 4
else if (f > 3360 && f <= 21000) tax = (0.16 * f) / 0.84
else if (f > 21000 && f <= 49500) tax = (0.24 * f - 2000) / 0.76
else if (f > 49500) tax = (0.32 * f - 7000) / 0.68
tax = Number(tax.toFixed(2))
const vat = Number(((tax + f) * 0.0151).toFixed(2))
// 直接补全整链 (tax→vat→feePreTax), 不依赖下游 watch:
// 小金额 (fee<=800) 个税为 0, tax 赋值前后不变, 下游 watch 不触发会漏算 vat/feePreTax
attendeeDialog.value.form.tax = tax
attendeeDialog.value.form.vatAndSurcharge = vat
attendeeDialog.value.form.feePreTax = Number((f + tax + vat).toFixed(2))
}
)
watch(
() => attendeeDialog.value.form.tax,
() => {
if (suppressFeeLink) return
const fee = Number(attendeeDialog.value.form.fee)
const tax = Number(attendeeDialog.value.form.tax)
if (isNaN(fee) || isNaN(tax)) return
const vat = Math.round((tax + fee) * 0.0151 * 100) / 100
attendeeDialog.value.form.vatAndSurcharge = Number(vat.toFixed(2))
}
)
watch(
() => attendeeDialog.value.form.vatAndSurcharge,
() => {
if (suppressFeeLink) return
const fee = Number(attendeeDialog.value.form.fee)
const tax = Number(attendeeDialog.value.form.tax)
const vat = Number(attendeeDialog.value.form.vatAndSurcharge)
if (isNaN(fee) || isNaN(tax) || isNaN(vat)) return
attendeeDialog.value.form.feePreTax = Number((fee + tax + vat).toFixed(2))
}
)
// ===================== 角色选中 → 带出项目劳务金额 =====================
// 用户选角色时, 从 biz_project.role_labor 里按角色名 (role 或"其他"的 customName) 匹配 amount,
// 把 amount (税后/实发) 填进 fee, 再由上面的 fee→tax→vat→feePreTax 链自动补齐其余三项.
// suppressFeeLink 在编辑回显时抑制, 避免用项目金额覆盖已存的参会人金额.
watch(
() => attendeeDialog.value.form.laborForm,
(val) => {
if (suppressFeeLink) return
if (!val) return
// 多选: 逗号分隔, 按每个角色名匹配项目劳务金额求和, 填进 fee (实发).
// "其他"自定义角色不在项目列表里 → 不计入; 全"其他"时 matchedAny=false → 不动 fee.
const { sum, matchedAny } = roleAmountSum(val)
if (matchedAny) {
attendeeDialog.value.form.fee = Number(sum.toFixed(2))
}
}
)
async function loadAttendees() {
attendeeLoading.value = true
try {
const resp = await request.get(`/business/meetingAttendee/list/${meetingId.value}`)
const list = (resp && (resp.data || resp)) || []
attendeeRows.value = Array.isArray(list) ? list : []
} catch (e) {
console.error('[meeting-detail] loadAttendees failed', e)
attendeeRows.value = []
} finally {
attendeeLoading.value = false
}
}
/** 勾选变化 → 记录选中行 */
function onAttendeeSelectionChange(sel) {
selectedAttendees.value = sel
}
/** 调后端推送电子签 (短信 + 站内信 + 置 is_esigned=1); loadingId 非空→单条行级 loading, 否则批量工具栏 loading */
async function pushEsign(ids, loadingId) {
if (!ids || !ids.length) return
if (loadingId != null) esignLoadingId.value = loadingId
else esignSending.value = true
try {
const resp = await request.post('/business/meetingAttendee/esign', ids, { __silentError: true })
const sent = (resp && resp.data != null) ? resp.data : ids.length
ElMessage.success(`已推送 ${sent} 份电子签`)
selectedAttendees.value = []
await loadAttendees()
} catch (e) {
console.error('[meeting-detail] pushEsign failed', e)
ElMessage.error('推送电子签失败')
} finally {
if (loadingId != null) esignLoadingId.value = null
else esignSending.value = false
}
}
/** 单条推送电子签 */
function onEsignRow(row) {
ElMessageBox.confirm(
`确认向「${row.name || row.phone || row.id}」推送电子签?将发送短信并推送站内信。`,
'推送电子签',
{ type: 'warning' }
).then(() => pushEsign([row.id], row.id)).catch(() => {})
}
/** 批量推送电子签 (先勾选再点按钮) */
function onBatchEsign() {
const ids = selectedAttendees.value.map(r => r.id).filter(id => id != null)
if (!ids.length) {
ElMessage.warning('请先勾选要推送电子签的参会人')
return
}
ElMessageBox.confirm(
`确认向已勾选的 ${ids.length} 位参会人推送电子签?将发送短信并推送站内信。`,
'批量推送电子签',
{ type: 'warning' }
).then(() => pushEsign(ids)).catch(() => {})
}
/** 调后端邀请参会 (推"会议邀请"站内信 + 置 is_invited=1, 不发短信); loadingId 非空→单条行级 loading, 否则批量工具栏 loading */
async function pushInvite(ids, loadingId) {
if (!ids || !ids.length) return
if (loadingId != null) inviteLoadingId.value = loadingId
else inviteSending.value = true
try {
const resp = await request.post('/business/meetingAttendee/invite', ids, { __silentError: true })
const sent = (resp && resp.data != null) ? resp.data : ids.length
ElMessage.success(`已邀请 ${sent} 位参会人 (站内信)`)
selectedAttendees.value = []
await loadAttendees()
} catch (e) {
console.error('[meeting-detail] pushInvite failed', e)
ElMessage.error('邀请参会失败')
} finally {
if (loadingId != null) inviteLoadingId.value = null
else inviteSending.value = false
}
}
/** 单条邀请参会 */
function onInviteRow(row) {
ElMessageBox.confirm(
`确认邀请「${row.name || row.phone || row.id}」参会?将推送站内信。`,
'邀请参会',
{ type: 'warning' }
).then(() => pushInvite([row.id], row.id)).catch(() => {})
}
/** 批量邀请参会 (先勾选再点按钮) */
function onBatchInvite() {
const ids = selectedAttendees.value.map(r => r.id).filter(id => id != null)
if (!ids.length) {
ElMessage.warning('请先勾选要邀请参会的参会人')
return
}
ElMessageBox.confirm(
`确认邀请已勾选的 ${ids.length} 位参会人参会?将推送站内信。`,
'批量邀请参会',
{ type: 'warning' }
).then(() => pushInvite(ids)).catch(() => {})
}
/** 打开新增/编辑 dialog; row=null → 新增 */
function openAttendeeDialog(row) {
suppressFeeLink = true
clearMismatchMap()
if (row) {
attendeeDialog.value = {
show: true,
title: '编辑参会人',
editing: true,
form: {
id: row.id,
meetingId: row.meetingId,
phone: row.phone || '',
name: row.name || '',
workUnit: row.workUnit || '',
department: row.department || '',
title: row.title || '',
idCard: row.idCard || '',
bankName: row.bankName || '',
bankCard: row.bankCard || '',
bankBranch: row.bankBranch || '',
accountName: row.accountName || '',
bankRegion: row.bankRegion || '',
bankAddress: row.bankAddress || '',
idCardAttachments: row.idCardAttachments || '',
laborForm: row.laborForm || '',
feePreTax: row.feePreTax ?? 0,
tax: row.tax ?? 0,
vatAndSurcharge: row.vatAndSurcharge ?? 0,
fee: row.fee ?? 0,
summary: row.summary || '',
onSitePhotos: row.onSitePhotos || ''
},
saving: false
}
} else {
attendeeDialog.value = {
show: true,
title: '新增参会人',
editing: false,
form: { ...emptyAttendeeForm(), meetingId: Number(meetingId.value) },
saving: false
}
}
nextTick(() => { suppressFeeLink = false })
}
/** 手机号放大镜: 按手机号查专家, 命中则回填参会人表单 (身份证/银行/科室/职称等) */
async function lookupExpertByPhone() {
const phone = (attendeeDialog.value.form.phone || '').trim()
if (!phone) {
ElMessage.warning('请先输入手机号')
return
}
if (!phone.match(/^1\d{10}$/)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
const resp = await request.get(`/business/expert/byPhone/${phone}`, { __silentError: true })
const expert = (resp && resp.data) || null
if (!expert) {
ElMessage.info('未找到该手机号对应的专家')
return
}
const f = attendeeDialog.value.form
f.name = expert.name || f.name
f.workUnit = expert.workUnit || f.workUnit
f.department = expert.department || f.department
f.title = expert.title || f.title
f.idCard = expert.idCard || f.idCard
f.bankName = expert.bankName || f.bankName
f.bankBranch = expert.bankBranch || f.bankBranch
f.bankCard = expert.bankCard || f.bankCard
f.bankRegion = expert.bankRegion || f.bankRegion
f.bankAddress = expert.bankAddress || f.bankAddress
f.idCardAttachments = expert.idCardAttachments || f.idCardAttachments
// 账户名称 = 持卡人姓名, 默认取专家姓名
f.accountName = expert.name || f.accountName
ElMessage.success('已回填专家信息')
} catch (e) {
console.error('[meeting-detail] lookupExpertByPhone failed', e)
ElMessage.error(e?.msg || e?.message || '查询失败')
}
}
/** 真正落库 (新增 → POST; 编辑 → PUT), 二次确认通过后调用 */
async function doSubmitAttendee() {
const { editing, form } = attendeeDialog.value
attendeeDialog.value.saving = true
try {
if (editing) {
await request.put('/business/meetingAttendee', form, { __silentError: true })
ElMessage.success('已保存')
} else {
await request.post('/business/meetingAttendee', form, { __silentError: true })
ElMessage.success('已添加')
}
attendeeDialog.value.show = false
await loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || (editing ? '保存失败' : '添加失败'))
} finally {
attendeeDialog.value.saving = false
}
}
/** 提交 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 {
await ElMessageBox.confirm(`确认删除参会人「${row.name || row.phone || row.id}」?该操作仅删除参会关系, 不会删除其 sys_user 账号`, '删除确认', { type: 'warning' })
} catch { return /* 用户取消 */ }
try {
await request.delete(`/business/meetingAttendee/${row.id}`, { __silentError: true })
ElMessage.success('已删除')
await loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
/** 操作列下拉分发: 邀请参会 / 推送电子签 / 删除 */
function onAttendeeCommand(cmd, row) {
if (cmd === 'invite') onInviteRow(row)
else if (cmd === 'esign') onEsignRow(row)
else if (cmd === 'delete') confirmDeleteAttendee(row)
}
// ===================== 参会人 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)
// 下载模板用 (fetch 流式下载, 不走 axios, 需显式带 Authorization)
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
/** 待导入的 Excel 文件 (on-change 捕获, 同一文件 dry-run + force 各发一次) */
const importFile = ref(null)
function openAttendeeImport() {
importResult.value = null
importFile.value = null
importOpen.value = true
}
function downloadAttendeeImportTpl() {
fetch(import.meta.env.VITE_APP_BASE_API + '/business/meetingAttendee/importTemplate', { headers: importHeaders.value })
.then(r => r.blob())
.then(blob => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '参会人批量导入模板.xlsx'
a.click()
})
.catch(() => ElMessage.error('模板下载失败'))
}
/** 导出当前会议参会人 (Excel, 复用导入模板列头, 含账户名称/开户行地址/银行详细地址/身份证附件) */
async function onExportAttendees() {
exporting.value = true
try {
// responseType:blob → 拦截器直接返回 axios res, res.data 即 Blob
const res = await request.post(
`/business/meetingAttendee/export?meetingId=${meetingId.value}`,
null,
{ responseType: 'blob', __silentError: true }
)
const blob = res.data
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `参会人_${row.value.meetingName || meetingId.value}.xlsx`
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error(e?.msg || e?.message || '导出失败')
} finally {
exporting.value = false
}
}
// ===================== 劳务协议 批量收发 (下载空目录模板 + 上传回填) =====================
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 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 || '上传失败')
} 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 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 || '上传失败')
} finally {
expertPhotoSubmitting.value = false
}
}
function onImportFileChange(uploadFile) {
importFile.value = uploadFile.raw || null
}
function onImportFileRemove() {
importFile.value = null
}
/** 确定导入: 先 dry-run 比对专家表, 有冲突弹二次确认, 确认后 force 入库 */
async function submitImportFile() {
const file = importFile.value
if (!file) {
ElMessage.warning('请先选择要导入的文件')
return
}
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
}
}
/** 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 || '导入失败')
}
}
async function loadStaff() {
try {
const [sp, ex] = await Promise.all([
request.get(`/business/meeting/supervisor/list/${meetingId.value}`),
request.get(`/business/meeting/executor/list/${meetingId.value}`)
])
supervisors.value = (sp && (sp.data || sp)) || []
executors.value = (ex && (ex.data || ex)) || []
} catch (e) { console.error('[meeting-detail] loadStaff failed', e) }
}
async function loadTrail() {
try {
const resp = await request.get(`/business/meeting/${meetingId.value}/audit-trail`)
auditTrail.value = (resp && (resp.data || resp)) || []
} catch (e) { console.error('[meeting-detail] loadTrail failed', e) }
}
async function load() {
meetingId.value = route.params.meetingId
if (!meetingId.value) return
loading.value = true
try {
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(), 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)
onSettle()
}
} catch (e) {
console.error('[meeting-detail] load failed', e)
row.value = {}
} finally { loading.value = false }
}
/**
* 加载项目级角色 (biz_project.role_labor JSON)
* 失败/为空 → projectRoles 留空, ProjectRoleMultiSelect 会显示空多选 + 虚拟"其他"逃生口
*/
async function loadProjectRoles() {
const pid = row.value.projectId
if (!pid) { projectRoles.value = []; return }
try {
const resp = await bizGet('project', pid)
const p = (resp && (resp.data?.data || resp.data)) || resp
const rl = p && p.roleLabor
if (typeof rl === 'string') {
try { projectRoles.value = JSON.parse(rl) || [] } catch (e) { projectRoles.value = [] }
} else if (Array.isArray(rl)) {
projectRoles.value = rl
} else {
projectRoles.value = []
}
} catch (e) {
console.warn('[meeting-detail] loadProjectRoles failed', e)
projectRoles.value = []
}
}
// ===================== 保存 (材料) =====================
/**
* 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
* onSave (保存按钮) 与 onSubmitMaterials (提交前自动落库) 共用.
*/
async function saveMaterials() {
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value]
// 1. 快照保存前的 material (subType -> {id, ossUrl})
// 用于保存后区分: 新增 / 替换 / 未变
const preSaveState = new Map()
materialsLoaded.value.forEach(m => {
if (m.subType && m.ossUrl) {
preSaveState.set(m.subType, { id: m.id, ossUrl: m.ossUrl, amount: m.amount })
}
})
// 2. 构造 payload
const payload = all.filter(r => r.url && r.url.trim()).map(r => ({
materialType: r.type, subType: r.subType, ossUrl: r.url, fileName: r.fileName || ''
}))
// 3. 保存 (全删全插, 后端用 DELETE + INSERT)
// __silentError: 让本处 catch 接管 toast, 避免 axios 拦截器和 catch 双弹
const resp = await request.put(`/business/meetingMaterial/${meetingId.value}`, payload, { __silentError: true })
const saved = (resp && resp.data) || []
// 4. 分类: 新增 / 替换 / 未变
// 仅对可识别文件 (.png/.jpg/.jpeg/.pdf/.zip) 触发 OCR
const toOcr = []
for (const m of saved) {
if (!m || !m.id || !m.ossUrl) continue
if (!isRecognizable(m.ossUrl)) continue
if (NON_OCR_SUBTYPES.has(m.subType)) continue
const old = preSaveState.get(m.subType)
if (!old) {
// 新增
toOcr.push({ materialId: m.id, ossUrl: m.ossUrl, oldMaterialId: null, subType: m.subType })
} else if (old.ossUrl !== m.ossUrl) {
// 替换 (URL 变了 → 后端需先清旧 invoice + amount=0 再 OCR)
toOcr.push({
materialId: m.id, ossUrl: m.ossUrl,
oldMaterialId: old.id, subType: m.subType
})
}
// 未变: 跳过
}
if (toOcr.length) {
submitOcrForMaterials(toOcr)
}
// 5. 保存即刷新材料 + 发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id.
// 会务费合计(meetingFee)与单行金额(materialAmount)都读 materialsLoaded, 移除材料时 toOcr=0,
// 若不重拉 loadMaterials 会残留旧金额/旧行 → 这里无条件重拉 (doSave 走 refreshFee, onSubmitMaterials 走这里)
loadMaterials()
loadInvoices()
// 6. 1.5s 后重拉 materials + 发票表, 让 OCR 回写的 amount/发票号 更新可见
if (toOcr.length) {
setTimeout(() => { loadMaterials(); loadInvoices() }, 1500)
}
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 || ''
if (scheduleUrl.value === cur) return
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, scheduleUrl: scheduleUrl.value }, { __silentError: true })
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), projectId: row.value.projectId, invitationUrl: invitationUrl.value }, { __silentError: true })
row.value.invitationUrl = invitationUrl.value
}
/** 点击"生成海报": 弹出配置 dialog (直接使用背景 or 自定义文字颜色) */
function onGeneratePoster() {
if (!row.value.scheduleUrl) {
ElMessage.warning('请先上传并保存日程海报')
return
}
genForm.addText = false
genForm.textColor = '#FFFFFF'
genPosterVisible.value = true
}
/** dialog 内"生成": 后端下载海报+叠加信息+上传OSS, 回写 poster_url */
async function doGeneratePoster() {
genPosterLoading.value = true
try {
const resp = await request.post(`/business/meeting/${meetingId.value}/generate-poster`, { addText: genForm.addText, textColor: genForm.textColor }, { __silentError: true })
row.value.posterUrl = (resp && resp.data) || ''
genPosterVisible.value = false
ElMessage.success('海报已生成')
} catch (e) {
console.error('[meeting-detail] generate poster failed', e)
ElMessage.error(e?.msg || e?.message || '生成海报失败')
} finally {
genPosterLoading.value = false
}
}
/** 预览海报 (el-image 自带全屏预览) */
function openPosterPreview() {
if (!row.value.posterUrl) return
posterPreviewVisible.value = true
}
/** 通用附件预览: urls 数组 → Preview 组件 (多图走画廊, 单 PDF 走 iframe) */
function openPreview(urls, title) {
previewUrls.value = Array.isArray(urls) ? urls : [urls]
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
// 保存所有待落库内容 (材料/日程海报/邀请函/付款凭证); 成功返回 OCR 提交数, 失败抛错
async function doSave() {
const { ocrCount } = await saveMaterials()
await saveSchedulePoster()
await saveInvitation()
if (canUploadVoucher.value) await saveVouchers()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
refreshFee()
captureSnapshot()
return ocrCount
}
async function onSave() {
if (saving.value) return
saving.value = true
try {
const ocrCount = await doSave()
ElMessage.success('保存成功')
if (ocrCount) ElMessage.info(`已提交 ${ocrCount} 个识别任务 (后台执行)`)
} catch (e) {
console.error('[meeting-detail] save failed', e)
// 后端 BizMeetingMaterialServiceImpl 已将 DuplicateKeyException 翻译为友好中文
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally { saving.value = false }
}
/** 判断 OSS URL 是否为图片 (.jpg/.jpeg/.png) */
function isImageUrl(url) {
return /\.(jpe?g|png)$/i.test(url || '')
}
/** 判断是否可识别: 图片或 PDF */
function isRecognizable(url) {
return /\.(jpe?g|png|pdf|zip)$/i.test(url || '')
}
/**
* 取某 subType 的识别金额 (从 materialsLoaded 查), 用于 file-row 后显示
* ZIP 路径下可能多张发票, material.amount 是 OCR 完成后回写求和值
*/
function materialAmount(subType) {
const m = materialsLoaded.value.find(x => x.subType === subType)
if (!m || m.amount == null) return 0
return Number(m.amount)
}
// ===================== 发票表 (会务材料 tab 顶部展示会议全部会务发票明细) =====================
/** 会议下所有会务发票明细 (biz_meeting_invoice join material 带出 subType/文件名) */
const invoiceRows = ref([])
/** 会务材料 subType → 中文名 (ROW_CONFIG label) */
function serviceLabel(subType) {
const r = ROW_CONFIG.find(x => x.subType === subType)
return r ? r.label : (subType || '—')
}
/** 发票文件名: sourceFilename(zip) → materialFileName(单文件) → ossUrl 末段 → '—' */
function invoiceFileName(row) {
if (row.sourceFilename) return row.sourceFilename
if (row.materialFileName) return row.materialFileName
const seg = (((row.ossUrl || '').split('?')[0]).split('/').pop()) || ''
return seg || '—'
}
/** 拉取发票明细 (静默, 发票表非关键) */
async function loadInvoices() {
try {
const resp = await request.get(`/business/meeting/invoice/list`, { params: { meetingId: meetingId.value }, __silentError: true })
invoiceRows.value = Array.isArray(resp && resp.data) ? resp.data : []
} catch (e) { /* 忽略 */ }
}
/**
* 保存成功后, 对所有需要识别的 material 提交 OCR 任务 (并行 fire-and-forget).
* 后端 InvoiceOcrService.submitRecognition:
* - 单文件: 后台 OCR → 是发票 → update invoice + 回写 material.amount
* 不是发票 → 删占位 invoice 行 + amount=0
* - zip (后端按 ossUrl 后缀判断): 下载解压 → 遍历 → 是发票 → 重传 OSS (新 URL) → 写 invoice 行
* 不是发票 → 跳过 (不入库)
* - 替换 (oldMaterialId != null): 先 DELETE invoice WHERE material_id=old + amount=0
*
* 前端仅做"提交", 不等结果; 识别状态由 InvoiceOcrScheduler 兜底
*/
function submitOcrForMaterials(items) {
if (!Array.isArray(items) || !items.length) return
items.forEach(m => {
request.post('/business/meeting/invoice/recognize', {
materialId: m.materialId,
meetingId: Number(meetingId.value),
ossUrl: m.ossUrl,
oldMaterialId: m.oldMaterialId // v3 新增 (替换场景)
}, { __silentError: true }).then(r => {
const data = (r && r.data) || {}
if (data.submitted === false) {
ElMessage.warning(`发票识别提交失败 (${m.subType}): ${data.errorMsg || '未知错误'}`)
}
// submitted=true: 静默, 后端在后台跑
}).catch(err => {
console.error('[meeting-detail] invoice ocr submit failed', m, err)
ElMessage.warning(`发票识别异常 (${m.subType}): ${err?.msg || err?.message || ''}`)
})
})
}
// ===================== 提交材料 (三按钮: 提交劳务/会务/全部) =====================
/** 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表 */
async function onSubmitMaterials(types) {
if (!types || !types.length) { ElMessage.warning('无可提交的材料轨'); return }
// 有「已上传未保存」的改动 → 先保存再提交 (与结算同款拦截)
if (hasUnsavedChanges.value) {
try {
await ElMessageBox.confirm(
'检测到尚未保存的上传内容,是否先保存再提交?',
'未保存的改动',
{ type: 'warning', confirmButtonText: '保存并提交', cancelButtonText: '取消' }
)
} catch { return }
}
busy.value.submitMaterial = true
try {
// 保存全部未落库内容 (材料 + 付款凭证 + 海报/邀请函), 与普通「保存」按钮同款 doSave
await doSave()
// __silentError: 拦截器不自动 toast, 错误统一由下方 catch 处理 (避免 axios 拦截器和 catch 双 toast)
await request.post(`/business/meeting/${meetingId.value}/submit-material`, { types }, { __silentError: true })
ElMessage.success('材料已提交, 待合规审核')
await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitMaterial = false }
}
// ===================== 分配 dialog =====================
const assignDialog = ref({
show: false, role: '', roleLabel: '', title: '',
candidates: [], selectedIds: [], loading: false, saving: false
})
async function openAssignDialog(role) {
assignDialog.value = {
show: true,
role,
roleLabel: role === 'supervisor' ? '监察员' : '执行人员',
title: `分配${role === 'supervisor' ? '监察员' : '执行人员'}`,
candidates: [],
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' ? listSponsorOrgs : listExecutorOrgs
const resp = await fn({})
assignDialog.value.candidates = resp?.data || []
} catch (e) { ElMessage.error('加载候选人失败') }
finally { assignDialog.value.loading = false }
}
async function confirmAssign() {
const { role, selectedIds } = assignDialog.value
const url = role === 'supervisor'
? `/business/meeting/supervisor/${meetingId.value}`
: `/business/meeting/executor/${meetingId.value}`
assignDialog.value.saving = true
try {
await request.put(url, { orgIds: selectedIds })
ElMessage.success('分配成功')
assignDialog.value.show = false
await loadStaff()
} catch (e) { ElMessage.error(e?.msg || e?.message || '分配失败') }
finally { assignDialog.value.saving = false }
}
// ===================== 审核 dialog (材料审核, 分轨独立通过/拒绝) =====================
const auditDialog = ref({
show: false, action: '', title: '',
opinion: '', tracks: [], saving: false
})
function openAuditDialog(action, scope = 'ALL') {
const isCompliance = action === 'COMPLIANCE'
const include = type => scope === 'ALL' || scope === type
const tracks = []
if (isCompliance) {
if (include('LABOR') && isC0(row.value.laborAuditStage, row.value.laborComplianceApproved)) tracks.push({ type: 'LABOR', label: '劳务材料', approved: true })
if (include('SERVICE') && isC0(row.value.serviceAuditStage, row.value.serviceComplianceApproved)) tracks.push({ type: 'SERVICE', label: '会务材料', approved: true })
} else {
if (include('LABOR') && isC1(row.value.laborAuditStage, row.value.laborComplianceApproved)) tracks.push({ type: 'LABOR', label: '劳务材料', approved: true })
if (include('SERVICE') && isC1(row.value.serviceAuditStage, row.value.serviceComplianceApproved)) tracks.push({ type: 'SERVICE', label: '会务材料', approved: true })
}
const scopeLabel = scope === 'LABOR' ? '劳务' : scope === 'SERVICE' ? '会务' : '全部'
auditDialog.value = {
show: true,
action,
title: isCompliance ? `合规审核 (材料 · ${scopeLabel})` : `监察意见 (材料 · ${scopeLabel})`,
opinion: '',
tracks,
saving: false
}
}
async function confirmAudit() {
const { action, opinion, tracks } = auditDialog.value
const items = tracks.map(t => ({ type: t.type, approved: t.approved }))
if (!items.length) { ElMessage.warning('无可审核的材料轨'); return }
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, { items, opinion }, { __silentError: true })
ElMessage.success('审核完成')
auditDialog.value.show = false
await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') }
finally { auditDialog.value.saving = false }
}
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
async function onSettle() {
// ① 有「已上传未保存」的改动 → 先保存再结算
if (hasUnsavedChanges.value) {
try {
await ElMessageBox.confirm(
'检测到尚未保存的上传内容(材料/付款凭证等),是否先保存再结算?',
'未保存的改动',
{ type: 'warning', confirmButtonText: '保存并结算', cancelButtonText: '取消' }
)
} catch { return }
busy.value.settle = true
try {
await doSave()
} catch (e) {
ElMessage.error('保存失败,已取消结算:' + (e?.msg || e?.message || ''))
return
} finally {
busy.value.settle = false
}
ElMessage.success('已保存')
}
// ② 结算确认
try {
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 }
}
async function onFinish() {
try {
await ElMessageBox.confirm('确认完结? 完结后会议流程终止', '完结确认', { type: 'warning' })
} catch { return }
busy.value.finish = true
try {
await request.post(`/business/meeting/${meetingId.value}/finish`, null, { __silentError: true })
ElMessage.success('已完结')
await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '完结失败') }
finally { busy.value.finish = false }
}
/** 审核/结算/完结后: 刷新会议事实字段 + 审核轨迹 */
async function refreshMeeting() {
const info = await bizGet('meeting', meetingId.value)
row.value = (info && (info.data || info)) || row.value
await loadTrail()
}
// ===================== 费用落库值刷新 (轮询) =====================
// 落库费用由后台 FeeCalcScheduler 每分钟汇总回写. fee_calc_status=0 (待汇总) 时,
// 前端每 15s 重拉会议详情, 直到 status=1 自动停, 让"统计中"角标最终落到真实数字.
let feePollTimer = null
function stopFeePolling() {
if (feePollTimer) { clearInterval(feePollTimer); feePollTimer = null }
}
async function refreshFee() {
try {
const resp = await bizGet('meeting', meetingId.value)
const m = (resp && (resp.data || resp)) || {}
row.value = { ...row.value, laborFee: m.laborFee, meetingFee: m.meetingFee, totalFee: m.totalFee, feeCalcStatus: m.feeCalcStatus }
} catch (e) { /* 拉取失败忽略, 下轮再试 */ }
loadInvoices()
// 会务费合计(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() {
stopFeePolling()
feePollTimer = setInterval(refreshFee, 15000)
}
// ===================== 会务材料 打包上传 (下载空目录模板 + 上传回填 biz_meeting_material) =====================
const serviceTplLoading = ref(false)
const serviceSubmitting = ref(false)
const serviceZipInput = ref(null)
/** 下载"会务材料"空目录模板 zip (会务材料/物料制作·酒店·…/ 全空目录) */
async function onDownloadServiceTemplate() {
serviceTplLoading.value = true
try {
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingMaterial/serviceTemplate/${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 {
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()
loadInvoices()
refreshFee()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
serviceSubmitting.value = false
}
}
// ===================== 导航 =====================
function goBack() {
// 角色感知返回: 按 currentRole 跳各自会议列表 (admin/manager/executor/sponsor)
const target = `/${currentRole.value}/meetings`
router.push(target)
}
// ===================== 初始化 =====================
onMounted(load)
onBeforeUnmount(stopFeePolling)
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; width: 100%; max-width: 100%; overflow-x: hidden; }
.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; }
.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; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 32px; }
.info-row { display: flex; align-items: center; gap: 8px; }
.info-row-full { grid-column: 1 / -1; }
.info-label { flex: 0 0 110px; font-size: 14px; color: #595959; text-align: right; white-space: nowrap; padding-right: 16px; }
.info-value { font-size: 14px; color: #1a1a1a; }
.info-value.code { font-family: ui-monospace, "Courier New", monospace; color: var(--brand-primary); }
.info-value.fee-val { font-weight: 600; color: #f5222d; }
.tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; }
/*
* 防止页面横向溢出 (宽度超出屏幕) 的关键:
* 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content.
* 参会人表固定宽列 ~1252px + min-width 列 ~1320px, min-content 累加 >2500px,
* 会把 7fr 轨道撑爆, 把右列 audit-column 挤到几 px.
* 2. left-col 与 .card 都要 min-width:0 — .card 是 left-col (flex column) 的 flex item,
* 默认 min-width:auto = 内容 min-content, 会拒绝缩小; 置 0 后 card 才能缩到 left-col 宽度.
* 3. .attendee-table-wrap overflow-x:auto — 表超宽时在容器内横向滚动, 不撑爆外层.
*/
.left-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.material-tabs :deep(.el-tabs__header) { margin-bottom: 12px; }
.material-tabs :deep(.el-tabs__item) { font-size: 14px; }
.file-list { display: flex; flex-direction: column; gap: 12px; }
.file-row { display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px; align-items: center; padding: 4px 0; }
.file-label { font-size: 14px; color: #595959; text-align: right; line-height: 28px; }
.fee-summary { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 10px 0 0; border-top: 1px dashed #e8e8e8; margin-top: 4px; }
.labor-fee-summary { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 10px 0 0; border-top: 1px dashed #e8e8e8; margin-top: 8px; }
.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 { grid-column: 2; justify-self: end; font-size: 13px; font-weight: 600; color: #f56c6c; white-space: nowrap; }
.invoice-table-wrap { margin: 2px 0 8px; overflow-x: auto; }
.invoice-table-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }
.invoice-table { width: 100%; min-width: 640px; }
.invoice-table-amount { font-weight: 600; color: #f56c6c; }
.invoice-table-pending { color: #c0c4cc; }
.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; }
.timeline { position: relative; padding-left: 24px; border-left: 2px solid #e8e8e8; }
.timeline-item { padding: 6px 0 12px 16px; position: relative; }
.timeline-item::before { content: ''; position: absolute; left: -29px; top: 10px; width: 10px; height: 10px; border-radius: 50%; background: var(--brand-primary); }
.timeline-item.done::before { background: #67c23a; }
.timeline-item.pending::before { background: #c0c4cc; }
.timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; }
.timeline-desc { font-size: 12px; color: #8c8c8c; line-height: 1.6; }
.timeline-desc.pending-text { color: #c0c4cc; font-style: italic; }
.timeline-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: #595959; line-height: 1.6; }
.timeline-meta .dot { color: #c0c4cc; }
.track-block { margin-bottom: 6px; }
.track-block:last-child { margin-bottom: 0; }
.track-label-row { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
.cycle-no { font-size: 11px; color: #909399; }
.track-tag { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 400; border-radius: 3px; padding: 1px 6px; line-height: 1.6; vertical-align: middle; }
.track-tag.track-labor { color: var(--brand-primary); background: var(--brand-primary-mix); }
.track-tag.track-service { color: var(--el-color-warning); background: var(--el-color-warning-light-9); }
.track-mini { display: inline-block; min-width: 34px; font-size: 11px; }
.track-mini.track-labor { color: var(--brand-primary); }
.track-mini.track-service { color: var(--el-color-warning); }
.timeline-opinion { margin-top: 4px; font-size: 12px; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-opinion.opinion-reject { color: #f56c6c; background: #fef0f0; }
.timeline-opinion.opinion-approve { color: #389e0d; background: #f6ffed; }
.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; }
.form-actions { display: flex; justify-content: flex-start; padding: 24px 0 0; }
/* 参会人管理 (放在 劳务材料 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; }
.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 列 */
.attendee-form-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; }
.attendee-form-2col :deep(.el-form-item) { margin-bottom: 12px; }
.attendee-form-2col :deep(.el-form-item__content) { min-width: 0; }
/* 实发金额偏离配置值 → 输入框红框软提示 (不拦截录入). */
.fee-warn :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #f56c6c inset !important; }
/* 手机号放大镜 suffix: 可点 + hover 高亮 */
.phone-lookup { cursor: pointer; color: var(--el-text-color-secondary); transition: color 0.2s; }
.phone-lookup:hover { color: var(--brand-primary, #409eff); }
/* 表格外层横向滚动容器: 内容总宽 ~1680px 超出 left-col 宽度时, 容器内出现横向滚动条, 不撑爆外层 grid */
.attendee-table-wrap { overflow-x: auto; max-width: 100%; min-width: 0; }
.row-actions { display: inline-flex; align-items: center; gap: 4px; }
/* 参会人实发金额偏离角色配置值 → 整行淡红背景 (软提示, 不拦截); hover 时加深保持反馈 */
.attendee-table :deep(tr.attendee-fee-warn > td.el-table__cell) { background-color: #fef0f0 !important; }
.attendee-table :deep(tr.attendee-fee-warn:hover > td.el-table__cell) { background-color: #fde2e2 !important; }
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */
.toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */
.filter-form {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important;
min-width: 0 !important;
}
/* 强制所有控件全宽 */
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
/* —— MeetingDetail 特有: 双栏 → 单栏堆叠 —— */
/* cols-row: 8fr/2fr 双栏 → 单栏堆叠 */
.cols-row {
grid-template-columns: 1fr !important;
gap: 12px !important;
}
/* info-grid: 1fr/1fr 双栏 → 单栏 (label+value 上下) */
.info-grid {
grid-template-columns: 1fr !important;
gap: 8px 0 !important;
}
/* info-row: label flex 0 0 110px 改为纵向 (label 上, value 下) */
.info-row {
flex-direction: column !important;
align-items: flex-start !important;
gap: 2px !important;
}
.info-label {
flex: none !important;
width: auto !important;
text-align: left !important;
padding-right: 0 !important;
font-size: 12px !important;
color: var(--el-text-color-secondary) !important;
}
.info-value { font-size: 13px !important; }
/* 卡片 padding 缩窄 */
.card { padding: 14px 16px !important; margin-bottom: 12px !important; }
.section-title { font-size: 15px !important; margin-bottom: 12px !important; }
.page-title { font-size: 18px !important; margin-bottom: 12px !important; }
/* attendee-toolbar 横向滚动 (5 个按钮) */
.attendee-toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
gap: 8px !important;
}
.attendee-toolbar::-webkit-scrollbar { height: 3px; }
.attendee-toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.attendee-toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 28px !important;
}
/* attendee-form-2col: 双栏 → 单栏 */
.attendee-form-2col {
grid-template-columns: 1fr !important;
gap: 0 !important;
}
/* file-row: 110px 1fr → label 上 value 下 */
.file-row {
grid-template-columns: 1fr !important;
gap: 4px 0 !important;
padding: 6px 0 !important;
}
.file-label {
text-align: left !important;
font-size: 12px !important;
line-height: 1.4 !important;
color: var(--el-text-color-secondary) !important;
}
.file-hint { grid-column: 1 !important; padding-left: 0 !important; }
.invoice-amount { grid-column: 1 !important; }
.invoice-table-wrap { overflow-x: auto; }
/* fee-summary: 去掉 110px 左 padding */
.fee-summary {
padding-left: 0 !important;
flex-wrap: wrap !important;
}
/* tab-actions 横滚 (操作按钮多) */
.tab-actions {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.tab-actions::-webkit-scrollbar { height: 3px; }
/* 时间轴缩窄 */
.timeline { padding-left: 18px !important; }
.timeline-item::before { left: -23px !important; }
/* 顶部"返回/操作"按钮组横滚 */
.page-title { flex-wrap: wrap !important; gap: 8px !important; }
/* 表格外层 wrap: 确保手机端表格能横向滚 */
.attendee-table-wrap { -webkit-overflow-scrolling: touch; }
/* 参会人表格 mobile: 字号缩到 11px, padding 紧凑, 行高紧凑 */
.attendee-table {
font-size: 11px !important;
}
.attendee-table :deep(.el-table th.el-table__cell),
.attendee-table :deep(.el-table td.el-table__cell) {
padding: 4px 3px !important;
font-size: 11px !important;
}
.attendee-table :deep(.el-table .row-actions) {
flex-direction: column !important;
align-items: flex-start !important;
gap: 1px !important;
}
.attendee-table :deep(.el-table .row-actions .el-link) {
font-size: 11px !important;
padding: 0 2px !important;
}
/* 标签字号缩窄 */
.attendee-table :deep(.el-table .el-tag) {
font-size: 10px !important;
padding: 0 4px !important;
height: 18px !important;
line-height: 18px !important;
}
/* 摘要行 (el-table__footer) 字号缩窄 */
.attendee-table :deep(.el-table .el-table__footer-wrapper td) {
font-size: 11px !important;
padding: 6px 3px !important;
}
}
</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>