feat: 会议劳务/会务材料分轨审核 + 注册页标题 + 下拉空值修复

会议材料分轨 (material_audit_stage 单轨 → labor/service 双轨):
- biz_meeting: labor_audit_stage/labor_compliance_approved + service_audit_stage/service_compliance_approved 四列替换原单轨两列
- StageDeriver: 单轨状态机(R/N/C0/C1/A) + 角色优先级选代表轨 + 会议物理态取两轨最小进度
- MeetingAuditStageEnum / 前端 meetingStage.js 镜像: 分轨推导 + 颜色跟随角色展示阶段(避免文案与颜色错位)
- BizMeetingController/ServiceImpl/AuditLog + mapper + MeetingStageScheduler 同步分轨字段
- MeetingDetail/Meetings/doctor/executor Meetings/ManagerProjectDetail/ManagerProjectsAssign/PublicityDetail 同步双轨

其它:
- 注册页 (执行单位/专家/支持单位) 加居中页标题
- DoctorDeptSelect/DoctorTitleSelect 空字符串统一 null, 避免挂载必填项飘红
- BizOrgMapper sponsor/executor options 加 status 字段
- sponsor/NewPerson 编辑态 orgName 只读
This commit is contained in:
郭庆泰
2026-08-26 18:13:22 +08:00
parent b5b3e3a213
commit b0256ccac3
25 changed files with 752 additions and 449 deletions
+229 -125
View File
@@ -25,7 +25,7 @@
<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">劳务 {{ fmtAuditStage(row.laborAuditStage) }} · 会务 {{ fmtAuditStage(row.serviceAuditStage) }}</span></div>
<div class="info-row"><span class="info-label">创建时间:</span><span class="info-value">{{ fmtDateTime(row.createTime) }}</span></div>
<div class="info-row"><span class="info-label">创建人员:</span><span class="info-value">{{ row.createBy || '-' }}</span></div>
<div class="info-row"><span class="info-label">劳务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.laborFee) }}</span></div>
@@ -177,7 +177,7 @@
<div class="file-list" style="margin-top: 16px;">
<!-- 劳务协议: 改为"按参会人逐个回填"的收发闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly" class="file-row">
<div v-if="!isSponsor && !isReadonly && laborEditable" class="file-row">
<span class="file-label">劳务协议:</span>
<div class="agreement-actions">
<el-button size="small" :loading="agreementTplLoading" @click="onDownloadAgreementTemplate">下载目录</el-button>
@@ -188,13 +188,13 @@
</div>
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
<!-- 专家照片: 与劳务协议同构的"下载目录 + 回填"闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly" class="file-row">
<div v-if="!isSponsor && !isReadonly && laborEditable" class="file-row">
<span class="file-label">专家照片:</span>
<div class="agreement-actions">
<el-button size="small" :loading="expertPhotoTplLoading" @click="onDownloadExpertPhotoTemplate">下载目录</el-button>
@@ -227,7 +227,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" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
@@ -237,7 +237,7 @@
<el-tag v-if="meetingFee.useMain" type="warning" size="small" effect="plain">已上传总发票, 以总发票为准</el-tag>
</div>
<!-- 打包上传: 下载空目录模板 + 上传会务材料 (单目录多文件自动打 zip) -->
<div v-if="!isSponsor && !isReadonly" class="file-row">
<div v-if="!isSponsor && !isReadonly && serviceEditable" class="file-row">
<span class="file-label">打包上传:</span>
<div class="agreement-actions">
<el-button size="small" :loading="serviceTplLoading" @click="onDownloadServiceTemplate">下载目录</el-button>
@@ -269,15 +269,22 @@
</el-tabs>
<div class="tab-actions">
<span style="flex:1"></span>
<!-- 执行人员: 提交材料 -->
<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="canSubmitLabor && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR'])">提交劳务</el-button>
<el-button v-if="canSubmitService && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['SERVICE'])">提交会务</el-button>
<el-button v-if="canSubmitAll && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR', 'SERVICE'])">提交全部</el-button>
<!-- 合规 / 监察审核 (材料, 分轨三按钮) -->
<el-button v-if="isManager && !isReadonly && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
<el-button v-if="isManager && !isReadonly && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
<el-button v-if="isManager && !isReadonly && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">审核劳务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">审核会务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">审核全部</el-button>
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
<el-button v-if="!isSponsor && !isReadonly && materialsEditable" type="primary" :loading="saving" @click="onSave">保存</el-button>
<!-- 保存: 合规方(manager) 永远可保存; 执行方/管理员仅材料可编辑(未提交/已退回)时可保存 -->
<el-button v-if="!isSponsor && !isReadonly && (isManager || materialsEditable)" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
</div>
</div>
@@ -287,50 +294,83 @@
<div class="card audit-column">
<div class="section-title">材料审核</div>
<div class="timeline">
<!-- 固定节点 1 -->
<!-- 节点 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>
<!-- 节点 2: 审核时间轴 (内嵌 提交 合规 监察 三步, 两轨并列, 保留重交历史) -->
<div :class="['timeline-item', auditNodeStatus()]">
<div class="timeline-title">审核时间轴</div>
<div class="audit-sub-timeline">
<div :class="['sub-timeline-item', nodeStatus('submit')]">
<div class="sub-timeline-title">执行方提交材料</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待执行人员提交</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text">待提交</span>
</div>
<div v-for="(c, i) in track.cycles" :key="i" class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<span>{{ c.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.submit.auditTime) }}</span>
</div>
</div>
</div>
<div 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 :class="['sub-timeline-item', nodeStatus('compliance')]">
<div class="sub-timeline-title">合规审核</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待合规审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance">
<span>{{ c.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待审核</span>
</div>
<div v-if="c.compliance?.opinion" class="timeline-opinion">💬 {{ c.compliance.opinion }}</div>
</template>
</div>
</div>
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div v-if="!(round.compliance?.auditResult === 'REJECTED')" :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 :class="['sub-timeline-item', nodeStatus('supervision')]">
<div class="sub-timeline-title">监察意见</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待监察审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance && c.compliance.auditResult === 'REJECTED'">
<span class="pending-text">已退回 · 未进入监察</span>
</template>
<template v-else-if="c.supervision">
<span>{{ c.supervision.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.supervision.auditTime) }}</span>
<el-tag size="small" :type="c.supervision.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.supervision.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待监察审核</span>
</div>
<div v-if="c.supervision?.opinion" class="timeline-opinion">💬 {{ c.supervision.opinion }}</div>
</template>
</div>
</div>
<div 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>
<!-- 节点 3/4: 结算 / 完结 -->
<div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div>
@@ -355,17 +395,22 @@
</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-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" />
<el-form-item label="审核项">
<div style="display:flex;flex-direction:column;gap:8px;width:100%">
<div v-for="t in auditDialog.tracks" :key="t.type" style="display:flex;align-items:center;gap:12px">
<span style="width:72px">{{ t.label }}</span>
<el-radio-group v-model="t.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
</div>
</div>
</el-form-item>
<el-form-item label="结果">
<el-radio-group v-model="auditDialog.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
<el-form-item label="意见">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见 (两轨共用)" />
</el-form-item>
</el-form>
<template #footer>
@@ -650,58 +695,78 @@ 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 为「轨道 × 轮次 =====================
/**
* 解析 audit_trail 为轮次结构 (纯函数, 不依赖外部状态)
* 规则: 一次 SUBMITTED + APPROVED = 一轮起点 (执行人员提交)
* 之后 1-2 条填入 compliance / supervision 槽
* 拒绝事件 (REJECTED) 不会被识别为新提交, 仍归入当前轮
* 解析指定材料轨 (materialType) 的审核日志为提交轮次数组 (保留重交历史).
* 次 SUBMITTED 起一轮, 后续 APPROVED/REJECTED 顺序填入 compliance → supervision 槽.
* 方案 B: 同轨被驳回后重交, 产生第 2/3 轮, 时间轴逐轮渲染「第 N 次」.
*/
function parseRounds(auditType) {
function parseTrackCycles(materialType) {
const rows = (auditTrail.value || [])
.filter(r => r.auditType === auditType)
.filter(r => r.auditType === 'MATERIAL' && r.materialType === materialType)
.slice()
.sort((a, b) => new Date(a.auditTime).getTime() - new Date(b.auditTime).getTime())
const rounds = []
let cur = null
const cycles = []
for (const row of rows) {
const isSubmit = row.auditResult === 'SUBMITTED'
if (isSubmit) {
if (cur) rounds.push(cur)
cur = { submit: row, compliance: null, supervision: null }
} else if (cur) {
if (!cur.compliance) cur.compliance = row
else if (!cur.supervision) cur.supervision = row
if (row.auditResult === 'SUBMITTED') {
cycles.push({ submit: row, compliance: null, supervision: null })
continue
}
const cur = cycles[cycles.length - 1]
if (!cur) continue
if (!cur.compliance) cur.compliance = row
else if (!cur.supervision) cur.supervision = row
}
if (cur) rounds.push(cur)
return rounds
return cycles
}
const materialRounds = computed(() => parseRounds('MATERIAL'))
/** 至少保证 1 轮 (空轮 = 三个节点都待提交), 保证 2/3/4 始终渲染 */
const displayMaterialRounds = computed(() =>
materialRounds.value.length ? materialRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
/**
* 节点状态视觉:
* - done → 绿色 (动作完成 + 通过)
* - rejected → 红色 (动作完成但拒绝, 通常触发下一轮)
* - pending → 灰色 (还没轮到)
* 轨道列表: 新数据 (两轨独立) 拆 劳务/会务 两轨; 历史数据 material_type=null
* 回退单轨 (无 label), 迁移后两轨同值, 时间轴不再按顺序错配.
*/
function partStatus(part) {
if (!part) return 'pending'
if (part.auditResult === 'REJECTED') return 'rejected'
return 'done'
const materialTracks = computed(() => {
const labor = parseTrackCycles('LABOR')
const service = parseTrackCycles('SERVICE')
const legacy = parseTrackCycles(null)
if (labor.length || service.length) {
return [
{ key: 'labor', label: '劳务', cycles: labor },
{ key: 'service', label: '会务', cycles: service }
]
}
if (legacy.length) return [{ key: 'legacy', label: '', cycles: legacy }]
return []
})
/**
* 审核时间轴内嵌子步骤 (提交/合规/监察) 状态 — 由物理阶段 (两轨取最小) 推导.
* - submit: 越过 NOT_STARTED/RUNNING (已提交过) 即 done
* - compliance: RECTIFYING=rejected, AWAITING_COMPLIANCE=pending(当前), 之后=done
* - supervision: RECTIFYING=rejected, AWAITING_SUPERVISION=pending(当前), 之后=done
*/
function nodeStatus(slot) {
const s = derivePhysicalStage(row.value)
if (slot === 'submit') return (s !== 'NOT_STARTED' && s !== 'RUNNING') ? 'done' : 'pending'
if (slot === 'compliance') {
if (s === 'RECTIFYING') return 'rejected'
if (s === 'AWAITING_COMPLIANCE') return 'pending'
if (['AWAITING_SUPERVISION', 'AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s)) return 'done'
return 'pending'
}
if (slot === 'supervision') {
if (s === 'RECTIFYING') return 'rejected'
if (s === 'AWAITING_SUPERVISION') return 'pending'
if (['AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s)) return 'done'
return 'pending'
}
return 'pending'
}
/**
* 固定节点 (1/5/6) 状态 — 跟随 current_stage (BizMeetingStageEnum 10 值 code)
* slot: PRE = 节点1 (会议已执行) = NOT_STARTED/FROZEN 之外都算已执行
* POST = 节点5 (结算) = AWAITING_SETTLEMENT / SETTLED / FINISHED (已结算)
* DONE = 节点6 (完结) = FINISHED (is_finished=1)
* 固定节点 (会议已执行/结算/完结) 状态 — 跟随 current_stage (BizMeetingStageEnum 10 值 code)
* slot: PRE = 会议已执行 = NOT_STARTED/FROZEN 之外都算已执行
* POST = 结算 = AWAITING_SETTLEMENT / SETTLED / FINISHED (已结算)
* DONE = 完结 = FINISHED (is_finished=1)
*/
function fixedNodeStatus(slot) {
const s = derivePhysicalStage(row.value)
@@ -718,6 +783,12 @@ function nodeDesc(slot) {
return '-'
}
/** 审核时间轴 组节点状态: 两轨均审核通过 (进入待结算) 后 done, 否则 pending (内嵌子步骤各自带色). */
function auditNodeStatus() {
const s = derivePhysicalStage(row.value)
return ['AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s) ? 'done' : 'pending'
}
// ===================== 按钮显隐 =====================
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
// 执行单位是项目级分配 (biz_project_assign), 不在 biz_meeting_executor 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
@@ -729,22 +800,30 @@ const isAssignedSupervisor = computed(() => isSponsor.value)
const isSponsorMain = computed(() => currentRole.value === 'sponsor' && userStore.isMain)
const executed = computed(() => isOne(row.value.isExecuted))
const frozen = computed(() => isOne(row.value.isFrozen))
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
// 材料可编辑 (保存修改): 未提交 / 被驳回 时; 提交审核后 (SUBMITTED) / 通过后 (APPROVED) 锁定, 隐藏保存按钮
const materialsEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
function canComplianceAudit() {
if (!isManager.value) return false
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
}
function canSupervisionAudit() {
if (!isAssignedSupervisor.value && !isSponsorMain.value) return false
return row.value.materialAuditStage === 'SUBMITTED' && isOne(row.value.materialComplianceApproved)
}
// 结算/完结 (合规/管理员 手动点击, 后端强校验 role)
/** 单轨是否处于「可提交/可编辑」态 (未提交 或 被驳回) */
const laborEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
/** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */
function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) }
function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) }
// 材料可提交: 已执行 + 未冻结 + 对应轨可编辑 (三按钮按轨显隐)
const executorSubmitReady = computed(() => isExecutor.value && executed.value && !frozen.value)
const canSubmitLabor = computed(() => executorSubmitReady.value && laborEditable.value)
const canSubmitService = computed(() => executorSubmitReady.value && serviceEditable.value)
const canSubmitAll = computed(() => executorSubmitReady.value && laborEditable.value && serviceEditable.value)
// 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定
const materialsEditable = computed(() => laborEditable.value || serviceEditable.value)
// 合规审核 (合规方/manager): 三按钮按轨 C0 显隐
const laborC0 = computed(() => isC0(row.value.laborAuditStage, row.value.laborComplianceApproved))
const serviceC0 = computed(() => isC0(row.value.serviceAuditStage, row.value.serviceComplianceApproved))
// 监察审核 (支持方/监察员): 三按钮按轨 C1 显隐
const supervisorAuditor = computed(() => isAssignedSupervisor.value || isSponsorMain.value)
const laborC1 = computed(() => isC1(row.value.laborAuditStage, row.value.laborComplianceApproved))
const serviceC1 = computed(() => isC1(row.value.serviceAuditStage, row.value.serviceComplianceApproved))
// 结算 (合规/管理员 手动点击): 两轨均审核通过才可结算
const canSettle = computed(() => (isManager.value || isAdmin.value)
&& row.value.materialAuditStage === 'APPROVED'
&& row.value.laborAuditStage === 'APPROVED'
&& row.value.serviceAuditStage === 'APPROVED'
&& !isOne(row.value.isSettled))
const canFinish = computed(() => (isManager.value || isAdmin.value)
&& isOne(row.value.isSettled) && !isOne(row.value.isFinished))
@@ -1397,7 +1476,7 @@ async function loadProjectRoles() {
/**
* 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
* onSave (保存按钮) 与 onSubmitMaterial (提交前自动落库) 共用.
* onSave (保存按钮) 与 onSubmitMaterials (提交前自动落库) 共用.
*/
async function saveMaterials() {
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value]
@@ -1599,18 +1678,17 @@ function submitOcrForMaterials(items) {
})
}
// ===================== 提交材料 =====================
// 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表,
// 只上传不保存时表为空, 会误报「未上传」.
async function onSubmitMaterial() {
// ===================== 提交材料 (三按钮: 提交劳务/会务/全部) =====================
/** 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表 */
async function onSubmitMaterials(types) {
if (!types || !types.length) { ElMessage.warning('无可提交的材料轨'); return }
busy.value.submitMaterial = true
try {
await saveMaterials()
// __silentError: 拦截器不自动 toast, 错误统一由下方 catch 处理 (避免 axios 拦截器和 catch 双 toast)
const resp = await request.post(`/business/meeting/${meetingId.value}/submit-material`, null, { __silentError: true })
await request.post(`/business/meeting/${meetingId.value}/submit-material`, { types }, { __silentError: true })
ElMessage.success('材料已提交, 待合规审核')
row.value.materialAuditStage = (resp && resp.data) || 'SUBMITTED'
await loadTrail()
await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitMaterial = false }
}
@@ -1654,30 +1732,43 @@ async function confirmAssign() {
finally { assignDialog.value.saving = false }
}
// ===================== 审核 dialog (材料审核) =====================
// ===================== 审核 dialog (材料审核, 分轨独立通过/拒绝) =====================
const auditDialog = ref({
show: false, action: '', title: '',
opinion: '', approved: true, saving: false
opinion: '', tracks: [], saving: false
})
function openAuditDialog(action) {
function openAuditDialog(action, scope = 'ALL') {
const isCompliance = action === 'COMPLIANCE'
const include = type => scope === 'ALL' || scope === type
const tracks = []
if (isCompliance) {
if (include('LABOR') && isC0(row.value.laborAuditStage, row.value.laborComplianceApproved)) tracks.push({ type: 'LABOR', label: '劳务材料', approved: true })
if (include('SERVICE') && isC0(row.value.serviceAuditStage, row.value.serviceComplianceApproved)) tracks.push({ type: 'SERVICE', label: '会务材料', approved: true })
} else {
if (include('LABOR') && isC1(row.value.laborAuditStage, row.value.laborComplianceApproved)) tracks.push({ type: 'LABOR', label: '劳务材料', approved: true })
if (include('SERVICE') && isC1(row.value.serviceAuditStage, row.value.serviceComplianceApproved)) tracks.push({ type: 'SERVICE', label: '会务材料', approved: true })
}
const scopeLabel = scope === 'LABOR' ? '劳务' : scope === 'SERVICE' ? '会务' : '全部'
auditDialog.value = {
show: true,
action,
title: action === 'COMPLIANCE' ? '合规审核 (材料)' : '监察审核 (材料)',
title: isCompliance ? `合规审核 (材料 · ${scopeLabel})` : `监察审核 (材料 · ${scopeLabel})`,
opinion: '',
approved: true,
tracks,
saving: false
}
}
async function confirmAudit() {
const { action, opinion, approved } = auditDialog.value
const { action, opinion, tracks } = auditDialog.value
const items = tracks.map(t => ({ type: t.type, approved: t.approved }))
if (!items.length) { ElMessage.warning('无可审核的材料轨'); return }
const url = action === 'COMPLIANCE'
? `/business/meeting/${meetingId.value}/audit-compliance`
: `/business/meeting/${meetingId.value}/audit-supervision`
auditDialog.value.saving = true
try {
await request.post(url, { approved, opinion }, { __silentError: true })
ElMessage.success(approved ? '审核通过' : '已拒绝')
await request.post(url, { items, opinion }, { __silentError: true })
ElMessage.success('审核完成')
auditDialog.value.show = false
await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') }
@@ -1865,8 +1956,21 @@ onBeforeUnmount(stopFeePolling)
.timeline-desc.pending-text { color: #c0c4cc; font-style: italic; }
.timeline-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: #595959; line-height: 1.6; }
.timeline-meta .dot { color: #c0c4cc; }
.track-block { margin-bottom: 6px; }
.track-block:last-child { margin-bottom: 0; }
.track-label-row { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
.cycle-no { font-size: 11px; color: #909399; }
.timeline-opinion { margin-top: 4px; font-size: 12px; color: #f56c6c; background: #fef0f0; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-item.done .timeline-opinion { color: #909399; background: #f5f7fa; }
/* 审核时间轴内嵌子时间轴 (提交 → 合规 → 监察) */
.audit-sub-timeline { position: relative; padding-left: 14px; border-left: 2px solid #e8e8e8; margin: 6px 0 2px; }
.sub-timeline-item { padding: 4px 0 10px 12px; position: relative; }
.sub-timeline-item:last-child { padding-bottom: 0; }
.sub-timeline-item::before { content: ''; position: absolute; left: -20px; top: 7px; width: 8px; height: 8px; border-radius: 50%; background: var(--brand-primary); }
.sub-timeline-item.done::before { background: #67c23a; }
.sub-timeline-item.pending::before { background: #c0c4cc; }
.sub-timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
.sub-timeline-title { font-size: 12px; font-weight: 600; color: #303133; margin-bottom: 4px; line-height: 1.4; }
.audit-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; }
.audit-column { margin-bottom: 0; padding: 16px 18px; min-width: 0; }