feat: 会议阶段劳务/会务前缀投影 + 结算前未保存拦截

- 阶段投影: 待审核/审核通过/待整改/待结算 按劳务/会务前缀区分 (前后端镜像)
- 劳务材料提交门槛改为参会人员列表≥1人 (非上传文件)
- sponsor/manager 操作列「审核」「结算」提为直链
- 会议结算时间轴节点: 待结算态显示「补充凭证并结算」
- 结算前检测未保存改动, 弹「保存并结算」拦截

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-29 20:02:33 +08:00
co-authored by Claude
parent e47f76478b
commit 1876601ba7
7 changed files with 171 additions and 30 deletions
+45 -6
View File
@@ -79,7 +79,7 @@ function chooseState(role, labor, service) {
function render(role, s, phase) {
switch (s) {
case 0: // R 退回
return role === 'executor' ? '已退回' : '待整改'
return '待整改'
case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一)
if (phase === 0) return '未执行'
if (phase === 1) return '执行中'
@@ -119,8 +119,40 @@ export function deriveStage(role, row) {
if (isTrue(row.isFrozen)) return '冻结中'
if (isTrue(row.isFinished)) return '已完结'
if (isTrue(row.isSettled)) return '已结算'
const chosen = chooseState(role, laborState(row), serviceState(row))
return render(role, chosen, executionPhase(row))
const phase = executionPhase(row)
const ls = laborState(row)
const ss = serviceState(row)
// admin/doctor 等中性角色: 维持原逻辑 (最小进度 + 单轨措辞), 不加前缀
if (role !== 'executor' && role !== 'sponsor' && role !== 'manager') {
return render(role, Math.min(ls, ss), phase)
}
// 三流程角色: 两轨合并 + 劳务/会务前缀
const ll = render(role, ls, phase)
const sl = render(role, ss, phase)
if (ll === sl) return ll
// 待办态最高优先: manager=C0(2), sponsor=C1(3), executor=R(0)
const urgent = role === 'manager' ? 2 : role === 'sponsor' ? 3 : 0
let track, label
if (ls === urgent) { track = 'labor'; label = ll }
else if (ss === urgent) { track = 'service'; label = sl }
else {
const lt = ts(row.laborAuditTime)
const st = ts(row.serviceAuditTime)
if (lt >= st) { track = 'labor'; label = ll }
else { track = 'service'; label = sl }
}
// 仅 待审核/审核通过/待整改/待结算 四类加前缀
if (label === '待审核' || label === '审核通过' || label === '待整改' || label === '待结算') {
return (track === 'labor' ? '劳务' : '会务') + label
}
return label
}
/** 时间戳解析 (null/非法 → -1, 保证 null 时间在最近时间比较里输给任何真实时间). */
function ts(v) {
if (v == null) return -1
const t = new Date(v).getTime()
return Number.isNaN(t) ? -1 : t
}
/**
@@ -134,14 +166,13 @@ export function stageLabel(role, row) {
* 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应.
* 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位
* (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色).
* 规则: 待整改/已退回=红, 未执行=灰, 执行中/已执行/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿.
* 规则: 待整改=红, 未执行=灰, 执行中/已执行/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿.
*/
const STAGE_STYLE = {
'冻结中': { cls: 'frozen', tag: 'info' },
'已完结': { cls: 'done', tag: 'success' },
'已结算': { cls: 'done', tag: 'success' },
'待整改': { cls: 'waiting', tag: 'danger' },
'已退回': { cls: 'waiting', tag: 'danger' },
'未执行': { cls: 'pending', tag: 'info' },
'执行中': { cls: 'running', tag: 'primary' },
'已执行': { cls: 'running', tag: 'primary' },
@@ -149,6 +180,14 @@ const STAGE_STYLE = {
'待审核': { cls: 'reviewing', tag: 'warning' },
'审核通过': { cls: 'done', tag: 'success' },
'待结算': { cls: 'done', tag: 'success' },
'劳务待审核': { cls: 'reviewing', tag: 'warning' },
'会务待审核': { cls: 'reviewing', tag: 'warning' },
'劳务审核通过': { cls: 'done', tag: 'success' },
'会务审核通过': { cls: 'done', tag: 'success' },
'劳务待整改': { cls: 'waiting', tag: 'danger' },
'会务待整改': { cls: 'waiting', tag: 'danger' },
'劳务待结算': { cls: 'done', tag: 'success' },
'会务待结算': { cls: 'done', tag: 'success' },
}
function stageStyle(role, row) {
@@ -174,7 +213,7 @@ export const STAGE_OPTIONS = [
{ label: '执行中', value: 'IN_PROGRESS' },
{ label: '已执行', value: 'RUNNING' },
{ label: '待合规审核', value: 'AWAITING_COMPLIANCE' },
{ label: '待支持审核', value: 'AWAITING_SUPERVISION' },
{ label: '待支持审核', value: 'AWAITING_SUPERVISION' },
{ label: '待整改', value: 'RECTIFYING' },
{ label: '待结算', value: 'AWAITING_SETTLEMENT' },
{ label: '已完结', value: 'FINISHED' },
+53 -1
View File
@@ -813,7 +813,11 @@ function fixedNodeStatus(slot) {
function nodeDesc(slot) {
const s = derivePhysicalStage(row.value)
if (slot === 'PRE') return s !== 'NOT_STARTED' && s !== 'FROZEN' ? '已执行' : '待执行'
if (slot === 'POST') return s === 'AWAITING_SETTLEMENT' || s === 'SETTLED' || s === 'FINISHED' ? '已结算' : '未结算'
if (slot === 'POST') {
if (s === 'SETTLED' || s === 'FINISHED') return '已结算'
if (s === 'AWAITING_SETTLEMENT') return '补充凭证并结算'
return '未结算'
}
if (slot === 'DONE') return isOne(row.value.isFinished) ? '已完结' : '未完结'
return '-'
}
@@ -877,9 +881,35 @@ async function loadMaterials() {
target.fileName = item.fileName || ''
}
})
captureSnapshot()
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
}
// ===================== 未保存改动检测 (结算前拦截用) =====================
/** 已落库快照 (load 后 + 保存后各刷新一次): 4 组上传行 url + 海报/邀请函 */
const savedSnapshot = ref(null)
function captureSnapshot() {
const urls = new Map()
;[...serviceMaterialRows.value, ...laborMaterialRows.value,
...laborVoucherRows.value, ...serviceVoucherRows.value]
.forEach(r => urls.set(r.subType, (r.url || '').trim()))
savedSnapshot.value = {
urls,
scheduleUrl: scheduleUrl.value || '',
invitationUrl: invitationUrl.value || '',
}
}
/** 是否有「已上传但未落库」的改动 (材料/凭证/海报/邀请函) */
const hasUnsavedChanges = computed(() => {
const s = savedSnapshot.value
if (!s) return false
if ((scheduleUrl.value || '') !== s.scheduleUrl) return true
if ((invitationUrl.value || '') !== s.invitationUrl) return true
return [...serviceMaterialRows.value, ...laborMaterialRows.value,
...laborVoucherRows.value, ...serviceVoucherRows.value]
.some(r => (r.url || '').trim() !== (s.urls.get(r.subType) || ''))
})
// ===================== 参会人 CRUD =====================
/** 会议下的参会人列表 (放在 劳务材料 tab 表格) */
const attendeeRows = ref([])
@@ -1714,6 +1744,7 @@ async function doSave() {
if (canUploadVoucher.value) await saveVouchers()
// 材料落库 → 会议费用待重算, 刷新状态并轮询到汇总完成
refreshFee()
captureSnapshot()
return ocrCount
}
@@ -1888,6 +1919,27 @@ async function confirmAudit() {
// ===================== 结算 / 完结 (合规/管理员 手动点击) =====================
async function onSettle() {
// ① 有「已上传未保存」的改动 → 先保存再结算
if (hasUnsavedChanges.value) {
try {
await ElMessageBox.confirm(
'检测到尚未保存的上传内容(材料/付款凭证等),是否先保存再结算?',
'未保存的改动',
{ type: 'warning', confirmButtonText: '保存并结算', cancelButtonText: '取消' }
)
} catch { return }
busy.value.settle = true
try {
await doSave()
} catch (e) {
ElMessage.error('保存失败,已取消结算:' + (e?.msg || e?.message || ''))
return
} finally {
busy.value.settle = false
}
ElMessage.success('已保存')
}
// ② 结算确认
try {
await ElMessageBox.confirm('确认结算? 结算前请确保已上传劳务/会务付款凭证。', '结算确认', { type: 'warning' })
} catch { return }
+6 -14
View File
@@ -81,6 +81,8 @@
<el-link :underline="false" type="primary" @click="viewOnly(row)">查看</el-link>
<el-link :underline="false" v-if="isRole('manager') && isCompliancePending(row)" type="warning" @click="onAudit(row)">审核</el-link>
<el-link :underline="false" v-if="isRole('admin', 'manager')" type="primary" @click="viewDetail(row)">编辑材料</el-link>
<!-- 结算: 直链 (两轨均审核通过才出现; 审核互斥, 不增加宽度) -->
<el-link :underline="false" v-if="isRole('admin', 'manager') && canSettleRow(row)" type="warning" @click="onSettle(row)">结算</el-link>
<!-- 更多: admin/manager 视角 7 个次操作折叠 -->
<el-dropdown v-if="isRole('admin', 'manager')" trigger="hover" @command="(cmd) => onMoreAction(cmd, row)">
<el-link :underline="false" type="primary" class="op-dropdown">
@@ -90,7 +92,6 @@
<el-dropdown-menu>
<el-dropdown-item command="edit">修改</el-dropdown-item>
<el-dropdown-item command="copy">复制</el-dropdown-item>
<el-dropdown-item v-if="canSettleRow(row)" command="settle">结算</el-dropdown-item>
<el-dropdown-item v-if="canFinishRow(row)" command="finish">完结</el-dropdown-item>
<el-dropdown-item v-if="isOneVal(row.isFrozen)" command="unfreeze">解冻</el-dropdown-item>
<el-dropdown-item command="downloadService">会务下载</el-dropdown-item>
@@ -101,17 +102,8 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- sponsor 视角: "审核"折叠到下拉 (sponsor 不参与其他次操作) -->
<el-dropdown v-if="isRole('sponsor') && isSponsorPending(row)" trigger="hover" @command="(cmd) => onMoreAction(cmd, row)">
<el-link :underline="false" type="primary" class="op-dropdown">
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
</el-link>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="audit">审核</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- sponsor 视角: 审核直链 (支持方审核, 不折叠到更多) -->
<el-link :underline="false" v-if="isRole('sponsor') && isSponsorPending(row)" type="warning" @click="onAudit(row)">审核</el-link>
</div></template>
</el-table-column>
</GrTable>
@@ -367,8 +359,8 @@ function canSettleRow(row) {
return row.laborAuditStage === 'APPROVED' && row.serviceAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
}
function onSettle(row) {
// 结算需要上传付款凭证 (详情页结算 dialog 二合一), 跳详情并自动打开结算 dialog
router.push({ path: `${detailBase.value}/detail/${row.meetingId}`, query: { settle: '1' } })
// 结算: 跳详情页即可, 不自动弹结算 dialog
router.push(`${detailBase.value}/detail/${row.meetingId}`)
}
// 列表完结按钮显隐: 已结算 且 未完结
function canFinishRow(row) {