Files
guoju0808/ry-vue3/src/views/meetings/MeetingDetail.vue
T
guoju0808 8542d545d5 feat: MeetingDetail 参会人表改用 GrTable + 移除合计行
GrTable 增强 (sibling subagent 已完成重构):
- mobile 模式从 el-table expand 行改为 el-descriptions 卡片列表
- 每行一张卡, 显示所有列
- 通过 extractDefaultSlot 抓取自定义 #default slot 的 vnode
- renderTemplate 在 mobile 卡片里渲染原始 vnode (含 maskPhone/maskBankCard/
  maskIdCard/openPreview/el-link/el-tag 等自定义 template)
- 支持无 prop 自定义 template 列在 mobile 正确渲染

MeetingDetail 改动:
- el-table → GrTable (show-summary 移除, 不要合计行)
- 移除 attendeeSummary 函数 (合计不再需要)
- 移除之前 mobile attendee-table 紧凑规则 (mobile 走卡片分支, 不需要)
- v-bind $attrs 透传 v-loading/border/size 给桌面 el-table
- 修复 GrTable 闭合标签 (</el-table> → </GrTable>)

桌面端 el-table 完整渲染不变 (sibling 完整保留透传逻辑)
mobile 端: 每行一张 el-descriptions 卡, 27+ 列全部展示, 自定义 template 正常渲染
2026-08-26 12:14:25 +00:00

