|
|
|
@@ -0,0 +1,760 @@
|
|
|
|
|
<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="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="labor">
|
|
|
|
|
<div class="file-list">
|
|
|
|
|
<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="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>
|
|
|
|
|
|
|
|
|
|
<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'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
|
|
|
|
|
// ===================== 状态 =====================
|
|
|
|
|
const meetingId = ref('')
|
|
|
|
|
const row = ref({})
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const activeTab = ref('service')
|
|
|
|
|
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) }
|
|
|
|
|
}
|
|
|
|
|
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()])
|
|
|
|
|
} 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: 2fr 1fr; gap: 16px; align-items: flex-start; }
|
|
|
|
|
.left-col { display: flex; flex-direction: column; gap: 16px; }
|
|
|
|
|
|
|
|
|
|
.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; }
|
|
|
|
|
</style>
|