From 0fb98311ce2279ec43590bf249d99496431d167f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E5=BA=86=E6=B3=B0?= <12369755+htcloud1@user.noreply.gitee.com> Date: Mon, 14 Sep 2026 22:37:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E5=88=86=E9=85=8D?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=94=AF=E6=8C=81=E6=96=B9=E4=B8=BB=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=20+=20=E7=BB=93=E9=A2=98=E5=89=8D=E7=BD=AE=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E6=9C=AA=E7=BB=93=E7=AE=97=E4=BC=9A=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8 项目分配 → 支持方主账号待办通知 - BizProjectController.edit 检测 sponsor_org_id 变更 (null→org / A→B) 调 BizNotifyService.projectAssignedToSupportOrg 发待办给支持方主账号 (biz_org.user_id, MAIN sponsor). 去掉支持方 (置 null) 不发. - BizNotifyService 新增 projectAssignedToSupportOrg, 与既有的 projectAssignedToSponsor (通知 SUB 监察员) 配套, 共 2 条. 结题前置检查 (manager) - BizProjectController 新增 GET /business/project/meetingSettlement 入参 projectIds 逗号分隔, 查还有未结算会议 (is_settled<>1) 的项目, 仅返回 unsettledCount>0 项. - BizProjectMapper.checkMeetingSettled (LinkedHashMap, HAVING > 0). - manager/Projects.vue openClose 先调该端点, 有未结算会议时弹警告 ElMessageBox.confirm (允许强制结题). 后端失败兜底空数组不阻塞. Co-Authored-By: Claude Code --- .../src/main/resources/application.yml | 2 +- .../BizMeetingInvoiceController.java | 4 +- .../controller/BizProjectController.java | 46 +++++++++-- .../business/mapper/BizProjectMapper.java | 3 + .../business/notify/BizNotifyService.java | 32 ++++++++ .../business/service/IBizProjectService.java | 3 + .../impl/BizMeetingAttendeeServiceImpl.java | 36 +++++++-- .../impl/BizMeetingMaterialServiceImpl.java | 8 +- .../service/impl/BizProjectServiceImpl.java | 7 ++ .../service/impl/InvoiceOcrService.java | 7 +- .../mapper/business/BizProjectMapper.xml | 20 +++++ .../mapper/business/BizProjectPlanMapper.xml | 2 + .../framework/config/SecurityConfig.java | 2 + ry-vue3/src/layout/AdminLayout.vue | 5 +- ry-vue3/src/router/index.js | 1 + ry-vue3/src/views/auth/RegisterExpert.vue | 55 +++++++++++++- ry-vue3/src/views/manager/Projects.vue | 76 +++++++++++++++---- ry-vue3/src/views/meetings/MeetingDetail.vue | 34 ++++++--- 18 files changed, 288 insertions(+), 55 deletions(-) diff --git a/ry-api/ruoyi-admin/src/main/resources/application.yml b/ry-api/ruoyi-admin/src/main/resources/application.yml index 936e228..c49c42c 100644 --- a/ry-api/ruoyi-admin/src/main/resources/application.yml +++ b/ry-api/ruoyi-admin/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: basename: i18n/messages profiles: # 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test) - active: test + active: prod group: test: druid,test prod: druid,prod diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java index c72beb1..ab6a801 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizMeetingInvoiceController.java @@ -6,6 +6,7 @@ import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.business.service.IBizMeetingInvoiceService; import com.ruoyi.business.service.impl.InvoiceOcrService; +import com.ruoyi.common.utils.SecurityUtils; /** * 会议发票识别 Controller (v3) @@ -44,7 +45,8 @@ public class BizMeetingInvoiceController extends BaseController body.getMaterialId(), body.getMeetingId(), body.getOssUrl(), - body.getOldMaterialId()); + body.getOldMaterialId(), + SecurityUtils.getUserId()); return success(r); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java index f0567b0..a92cfaf 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java @@ -233,21 +233,30 @@ public class BizProjectController extends BaseController @PutMapping public AjaxResult edit(@RequestBody BizProject bizProject) { + BizProject old = bizProject.getProjectId() != null ? bizProjectService.getById(bizProject.getProjectId()) : null; + // submit_deadline_days 变更 → 级联重算该项目存量「从未退回、且仍有轨未提交」会议的提交截止时间. // 建会时 deadline 一次性落库, 改项目天数本不影响已有会议; 这里补上级联, 避免"改了天数会议却不按新天数冻结". Integer newDays = bizProject.getSubmitDeadlineDays(); - boolean daysChanged = false; - if (bizProject.getProjectId() != null && newDays != null) - { - BizProject old = bizProjectService.getById(bizProject.getProjectId()); - Integer oldDays = old == null ? null : old.getSubmitDeadlineDays(); - daysChanged = !Objects.equals(oldDays, newDays); - } + boolean daysChanged = bizProject.getProjectId() != null && newDays != null + && !Objects.equals(old == null ? null : old.getSubmitDeadlineDays(), newDays); + + // sponsor_org_id 变更 (合规把项目分配给支持方) → 通知新支持方主账号 (待办: 去查看并分配监察员) + Long newSponsorOrgId = bizProject.getSponsorOrgId(); + boolean sponsorChanged = bizProject.getProjectId() != null && newSponsorOrgId != null + && !Objects.equals(old == null ? null : old.getSponsorOrgId(), newSponsorOrgId); + int rows = bizProjectService.updateByPrimaryKey(bizProject); if (daysChanged) { bizMeetingService.recomputeSubmitDeadlinesByProject(bizProject.getProjectId(), newDays); } + if (sponsorChanged) + { + Long sponsorMainUserId = resolveMainUserIdByOrgId(newSponsorOrgId); + bizNotifyService.projectAssignedToSupportOrg(sponsorMainUserId, bizProject.getProjectId(), + old == null ? null : old.getProjectName()); + } return toAjax(rows); } @Log(title = "项目", businessType = BusinessType.DELETE) @@ -595,6 +604,29 @@ public class BizProjectController extends BaseController return getDataTable(list); } + /** + * 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目. + * GET /business/project/meetingSettlement?projectIds=1,2,3 + * 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0 + * (0 会议或全部已结算不返回). 前端结题弹警告据此判定. + * 接收逗号分隔字符串 (跨 axios 数组序列化版本安全), 内部 split 转 List. + */ + @GetMapping("/meetingSettlement") + public AjaxResult checkMeetingSettled(@RequestParam("projectIds") String projectIdsStr) + { + if (projectIdsStr == null || projectIdsStr.trim().isEmpty()) { + return success(new ArrayList<>()); + } + List ids = new ArrayList<>(); + for (String s : projectIdsStr.split(",")) { + String t = s.trim(); + if (!t.isEmpty()) { + try { ids.add(Long.parseLong(t)); } catch (NumberFormatException ignore) {} + } + } + return success(bizProjectService.checkMeetingSettled(ids)); + } + /** * 支持方批量分配监察员 (多个项目, 同一个监察员 + 同一份说明) * POST /business/project/sponsorAssignBatch diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java index 740b3e2..3824b83 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java @@ -1,5 +1,6 @@ package com.ruoyi.business.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.business.domain.BizProject; /** @@ -36,4 +37,6 @@ public interface BizProjectMapper int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId); /** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */ int closeExpiredOpenStatus(); + /** 结题前置检查: 返回还有未结算会议(is_settled<>1)的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */ + List> checkMeetingSettled(@org.apache.ibatis.annotations.Param("projectIds") List projectIds); } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java index b2824e5..95beb79 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/notify/BizNotifyService.java @@ -378,4 +378,36 @@ public class BizNotifyService bizMessageService.insert(msg); log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId); } + + /** + * #8 项目分配给支持方 → 通知支持方主账号 (待办: 去查看项目并分配监察员). + * + *

调用方: {@link com.ruoyi.business.controller.BizProjectController#edit}, + * 检测到 biz_project.sponsor_org_id 真正变了 (null→org / A→B) 才调. 去掉支持方 (置 null) 不发. + * + *

与 {@link #projectAssignedToSponsor} 的区别: 那条是通知被分配的监察员 (SUB sponsor), + * 本条是通知支持单位的主账号 (biz_org.user_id, MAIN sponsor). + * + * @param sponsorUserId 支持方主账号 sys_user.user_id (biz_org.user_id, nullable, 跳过) + * @param projectId biz_project.project_id (Long, 用于 bizId) + * @param projectName 项目名 (可空, 兜底) + */ + public void projectAssignedToSupportOrg(Long sponsorUserId, Long projectId, String projectName) + { + if (sponsorUserId == null) { + log.warn("[notify] projectAssignedToSupportOrg: sponsorUserId 为空, 跳过 (projectId={})", projectId); + return; + } + String name = projectName != null ? projectName : ("项目 #" + projectId); + BizMessage msg = new BizMessage(); + msg.setReceiverUserId(sponsorUserId); + msg.setMsgType(TYPE_TODO); + msg.setTitle("项目分配: " + name); + msg.setContent("您单位被分配为项目【" + name + "】的支持方,请登录系统查看并分配监察员。"); + msg.setBizType(BIZ_PROJECT); + msg.setBizId(projectId); + msg.setCreateBy("system"); + bizMessageService.insert(msg); + log.info("[notify] projectAssignedToSupportOrg 已发 uid={} projectId={}", sponsorUserId, projectId); + } } \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java index aadb54c..e2417cd 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java @@ -1,6 +1,7 @@ package com.ruoyi.business.service; import java.util.List; +import java.util.Map; import com.ruoyi.business.domain.BizProject; /** @@ -47,4 +48,6 @@ public interface IBizProjectService /** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */ int closeExpiredOpenStatus(); + /** 结题前置检查: 返回还有未结算会议的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */ + List> checkMeetingSettled(List projectIds); } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java index 84bac83..fa2dbfe 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingAttendeeServiceImpl.java @@ -1031,18 +1031,40 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService return folders; } - /** 从 zip 条目路径里解析父目录段 {序号}_姓名 → [序号数字, 姓名段]; 文件不在该结构下返回 null. - * 父目录 = 文件名前最后一段目录 (路径倒数第二段), 与模板生成的 {序号}_姓名/ 一致. */ + /** 从 zip 条目路径里定位参会人 → [序号数字, 姓名段]; 无法定位返回 null. + * 兼容多种形态: + * - 目录: {序号}_姓名/ 作为路径中任意一层目录 (套 1~N 层均可), 且文件可在该目录下的子目录里 → 取最靠近文件的匹配段 + * - 单文件: 无目录时, 文件名本身为 {序号}_姓名[.扩展名] (如 1_张三.pdf) → 姓名剥掉最后一个扩展名 + * 优先目录段, 找不到再认文件名. */ private static String[] parseAgreementDir(String path) { if (path == null || path.isEmpty()) return null; String[] segs = path.split("/"); - if (segs.length < 2) return null; - String dir = segs[segs.length - 2]; - int underscore = dir.indexOf('_'); + if (segs.length == 0) return null; + + // 1) 目录段优先: 从倒数第 2 段向根方向扫, 取最深匹配 {序号}_姓名 的目录段 + for (int i = segs.length - 2; i >= 0; i--) { + String[] t = attendeeTokenOf(segs[i], false); + if (t != null) return t; + } + + // 2) 文件名兜底: 最后一段 (文件名) 匹配 {序号}_姓名[.扩展名] + return attendeeTokenOf(segs[segs.length - 1], true); + } + + /** 从单个路径段解析 {序号}_姓名 → [序号数字, 姓名段]; 不匹配返回 null. + * stripExt=true 时把姓名段末尾的最后一个 .扩展名 剥掉 (仅文件名兜底用). */ + private static String[] attendeeTokenOf(String s, boolean stripExt) { + if (s == null || s.isEmpty()) return null; + int underscore = s.indexOf('_'); if (underscore <= 0) return null; - String numPart = dir.substring(0, underscore); + String numPart = s.substring(0, underscore); if (!numPart.matches("\\d+")) return null; - return new String[] { numPart, dir.substring(underscore + 1) }; + String name = s.substring(underscore + 1); + if (stripExt) { + int dot = name.lastIndexOf('.'); + if (dot > 0) name = name.substring(0, dot); + } + return new String[] { numPart, name }; } /** 严格匹配: 序号 (order by id 后的 1-based 下标) 必须落位, 且该位姓名与目录姓名一致才回填. diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java index 14d75fc..11d20cb 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizMeetingMaterialServiceImpl.java @@ -551,9 +551,9 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService { if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue; if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue; - // 签到表连拍多张: oss_url 是 JSON 数组字符串, 逐个 copy (签到表-1.jpg ...); 老数据单 URL 由 copyJsonUrls 兜底 - if ("L_SIGN_IN".equals(m.getSubType()) && m.getOssUrl().trim().startsWith("[")) { - copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName("L_SIGN_IN")) + "/", laborFileLabel("L_SIGN_IN")); + // oss_url 是 JSON 数组字符串 (签到表连拍 / 电子签到表 / 企业权益多文件): 逐个 copy (XX-1.ext ...); 老数据单 URL 由 copyJsonUrls 兜底 + if (m.getOssUrl().trim().startsWith("[")) { + copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName(m.getSubType())) + "/", laborFileLabel(m.getSubType())); continue; } String srcKey = ossZipService.extractKey(m.getOssUrl()); @@ -924,7 +924,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService } // 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总) if (ocrNeeded) { - invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId); + invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId, userId); } updated++; uploadProgressRegistry.update(jobId, processed); diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java index 44338dc..c07bb84 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectServiceImpl.java @@ -1,6 +1,8 @@ package com.ruoyi.business.service.impl; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -154,4 +156,9 @@ public class BizProjectServiceImpl implements IBizProjectService public int closeExpiredOpenStatus() { return bizProjectMapper.closeExpiredOpenStatus(); } + @Override + public List> checkMeetingSettled(List projectIds) { + if (projectIds == null || projectIds.isEmpty()) return new ArrayList<>(); + return bizProjectMapper.checkMeetingSettled(projectIds); + } } diff --git a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java index c98a4f1..873851d 100644 --- a/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java +++ b/ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/InvoiceOcrService.java @@ -21,7 +21,6 @@ import com.ruoyi.business.ocr.InvoiceResult; import com.ruoyi.business.ocr.LocalInvoiceRecognizer; import com.ruoyi.business.ocr.ZipExtractor; import com.ruoyi.business.oss.OssUploader; -import com.ruoyi.common.utils.SecurityUtils; import cn.hutool.core.io.FileUtil; /** @@ -76,11 +75,12 @@ public class InvoiceOcrService * @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包) * @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0; * null → 新增场景, 不做清理 + * @param creatorId 操作人 ID (由调用方在 HTTP 线程取好传入, 后台 OCR 线程无 SecurityContext) * @return 提交摘要 (立即返回, 不等 OCR 完成) *

是否 zip 不再由前端传, 后端按 ossUrl 后缀 (转小写) 判断, 避免前后端不一致. */ public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl, - Long oldMaterialId) + Long oldMaterialId, Long creatorId) { RecognizeResult out = new RecognizeResult(); if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty()) @@ -92,9 +92,6 @@ public class InvoiceOcrService // 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参 boolean isZip = ossUrl.toLowerCase().endsWith(".zip"); - // 提前取登录用户 ID: OCR 在后台线程池跑, 无 SecurityContext, 必须在此 (HTTP 线程) 取好 - final Long creatorId = SecurityUtils.getUserId(); - // 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0) if (oldMaterialId != null) { diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml index 7ed4b42..a782897 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml @@ -600,4 +600,24 @@ and b.staff_user_id = #{userId} ) t + + + \ No newline at end of file diff --git a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml index 64de2a6..e3c465f 100644 --- a/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml +++ b/ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectPlanMapper.xml @@ -84,6 +84,7 @@ submitter_id, create_by, create_time, + submit_time, #{planId}, @@ -101,6 +102,7 @@ #{submitterId}, #{createBy}, #{createTime}, + #{submitTime}, diff --git a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index cb0a8ef..1ede3d4 100644 --- a/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -72,6 +72,8 @@ public class SecurityConfig .requestMatchers(HttpMethod.POST, "/business/meetingMaterial/cameraUpload").permitAll() // 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限 .requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll() + // 医院字典远程搜索 (医生注册下拉"边输入边查询", 未登录可访问) + .requestMatchers(HttpMethod.GET, "/business/hospital/search").permitAll() // 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据 .requestMatchers(HttpMethod.GET, "/business/dict/department/active", "/business/dict/title/active", "/business/dict/department/*", "/business/dict/title/*").permitAll() diff --git a/ry-vue3/src/layout/AdminLayout.vue b/ry-vue3/src/layout/AdminLayout.vue index a0a9fea..1382657 100644 --- a/ry-vue3/src/layout/AdminLayout.vue +++ b/ry-vue3/src/layout/AdminLayout.vue @@ -109,7 +109,7 @@ import { usePageTitle } from '@/utils/pageTitle' import { getMyExpertProfile } from '@/api/business/expert' import { ElNotification } from 'element-plus' import ForcePasswordDialog from '@/components/ForcePasswordDialog.vue' -import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue' +import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, FirstAidKit, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue' const route = useRoute() const router = useRouter() @@ -244,7 +244,8 @@ const MENU = { { path: '/admin/executor-orgs', title: '执行单位管理', icon: OfficeBuilding }, { path: '/admin/department', title: '科室管理', icon: Grid }, { path: '/admin/title', title: '职称管理', icon: Medal }, - { path: '/admin/project-category', title: '项目类别管理', icon: Collection } + { path: '/admin/project-category', title: '项目类别管理', icon: Collection }, + { path: '/admin/hospital', title: '医院管理', icon: FirstAidKit } ]}, { path: '/admin/manage', title: '网站管理', icon: Setting, children: [ { path: '/admin/article', title: '协议管理', icon: Files }, diff --git a/ry-vue3/src/router/index.js b/ry-vue3/src/router/index.js index 2768f30..65e58d0 100644 --- a/ry-vue3/src/router/index.js +++ b/ry-vue3/src/router/index.js @@ -57,6 +57,7 @@ const routes = [ { path: 'special-plan/new', name: 'admin-special-plan-new', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '新建专项计划' } }, { path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } }, { path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } }, + { path: 'hospital', name: 'admin-hospital', component: () => import('@/views/admin/Hospital.vue'), meta: { title: '医院管理' } }, { path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } }, { path: 'labor-protocol/new', name: 'admin-labor-protocol-new', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '新建劳务协议模板' } }, { path: 'labor-protocol/edit/:id', name: 'admin-labor-protocol-edit', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '编辑劳务协议模板' } }, diff --git a/ry-vue3/src/views/auth/RegisterExpert.vue b/ry-vue3/src/views/auth/RegisterExpert.vue index 93c6d63..455cb91 100644 --- a/ry-vue3/src/views/auth/RegisterExpert.vue +++ b/ry-vue3/src/views/auth/RegisterExpert.vue @@ -17,7 +17,21 @@ - + + + @@ -32,7 +46,7 @@

