feat: 会议提交剩余时间+解冻 + 会务/劳务材料批量下载 + 多角色人员/账号模块完善
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,6 @@
|
||||
<!-- 页标题 -->
|
||||
<div class="page-title">
|
||||
<span>会议详情</span>
|
||||
<!-- admin / manager 角色专用: 删除会议 (软删, 后端强校验) -->
|
||||
<el-button v-if="canDeleteMeeting" type="danger" plain size="small" class="admin-delete-btn" :loading="deleting" @click="onAdminDelete">删除会议</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cols-row">
|
||||
@@ -29,30 +27,31 @@
|
||||
<div class="info-row"><span class="info-label">会议结束时间:</span><span class="info-value">{{ fmtDateTime(row.endTime) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">材料审核状态:</span><span class="info-value">{{ fmtAuditStage(row.materialAuditStage) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建时间:</span><span class="info-value">{{ fmtDateTime(row.createTime) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">凭证审核状态:</span><span class="info-value">{{ fmtAuditStage(row.voucherAuditStage) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建人员:</span><span class="info-value">{{ row.createBy || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">劳务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.laborFee) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.meetingFee) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">总费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.totalFee) }}</span>
|
||||
<el-tag v-if="row.feeCalcStatus === 0" type="info" size="small" effect="plain">统计中…</el-tag>
|
||||
</div>
|
||||
<!-- 监察员 + 执行人员 (各占整行, 跨 2 列) -->
|
||||
<!-- 监察员 + 执行人员 (各占整行, 跨 2 列). 临时: manager 详情页隐掉 -->
|
||||
<template v-if="!isManager">
|
||||
<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>
|
||||
<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-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('supervisor')">分配</el-button>
|
||||
<el-button v-if="isManager && !isReadonly" 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>
|
||||
<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-button v-if="isManager" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
|
||||
<el-button v-if="isManager && !isReadonly" link type="primary" size="small" @click="openAssignDialog('executor')">分配</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,13 +62,12 @@
|
||||
<el-tab-pane label="劳务材料" name="labor">
|
||||
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) -->
|
||||
<div class="attendee-section">
|
||||
<div v-if="!isSponsor" class="attendee-toolbar">
|
||||
<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>
|
||||
<span class="toolbar-hint">勾选参会人后点"邀请参会"发站内信 / 点"推送电子签"发短信+站内信; 手机号未注册将自动建 sys_user (用户名=密码=手机号)</span>
|
||||
</div>
|
||||
<!--
|
||||
参会人表有 13 列, min-width 累加 ~1680px, 直接 el-table width:100% 会撑爆 left-col
|
||||
@@ -79,7 +77,8 @@
|
||||
-->
|
||||
<div class="attendee-table-wrap">
|
||||
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange">
|
||||
<el-table-column v-if="!isSponsor" type="selection" width="42" />
|
||||
<el-table-column 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>
|
||||
@@ -148,7 +147,7 @@
|
||||
<el-tag v-else type="info" size="small">未推送</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isSponsor" label="操作" width="100" align="center" fixed="right">
|
||||
<el-table-column v-if="!isSponsor && !isReadonly" label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" size="small" @click="openAttendeeDialog(row)">编辑</el-button>
|
||||
@@ -175,19 +174,45 @@
|
||||
</div>
|
||||
|
||||
<div class="file-list" style="margin-top: 16px;">
|
||||
<!-- 劳务协议: 改为"按参会人逐个回填"的收发闭环, 不再作为单文件材料直接上传保存 -->
|
||||
<div v-if="!isSponsor && !isReadonly" 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" class="file-uploader" />
|
||||
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly" class="file-uploader" />
|
||||
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<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" 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" />
|
||||
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议日程海报</div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<span class="file-label">邀请函:</span>
|
||||
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<div class="file-hint">会议邀请函</div>
|
||||
</div>
|
||||
<!-- 临时: manager 详情页隐掉海报生成 (含 生成海报/预览海报) -->
|
||||
<div v-if="!isManager && !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>
|
||||
@@ -200,7 +225,7 @@
|
||||
<div class="file-list">
|
||||
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
|
||||
<span class="file-label">{{ r.label }}:</span>
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
|
||||
<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>
|
||||
@@ -209,51 +234,53 @@
|
||||
<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" 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 label="劳务凭证" name="laborVoucher">
|
||||
<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" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<OssFileUploader v-model="r.url" :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 label="会务凭证" name="serviceVoucher">
|
||||
<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" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor" />
|
||||
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
|
||||
<OssFileUploader v-model="r.url" :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">
|
||||
<el-button v-if="!isSponsor" @click="onPackUpload">打包上传</el-button>
|
||||
<a class="file-link inline-link" href="javascript:void(0)" @click="onDownloadTemplate">会务材料文件夹模板下载</a>
|
||||
<span style="flex:1"></span>
|
||||
<!-- 执行人员: 提交材料 / 提交凭证 -->
|
||||
<el-button v-if="canSubmitMaterial" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
|
||||
<el-button v-if="canSubmitVoucher" type="warning" :loading="busy.submitVoucher" @click="onSubmitVoucher">提交凭证</el-button>
|
||||
<!-- 合规 / 监察审核 -->
|
||||
<el-button v-if="canComplianceAudit('MATERIAL')" type="success" @click="openAuditDialog('COMPLIANCE','MATERIAL')">合规审核 (材料)</el-button>
|
||||
<el-button v-if="canComplianceAudit('VOUCHER')" type="success" @click="openAuditDialog('COMPLIANCE','VOUCHER')">合规审核 (凭证)</el-button>
|
||||
<el-button v-if="canSupervisionAudit('MATERIAL')" type="primary" @click="openAuditDialog('SUPERVISION','MATERIAL')">监察审核 (材料)</el-button>
|
||||
<el-button v-if="canSupervisionAudit('VOUCHER')" type="primary" @click="openAuditDialog('SUPERVISION','VOUCHER')">监察审核 (凭证)</el-button>
|
||||
<!-- 执行人员: 提交材料 -->
|
||||
<el-button v-if="canSubmitMaterial && !isReadonly" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</el-button>
|
||||
<!-- 合规 / 监察审核 (材料) -->
|
||||
<el-button v-if="canComplianceAudit() && !isReadonly" type="success" @click="openAuditDialog('COMPLIANCE')">合规审核 (材料)</el-button>
|
||||
<el-button v-if="canSupervisionAudit() && !isReadonly" type="primary" @click="openAuditDialog('SUPERVISION')">监察审核 (材料)</el-button>
|
||||
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
|
||||
<el-button v-if="canSettle" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
|
||||
<el-button v-if="canFinish" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
|
||||
<el-button v-if="!isSponsor" type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||
<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>
|
||||
<el-button v-if="!isSponsor && !isReadonly" 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>
|
||||
@@ -312,68 +339,13 @@
|
||||
</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-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>
|
||||
@@ -381,15 +353,9 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核 dialog (合规/监察通用, 支持材料+凭证多选) -->
|
||||
<!-- 审核 dialog (合规/监察通用, 材料审核) -->
|
||||
<el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="审核类型">
|
||||
<el-checkbox-group v-model="auditDialog.types">
|
||||
<el-checkbox label="MATERIAL">材料</el-checkbox>
|
||||
<el-checkbox label="VOUCHER">凭证</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="意见">
|
||||
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" />
|
||||
</el-form-item>
|
||||
@@ -543,7 +509,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch, nextTick }
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import { bizGet } from '@/api/public'
|
||||
import { listSupporters, listExecutor } from '@/api/system'
|
||||
import { listSponsorOrgs, listExecutorOrgs } from '@/api/system'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import OssFileUploader from '@/components/OssFileUploader.vue'
|
||||
@@ -567,6 +533,8 @@ 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 显隐 */
|
||||
@@ -581,16 +549,18 @@ const saving = ref(false)
|
||||
const supervisors = ref([])
|
||||
const executors = ref([])
|
||||
const auditTrail = ref([])
|
||||
const busy = ref({ submitMaterial: false, submitVoucher: false, settle: false, finish: false })
|
||||
const busy = ref({ submitMaterial: false, settle: false, finish: false })
|
||||
|
||||
const currentUserId = computed(() => userStore.user?.userId)
|
||||
const currentRole = computed(() => userStore.role)
|
||||
const isManager = computed(() => currentRole.value === 'manager')
|
||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||
const isSponsor = computed(() => currentRole.value === 'sponsor')
|
||||
/** 软删除会议: admin / manager 可调 (后端 BizMeetingController.DELETE 强校验) */
|
||||
const canDeleteMeeting = computed(() => isAdmin.value || isManager.value)
|
||||
const deleting = ref(false)
|
||||
/** 只读模式: 从会议列表「查看」跳入 (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') }
|
||||
@@ -649,19 +619,16 @@ const ROW_CONFIG = [
|
||||
{ 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: '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', subType: 'L_EXPERT_PHOTO', label: '专家照片', hint: '专家现场照片' },
|
||||
{ type: 'LABOR_VOUCHER', subType: 'LV_PAYMENT', label: '劳务付款凭证', hint: '劳务付款凭证文件' },
|
||||
{ type: 'SERVICE_VOUCHER', subType: 'SV_PAYMENT', label: '会务付款凭证', hint: '会务付款凭证文件' }
|
||||
]
|
||||
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
|
||||
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK', 'L_EXPERT_PHOTO'])
|
||||
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) }
|
||||
@@ -700,15 +667,11 @@ function parseRounds(auditType) {
|
||||
}
|
||||
|
||||
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 }]
|
||||
)
|
||||
|
||||
/**
|
||||
* 节点状态视觉:
|
||||
@@ -746,9 +709,10 @@ function nodeDesc(slot) {
|
||||
// ===================== 按钮显隐 =====================
|
||||
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
|
||||
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
|
||||
// 后端 submit-material/submit-voucher 用 isExecutorOfProject 做同等强校验.
|
||||
// 后端 submit-material 用 isExecutorOfProject 做同等强校验.
|
||||
const isExecutor = computed(() => currentRole.value === 'executor')
|
||||
const isAssignedSupervisor = computed(() => supervisors.value.some(u => u.userId === currentUserId.value))
|
||||
// 监察员判定: 后端 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))
|
||||
@@ -756,23 +720,17 @@ const frozen = computed(() => isOne(row.value.isFrozen))
|
||||
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
|
||||
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
|
||||
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
|
||||
const canSubmitVoucher = computed(() => isExecutor.value && executed.value && !frozen.value
|
||||
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.voucherAuditStage))
|
||||
function canComplianceAudit(type) {
|
||||
function canComplianceAudit() {
|
||||
if (!isManager.value) return false
|
||||
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
|
||||
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
|
||||
return s === 'SUBMITTED' && !isOne(c)
|
||||
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
|
||||
}
|
||||
function canSupervisionAudit(type) {
|
||||
function canSupervisionAudit() {
|
||||
if (!isAssignedSupervisor.value && !isSponsorMain.value) return false
|
||||
const s = type === 'MATERIAL' ? row.value.materialAuditStage : row.value.voucherAuditStage
|
||||
const c = type === 'MATERIAL' ? row.value.materialComplianceApproved : row.value.voucherComplianceApproved
|
||||
return s === 'SUBMITTED' && isOne(c)
|
||||
return row.value.materialAuditStage === 'SUBMITTED' && isOne(row.value.materialComplianceApproved)
|
||||
}
|
||||
// 结算/完结 (合规/管理员 手动点击, 后端强校验 role)
|
||||
const canSettle = computed(() => (isManager.value || isAdmin.value)
|
||||
&& row.value.materialAuditStage === 'APPROVED' && row.value.voucherAuditStage === 'APPROVED'
|
||||
&& row.value.materialAuditStage === 'APPROVED'
|
||||
&& !isOne(row.value.isSettled))
|
||||
const canFinish = computed(() => (isManager.value || isAdmin.value)
|
||||
&& isOne(row.value.isSettled) && !isOne(row.value.isFinished))
|
||||
@@ -786,7 +744,7 @@ async function loadMaterials() {
|
||||
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]
|
||||
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)
|
||||
@@ -1216,6 +1174,114 @@ async function onExportAttendees() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 劳务协议 批量收发 (下载空目录模板 + 上传回填) =====================
|
||||
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()
|
||||
}
|
||||
@@ -1275,8 +1341,14 @@ async function load() {
|
||||
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 = {}
|
||||
@@ -1309,12 +1381,12 @@ async function loadProjectRoles() {
|
||||
|
||||
// ===================== 保存 (材料) =====================
|
||||
/**
|
||||
* 把当前 4 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
|
||||
* 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
|
||||
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
|
||||
* onSave (保存按钮) 与 onSubmitMaterial/onSubmitVoucher (提交前自动落库) 共用.
|
||||
* onSave (保存按钮) 与 onSubmitMaterial (提交前自动落库) 共用.
|
||||
*/
|
||||
async function saveMaterials() {
|
||||
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value, ...serviceVoucherRows.value, ...laborVoucherRows.value]
|
||||
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value]
|
||||
|
||||
// 1. 快照保存前的 material (subType -> {id, ossUrl})
|
||||
// 用于保存后区分: 新增 / 替换 / 未变
|
||||
@@ -1369,6 +1441,17 @@ async function saveMaterials() {
|
||||
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 || ''
|
||||
@@ -1377,6 +1460,14 @@ async function saveSchedulePoster() {
|
||||
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) {
|
||||
@@ -1416,6 +1507,8 @@ async function onSave() {
|
||||
try {
|
||||
const { saved, ocrCount } = await saveMaterials()
|
||||
await saveSchedulePoster()
|
||||
await saveInvitation()
|
||||
if (canUploadVoucher.value) await saveVouchers()
|
||||
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
|
||||
refreshFee()
|
||||
ElMessage.success(`保存成功 (${saved.length} 条)`)
|
||||
@@ -1485,7 +1578,7 @@ function submitOcrForMaterials(items) {
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 提交材料 / 提交凭证 =====================
|
||||
// ===================== 提交材料 =====================
|
||||
// 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表,
|
||||
// 只上传不保存时表为空, 会误报「未上传」.
|
||||
async function onSubmitMaterial() {
|
||||
@@ -1500,18 +1593,6 @@ async function onSubmitMaterial() {
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
|
||||
finally { busy.value.submitMaterial = false }
|
||||
}
|
||||
async function onSubmitVoucher() {
|
||||
busy.value.submitVoucher = true
|
||||
try {
|
||||
await saveMaterials()
|
||||
// __silentError: 同上, 避免双 toast
|
||||
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-voucher`, null, { __silentError: true })
|
||||
ElMessage.success('凭证已提交, 待合规审核')
|
||||
row.value.voucherAuditStage = (resp && resp.data) || 'SUBMITTED'
|
||||
await loadTrail()
|
||||
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
|
||||
finally { busy.value.submitVoucher = false }
|
||||
}
|
||||
|
||||
// ===================== 分配 dialog =====================
|
||||
const assignDialog = ref({
|
||||
@@ -1525,17 +1606,15 @@ async function openAssignDialog(role) {
|
||||
roleLabel: role === 'supervisor' ? '监察员' : '执行人员',
|
||||
title: `分配${role === 'supervisor' ? '监察员' : '执行人员'}`,
|
||||
candidates: [],
|
||||
selectedIds: (role === 'supervisor' ? supervisors.value : executors.value).map(u => u.userId),
|
||||
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' ? 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
|
||||
const fn = role === 'supervisor' ? listSponsorOrgs : listExecutorOrgs
|
||||
const resp = await fn({})
|
||||
assignDialog.value.candidates = resp?.data || []
|
||||
} catch (e) { ElMessage.error('加载候选人失败') }
|
||||
finally { assignDialog.value.loading = false }
|
||||
}
|
||||
@@ -1546,7 +1625,7 @@ async function confirmAssign() {
|
||||
: `/business/meeting/executor/${meetingId.value}`
|
||||
assignDialog.value.saving = true
|
||||
try {
|
||||
await request.put(url, { userIds: selectedIds })
|
||||
await request.put(url, { orgIds: selectedIds })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialog.value.show = false
|
||||
await loadStaff()
|
||||
@@ -1554,36 +1633,29 @@ async function confirmAssign() {
|
||||
finally { assignDialog.value.saving = false }
|
||||
}
|
||||
|
||||
// ===================== 审核 dialog (材料+凭证可多选) =====================
|
||||
// ===================== 审核 dialog (材料审核) =====================
|
||||
const auditDialog = ref({
|
||||
show: false, action: '', types: [], title: '',
|
||||
show: false, action: '', title: '',
|
||||
opinion: '', approved: true, saving: false
|
||||
})
|
||||
function openAuditDialog(action, auditType) {
|
||||
const types = [auditType]
|
||||
const other = auditType === 'MATERIAL' ? 'VOUCHER' : 'MATERIAL'
|
||||
const eligible = action === 'COMPLIANCE' ? canComplianceAudit : canSupervisionAudit
|
||||
if (eligible(other)) types.push(other)
|
||||
function openAuditDialog(action) {
|
||||
auditDialog.value = {
|
||||
show: true,
|
||||
action,
|
||||
types,
|
||||
title: action === 'COMPLIANCE' ? '合规审核' : '监察审核',
|
||||
title: action === 'COMPLIANCE' ? '合规审核 (材料)' : '监察审核 (材料)',
|
||||
opinion: '',
|
||||
approved: true,
|
||||
saving: false
|
||||
}
|
||||
}
|
||||
async function confirmAudit() {
|
||||
const { action, types, opinion, approved } = auditDialog.value
|
||||
if (!types || !types.length) { ElMessage.warning('请选择审核类型'); return }
|
||||
const auditType = types.length === 2 ? 'BOTH' : types[0]
|
||||
const { action, 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 {
|
||||
await request.post(url, { auditType, approved, opinion }, { __silentError: true })
|
||||
await request.post(url, { approved, opinion }, { __silentError: true })
|
||||
ElMessage.success(approved ? '审核通过' : '已拒绝')
|
||||
auditDialog.value.show = false
|
||||
await refreshMeeting()
|
||||
@@ -1592,15 +1664,17 @@ async function confirmAudit() {
|
||||
}
|
||||
|
||||
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
|
||||
// 结算前必须先已上传保存付款凭证 (劳务/会务凭证 tab), 后端 settle 校验凭证存在, 缺失则报错.
|
||||
async function onSettle() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认结算? 结算后将锁定材料与凭证 (仅可继续点「完结」)', '结算确认', { type: 'warning' })
|
||||
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 }
|
||||
}
|
||||
@@ -1644,31 +1718,58 @@ function scheduleFeePoll() {
|
||||
feePollTimer = setInterval(refreshFee, 15000)
|
||||
}
|
||||
|
||||
// ===================== 占位 =====================
|
||||
function onDownloadTemplate() { ElMessage.info('模板下载 - 接口待对接') }
|
||||
function onPackUpload() { ElMessage.info('打包上传 - 接口待对接') }
|
||||
// ===================== 会务材料 打包上传 (下载空目录模板 + 上传回填 biz_meeting_material) =====================
|
||||
const serviceTplLoading = ref(false)
|
||||
const serviceSubmitting = ref(false)
|
||||
const serviceZipInput = ref(null)
|
||||
|
||||
// ===================== admin / manager 删除会议 (软删) =====================
|
||||
/**
|
||||
* 后端 BizMeetingController.DELETE 强校验 role in (admin, manager).
|
||||
* 详情页删除后跳回当前角色对应的会议列表 (避免停在已删会议的空页).
|
||||
*/
|
||||
async function onAdminDelete() {
|
||||
/** 下载"会务材料"空目录模板 zip (会务材料/物料制作·酒店·…/ 全空目录) */
|
||||
async function onDownloadServiceTemplate() {
|
||||
serviceTplLoading.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除会议「${row.value.meetingName || ''}」吗?\n删除为软删除, 数据保留审计追溯, 医生/参会人将不再看到该会议.`,
|
||||
'删除确认', { type: 'warning' }
|
||||
const resp = await fetch(
|
||||
import.meta.env.VITE_APP_BASE_API + `/business/meetingMaterial/serviceTemplate/${meetingId.value}`,
|
||||
{ headers: importHeaders.value }
|
||||
)
|
||||
} catch { return }
|
||||
deleting.value = true
|
||||
try {
|
||||
await request.delete(`/business/meeting/${meetingId.value}`)
|
||||
ElMessage.success('删除成功')
|
||||
router.push(isAdmin.value ? '/admin/meetings' : '/manager/meetings')
|
||||
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(e?.msg || '删除失败')
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1689,7 +1790,6 @@ onBeforeUnmount(stopFeePolling)
|
||||
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
|
||||
.breadcrumb .current { color: #262626; font-weight: 500; }
|
||||
.page-title { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.admin-delete-btn { font-size: 13px; }
|
||||
.card { background: #fff; border: 1px solid #f0f0f0; border-radius: 8px; padding: 20px 24px; margin-bottom: 16px; min-width: 0; }
|
||||
.section-title { font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 16px; padding-left: 10px; border-left: 3px solid var(--brand-primary); }
|
||||
.text-muted { color: #8c8c8c; font-size: 13px; }
|
||||
@@ -1725,13 +1825,13 @@ onBeforeUnmount(stopFeePolling)
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -1747,7 +1847,7 @@ onBeforeUnmount(stopFeePolling)
|
||||
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
|
||||
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
|
||||
|
||||
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; min-width: 0; }
|
||||
.audit-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; }
|
||||
|
||||
@@ -1756,7 +1856,6 @@ onBeforeUnmount(stopFeePolling)
|
||||
/* 参会人管理 (放在 劳务材料 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 列 */
|
||||
|
||||
Reference in New Issue
Block a user