feat: 合规员(manager) 自建会议可自传自审, 现场照片支持直接上传

- 后端 submitMaterial: manager 跳过执行方/已执行/已冻结三道门槛
- 前端提交按钮门槛同步放宽 (manager 不受已执行/已冻结限制)
- 现场照片(签到表/前全景/后全景) 新增直接上传入口, 与扫码拍照并存
- 劳务材料 tab 增加"劳务明细表"小标题

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-14 13:39:13 +00:00
co-authored by Claude Code
parent 1dc200a3cc
commit 44d4920777
2 changed files with 34 additions and 15 deletions
@@ -351,8 +351,8 @@ public class BizMeetingController extends BaseController {
/** /**
* 执行人员提交材料 (分轨: 劳务 LABOR / 会务 SERVICE 可多选, 每轨独立提交). * 执行人员提交材料 (分轨: 劳务 LABOR / 会务 SERVICE 可多选, 每轨独立提交).
* <ul> * <ul>
* <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li> * <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验) — 合规员(manager) 自建会议自传自审, 跳过此校验</li>
* <li>校验 2: 已执行 (is_executed=1) 且未冻结</li> * <li>校验 2: 已执行 (is_executed=1) 且未冻结 — 合规员(manager) 跳过"已执行"与"已冻结"两道时间门槛 (不依赖 scheduler)</li>
* <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li> * <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li>
* <li>校验 4: 会务轨在 biz_meeting_material 至少 1 条 (M_*); 劳务轨 = 参会人员列表至少 1 人 (非上传文件)</li> * <li>校验 4: 会务轨在 biz_meeting_material 至少 1 条 (M_*); 劳务轨 = 参会人员列表至少 1 人 (非上传文件)</li>
* </ul> * </ul>
@@ -366,11 +366,15 @@ public class BizMeetingController extends BaseController {
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) { String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
// 合规员(manager) 自建会议可自传自审: 跳过"必须是项目执行方"的强校验
if (!"manager".equals(roleType) && !bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) {
throw new ServiceException("您不是该项目的执行方, 无法提交材料"); throw new ServiceException("您不是该项目的执行方, 无法提交材料");
} }
if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料"); // 合规员(manager) 自建会议自传自审: 不依赖 scheduler 翻 is_executed, 跳过"已执行"时间门槛
if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料"); if (!"manager".equals(roleType) && !isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料");
// 合规员(manager) 自建会议自传自审: 同样跳过"已冻结"门槛 (冻结=提交截止已过, 但自审流程不应对合规员本人设卡)
if (!"manager".equals(roleType) && isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料");
List<String> types = body == null ? null : body.getTypes(); List<String> types = body == null ? null : body.getTypes();
if (types == null || types.isEmpty()) throw new ServiceException("请选择要提交的材料类型"); if (types == null || types.isEmpty()) throw new ServiceException("请选择要提交的材料类型");
+25 -10
View File
@@ -63,8 +63,9 @@
<div class="section-title">材料管理</div> <div class="section-title">材料管理</div>
<el-tabs v-model="activeTab" class="material-tabs"> <el-tabs v-model="activeTab" class="material-tabs">
<el-tab-pane label="劳务材料" name="labor"> <el-tab-pane label="劳务材料" name="labor">
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) --> <!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面) -->
<div class="attendee-section"> <div class="attendee-section">
<div class="attendee-subtitle">劳务明细表</div>
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar"> <div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button> <el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button> <el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button>
@@ -195,7 +196,10 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" /> <div v-if="isCameraSubType(r.subType)" class="camera-upload-row">
<CameraQrUpload v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" accept=".png,.jpg,.jpeg,.gif,.webp,.bmp" placeholder="直接上传(本地图片)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
</div>
<template v-else-if="r.subType === 'L_ESIGN_IN'"> <template v-else-if="r.subType === 'L_ESIGN_IN'">
<div class="esign-actions"> <div class="esign-actions">
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" /> <OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
@@ -898,10 +902,10 @@ const frozen = computed(() => isOne(row.value.isFrozen))
/** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */ /** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */
const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage)) 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)) const serviceEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
// 提交审核后该轨锁定 (manager/admin 也跟着锁), 让 OssFileUploader 移除按钮消失 // 提交审核后该轨锁定执行方 (executor), 让 OssFileUploader 移除按钮消失; 合规/管理员 (manager/admin) 任何时候都可编辑保存, 故不锁
// (仅控制 OssFileUploader 的 readonly; 不影响 laborEditable (下载模板/参会人列表的判定)) // (仅控制 OssFileUploader 的 readonly; 不影响 laborEditable (下载模板/参会人列表的判定))
const laborLocked = computed(() => ['SUBMITTED', 'APPROVED'].includes(row.value.laborAuditStage)) const laborLocked = computed(() => !isManager.value && !isAdmin.value && ['SUBMITTED', 'APPROVED'].includes(row.value.laborAuditStage))
const serviceLocked = computed(() => ['SUBMITTED', 'APPROVED'].includes(row.value.serviceAuditStage)) const serviceLocked = computed(() => !isManager.value && !isAdmin.value && ['SUBMITTED', 'APPROVED'].includes(row.value.serviceAuditStage))
// 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改 // 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改
const attendeeEditable = computed(() => { const attendeeEditable = computed(() => {
if (isSponsor.value || isReadonly.value) return false if (isSponsor.value || isReadonly.value) return false
@@ -911,11 +915,17 @@ const attendeeEditable = computed(() => {
/** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */ /** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */
function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) } function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) }
function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) } function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) }
// 材料可提交: 已执行 + 未冻结 + 对应轨可编辑 (三按钮按轨显隐) // 材料可提交: 执行方需"已执行"+未冻结; 合规员(manager 自建会议自传自审)不受"已执行"与"已冻结"两道时间门槛限制
const executorSubmitReady = computed(() => isExecutor.value && executed.value && !frozen.value) const executorSubmitReady = computed(() => {
const canSubmitLabor = computed(() => executorSubmitReady.value && laborEditable.value) if (isManager.value) return true
const canSubmitService = computed(() => executorSubmitReady.value && serviceEditable.value) return isExecutor.value && executed.value && !frozen.value
const canSubmitAll = computed(() => executorSubmitReady.value && laborEditable.value && serviceEditable.value) })
// 单轨"可提交"门槛 (区别于 laborEditable 的"可编辑"): 仅未提交/被驳回可提交, 提交后按钮隐藏
const laborSubmittable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceSubmittable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
const canSubmitLabor = computed(() => executorSubmitReady.value && laborSubmittable.value)
const canSubmitService = computed(() => executorSubmitReady.value && serviceSubmittable.value)
const canSubmitAll = computed(() => executorSubmitReady.value && laborSubmittable.value && serviceSubmittable.value)
// 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定 // 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定
const materialsEditable = computed(() => laborEditable.value || serviceEditable.value) const materialsEditable = computed(() => laborEditable.value || serviceEditable.value)
// 合规审核 (合规方/manager): 三按钮按轨 C0 显隐 // 合规审核 (合规方/manager): 三按钮按轨 C0 显隐
@@ -2590,6 +2600,10 @@ onBeforeUnmount(() => {
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; } .agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* 电子签到表 多文件上传 + zip 按钮 纵向排列 */ /* 电子签到表 多文件上传 + zip 按钮 纵向排列 */
.esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; } .esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
/* 现场照片 (签到表/前全景/后全景): 扫码拍照 + 直接上传 左右并存 */
.camera-upload-row { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
.camera-upload-row :deep(.camera-qr-upload) { width: auto; flex: 0 0 auto; }
.camera-upload-row :deep(.ht-file-upload) { flex: 1 1 0; min-width: 0; }
.upload-progress { grid-column: 2; display: flex; align-items: center; gap: 8px; max-width: 400px; } .upload-progress { grid-column: 2; display: flex; align-items: center; gap: 8px; max-width: 400px; }
.upload-progress .el-progress { flex: 1; } .upload-progress .el-progress { flex: 1; }
.upload-progress-status { font-size: 12px; color: #8c8c8c; white-space: nowrap; } .upload-progress-status { font-size: 12px; color: #8c8c8c; white-space: nowrap; }
@@ -2638,6 +2652,7 @@ onBeforeUnmount(() => {
/* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */ /* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */
.attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; } .attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; }
.attendee-subtitle { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 12px; }
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; } .attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; } .form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }