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
+2 -1
View File
@@ -65,7 +65,8 @@ const rawOptions = ref([])
const loading = ref(false)
// el-select 内部绑定的 key (可能 = 字典值, 可能 = OTHER_KEY)
const selectedKey = ref(props.modelValue ?? null)
// 空字符串统一成 null, 避免挂载时 '' → null 触发 el-select 的 watch→validate('change') 导致必填项直接飘红
const selectedKey = ref((props.modelValue == null || props.modelValue === '') ? null : props.modelValue)
// "其他" 输入框独立内容
const otherText = ref(props.modelValue ?? '')
+2 -1
View File
@@ -65,7 +65,8 @@ const rawOptions = ref([])
const loading = ref(false)
// el-select 内部绑定的 key
const selectedKey = ref(props.modelValue ?? null)
// 空字符串统一成 null, 避免挂载时 '' → null 触发 el-select 的 watch→validate('change') 导致必填项直接飘红
const selectedKey = ref((props.modelValue == null || props.modelValue === '') ? null : props.modelValue)
// "其他" 输入框独立内容
const otherText = ref(props.modelValue ?? '')
+104 -57
View File
@@ -2,8 +2,8 @@
* 会议阶段显示 (事实驱动, 与后端 StageDeriver 镜像).
*
* biz_meeting 现在存「事实」: is_executed / is_frozen / is_settled / is_finished
* + material_audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
* + material_compliance_approved (区分两级审核) + 审核时间.
* + 劳务/会务两轨各自 audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
* + 各自 compliance_approved (区分两级审核) + 审核时间.
*
* 各角色看到的「阶段名称」由这些事实实时推导, 不再是单一 current_stage 枚举投影.
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
@@ -13,21 +13,87 @@ function isTrue(v) {
return v === 1 || v === '1' || v === true
}
/** 单轨状态码: R=0, N=1, C0=2, C1=3, A=4 (数值越小 = 流程越靠前). */
function stateOf(stage, complianceApproved) {
if (stage === 'REJECTED') return 0
if (stage === 'SUBMITTED') return isTrue(complianceApproved) ? 3 : 2
if (stage === 'APPROVED') return 4
return 1 // NOT_SUBMITTED (或空兜底)
}
function laborState(row) {
return stateOf(row.laborAuditStage, row.laborComplianceApproved)
}
function serviceState(row) {
return stateOf(row.serviceAuditStage, row.serviceComplianceApproved)
}
/**
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
* 两轨取最小进度 (流程最靠前的一轨决定会议物理态).
*/
export function derivePhysicalStage(row) {
if (!row) return 'NOT_STARTED'
if (isTrue(row.isFrozen)) return 'FROZEN'
if (isTrue(row.isFinished)) return 'FINISHED'
if (isTrue(row.isSettled)) return 'SETTLED'
const material = row.materialAuditStage
if (material === 'REJECTED') return 'RECTIFYING'
if (material === 'APPROVED') return 'AWAITING_SETTLEMENT'
if (material === 'SUBMITTED') return isTrue(row.materialComplianceApproved) ? 'AWAITING_SUPERVISION' : 'AWAITING_COMPLIANCE'
const s = Math.min(laborState(row), serviceState(row))
if (s === 0) return 'RECTIFYING'
if (s === 2) return 'AWAITING_COMPLIANCE'
if (s === 3) return 'AWAITING_SUPERVISION'
if (s === 4) return 'AWAITING_SETTLEMENT'
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
}
/** manager 优先级: C0=0, R=1, N=2, C1=3, A=4 (越小越优先). C0=有活最优先; 无活时按最早环节优先(R<N<C1<A). */
function managerPriority(s) {
switch (s) {
case 2: return 0
case 0: return 1
case 1: return 2
case 3: return 3
default: return 4
}
}
/** sponsor 优先级: C1=0, R=1, N=2, C0=3, A=4 (越小越优先). C1=有活最优先; 无活时按最早环节优先(R<N<C0<A). */
function sponsorPriority(s) {
switch (s) {
case 3: return 0
case 0: return 1
case 1: return 2
case 2: return 3
default: return 4
}
}
/** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */
function chooseState(role, labor, service) {
if (role === 'manager') return managerPriority(labor) <= managerPriority(service) ? labor : service
if (role === 'sponsor') return sponsorPriority(labor) <= sponsorPriority(service) ? labor : service
return Math.min(labor, service)
}
/** 单轨措辞 (代表轨状态 + 角色 → 展示名). */
function render(role, s, executed) {
switch (s) {
case 0: // R 退回
return role === 'executor' ? '已退回' : '待整改'
case 1: // N 未提交
if (!executed) return '未执行'
return role === 'executor' ? '执行中' : '已执行未传材料'
case 2: // C0 合规审中
if (role === 'sponsor') return '已执行未传材料' // 只读
return '待审核'
case 3: // C1 支持方审中
if (role === 'manager') return '审核通过'
return '待审核'
default: // 4 = A 已通过
return '待结算'
}
}
/**
* 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay).
* role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性.
@@ -37,34 +103,8 @@ export function deriveStage(role, row) {
if (isTrue(row.isFrozen)) return '冻结中'
if (isTrue(row.isFinished)) return '已完结'
if (isTrue(row.isSettled)) return '已结算'
const material = row.materialAuditStage
// 退回: 执行方看「已退回」, 其他方看「待整改」
if (material === 'REJECTED') {
return role === 'executor' ? '已退回' : '待整改'
}
// 材料已支持方通过 → 待结算 (不再有 24h 慢路径)
if (material === 'APPROVED') {
return '待结算'
}
// 材料在审 (SUBMITTED)
if (material === 'SUBMITTED') {
if (isTrue(row.materialComplianceApproved)) {
// 支持方审中
return role === 'manager' ? '审核通过' : '待审核'
}
// 合规审中
return role === 'sponsor' ? '已执行未传材料' : '待审核'
}
// 未提交
if (isTrue(row.isExecuted)) {
return role === 'executor' ? '执行中' : '已执行未传材料'
}
return '未执行'
const chosen = chooseState(role, laborState(row), serviceState(row))
return render(role, chosen, isTrue(row.isExecuted))
}
/**
@@ -74,31 +114,38 @@ export function stageLabel(role, row) {
return deriveStage(role, row)
}
/** 颜色 class (业务专用 tag), 基于物理阶段 */
export function stageClass(row) {
const s = derivePhysicalStage(row)
if (s === 'NOT_STARTED') return 'pending'
if (s === 'RUNNING') return 'running'
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'reviewing'
if (s === 'SUPERVISION_APPROVED') return 'done'
if (s === 'RECTIFYING') return 'waiting'
if (s === 'AWAITING_SETTLEMENT') return 'waiting'
if (s === 'SETTLED') return 'done'
if (s === 'FINISHED') return 'done'
if (s === 'FROZEN') return 'frozen'
return 'default'
/**
* 展示阶段名 → 颜色映射 (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' },
'待审核': { cls: 'reviewing', tag: 'warning' },
'审核通过': { cls: 'done', tag: 'success' },
'待结算': { cls: 'done', tag: 'success' },
}
/** el-tag type (doctor 列表用), 基于物理阶段 */
export function stageTag(row) {
const s = derivePhysicalStage(row)
if (s === 'NOT_STARTED') return 'info'
if (s === 'RUNNING' || s === 'RECTIFYING') return 'primary'
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'warning'
if (s === 'SUPERVISION_APPROVED' || s === 'SETTLED' || s === 'FINISHED') return 'success'
if (s === 'AWAITING_SETTLEMENT') return 'warning'
if (s === 'FROZEN') return 'info'
return 'info'
function stageStyle(role, row) {
return STAGE_STYLE[deriveStage(role, row)] || { cls: 'default', tag: 'info' }
}
/** 颜色 class (业务专用 tag), 基于角色展示阶段 (与 stageLabel 文案一致). */
export function stageClass(role, row) {
return stageStyle(role, row).cls
}
/** el-tag type (doctor 列表用), 基于角色展示阶段. */
export function stageTag(role, row) {
return stageStyle(role, row).tag
}
/**
@@ -3,6 +3,8 @@
<div class="page-wrap">
<div class="page-card">
<h2 class="page-title">项目执行单位注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" />
<el-step title="注册成功" />
@@ -196,6 +198,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
@@ -3,6 +3,8 @@
<div class="page-wrap">
<div class="page-card">
<h2 class="page-title">项目参与专家注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" />
<el-step title="注册成功" />
@@ -185,6 +187,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
@@ -3,6 +3,8 @@
<div class="page-wrap">
<div class="page-card">
<h2 class="page-title">项目支持单位注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" />
<el-step title="注册成功" />
@@ -210,6 +212,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
+1 -1
View File
@@ -18,7 +18,7 @@
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
<el-table-column prop="currentStage" label="当前阶段" width="140">
<template #default="{ row }">
<el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
<el-tag :type="stageTag('doctor', row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="日程" width="130" align="center">
+1 -1
View File
@@ -51,7 +51,7 @@
</el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }">
<span :class="['stage-tag', 'stage-' + stageClass(row)]">{{ stageLabel('executor', row) }}</span>
<span :class="['stage-tag', 'stage-' + stageClass('executor', row)]">{{ stageLabel('executor', row) }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
@@ -44,10 +44,10 @@
<el-table-column prop="orgName" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="sessions" label="场次" width="90" align="right" />
<el-table-column label="金额" width="130" align="right">
<template #default="{ row }">¥ {{ fmtMoney(row.amount) }}</template>
<template #default="{ row }"><span class="money">¥ {{ fmtMoney(row.amount) }}</span></template>
</el-table-column>
<template #empty>
<span style="color:#c0c4cc">暂无分配 ( 项目管理 / 项目分配 添加)</span>
<span style="color:#909399">暂无分配 ( 项目管理 / 项目分配 添加)</span>
</template>
</el-table>
</el-form-item>
@@ -71,7 +71,7 @@
<tbody>
<tr v-for="(r, idx) in form.roleRows" :key="idx">
<td>{{ r.role || '-' }}</td>
<td style="text-align:right">¥ {{ fmtMoney(r.amount) }}</td>
<td class="money" style="text-align:right">¥ {{ fmtMoney(r.amount) }}</td>
</tr>
<tr v-if="!form.roleRows.length"><td colspan="2" class="empty-row">暂无角色</td></tr>
</tbody>
@@ -104,6 +104,11 @@
<!-- 公告预览 (只读) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<!-- 底部操作: 返回 (按角色回退) -->
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
@@ -148,7 +153,7 @@ const notices = computed(() => {
return items.map(m => ({
label: m.label,
url: m.url,
name: m.url ? (m.url.split('/').pop() || '') : ''
name: fileNameFromUrl(m.url)
}))
})
@@ -176,6 +181,17 @@ function fmtDate(v) {
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
return m ? `${m[1]}.${m[2]}.${m[3]}` : s
}
// 从 OSS URL 提取并解码文件名 (中文文件名是 URL 编码的, 需 decodeURIComponent 还原)
function fileNameFromUrl(url) {
if (!url) return ''
try {
const path = String(url).split('?')[0]
return decodeURIComponent(path.split('/').pop() || '')
} catch (e) {
// 非标准编码导致解码失败 → 退回原始最后一段
return String(url).split('?')[0].split('/').pop() || ''
}
}
// ===================== 加载 =====================
async function loadProject() {
@@ -279,9 +295,9 @@ onMounted(async () => {
</script>
<style scoped>
/* 1:1 抄列表页 (manager/Projects.vue) 风格 */
/* 项目详情 — 统一排版: 正文 14px, 章节标题/表头 600, 金额等宽, 三级灰 strong/regular/muted */
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; max-width: 1200px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 12px; }
.breadcrumb .current { color: #262626; font-weight: 500; }
/* 章节标题 (与编辑页 ProjectsNew.vue 保持一致) */
@@ -292,15 +308,21 @@ onMounted(async () => {
}
.new-card-title:first-child { margin-top: 0; }
/* 项目信息: 11 字段, 一列竖排 (el-form + 文本, 无分隔线) */
/* 底部操作按钮 (返回) */
.form-actions { margin-top: 24px; display: flex; gap: 8px; }
/* 项目信息: 只读 el-form, 标签 14px/regular, 值 14px/strong */
.info-form { padding-top: 4px; }
.info-form :deep(.el-form-item) { margin-bottom: 8px; }
.info-form :deep(.el-form-item__label) {
font-size: 14px; color: #595959; width: 130px; padding-right: 16px;
font-size: 14px; color: #606266; width: 130px; padding-right: 16px;
}
.info-value { font-size: 15px; color: #1a1a1a; font-weight: 500; }
.info-value.money {
font-family: ui-monospace, "Courier New", monospace;
.info-value { font-size: 14px; color: #262626; font-weight: 400; }
/* 金额: 统一等宽字体 + tabular 数字对齐 (表单值 + 两处表格通用) */
.money {
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, "Courier New", monospace;
font-variant-numeric: tabular-nums;
}
/* 监督员 */
@@ -310,31 +332,33 @@ onMounted(async () => {
padding: 8px 12px; background: #fafafa; border-radius: 4px;
margin-bottom: 6px;
}
.monitor-num { width: 24px; color: #8c8c8c; font-size: 13px; }
.monitor-name { flex: 1; font-size: 14px; color: #1a1a1a; }
.empty-hint { text-align: center; color: #c0c4cc; padding: 12px 0; font-size: 13px; }
.monitor-num { width: 24px; color: #909399; font-size: 13px; }
.monitor-name { flex: 1; font-size: 14px; color: #262626; }
.empty-hint { text-align: center; color: #909399; padding: 12px 0; font-size: 13px; }
/* 角色劳务表格 */
.info-table { width: 100%; border-collapse: collapse; font-size: 13px; }
/* 角色劳务表格 + 服务公司表格: 统一 14px, 表头 600/strong, 单元格 strong */
.info-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.role-labor-table { width: auto; border: 1px solid #ebeef5; } /* 角色劳务表格按内容紧凑显示, 不撑满卡片 */
.role-labor-table th,
.role-labor-table td { border-right: 1px solid #ebeef5; }
.role-labor-table th:last-child,
.role-labor-table td:last-child { border-right: none; }
/* 服务公司表格 (el-table in form): 紧凑, 不要撑满整张卡片 */
.assign-table-el { width: auto; max-width: 560px; }
.assign-table-el { width: auto; max-width: 560px; font-size: 14px; }
.assign-table-el :deep(.el-table__empty-block) { min-height: 48px; }
.info-table th { background: #fafafa; padding: 10px 12px; text-align: left; color: #1a1a1a; font-weight: 600; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; }
.empty-row { text-align: center; color: #c0c4cc; }
.assign-table-el :deep(th.el-table__cell) { color: #262626; font-weight: 600; background: #fafafa; }
.assign-table-el :deep(td.el-table__cell) { color: #262626; }
.info-table th { background: #fafafa; padding: 10px 12px; text-align: left; color: #262626; font-weight: 600; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #262626; vertical-align: middle; }
.empty-row { text-align: center; color: #909399; }
/* 公告文件 (只读 4 行: 邀请函/支持函/通知/日程) */
.notice-list { display: flex; flex-direction: column; gap: 6px; }
.notice-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
.notice-label { width: 90px; flex-shrink: 0; color: #606266; font-size: 14px; }
.notice-file { flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0; }
.file-name { color: #1a1a1a; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-empty { color: #c0c4cc; font-size: 13px; }
.file-name { color: #262626; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-empty { color: #909399; font-size: 13px; }
/* 提示 (灰色, 跟 ProjectsNew 一致) */
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
@@ -51,7 +51,8 @@
<el-select v-model="assignForm.sponsorOrgId" placeholder="请选择支持单位" filterable
:filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick">
<el-option v-for="u in sponsorOrgOptions" :key="u.orgId"
:label="u.orgName" :value="u.orgId" />
:label="u.orgName + (isOrgDisabled(u) ? '(已禁用)' : '')" :value="u.orgId"
:disabled="isOrgDisabled(u)" />
</el-select>
</el-form-item>
@@ -84,7 +85,8 @@
style="width:100%"
@change="v => onExecUserPick(row, v)">
<el-option v-for="u in execOptions(row)" :key="u.orgId"
:label="u.orgName" :value="u.orgId" />
:label="u.orgName + (isOrgDisabled(u) ? '(已禁用)' : '')" :value="u.orgId"
:disabled="isOrgDisabled(u)" />
</el-select>
</template>
</el-table-column>
@@ -184,6 +186,11 @@ function openPreview(url, title) {
previewOpen.value = true
}
// 公司是否被禁用 (biz_org.status='1') — 禁用项仍展示但置灰、不可选
function isOrgDisabled(u) {
return String(u?.status) === '1'
}
// 加载状态
const singleLoading = ref(false)
const batchLoading = ref(false)
+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; }
+22 -60
View File
@@ -65,13 +65,14 @@
</el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }">
<span :class="['stage-tag', 'stage-' + stageClass(row)]">{{ stageLabel(roleSegment, row) }}</span>
<span :class="['stage-tag', 'stage-' + stageClass(roleSegment, row)]">{{ stageLabel(roleSegment, row) }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="500" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="viewOnly(row)">查看</el-button>
<el-button v-if="isRole('manager') && isCompliancePending(row)" link type="warning" @click="onAudit(row)">审核</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="viewDetail(row)">编辑材料</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onEdit(row)">修改</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onCopy(row)">复制</el-button>
@@ -81,7 +82,7 @@
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onDownloadService(row)">会务下载</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onDownloadLabor(row)">劳务下载</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="danger" @click="onDelete(row)">删除</el-button>
<el-button v-if="isRole('sponsor') && derivePhysicalStage(row) === 'AWAITING_SUPERVISION'" link type="warning" @click="onApproval(row)"></el-button>
<el-button v-if="isRole('sponsor') && isSponsorPending(row)" link type="warning" @click="onAudit(row)"></el-button>
</template>
</el-table-column>
</el-table>
@@ -100,26 +101,6 @@
<!-- 修改 / 复制: 按角色跳 admin-meetings-new / manager-meetings-new (MeetingNew.vue ) -->
<!-- 查看: 按角色跳 /admin/meetings/detail/:id /manager/meetings/detail/:id -->
<!-- 支持方(监察员) 审批 dialog: sponsor current_stage=AWAITING_SUPERVISION 时可见 -->
<el-dialog v-model="approvalOpen" title="支持方审批" width="560px">
<el-form label-width="100px">
<el-form-item label="会议名称"><span>{{ approvalRow.meetingName }}</span></el-form-item>
<el-form-item label="审批结果">
<el-radio-group v-model="approvalForm.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">退回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见">
<el-input v-model="approvalForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="退回时意见必填, 将通知执行方整改" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="approvalOpen = false">取消</el-button>
<el-button type="primary" :loading="approvalSaving" @click="onApprovalConfirm">确定</el-button>
</template>
</el-dialog>
<!-- 合规人员(manager) 批量审核 dialog: 对选中的待合规审核会议批量通过/退回 -->
<el-dialog v-model="batchAuditOpen" title="批量合规审核" width="560px">
<el-form label-width="100px">
@@ -149,7 +130,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { bizList, bizDelete } from '@/api/public'
import request from '@/utils/request'
import { stageLabel, STAGE_OPTIONS, derivePhysicalStage, stageClass } from '@/utils/meetingStage'
import { stageLabel, STAGE_OPTIONS, stageClass } from '@/utils/meetingStage'
import { ElMessage, ElMessageBox } from 'element-plus'
// ========== 角色感知 (admin/manager 共用此页, 按 URL 第 1 段识别) ==========
@@ -304,8 +285,10 @@ function isOneVal(v) { return v === 1 || v === '1' || v === true }
// 已提交(SUBMITTED/APPROVED): 曾被冻结过 → 超时提交, 否则 → 按时提交
// 未提交/退回(NOT_SUBMITTED/REJECTED): 已冻结 → 红色 0小时; 否则 submitDeadline - now 倒计时
function submitRemain(row) {
const stage = row.materialAuditStage
if (stage === 'SUBMITTED' || stage === 'APPROVED') {
// 两轨都进入审核 (SUBMITTED/APPROVED) 才算已提交; 否则任一条未提交/被驳回 → 仍在提交窗口
const laborDone = row.laborAuditStage === 'SUBMITTED' || row.laborAuditStage === 'APPROVED'
const serviceDone = row.serviceAuditStage === 'SUBMITTED' || row.serviceAuditStage === 'APPROVED'
if (laborDone && serviceDone) {
return row.freezeTime ? { text: '超时提交', red: false } : { text: '按时提交', red: false }
}
if (isOneVal(row.isFrozen)) return { text: '0小时', red: true }
@@ -317,9 +300,9 @@ function submitRemain(row) {
const hours = totalHours % 24
return { text: `${days}${hours}小时`, red: false }
}
// 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (材料 APPROVED 且未结算)
// 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (两轨均 APPROVED 且未结算)
function canSettleRow(row) {
return row.materialAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
return row.laborAuditStage === 'APPROVED' && row.serviceAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
}
function onSettle(row) {
// 结算需要上传付款凭证 (详情页结算 dialog 二合一), 跳详情并自动打开结算 dialog
@@ -352,38 +335,15 @@ async function onUnfreeze(row) {
} catch (e) { ElMessage.error(e?.msg || e?.message || '解冻失败') }
}
// ========== 支持方(监察员) 审 ==========
const approvalOpen = ref(false)
const approvalSaving = ref(false)
const approvalRow = ref({})
const approvalForm = reactive({ approved: true, opinion: '' })
function onApproval(row) {
approvalRow.value = row
approvalForm.approved = true
approvalForm.opinion = ''
approvalOpen.value = true
// ========== 支持方(监察员) 审核: 跳详情页 (详情页内按 C1 轨给 审核劳务/会务/全部) ==========
// 有任一轨处于 C1 (已提交待支持方审) 即显示「审核」; 会务被退回时物理阶段=待整改, 但劳务 C1 仍需审, 不能用物理阶段判
function isSponsorPending(row) {
return (row.laborAuditStage === 'SUBMITTED' && isOneVal(row.laborComplianceApproved))
|| (row.serviceAuditStage === 'SUBMITTED' && isOneVal(row.serviceComplianceApproved))
}
async function onApprovalConfirm() {
if (!approvalForm.approved && !approvalForm.opinion.trim()) {
ElMessage.warning('退回时意见不能为空')
return
}
approvalSaving.value = true
try {
await request.post(`/business/meeting/${approvalRow.value.meetingId}/audit-supervision`, {
approved: approvalForm.approved,
opinion: approvalForm.opinion
})
ElMessage.success(approvalForm.approved ? '已通过 → 审核通过' : '已退回 → 待整改')
approvalOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '审批失败')
} finally {
approvalSaving.value = false
}
// 审核: 跳详情页 (非 readonly), 详情页按角色控制显示三按钮
function onAudit(row) {
router.push(`${detailBase.value}/detail/${row.meetingId}`)
}
// ========== 合规人员(manager) 批量审核 ==========
@@ -392,9 +352,11 @@ const batchAuditSaving = ref(false)
const batchAuditForm = reactive({ approved: true, opinion: '' })
const batchEligibleIds = ref([])
// 待合规审核判据: material_audit_stage=SUBMITTED 且 compliance_approved≠1 (与详情页 canComplianceAudit 一致)
// 待合规审核判据: 任一轨 SUBMITTED 且 compliance_approved≠1 (C0, 与详情页 canComplianceAudit 一致)
function isCompliancePending(row) {
return row.materialAuditStage === 'SUBMITTED' && !isOneVal(row.materialComplianceApproved)
const laborC0 = row.laborAuditStage === 'SUBMITTED' && !isOneVal(row.laborComplianceApproved)
const serviceC0 = row.serviceAuditStage === 'SUBMITTED' && !isOneVal(row.serviceComplianceApproved)
return laborC0 || serviceC0
}
function onBatchAudit() {
+8 -1
View File
@@ -93,7 +93,7 @@
<!-- : 操作按钮 - 悬浮固定到页面最右 (按选中 tab 动态显示) -->
<aside class="detail-actions">
<!-- 邀请函: 立即报名 -->
<button v-if="activeTab === 'invitation'" class="action-btn primary signup-btn" :disabled="signing || signed" @click="onSignup">
<button v-if="activeTab === 'invitation' && canShowSignupBtn" class="action-btn primary signup-btn" :disabled="signing || signed" @click="onSignup">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg>
@@ -217,6 +217,12 @@ const canShowExecutionBtn = computed(() => {
if (!r) return true
return r === 'executor'
})
// 立即报名 (专家参与邀请函): 仅专家 + 匿名可见; sponsor/executor/manager/admin 隐藏
const canShowSignupBtn = computed(() => {
const r = userStore.user?.role || ''
if (!r) return true
return r === 'doctor'
})
const showQr = ref(false)
const qrUrl = ref('')
@@ -380,6 +386,7 @@ async function checkSigned() {
}
const signing = ref(false)
async function onSignup() {
if (!canShowSignupBtn.value) return // 防御: 防止 v-if 被绕过
if (!loggedIn.value) {
ElMessage.warning('请先登录系统')
router.push('/login')
+1 -1
View File
@@ -42,7 +42,7 @@
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" :readonly="!!myOrg" placeholder="请输入所属公司" maxlength="200">
<el-input v-model="form.orgName" :readonly="!!myOrg || isEdit" placeholder="请输入所属公司" maxlength="200">
<template #append v-if="myOrg">
<el-tooltip content="主账号公司, 注册时已自动关联" placement="top">
<el-icon><Lock /></el-icon>
+5 -26
View File
@@ -6,7 +6,7 @@
筛选栏: <el-form inline :model="q" class="filter-form">
工具栏: <div class="toolbar">
列表列 (按原型 sponsor-people.html left 排序):
复选框 / 姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色(角色+账号类型已合并) / 状态 / 操作
姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色(角色+账号类型已合并) / 状态 / 操作
-->
<div class="page-card">
<div class="breadcrumb">首页 / 人员管理</div>
@@ -39,11 +39,9 @@
<div class="toolbar">
<el-button type="primary" @click="onCreate">新建人员</el-button>
<el-button @click="onImport">批量导入</el-button>
<el-button :disabled="!selected.length || hasSelfInSelection" @click="onBatchRemove">批量删除</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" :selectable="(row) => row.userId !== store.user?.userId" />
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="account" label="账号" width="120" show-overflow-tooltip />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="130" />
@@ -136,7 +134,7 @@
</template>
<script setup>
import { reactive, ref, computed } from 'vue'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizUpdate, bizDelete, resetPersonPassword } from '@/api/public'
@@ -148,9 +146,6 @@ import request from '@/utils/request'
const router = useRouter()
const store = useUserStore()
// 批量操作禁用判断: 选了主账号自己 (跟"操作列禁用/删除隐藏"是同一条规则, 防止误删)
const hasSelfInSelection = computed(() => selected.value.some(r => r.userId === store.user?.userId))
// sponsor 端只看自己团队 (主账号+子账号) 创建的人, 走专属接口 /business/person/sponsorList (后端强制隔离)
const q = reactive({
name: '', phone: '', orgName: '', department: '', status: ''
@@ -158,7 +153,6 @@ const q = reactive({
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
const rows = ref([])
const loading = ref(false)
const selected = ref([])
async function load() {
loading.value = true
@@ -183,8 +177,6 @@ function reset() {
load()
}
function onSelectionChange(arr) { selected.value = arr }
function onCreate() {
router.push({ path: '/sponsor/people/new' })
}
@@ -241,21 +233,6 @@ async function onRemoveOne(row) {
}
}
async function onBatchRemove() {
if (!selected.value.length) { ElMessage.warning('请先勾选要删除的人员'); return }
if (hasSelfInSelection.value) { ElMessage.warning('选中的包含您本人, 不能删除'); return }
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 条? 该操作不可恢复`, '批量删除', { type: 'warning' })
} catch { return }
let ok = 0
for (const r of selected.value) {
try { await bizDelete('person', r.personId); ok++ } catch {}
}
ElMessage.success(`已删除 ${ok}`)
selected.value = []
load()
}
// 批量导入 (Excel)
const importVisible = ref(false)
const uploadRef = ref()
@@ -338,4 +315,6 @@ load()
:deep(.el-table .el-button.is-link) { color: var(--brand-primary); background: transparent; border: none; }
:deep(.el-table .el-button.is-link:hover) { color: var(--brand-primary-deep); background: transparent; }
:deep(.el-table .el-button.is-link.is-disabled) { color: #c0c4cc; background: transparent; }
:deep(.el-table .el-button--danger.is-link) { color: var(--el-color-danger); }
:deep(.el-table .el-button--danger.is-link:hover) { color: var(--el-color-danger); }
</style>