Files
guoju0808/ry-vue3/src/views/manager/MeetingDetail.vue
T

1143 lines
56 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">会议详情</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>
<!-- 监察员 + 执行人员 (各占整行, 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 class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" @click="openAttendeeImport">批量导入</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%;">
<el-table-column prop="name" label="医生" min-width="90" />
<el-table-column prop="phone" label="联系电话" width="120" />
<el-table-column prop="workUnit" label="医院" min-width="140" show-overflow-tooltip />
<el-table-column prop="department" label="科室" min-width="90" show-overflow-tooltip />
<el-table-column prop="title" label="职称" min-width="90" show-overflow-tooltip />
<el-table-column prop="bankName" label="银行" min-width="100" show-overflow-tooltip />
<el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip />
<el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip />
<el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip />
<el-table-column label="应发金额" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="个税税金" width="90" align="right">
<template #default="{ row }">{{ row.tax != null ? '¥ ' + Number(row.tax).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="增值税及附加成本" width="130" align="right">
<template #default="{ row }">{{ row.vatAndSurcharge != null ? '¥ ' + Number(row.vatAndSurcharge).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column label="实发金额" width="110" align="right">
<template #default="{ row }">{{ row.fee != null ? '¥ ' + Number(row.fee).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip />
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<a v-if="row.laborProtocol" :href="row.laborProtocol" 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="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="confirmDeleteAttendee(row)">删除</el-button>
</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>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" />
<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="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" />
<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="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" />
<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" />
<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 @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 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-radio-group v-model="auditDialog.auditType">
<el-radio-button label="MATERIAL" :disabled="auditDialog.auditTypeLocked">材料</el-radio-button>
<el-radio-button label="VOUCHER" :disabled="auditDialog.auditTypeLocked">凭证</el-radio-button>
</el-radio-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="手机号" 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.name" placeholder="未注册手机号将以此为昵称新建账号" />
</el-form-item>
<el-form-item label="工作单位">
<el-input v-model="attendeeDialog.form.workUnit" />
</el-form-item>
<el-form-item label="科室">
<el-input v-model="attendeeDialog.form.department" />
</el-form-item>
<el-form-item label="职称">
<el-input v-model="attendeeDialog.form.title" />
</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="银行">
<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.laborForm" placeholder="如: 授课/主持/评审" />
</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 label="现场照片">
<el-input v-model="attendeeDialog.form.onSitePhotos" placeholder="OSS URLs, 多张用逗号分隔" maxlength="1000" />
</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>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } 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 ImportResultDialog from '@/components/ImportResultDialog.vue'
import { ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// ===================== 状态 =====================
const meetingId = ref('')
const row = 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, submitVoucher: false })
const currentUserId = computed(() => userStore.user?.userId)
const currentRole = computed(() => userStore.role)
const isManager = computed(() => currentRole.value === 'manager')
// ===================== 工具 =====================
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 = { INIT: '待提交', SUBMITTED: '已提交', COMPLIANCE_APPROVED: '合规通过', APPROVED: '监察通过' }
function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v }
// ===================== 材料管理 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_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
]
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.currentStage === 'SUBMITTED' && row.auditResult === 'APPROVED'
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
* slot: PRE = 节点1 (会议已执行), POST = 节点5 (结算), DONE = 节点6 (完结)
*/
function fixedNodeStatus(slot) {
const cur = row.value.currentStage
if (slot === 'PRE') return ['EXECUTED', 'SETTLING', 'COMPLETED'].includes(cur) ? 'done' : 'pending'
if (slot === 'POST') return ['SETTLING', 'COMPLETED'].includes(cur) ? 'done' : 'pending'
if (slot === 'DONE') return cur === 'COMPLETED' ? 'done' : 'pending'
return 'pending'
}
function nodeDesc(slot) {
const cur = row.value.currentStage
if (slot === 'PRE') return ['EXECUTED', 'SETTLING', 'COMPLETED'].includes(cur) ? '已执行' : '待执行'
if (slot === 'POST') return ['SETTLING', 'COMPLETED'].includes(cur) ? '已结算' : '未结算'
if (slot === 'DONE') return cur === 'COMPLETED' ? '已完结' : '未完结'
return '-'
}
// ===================== 按钮显隐 =====================
const isAssignedExecutor = computed(() => executors.value.some(u => u.userId === currentUserId.value))
const isAssignedSupervisor = computed(() => supervisors.value.some(u => u.userId === currentUserId.value))
const canSubmitMaterial = computed(() => isAssignedExecutor.value && row.value.materialAuditStage === 'INIT')
const canSubmitVoucher = computed(() => isAssignedExecutor.value && row.value.voucherAuditStage === 'INIT')
function canComplianceAudit(type) {
if (!isManager.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
return s === 'SUBMITTED'
}
function canSupervisionAudit(type) {
if (!isAssignedSupervisor.value) return false
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
return s === 'COMPLIANCE_APPROVED'
}
// ===================== 加载 =====================
/** 后端返回的完整 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) { target.url = item.ossUrl || ''; target.fileName = item.fileName || '' }
})
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
}
// ===================== 参会人 CRUD =====================
/** 会议下的参会人列表 (放在 劳务材料 tab 表格) */
const attendeeRows = ref([])
const attendeeLoading = ref(false)
/** 参会人 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: '',
laborForm: '',
feePreTax: 0,
tax: 0,
vatAndSurcharge: 0,
fee: 0,
summary: '',
onSitePhotos: ''
}
}
function resetAttendeeForm() {
attendeeDialog.value = {
show: false, title: '', editing: false,
form: emptyAttendeeForm(), saving: false
}
}
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
}
}
/** 打开新增/编辑 dialog; row=null → 新增 */
function openAttendeeDialog(row) {
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 || '',
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
}
}
}
/** 提交 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[3-9]\d{9}$/)) {
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 || '删除失败')
}
}
// ===================== 参会人 Excel 批量导入 (沿用 dialog-import SKILL) =====================
const importOpen = ref(false)
const importing = ref(false)
const importResultOpen = ref(false)
const importResult = ref(null)
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(() => `/dev-api/business/meetingAttendee/importData?meetingId=${meetingId.value}`)
function openAttendeeImport() {
importResult.value = null
importOpen.value = true
}
function downloadAttendeeImportTpl() {
fetch('/dev-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('模板下载失败'))
}
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)) || {}
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees()])
} catch (e) {
console.error('[meeting-detail] load failed', e)
row.value = {}
} finally { loading.value = false }
}
// ===================== 保存 (材料) =====================
async function onSave() {
if (saving.value) return
saving.value = true
try {
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) || []
ElMessage.success(`保存成功 (${saved.length} 条)`)
// 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
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)
ElMessage.info(`已提交 ${toOcr.length} 个识别任务 (后台执行)`)
}
// 5. 1.5s 后重拉 materials, 让 amount 字段更新可见
if (toOcr.length) {
setTimeout(() => loadMaterials(), 1500)
}
} 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 || ''}`)
})
})
}
// ===================== 提交材料 / 提交凭证 =====================
async function onSubmitMaterial() {
busy.value.submitMaterial = true
try {
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-material`)
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 {
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-voucher`)
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: '', auditType: 'MATERIAL', auditTypeLocked: false,
opinion: '', approved: true, saving: false
})
function openAuditDialog(action, auditType) {
auditDialog.value = {
show: true,
action,
auditType,
auditTypeLocked: !!auditType,
opinion: '',
approved: true,
saving: false
}
}
async function confirmAudit() {
const { action, auditType, opinion, approved } = auditDialog.value
const url = action === 'COMPLIANCE'
? `/business/meeting/${meetingId.value}/audit-compliance`
: `/business/meeting/${meetingId.value}/audit-supervision`
auditDialog.value.saving = true
try {
const resp = await request.post(url, { auditType, approved, opinion })
const newStage = (resp && resp.data) || ''
if (auditType === 'MATERIAL') row.value.materialAuditStage = newStage
else row.value.voucherAuditStage = newStage
ElMessage.success(approved ? '审核通过' : '已拒绝')
auditDialog.value.show = false
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') }
finally { auditDialog.value.saving = false }
}
// ===================== 占位 =====================
function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') }
function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') }
// ===================== 导航 =====================
function goBack() { router.push('/manager/meetings') }
// ===================== 初始化 =====================
onMounted(load)
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; width: 100%; }
.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; }
.card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; }
.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); }
.tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cols-row { display: grid; grid-template-columns: 7fr 3fr; gap: 16px; align-items: flex-start; }
/*
* min-width:0 是 CSS Grid 关键: 默认 grid item 的 min-width 是 auto (= 内容最小宽度),
* 参会人表 13 列 min-width 累加 ~1680px 会把 left-col 撑到 1730px, 把 audit-column 挤到 93px.
* 加 min-width:0 让 left-col 可以缩小到 7fr 的网格槽宽度.
*/
.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; }
.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: 1fr 1fr; gap: 16px; }
.audit-column { margin-bottom: 0; padding: 16px 18px; }
.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; }
</style>