批量推送代码

This commit is contained in:
郭庆泰
2026-08-26 23:14:46 +08:00
parent 18f7e93d5b
commit 36528b6e0c
36 changed files with 933 additions and 779 deletions
+160 -100
View File
@@ -299,75 +299,70 @@
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<!-- 节点 2: 审核时间轴 (内嵌 提交 合规 监察 三步, 两轨并列, 保留重交历史) -->
<div :class="['timeline-item', auditNodeStatus()]">
<div class="timeline-title">审核时间轴</div>
<div class="audit-sub-timeline">
<div :class="['sub-timeline-item', nodeStatus('submit')]">
<div class="sub-timeline-title">执行方提交材料</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待执行人员提交</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text">待提交</span>
</div>
<div v-for="(c, i) in track.cycles" :key="i" class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<span>{{ c.submit.auditor }}</span>
<!-- 材料审核三步 (提交 合规 监察) 并入主时间轴, 两轨并列, 保留重交历史 -->
<div :class="['timeline-item', nodeStatus('submit')]">
<div class="timeline-title">执行方提交材料</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待执行人员提交</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text">待提交</span>
</div>
<div v-for="(c, i) in track.cycles" :key="i" class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<span>{{ c.submit.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.submit.auditTime) }}</span>
</div>
</div>
</div>
<div :class="['timeline-item', nodeStatus('compliance')]">
<div class="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.submit.auditTime) }}</span>
</div>
</div>
</div>
<div :class="['sub-timeline-item', nodeStatus('compliance')]">
<div class="sub-timeline-title">合规审核</div>
<div v-if="!materialTracks.length" class="timeline-desc pending-text">待合规审核</div>
<div v-for="track in materialTracks" :key="track.key" class="track-block">
<div class="track-label-row">
<el-tag v-if="track.label" size="small" effect="plain">{{ track.label }}</el-tag>
<span v-if="!track.cycles.length" class="pending-text"></span>
</div>
<template v-for="(c, i) in track.cycles" :key="i">
<div class="timeline-meta">
<span v-if="track.cycles.length > 1" class="cycle-no">{{ i + 1 }}</span>
<template v-if="c.compliance">
<span>{{ c.compliance.auditor }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待审核</span>
</div>
<div v-if="c.compliance?.opinion" class="timeline-opinion">💬 {{ c.compliance.opinion }}</div>
<span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</template>
<span v-else class="pending-text">待审核</span>
</div>
<div v-if="c.compliance?.opinion" class="timeline-opinion">💬 {{ c.compliance.opinion }}</div>
</template>
</div>
</div>
<div :class="['timeline-item', nodeStatus('supervision')]">
<div class="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 :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 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>
<div v-if="c.supervision?.opinion" class="timeline-opinion">💬 {{ c.supervision.opinion }}</div>
</template>
</div>
</div>
<!-- 节点 3/4: 结算 / 完结 -->
@@ -421,14 +416,18 @@
<!-- 参会人 dialog (新增/编辑通用, 2 列布局) -->
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="760px" append-to-body :close-on-click-modal="false" @closed="resetAttendeeForm">
<el-form :model="attendeeDialog.form" label-width="100px" class="attendee-form-2col">
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" :rules="attendeeRules" label-width="100px" class="attendee-form-2col">
<!-- Col 1: 基础档案 (8 字段) -->
<el-form-item label="联系方式" required>
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11">
<template v-if="!attendeeDialog.editing" #suffix>
<el-icon class="phone-lookup" title="按手机号查专家并回填" @click="lookupExpertByPhone"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
</el-form-item>
<el-form-item label="联系方式" required>
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11" />
</el-form-item>
<el-form-item label="医院">
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
@@ -475,7 +474,7 @@
<el-form-item label="增值税附加">
<el-input-number v-model="attendeeDialog.form.vatAndSurcharge" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发金额">
<el-form-item label="实发金额" prop="fee">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="摘要">
@@ -570,7 +569,7 @@ import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
import { ElMessageBox } from 'element-plus'
import { Upload, ArrowDown } from '@element-plus/icons-vue'
import { Upload, ArrowDown, Search } from '@element-plus/icons-vue'
import { derivePhysicalStage } from '@/utils/meetingStage'
const route = useRoute()
@@ -783,12 +782,6 @@ 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 里, 旧判定会让执行单位在"执行中"看不到提交按钮.
@@ -937,6 +930,50 @@ function resetAttendeeForm() {
show: false, title: '', editing: false,
form: emptyAttendeeForm(), saving: false
}
// 清掉上次的校验错误 (防止关掉后重开还残留"实发金额不能超过..."红字)
attendeeFormRef.value?.clearValidate()
}
/** 参会人表单 ref (el-form validate 用) */
const attendeeFormRef = ref()
/** 计算所选角色(可多个, 逗号分隔)的项目劳务金额合计; 返回 { sum, matchedAny } */
function roleAmountSum(laborForm) {
const result = { sum: 0, matchedAny: false }
if (!laborForm) return result
const items = String(laborForm).split(',').map(s => s.trim()).filter(Boolean)
items.forEach(item => {
const matched = (projectRoles.value || []).find(r => {
if (!r) return false
const label = r.role === '其他' ? (r.customName || '').trim() : r.role
return label === item
})
if (matched && matched.amount != null) {
result.sum += Number(matched.amount) || 0
result.matchedAny = true
}
})
return result
}
/** 实发金额校验: 不超所选角色的劳务金额合计 (未选角色时跳过) */
function validateFee(rule, value, callback) {
const laborForm = attendeeDialog.value.form.laborForm
if (!laborForm || !String(laborForm).trim()) {
callback()
return
}
const { sum } = roleAmountSum(laborForm)
const fee = Number(value) || 0
if (fee > sum) {
callback(new Error(`实发金额不能超过角色金额 ${sum.toFixed(2)}`))
} else {
callback()
}
}
const attendeeRules = {
fee: [{ validator: validateFee, trigger: ['blur', 'change'] }]
}
// ===================== 金额联动 (照搬 hwt guest.vue 单向链) =====================
@@ -994,20 +1031,7 @@ watch(
if (!val) return
// 多选: 逗号分隔, 按每个角色名匹配项目劳务金额求和, 填进 fee (实发).
// "其他"自定义角色不在项目列表里 → 不计入; 全"其他"时 matchedAny=false → 不动 fee.
const items = String(val).split(',').map(s => s.trim()).filter(Boolean)
let sum = 0
let matchedAny = false
items.forEach(item => {
const matched = (projectRoles.value || []).find(r => {
if (!r) return false
const label = r.role === '其他' ? (r.customName || '').trim() : r.role
return label === item
})
if (matched && matched.amount != null) {
sum += Number(matched.amount) || 0
matchedAny = true
}
})
const { sum, matchedAny } = roleAmountSum(val)
if (matchedAny) {
attendeeDialog.value.form.fee = Number(sum.toFixed(2))
}
@@ -1165,6 +1189,44 @@ function openAttendeeDialog(row) {
nextTick(() => { suppressFeeLink = false })
}
/** 手机号放大镜: 按手机号查专家, 命中则回填参会人表单 (身份证/银行/科室/职称等) */
async function lookupExpertByPhone() {
const phone = (attendeeDialog.value.form.phone || '').trim()
if (!phone) {
ElMessage.warning('请先输入手机号')
return
}
if (!phone.match(/^1\d{10}$/)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
const resp = await request.get(`/business/expert/byPhone/${phone}`, { __silentError: true })
const expert = (resp && resp.data) || null
if (!expert) {
ElMessage.info('未找到该手机号对应的专家')
return
}
const f = attendeeDialog.value.form
f.name = expert.name || f.name
f.workUnit = expert.workUnit || f.workUnit
f.department = expert.department || f.department
f.title = expert.title || f.title
f.idCard = expert.idCard || f.idCard
f.bankName = expert.bankName || f.bankName
f.bankCard = expert.bankCard || f.bankCard
f.bankRegion = expert.bankRegion || f.bankRegion
f.bankAddress = expert.bankAddress || f.bankAddress
f.idCardAttachments = expert.idCardAttachments || f.idCardAttachments
// 账户名称 = 持卡人姓名, 默认取专家姓名
f.accountName = expert.name || f.accountName
ElMessage.success('已回填专家信息')
} catch (e) {
console.error('[meeting-detail] lookupExpertByPhone failed', e)
ElMessage.error(e?.msg || e?.message || '查询失败')
}
}
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
async function confirmAttendee() {
const { editing, form } = attendeeDialog.value
@@ -1176,6 +1238,12 @@ async function confirmAttendee() {
ElMessage.warning('手机号格式不正确')
return
}
// 校验实发金额不超所选角色劳务金额合计 (未选角色时跳过)
try {
await attendeeFormRef.value.validate()
} catch {
return
}
attendeeDialog.value.saving = true
try {
if (editing) {
@@ -1962,15 +2030,6 @@ onBeforeUnmount(stopFeePolling)
.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; }
@@ -1987,6 +2046,9 @@ onBeforeUnmount(stopFeePolling)
.attendee-form-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; }
.attendee-form-2col :deep(.el-form-item) { margin-bottom: 12px; }
.attendee-form-2col :deep(.el-form-item__content) { min-width: 0; }
/* 手机号放大镜 suffix: 可点 + hover 高亮 */
.phone-lookup { cursor: pointer; color: var(--el-text-color-secondary); transition: color 0.2s; }
.phone-lookup:hover { color: var(--brand-primary, #409eff); }
/* 表格外层横向滚动容器: 内容总宽 ~1680px 超出 left-col 宽度时, 容器内出现横向滚动条, 不撑爆外层 grid */
.attendee-table-wrap { overflow-x: auto; max-width: 100%; min-width: 0; }
.row-actions { display: inline-flex; align-items: center; gap: 4px; }
@@ -2161,8 +2223,6 @@ onBeforeUnmount(stopFeePolling)
/* 时间轴缩窄 */
.timeline { padding-left: 18px !important; }
.timeline-item::before { left: -23px !important; }
.audit-sub-timeline { padding-left: 12px !important; }
.sub-timeline-item::before { left: -18px !important; }
/* 顶部"返回/操作"按钮组横滚 */
.page-title { flex-wrap: wrap !important; gap: 8px !important; }