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 + 供应商/招标接口切生产域名
2603 lines
125 KiB
Vue
2603 lines
125 KiB
Vue
<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">仅允许导入 xls、xlsx 格式文件</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>
|