2165 lines
104 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-card manager-meeting-detail">
<!-- 面包屑 -->
<div class="breadcrumb">
首页 / 会议管理 / <span class="current">会议详情</span>
</div>
<!-- 页标题 -->
<div class="page-title">
<span>会议详情</span>
</div>
<div class="cols-row">
<!-- 左列 -->
<div class="left-col">
<!-- 1. 会议基本信息 -->
<div class="card">
<div class="section-title">会议基本信息</div>
<div class="info-grid" v-loading="loading">
<div class="info-row"><span class="info-label">项目编号:</span><span class="info-value code">{{ row.projectNo || '-' }}</span></div>
<div class="info-row"><span class="info-label">会议名称:</span><span class="info-value">{{ row.meetingName || '-' }}</span></div>
<div class="info-row"><span class="info-label">项目名称:</span><span class="info-value">{{ row.projectName || '-' }}</span></div>
<div class="info-row"><span class="info-label">期数:</span><span class="info-value">{{ periodDisplay }}</span></div>
<div class="info-row"><span class="info-label">总场次/总期数:</span><span class="info-value">{{ 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.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" @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>
</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">
<GrTable :data="attendeeRows" v-loading="attendeeLoading" border size="small" @selection-change="onAttendeeSelectionChange" class="attendee-table">
<el-table-column v-if="!isSponsor && !isReadonly" type="selection" width="42" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column prop="name" label="医生" min-width="90" />
<el-table-column label="联系电话" width="120">
<template #default="{ row }">{{ isSponsor ? maskPhone(row.phone) : row.phone }}</template>
</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 }">
<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="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 }">
<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="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 && !isReadonly" label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-link :underline="false" type="primary" @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" 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>
</GrTable>
</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 v-if="!isExecutor" 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-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/`" :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>
<!-- 合规 / 监察审核 (材料, 分轨三按钮) -->
<el-button v-if="isManager && !isReadonly && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
<el-button v-if="isManager && !isReadonly && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
<el-button v-if="isManager && !isReadonly && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">审核劳务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">审核会务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">审核全部</el-button>
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<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>
<!-- 节点 2: 审核时间轴 (内嵌 提交 合规 监察 三步, 两轨并列, 保留重交历史) -->
<div :class="['timeline-item', auditNodeStatus()]">
<div class="timeline-title">审核时间轴</div>
<div class="audit-sub-timeline">
<div :class="['sub-timeline-item', nodeStatus('submit')]">
<div class="sub-timeline-title">执行方提交材料</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待执行人员提交</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text">待提交</span>
</div>
<div v-for="(c, i) in track.cycles" :key="i" class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<span>{{ c.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.submit.auditTime) }}</span>
</div>
</div>
</div>
<div :class="['sub-timeline-item', nodeStatus('compliance')]">
<div class="sub-timeline-title">合规审核</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待合规审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance">
<span>{{ c.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待审核</span>
</div>
<div v-if="c.compliance?.opinion" class="timeline-opinion">💬 {{ c.compliance.opinion }}</div>
</template>
</div>
</div>
<div :class="['sub-timeline-item', nodeStatus('supervision')]">
<div class="sub-timeline-title">监察意见</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待监察审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance && c.compliance.auditResult === 'REJECTED'">
<span class="pending-text">已退回 · 未进入监察</span>
</template>
<template v-else-if="c.supervision">
<span>{{ c.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.supervision.auditTime) }}</span>
<el-tag size="small" :type="c.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待监察审核</span>
</div>
<div v-if="c.supervision?.opinion" class="timeline-opinion">💬 {{ c.supervision.opinion }}</div>
</template>
</div>
</div>
</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 :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="身份证附件" style="grid-column: 1 / -1">
<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="角色" style="grid-column: 1 / -1">
<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" />
<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 GrTable from '@/components/GrTable.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 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('')
/** 邀请函 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())}`
}
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 || ''
}
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
function idCardFiles(row) {
return (row.idCardAttachments || '').split(',').map(s => s.trim()).filter(Boolean)
}
// ===================== 材料管理 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 []
})
/**
* 审核时间轴内嵌子步骤 (提交/合规/监察) 状态 — 由物理阶段 (两轨取最小) 推导.
* - submit: 越过 NOT_STARTED/RUNNING (已提交过) 即 done
* - compliance: RECTIFYING=rejected, AWAITING_COMPLIANCE=pending(当前), 之后=done
* - supervision: RECTIFYING=rejected, AWAITING_SUPERVISION=pending(当前), 之后=done
*/
function nodeStatus(slot) {
const s = derivePhysicalStage(row.value)
if (slot === 'submit') return (s !== 'NOT_STARTED' && s !== 'RUNNING') ? 'done' : 'pending'
if (slot === 'compliance') {
if (s === 'RECTIFYING') return 'rejected'
if (s === 'AWAITING_COMPLIANCE') return 'pending'
if (['AWAITING_SUPERVISION', 'AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s)) return 'done'
return 'pending'
}
if (slot === 'supervision') {
if (s === 'RECTIFYING') return 'rejected'
if (s === 'AWAITING_SUPERVISION') return 'pending'
if (['AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s)) return 'done'
return 'pending'
}
return 'pending'
}
/**
* 固定节点 (会议已执行/结算/完结) 状态 — 跟随 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') return s === 'AWAITING_SETTLEMENT' || s === 'SETTLED' || s === 'FINISHED' ? '已结算' : '未结算'
if (slot === 'DONE') return isOne(row.value.isFinished) ? '已完结' : '未完结'
return '-'
}
/** 审核时间轴 组节点状态: 两轨均审核通过 (进入待结算) 后 done, 否则 pending (内嵌子步骤各自带色). */
function auditNodeStatus() {
const s = derivePhysicalStage(row.value)
return ['AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s) ? 'done' : 'pending'
}
// ===================== 按钮显隐 =====================
// 执行方判定: 用 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))
/** 单轨是否处于「可提交/可编辑」态 (未提交 或 被驳回) */
const laborEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
/** 单轨状态判定: 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 || ''
}
})
} 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 }
})
/** 已勾选的参会人 (批量邀请/推送共用) */
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
}
}
// ===================== 劳务协议 批量收发 (下载空目录模板 + 上传回填) =====================
const agreementTplLoading = ref(false)
const agreementSubmitting = ref(false)
const agreementZipInput = ref(null)
/** 下载"劳务协议"空目录模板 zip (劳务协议/{序号}_{姓名}/ 全空目录), 走 fetch 直接流式下载 */
async function onDownloadAgreementTemplate() {
agreementTplLoading.value = true
try {
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingAttendee/agreementTemplate/${meetingId.value}`,
{ headers: importHeaders.value }
)
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '劳务协议模板.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error('模板下载失败')
} finally {
agreementTplLoading.value = false
}
}
function onPickAgreementZip() {
agreementZipInput.value?.click()
}
async function onAgreementZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许重复选择同一文件
if (!file) return
const fd = new FormData()
fd.append('file', file)
agreementSubmitting.value = true
try {
const resp = await request.post(
`/business/meetingAttendee/uploadAgreements?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 位参会人的劳务协议`)
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
agreementSubmitting.value = false
}
}
// ===================== 专家照片 批量收发 (下载空目录模板 + 上传回填 on_site_photos) =====================
const expertPhotoTplLoading = ref(false)
const expertPhotoSubmitting = ref(false)
const expertPhotoZipInput = ref(null)
/** 下载"专家照片"空目录模板 zip (专家照片/{序号}_{姓名}/ 全空目录) */
async function onDownloadExpertPhotoTemplate() {
expertPhotoTplLoading.value = true
try {
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingAttendee/expertPhotoTemplate/${meetingId.value}`,
{ headers: importHeaders.value }
)
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '专家照片目录.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error('模板下载失败')
} finally {
expertPhotoTplLoading.value = false
}
}
function onPickExpertPhotoZip() {
expertPhotoZipInput.value?.click()
}
async function onExpertPhotoZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许重复选择同一文件
if (!file) return
const fd = new FormData()
fd.append('file', file)
expertPhotoSubmitting.value = true
try {
const resp = await request.post(
`/business/meetingAttendee/uploadExpertPhotos?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 位参会人的专家照片`)
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
} finally {
expertPhotoSubmitting.value = false
}
}
function submitImportFile() {
importUploadRef.value?.submit()
}
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 || ''
invitationUrl.value = row.value.invitationUrl || ''
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles()])
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
if (route.query.settle === '1' && canSettle.value) {
router.replace(route.path)
onSettle()
}
} catch (e) {
console.error('[meeting-detail] load failed', e)
row.value = {}
} 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 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 }
}
/**
* 保存付款凭证 (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), 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), 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
}
async function onSave() {
if (saving.value) return
saving.value = true
try {
const { saved, ocrCount } = await saveMaterials()
await saveSchedulePoster()
await saveInvitation()
if (canUploadVoucher.value) await saveVouchers()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
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 onSubmitMaterials(types) {
if (!types || !types.length) { ElMessage.warning('无可提交的材料轨'); return }
busy.value.submitMaterial = true
try {
await saveMaterials()
// __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 }
}
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
// 结算前必须先已上传保存付款凭证 (劳务/会务凭证 tab), 后端 settle 校验凭证存在, 缺失则报错.
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()
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) { /* 拉取失败忽略, 下轮再试 */ }
if (row.value.feeCalcStatus === 0) 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()
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; 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; }
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
.invoice-amount { font-size: 13px; font-weight: 600; color: #f56c6c; padding-left: 8px; white-space: nowrap; }
.file-link { color: var(--brand-primary); text-decoration: none; font-size: 14px; }
.file-link:hover { text-decoration: underline; }
.file-link.inline-link { font-size: 13px; }
.tab-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.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; }
.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-sub-timeline { position: relative; padding-left: 14px; border-left: 2px solid #e8e8e8; margin: 6px 0 2px; }
.sub-timeline-item { padding: 4px 0 10px 12px; position: relative; }
.sub-timeline-item:last-child { padding-bottom: 0; }
.sub-timeline-item::before { content: ''; position: absolute; left: -20px; top: 7px; width: 8px; height: 8px; border-radius: 50%; background: var(--brand-primary); }
.sub-timeline-item.done::before { background: #67c23a; }
.sub-timeline-item.pending::before { background: #c0c4cc; }
.sub-timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
.sub-timeline-title { font-size: 12px; font-weight: 600; color: #303133; margin-bottom: 4px; line-height: 1.4; }
.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; }
/* 表格外层横向滚动容器: 内容总宽 ~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; }
/* ========================================
移动端适配 (≤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; }
/* 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; }
.audit-sub-timeline { padding-left: 12px !important; }
.sub-timeline-item::before { left: -18px !important; }
/* 顶部"返回/操作"按钮组横滚 */
.page-title { flex-wrap: wrap !important; gap: 8px !important; }
/* 表格外层 wrap: 确保手机端表格能横向滚 */
.attendee-table-wrap { -webkit-overflow-scrolling: touch; }
}
</style>