- {{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }} + {{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
@@ -111,9 +125,44 @@ const form = reactive({ licenseCertUrl: '', titleCertUrl: '' }) +// ===== 医院远程搜索 (边输入边查询, 初始 options 为空) ===== +const hospitalOptions = ref([]) +const hospitalLoading = ref(false) +let hospitalTimer = null + +function hospitalLabel(h) { + const region = [h.province, h.city].filter(Boolean).join('/') + return region ? `${h.hospital} (${region})` : h.hospital +} + +function searchHospital(keyword) { + if (hospitalTimer) clearTimeout(hospitalTimer) + const kw = (keyword || '').trim() + if (!kw) { hospitalOptions.value = []; return } + hospitalLoading.value = true + hospitalTimer = setTimeout(async () => { + try { + const r = await request.get('/business/hospital/search', { params: { keyword: kw } }) + hospitalOptions.value = r?.data || [] + } finally { hospitalLoading.value = false } + }, 200) +} + const rules = { realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }], - workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }], + workUnit: [ + { required: true, message: '请选择医院', trigger: 'change' }, + { + validator: (rule, value, cb) => { + if (value && !hospitalOptions.value.some(h => h.hospital === value)) { + cb(new Error('请从下拉列表选择医院')) + } else { + cb() + } + }, + trigger: 'change' + } + ], department: [{ required: true, message: '请选择科室', trigger: 'change' }], doctorTitle: [{ required: true, message: '请选择职称', trigger: 'change' }], phone: [ diff --git a/ry-vue3/src/views/manager/Projects.vue b/ry-vue3/src/views/manager/Projects.vue index eb21fbd..c496f7d 100644 --- a/ry-vue3/src/views/manager/Projects.vue +++ b/ry-vue3/src/views/manager/Projects.vue @@ -451,10 +451,35 @@ async function doExportSignupExpert() { } // ========== 单行操作按钮 handlers(弹 5 个独立 dialog) ========== -function openClose(row) { +/** 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目. + * 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0. + * 0 会议或全部已结算 → 不返回该项; 后端失败兜底 [] (不阻塞结题). */ +async function checkMeetingSettled(projectIds) { + if (!projectIds || !projectIds.length) return [] + try { + const r = await request.get('/business/project/meetingSettlement', { params: { projectIds: projectIds.join(',') } }) + return r?.data || [] + } catch { return [] } +} + +async function openClose(row) { if (row.isFinished === 'Y') { return ElMessage.warning('该项目已结题,无需重复操作') } + // 有未结算会议 → 警告 (允许强制结题); 否则走原确认框 + const unsettledList = await checkMeetingSettled([row.projectId]) + const item = unsettledList.find(x => x.projectId === row.projectId) + if (item && item.unsettledCount > 0) { + try { + await ElMessageBox.confirm( + `项目 ${row.projectNo} 还有 ${item.unsettledCount} 场会议未结算,仍要结题吗?`, + '结题提示', + { type: 'warning', confirmButtonText: '仍要结题', cancelButtonText: '取消' } + ) + } catch { return } + closeTargetRow.value = row + return confirmClose() + } closeTargetRow.value = row closeModalOpen.value = true } @@ -638,7 +663,7 @@ function openAssign() { } if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`) } -function openBatch(kind) { +async function openBatch(kind) { if (!selection.value.length) return ElMessage.warning('请先勾选项目') batchKind.value = kind if (kind === 'score') { @@ -648,24 +673,45 @@ function openBatch(kind) { if (kind === 'activate') { activateDeadline.value = '' } + // 结题: 先查未结算会议数, 有未结算 → 警告 (确认后跳过 batch modal 直接结题); 否则走原 batch modal + if (kind === 'close') { + const ids = selection.value.map(r => r.projectId).filter(Boolean) + const unsettledList = await checkMeetingSettled(ids) + if (unsettledList.length > 0) { + try { + await ElMessageBox.confirm( + `其中 ${unsettledList.length} 个项目还有未结算会议,仍要批量结题吗?`, + '结题提示', + { type: 'warning', confirmButtonText: '仍要批量结题', cancelButtonText: '取消' } + ) + } catch { return } + batchSubmitting.value = true + try { await runBatchClose() } catch { ElMessage.error('批量结题失败') } + finally { batchSubmitting.value = false } + return + } + } batchOpen.value = true } +/** 批量结题实际动作: 过滤已结题 + Promise.all + 提示 + 重载. + * 供 batch modal confirm (全部已结算) 与 openBatch unsettled 直结 (有未结算) 两路复用. */ +async function runBatchClose() { + const targets = selection.value.filter(r => r.isFinished !== 'Y') + const skipCount = selection.value.length - targets.length + await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' }))) + ElMessage.success(skipCount > 0 + ? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)` + : `已批量结题 ${targets.length} 个项目`) + batchOpen.value = false + selection.value = [] + load() +} + async function confirmBatch() { if (batchKind.value === 'close') { - // 过滤掉已结题项目, 避免无效请求 - const targets = selection.value.filter(r => r.isFinished !== 'Y') - const skipCount = selection.value.length - targets.length - try { - batchSubmitting.value = true - await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' }))) - ElMessage.success(skipCount > 0 - ? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)` - : `已批量结题 ${targets.length} 个项目`) - batchOpen.value = false - selection.value = [] - load() - } catch { ElMessage.error('批量结题失败') } + batchSubmitting.value = true + try { await runBatchClose() } catch { ElMessage.error('批量结题失败') } finally { batchSubmitting.value = false } } else if (batchKind.value === 'activate') { if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间') diff --git a/ry-vue3/src/views/meetings/MeetingDetail.vue b/ry-vue3/src/views/meetings/MeetingDetail.vue index 19284b5..f0c776a 100644 --- a/ry-vue3/src/views/meetings/MeetingDetail.vue +++ b/ry-vue3/src/views/meetings/MeetingDetail.vue @@ -203,7 +203,7 @@ - + ¥ {{ materialAmount(r.subType).toFixed(2) }}
{{ r.hint }}
@@ -355,12 +355,14 @@ {{ stepLabel(ev.step) }} {{ l }} -
- {{ e.label }} - {{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }} -
-
-
💬 {{ e.opinion }}
+ +
+
💬 {{ e.opinion }}
@@ -721,7 +723,7 @@ const ROW_CONFIG = [ { type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' }, { type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg' }, { type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' }, - { type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' }, + { type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '', multi: true }, { type: 'LABOR', subType: 'L_ESIGN_IN', label: '电子签到表', hint: '会议电子签到表', accept: '.xls,.xlsx,.csv' }, { type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' }, { type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' }, @@ -836,6 +838,18 @@ function stepLabel(step) { function trackParts(node) { return (node.entries || []).map(e => e && e.label).filter(Boolean) } +/** 同一节点下按意见文本去重: 劳务/会务同时审核会写两条相同意见, 只显示一遍; 内容不同则都保留. */ +function dedupOpinions(node) { + const seen = new Set() + const out = [] + for (const e of (node.entries || [])) { + if (!e || !e.opinion) continue + if (seen.has(e.opinion)) continue + seen.add(e.opinion) + out.push(e) + } + return out +} function eventStatus(ev) { return ev.entries.some(e => e.auditResult === 'REJECTED') ? 'rejected' : 'done' } @@ -2546,7 +2560,7 @@ onBeforeUnmount(() => { .info-value.fee-val { font-weight: 600; color: #f5222d; } .tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; } +.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(280px, 2fr); gap: 16px; align-items: flex-start; } /* * 防止页面横向溢出 (宽度超出屏幕) 的关键: * 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content. @@ -2593,7 +2607,7 @@ onBeforeUnmount(() => { .timeline-item.done::before { background: #67c23a; } .timeline-item.pending::before { background: #c0c4cc; } .timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); } -.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; } +.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; white-space: nowrap; } .timeline-desc { font-size: 12px; color: #8c8c8c; line-height: 1.6; } .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; }