Files
guoju0808/ry-vue3/src/views/meetings/MeetingDetail.vue
T
郭庆泰andClaude 904e28710f feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)
- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境
- 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost
- 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配
- 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度
- 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver
- 清理 biz_support_intent 旧表 (6 Java/XML 删除)
- ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传
- 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 01:00:09 +08:00

1769 lines
87 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-card manager-meeting-detail">
<!-- 面包屑 -->
<div class="breadcrumb">
首页 / 会议管理 / <span class="current">会议详情</span>
</div>
<!-- 页标题 -->
<div class="page-title">
<span>会议详情</span>
<!-- admin / manager 角色专用: 删除会议 (软删, 后端强校验) -->
<el-button v-if="canDeleteMeeting" type="danger" plain size="small" class="admin-delete-btn" :loading="deleting" @click="onAdminDelete">删除会议</el-button>
</div>
<div class="cols-row">
<!-- 左列 -->
<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">{{ row.totalPeriods ?? '-' }}</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.materialAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建时间:</span><span class="info-value">{{ fmtDateTime(row.createTime) }}</span></div>
<div class="info-row"><span class="info-label">凭证审核状态:</span><span class="info-value">{{ fmtAuditStage(row.voucherAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建人员:</span><span class="info-value">{{ row.createBy || '-' }}</span></div>
<div class="info-row"><span class="info-label">劳务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.laborFee) }}</span></div>
<div class="info-row"><span class="info-label">会务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.meetingFee) }}</span></div>
<div class="info-row"><span class="info-label">总费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.totalFee) }}</span>
<el-tag v-if="row.feeCalcStatus === 0" type="info" size="small" effect="plain">统计中…</el-tag>
</div>
<!-- 监察员 + 执行人员 (各占整行, 2 ) -->
<div class="info-row info-row-full">
<span class="info-label">监察员:</span>
<div class="tag-list">
<el-tag v-for="u in supervisors" :key="u.userId" type="info" size="small">{{ u.userName }}</el-tag>
<span v-if="!supervisors.length" class="text-muted">- 未分配 -</span>
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
</div>
</div>
<div class="info-row info-row-full">
<span class="info-label">执行人员:</span>
<div class="tag-list">
<el-tag v-for="u in executors" :key="u.userId" type="success" size="small">{{ u.userName }}</el-tag>
<span v-if="!executors.length" class="text-muted">- 未分配 -</span>
<el-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
</div>
</div>
</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" class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" @click="openAttendeeImport">批量导入</el-button>
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">邀请参会</el-button>
<el-button size="small" :disabled="!selectedAttendees.length" :loading="esignSending" @click="onBatchEsign">推送电子签</el-button>
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
<span class="toolbar-hint">勾选参会人后点"邀请参会"发站内信 / "推送电子签"发短信+站内信; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
</div>
<!--
参会人表有 13 , min-width 累加 ~1680px, 直接 el-table width:100% 会撑爆 left-col
(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%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange">
<el-table-column v-if="!isSponsor" type="selection" width="42" />
<el-table-column prop="name" label="医生" min-width="90" />
<el-table-column label="联系电话" width="120">
<template #default="{ row }">{{ isSponsor ? maskPhone(row.phone) : row.phone }}</template>
</el-table-column>
<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 label="银行卡号码" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ isSponsor ? maskBankCard(row.bankCard) : row.bankCard }}</template>
</el-table-column>
<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 label="身份证号" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ isSponsor ? maskIdCard(row.idCard) : row.idCard }}</template>
</el-table-column>
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<a v-if="row.idCardAttachments" :href="row.idCardAttachments.split(',')[0]" target="_blank" class="file-link">查看</a>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<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 label="劳务协议" width="120" align="center">
<template #default="{ row }">
<a v-if="protocolUrl(row)" :href="protocolUrl(row)" target="_blank" class="file-link">查看</a>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="现场照片" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="!row.onSitePhotos" class="text-muted">-</span>
<span v-else>{{ row.onSitePhotos.split(',').length }} </span>
</template>
</el-table-column>
<el-table-column label="签字" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.handsign" 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.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" label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
<el-dropdown trigger="click" @command="(cmd) => onAttendeeCommand(cmd, row)">
<el-button link type="primary" size="small" :loading="inviteLoadingId === row.id || esignLoadingId === row.id">
更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<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" 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>
<div class="file-list" style="margin-top: 16px;">
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<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="file-row">
<span class="file-label">日程海报:</span>
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor" />
<div class="file-hint">会议日程海报</div>
</div>
<div 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-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<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>
</div>
</el-tab-pane>
<el-tab-pane label="劳务凭证" name="laborVoucher">
<div class="file-list">
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="会务凭证" name="serviceVoucher">
<div class="file-list">
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="tab-actions">
<el-button v-if="!isSponsor" @click="onPackUpload">打包上传</el-button>
<a class="file-link inline-link" href="javascript:void(0)" @click="onDownloadTemplate">会务材料文件夹模板下载</a>
<span style="flex:1"></span>
<!-- 执行人员: 提交材料 / 提交凭证 -->
<el-button v-if="canSubmitMaterial" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
<el-button v-if="canSubmitVoucher" type="warning" :loading="busy.submitVoucher" @click="onSubmitVoucher">提交凭证</el-button>
<!-- 合规 / 监察审核 -->
<el-button v-if="canComplianceAudit('MATERIAL')" type="success" @click="openAuditDialog('COMPLIANCE','MATERIAL')">合规审核 (材料)</el-button>
<el-button v-if="canComplianceAudit('VOUCHER')" type="success" @click="openAuditDialog('COMPLIANCE','VOUCHER')">合规审核 (凭证)</el-button>
<el-button v-if="canSupervisionAudit('MATERIAL')" type="primary" @click="openAuditDialog('SUPERVISION','MATERIAL')">监察审核 (材料)</el-button>
<el-button v-if="canSupervisionAudit('VOUCHER')" type="primary" @click="openAuditDialog('SUPERVISION','VOUCHER')">监察审核 (凭证)</el-button>
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<el-button v-if="canSettle" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
<el-button v-if="canFinish" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
<el-button v-if="!isSponsor" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
<p class="hint-text">*结算后, 已完结状态, 执行方不能再进行编辑</p>
</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>
<!-- 动态轮次 2/3/4 -->
<template v-for="(round, idx) in displayMaterialRounds" :key="`mat-r${idx}`">
<div :class="['timeline-item', partStatus(round.submit)]">
<div class="timeline-title">执行方提交材料</div>
<div v-if="round.submit" class="timeline-meta">
<span>{{ round.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.submit.auditTime) }}</span>
</div>
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
</div>
<div :class="['timeline-item', partStatus(round.compliance)]">
<div class="timeline-title">合规审核</div>
<div v-if="round.compliance" class="timeline-meta">
<span>{{ round.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span>
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.supervision.auditTime) }}</span>
<el-tag size="small" :type="round.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待监察审核</div>
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
</div>
</template>
<!-- 固定节点 5/6 -->
<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 class="card audit-column">
<div class="section-title">凭证审核</div>
<div class="timeline">
<div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<template v-for="(round, idx) in displayVoucherRounds" :key="`vch-r${idx}`">
<div :class="['timeline-item', partStatus(round.submit)]">
<div class="timeline-title">执行方提交凭证</div>
<div v-if="round.submit" class="timeline-meta">
<span>{{ round.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.submit.auditTime) }}</span>
</div>
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
</div>
<div :class="['timeline-item', partStatus(round.compliance)]">
<div class="timeline-title">合规审核</div>
<div v-if="round.compliance" class="timeline-meta">
<span>{{ round.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span>
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(round.supervision.auditTime) }}</span>
<el-tag size="small" :type="round.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">
{{ round.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</div>
<div v-else class="timeline-desc pending-text">待监察审核</div>
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
</div>
</template>
<div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
</div>
<div :class="['timeline-item', fixedNodeStatus('DONE')]">
<div class="timeline-title">会议完结</div>
<div class="timeline-desc">{{ nodeDesc('DONE') }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分配 dialog -->
<el-dialog v-model="assignDialog.show" :title="assignDialog.title" width="500px">
<el-select v-model="assignDialog.selectedIds" multiple filterable :placeholder="`选择${assignDialog.roleLabel}`" style="width:100%" :loading="assignDialog.loading">
<el-option v-for="u in assignDialog.candidates" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
</el-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="审核类型">
<el-checkbox-group v-model="auditDialog.types">
<el-checkbox label="MATERIAL">材料</el-checkbox>
<el-checkbox label="VOUCHER">凭证</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="意见">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" />
</el-form-item>
<el-form-item label="结果">
<el-radio-group v-model="auditDialog.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
</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 :model="attendeeDialog.form" label-width="100px" class="attendee-form-2col">
<!-- Col 1: 基础档案 (8 字段) -->
<el-form-item label="姓名">
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
</el-form-item>
<el-form-item label="联系方式" required>
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11" />
</el-form-item>
<el-form-item label="医院">
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
</el-form-item>
<el-form-item label="身份证附件">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
</el-form-item>
<el-form-item label="科室">
<doctor-dept-select v-model="attendeeDialog.form.department" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
</el-form-item>
<el-form-item label="职称">
<doctor-title-select v-model="attendeeDialog.form.title" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
</el-form-item>
<el-form-item label="银行">
<el-input v-model="attendeeDialog.form.bankName" placeholder="如: 中国工商银行" />
</el-form-item>
<el-form-item label="银行卡号码">
<el-input v-model="attendeeDialog.form.bankCard" placeholder="持卡人银行卡号" maxlength="25" />
</el-form-item>
<!-- Col 2: 财务 + 备注 (8 字段) -->
<el-form-item label="开户行">
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
</el-form-item>
<el-form-item label="账户名称">
<el-input v-model="attendeeDialog.form.accountName" placeholder="持卡人姓名" />
</el-form-item>
<el-form-item label="开户行地址">
<el-input v-model="attendeeDialog.form.bankRegion" placeholder="如: 北京市东城区" />
</el-form-item>
<el-form-item label="银行详细地址">
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
</el-form-item>
<el-form-item label="角色">
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
</el-form-item>
<el-form-item label="应发金额">
<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="个税税金">
<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="增值税附加">
<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="实发金额">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</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 批量导入 (沿用 dialog-import SKILL: v-if + on-error + 模板下载在框外 + ImportResultDialog) -->
<el-dialog v-model="importOpen" title="批量导入参会人" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
v-if="importOpen"
ref="importUploadRef"
:limit="1"
:headers="importHeaders"
accept=".xlsx, .xls"
:action="importAction"
:on-progress="onImportProgress"
:on-success="onImportSuccess"
:on-error="onImportError"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadAttendeeImportTpl">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImportFile"> </el-button>
</template>
</el-dialog>
<el-dialog v-model="genPosterVisible" title="生成海报" width="440px" :close-on-click-modal="false" append-to-body>
<el-form label-width="120px">
<el-form-item label="添加文字">
<el-switch v-model="genForm.addText" />
</el-form-item>
<el-form-item v-if="genForm.addText" label="文字颜色">
<el-color-picker v-model="genForm.textColor" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="genPosterVisible = false"> </el-button>
<el-button type="primary" :loading="genPosterLoading" @click="doGeneratePoster"> </el-button>
</template>
</el-dialog>
<el-dialog v-model="posterPreviewVisible" title="会议海报预览" width="640px" append-to-body>
<el-image
v-if="row.posterUrl"
:src="row.posterUrl"
:preview-src-list="[row.posterUrl]"
fit="contain"
style="width: 100%;"
/>
<div v-else class="text-muted">尚未生成海报</div>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<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 { listSupporters, listExecutor } 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 DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
import { ElMessageBox } from 'element-plus'
import { Upload, ArrowDown } 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('')
/** 生成海报 loading */
const genPosterLoading = ref(false)
/** 生成海报配置 dialog 显隐 */
const genPosterVisible = ref(false)
/** 生成海报配置: addText=是否添加文字(会议信息), textColor=文字颜色 */
const genForm = reactive({ addText: false, textColor: '#FFFFFF' })
/** 海报预览 dialog 显隐 */
const posterPreviewVisible = ref(false)
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, submitVoucher: false, settle: false, finish: false })
const currentUserId = computed(() => userStore.user?.userId)
const currentRole = computed(() => userStore.role)
const isManager = computed(() => currentRole.value === 'manager')
const isAdmin = computed(() => currentRole.value === 'admin')
const isSponsor = computed(() => currentRole.value === 'sponsor')
/** 软删除会议: admin / manager 可调 (后端 BizMeetingController.DELETE 强校验) */
const canDeleteMeeting = computed(() => isAdmin.value || isManager.value)
const deleting = ref(false)
// ===================== 工具 =====================
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())}`
}
const periodDisplay = computed(() => {
const p = row.value.periodNo
const t = row.value.totalPeriods
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 脱敏展示 (对齐后端 desensitizeHtml 规则: 手机前3+后4 / 银行卡前4+后4 / 身份证前6+后4)
function maskPhone(v) {
const s = String(v ?? '')
if (!s) return ''
if (s.length === 11) return s.slice(0, 3) + '****' + s.slice(7)
return s.replace(/^(\d{3})\d+(\d{4})$/, '$1****$2')
}
function maskBankCard(v) {
const s = String(v ?? '')
if (!s) return ''
return s.length >= 8 ? s.slice(0, 4) + '****' + s.slice(-4) : s
}
function maskIdCard(v) {
const s = String(v ?? '')
if (!s) return ''
return s.length === 18 ? s.slice(0, 6) + '********' + s.slice(14) : s
}
/** sponsor 看脱敏 PDF, 其他角色看完整 PDF */
function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
return row.laborProtocol || ''
}
// ===================== 材料管理 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: '总发票单独推送至OA, 单独下载' },
{ type: 'LABOR', subType: 'L_DETAIL', label: '劳务明细表', hint: '劳务明细表下载' },
{ type: 'LABOR', subType: 'L_AGREEMENT', label: '劳务协议', hint: '劳务协议模板' },
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' },
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
{ type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' },
{ type: 'LABOR', subType: 'L_PANORAMA_BACK', label: '后全景', hint: '会议现场后全景 (不带定位框)' },
{ type: 'LABOR', subType: 'L_EXPERT_PHOTO', label: '专家照片', hint: '专家现场照片' },
{ type: 'LABOR_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
]
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK', 'L_EXPERT_PHOTO'])
/** 扫码拍照 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 为轮次 =====================
/**
* 解析 audit_trail 为轮次结构 (纯函数, 不依赖外部状态)
* 规则: 一次 SUBMITTED + APPROVED = 一轮起点 (执行人员提交)
* 之后 1-2 条填入 compliance / supervision 槽
* 拒绝事件 (REJECTED) 不会被识别为新提交, 仍归入当前轮
*/
function parseRounds(auditType) {
const rows = (auditTrail.value || [])
.filter(r => r.auditType === auditType)
.slice()
.sort((a, b) => new Date(a.auditTime).getTime() - new Date(b.auditTime).getTime())
const rounds = []
let cur = null
for (const row of rows) {
const isSubmit = row.auditResult === 'SUBMITTED'
if (isSubmit) {
if (cur) rounds.push(cur)
cur = { submit: row, compliance: null, supervision: null }
} else if (cur) {
if (!cur.compliance) cur.compliance = row
else if (!cur.supervision) cur.supervision = row
}
}
if (cur) rounds.push(cur)
return rounds
}
const materialRounds = computed(() => parseRounds('MATERIAL'))
const voucherRounds = computed(() => parseRounds('VOUCHER'))
/** 至少保证 1 轮 (空轮 = 三个节点都待提交), 保证 2/3/4 始终渲染 */
const displayMaterialRounds = computed(() =>
materialRounds.value.length ? materialRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
const displayVoucherRounds = computed(() =>
voucherRounds.value.length ? voucherRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
/**
* 节点状态视觉:
* - done → 绿色 (动作完成 + 通过)
* - rejected → 红色 (动作完成但拒绝, 通常触发下一轮)
* - pending → 灰色 (还没轮到)
*/
function partStatus(part) {
if (!part) return 'pending'
if (part.auditResult === 'REJECTED') return 'rejected'
return 'done'
}
/**
* 固定节点 (1/5/6) 状态 — 跟随 current_stage (BizMeetingStageEnum 10 值 code)
* slot: PRE = 节点1 (会议已执行) = NOT_STARTED/FROZEN 之外都算已执行
* POST = 节点5 (结算) = AWAITING_SETTLEMENT / SETTLED / FINISHED (已结算)
* DONE = 节点6 (完结) = 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') return s === 'AWAITING_SETTLEMENT' || s === 'SETTLED' || s === 'FINISHED' ? '已结算' : '未结算'
if (slot === 'DONE') return isOne(row.value.isFinished) ? '已完结' : '未完结'
return '-'
}
// ===================== 按钮显隐 =====================
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
// 后端 submit-material/submit-voucher 用 isExecutorOfProject 做同等强校验.
const isExecutor = computed(() => currentRole.value === 'executor')
const isAssignedSupervisor = computed(() => supervisors.value.some(u => u.userId === currentUserId.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))
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
const canSubmitVoucher = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.voucherAuditStage))
function canComplianceAudit(type) {
if (!isManager.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
return s === 'SUBMITTED' && !isOne(c)
}
function canSupervisionAudit(type) {
if (!isAssignedSupervisor.value && !isSponsorMain.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
return s === 'SUBMITTED' && isOne(c)
}
// 结算/完结 (合规/管理员 手动点击, 后端强校验 role)
const canSettle = computed(() => (isManager.value || isAdmin.value)
&& row.value.materialAuditStage === 'APPROVED' && row.value.voucherAuditStage === '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, ...serviceVoucherRows.value, ...laborVoucherRows.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 || ''
}
})
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
}
// ===================== 参会人 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 }
})
/** 参会人表格底部合计行: "医生"列显示"合计", "应发金额"列显示劳务费合计 */
function attendeeSummary({ columns }) {
const sums = columns.map(() => '')
const nameIdx = columns.findIndex(c => c.label === '医生')
const feeIdx = columns.findIndex(c => c.label === '应发金额')
if (nameIdx >= 0) sums[nameIdx] = '合计'
if (feeIdx >= 0) sums[feeIdx] = '¥ ' + laborFeeTotal.value.toFixed(2)
return sums
}
/** 已勾选的参会人 (批量邀请/推送共用) */
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([])
/** 参会人 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: ''
}
}
function resetAttendeeForm() {
attendeeDialog.value = {
show: false, title: '', editing: false,
form: emptyAttendeeForm(), saving: false
}
}
// ===================== 金额联动 (照搬 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
attendeeDialog.value.form.tax = Number(tax.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 items = String(val).split(',').map(s => s.trim()).filter(Boolean)
let sum = 0
let matchedAny = false
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) {
sum += Number(matched.amount) || 0
matchedAny = true
}
})
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
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 })
}
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
async function confirmAttendee() {
const { editing, form } = attendeeDialog.value
if (!form.phone || !form.phone.trim()) {
ElMessage.warning('请输入手机号')
return
}
if (!form.phone.trim().match(/^1\d{10}$/)) {
ElMessage.warning('手机号格式不正确')
return
}
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
}
}
/** 删除参会人 (单条确认) */
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 批量导入 (沿用 dialog-import SKILL) =====================
const importOpen = ref(false)
const importing = ref(false)
const importResultOpen = ref(false)
const importResult = ref(null)
const exporting = ref(false)
const importUploadRef = ref(null)
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action; meetingId 走 query 串
const importAction = computed(() => `${import.meta.env.VITE_APP_BASE_API}/business/meetingAttendee/importData?meetingId=${meetingId.value}`)
function openAttendeeImport() {
importResult.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
}
}
function submitImportFile() {
importUploadRef.value?.submit()
}
function onImportProgress() { importing.value = true }
function onImportSuccess(res) {
importing.value = false
const result = (res && res.data) ? res.data : res
if (!result || typeof result.okNum !== 'number') {
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
importResult.value = null
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
// 刷新列表 (新参会人会出现)
loadAttendees()
}
function onImportError(err) {
importing.value = false
let msg = '上传失败, 请重试'
if (err && err.response != null) {
try {
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
msg = body.msg || msg
} catch { /* JSON 解析失败用兜底 */ }
} else if (err && err.message) {
msg = err.message
}
ElMessage.error(msg)
importResult.value = null
}
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 || ''
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles()])
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
} 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 = []
}
}
// ===================== 保存 (材料) =====================
/**
* 把当前 4 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
* onSave (保存按钮) 与 onSubmitMaterial/onSubmitVoucher (提交前自动落库) 共用.
*/
async function saveMaterials() {
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.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 isZip = isZipUrl(m.ossUrl)
const old = preSaveState.get(m.subType)
if (!old) {
// 新增
toOcr.push({ materialId: m.id, ossUrl: m.ossUrl, isZip, oldMaterialId: null, subType: m.subType })
} else if (old.ossUrl !== m.ossUrl) {
// 替换 (URL 变了 → 后端需先清旧 invoice + amount=0 再 OCR)
toOcr.push({
materialId: m.id, ossUrl: m.ossUrl, isZip,
oldMaterialId: old.id, subType: m.subType
})
}
// 未变: 跳过
}
if (toOcr.length) {
submitOcrForMaterials(toOcr)
}
// 5. 1.5s 后重拉 materials, 让 amount 字段更新可见
if (toOcr.length) {
setTimeout(() => loadMaterials(), 1500)
}
return { saved, ocrCount: toOcr.length }
}
/** 保存日程海报到 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), scheduleUrl: scheduleUrl.value }, { __silentError: true })
row.value.scheduleUrl = scheduleUrl.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
}
async function onSave() {
if (saving.value) return
saving.value = true
try {
const { saved, ocrCount } = await saveMaterials()
await saveSchedulePoster()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
refreshFee()
ElMessage.success(`保存成功 (${saved.length} 条)`)
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)$/i.test(url || '')
}
/** 判断是否 zip (走 zip 路径: 解压 + 重传 OSS) */
function isZipUrl(url) {
return /\.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)
}
/**
* 保存成功后, 对所有需要识别的 material 提交 OCR 任务 (并行 fire-and-forget).
* 后端 InvoiceOcrService.submitRecognition:
* - 单文件 (isZip=false): 后台 OCR → 是发票 → update invoice + 回写 material.amount
* 不是发票 → 删占位 invoice 行 + amount=0
* - zip (isZip=true): 下载解压 → 遍历 → 是发票 → 重传 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,
isZip: m.isZip, // v3 新增
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 onSubmitMaterial() {
busy.value.submitMaterial = true
try {
await saveMaterials()
// __silentError: 拦截器不自动 toast, 错误统一由下方 catch 处理 (避免 axios 拦截器和 catch 双 toast)
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-material`, null, { __silentError: true })
ElMessage.success('材料已提交, 待合规审核')
row.value.materialAuditStage = (resp && resp.data) || 'SUBMITTED'
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitMaterial = false }
}
async function onSubmitVoucher() {
busy.value.submitVoucher = true
try {
await saveMaterials()
// __silentError: 同上, 避免双 toast
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-voucher`, null, { __silentError: true })
ElMessage.success('凭证已提交, 待合规审核')
row.value.voucherAuditStage = (resp && resp.data) || 'SUBMITTED'
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitVoucher = false }
}
// ===================== 分配 dialog =====================
const assignDialog = ref({
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 => u.userId),
loading: false,
saving: false
}
assignDialog.value.loading = true
try {
const fn = role === 'supervisor' ? listSupporters : listExecutor
const resp = await fn({ status: '0', accountType: 'MAIN' })
// 兼容不同返回结构
const rows = resp?.rows || resp?.data?.rows || (Array.isArray(resp) ? resp : [])
assignDialog.value.candidates = rows
} 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, { userIds: 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: '', types: [], title: '',
opinion: '', approved: true, saving: false
})
function openAuditDialog(action, auditType) {
const types = [auditType]
const other = auditType === 'MATERIAL' ? 'VOUCHER' : 'MATERIAL'
const eligible = action === 'COMPLIANCE' ? canComplianceAudit : canSupervisionAudit
if (eligible(other)) types.push(other)
auditDialog.value = {
show: true,
action,
types,
title: action === 'COMPLIANCE' ? '合规审核' : '监察审核',
opinion: '',
approved: true,
saving: false
}
}
async function confirmAudit() {
const { action, types, opinion, approved } = auditDialog.value
if (!types || !types.length) { ElMessage.warning('请选择审核类型'); return }
const auditType = types.length === 2 ? 'BOTH' : types[0]
const url = action === 'COMPLIANCE'
? `/business/meeting/${meetingId.value}/audit-compliance`
: `/business/meeting/${meetingId.value}/audit-supervision`
auditDialog.value.saving = true
try {
await request.post(url, { auditType, approved, opinion }, { __silentError: true })
ElMessage.success(approved ? '审核通过' : '已拒绝')
auditDialog.value.show = false
await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') }
finally { auditDialog.value.saving = false }
}
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
async function onSettle() {
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()
} 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) { /* 拉取失败忽略, 下轮再试 */ }
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
else stopFeePolling()
}
function scheduleFeePoll() {
stopFeePolling()
feePollTimer = setInterval(refreshFee, 15000)
}
// ===================== 占位 =====================
function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') }
function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') }
// ===================== admin / manager 删除会议 (软删) =====================
/**
* 后端 BizMeetingController.DELETE 强校验 role in (admin, manager).
* 详情页删除后跳回当前角色对应的会议列表 (避免停在已删会议的空页).
*/
async function onAdminDelete() {
try {
await ElMessageBox.confirm(
`确定要删除会议「${row.value.meetingName || ''}」吗?\n删除为软删除, 数据保留审计追溯, 医生/参会人将不再看到该会议.`,
'删除确认', { type: 'warning' }
)
} catch { return }
deleting.value = true
try {
await request.delete(`/business/meeting/${meetingId.value}`)
ElMessage.success('删除成功')
router.push(isAdmin.value ? '/admin/meetings' : '/manager/meetings')
} catch (e) {
ElMessage.error(e?.msg || '删除失败')
} finally {
deleting.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; }
.admin-delete-btn { font-size: 13px; }
.card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; min-width: 0; }
.section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
.text-muted { color: #8c8c8c; font-size: 13px; }
.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, 7fr) minmax(0, 3fr); 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; align-items: center; gap: 10px; padding: 10px 0 0 110px; border-top: 1px dashed #e8e8e8; margin-top: 4px; }
.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; }
.invoice-amount { font-size: 13px; font-weight: 600; color: #f56c6c; padding-left: 8px; white-space: nowrap; }
.file-link { color: var(--brand-primary); text-decoration: none; font-size: 14px; }
.file-link:hover { text-decoration: underline; }
.file-link.inline-link { font-size: 13px; }
.tab-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.timeline { position: relative; padding-left: 24px; border-left: 2px solid #e8e8e8; }
.timeline-item { padding: 6px 0 12px 16px; position: relative; }
.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; }
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; min-width: 0; }
.audit-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; }
.toolbar-hint { font-size: 12px; color: #909399; line-height: 1.5; }
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }
/* 参会人 dialog 2 列布局: el-form 用 grid, 每个 el-form-item 占 1 列, form-hint 横跨 2 列 */
.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; }
/* 表格外层横向滚动容器: 内容总宽 ~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; }
</style>