Compare commits

...
2 Commits
Author SHA1 Message Date
adminandClaude Code 44d4920777 feat: 合规员(manager) 自建会议可自传自审, 现场照片支持直接上传
- 后端 submitMaterial: manager 跳过执行方/已执行/已冻结三道门槛
- 前端提交按钮门槛同步放宽 (manager 不受已执行/已冻结限制)
- 现场照片(签到表/前全景/后全景) 新增直接上传入口, 与扫码拍照并存
- 劳务材料 tab 增加"劳务明细表"小标题

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 13:39:13 +00:00
adminandClaude Code 1dc200a3cc build: 分环境打包 (Maven profile 注入激活 profile, jar 名加 -test/-prod 后缀)
- ry-api/pom.xml 新增 test/prod profile, 提供 active.profile 与 jar.suffix
- ruoyi-admin 资源过滤仅针对 application*.yml, finalName 追加 jar.suffix
- application.yml 的 spring.profiles.active 改用 @active.profile@, 免运行时 --spring.profiles.active

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 13:38:34 +00:00
5 changed files with 76 additions and 18 deletions
+22
View File
@@ -31,6 +31,10 @@
<jwt.version>0.9.1</jwt.version> <jwt.version>0.9.1</jwt.version>
<jaxb-api.version>2.3.1</jaxb-api.version> <jaxb-api.version>2.3.1</jaxb-api.version>
<springdoc.version>3.0.3</springdoc.version> <springdoc.version>3.0.3</springdoc.version>
<!-- 打包注入的激活 profile: 默认 prod, -Ptest / -Pprod 覆盖 -->
<active.profile>prod</active.profile>
<!-- jar 名后缀: 默认空 (=ruoyi-admin.jar), -Ptest → -test, -Pprod → -prod -->
<jar.suffix></jar.suffix>
</properties> </properties>
<!-- 依赖声明 --> <!-- 依赖声明 -->
@@ -215,6 +219,24 @@
</plugins> </plugins>
</build> </build>
<!-- 分环境打包: mvn package -Ptest / -Pprod 生成 ruoyi-admin-test.jar / ruoyi-admin-prod.jar (profile 已注入, 免 spring.profiles.active 启动参数) -->
<profiles>
<profile>
<id>test</id>
<properties>
<active.profile>test</active.profile>
<jar.suffix>-test</jar.suffix>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<active.profile>prod</active.profile>
<jar.suffix>-prod</jar.suffix>
</properties>
</profile>
</profiles>
<repositories> <repositories>
<repository> <repository>
<id>public</id> <id>public</id>
+18 -1
View File
@@ -63,6 +63,23 @@
</dependencies> </dependencies>
<build> <build>
<!-- 仅过滤 application*.yml 注入 @active.profile@, 其余资源 (logback/mapper/i18n) 不过滤避免误替换 ${...} -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>application*.yml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>application*.yml</exclude>
</excludes>
</resource>
</resources>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -89,7 +106,7 @@
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>
<finalName>${project.artifactId}</finalName> <finalName>${project.artifactId}${jar.suffix}</finalName>
</build> </build>
</project> </project>
@@ -6,8 +6,8 @@ spring:
# 国际化资源文件路径 # 国际化资源文件路径
basename: i18n/messages basename: i18n/messages
profiles: profiles:
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test) # 默认 prod; Maven 打包时用 -Ptest/-Pprod 注入 @active.profile@ (见 pom.xml profiles), 免运行时 --spring.profiles.active
active: prod active: @active.profile@
group: group:
test: druid,test test: druid,test
prod: druid,prod prod: druid,prod
@@ -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; }