批量推送代码

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-29 17:52:05 +08:00
co-authored by Claude
parent 36528b6e0c
commit e47f76478b
130 changed files with 3972 additions and 2546 deletions
+143 -98
View File
@@ -16,12 +16,13 @@
<!-- 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">{{ displayTotalPeriods ?? '-' }}</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>
@@ -81,7 +82,11 @@
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange" class="attendee-table">
<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="医生" min-width="90">
<template #default="{ row }">
<span :style="(row.hasIntent === 1 || row.hasIntent === '1') ? {} : { color: '#f56c6c' }">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="联系电话" width="120">
<template #default="{ row }">{{ isSponsor ? maskPhone(row.phone) : row.phone }}</template>
</el-table-column>
@@ -125,10 +130,10 @@
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="现场照片" min-width="140" show-overflow-tooltip>
<el-table-column label="现场照片" min-width="90" align="center">
<template #default="{ row }">
<span v-if="!row.onSitePhotos" class="text-muted">-</span>
<span v-else>{{ row.onSitePhotos.split(',').length }} </span>
<el-link :underline="false" v-if="onSitePhotoFiles(row).length" type="primary" @click="openOnSitePhotos(row)">查看</el-link>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="签字" width="70" align="center">
@@ -299,71 +304,35 @@
<div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div>
<!-- 材料审核三步 (提交 合规 监察) 并入主时间轴, 两轨并列, 保留重交历史 -->
<div :class="['timeline-item', nodeStatus('submit')]">
<!-- 材料审核: 单条时间轴, 按轮次循环 (提交 合规 监察 提交 ), 节点上标注 劳务/会务 -->
<div v-if="!timelineEvents.length && !pendingNode" class="timeline-item pending">
<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 class="timeline-desc pending-text">待执行人员提交</div>
</div>
<template v-for="(ev, idx) in timelineEvents" :key="'ev' + idx">
<div :class="['timeline-item', eventStatus(ev)]">
<div class="timeline-title">
{{ stepLabel(ev.step) }}
<span v-if="trackTag(ev)" class="track-tag">{{ trackTag(ev) }}</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 v-for="(e, i) in ev.entries" :key="i" class="timeline-meta">
<span v-if="ev.entries.length > 1" class="track-mini">{{ e.label }}</span>
<span>{{ e.auditor || '—' }}</span>
<span class="dot">·</span>
<span>{{ fmtDateTime(c.submit.auditTime) }}</span>
<span>{{ fmtDateTime(e.auditTime) }}</span>
<el-tag v-if="e.auditResult" size="small" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</div>
<div v-for="(e, i) in ev.entries" :key="'op' + i">
<div v-if="e.opinion" :class="['timeline-opinion', e.auditResult === 'REJECTED' ? 'opinion-reject' : 'opinion-approve']">💬 {{ e.opinion }}</div>
</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.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>
<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>
</template>
<div v-if="pendingNode" class="timeline-item pending">
<div class="timeline-title">
{{ stepLabel(pendingNode.step) }}
<span v-if="trackTag(pendingNode)" class="track-tag">{{ trackTag(pendingNode) }}</span>
</div>
<div class="timeline-desc pending-text">{{ pendingDesc(pendingNode.step) }}</div>
</div>
<!-- 节点 3/4: 结算 / 完结 -->
<div :class="['timeline-item', fixedNodeStatus('POST')]">
@@ -622,9 +591,16 @@ function fmtDateTime(v) {
if (isNaN(dt.getTime())) return v
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
// 执行方看到的"总期数"= 分配给本执行方的场次 (assignedSessions), 而非项目总场次 (total_periods); 其他角色仍用项目总场次
const displayTotalPeriods = computed(() => {
if (currentRole.value === 'executor') {
return row.value.assignedSessions != null ? row.value.assignedSessions : row.value.totalPeriods
}
return row.value.totalPeriods
})
const periodDisplay = computed(() => {
const p = row.value.periodNo
const t = row.value.totalPeriods
const t = displayTotalPeriods.value
if (p == null && t == null) return '-'
if (p == null) return `${t}`
if (t == null) return `${p}`
@@ -664,6 +640,20 @@ function protocolUrl(row) {
function idCardFiles(row) {
return (row.idCardAttachments || '').split(',').map(s => s.trim()).filter(Boolean)
}
/** 现场照片 CSV "url1,url2" 拆成非空数组 */
function onSitePhotoFiles(row) {
return (row.onSitePhotos || '').split(',').map(s => s.trim()).filter(Boolean)
}
/** OSS 图片压缩参数 (对齐 PosterService.download): 缩宽 + 压质量 + 转 JPEG, 减小预览体积 */
function ossThumb(url, width = 1200) {
if (!url || url.includes('x-oss-process')) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}x-oss-process=image/resize,w_${width}/quality,q_80/format,jpg`
}
/** 现场照片预览: 每张带 OSS 压缩参数 → Preview 组件平铺多图 */
function openOnSitePhotos(row) {
openPreview(onSitePhotoFiles(row).map(u => ossThumb(u)), '现场照片')
}
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
@@ -738,27 +728,73 @@ const materialTracks = computed(() => {
})
/**
* 审核时间轴内嵌子步骤 (提交/合规/监察) 状态 — 由物理阶段 (两轨取最小) 推导.
* - submit: 越过 NOT_STARTED/RUNNING (已提交过) 即 done
* - compliance: RECTIFYING=rejected, AWAITING_COMPLIANCE=pending(当前), 之后=done
* - supervision: RECTIFYING=rejected, AWAITING_SUPERVISION=pending(当前), 之后=done
* 单条材料审核时间轴: 把两轨(劳务/会务)的 cycle 事件拍平成一条按时间升序的节点流.
* 节点 = { step: submit|compliance|supervision, entries: [{label, roundNo, auditor, auditTime, auditResult, opinion}] }
* - 同一步骤 + 同一 timestamp 的两轨事件合并成一个节点 (提交全部/审核全部会同时写两轨日志)
* - entries.length>1 时, 每行前置 track-mini 标注是哪一轨
*/
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'
const timelineEvents = computed(() => {
const raw = []
for (const track of materialTracks.value) {
const label = track.label
track.cycles.forEach((c, i) => {
const roundNo = i + 1
if (c.submit) raw.push({ step: 'submit', time: c.submit.auditTime, entry: { label, roundNo, auditor: c.submit.auditor, auditTime: c.submit.auditTime } })
if (c.compliance) raw.push({ step: 'compliance', time: c.compliance.auditTime, entry: { label, roundNo, auditor: c.compliance.auditor, auditTime: c.compliance.auditTime, auditResult: c.compliance.auditResult, opinion: c.compliance.opinion } })
if (c.supervision) raw.push({ step: 'supervision', time: c.supervision.auditTime, entry: { label, roundNo, auditor: c.supervision.auditor, auditTime: c.supervision.auditTime, auditResult: c.supervision.auditResult, opinion: c.supervision.opinion } })
})
}
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'
raw.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
const nodes = []
for (const ev of raw) {
const last = nodes[nodes.length - 1]
if (last && last.step === ev.step && last.time === ev.time) { last.entries.push(ev.entry); continue }
nodes.push({ step: ev.step, time: ev.time, entries: [ev.entry] })
}
return 'pending'
return nodes
})
/** 当前"待办"节点 (时间轴末尾的灰色占位): 依各轨状态推导下一步该谁动. */
const pendingNode = computed(() => {
if (!executed.value || isOne(row.value.isSettled) || isOne(row.value.isFinished)) return null
const c0 = []; if (laborC0.value) c0.push('劳务'); if (serviceC0.value) c0.push('会务')
if (c0.length) return { step: 'compliance', entries: c0.map(l => ({ label: l })) }
const c1 = []; if (laborC1.value) c1.push('劳务'); if (serviceC1.value) c1.push('会务')
if (c1.length) return { step: 'supervision', entries: c1.map(l => ({ label: l })) }
const rej = []
if (row.value.laborAuditStage === 'REJECTED') rej.push('劳务')
if (row.value.serviceAuditStage === 'REJECTED') rej.push('会务')
if (rej.length) return { step: 'submit', entries: rej.map(l => ({ label: l })) }
return null
})
function stepLabel(step) {
if (step === 'submit') return '执行方提交材料'
if (step === 'compliance') return '合规审核'
return '监察意见'
}
/** 节点右侧轨标注: 提交节点带轮次 (劳务·第1次), 审核节点只标轨 (劳务·会务). */
function trackTag(node) {
const labels = node.entries.map(e => e.label).filter(Boolean)
if (!labels.length) return ''
if (node.step === 'submit') {
const withRound = node.entries.filter(e => e.roundNo != null)
if (withRound.length) {
if (withRound.length === node.entries.length && withRound.every(e => e.roundNo === withRound[0].roundNo)) {
return `${labels.join('·')} · 第${withRound[0].roundNo}`
}
return node.entries.map(e => (e.roundNo != null ? `${e.label}·第${e.roundNo}` : e.label)).filter(Boolean).join(' · ')
}
}
return labels.join('·')
}
function eventStatus(ev) {
return ev.entries.some(e => e.auditResult === 'REJECTED') ? 'rejected' : 'done'
}
function pendingDesc(step) {
if (step === 'submit') return '待重新提交'
if (step === 'compliance') return '待合规审核'
return '待监察审核'
}
/**
@@ -793,9 +829,9 @@ 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))
/** 单轨是否处于「可提交/可编辑」态 (未提交 或 被驳回) */
const laborEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
/** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */
const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => isManager.value || isAdmin.value || ['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) }
@@ -1065,7 +1101,7 @@ async function pushEsign(ids, loadingId) {
try {
const resp = await request.post('/business/meetingAttendee/esign', ids, { __silentError: true })
const sent = (resp && resp.data != null) ? resp.data : ids.length
ElMessage.success(`已推送 ${sent} 份电子签 (短信 + 站内信)`)
ElMessage.success(`已推送 ${sent} 份电子签`)
selectedAttendees.value = []
await loadAttendees()
} catch (e) {
@@ -1214,6 +1250,7 @@ async function lookupExpertByPhone() {
f.title = expert.title || f.title
f.idCard = expert.idCard || f.idCard
f.bankName = expert.bankName || f.bankName
f.bankBranch = expert.bankBranch || f.bankBranch
f.bankCard = expert.bankCard || f.bankCard
f.bankRegion = expert.bankRegion || f.bankRegion
f.bankAddress = expert.bankAddress || f.bankAddress
@@ -1669,17 +1706,23 @@ function openPreview(urls, title) {
previewOpen.value = true
}
// 保存所有待落库内容 (材料/日程海报/邀请函/付款凭证); 成功返回 OCR 提交数, 失败抛错
async function doSave() {
const { ocrCount } = await saveMaterials()
await saveSchedulePoster()
await saveInvitation()
if (canUploadVoucher.value) await saveVouchers()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
refreshFee()
return ocrCount
}
async function onSave() {
if (saving.value) return
saving.value = true
try {
const { saved, ocrCount } = await saveMaterials()
await saveSchedulePoster()
await saveInvitation()
if (canUploadVoucher.value) await saveVouchers()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
refreshFee()
ElMessage.success(`保存成功 (${saved.length} 条)`)
const ocrCount = await doSave()
ElMessage.success('保存成功')
if (ocrCount) ElMessage.info(`已提交 ${ocrCount} 个识别任务 (后台执行)`)
} catch (e) {
console.error('[meeting-detail] save failed', e)
@@ -1844,7 +1887,6 @@ async function confirmAudit() {
}
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
// 结算前必须先已上传保存付款凭证 (劳务/会务凭证 tab), 后端 settle 校验凭证存在, 缺失则报错.
async function onSettle() {
try {
await ElMessageBox.confirm('确认结算? 结算前请确保已上传劳务/会务付款凭证。', '结算确认', { type: 'warning' })
@@ -2028,8 +2070,11 @@ onBeforeUnmount(stopFeePolling)
.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; }
.track-tag { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 400; color: #909399; background: #f5f7fa; border-radius: 3px; padding: 1px 6px; line-height: 1.6; vertical-align: middle; }
.track-mini { display: inline-block; min-width: 34px; font-size: 11px; color: #909399; }
.timeline-opinion { margin-top: 4px; font-size: 12px; padding: 4px 8px; border-radius: 3px; line-height: 1.5; word-break: break-all; }
.timeline-opinion.opinion-reject { color: #f56c6c; background: #fef0f0; }
.timeline-opinion.opinion-approve { color: #389e0d; background: #f6ffed; }
.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; }
@@ -2261,4 +2306,4 @@ onBeforeUnmount(stopFeePolling)
padding: 6px 3px !important;
}
}
</style>
</style>