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
@@ -31,6 +31,7 @@ import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.service.IBizOrgService; import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizProjectSponsorAssignService;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.StageDeriver; import com.ruoyi.business.service.StageDeriver;
import com.ruoyi.business.service.PosterService; import com.ruoyi.business.service.PosterService;
@@ -63,6 +64,8 @@ public class BizMeetingController extends BaseController {
@Autowired @Autowired
private IBizProjectService bizProjectService; private IBizProjectService bizProjectService;
@Autowired @Autowired
private IBizProjectSponsorAssignService bizProjectSponsorAssignService;
@Autowired
private IBizOrgService bizOrgService; private IBizOrgService bizOrgService;
@Autowired @Autowired
private StageDeriver stageDeriver; private StageDeriver stageDeriver;
@@ -87,12 +90,12 @@ public class BizMeetingController extends BaseController {
bizMeeting.getParams().put("sponsorAdminUserId", uid); bizMeeting.getParams().put("sponsorAdminUserId", uid);
} }
} }
// executor 数据权限: 只看"我的项目"下的会议 (与项目列表 selectExecutorList/selectExecutorStaffList 同源). // executor 数据权限: MAIN 看"我的执行单位"下的会议 (走 biz_project_assign.execution_unit_id);
// MAIN 走 biz_project_assign (execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id. // SUB(执行人) 只看"本人创建的会议" (按 create_by 过滤, 不再看整个分配项目的会议).
else if ("executor".equals(roleType)) { else if ("executor".equals(roleType)) {
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid); SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) { if (current != null && "SUB".equals(current.getAccountType())) {
bizMeeting.getParams().put("executorStaffUserId", uid); bizMeeting.getParams().put("executorCreatorUsername", SecurityUtils.getUsername());
} else { } else {
bizMeeting.getParams().put("executorUserId", uid); bizMeeting.getParams().put("executorUserId", uid);
} }
@@ -233,18 +236,19 @@ public class BizMeetingController extends BaseController {
// =================================================================== // ===================================================================
/** /**
* 执行人员提交材料 * 执行人员提交材料 (分轨: 劳务 LABOR / 会务 SERVICE 可多选, 每轨独立提交).
* <ul> * <ul>
* <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li> * <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li>
* <li>校验 2: 已执行 (is_executed=1) 且未冻结</li> * <li>校验 2: 已执行 (is_executed=1) 且未冻结</li>
* <li>校验 3: material_audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li> * <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li>
* <li>校验 4: biz_meeting_material 劳务(L_*)与会务(M_*)各至少 1 条, 不必全部子类型填满</li> * <li>校验 4: 所选轨在 biz_meeting_material 各至少 1 条 (劳务 L_* / 会务 M_*)</li>
* </ul> * </ul>
* 通过后 material → SUBMITTED (compliance_approved=0), 记 audit_log. * body: { "types": ["LABOR","SERVICE"] }
* 通过后该轨 → SUBMITTED (compliance_approved=0), 每轨记一条 audit_log.
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-material") @PostMapping("/{meetingId}/submit-material")
public AjaxResult submitMaterial(@PathVariable("meetingId") Long meetingId) { public AjaxResult submitMaterial(@PathVariable("meetingId") Long meetingId, @RequestBody SubmitBody body) {
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
@@ -254,33 +258,52 @@ public class BizMeetingController extends BaseController {
} }
if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料"); if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料");
if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料"); if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料");
String stage = m.getMaterialAuditStage();
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) { List<String> types = body == null ? null : body.getTypes();
throw new ServiceException("当前阶段 (" + stage + ") 不允许提交材料"); if (types == null || types.isEmpty()) throw new ServiceException("请选择要提交材料类型");
for (String type : types) {
if (!"LABOR".equals(type) && !"SERVICE".equals(type)) throw new ServiceException("未知材料类型: " + type);
} }
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId); List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_")); boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_"));
boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_")); boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_"));
if (!hasLabor || !hasService) {
throw new ServiceException("劳务材料和会务材料各至少上传一条"); for (String type : types) {
String label = "LABOR".equals(type) ? "劳务材料" : "会务材料";
String stage = "LABOR".equals(type) ? m.getLaborAuditStage() : m.getServiceAuditStage();
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
throw new ServiceException(label + "当前阶段 (" + stage + ") 不允许提交");
}
boolean has = "LABOR".equals(type) ? hasLabor : hasService;
if (!has) throw new ServiceException(label + "至少上传一条");
} }
m.setMaterialAuditStage("SUBMITTED"); for (String type : types) {
m.setMaterialComplianceApproved(0); if ("LABOR".equals(type)) {
m.setLaborAuditStage("SUBMITTED");
m.setLaborComplianceApproved(0);
} else {
m.setServiceAuditStage("SUBMITTED");
m.setServiceComplianceApproved(0);
}
}
m.setMaterialAuditTime(new Date()); m.setMaterialAuditTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "MATERIAL", "SUBMITTED", "执行人员提交材料"); for (String type : types) {
appendAuditLog(m, "MATERIAL", "SUBMITTED", "执行人员提交" + ("LABOR".equals(type) ? "劳务" : "会务") + "材料", type);
}
return success("SUBMITTED"); return success("SUBMITTED");
} }
/** /**
* 合规审核 (role_type=manager), 材料两级审核中的第一级. * 合规审核 (role_type=manager), 材料两级审核中的第一级 (分轨).
* <p> * <p>
* body: { "approved": true|false, "opinion": "..." } * body: { "items": [{ "type": "LABOR|SERVICE", "approved": true|false }], "opinion": "..." }
* <p>合规审中判据: material_audit_stage=SUBMITTED 且 compliance_approved=0. * <p>逐轨审核: 该轨 C0 (SUBMITTED 且 compliance_approved=0) 才可审;
* 通过 → compliance_approved=1 (转支持方审), 拒绝 → REJECTED (退回执行方). * 通过 → compliance_approved=1 (转支持方审), 拒绝 → REJECTED (退回执行方).
* 一轨给通过、另一轨给拒绝 在同一 body 里各自表达.
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-compliance") @PostMapping("/{meetingId}/audit-compliance")
@@ -290,17 +313,25 @@ public class BizMeetingController extends BaseController {
} }
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
boolean approved = Boolean.TRUE.equals(body.getApproved()); List<AuditItem> items = body == null ? null : body.getItems();
if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) { if (items == null || items.isEmpty()) throw new ServiceException("请选择要审核的材料类型");
for (AuditItem it : items) {
if (!"LABOR".equals(it.getType()) && !"SERVICE".equals(it.getType())) throw new ServiceException("未知材料类型: " + it.getType());
if (!Boolean.TRUE.equals(it.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空"); throw new ServiceException("拒绝时意见不能为空");
} }
return success(doComplianceAudit(m, approved, body.getOpinion())); }
for (AuditItem it : items) {
doComplianceAuditTrack(m, it.getType(), Boolean.TRUE.equals(it.getApproved()), body.getOpinion());
}
return success("OK");
} }
/** /**
* 批量合规审核 (role_type=manager): 一次对多个会议执行材料一级审核. * 批量合规审核 (role_type=manager): 一次对多个会议执行材料一级审核.
* <p> * <p>
* body: { "meetingIds": [..], "approved": true|false, "opinion": "..." } * body: { "meetingIds": [..], "approved": true|false, "opinion": "..." }
* <p>分轨简化: 一个结果应用到每个会议所有处于合规审中 (C0) 的轨 (劳务/会务), 不做逐轨不同结果.
* <p>best-effort: 逐条审核, 状态不符的会议跳过并回执失败原因, 不整批回滚. * <p>best-effort: 逐条审核, 状态不符的会议跳过并回执失败原因, 不整批回滚.
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@@ -325,7 +356,7 @@ public class BizMeetingController extends BaseController {
try { try {
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
doComplianceAudit(m, approved, body.getOpinion()); doComplianceAuditAllC0(m, approved, body.getOpinion());
successIds.add(meetingId); successIds.add(meetingId);
} catch (ServiceException e) { } catch (ServiceException e) {
Map<String, Object> f = new HashMap<>(); Map<String, Object> f = new HashMap<>();
@@ -343,35 +374,53 @@ public class BizMeetingController extends BaseController {
return success(data); return success(data);
} }
/** 某轨是否处于合规审中 (C0): SUBMITTED 且 compliance_approved != 1. */
private static boolean isCompliancePending(String stage, Integer compliance) {
return "SUBMITTED".equals(stage) && (compliance == null || compliance != 1);
}
/** /**
* 内部: 合规审核核心流转 (阶段校验 + 状态写入 + audit_log). 单条/批量共用. * 内部: 合规审核单轨核心流转 (阶段校验 + 状态写入 + audit_log). 单条/批量共用.
*/ */
private String doComplianceAudit(BizMeeting m, boolean approved, String opinion) { private void doComplianceAuditTrack(BizMeeting m, String type, boolean approved, String opinion) {
Integer compliance = m.getMaterialComplianceApproved(); boolean labor = "LABOR".equals(type);
if (!"SUBMITTED".equals(m.getMaterialAuditStage()) || (compliance != null && compliance == 1)) { String stage = labor ? m.getLaborAuditStage() : m.getServiceAuditStage();
throw new ServiceException("当前阶段不允许合规审核"); Integer compliance = labor ? m.getLaborComplianceApproved() : m.getServiceComplianceApproved();
if (!isCompliancePending(stage, compliance)) {
throw new ServiceException((labor ? "劳务材料" : "会务材料") + "当前阶段不允许合规审核");
} }
String result = approved ? "APPROVED" : "REJECTED";
if (approved) { if (approved) {
m.setMaterialComplianceApproved(1); if (labor) m.setLaborComplianceApproved(1); else m.setServiceComplianceApproved(1);
} else { } else {
m.setMaterialAuditStage("REJECTED"); if (labor) m.setLaborAuditStage("REJECTED"); else m.setServiceAuditStage("REJECTED");
// 退回 → 提交截止时间重新计算 (now + 项目天数) // 退回 → 提交截止时间重新计算 (now + 项目天数)
m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date())); m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date()));
} }
m.setMaterialAuditTime(new Date()); m.setMaterialAuditTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "MATERIAL", result, opinion); appendAuditLog(m, "MATERIAL", approved ? "APPROVED" : "REJECTED", opinion, type);
return result;
} }
/** /**
* 支持方(监察员) 审核, 材料两级审核中的第二级. * 内部: 批量合规审核 — 一个结果应用到该会议所有处于合规审中 (C0) 的轨 (简化, 不做逐轨不同结果).
*/
private void doComplianceAuditAllC0(BizMeeting m, boolean approved, String opinion) {
List<String> targets = new ArrayList<>();
if (isCompliancePending(m.getLaborAuditStage(), m.getLaborComplianceApproved())) targets.add("LABOR");
if (isCompliancePending(m.getServiceAuditStage(), m.getServiceComplianceApproved())) targets.add("SERVICE");
if (targets.isEmpty()) throw new ServiceException("当前阶段不允许合规审核");
for (String type : targets) {
doComplianceAuditTrack(m, type, approved, opinion);
}
}
/**
* 支持方(监察员) 审核, 材料两级审核中的第二级 (分轨).
* <p> * <p>
* body: { "approved": true|false, "opinion": "..." } * body: { "items": [{ "type": "LABOR|SERVICE", "approved": true|false }], "opinion": "..." }
* <p>支持方审中判据: material_audit_stage=SUBMITTED 且 compliance_approved=1. * <p>逐轨审核: 该轨 C1 (SUBMITTED 且 compliance_approved=1) 才可审;
* 通过 → APPROVED (一并写监管意见), 拒绝 → REJECTED (退回执行方). * 通过 → APPROVED (任一轨通过即写监管意见), 拒绝 → REJECTED (退回执行方 + 通知).
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-supervision") @PostMapping("/{meetingId}/audit-supervision")
@@ -382,27 +431,57 @@ public class BizMeetingController extends BaseController {
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
// 授权: 监察员 (biz_meeting_supervisor.sponsor_org_id) 或 支持方企业 (biz_project.sponsor_org_id) 均可审 // 授权 (与列表可见性同源, 原则: 能看到就能监察):
// 1) 项目级监察员 (biz_project_sponsor_assign.monitor_user_id = 当前 user_id) — SUB 监察员列表同源
// 2) 会议级监察员机构 (biz_meeting_supervisor.sponsor_org_id = 我的 org_id)
// 3) 支持方主账号 (biz_project.sponsor_org_id = 我的 org_id)
BizProject project = m.getProjectId() == null ? null : bizProjectService.getById(m.getProjectId());
boolean isProjectMonitor = m.getProjectId() != null && userId != null
&& bizProjectSponsorAssignService.listByProjectId(String.valueOf(m.getProjectId())).stream()
.anyMatch(a -> userId.equals(a.getMonitorUserId()));
boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream() boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream()
.anyMatch(s -> myOrgId != null && myOrgId.equals(s.getSponsorOrgId())); .anyMatch(s -> myOrgId != null && myOrgId.equals(s.getSponsorOrgId()));
BizProject project = m.getProjectId() == null ? null : bizProjectService.getById(m.getProjectId());
boolean isSponsorMain = project != null && myOrgId != null && myOrgId.equals(project.getSponsorOrgId()); boolean isSponsorMain = project != null && myOrgId != null && myOrgId.equals(project.getSponsorOrgId());
if (!isSupervisor && !isSponsorMain) { if (!isProjectMonitor && !isSupervisor && !isSponsorMain) {
throw new ServiceException("您不是该会议监察员, 无权监察"); throw new ServiceException("您不是该会议监察员, 无权监察");
} }
boolean approved = Boolean.TRUE.equals(body.getApproved()); List<AuditItem> items = body == null ? null : body.getItems();
if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) { if (items == null || items.isEmpty()) throw new ServiceException("请选择要审核的材料类型");
boolean anyRejected = false;
for (AuditItem it : items) {
if (!"LABOR".equals(it.getType()) && !"SERVICE".equals(it.getType())) throw new ServiceException("未知材料类型: " + it.getType());
if (!Boolean.TRUE.equals(it.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空"); throw new ServiceException("拒绝时意见不能为空");
} }
}
Integer compliance = m.getMaterialComplianceApproved(); for (AuditItem it : items) {
if (!"SUBMITTED".equals(m.getMaterialAuditStage()) || compliance == null || compliance != 1) { boolean approved = Boolean.TRUE.equals(it.getApproved());
throw new ServiceException("当前阶段不允许监察审核"); doSupervisionAuditTrack(m, it.getType(), approved, body.getOpinion());
if (!approved) anyRejected = true;
} }
String result = approved ? "APPROVED" : "REJECTED"; // 退回 → 通知执行方 (待整改 + 说明, 任一轨被拒即通知一次)
m.setMaterialAuditStage(approved ? "APPROVED" : "REJECTED"); if (anyRejected) {
for (BizMeetingExecutor e : bizMeetingExecutorService.selectByMeetingId(meetingId)) {
bizNotifyService.meetingSupervisionRejected(resolveMainUserIdByOrgId(e.getExecutorOrgId()), meetingId, m.getMeetingName(), body.getOpinion());
}
}
return success("OK");
}
/**
* 内部: 支持方审核单轨核心流转 (阶段校验 + 状态写入 + audit_log).
*/
private void doSupervisionAuditTrack(BizMeeting m, String type, boolean approved, String opinion) {
boolean labor = "LABOR".equals(type);
String stage = labor ? m.getLaborAuditStage() : m.getServiceAuditStage();
Integer compliance = labor ? m.getLaborComplianceApproved() : m.getServiceComplianceApproved();
if (!"SUBMITTED".equals(stage) || compliance == null || compliance != 1) {
throw new ServiceException((labor ? "劳务材料" : "会务材料") + "当前阶段不允许监察审核");
}
if (labor) m.setLaborAuditStage(approved ? "APPROVED" : "REJECTED");
else m.setServiceAuditStage(approved ? "APPROVED" : "REJECTED");
// 退回 → 提交截止时间重新计算 (now + 项目天数) // 退回 → 提交截止时间重新计算 (now + 项目天数)
if (!approved) { if (!approved) {
m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date())); m.setSubmitDeadline(computeSubmitDeadline(m.getProjectId(), new Date()));
@@ -410,21 +489,13 @@ public class BizMeetingController extends BaseController {
m.setMaterialAuditTime(new Date()); m.setMaterialAuditTime(new Date());
// 材料通过 → 写监管意见 (支持方的书面意见) // 材料通过 → 写监管意见 (支持方的书面意见)
if (approved) { if (approved) {
m.setSupervisionOpinion(body.getOpinion()); m.setSupervisionOpinion(opinion);
m.setSupervisionBy(SecurityUtils.getUsername()); m.setSupervisionBy(SecurityUtils.getUsername());
m.setSupervisionTime(new Date()); m.setSupervisionTime(new Date());
} }
m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "MATERIAL", result, body.getOpinion()); appendAuditLog(m, "MATERIAL", approved ? "APPROVED" : "REJECTED", opinion, type);
// 退回 → 通知执行方 (待整改 + 说明)
if (!approved) {
for (BizMeetingExecutor e : bizMeetingExecutorService.selectByMeetingId(meetingId)) {
bizNotifyService.meetingSupervisionRejected(resolveMainUserIdByOrgId(e.getExecutorOrgId()), meetingId, m.getMeetingName(), body.getOpinion());
}
}
return success(result);
} }
/** /**
@@ -443,8 +514,8 @@ public class BizMeetingController extends BaseController {
} }
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
if (!"APPROVED".equals(m.getMaterialAuditStage())) { if (!"APPROVED".equals(m.getLaborAuditStage()) || !"APPROVED".equals(m.getServiceAuditStage())) {
throw new ServiceException("材料审核通过后才能结算"); throw new ServiceException("劳务与会务材料审核通过后才能结算");
} }
if (isSettled(m)) throw new ServiceException("会议已结算"); if (isSettled(m)) throw new ServiceException("会议已结算");
// 费用未汇总完 (fee_calc_status=0) 禁止结算: 此时 labor_fee/meeting_fee 可能为旧值/0, 直接回写会污染项目金额. // 费用未汇总完 (fee_calc_status=0) 禁止结算: 此时 labor_fee/meeting_fee 可能为旧值/0, 直接回写会污染项目金额.
@@ -465,7 +536,7 @@ public class BizMeetingController extends BaseController {
m.setSettleTime(new Date()); m.setSettleTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "SETTLE", "APPROVED", "会议结算"); appendAuditLog(m, "SETTLE", "APPROVED", "会议结算", null);
return success("SETTLED"); return success("SETTLED");
} }
@@ -487,7 +558,7 @@ public class BizMeetingController extends BaseController {
m.setFinishTime(new Date()); m.setFinishTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "FINISH", "APPROVED", "会议完结"); appendAuditLog(m, "FINISH", "APPROVED", "会议完结", null);
return success("FINISHED"); return success("FINISHED");
} }
@@ -559,13 +630,14 @@ public class BizMeetingController extends BaseController {
/** /**
* 内部: 写一条 audit_log (4 列角色展示状态由 post-transition 事实推导). * 内部: 写一条 audit_log (4 列角色展示状态由 post-transition 事实推导).
*/ */
private void appendAuditLog(BizMeeting m, String auditType, String result, String opinion) { private void appendAuditLog(BizMeeting m, String auditType, String result, String opinion, String materialType) {
BizMeetingAuditLog log = new BizMeetingAuditLog(); BizMeetingAuditLog log = new BizMeetingAuditLog();
log.setMeetingId(m.getMeetingId()); log.setMeetingId(m.getMeetingId());
log.setAuditor(SecurityUtils.getUsername()); log.setAuditor(SecurityUtils.getUsername());
log.setAuditType(auditType); log.setAuditType(auditType);
log.setAuditResult(result); log.setAuditResult(result);
log.setOpinion(opinion); log.setOpinion(opinion);
log.setMaterialType(materialType);
log.setCreateTime(new Date()); log.setCreateTime(new Date());
log.setAuditTime(new Date()); log.setAuditTime(new Date());
log.setExecutorStage(stageDeriver.deriveDisplay("executor", m)); log.setExecutorStage(stageDeriver.deriveDisplay("executor", m));
@@ -575,16 +647,33 @@ public class BizMeetingController extends BaseController {
bizMeetingAuditLogService.insert(log); bizMeetingAuditLogService.insert(log);
} }
/** request body for audit endpoints */ /** request body for audit endpoints (逐轨: 每项一个材料类型 + 通过/拒绝) */
public static class AuditBody { public static class AuditBody {
private Boolean approved; // true=通过 false=拒绝 private List<AuditItem> items; // 逐轨审核项
private String opinion; // 意见 private String opinion; // 意见 (拒绝时必填)
public Boolean getApproved() { return approved; } public List<AuditItem> getItems() { return items; }
public void setApproved(Boolean approved) { this.approved = approved; } public void setItems(List<AuditItem> items) { this.items = items; }
public String getOpinion() { return opinion; } public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; } public void setOpinion(String opinion) { this.opinion = opinion; }
} }
/** 单轨审核项 */
public static class AuditItem {
private String type; // LABOR / SERVICE
private Boolean approved; // true=通过 false=拒绝
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public Boolean getApproved() { return approved; }
public void setApproved(Boolean approved) { this.approved = approved; }
}
/** request body for submit-material (分轨多选) */
public static class SubmitBody {
private List<String> types; // ["LABOR","SERVICE"]
public List<String> getTypes() { return types; }
public void setTypes(List<String> types) { this.types = types; }
}
/** request body for batch audit endpoint */ /** request body for batch audit endpoint */
public static class BatchAuditBody { public static class BatchAuditBody {
private List<Long> meetingIds; private List<Long> meetingIds;
@@ -72,8 +72,10 @@ public class BizMeeting extends BaseEntity {
/** 监察时间 (与 DB datetime 对齐) */ /** 监察时间 (与 DB datetime 对齐) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date supervisionTime; private Date supervisionTime;
/** 材料审核阶段 (NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED, 见 MeetingAuditStageEnum) */ /** 劳务材料审核阶段 (NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED, 见 MeetingAuditStageEnum) */
private String materialAuditStage; private String laborAuditStage;
/** 会务材料审核阶段 (NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED, 见 MeetingAuditStageEnum) */
private String serviceAuditStage;
/** 是否执行 0否1是 (会议开始时间到, scheduler 置1) */ /** 是否执行 0否1是 (会议开始时间到, scheduler 置1) */
private Integer isExecuted; private Integer isExecuted;
/** 执行时间 */ /** 执行时间 */
@@ -97,8 +99,10 @@ public class BizMeeting extends BaseEntity {
/** 材料最近一次审核动作时间 */ /** 材料最近一次审核动作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date materialAuditTime; private Date materialAuditTime;
/** 材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */ /** 劳务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
private Integer materialComplianceApproved; private Integer laborComplianceApproved;
/** 会务材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
private Integer serviceComplianceApproved;
/** 提交截止时间 (建会 = end_time + 项目 submit_deadline_days 天; 退回/解冻 = now + 天数; null = 项目未设天数, 永不冻结) */ /** 提交截止时间 (建会 = end_time + 项目 submit_deadline_days 天; 退回/解冻 = now + 天数; null = 项目未设天数, 永不冻结) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submitDeadline; private Date submitDeadline;
@@ -189,8 +193,10 @@ public class BizMeeting extends BaseEntity {
public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; } public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; }
public String getLaborSigned() { return laborSigned; } public String getLaborSigned() { return laborSigned; }
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; } public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public String getMaterialAuditStage() { return materialAuditStage; } public String getLaborAuditStage() { return laborAuditStage; }
public void setMaterialAuditStage(String materialAuditStage) { this.materialAuditStage = materialAuditStage; } public void setLaborAuditStage(String laborAuditStage) { this.laborAuditStage = laborAuditStage; }
public String getServiceAuditStage() { return serviceAuditStage; }
public void setServiceAuditStage(String serviceAuditStage) { this.serviceAuditStage = serviceAuditStage; }
public Integer getIsExecuted() { return isExecuted; } public Integer getIsExecuted() { return isExecuted; }
public void setIsExecuted(Integer isExecuted) { this.isExecuted = isExecuted; } public void setIsExecuted(Integer isExecuted) { this.isExecuted = isExecuted; }
public Date getExecuteTime() { return executeTime; } public Date getExecuteTime() { return executeTime; }
@@ -209,8 +215,10 @@ public class BizMeeting extends BaseEntity {
public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; } public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; }
public Date getMaterialAuditTime() { return materialAuditTime; } public Date getMaterialAuditTime() { return materialAuditTime; }
public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; } public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; }
public Integer getMaterialComplianceApproved() { return materialComplianceApproved; } public Integer getLaborComplianceApproved() { return laborComplianceApproved; }
public void setMaterialComplianceApproved(Integer materialComplianceApproved) { this.materialComplianceApproved = materialComplianceApproved; } public void setLaborComplianceApproved(Integer laborComplianceApproved) { this.laborComplianceApproved = laborComplianceApproved; }
public Integer getServiceComplianceApproved() { return serviceComplianceApproved; }
public void setServiceComplianceApproved(Integer serviceComplianceApproved) { this.serviceComplianceApproved = serviceComplianceApproved; }
public Date getSubmitDeadline() { return submitDeadline; } public Date getSubmitDeadline() { return submitDeadline; }
public void setSubmitDeadline(Date submitDeadline) { this.submitDeadline = submitDeadline; } public void setSubmitDeadline(Date submitDeadline) { this.submitDeadline = submitDeadline; }
public Long getUserId() { return userId; } public Long getUserId() { return userId; }
@@ -7,7 +7,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
* 会议审核流程日志对象 biz_meeting_audit_log * 会议审核流程日志对象 biz_meeting_audit_log
* <p> * <p>
* 记录会议审核的每一次流转: 谁、什么时间、什么意见、当前阶段. * 记录会议审核的每一次流转: 谁、什么时间、什么意见、当前阶段.
* 与 biz_meeting.material_audit_stage 配合, 还原审核轨迹. * 与 biz_meeting 劳务/会务两轨 audit_stage 配合, 还原审核轨迹.
*/ */
public class BizMeetingAuditLog { public class BizMeetingAuditLog {
@@ -51,6 +51,9 @@ public class BizMeetingAuditLog {
/** 审核结果 (APPROVED / REJECTED) */ /** 审核结果 (APPROVED / REJECTED) */
private String auditResult; private String auditResult;
/** 材料类型 (LABOR / SERVICE; NULL = 会议级操作如结算/完结, 或历史数据) */
private String materialType;
/** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */ /** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted; private Integer isDeleted;
@@ -90,6 +93,9 @@ public class BizMeetingAuditLog {
public String getAuditResult() { return auditResult; } public String getAuditResult() { return auditResult; }
public void setAuditResult(String auditResult) { this.auditResult = auditResult; } public void setAuditResult(String auditResult) { this.auditResult = auditResult; }
public String getMaterialType() { return materialType; }
public void setMaterialType(String materialType) { this.materialType = materialType; }
public Integer getIsDeleted() { return isDeleted; } public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
} }
@@ -10,12 +10,11 @@ import lombok.extern.slf4j.Slf4j;
* 会议事实/阶段 自动流转调度器 (每分钟一次). * 会议事实/阶段 自动流转调度器 (每分钟一次).
* <p> * <p>
* 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实 * 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实
* (is_executed / is_frozen / material_audit_stage / 审核时间 …), * (is_executed / is_frozen / 劳务·会务两轨 audit_stage / 审核时间 …),
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的类事实落地: * 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的类事实落地:
* <pre> * <pre>
* 1) start_time 到 → is_executed=1 (执行中) * 1) start_time 到 → is_executed=1 (执行中)
* 2) end_time + submit_deadline_days 到 且 material 未提交 → is_frozen=1 (冻结) * 2) submit_deadline 到 且 任一轨未提交/已退回 → is_frozen=1 (冻结)
* 3) material 通过 且 审核时间过 24h → 待结算 (current_stage 缓存翻 AWAITING_SETTLEMENT)
* </pre> * </pre>
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围. * 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围.
* <p> * <p>
@@ -29,7 +28,7 @@ public class MeetingStageScheduler
private BizMeetingMapper meetingMapper; private BizMeetingMapper meetingMapper;
/** /**
* 每分钟: start_time 已过 且 material 未提交 且未执行 → 置执行中. * 每分钟: start_time 已过 且 劳务·会务两轨均未提交 且未执行 → 置执行中.
*/ */
@Scheduled(fixedRate = 60_000, initialDelay = 30_000) @Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markExecuted() public void markExecuted()
@@ -49,7 +48,7 @@ public class MeetingStageScheduler
} }
/** /**
* 每分钟: material 未提交 且 提交截止时间 (end_time + submit_deadline_days) 已过 → 冻结. * 每分钟: 任一轨未提交/已退回 且 提交截止时间已过 → 冻结 (会议级).
*/ */
@Scheduled(fixedRate = 60_000, initialDelay = 30_000) @Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markFrozen() public void markFrozen()
@@ -7,11 +7,14 @@ import com.ruoyi.business.domain.BizMeeting;
* 会议阶段推导器 (单一可信源). * 会议阶段推导器 (单一可信源).
* <p> * <p>
* 事实与展示分离: {@code biz_meeting} 只存事实 (is_executed/is_settled/is_finished/is_frozen * 事实与展示分离: {@code biz_meeting} 只存事实 (is_executed/is_settled/is_finished/is_frozen
* + material_audit_stage + 审核时间 + compliance_approved), 各角色看到的 * + 劳务/会务两轨各自 audit_stage + compliance_approved + 审核时间), 各角色看到的
* 「阶段名称」由本类实时计算. * 「阶段名称」由本类实时计算.
* <p>
* 劳务/会务材料分轨后, 每条轨道独立走 N/C0/C1/A/R 状态机, 会议级阶段由两轨汇合:
* <ul> * <ul>
* <li>{@link #derivePhysicalStage(BizMeeting)}: 10 值物理阶段 (current_stage 缓存 + 列表筛选).</li> * <li>{@link #derivePhysicalStage(BizMeeting)}: 10 值物理阶段 = 两轨最小进度 (current_stage 缓存 + 列表筛选).</li>
* <li>{@link #deriveDisplay(String, BizMeeting)}: 各角色展示名 (audit_log 4 列 + 前端镜像).</li> * <li>{@link #deriveDisplay(String, BizMeeting)}: 各角色展示名 (audit_log 4 列 + 前端镜像),
* 按角色优先级选代表轨, 再用单轨措辞渲染.</li>
* </ul> * </ul>
* *
* @author guoju * @author guoju
@@ -24,10 +27,29 @@ public class StageDeriver
return v != null && v == 1; return v != null && v == 1;
} }
/** 单轨状态码: R=0, N=1, C0=2, C1=3, A=4 (数值越小 = 流程越靠前). */
private static int stateOf(String stage, Integer complianceApproved)
{
if ("REJECTED".equals(stage)) return 0;
if ("SUBMITTED".equals(stage)) return t(complianceApproved) ? 3 : 2;
if ("APPROVED".equals(stage)) return 4;
return 1; // NOT_SUBMITTED (或 null 兜底)
}
private static int laborState(BizMeeting m)
{
return stateOf(m.getLaborAuditStage(), m.getLaborComplianceApproved());
}
private static int serviceState(BizMeeting m)
{
return stateOf(m.getServiceAuditStage(), m.getServiceComplianceApproved());
}
/** /**
* 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/ * 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/
* SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导. * SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导.
* 用于 current_stage 缓存 (列表筛选按物理态精确匹配). * 两轨取最小进度 (流程最靠前的一轨决定会议物理态), 用于 current_stage 缓存 (列表筛选按物理态精确匹配).
*/ */
public String derivePhysicalStage(BizMeeting m) public String derivePhysicalStage(BizMeeting m)
{ {
@@ -35,26 +57,25 @@ public class StageDeriver
if (t(m.getIsFinished())) return "FINISHED"; if (t(m.getIsFinished())) return "FINISHED";
if (t(m.getIsSettled())) return "SETTLED"; if (t(m.getIsSettled())) return "SETTLED";
String material = m.getMaterialAuditStage(); int s = Math.min(laborState(m), serviceState(m));
if ("REJECTED".equals(material)) return "RECTIFYING"; switch (s)
if ("APPROVED".equals(material))
{ {
return "AWAITING_SETTLEMENT"; case 0: return "RECTIFYING";
case 2: return "AWAITING_COMPLIANCE";
case 3: return "AWAITING_SUPERVISION";
case 4: return "AWAITING_SETTLEMENT";
default: return t(m.getIsExecuted()) ? "RUNNING" : "NOT_STARTED"; // s = 1 (N)
} }
if ("SUBMITTED".equals(material))
{
return t(m.getMaterialComplianceApproved()) ? "AWAITING_SUPERVISION" : "AWAITING_COMPLIANCE";
}
// NOT_SUBMITTED (或 null 兜底)
return t(m.getIsExecuted()) ? "RUNNING" : "NOT_STARTED";
} }
/** /**
* 各角色展示阶段名. role ∈ {executor, sponsor, manager, admin}; doctor/expert 等非流程角色按 admin 中性. * 各角色展示阶段名. role ∈ {executor, sponsor, manager, admin}; doctor/expert 等非流程角色按 admin 中性.
* <p> * <p>
* 优先级自上而下命中即返回: * 固定顶格: 冻结中 → 已完结 → 已结算. 否则按角色优先级选「代表轨」再渲染单轨措辞:
* <pre> * <pre>
* 冻结 → 完结 → 已结算 → (材料) 审核驳回 → 审核通过 → (材料) APPROVED → SUBMITTED → NOT_SUBMITTED * 默认 (executor/admin/doctor): 最小进度 (R &gt; N &gt; C0 &gt; C1 &gt; A)
* manager: 优先 C0(待合规审核) &gt; R &gt; N &gt; C1 &gt; A → 有活「待审核」, 无活按最早环节优先
* sponsor: 优先 C1(待支持方审核) &gt; R &gt; N &gt; C0 &gt; A → 有活「待审核」, 无活按最早环节优先
* </pre> * </pre>
*/ */
public String deriveDisplay(String role, BizMeeting m) public String deriveDisplay(String role, BizMeeting m)
@@ -63,44 +84,69 @@ public class StageDeriver
if (t(m.getIsFinished())) return "已完结"; if (t(m.getIsFinished())) return "已完结";
if (t(m.getIsSettled())) return "已结算"; if (t(m.getIsSettled())) return "已结算";
String material = m.getMaterialAuditStage(); int chosen = chooseState(role, laborState(m), serviceState(m));
return render(role, chosen, t(m.getIsExecuted()));
// 退回: 执行方看「已退回」, 其他方看「待整改」
if ("REJECTED".equals(material))
{
return "executor".equals(role) ? "已退回" : "待整改";
} }
// 材料已支持方通过 → 待结算 (支持方审通过即待结算, 不再有 24h 慢路径) /** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */
if ("APPROVED".equals(material)) private static int chooseState(String role, int labor, int service)
{ {
if ("manager".equals(role))
{
return managerPriority(labor) <= managerPriority(service) ? labor : service;
}
if ("sponsor".equals(role))
{
return sponsorPriority(labor) <= sponsorPriority(service) ? labor : service;
}
// 默认: 最小进度 (状态码本身即 R<N<C0<C1<A 顺序)
return Math.min(labor, service);
}
/** manager 优先级: C0=0, R=1, N=2, C1=3, A=4 (越小越优先). C0=有活最优先; 无活时按最早环节优先(R<N<C1<A). */
private static int managerPriority(int s)
{
switch (s)
{
case 2: return 0; // C0 待合规审核 (有活)
case 0: return 1; // R 待整改 (无活时最早)
case 1: return 2; // N 未提交
case 3: return 3; // C1 待支持方审核
default: return 4; // A 已通过
}
}
/** sponsor 优先级: C1=0, R=1, N=2, C0=3, A=4 (越小越优先). C1=有活最优先; 无活时按最早环节优先(R<N<C0<A). */
private static int sponsorPriority(int s)
{
switch (s)
{
case 3: return 0; // C1 待支持方审核 (有活)
case 0: return 1; // R 待整改 (无活时最早)
case 1: return 2; // N 未提交
case 2: return 3; // C0 待合规审核
default: return 4; // A 已通过
}
}
/** 单轨措辞 (代表轨状态 + 角色 → 展示名). */
private static String render(String role, int s, boolean executed)
{
switch (s)
{
case 0: // R 退回
return "executor".equals(role) ? "已退回" : "待整改";
case 1: // N 未提交
if (!executed) return "未执行";
return "executor".equals(role) ? "执行中" : "已执行未传材料";
case 2: // C0 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
return "待审核"; // executor / manager / admin
case 3: // C1 支持方审中
if ("manager".equals(role)) return "审核通过";
return "待审核"; // executor / sponsor / admin
default: // 4 = A 已通过
return "待结算"; return "待结算";
} }
// 材料在审 (SUBMITTED)
if ("SUBMITTED".equals(material))
{
if (t(m.getMaterialComplianceApproved()))
{
// 支持方审中
if ("manager".equals(role)) return "审核通过";
if ("sponsor".equals(role)) return "待审核";
return "待审核"; // executor / admin
}
else
{
// 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
if ("manager".equals(role)) return "待审核";
return "待审核"; // executor / admin
}
}
// 未提交
if (t(m.getIsExecuted()))
{
return "executor".equals(role) ? "执行中" : "已执行未传材料";
}
return "未执行";
} }
} }
@@ -61,8 +61,11 @@ public class BizMeetingServiceImpl implements IBizMeetingService
if (entity.getCurrentStage() == null || entity.getCurrentStage().isEmpty()) { if (entity.getCurrentStage() == null || entity.getCurrentStage().isEmpty()) {
entity.setCurrentStage(BizMeetingStageEnum.NOT_STARTED.getCode()); entity.setCurrentStage(BizMeetingStageEnum.NOT_STARTED.getCode());
} }
if (entity.getMaterialAuditStage() == null || entity.getMaterialAuditStage().isEmpty()) { if (entity.getLaborAuditStage() == null || entity.getLaborAuditStage().isEmpty()) {
entity.setMaterialAuditStage("NOT_SUBMITTED"); entity.setLaborAuditStage("NOT_SUBMITTED");
}
if (entity.getServiceAuditStage() == null || entity.getServiceAuditStage().isEmpty()) {
entity.setServiceAuditStage("NOT_SUBMITTED");
} }
return bizMeetingMapper.insert(entity); return bizMeetingMapper.insert(entity);
} }
@@ -15,11 +15,12 @@
<result property="auditTime" column="audit_time" /> <result property="auditTime" column="audit_time" />
<result property="auditType" column="audit_type" /> <result property="auditType" column="audit_type" />
<result property="auditResult" column="audit_result" /> <result property="auditResult" column="audit_result" />
<result property="materialType" column="material_type" />
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select id, meeting_id, auditor, opinion, executor_stage, sponsor_stage, manager_stage, admin_stage, create_time, audit_time, audit_type, audit_result, is_deleted select id, meeting_id, auditor, opinion, executor_stage, sponsor_stage, manager_stage, admin_stage, create_time, audit_time, audit_type, audit_result, material_type, is_deleted
from biz_meeting_audit_log from biz_meeting_audit_log
</sql> </sql>
@@ -52,6 +53,7 @@
<if test="auditTime != null">audit_time,</if> <if test="auditTime != null">audit_time,</if>
<if test="auditType != null and auditType != ''">audit_type,</if> <if test="auditType != null and auditType != ''">audit_type,</if>
<if test="auditResult != null and auditResult != ''">audit_result,</if> <if test="auditResult != null and auditResult != ''">audit_result,</if>
<if test="materialType != null and materialType != ''">material_type,</if>
</trim> </trim>
<trim prefix="values (" suffix=")" suffixOverrides=","> <trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if> <if test="meetingId != null">#{meetingId},</if>
@@ -65,6 +67,7 @@
<if test="auditTime != null">#{auditTime},</if> <if test="auditTime != null">#{auditTime},</if>
<if test="auditType != null and auditType != ''">#{auditType},</if> <if test="auditType != null and auditType != ''">#{auditType},</if>
<if test="auditResult != null and auditResult != ''">#{auditResult},</if> <if test="auditResult != null and auditResult != ''">#{auditResult},</if>
<if test="materialType != null and materialType != ''">#{materialType},</if>
</trim> </trim>
</insert> </insert>
@@ -21,7 +21,8 @@
<result property="supervisionOpinion" column="supervision_opinion" /> <result property="supervisionOpinion" column="supervision_opinion" />
<result property="supervisionBy" column="supervision_by" /> <result property="supervisionBy" column="supervision_by" />
<result property="supervisionTime" column="supervision_time" /> <result property="supervisionTime" column="supervision_time" />
<result property="materialAuditStage" column="material_audit_stage" /> <result property="laborAuditStage" column="labor_audit_stage" />
<result property="serviceAuditStage" column="service_audit_stage" />
<result property="isExecuted" column="is_executed" /> <result property="isExecuted" column="is_executed" />
<result property="executeTime" column="execute_time" /> <result property="executeTime" column="execute_time" />
<result property="isSettled" column="is_settled" /> <result property="isSettled" column="is_settled" />
@@ -31,7 +32,8 @@
<result property="isFrozen" column="is_frozen" /> <result property="isFrozen" column="is_frozen" />
<result property="freezeTime" column="freeze_time" /> <result property="freezeTime" column="freeze_time" />
<result property="materialAuditTime" column="material_audit_time" /> <result property="materialAuditTime" column="material_audit_time" />
<result property="materialComplianceApproved" column="material_compliance_approved" /> <result property="laborComplianceApproved" column="labor_compliance_approved" />
<result property="serviceComplianceApproved" column="service_compliance_approved" />
<result property="submitDeadline" column="submit_deadline" /> <result property="submitDeadline" column="submit_deadline" />
<result property="invitationUrl" column="invitation_url" /> <result property="invitationUrl" column="invitation_url" />
<result property="projectInvitationUrl" column="project_invitation_url" /> <result property="projectInvitationUrl" column="project_invitation_url" />
@@ -51,7 +53,7 @@
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
meeting_id, business_id, project_id, execution_unit_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, address, remark, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, material_compliance_approved, submit_deadline, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted meeting_id, business_id, project_id, execution_unit_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, address, remark, current_stage, supervision_opinion, supervision_by, supervision_time, labor_audit_stage, service_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, labor_compliance_approved, service_compliance_approved, submit_deadline, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted
</sql> </sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long"> <select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
select select
@@ -93,16 +95,13 @@
and p2.is_finished = '1' and p2.open_status = 'N' and p2.is_finished = '1' and p2.open_status = 'N'
) )
</if> </if>
<!-- executor 数据权限: 只看"我的项目"下的会议. MAIN 走 biz_project_assign (execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id --> <!-- executor 数据权限: MAIN 看本执行单位项目下的会议 (biz_project_assign.execution_unit_id), SUB(执行人) 只看本人创建的会议 (create_by) -->
<if test="params.executorUserId != null">and project_id in ( <if test="params.executorUserId != null">and project_id in (
select distinct a.project_id from biz_project_assign a select distinct a.project_id from biz_project_assign a
where a.is_deleted = 0 where a.is_deleted = 0
and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor') and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')
)</if> )</if>
<if test="params.executorStaffUserId != null">and project_id in ( <if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if>
select distinct a.project_id from biz_project_executor_assign a
where a.is_deleted = 0 and a.staff_user_id = #{params.executorStaffUserId}
)</if>
<!-- 合规人员(manager) 数据权限: 只看本人创建项目的会议 --> <!-- 合规人员(manager) 数据权限: 只看本人创建项目的会议 -->
<if test="params.managerCreateUserId != null">and project_id in ( <if test="params.managerCreateUserId != null">and project_id in (
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0 select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
@@ -131,7 +130,8 @@
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if> <if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if>
<if test="supervisionTime != null">supervision_time,</if> <if test="supervisionTime != null">supervision_time,</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage,</if> <if test="laborAuditStage != null and laborAuditStage != ''">labor_audit_stage,</if>
<if test="serviceAuditStage != null and serviceAuditStage != ''">service_audit_stage,</if>
<if test="submitDeadline != null">submit_deadline,</if> <if test="submitDeadline != null">submit_deadline,</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if> <if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if> <if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
@@ -161,7 +161,8 @@
<if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if> <if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if>
<if test="supervisionTime != null">#{supervisionTime},</if> <if test="supervisionTime != null">#{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">#{materialAuditStage},</if> <if test="laborAuditStage != null and laborAuditStage != ''">#{laborAuditStage},</if>
<if test="serviceAuditStage != null and serviceAuditStage != ''">#{serviceAuditStage},</if>
<if test="submitDeadline != null">#{submitDeadline},</if> <if test="submitDeadline != null">#{submitDeadline},</if>
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if> <if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if> <if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
@@ -194,7 +195,8 @@
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if> <if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
<if test="supervisionTime != null">supervision_time = #{supervisionTime},</if> <if test="supervisionTime != null">supervision_time = #{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if> <if test="laborAuditStage != null and laborAuditStage != ''">labor_audit_stage = #{laborAuditStage},</if>
<if test="serviceAuditStage != null and serviceAuditStage != ''">service_audit_stage = #{serviceAuditStage},</if>
<if test="isExecuted != null">is_executed = #{isExecuted},</if> <if test="isExecuted != null">is_executed = #{isExecuted},</if>
<if test="executeTime != null">execute_time = #{executeTime},</if> <if test="executeTime != null">execute_time = #{executeTime},</if>
<if test="isSettled != null">is_settled = #{isSettled},</if> <if test="isSettled != null">is_settled = #{isSettled},</if>
@@ -204,7 +206,8 @@
<if test="isFrozen != null">is_frozen = #{isFrozen},</if> <if test="isFrozen != null">is_frozen = #{isFrozen},</if>
<if test="freezeTime != null">freeze_time = #{freezeTime},</if> <if test="freezeTime != null">freeze_time = #{freezeTime},</if>
<if test="materialAuditTime != null">material_audit_time = #{materialAuditTime},</if> <if test="materialAuditTime != null">material_audit_time = #{materialAuditTime},</if>
<if test="materialComplianceApproved != null">material_compliance_approved = #{materialComplianceApproved},</if> <if test="laborComplianceApproved != null">labor_compliance_approved = #{laborComplianceApproved},</if>
<if test="serviceComplianceApproved != null">service_compliance_approved = #{serviceComplianceApproved},</if>
<if test="submitDeadline != null">submit_deadline = #{submitDeadline},</if> <if test="submitDeadline != null">submit_deadline = #{submitDeadline},</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if> <if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if> <if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
@@ -258,7 +261,8 @@
and is_frozen = 0 and is_frozen = 0
and start_time is not null and start_time is not null
and start_time &lt;= NOW() and start_time &lt;= NOW()
and material_audit_stage = 'NOT_SUBMITTED' and labor_audit_stage = 'NOT_SUBMITTED'
and service_audit_stage = 'NOT_SUBMITTED'
</update> </update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material 未提交/已退回 且 submit_deadline 已过 → 冻结. <!-- 自动流转 (MeetingStageScheduler 每分钟调): material 未提交/已退回 且 submit_deadline 已过 → 冻结.
submit_deadline 由建会/退回/解冻 时写入 (end_time 或 now + 项目 submit_deadline_days 天), null = 项目未设天数 → 永不冻结. --> submit_deadline 由建会/退回/解冻 时写入 (end_time 或 now + 项目 submit_deadline_days 天), null = 项目未设天数 → 永不冻结. -->
@@ -269,7 +273,8 @@
current_stage = 'FROZEN' current_stage = 'FROZEN'
where is_deleted = 0 where is_deleted = 0
and is_frozen = 0 and is_frozen = 0
and material_audit_stage in ('NOT_SUBMITTED', 'REJECTED') and (labor_audit_stage in ('NOT_SUBMITTED', 'REJECTED')
or service_audit_stage in ('NOT_SUBMITTED', 'REJECTED'))
and submit_deadline is not null and submit_deadline is not null
and submit_deadline &lt;= NOW() and submit_deadline &lt;= NOW()
</update> </update>
@@ -111,6 +111,7 @@
<select id="selectSponsorOrgOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap"> <select id="selectSponsorOrgOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap">
select o.org_id as orgId, select o.org_id as orgId,
o.org_name as orgName, o.org_name as orgName,
o.status as status,
u.user_name as userName u.user_name as userName
from biz_org o from biz_org o
join sys_user u on u.user_id = o.user_id join sys_user u on u.user_id = o.user_id
@@ -135,6 +136,7 @@
<select id="selectExecutorOrgOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap"> <select id="selectExecutorOrgOptions" parameterType="BizOrg" resultType="java.util.LinkedHashMap">
select o.org_id as orgId, select o.org_id as orgId,
o.org_name as orgName, o.org_name as orgName,
o.status as status,
u.user_name as userName u.user_name as userName
from biz_org o from biz_org o
join sys_user u on u.user_id = o.user_id join sys_user u on u.user_id = o.user_id
@@ -1,10 +1,11 @@
package com.ruoyi.common.enums; package com.ruoyi.common.enums;
/** /**
* 材料 审核阶段 (biz_meeting.material_audit_stage) * 材料 审核阶段 (单轨 4 值, 用于 biz_meeting.labor_audit_stage / service_audit_stage)
* <p> * <p>
* 4 值 (用户拍板「已提交/待审核」合并): 提交后进入 SUBMITTED, 两级审核(合规先-支持方后) * 劳务/会务两轨各自独立走这条状态机. 4 值 (用户拍板「已提交/待审核」合并):
* 用 material_compliance_approved 布尔区分「合规审中 vs 支持方审中」. * 提交后进入 SUBMITTED, 两级审核(合规先-支持方后)用各自 compliance_approved 布尔区分
* 「合规审中 vs 支持方审中」.
* <pre> * <pre>
* NOT_SUBMITTED 未提交 执行方还没交 * NOT_SUBMITTED 未提交 执行方还没交
* ↓ (执行方提交) * ↓ (执行方提交)
+2 -1
View File
@@ -65,7 +65,8 @@ const rawOptions = ref([])
const loading = ref(false) const loading = ref(false)
// el-select 内部绑定的 key (可能 = 字典值, 可能 = OTHER_KEY) // 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 ?? '') const otherText = ref(props.modelValue ?? '')
+2 -1
View File
@@ -65,7 +65,8 @@ const rawOptions = ref([])
const loading = ref(false) const loading = ref(false)
// el-select 内部绑定的 key // 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 ?? '') const otherText = ref(props.modelValue ?? '')
+104 -57
View File
@@ -2,8 +2,8 @@
* 会议阶段显示 (事实驱动, 与后端 StageDeriver 镜像). * 会议阶段显示 (事实驱动, 与后端 StageDeriver 镜像).
* *
* biz_meeting 现在存「事实」: is_executed / is_frozen / is_settled / is_finished * biz_meeting 现在存「事实」: is_executed / is_frozen / is_settled / is_finished
* + material_audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED) * + 劳务/会务两轨各自 audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
* + material_compliance_approved (区分两级审核) + 审核时间. * + 各自 compliance_approved (区分两级审核) + 审核时间.
* *
* 各角色看到的「阶段名称」由这些事实实时推导, 不再是单一 current_stage 枚举投影. * 各角色看到的「阶段名称」由这些事实实时推导, 不再是单一 current_stage 枚举投影.
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配. * current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
@@ -13,21 +13,87 @@ function isTrue(v) {
return v === 1 || v === '1' || v === true 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), 用于颜色/筛选. * 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
* 两轨取最小进度 (流程最靠前的一轨决定会议物理态).
*/ */
export function derivePhysicalStage(row) { export function derivePhysicalStage(row) {
if (!row) return 'NOT_STARTED' if (!row) return 'NOT_STARTED'
if (isTrue(row.isFrozen)) return 'FROZEN' if (isTrue(row.isFrozen)) return 'FROZEN'
if (isTrue(row.isFinished)) return 'FINISHED' if (isTrue(row.isFinished)) return 'FINISHED'
if (isTrue(row.isSettled)) return 'SETTLED' if (isTrue(row.isSettled)) return 'SETTLED'
const material = row.materialAuditStage const s = Math.min(laborState(row), serviceState(row))
if (material === 'REJECTED') return 'RECTIFYING' if (s === 0) return 'RECTIFYING'
if (material === 'APPROVED') return 'AWAITING_SETTLEMENT' if (s === 2) return 'AWAITING_COMPLIANCE'
if (material === 'SUBMITTED') return isTrue(row.materialComplianceApproved) ? 'AWAITING_SUPERVISION' : 'AWAITING_COMPLIANCE' if (s === 3) return 'AWAITING_SUPERVISION'
if (s === 4) return 'AWAITING_SETTLEMENT'
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED' 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). * 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay).
* role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性. * 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.isFrozen)) return '冻结中'
if (isTrue(row.isFinished)) return '已完结' if (isTrue(row.isFinished)) return '已完结'
if (isTrue(row.isSettled)) return '已结算' if (isTrue(row.isSettled)) return '已结算'
const chosen = chooseState(role, laborState(row), serviceState(row))
const material = row.materialAuditStage return render(role, chosen, isTrue(row.isExecuted))
// 退回: 执行方看「已退回」, 其他方看「待整改」
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 '未执行'
} }
/** /**
@@ -74,31 +114,38 @@ export function stageLabel(role, row) {
return deriveStage(role, row) return deriveStage(role, row)
} }
/** 颜色 class (业务专用 tag), 基于物理阶段 */ /**
export function stageClass(row) { * 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应.
const s = derivePhysicalStage(row) * 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位
if (s === 'NOT_STARTED') return 'pending' * (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色).
if (s === 'RUNNING') return 'running' * 规则: 待整改/已退回=红, 未执行=灰, 执行中/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿.
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'reviewing' */
if (s === 'SUPERVISION_APPROVED') return 'done' const STAGE_STYLE = {
if (s === 'RECTIFYING') return 'waiting' '冻结中': { cls: 'frozen', tag: 'info' },
if (s === 'AWAITING_SETTLEMENT') return 'waiting' '已完结': { cls: 'done', tag: 'success' },
if (s === 'SETTLED') return 'done' '已结算': { cls: 'done', tag: 'success' },
if (s === 'FINISHED') return 'done' '待整改': { cls: 'waiting', tag: 'danger' },
if (s === 'FROZEN') return 'frozen' '已退回': { cls: 'waiting', tag: 'danger' },
return 'default' '未执行': { 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 列表用), 基于物理阶段 */ function stageStyle(role, row) {
export function stageTag(row) { return STAGE_STYLE[deriveStage(role, row)] || { cls: 'default', tag: 'info' }
const s = derivePhysicalStage(row) }
if (s === 'NOT_STARTED') return 'info'
if (s === 'RUNNING' || s === 'RECTIFYING') return 'primary' /** 颜色 class (业务专用 tag), 基于角色展示阶段 (与 stageLabel 文案一致). */
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'warning' export function stageClass(role, row) {
if (s === 'SUPERVISION_APPROVED' || s === 'SETTLED' || s === 'FINISHED') return 'success' return stageStyle(role, row).cls
if (s === 'AWAITING_SETTLEMENT') return 'warning' }
if (s === 'FROZEN') return 'info'
return 'info' /** 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-wrap">
<div class="page-card"> <div class="page-card">
<h2 class="page-title">项目执行单位注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps"> <el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" /> <el-step title="基本信息" />
<el-step title="注册成功" /> <el-step title="注册成功" />
@@ -196,6 +198,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped> <style scoped>
.page-card { max-width: 1200px; margin: 0 auto; } .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; } .steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; } .step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; } .sms-row { display: flex; gap: 12px; width: 100%; }
@@ -3,6 +3,8 @@
<div class="page-wrap"> <div class="page-wrap">
<div class="page-card"> <div class="page-card">
<h2 class="page-title">项目参与专家注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps"> <el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" /> <el-step title="基本信息" />
<el-step title="注册成功" /> <el-step title="注册成功" />
@@ -185,6 +187,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped> <style scoped>
.page-card { max-width: 1200px; margin: 0 auto; } .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; } .steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; } .step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; } .sms-row { display: flex; gap: 12px; width: 100%; }
@@ -3,6 +3,8 @@
<div class="page-wrap"> <div class="page-wrap">
<div class="page-card"> <div class="page-card">
<h2 class="page-title">项目支持单位注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps"> <el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" /> <el-step title="基本信息" />
<el-step title="注册成功" /> <el-step title="注册成功" />
@@ -210,6 +212,7 @@ onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
<style scoped> <style scoped>
.page-card { max-width: 1200px; margin: 0 auto; } .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; } .steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; } .step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; } .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="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
<el-table-column prop="currentStage" label="当前阶段" width="140"> <el-table-column prop="currentStage" label="当前阶段" width="140">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="日程" width="130" align="center"> <el-table-column label="日程" width="130" align="center">
+1 -1
View File
@@ -51,7 +51,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center"> <el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip /> <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="orgName" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="sessions" label="场次" width="90" align="right" /> <el-table-column prop="sessions" label="场次" width="90" align="right" />
<el-table-column label="金额" width="130" 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> </el-table-column>
<template #empty> <template #empty>
<span style="color:#c0c4cc">暂无分配 ( 项目管理 / 项目分配 添加)</span> <span style="color:#909399">暂无分配 ( 项目管理 / 项目分配 添加)</span>
</template> </template>
</el-table> </el-table>
</el-form-item> </el-form-item>
@@ -71,7 +71,7 @@
<tbody> <tbody>
<tr v-for="(r, idx) in form.roleRows" :key="idx"> <tr v-for="(r, idx) in form.roleRows" :key="idx">
<td>{{ r.role || '-' }}</td> <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>
<tr v-if="!form.roleRows.length"><td colspan="2" class="empty-row">暂无角色</td></tr> <tr v-if="!form.roleRows.length"><td colspan="2" class="empty-row">暂无角色</td></tr>
</tbody> </tbody>
@@ -104,6 +104,11 @@
<!-- 公告预览 (只读) --> <!-- 公告预览 (只读) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" /> <Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
<!-- 底部操作: 返回 (按角色回退) -->
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div> </div>
</template> </template>
@@ -148,7 +153,7 @@ const notices = computed(() => {
return items.map(m => ({ return items.map(m => ({
label: m.label, label: m.label,
url: m.url, 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})/) const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
return m ? `${m[1]}.${m[2]}.${m[3]}` : s 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() { async function loadProject() {
@@ -279,9 +295,9 @@ onMounted(async () => {
</script> </script>
<style scoped> <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; } .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; } .breadcrumb .current { color: #262626; font-weight: 500; }
/* 章节标题 (与编辑页 ProjectsNew.vue 保持一致) */ /* 章节标题 (与编辑页 ProjectsNew.vue 保持一致) */
@@ -292,15 +308,21 @@ onMounted(async () => {
} }
.new-card-title:first-child { margin-top: 0; } .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 { padding-top: 4px; }
.info-form :deep(.el-form-item) { margin-bottom: 8px; } .info-form :deep(.el-form-item) { margin-bottom: 8px; }
.info-form :deep(.el-form-item__label) { .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 { font-size: 14px; color: #262626; font-weight: 400; }
.info-value.money {
font-family: ui-monospace, "Courier New", monospace; /* 金额: 统一等宽字体 + 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; padding: 8px 12px; background: #fafafa; border-radius: 4px;
margin-bottom: 6px; margin-bottom: 6px;
} }
.monitor-num { width: 24px; color: #8c8c8c; font-size: 13px; } .monitor-num { width: 24px; color: #909399; font-size: 13px; }
.monitor-name { flex: 1; font-size: 14px; color: #1a1a1a; } .monitor-name { flex: 1; font-size: 14px; color: #262626; }
.empty-hint { text-align: center; color: #c0c4cc; padding: 12px 0; font-size: 13px; } .empty-hint { text-align: center; color: #909399; padding: 12px 0; font-size: 13px; }
/* 角色劳务表格 */ /* 角色劳务表格 + 服务公司表格: 统一 14px, 表头 600/strong, 单元格 strong */
.info-table { width: 100%; border-collapse: collapse; font-size: 13px; } .info-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.role-labor-table { width: auto; border: 1px solid #ebeef5; } /* 角色劳务表格按内容紧凑显示, 不撑满卡片 */ .role-labor-table { width: auto; border: 1px solid #ebeef5; } /* 角色劳务表格按内容紧凑显示, 不撑满卡片 */
.role-labor-table th, .role-labor-table th,
.role-labor-table td { border-right: 1px solid #ebeef5; } .role-labor-table td { border-right: 1px solid #ebeef5; }
.role-labor-table th:last-child, .role-labor-table th:last-child,
.role-labor-table td:last-child { border-right: none; } .role-labor-table td:last-child { border-right: none; }
/* 服务公司表格 (el-table in form): 紧凑, 不要撑满整张卡片 */ /* 服务公司表格 (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; } .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; } .assign-table-el :deep(th.el-table__cell) { color: #262626; font-weight: 600; background: #fafafa; }
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; } .assign-table-el :deep(td.el-table__cell) { color: #262626; }
.empty-row { text-align: center; color: #c0c4cc; } .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 行: 邀请函/支持函/通知/日程) */ /* 公告文件 (只读 4 行: 邀请函/支持函/通知/日程) */
.notice-list { display: flex; flex-direction: column; gap: 6px; } .notice-list { display: flex; flex-direction: column; gap: 6px; }
.notice-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; } .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-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; } .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-name { color: #262626; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-empty { color: #c0c4cc; font-size: 13px; } .file-empty { color: #909399; font-size: 13px; }
/* 提示 (灰色, 跟 ProjectsNew 一致) */ /* 提示 (灰色, 跟 ProjectsNew 一致) */
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; } .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 <el-select v-model="assignForm.sponsorOrgId" placeholder="请选择支持单位" filterable
:filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick"> :filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick">
<el-option v-for="u in sponsorOrgOptions" :key="u.orgId" <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-select>
</el-form-item> </el-form-item>
@@ -84,7 +85,8 @@
style="width:100%" style="width:100%"
@change="v => onExecUserPick(row, v)"> @change="v => onExecUserPick(row, v)">
<el-option v-for="u in execOptions(row)" :key="u.orgId" <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> </el-select>
</template> </template>
</el-table-column> </el-table-column>
@@ -184,6 +186,11 @@ function openPreview(url, title) {
previewOpen.value = true previewOpen.value = true
} }
// 公司是否被禁用 (biz_org.status='1') — 禁用项仍展示但置灰、不可选
function isOrgDisabled(u) {
return String(u?.status) === '1'
}
// 加载状态 // 加载状态
const singleLoading = ref(false) const singleLoading = ref(false)
const batchLoading = ref(false) const batchLoading = ref(false)
+225 -121
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">{{ 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">{{ 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">{{ 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">{{ 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">{{ row.createBy || '-' }}</span></div>
<div class="info-row"><span class="info-label">劳务费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.laborFee) }}</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 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> <span class="file-label">劳务协议:</span>
<div class="agreement-actions"> <div class="agreement-actions">
<el-button size="small" :loading="agreementTplLoading" @click="onDownloadAgreementTemplate">下载目录</el-button> <el-button size="small" :loading="agreementTplLoading" @click="onDownloadAgreementTemplate">下载目录</el-button>
@@ -188,13 +188,13 @@
</div> </div>
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row"> <div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span> <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" /> <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" /> <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> <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 v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div> </div>
<!-- 专家照片: 与劳务协议同构的"下载目录 + 回填"闭环, 不再作为单文件材料直接上传保存 --> <!-- 专家照片: 与劳务协议同构的"下载目录 + 回填"闭环, 不再作为单文件材料直接上传保存 -->
<div v-if="!isSponsor && !isReadonly" class="file-row"> <div v-if="!isSponsor && !isReadonly && laborEditable" class="file-row">
<span class="file-label">专家照片:</span> <span class="file-label">专家照片:</span>
<div class="agreement-actions"> <div class="agreement-actions">
<el-button size="small" :loading="expertPhotoTplLoading" @click="onDownloadExpertPhotoTemplate">下载目录</el-button> <el-button size="small" :loading="expertPhotoTplLoading" @click="onDownloadExpertPhotoTemplate">下载目录</el-button>
@@ -227,7 +227,7 @@
<div class="file-list"> <div class="file-list">
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row"> <div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span> <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> <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 v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div> </div>
@@ -237,7 +237,7 @@
<el-tag v-if="meetingFee.useMain" type="warning" size="small" effect="plain">已上传总发票, 以总发票为准</el-tag> <el-tag v-if="meetingFee.useMain" type="warning" size="small" effect="plain">已上传总发票, 以总发票为准</el-tag>
</div> </div>
<!-- 打包上传: 下载空目录模板 + 上传会务材料 (单目录多文件自动打 zip) --> <!-- 打包上传: 下载空目录模板 + 上传会务材料 (单目录多文件自动打 zip) -->
<div v-if="!isSponsor && !isReadonly" class="file-row"> <div v-if="!isSponsor && !isReadonly && serviceEditable" class="file-row">
<span class="file-label">打包上传:</span> <span class="file-label">打包上传:</span>
<div class="agreement-actions"> <div class="agreement-actions">
<el-button size="small" :loading="serviceTplLoading" @click="onDownloadServiceTemplate">下载目录</el-button> <el-button size="small" :loading="serviceTplLoading" @click="onDownloadServiceTemplate">下载目录</el-button>
@@ -269,15 +269,22 @@
</el-tabs> </el-tabs>
<div class="tab-actions"> <div class="tab-actions">
<span style="flex:1"></span> <span style="flex:1"></span>
<!-- 执行人员: 提交材料 --> <!-- 执行人员: 提交材料 (三按钮按轨) -->
<el-button v-if="canSubmitMaterial && !isReadonly" type="warning" :loading="busy.submitMaterial" @click="onSubmitMaterial">提交材料</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="canComplianceAudit() && !isReadonly" type="success" @click="openAuditDialog('COMPLIANCE')">合规审核 (材料)</el-button> <el-button v-if="canSubmitAll && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR', 'SERVICE'])">提交全部</el-button>
<el-button v-if="canSupervisionAudit() && !isReadonly" type="primary" @click="openAuditDialog('SUPERVISION')">监察审核 (材料)</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="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="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> </div>
</div> </div>
@@ -287,50 +294,83 @@
<div class="card audit-column"> <div class="card audit-column">
<div class="section-title">材料审核</div> <div class="section-title">材料审核</div>
<div class="timeline"> <div class="timeline">
<!-- 固定节点 1 --> <!-- 节点 1: 会议已执行 -->
<div :class="['timeline-item', fixedNodeStatus('PRE')]"> <div :class="['timeline-item', fixedNodeStatus('PRE')]">
<div class="timeline-title">会议已执行</div> <div class="timeline-title">会议已执行</div>
<div class="timeline-desc">{{ nodeDesc('PRE') }}</div> <div class="timeline-desc">{{ nodeDesc('PRE') }}</div>
</div> </div>
<!-- 动态轮次 2/3/4 --> <!-- 节点 2: 审核时间轴 (内嵌 提交 合规 监察 三步, 两轨并列, 保留重交历史) -->
<template v-for="(round, idx) in displayMaterialRounds" :key="`mat-r${idx}`"> <div :class="['timeline-item', auditNodeStatus()]">
<div :class="['timeline-item', partStatus(round.submit)]"> <div class="timeline-title">审核时间轴</div>
<div class="timeline-title">执行方提交材料</div> <div class="audit-sub-timeline">
<div v-if="round.submit" class="timeline-meta"> <div :class="['sub-timeline-item', nodeStatus('submit')]">
<span>{{ round.submit.auditor }}</span> <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 class="dot">·</span>
<span>{{ fmtDateTime(round.submit.auditTime) }}</span> <span>{{ fmtDateTime(c.submit.auditTime) }}</span>
</div> </div>
<div v-else class="timeline-desc pending-text">待执行人员提交</div>
</div> </div>
<div :class="['timeline-item', partStatus(round.compliance)]"> </div>
<div class="timeline-title">合规审核</div> <div :class="['sub-timeline-item', nodeStatus('compliance')]">
<div v-if="round.compliance" class="timeline-meta"> <div class="sub-timeline-title">合规审核</div>
<span>{{ round.compliance.auditor }}</span> <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 class="dot">·</span>
<span>{{ fmtDateTime(round.compliance.auditTime) }}</span> <span>{{ fmtDateTime(c.compliance.auditTime) }}</span>
<el-tag size="small" :type="round.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'"> <el-tag size="small" :type="c.compliance.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ c.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
{{ round.compliance.auditResult === 'REJECTED' ? '拒绝' : '通过' }}
</el-tag>
</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>
<div v-else class="timeline-desc pending-text">待监察审核</div>
<div v-if="round.supervision?.opinion" class="timeline-opinion">💬 {{ round.supervision.opinion }}</div>
</div>
</template> </template>
<!-- 固定节点 5/6 --> <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="['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>
</div>
<!-- 节点 3/4: 结算 / 完结 -->
<div :class="['timeline-item', fixedNodeStatus('POST')]"> <div :class="['timeline-item', fixedNodeStatus('POST')]">
<div class="timeline-title">会议结算</div> <div class="timeline-title">会议结算</div>
<div class="timeline-desc">{{ nodeDesc('POST') }}</div> <div class="timeline-desc">{{ nodeDesc('POST') }}</div>
@@ -355,17 +395,22 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 审核 dialog (合规/监察通用, 材料审核) --> <!-- 审核 dialog (合规/监察通用, 材料审核, 分轨独立通过/拒绝) -->
<el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px"> <el-dialog v-model="auditDialog.show" :title="auditDialog.title" width="500px">
<el-form label-width="80px"> <el-form label-width="80px">
<el-form-item label="意见"> <el-form-item label="审核项">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见" /> <div style="display:flex;flex-direction:column;gap:8px;width:100%">
</el-form-item> <div v-for="t in auditDialog.tracks" :key="t.type" style="display:flex;align-items:center;gap:12px">
<el-form-item label="结果"> <span style="width:72px">{{ t.label }}</span>
<el-radio-group v-model="auditDialog.approved"> <el-radio-group v-model="t.approved">
<el-radio :label="true">通过</el-radio> <el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio> <el-radio :label="false">拒绝</el-radio>
</el-radio-group> </el-radio-group>
</div>
</div>
</el-form-item>
<el-form-item label="意见">
<el-input v-model="auditDialog.opinion" type="textarea" :rows="3" placeholder="请输入审核意见 (两轨共用)" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -650,58 +695,78 @@ const laborMaterialRows = ref(makeRows(r => r.type === 'LABOR'))
const laborVoucherRows = ref(makeRows(r => r.type === 'LABOR_VOUCHER')) const laborVoucherRows = ref(makeRows(r => r.type === 'LABOR_VOUCHER'))
const serviceVoucherRows = ref(makeRows(r => r.type === 'SERVICE_VOUCHER')) const serviceVoucherRows = ref(makeRows(r => r.type === 'SERVICE_VOUCHER'))
// ===================== 时间轴: 解析 audit_trail 为轮次 ===================== // ===================== 时间轴: 解析 audit_trail 为「轨道 × 轮次 =====================
/** /**
* 解析 audit_trail 为轮次结构 (纯函数, 不依赖外部状态) * 解析指定材料轨 (materialType) 的审核日志为提交轮次数组 (保留重交历史).
* 规则: 一次 SUBMITTED + APPROVED = 一轮起点 (执行人员提交) * 次 SUBMITTED 起一轮, 后续 APPROVED/REJECTED 顺序填入 compliance → supervision 槽.
* 之后 1-2 条填入 compliance / supervision 槽 * 方案 B: 同轨被驳回后重交, 产生第 2/3 轮, 时间轴逐轮渲染「第 N 次」.
* 拒绝事件 (REJECTED) 不会被识别为新提交, 仍归入当前轮
*/ */
function parseRounds(auditType) { function parseTrackCycles(materialType) {
const rows = (auditTrail.value || []) const rows = (auditTrail.value || [])
.filter(r => r.auditType === auditType) .filter(r => r.auditType === 'MATERIAL' && r.materialType === materialType)
.slice() .slice()
.sort((a, b) => new Date(a.auditTime).getTime() - new Date(b.auditTime).getTime()) .sort((a, b) => new Date(a.auditTime).getTime() - new Date(b.auditTime).getTime())
const rounds = [] const cycles = []
let cur = null
for (const row of rows) { for (const row of rows) {
const isSubmit = row.auditResult === 'SUBMITTED' if (row.auditResult === 'SUBMITTED') {
if (isSubmit) { cycles.push({ submit: row, compliance: null, supervision: null })
if (cur) rounds.push(cur) continue
cur = { submit: row, compliance: null, supervision: null } }
} else if (cur) { const cur = cycles[cycles.length - 1]
if (!cur) continue
if (!cur.compliance) cur.compliance = row if (!cur.compliance) cur.compliance = row
else if (!cur.supervision) cur.supervision = row else if (!cur.supervision) cur.supervision = row
} }
return cycles
} }
if (cur) rounds.push(cur)
return rounds
}
const materialRounds = computed(() => parseRounds('MATERIAL'))
/** 至少保证 1 轮 (空轮 = 三个节点都待提交), 保证 2/3/4 始终渲染 */
const displayMaterialRounds = computed(() =>
materialRounds.value.length ? materialRounds.value : [{ submit: null, compliance: null, supervision: null }]
)
/** /**
* 节点状态视觉: * 轨道列表: 新数据 (两轨独立) 拆 劳务/会务 两轨; 历史数据 material_type=null
* - done → 绿色 (动作完成 + 通过) * 回退单轨 (无 label), 迁移后两轨同值, 时间轴不再按顺序错配.
* - rejected → 红色 (动作完成但拒绝, 通常触发下一轮)
* - pending → 灰色 (还没轮到)
*/ */
function partStatus(part) { const materialTracks = computed(() => {
if (!part) return 'pending' const labor = parseTrackCycles('LABOR')
if (part.auditResult === 'REJECTED') return 'rejected' const service = parseTrackCycles('SERVICE')
return 'done' 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) * 固定节点 (会议已执行/结算/完结) 状态 — 跟随 current_stage (BizMeetingStageEnum 10 值 code)
* slot: PRE = 节点1 (会议已执行) = NOT_STARTED/FROZEN 之外都算已执行 * slot: PRE = 会议已执行 = NOT_STARTED/FROZEN 之外都算已执行
* POST = 节点5 (结算) = AWAITING_SETTLEMENT / SETTLED / FINISHED (已结算) * POST = 结算 = AWAITING_SETTLEMENT / SETTLED / FINISHED (已结算)
* DONE = 节点6 (完结) = FINISHED (is_finished=1) * DONE = 完结 = FINISHED (is_finished=1)
*/ */
function fixedNodeStatus(slot) { function fixedNodeStatus(slot) {
const s = derivePhysicalStage(row.value) const s = derivePhysicalStage(row.value)
@@ -718,6 +783,12 @@ function nodeDesc(slot) {
return '-' return '-'
} }
/** 审核时间轴 组节点状态: 两轨均审核通过 (进入待结算) 后 done, 否则 pending (内嵌子步骤各自带色). */
function auditNodeStatus() {
const s = derivePhysicalStage(row.value)
return ['AWAITING_SETTLEMENT', 'SETTLED', 'FINISHED'].includes(s) ? 'done' : 'pending'
}
// ===================== 按钮显隐 ===================== // ===================== 按钮显隐 =====================
// 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员). // 执行方判定: 用 role (executor) 而非 biz_meeting_executor (会议级执行人员).
// 执行单位是项目级分配 (biz_project_assign), 不在 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 isSponsorMain = computed(() => currentRole.value === 'sponsor' && userStore.isMain)
const executed = computed(() => isOne(row.value.isExecuted)) const executed = computed(() => isOne(row.value.isExecuted))
const frozen = computed(() => isOne(row.value.isFrozen)) const frozen = computed(() => isOne(row.value.isFrozen))
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED} /** 单轨是否处于「可提交/可编辑」态 (未提交 或 被驳回) */
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value const laborEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage)) const serviceEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
// 材料可编辑 (保存修改): 未提交 / 被驳回 时; 提交审核后 (SUBMITTED) / 通过后 (APPROVED) 锁定, 隐藏保存按钮 /** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */
const materialsEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage)) function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) }
function canComplianceAudit() { function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) }
if (!isManager.value) return false // 材料可提交: 已执行 + 未冻结 + 对应轨可编辑 (三按钮按轨显隐)
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved) const executorSubmitReady = computed(() => isExecutor.value && executed.value && !frozen.value)
} const canSubmitLabor = computed(() => executorSubmitReady.value && laborEditable.value)
function canSupervisionAudit() { const canSubmitService = computed(() => executorSubmitReady.value && serviceEditable.value)
if (!isAssignedSupervisor.value && !isSponsorMain.value) return false const canSubmitAll = computed(() => executorSubmitReady.value && laborEditable.value && serviceEditable.value)
return row.value.materialAuditStage === 'SUBMITTED' && isOne(row.value.materialComplianceApproved) // 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定
} const materialsEditable = computed(() => laborEditable.value || serviceEditable.value)
// 结算/完结 (合规/管理员 手动点击, 后端强校验 role) // 合规审核 (合规方/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) const canSettle = computed(() => (isManager.value || isAdmin.value)
&& row.value.materialAuditStage === 'APPROVED' && row.value.laborAuditStage === 'APPROVED'
&& row.value.serviceAuditStage === 'APPROVED'
&& !isOne(row.value.isSettled)) && !isOne(row.value.isSettled))
const canFinish = computed(() => (isManager.value || isAdmin.value) const canFinish = computed(() => (isManager.value || isAdmin.value)
&& isOne(row.value.isSettled) && !isOne(row.value.isFinished)) && isOne(row.value.isSettled) && !isOne(row.value.isFinished))
@@ -1397,7 +1476,7 @@ async function loadProjectRoles() {
/** /**
* 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT), * 把当前 2 个 tab 已上传的文件 (r.url) 全量保存到 biz_meeting_material (后端 DELETE + INSERT),
* 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast. * 并触发 OCR. 返回 { saved, ocrCount }; 出错抛出, 由调用方决定 toast.
* onSave (保存按钮) 与 onSubmitMaterial (提交前自动落库) 共用. * onSave (保存按钮) 与 onSubmitMaterials (提交前自动落库) 共用.
*/ */
async function saveMaterials() { async function saveMaterials() {
const all = [...serviceMaterialRows.value, ...laborMaterialRows.value] const all = [...serviceMaterialRows.value, ...laborMaterialRows.value]
@@ -1599,18 +1678,17 @@ function submitOcrForMaterials(items) {
}) })
} }
// ===================== 提交材料 ===================== // ===================== 提交材料 (三按钮: 提交劳务/会务/全部) =====================
// 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表, /** 提交前先 saveMaterials() 落库: 后端 submit-material 校验的是 biz_meeting_material 表 */
// 只上传不保存时表为空, 会误报「未上传」. async function onSubmitMaterials(types) {
async function onSubmitMaterial() { if (!types || !types.length) { ElMessage.warning('无可提交的材料轨'); return }
busy.value.submitMaterial = true busy.value.submitMaterial = true
try { try {
await saveMaterials() await saveMaterials()
// __silentError: 拦截器不自动 toast, 错误统一由下方 catch 处理 (避免 axios 拦截器和 catch 双 toast) // __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('材料已提交, 待合规审核') ElMessage.success('材料已提交, 待合规审核')
row.value.materialAuditStage = (resp && resp.data) || 'SUBMITTED' await refreshMeeting()
await loadTrail()
} catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') } } catch (e) { ElMessage.error(e?.msg || e?.message || '提交失败') }
finally { busy.value.submitMaterial = false } finally { busy.value.submitMaterial = false }
} }
@@ -1654,30 +1732,43 @@ async function confirmAssign() {
finally { assignDialog.value.saving = false } finally { assignDialog.value.saving = false }
} }
// ===================== 审核 dialog (材料审核) ===================== // ===================== 审核 dialog (材料审核, 分轨独立通过/拒绝) =====================
const auditDialog = ref({ const auditDialog = ref({
show: false, action: '', title: '', 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 = { auditDialog.value = {
show: true, show: true,
action, action,
title: action === 'COMPLIANCE' ? '合规审核 (材料)' : '监察审核 (材料)', title: isCompliance ? `合规审核 (材料 · ${scopeLabel})` : `监察审核 (材料 · ${scopeLabel})`,
opinion: '', opinion: '',
approved: true, tracks,
saving: false saving: false
} }
} }
async function confirmAudit() { 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' const url = action === 'COMPLIANCE'
? `/business/meeting/${meetingId.value}/audit-compliance` ? `/business/meeting/${meetingId.value}/audit-compliance`
: `/business/meeting/${meetingId.value}/audit-supervision` : `/business/meeting/${meetingId.value}/audit-supervision`
auditDialog.value.saving = true auditDialog.value.saving = true
try { try {
await request.post(url, { approved, opinion }, { __silentError: true }) await request.post(url, { items, opinion }, { __silentError: true })
ElMessage.success(approved ? '审核通过' : '已拒绝') ElMessage.success('审核完成')
auditDialog.value.show = false auditDialog.value.show = false
await refreshMeeting() await refreshMeeting()
} catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') } } catch (e) { ElMessage.error(e?.msg || e?.message || '审核失败') }
@@ -1865,8 +1956,21 @@ onBeforeUnmount(stopFeePolling)
.timeline-desc.pending-text { color: #c0c4cc; font-style: italic; } .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 { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: #595959; line-height: 1.6; }
.timeline-meta .dot { color: #c0c4cc; } .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-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; } .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-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; } .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>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center"> <el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip /> <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="500" fixed="right" align="center"> <el-table-column label="操作" width="500" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="viewOnly(row)">查看</el-button> <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="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="onEdit(row)">修改</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onCopy(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="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="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('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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -100,26 +101,6 @@
<!-- 修改 / 复制: 按角色跳 admin-meetings-new / manager-meetings-new (MeetingNew.vue ) --> <!-- 修改 / 复制: 按角色跳 admin-meetings-new / manager-meetings-new (MeetingNew.vue ) -->
<!-- 查看: 按角色跳 /admin/meetings/detail/:id /manager/meetings/detail/:id --> <!-- 查看: 按角色跳 /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: 对选中的待合规审核会议批量通过/退回 --> <!-- 合规人员(manager) 批量审核 dialog: 对选中的待合规审核会议批量通过/退回 -->
<el-dialog v-model="batchAuditOpen" title="批量合规审核" width="560px"> <el-dialog v-model="batchAuditOpen" title="批量合规审核" width="560px">
<el-form label-width="100px"> <el-form label-width="100px">
@@ -149,7 +130,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { bizList, bizDelete } from '@/api/public' import { bizList, bizDelete } from '@/api/public'
import request from '@/utils/request' 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' import { ElMessage, ElMessageBox } from 'element-plus'
// ========== 角色感知 (admin/manager 共用此页, 按 URL 第 1 段识别) ========== // ========== 角色感知 (admin/manager 共用此页, 按 URL 第 1 段识别) ==========
@@ -304,8 +285,10 @@ function isOneVal(v) { return v === 1 || v === '1' || v === true }
// 已提交(SUBMITTED/APPROVED): 曾被冻结过 → 超时提交, 否则 → 按时提交 // 已提交(SUBMITTED/APPROVED): 曾被冻结过 → 超时提交, 否则 → 按时提交
// 未提交/退回(NOT_SUBMITTED/REJECTED): 已冻结 → 红色 0小时; 否则 submitDeadline - now 倒计时 // 未提交/退回(NOT_SUBMITTED/REJECTED): 已冻结 → 红色 0小时; 否则 submitDeadline - now 倒计时
function submitRemain(row) { function submitRemain(row) {
const stage = row.materialAuditStage // 两轨都进入审核 (SUBMITTED/APPROVED) 才算已提交; 否则任一条未提交/被驳回 → 仍在提交窗口
if (stage === 'SUBMITTED' || stage === '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 } return row.freezeTime ? { text: '超时提交', red: false } : { text: '按时提交', red: false }
} }
if (isOneVal(row.isFrozen)) return { text: '0小时', red: true } if (isOneVal(row.isFrozen)) return { text: '0小时', red: true }
@@ -317,9 +300,9 @@ function submitRemain(row) {
const hours = totalHours % 24 const hours = totalHours % 24
return { text: `${days}${hours}小时`, red: false } return { text: `${days}${hours}小时`, red: false }
} }
// 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (材料 APPROVED 且未结算) // 列表结算按钮显隐: 与 MeetingDetail.canSettle 一致 (两轨均 APPROVED 且未结算)
function canSettleRow(row) { function canSettleRow(row) {
return row.materialAuditStage === 'APPROVED' && !isOneVal(row.isSettled) return row.laborAuditStage === 'APPROVED' && row.serviceAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
} }
function onSettle(row) { function onSettle(row) {
// 结算需要上传付款凭证 (详情页结算 dialog 二合一), 跳详情并自动打开结算 dialog // 结算需要上传付款凭证 (详情页结算 dialog 二合一), 跳详情并自动打开结算 dialog
@@ -352,38 +335,15 @@ async function onUnfreeze(row) {
} catch (e) { ElMessage.error(e?.msg || e?.message || '解冻失败') } } catch (e) { ElMessage.error(e?.msg || e?.message || '解冻失败') }
} }
// ========== 支持方(监察员) 审 ========== // ========== 支持方(监察员) 审核: 跳详情页 (详情页内按 C1 轨给 审核劳务/会务/全部) ==========
const approvalOpen = ref(false) // 有任一轨处于 C1 (已提交待支持方审) 即显示「审核」; 会务被退回时物理阶段=待整改, 但劳务 C1 仍需审, 不能用物理阶段判
const approvalSaving = ref(false) function isSponsorPending(row) {
const approvalRow = ref({}) return (row.laborAuditStage === 'SUBMITTED' && isOneVal(row.laborComplianceApproved))
const approvalForm = reactive({ approved: true, opinion: '' }) || (row.serviceAuditStage === 'SUBMITTED' && isOneVal(row.serviceComplianceApproved))
function onApproval(row) {
approvalRow.value = row
approvalForm.approved = true
approvalForm.opinion = ''
approvalOpen.value = true
}
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) 批量审核 ========== // ========== 合规人员(manager) 批量审核 ==========
@@ -392,9 +352,11 @@ const batchAuditSaving = ref(false)
const batchAuditForm = reactive({ approved: true, opinion: '' }) const batchAuditForm = reactive({ approved: true, opinion: '' })
const batchEligibleIds = ref([]) const batchEligibleIds = ref([])
// 待合规审核判据: material_audit_stage=SUBMITTED 且 compliance_approved≠1 (与详情页 canComplianceAudit 一致) // 待合规审核判据: 任一轨 SUBMITTED 且 compliance_approved≠1 (C0, 与详情页 canComplianceAudit 一致)
function isCompliancePending(row) { 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() { function onBatchAudit() {
+8 -1
View File
@@ -93,7 +93,7 @@
<!-- : 操作按钮 - 悬浮固定到页面最右 (按选中 tab 动态显示) --> <!-- : 操作按钮 - 悬浮固定到页面最右 (按选中 tab 动态显示) -->
<aside class="detail-actions"> <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"> <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"/> <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> </svg>
@@ -217,6 +217,12 @@ const canShowExecutionBtn = computed(() => {
if (!r) return true if (!r) return true
return r === 'executor' 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 showQr = ref(false)
const qrUrl = ref('') const qrUrl = ref('')
@@ -380,6 +386,7 @@ async function checkSigned() {
} }
const signing = ref(false) const signing = ref(false)
async function onSignup() { async function onSignup() {
if (!canShowSignupBtn.value) return // 防御: 防止 v-if 被绕过
if (!loggedIn.value) { if (!loggedIn.value) {
ElMessage.warning('请先登录系统') ElMessage.warning('请先登录系统')
router.push('/login') router.push('/login')
+1 -1
View File
@@ -42,7 +42,7 @@
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="所属公司" prop="orgName"> <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"> <template #append v-if="myOrg">
<el-tooltip content="主账号公司, 注册时已自动关联" placement="top"> <el-tooltip content="主账号公司, 注册时已自动关联" placement="top">
<el-icon><Lock /></el-icon> <el-icon><Lock /></el-icon>
+5 -26
View File
@@ -6,7 +6,7 @@
筛选栏: <el-form inline :model="q" class="filter-form"> 筛选栏: <el-form inline :model="q" class="filter-form">
工具栏: <div class="toolbar"> 工具栏: <div class="toolbar">
列表列 (按原型 sponsor-people.html left 排序): 列表列 (按原型 sponsor-people.html left 排序):
复选框 / 姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色(角色+账号类型已合并) / 状态 / 操作 姓名 / 手机号 / 工作单位 / 部门 / 职务 / 角色(角色+账号类型已合并) / 状态 / 操作
--> -->
<div class="page-card"> <div class="page-card">
<div class="breadcrumb">首页 / 人员管理</div> <div class="breadcrumb">首页 / 人员管理</div>
@@ -39,11 +39,9 @@
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" @click="onCreate">新建人员</el-button> <el-button type="primary" @click="onCreate">新建人员</el-button>
<el-button @click="onImport">批量导入</el-button> <el-button @click="onImport">批量导入</el-button>
<el-button :disabled="!selected.length || hasSelfInSelection" @click="onBatchRemove">批量删除</el-button>
</div> </div>
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelectionChange"> <el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="selection" width="48" :selectable="(row) => row.userId !== store.user?.userId" />
<el-table-column prop="account" label="账号" width="120" show-overflow-tooltip /> <el-table-column prop="account" label="账号" width="120" show-overflow-tooltip />
<el-table-column prop="name" label="姓名" width="100" /> <el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="130" /> <el-table-column prop="phone" label="手机号" width="130" />
@@ -136,7 +134,7 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref, computed } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { bizUpdate, bizDelete, resetPersonPassword } from '@/api/public' import { bizUpdate, bizDelete, resetPersonPassword } from '@/api/public'
@@ -148,9 +146,6 @@ import request from '@/utils/request'
const router = useRouter() const router = useRouter()
const store = useUserStore() const store = useUserStore()
// 批量操作禁用判断: 选了主账号自己 (跟"操作列禁用/删除隐藏"是同一条规则, 防止误删)
const hasSelfInSelection = computed(() => selected.value.some(r => r.userId === store.user?.userId))
// sponsor 端只看自己团队 (主账号+子账号) 创建的人, 走专属接口 /business/person/sponsorList (后端强制隔离) // sponsor 端只看自己团队 (主账号+子账号) 创建的人, 走专属接口 /business/person/sponsorList (后端强制隔离)
const q = reactive({ const q = reactive({
name: '', phone: '', orgName: '', department: '', status: '' name: '', phone: '', orgName: '', department: '', status: ''
@@ -158,7 +153,6 @@ const q = reactive({
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 }) const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
const rows = ref([]) const rows = ref([])
const loading = ref(false) const loading = ref(false)
const selected = ref([])
async function load() { async function load() {
loading.value = true loading.value = true
@@ -183,8 +177,6 @@ function reset() {
load() load()
} }
function onSelectionChange(arr) { selected.value = arr }
function onCreate() { function onCreate() {
router.push({ path: '/sponsor/people/new' }) 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) // 批量导入 (Excel)
const importVisible = ref(false) const importVisible = ref(false)
const uploadRef = ref() 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) { 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: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.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> </style>