Compare commits

..
14 Commits
Author SHA1 Message Date
adminandClaude Code 3f587fbd9f feat: 会议材料上传/发票OCR识别/用户管理增强 + 日志降噪 + 薪亿通接口规范
- 会议/劳务材料支持直接上传与提交审核 (BizMeetingMaterial 等)
- 发票 OCR 识别增强 (InvoiceOcrService)
- 现场照片/证书批量导入覆盖式回填 (ExcelUtil/AttendeeImportVo/CameraQrUpload)
- 用户管理增强、超级管理员校验与权限拒绝处理 (SuperManagerChecker/AccessDeniedHandlerImpl)
- 日志级别调整为 info/warn (生产降噪)
- 新增薪亿通·个人票税接口规范文档 (自然人开票服务分册 + 轻量化对接分册)
- .gitignore 排除 key.txt/*.rdb/*.key/tmp/opt 等敏感与本地文件

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-12 01:13:17 +00:00
adminandClaude Code 44d4920777 feat: 合规员(manager) 自建会议可自传自审, 现场照片支持直接上传
- 后端 submitMaterial: manager 跳过执行方/已执行/已冻结三道门槛
- 前端提交按钮门槛同步放宽 (manager 不受已执行/已冻结限制)
- 现场照片(签到表/前全景/后全景) 新增直接上传入口, 与扫码拍照并存
- 劳务材料 tab 增加"劳务明细表"小标题

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 13:38:34 +00:00
adminandClaude Code f8afe9baab fix: 证书批量导入改为覆盖式回填 (直接替换旧 URL, 不再逗号追加)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:44:12 +00:00
adminandClaude Code 8461f3400d fix: 生产环境日志级别关闭 debug (info/warn)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:39:05 +00:00
adminandClaude Code fcc77c977b chore: 修正测试环境部署路径注释 /hegui/ → /hg/
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:07:27 +00:00
adminandClaude Code 47c9eb1a85 fix: portal 邀请函/支持函日期仅显示年月日
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:07:27 +00:00
adminandClaude Code 4652ac83d1 feat: 项目发布时间改为纯日期 (发布日期)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:07:27 +00:00
adminandClaude Code 80ec8f707a fix: 签到表高斯模糊打码位置 60%~70% → 75%~85%
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:07:27 +00:00
adminandClaude Code 700f65bcba feat: 新建会议项目快照改用项目起止时间 (替换创建人/创建时间)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:07:27 +00:00
adminandClaude Code 58e4e0a9d0 feat: 专家执业证书/职称证明批量导入 (zip 按手机号/姓名匹配回填)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 14:07:26 +00:00
adminandClaude Code fded62bedf fix: 执行方首页"未执行会议"数字与列表筛选口径不一致
- 数字原用 "会议总数 - RUNNING" 减法, 点进去却按 currentStage=NOT_STARTED 筛,
  导致 FROZEN/待监管等阶段被算进数字、但列表为空
- 改为直接统计 current_stage=NOT_STARTED, 与跳转链接一致
- 后端 list 已按 executor MAIN/SUB 隔离, 主/子账号各归各

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 17:16:56 +00:00
adminandClaude Code 0c3c35c0f8 feat: 项目信息新增发布时间字段(默认页面打开时间, 可修改不可设未来)
- 表单新增"发布时间" datetime 选择器, 默认 nowLocalDatetime()
- disabledDate 禁用今天之后日历日期 + validator 校验不得晚于当前时间
- 编辑回显 publishTime, 发布时用表单里的发布时间 (不再取点击瞬间)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 17:16:49 +00:00
adminandClaude Code 8e1b1fc209 fix: 邀请函 PDF 落款机构名/姓名占位符兼容每字空格排版
- 落款机构 ORG_TEXT 扩展为无空格与每字空格两种写法
  ("北京整合医学学会" / "北 京 整 合 医 学 学 会")
- 姓名占位符扩展为 "xxx" / "x x x" 两种, 大小写由 toLowerCase 归一
- 盖章取全文最后一个匹配, 未匹配日志文案同步更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 17:16:43 +00:00
54 changed files with 2196 additions and 277 deletions
+12
View File
@@ -98,3 +98,15 @@ oalogin/
11.pdf 11.pdf
11_extracted.txt 11_extracted.txt
proto/合规系统0904/ proto/合规系统0904/
# ===== 密钥 / 敏感数据 (严禁入库) =====
key.txt
*.rdb
*.key
# ===== 本地临时 / 会话脚本 =====
tmp/
claude.sh
# ===== 本地工具链 (自包含 git 等) =====
opt/
+22
View File
@@ -31,6 +31,10 @@
<jwt.version>0.9.1</jwt.version> <jwt.version>0.9.1</jwt.version>
<jaxb-api.version>2.3.1</jaxb-api.version> <jaxb-api.version>2.3.1</jaxb-api.version>
<springdoc.version>3.0.3</springdoc.version> <springdoc.version>3.0.3</springdoc.version>
<!-- 打包注入的激活 profile: 默认 prod, -Ptest / -Pprod 覆盖 -->
<active.profile>prod</active.profile>
<!-- jar 名后缀: 默认空 (=ruoyi-admin.jar), -Ptest → -test, -Pprod → -prod -->
<jar.suffix></jar.suffix>
</properties> </properties>
<!-- 依赖声明 --> <!-- 依赖声明 -->
@@ -215,6 +219,24 @@
</plugins> </plugins>
</build> </build>
<!-- 分环境打包: mvn package -Ptest / -Pprod 生成 ruoyi-admin-test.jar / ruoyi-admin-prod.jar (profile 已注入, 免 spring.profiles.active 启动参数) -->
<profiles>
<profile>
<id>test</id>
<properties>
<active.profile>test</active.profile>
<jar.suffix>-test</jar.suffix>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<active.profile>prod</active.profile>
<jar.suffix>-prod</jar.suffix>
</properties>
</profile>
</profiles>
<repositories> <repositories>
<repository> <repository>
<id>public</id> <id>public</id>
+18 -1
View File
@@ -63,6 +63,23 @@
</dependencies> </dependencies>
<build> <build>
<!-- 仅过滤 application*.yml 注入 @active.profile@, 其余资源 (logback/mapper/i18n) 不过滤避免误替换 ${...} -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>application*.yml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>application*.yml</exclude>
</excludes>
</resource>
</resources>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -89,7 +106,7 @@
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>
<finalName>${project.artifactId}</finalName> <finalName>${project.artifactId}${jar.suffix}</finalName>
</build> </build>
</project> </project>
@@ -216,6 +216,19 @@ public class SysUserController extends BaseController
return toAjax(userService.updateUserStatus(user)); return toAjax(userService.updateUserStatus(user));
} }
/**
* 切换合规人员(manager)的「全局管理员」标记 (复用 account_type: MAIN=全局管理员 / SUB=普通合规员).
* 仅对 role_type=manager 生效, 其它角色在 service 层拒绝.
*/
@PreAuthorize("@ss.hasPermi('system:user:edit')")
@Log(title = "用户管理", businessType = BusinessType.UPDATE)
@PutMapping("/changeManagerAdmin")
public AjaxResult changeManagerAdmin(@RequestBody SysUser user)
{
userService.checkUserAllowed(user);
return toAjax(userService.updateManagerAdmin(user.getUserId(), user.getAccountType()));
}
/** /**
* 根据用户编号获取授权角色 * 根据用户编号获取授权角色
*/ */
@@ -6,8 +6,8 @@ spring:
# 国际化资源文件路径 # 国际化资源文件路径
basename: i18n/messages basename: i18n/messages
profiles: profiles:
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test) # 默认 prod; Maven 打包时用 -Ptest/-Pprod 注入 @active.profile@ (见 pom.xml profiles), 免运行时 --spring.profiles.active
active: prod active: @active.profile@
group: group:
test: druid,test test: druid,test
prod: druid,prod prod: druid,prod
@@ -215,6 +215,6 @@ server:
# 日志配置 # 日志配置
logging: logging:
level: level:
com.ruoyi: debug com.ruoyi: info
org.springframework: debug org.springframework: warn
com.ruoyi.business: debug com.ruoyi.business: info
@@ -1,6 +1,8 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -169,4 +171,34 @@ public class BizExpertController extends BaseController
ImportResult result = bizExpertService.importExpert(file, updateSupport, operName); ImportResult result = bizExpertService.importExpert(file, updateSupport, operName);
return success(result); return success(result);
} }
/**
* 批量导入执业证书 (zip 压缩包或单文件): 按文件名 (手机号优先/姓名兜底) 匹配专家回填 practice_cert_url.
* 提交即返回 jobId, 前端轮询 GET /business/upload/progress/{jobId} 读进度与结果.
*/
@Log(title = "专家执业证书导入", businessType = BusinessType.IMPORT)
@PostMapping("/importPracticeCert")
public AjaxResult importPracticeCert(MultipartFile file) throws Exception
{
String operName = SecurityUtils.getUsername();
String jobId = bizExpertService.submitCertImport(file, "practice", operName);
Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
/**
* 批量导入职称证明 (zip 压缩包或单文件): 按文件名 (手机号优先/姓名兜底) 匹配专家回填 title_cert_url.
* 提交即返回 jobId, 前端轮询 GET /business/upload/progress/{jobId} 读进度与结果.
*/
@Log(title = "专家职称证明导入", businessType = BusinessType.IMPORT)
@PostMapping("/importTitleCert")
public AjaxResult importTitleCert(MultipartFile file) throws Exception
{
String operName = SecurityUtils.getUsername();
String jobId = bizExpertService.submitCertImport(file, "title", operName);
Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
} }
@@ -284,8 +284,10 @@ public class BizMeetingAttendeeController extends BaseController {
public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) { public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) {
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId); List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
List<BizMeetingAttendeeImportVo> exportList = new ArrayList<>(list.size()); List<BizMeetingAttendeeImportVo> exportList = new ArrayList<>(list.size());
for (BizMeetingAttendee a : list) { for (int i = 0; i < list.size(); i++) {
exportList.add(BizMeetingAttendeeImportVo.from(a)); BizMeetingAttendeeImportVo vo = BizMeetingAttendeeImportVo.from(list.get(i));
vo.setSeq(String.valueOf(i + 1));
exportList.add(vo);
} }
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.exportExcel(response, exportList, "参会人"); util.exportExcel(response, exportList, "参会人");
@@ -36,6 +36,7 @@ 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;
import com.ruoyi.business.service.SuperManagerChecker;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.mapper.SysUserMapper; import com.ruoyi.system.mapper.SysUserMapper;
@@ -72,6 +73,8 @@ public class BizMeetingController extends BaseController {
private StageDeriver stageDeriver; private StageDeriver stageDeriver;
@Autowired @Autowired
private PosterService posterService; private PosterService posterService;
@Autowired
private SuperManagerChecker superManagerChecker;
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizMeeting bizMeeting, public TableDataInfo list(BizMeeting bizMeeting,
@@ -115,7 +118,8 @@ public class BizMeetingController extends BaseController {
bizMeeting.getParams().put("assignedExecutorUserId", aggUid); bizMeeting.getParams().put("assignedExecutorUserId", aggUid);
} }
// 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目) // 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目)
else if ("manager".equals(roleType)) { // manager01 等白名单超级合规员不受此限制 (等同 admin 可见全部会议)
else if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) {
bizMeeting.getParams().put("managerCreateUserId", uid); bizMeeting.getParams().put("managerCreateUserId", uid);
} }
// 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己) // 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己)
@@ -351,8 +355,8 @@ public class BizMeetingController extends BaseController {
/** /**
* 执行人员提交材料 (分轨: 劳务 LABOR / 会务 SERVICE 可多选, 每轨独立提交). * 执行人员提交材料 (分轨: 劳务 LABOR / 会务 SERVICE 可多选, 每轨独立提交).
* <ul> * <ul>
* <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li> * <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验) — 合规员(manager) 自建会议自传自审, 跳过此校验</li>
* <li>校验 2: 已执行 (is_executed=1) 且未冻结</li> * <li>校验 2: 已执行 (is_executed=1) 且未冻结 — 合规员(manager) 跳过"已执行"与"已冻结"两道时间门槛 (不依赖 scheduler)</li>
* <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li> * <li>校验 3: 所选轨 audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li>
* <li>校验 4: 会务轨在 biz_meeting_material 至少 1 条 (M_*); 劳务轨 = 参会人员列表至少 1 人 (非上传文件)</li> * <li>校验 4: 会务轨在 biz_meeting_material 至少 1 条 (M_*); 劳务轨 = 参会人员列表至少 1 人 (非上传文件)</li>
* </ul> * </ul>
@@ -366,11 +370,15 @@ public class BizMeetingController extends BaseController {
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) { String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
// 合规员(manager) 自建会议可自传自审: 跳过"必须是项目执行方"的强校验
if (!"manager".equals(roleType) && !bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) {
throw new ServiceException("您不是该项目的执行方, 无法提交材料"); throw new ServiceException("您不是该项目的执行方, 无法提交材料");
} }
if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料"); // 合规员(manager) 自建会议自传自审: 不依赖 scheduler 翻 is_executed, 跳过"已执行"时间门槛
if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料"); if (!"manager".equals(roleType) && !isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料");
// 合规员(manager) 自建会议自传自审: 同样跳过"已冻结"门槛 (冻结=提交截止已过, 但自审流程不应对合规员本人设卡)
if (!"manager".equals(roleType) && isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料");
List<String> types = body == null ? null : body.getTypes(); List<String> types = body == null ? null : body.getTypes();
if (types == null || types.isEmpty()) throw new ServiceException("请选择要提交的材料类型"); if (types == null || types.isEmpty()) throw new ServiceException("请选择要提交的材料类型");
@@ -205,6 +205,35 @@ public class BizMeetingMaterialController extends BaseController {
return success(result); return success(result);
} }
/**
* 劳务材料"打包上传" — 下载空目录模板 zip (劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函 全空目录;
* 签到表/前全景/后全景 仅 admin/manager 下载的模板包含).
*/
@GetMapping("/laborTemplate/{meetingId}")
public void laborTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
byte[] data = bizMeetingMaterialService.buildLaborTemplateZip(meetingId, roleType);
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment;filename=labor-material-template.zip");
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
/**
* 劳务材料"打包上传" — 上传 zip, 解压后按目录分类回填 (劳务明细表批量导入 / 劳务协议·专家照片按参会人回填 /
* 企业权益·电子签到表·签到表·前后全景材料回填 / 日程海报·邀请函单文件回填会议字段).
*/
@Log(title = "劳务材料回填", businessType = BusinessType.UPDATE)
@PostMapping("/uploadLaborMaterials")
public AjaxResult uploadLaborMaterials(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
String jobId = bizMeetingMaterialService.submitLaborMaterialsUpload(file, meetingId, roleType);
Map<String, Object> result = new HashMap<>();
result.put("jobId", jobId);
return success(result);
}
/** /**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p> * <p>
@@ -39,6 +39,7 @@ import com.ruoyi.business.service.IBizProjectAssignService;
import com.ruoyi.business.service.IBizProjectRatingService; import com.ruoyi.business.service.IBizProjectRatingService;
import com.ruoyi.business.service.IBizProjectSponsorAssignService; import com.ruoyi.business.service.IBizProjectSponsorAssignService;
import com.ruoyi.business.service.IBizProjectExecutorAssignService; import com.ruoyi.business.service.IBizProjectExecutorAssignService;
import com.ruoyi.business.service.SuperManagerChecker;
import com.ruoyi.system.domain.vo.SysUserExtendVo; import com.ruoyi.system.domain.vo.SysUserExtendVo;
import com.ruoyi.business.mapper.BizSysUserQueryMapper; import com.ruoyi.business.mapper.BizSysUserQueryMapper;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
@@ -78,6 +79,8 @@ public class BizProjectController extends BaseController
private IBizExpertService bizExpertService; private IBizExpertService bizExpertService;
@Autowired @Autowired
private IBizMeetingService bizMeetingService; private IBizMeetingService bizMeetingService;
@Autowired
private SuperManagerChecker superManagerChecker;
/** /**
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目) * 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
@@ -110,8 +113,9 @@ public class BizProjectController extends BaseController
public TableDataInfo list(BizProject bizProject) public TableDataInfo list(BizProject bizProject)
{ {
// 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制 // 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制
// manager01 等白名单超级合规员不受此限制 (等同 admin 可见全部项目)
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) { if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId()); bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
} else if ("leader".equals(roleType)) { } else if ("leader".equals(roleType)) {
bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId()); bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId());
@@ -817,11 +821,11 @@ public class BizProjectController extends BaseController
util.exportExcel(response, exportList, "报名专家"); util.exportExcel(response, exportList, "报名专家");
} }
/** 与 list() 一致的导出数据范围: manager 只看本人创建的项目; 勾选时按 projectIds 过滤 */ /** 与 list() 一致的导出数据范围: manager 只看本人创建的项目 (白名单超级合规员不受限); 勾选时按 projectIds 过滤 */
private void applyManagerScope(BizProject bizProject) private void applyManagerScope(BizProject bizProject)
{ {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) { if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId()); bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
} }
// 勾选的项目: 前端勾选时传 projectIds, 转 params.projectIds 做 IN 过滤 // 勾选的项目: 前端勾选时传 projectIds, 转 params.projectIds 做 IN 过滤
@@ -34,8 +34,9 @@ public class AdminUserCreateBody {
private String practiceCertUrl; private String practiceCertUrl;
private String titleCertUrl; private String titleCertUrl;
// ===== executor / sponsor 专用 ===== // ===== executor / sponsor / manager 专用 =====
/** MAIN=新建单位(主账号) / SUB=选已有单位(子账号) */ /** executor/sponsor: MAIN=新建单位(主账号) / SUB=选已有单位(子账号);
* manager: MAIN=全局管理员(可见全量) / SUB=普通合规员(只看本人创建), 默认 SUB */
private String accountType; private String accountType;
/** SUB: 所属单位 biz_org.org_id */ /** SUB: 所属单位 biz_org.org_id */
private Long orgId; private Long orgId;
@@ -21,29 +21,29 @@ import com.ruoyi.common.annotation.Excel;
*/ */
public class BizMeetingAttendeeImportVo { public class BizMeetingAttendeeImportVo {
/** 序号 (导入模板展示行号 / 导出自动填行号; 导入时读取但忽略, 不落库) */
@Excel(name = "序号", sort = 1)
private String seq;
/** 医生 (原"姓名") */ /** 医生 (原"姓名") */
@Excel(name = "医生##ysxm", sort = 1) @Excel(name = "医生##ysxm", sort = 2)
private String name; private String name;
/** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */ /** 职称 */
@Excel(name = "联系电话##lxdh", sort = 2) @Excel(name = "职称##zc", sort = 3)
private String phone; private String title;
/** 医院 (原"工作单位") */
@Excel(name = "医院##yy", sort = 3)
private String workUnit;
/** 科室 */ /** 科室 */
@Excel(name = "科室##ks", sort = 4) @Excel(name = "科室##ks", sort = 4)
private String department; private String department;
/** 职称 */ /** 医院 (原"工作单位") */
@Excel(name = "职称##zc", sort = 5) @Excel(name = "医院##yy", sort = 5)
private String title; private String workUnit;
/** 身份证号 */ /** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */
@Excel(name = "身份证号##sfzh", sort = 6) @Excel(name = "联系电话##lxdh", sort = 6)
private String idCard; private String phone;
/** 银行 */ /** 银行 */
@Excel(name = "银行##yh", sort = 7) @Excel(name = "银行##yh", sort = 7)
@@ -57,46 +57,52 @@ public class BizMeetingAttendeeImportVo {
@Excel(name = "开户行##khh", sort = 9) @Excel(name = "开户行##khh", sort = 9)
private String bankBranch; private String bankBranch;
/** 角色 (原"劳务形式": 授课/主持/评审...) */ /** 身份证号 */
@Excel(name = "角色", sort = 10) @Excel(name = "身份证号##sfzh", sort = 10)
private String laborForm; private String idCard;
/** 应发金额 (decimal, 元) */
@Excel(name = "应发金额##yfje", sort = 11)
private BigDecimal feePreTax;
/** 个税税金 (decimal, 元) */
@Excel(name = "个税税金##sj", sort = 12)
private BigDecimal tax;
/** 增值税及附加成本 (decimal, 元) */
@Excel(name = "增值税及附加成本##zzsjfjcb", sort = 13)
private BigDecimal vatAndSurcharge;
/** 实发金额 (decimal, 元) */ /** 实发金额 (decimal, 元) */
@Excel(name = "实发金额##sfje", sort = 14) @Excel(name = "实发金额##sfje", sort = 11)
private BigDecimal fee; private BigDecimal fee;
/** 摘要 */ /** 摘要 */
@Excel(name = "摘要##zy", sort = 15) @Excel(name = "摘要##zy", sort = 12)
private String summary; private String summary;
/** 角色 (原"劳务形式": 授课/主持/评审...) */
@Excel(name = "角色", sort = 13)
private String laborForm;
/** 应发金额 (decimal, 元) */
@Excel(name = "应发金额##yfje", sort = 14)
private BigDecimal feePreTax;
/** 个税税金 (decimal, 元) */
@Excel(name = "个税税金##sj", sort = 15)
private BigDecimal tax;
/** 增值税及附加成本 (decimal, 元) */
@Excel(name = "增值税及附加成本##zzsjfjcb", sort = 16)
private BigDecimal vatAndSurcharge;
/** 账户名称(持卡人姓名) */ /** 账户名称(持卡人姓名) */
@Excel(name = "账户名称(持卡人姓名)", sort = 16) @Excel(name = "账户名称(持卡人姓名)", sort = 17)
private String accountName; private String accountName;
/** 开户银行地址(省/市) */ /** 开户银行地址(省/市) */
@Excel(name = "开户银行地址", sort = 17) @Excel(name = "开户银行地址", sort = 18)
private String bankRegion; private String bankRegion;
/** 银行详细地址 */ /** 银行详细地址 */
@Excel(name = "银行详细地址", sort = 18) @Excel(name = "银行详细地址", sort = 19)
private String bankAddress; private String bankAddress;
/** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */ /** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */
@Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT) @Excel(name = "身份证附件", sort = 20, type = Excel.Type.EXPORT)
private String idCardAttachments; private String idCardAttachments;
public String getSeq() { return seq; }
public void setSeq(String seq) { this.seq = seq; }
public String getPhone() { return phone; } public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; } public void setPhone(String phone) { this.phone = phone; }
public String getName() { return name; } public String getName() { return name; }
@@ -48,4 +48,17 @@ public interface IBizExpertService
* @return 导入结果 * @return 导入结果
*/ */
ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception; ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception;
/**
* 批量导入证书文件 (执业证书 / 职称证明): 提交即返回 jobId, 后台线程解压 zip (或单文件),
* 按文件名匹配专家 (手机号优先, 姓名唯一命中兜底), 逐个上传 OSS 并覆盖式回填
* practiceCertUrl (certType="practice") 或 titleCertUrl (certType="title").
* 前端轮询 {@code GET /business/upload/progress/{jobId}} 读进度与结果.
*
* @param file .zip 压缩包 或单个 .png/.jpg/.jpeg/.pdf/.webp 文件
* @param certType "practice" | "title"
* @param operName 操作人 (写入 update_by)
* @return jobId (进度查询凭据)
*/
String submitCertImport(MultipartFile file, String certType, String operName) throws Exception;
} }
@@ -74,6 +74,15 @@ public interface IBizMeetingAttendeeService {
*/ */
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception; ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
/**
* 从字节流批量导入参会人 (劳务材料"打包上传"内嵌的 劳务明细表 回填用).
* 逻辑与 {@link #importFromExcel} 完全一致, 只是入参为已解压的 Excel 字节, 免于构造 MultipartFile.
*
* @param excelBytes Excel 文件字节 (xls/xlsx)
* @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }
*/
ImportResult importFromExcelBytes(byte[] excelBytes, Long meetingId, String operName) throws Exception;
/** /**
* 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表. * 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
* *
@@ -128,6 +128,35 @@ public interface IBizMeetingMaterialService {
*/ */
String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception; String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception;
/**
* 生成"劳务材料"空目录模板 zip (劳务材料 tab "打包上传" 的"下载目录"按钮).
* <p>
* zip 内为 10 个空目录: 劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函.
* 签到表/前全景/后全景 仅 admin/manager 可见 (其他角色下载的模板不含这三个目录),
* 劳务协议/专家照片 额外生成 {序号}_{姓名}/ 子目录 (每个参会人一个), 劳务协议目录内附 说明.txt.
*
* @param meetingId 会议 id
* @param roleType 当前用户角色类型 (admin/manager 可下载含拍照签到目录的模板)
* @return zip 字节 (UTF-8 目录名)
*/
byte[] buildLaborTemplateZip(Long meetingId, String roleType);
/**
* 提交"劳务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
* 后台线程解压按目录名分类回填 (劳务明细表批量导入 / 劳务协议·专家照片按参会人回填 /
* 企业权益·电子签到表·签到表·前后全景材料回填 / 日程海报·邀请函单文件回填到会议字段),
* 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
* <p>
* 签到表/前全景/后全景 仅 admin/manager 可回填, 其他角色的 zip 内这三个目录文件会被忽略.
*
* @param file 用户重新压缩的 zip
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
* @param roleType 当前用户角色类型 (用于拍照签到目录的权限过滤)
* @return jobId (进度/结果轮询键)
* @throws Exception 文件为空 / 读字节失败
*/
String submitLaborMaterialsUpload(MultipartFile file, Long meetingId, String roleType) throws Exception;
/** /**
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存). * 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
* <p> * <p>
@@ -56,10 +56,10 @@ public class InvitationPdfService {
/** 公章尺寸 (宽高, px), 复用 PdfService 劳务协议章口径 */ /** 公章尺寸 (宽高, px), 复用 PdfService 劳务协议章口径 */
private static final float SEAL_WIDTH = 120f; private static final float SEAL_WIDTH = 120f;
private static final float SEAL_HEIGHT = 120f; private static final float SEAL_HEIGHT = 120f;
/** 需盖章的落款机构名 (取全文最后一个) */ /** 需盖章的落款机构名 (取全文最后一个), 兼容无空格/每字空格两种排版 */
private static final String ORG_TEXT = "北京整合医学学会"; private static final String[] ORG_TEXTS = {"北京整合医学学会", "北 京 整 合 医 学 学 会"};
/** 姓名占位符 (忽略大小写, 兼容 xxx / XXX) */ /** 姓名占位符 (忽略大小写, 由 toLowerCase 归一), 兼容无空格/带空格两种排版 */
private static final String PLACEHOLDER = "xxx"; private static final String[] PLACEHOLDERS = {"xxx", "x x x"};
/** 按 URL 下载邀请函模板 PDF 字节 (批量发送时对同一会议只调用一次) */ /** 按 URL 下载邀请函模板 PDF 字节 (批量发送时对同一会议只调用一次) */
public byte[] downloadTemplate(String url) { public byte[] downloadTemplate(String url) {
@@ -144,21 +144,27 @@ public class InvitationPdfService {
} }
String lower = full.toLowerCase(Locale.ROOT); String lower = full.toLowerCase(Locale.ROOT);
int i = 0; // 姓名占位符: 兼容无空格/带空格两种写法 (xxx / x x x, 大小写由 toLowerCase 归一)
while ((i = lower.indexOf(PLACEHOLDER, i)) >= 0) { for (String ph : PLACEHOLDERS) {
nameTargets.add(buildNameTarget(charInfos, i, PLACEHOLDER.length(), p)); int i = 0;
i += PLACEHOLDER.length(); while ((i = lower.indexOf(ph, i)) >= 0) {
nameTargets.add(buildNameTarget(charInfos, i, ph.length(), p));
i += ph.length();
}
} }
int j = 0; // 落款机构: 兼容无空格/每字空格两种写法, 取全文最后一个匹配
while ((j = full.indexOf(ORG_TEXT, j)) >= 0) { for (String org : ORG_TEXTS) {
seal = buildSealTarget(charInfos, j, ORG_TEXT.length(), p); int j = 0;
j += ORG_TEXT.length(); while ((j = full.indexOf(org, j)) >= 0) {
seal = buildSealTarget(charInfos, j, org.length(), p);
j += org.length();
}
} }
} }
// 第二遍: 叠加 // 第二遍: 叠加
if (nameTargets.isEmpty()) { if (nameTargets.isEmpty()) {
log.warn("[invitation] 未找到占位符 xxx, 跳过姓名替换"); log.warn("[invitation] 未找到姓名占位符 (xxx / x x x), 跳过姓名替换");
} else { } else {
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false); PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
for (NameTarget t : nameTargets) { for (NameTarget t : nameTargets) {
@@ -166,7 +172,7 @@ public class InvitationPdfService {
} }
} }
if (seal == null) { if (seal == null) {
log.warn("[invitation] 未找到 '{}', 跳过盖章", ORG_TEXT); log.warn("[invitation] 未找到落款机构名 (北京整合医学学会), 跳过盖章");
} else { } else {
drawSeal(doc, seal); drawSeal(doc, seal);
} }
@@ -0,0 +1,28 @@
package com.ruoyi.business.service;
import org.springframework.stereotype.Component;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 超级合规员(manager)判定: 复用 {@code sys_user.account_type} 标记「全局管理员」.
*
* <p>role_type=manager 且 account_type=MAIN → 全局管理员 (等同 admin 可见全量, 不受「只看本人创建的项目/会议」数据范围限制);
* account_type=SUB 或 null → 普通合规员 (只看本人创建的). 其它角色不受影响.</p>
*
* <p>不再依赖配置白名单, 改由 admin/用户管理 里切换 manager 的 account_type (MAIN/SUB) 控制, 免改代码.</p>
*/
@Component
public class SuperManagerChecker
{
/** 当前登录用户是否为「不受限」的 manager (role_type=manager 且 account_type=MAIN) */
public boolean isUnrestrictedManager()
{
try {
SysUser user = SecurityUtils.getLoginUser().getUser();
return "manager".equals(user.getRoleType()) && "MAIN".equals(user.getAccountType());
} catch (Exception e) {
return false;
}
}
}
@@ -112,6 +112,10 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
/** admin: 纯 sys_user; manager: sys_user + biz_person (unit_type='manager', 让 biz_project.create_user_name 反查得到姓名) */ /** admin: 纯 sys_user; manager: sys_user + biz_person (unit_type='manager', 让 biz_project.create_user_name 反查得到姓名) */
private SysUser insertPlain(AdminUserCreateBody b, String role, String encPwd) { private SysUser insertPlain(AdminUserCreateBody b, String role, String encPwd) {
SysUser u = baseUser(b, role, encPwd); SysUser u = baseUser(b, role, encPwd);
if ("manager".equals(role)) {
// 合规员默认 SUB (普通合规员, 只看本人创建的数据); 勾选「全局管理员」时前端传 MAIN
u.setAccountType(b.getAccountType() == null || b.getAccountType().isEmpty() ? "SUB" : b.getAccountType());
}
sysUserService.insertUser(u); sysUserService.insertUser(u);
if ("manager".equals(role)) { if ("manager".equals(role)) {
BizPerson p = new BizPerson(); BizPerson p = new BizPerson();
@@ -1,14 +1,28 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -17,7 +31,9 @@ import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertImportVo; import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.business.mapper.BizExpertMapper; import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.service.IBizExpertService; import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.upload.UploadProgressRegistry;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
@@ -46,6 +62,24 @@ public class BizExpertServiceImpl implements IBizExpertService
@Autowired @Autowired
private BizNotifyService bizNotifyService; private BizNotifyService bizNotifyService;
@Autowired
private OssUploader ossUploader;
@Autowired
private UploadProgressRegistry uploadProgressRegistry;
@Autowired
@Qualifier("zipUploadExecutor")
private ExecutorService zipUploadExecutor;
/** 证书类型: 执业证书 → practiceCertUrl + OSS 子目录 ry8080/cert/practice */
private static final String CERT_TYPE_PRACTICE = "practice";
/** 证书类型: 职称证明 → titleCertUrl + OSS 子目录 ry8080/cert/title */
private static final String CERT_TYPE_TITLE = "title";
/** 手机号 (1 开头 11 位) 匹配模式, 用于从证书文件名里提取专家标识 */
private static final Pattern PHONE_PATTERN = Pattern.compile("1\\d{10}");
@Override @Override
public BizExpert getById(Long expertId) public BizExpert getById(Long expertId)
{ return bizExpertMapper.selectByPrimaryKey(expertId); } { return bizExpertMapper.selectByPrimaryKey(expertId); }
@@ -438,4 +472,218 @@ public class BizExpertServiceImpl implements IBizExpertService
this.rowNo = rowNo; this.rowNo = rowNo;
} }
} }
// ==================== 证书批量导入 (执业证书 / 职称证明) ====================
@Override
public String submitCertImport(MultipartFile file, String certType, String operName) throws Exception {
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的证书文件");
boolean practice = CERT_TYPE_PRACTICE.equals(certType);
boolean title = CERT_TYPE_TITLE.equals(certType);
if (!practice && !title) throw new ServiceException("未知证书类型: " + certType);
String jobId = UUID.randomUUID().toString().replace("-", "");
byte[] fileBytes = file.getBytes();
String originalName = file.getOriginalFilename();
uploadProgressRegistry.start(jobId);
// 后台线程无 SecurityContext, 操作人已在请求线程 (controller) 取好
final String type = certType;
final String username = operName;
zipUploadExecutor.submit(() -> {
try {
Map<String, Object> result = doCertImport(fileBytes, originalName, type, jobId, username);
uploadProgressRegistry.finish(jobId, result);
} catch (Exception e) {
log.warn("[expert] 证书批量回填失败 type={} err={}", type, e.getMessage(), e);
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
}
});
return jobId;
}
/** 后台实际处理: 解压 → 匹配 → 上传 OSS → 回填 URL */
private Map<String, Object> doCertImport(byte[] fileBytes, String originalName, String certType,
String jobId, String username) throws Exception {
List<CertFile> files = parseCertFiles(fileBytes, originalName);
if (files.isEmpty()) {
throw new ServiceException("压缩包内没有可识别的证书文件 (支持 .png/.jpg/.jpeg/.pdf/.webp)");
}
// 一次性加载所有专家, 构建 phone/name 索引, 避免每文件一次 SQL
List<BizExpert> allExperts = bizExpertMapper.selectList(new BizExpert());
Map<String, BizExpert> byPhone = new HashMap<>();
Map<String, List<BizExpert>> byName = new HashMap<>();
for (BizExpert e : allExperts) {
if (e.getPhone() != null && !e.getPhone().isEmpty()) byPhone.put(e.getPhone(), e);
if (e.getName() != null && !e.getName().trim().isEmpty()) {
byName.computeIfAbsent(e.getName().trim(), k -> new ArrayList<>()).add(e);
}
}
boolean practice = CERT_TYPE_PRACTICE.equals(certType);
String subDir = practice ? "ry8080/cert/practice" : "ry8080/cert/title";
uploadProgressRegistry.setTotal(jobId, files.size());
int updated = 0;
int processed = 0;
List<Map<String, Object>> results = new ArrayList<>();
for (CertFile f : files) {
processed++;
Map<String, Object> r = new HashMap<>();
r.put("fileName", f.name);
CertMatch cm = matchExpert(f.name, byPhone, byName);
if (cm.expert == null) {
r.put("matched", false);
r.put("reason", cm.reason);
results.add(r);
uploadProgressRegistry.update(jobId, processed);
continue;
}
BizExpert matched = cm.expert;
String url = ossUploader.uploadWithOriginalName(f.bytes, f.name, subDir);
// 覆盖式回填: 直接替换为新 URL (证书不允许多张)
BizExpert entity = new BizExpert();
entity.setExpertId(matched.getExpertId());
entity.setUpdateBy(username);
if (practice) entity.setPracticeCertUrl(url);
else entity.setTitleCertUrl(url);
bizExpertMapper.updateByPrimaryKey(entity);
updated++;
r.put("matched", true);
r.put("expertName", matched.getName());
r.put("phone", matched.getPhone());
results.add(r);
uploadProgressRegistry.update(jobId, processed);
}
log.info("[expert] 证书回填完成 type={} 命中{}个 (跳过{}个)", certType, updated, files.size() - updated);
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", files.size());
result.put("skipped", files.size() - updated);
result.put("results", results);
return result;
}
/**
* 按文件名匹配专家: 手机号优先 (唯一), 姓名兜底 (仅唯一命中才返回).
* 文件名先剥扩展名, 手机号从原串提取; 姓名兜底时剔除证书关键词与分隔符.
* 未命中返回 {@link CertMatch} (expert=null, reason 区分 手机号不存在 / 姓名重名 / 姓名不存在).
*/
private CertMatch matchExpert(String fileName, Map<String, BizExpert> byPhone, Map<String, List<BizExpert>> byName) {
String base = stripExt(fileName);
if (base.isEmpty()) return new CertMatch(null, "文件名无法解析");
Matcher m = PHONE_PATTERN.matcher(base);
if (m.find()) {
String phone = m.group();
BizExpert e = byPhone.get(phone);
if (e != null) return new CertMatch(e, null);
return new CertMatch(null, "手机号 " + phone + " 不存在");
}
String name = stripCertKeywords(base);
if (name.isEmpty()) return new CertMatch(null, "文件名无法解析出手机号或姓名");
List<BizExpert> list = byName.get(name);
if (list == null || list.isEmpty()) return new CertMatch(null, "姓名 " + name + " 不存在");
if (list.size() > 1) return new CertMatch(null, "姓名 " + name + " 重名, 无法唯一定位, 请用手机号命名");
return new CertMatch(list.get(0), null);
}
/** 去掉文件名最后一个扩展名 (如 张三.pdf → 张三) */
private static String stripExt(String name) {
if (name == null) return "";
int dot = name.lastIndexOf('.');
String s = dot > 0 ? name.substring(0, dot) : name;
return s.trim();
}
/** 剔除证书关键词与分隔符, 供姓名兜底匹配 (执业证书_张三 → 张三) */
private static String stripCertKeywords(String base) {
String s = base;
s = s.replaceAll("(?i)(执业证书|职称证明|职业资格证书|资格证书|职称证书|职称|证书|证明)", " ");
s = s.replaceAll("[_\\-\\s]+", " ");
return s.trim();
}
/** 解析输入: zip → 展平所有证书文件; 单文件 → 单条; 不支持的扩展名抛异常 */
private List<CertFile> parseCertFiles(byte[] bytes, String originalName) throws IOException {
String lower = originalName == null ? "" : originalName.toLowerCase();
if (lower.endsWith(".zip")) {
return parseCertZip(bytes);
}
if (isCertExt(originalName)) {
List<CertFile> out = new ArrayList<>();
out.add(new CertFile(originalName, bytes));
return out;
}
throw new ServiceException("不支持的文件类型, 请上传 .zip 压缩包或 .png/.jpg/.jpeg/.pdf/.webp 文件");
}
/** 解压 zip: UTF-8 失败回退 GBK (中文 Windows 打包器), 展平子目录, 只保留证书文件 */
private List<CertFile> parseCertZip(byte[] zipBytes) throws IOException {
try {
return readCertZip(zipBytes, StandardCharsets.UTF_8);
} catch (IllegalArgumentException | MalformedInputException e) {
log.warn("[expert] 证书 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
return readCertZip(zipBytes, Charset.forName("GBK"));
}
}
private List<CertFile> readCertZip(byte[] zipBytes, Charset charset) throws IOException {
List<CertFile> out = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String path = entry.getName();
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
String name = lastSegment(path);
if (!isCertExt(name)) continue;
out.add(new CertFile(name, readAllBytes(zis)));
}
}
return out;
}
private static String lastSegment(String path) {
int i = path.lastIndexOf('/');
return i >= 0 ? path.substring(i + 1) : path;
}
private static boolean isCertExt(String name) {
if (name == null) return false;
String l = name.toLowerCase();
return l.endsWith(".png") || l.endsWith(".jpg") || l.endsWith(".jpeg")
|| l.endsWith(".pdf") || l.endsWith(".webp");
}
private static byte[] readAllBytes(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
return out.toByteArray();
}
/** 证书文件 (解压后的单个文件: 文件名 + 字节) */
private static class CertFile {
final String name;
final byte[] bytes;
CertFile(String name, byte[] bytes) {
this.name = name;
this.bytes = bytes;
}
}
/** 证书文件名 → 专家匹配结果 (expert 为 null 时 reason 说明未命中原因) */
private static class CertMatch {
final BizExpert expert;
final String reason;
CertMatch(BizExpert expert, String reason) {
this.expert = expert;
this.reason = reason;
}
}
} }
@@ -476,9 +476,19 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
@Override @Override
public ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception { public ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空"); if (meetingId == null) throw new ServiceException("meetingId 不能为空");
return doImport(file.getInputStream(), meetingId, operName);
}
@Override
public ImportResult importFromExcelBytes(byte[] excelBytes, Long meetingId, String operName) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (excelBytes == null || excelBytes.length == 0) throw new ServiceException("导入数据不能为空");
return doImport(new ByteArrayInputStream(excelBytes), meetingId, operName);
}
private ImportResult doImport(InputStream in, Long meetingId, String operName) throws Exception {
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class); ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream()); List<BizMeetingAttendeeImportVo> rows = util.importExcel(in);
if (rows == null || rows.isEmpty()) { if (rows == null || rows.isEmpty()) {
throw new ServiceException("导入数据不能为空"); throw new ServiceException("导入数据不能为空");
} }
@@ -512,12 +522,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
result.fail(rowNo, "手机号格式不正确: " + phone); result.fail(rowNo, "手机号格式不正确: " + phone);
continue; continue;
} }
// 身份证号位数校验 (18位, 末位可为 X/x); 空值跳过 (导入不强制必填, 与前端 dialog 的必填规则分开) // 身份证号不校验格式 (可能为护照号等非18位证件号); 仅 trim 后入库
String idCard = vo.getIdCard() == null ? null : vo.getIdCard().trim(); String idCard = vo.getIdCard() == null ? null : vo.getIdCard().trim();
if (idCard != null && !idCard.isEmpty() && !idCard.matches("^\\d{17}[\\dXx]$")) {
result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard);
continue;
}
// 角色(laborForm) 必填校验 (与前端 dialog 必填规则保持一致: 角色单选 radio 不能为空; // 角色(laborForm) 必填校验 (与前端 dialog 必填规则保持一致: 角色单选 radio 不能为空;
// 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库) // 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库)
String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim(); String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim();
@@ -789,8 +795,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
public byte[] buildAttendeeInfoExcel(List<BizMeetingAttendee> list) { public byte[] buildAttendeeInfoExcel(List<BizMeetingAttendee> list) {
List<BizMeetingAttendeeImportVo> voList = new ArrayList<>(); List<BizMeetingAttendeeImportVo> voList = new ArrayList<>();
if (list != null) { if (list != null) {
for (BizMeetingAttendee a : list) { for (int i = 0; i < list.size(); i++) {
voList.add(BizMeetingAttendeeImportVo.from(a)); BizMeetingAttendeeImportVo vo = BizMeetingAttendeeImportVo.from(list.get(i));
vo.setSeq(String.valueOf(i + 1));
voList.add(vo);
} }
} }
SXSSFWorkbook wb = new SXSSFWorkbook(500); SXSSFWorkbook wb = new SXSSFWorkbook(500);
@@ -899,13 +907,18 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
uploadProgressRegistry.update(jobId, processed); uploadProgressRegistry.update(jobId, processed);
continue; continue;
} }
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议 // 劳务协议可多文件: 目录内所有文件都上传 OSS (不打包 zip), 逗号拼接覆盖式回填 labor_protocol, 前端按逗号拆分逐个预览
String fileName = (!f.fileNames.isEmpty()) ? f.fileNames.get(0) : "labor-protocol.pdf"; StringBuilder sb = new StringBuilder();
String url = ossUploader.upload(f.files.get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol"); for (int i = 0; i < f.files.size(); i++) {
String fileName = (i < f.fileNames.size()) ? f.fileNames.get(i) : ("labor-protocol-" + (i + 1) + ".pdf");
String url = ossUploader.uploadWithOriginalName(f.files.get(i), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
if (sb.length() > 0) sb.append(",");
sb.append(url);
}
BizMeetingAttendee entity = new BizMeetingAttendee(); BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(m.attendee.getId()); entity.setId(m.attendee.getId());
entity.setLaborProtocol(url); entity.setLaborProtocol(sb.toString());
entity.setUpdateBy(username); entity.setUpdateBy(username);
updateLaborProtocol(entity); updateLaborProtocol(entity);
updated++; updated++;
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -12,6 +13,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.net.URI; import java.net.URI;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -51,6 +53,9 @@ import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper; import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
@@ -99,6 +104,9 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
"M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL", "M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL",
"M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_SETTLEMENT_STAMP", "M_INVOICE"); "M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_SETTLEMENT_STAMP", "M_INVOICE");
/** 会务材料单类最多文件数 (多文件 JSON 数组写 oss_url, 防超列宽; 与电子签到表 ESIGN_MAX_FILES 对齐) */
private static final int SERVICE_MAX_FILES = 50;
/** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */ /** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */
private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>(); private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>();
static { static {
@@ -127,6 +135,29 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
LABOR_SUBTYPE_LABEL.put("LV_PAYMENT", "劳务付款凭证"); LABOR_SUBTYPE_LABEL.put("LV_PAYMENT", "劳务付款凭证");
} }
/** 劳务"打包上传" zip 内目录名 → 处理类别 (DETAIL=劳务明细表/AGREEMENT=劳务协议/…/SCHEDULE=日程海报/INVITATION=邀请函) */
private static final Map<String, String> LABOR_CATEGORY_BY_LABEL = new HashMap<>();
static {
LABOR_CATEGORY_BY_LABEL.put("劳务明细表", "DETAIL");
LABOR_CATEGORY_BY_LABEL.put("劳务协议", "AGREEMENT");
LABOR_CATEGORY_BY_LABEL.put("企业权益", "ENTERPRISE_BENEFIT");
LABOR_CATEGORY_BY_LABEL.put("电子签到表", "ESIGN_IN");
LABOR_CATEGORY_BY_LABEL.put("签到表", "SIGN_IN");
LABOR_CATEGORY_BY_LABEL.put("前全景", "PANORAMA_FRONT");
LABOR_CATEGORY_BY_LABEL.put("后全景", "PANORAMA_BACK");
LABOR_CATEGORY_BY_LABEL.put("专家照片", "EXPERT_PHOTO");
LABOR_CATEGORY_BY_LABEL.put("日程海报", "SCHEDULE");
LABOR_CATEGORY_BY_LABEL.put("邀请函", "INVITATION");
}
/** 劳务"打包上传"模板目录顺序 (与前端 劳务材料 tab 展示顺序一致) */
private static final List<String> LABOR_TEMPLATE_CATEGORIES = Arrays.asList(
"劳务明细表", "劳务协议", "企业权益", "电子签到表",
"签到表", "前全景", "后全景", "专家照片", "日程海报", "邀请函");
/** 拍照签到目录 (仅 admin/manager 可下载模板 + 回填) */
private static final Set<String> LABOR_CAMERA_LABELS = new HashSet<>(Arrays.asList("签到表", "前全景", "后全景"));
private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
@Autowired @Autowired
@@ -217,6 +248,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
for (BizMeetingMaterial m : list) { for (BizMeetingMaterial m : list) {
m.setId(null); m.setId(null);
m.setMeetingId(meetingId); m.setMeetingId(meetingId);
checkServiceFileCount(m);
BizMeetingMaterial old = oldBySubType.get(m.getSubType()); BizMeetingMaterial old = oldBySubType.get(m.getSubType());
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl()); boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
if (unchanged) { if (unchanged) {
@@ -876,30 +908,37 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType); String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
BizMeetingMaterial old = existingBySubType.get(subType); BizMeetingMaterial old = existingBySubType.get(subType);
// 先算好要上传的字节 + 文件名 (单文件原样 / 多文件先打 zip), 便于做"内容未变"判断 // 多文件数量上限 (JSON 数组写 oss_url, 防超列宽): 超限直接失败, 前端轮询展示错误
String fileName; if (files.size() > SERVICE_MAX_FILES) {
byte[] toUpload; throw new ServiceException(label + ": 单个材料分类最多上传 " + SERVICE_MAX_FILES + " 个文件, 当前 " + files.size() + " 个");
boolean isZip = files.size() > 1; }
// 单文件: 原样直传; 多文件: 逐个直传 OSS 后 oss_url 存 JSON 数组 (不再打 zip)
String fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg");
boolean ocrNeeded = false;
String ossUrl;
if (files.size() == 1) { if (files.size() == 1) {
fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg"); byte[] toUpload = files.get(0);
toUpload = files.get(0); // 内容未变且已计算过 (fee_status=1) → 跳过重传重 OCR, 保留旧金额, 不触发会务费重算
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
&& contentEquals(old.getOssUrl(), toUpload)) {
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
uploadProgressRegistry.update(jobId, processed);
continue;
}
ocrNeeded = RECOGNIZABLE.matcher(fileName).find();
ossUrl = ossUploader.upload(toUpload, fileName, subDir);
} else { } else {
fileName = label + ".zip"; List<String> urls = new ArrayList<>();
toUpload = zipFiles(files, names); for (int i = 0; i < files.size(); i++) {
String n = (names != null && i < names.size()) ? names.get(i) : (label + "-" + (i + 1));
if (!ocrNeeded && RECOGNIZABLE.matcher(n).find()) ocrNeeded = true;
urls.add(ossUploader.upload(files.get(i), n, subDir));
}
ossUrl = JSONUtil.toJsonStr(urls);
} }
// 内容未变且已计算过 (fee_status=1) → 跳过重传重 OCR, 保留旧金额, 不触发会务费重算 // 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单/多文件里任一可识别 (jpg/png/pdf/zip) → 待 OCR(0), 其余已计算(1)
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
&& contentEquals(old.getOssUrl(), toUpload)) {
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
uploadProgressRegistry.update(jobId, processed);
continue;
}
String ossUrl = ossUploader.upload(toUpload, fileName, subDir);
// 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 待 OCR(0), 其余已计算(1)
boolean ocrNeeded = isZip || (ossUrl != null && RECOGNIZABLE.matcher(ossUrl).find());
int feeStatus = ocrNeeded ? 0 : 1; int feeStatus = ocrNeeded ? 0 : 1;
Long materialId; Long materialId;
@@ -1021,7 +1060,590 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
private boolean needsOcr(BizMeetingMaterial m) { private boolean needsOcr(BizMeetingMaterial m) {
if (m == null || m.getOssUrl() == null || m.getOssUrl().isEmpty()) return false; if (m == null || m.getOssUrl() == null || m.getOssUrl().isEmpty()) return false;
if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false; if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false;
return RECOGNIZABLE.matcher(m.getOssUrl()).find(); String oss = m.getOssUrl().trim();
// 多文件 (JSON 数组字符串, 会务材料多文件直传): 任一文件可识别即需 OCR
if (oss.startsWith("[")) {
try {
JSONArray arr = JSONUtil.parseArray(oss);
for (int i = 0; i < arr.size(); i++) {
String u = arr.getStr(i);
if (u != null && RECOGNIZABLE.matcher(u).find()) return true;
}
return false;
} catch (Exception e) {
// 解析失败按单 URL 兜底
return RECOGNIZABLE.matcher(oss).find();
}
}
return RECOGNIZABLE.matcher(oss).find();
}
/**
* 会务材料多文件数量上限校验 (PUT 全量保存路径): oss_url 存 JSON 数组, 超限在写库前拦截.
* 仅对 SERVICE 材料生效; 劳务/凭证/签到表等各自有独立限制或不受此约束.
*/
private void checkServiceFileCount(BizMeetingMaterial m) {
if (m == null || m.getOssUrl() == null || !"SERVICE".equals(m.getMaterialType())) return;
String oss = m.getOssUrl().trim();
if (!oss.startsWith("[")) return;
try {
JSONArray arr = JSONUtil.parseArray(oss);
if (arr.size() > SERVICE_MAX_FILES) {
throw new ServiceException("会务材料单个分类最多上传 " + SERVICE_MAX_FILES + " 个文件, 当前 " + arr.size() + " 个");
}
} catch (ServiceException se) {
throw se;
} catch (Exception ignored) {
// 非 JSON 数组, 忽略
}
}
// ===================================================================
// 劳务材料"打包上传" (下载空目录模板 + 上传按目录分类回填)
// ===================================================================
@Override
public byte[] buildLaborTemplateZip(Long meetingId, String roleType) {
boolean cameraAllowed = "admin".equals(roleType) || "manager".equals(roleType);
List<BizMeetingAttendee> attendees = attendeeService.selectByMeetingId(meetingId);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
// 顶层说明, 保证 zip 内至少有一个真实文件 (纯空目录的 zip 部分工具无法解压)
zos.putNextEntry(new ZipEntry("README.txt"));
zos.write("请把材料文件放入对应目录后,压缩成zip包并上传".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
for (String label : LABOR_TEMPLATE_CATEGORIES) {
if (LABOR_CAMERA_LABELS.contains(label) && !cameraAllowed) continue; // 非 admin/manager 不含拍照签到目录
zos.putNextEntry(new ZipEntry(label + "/"));
zos.closeEntry();
if ("劳务明细表".equals(label)) {
// 明细表目录内置一张带表头的空白导入模板, 用户按表头填数据后放回即可批量导入
byte[] excelBytes = new ExcelUtil<>(BizMeetingAttendeeImportVo.class)
.importTemplateExcelBytes("参会人导入", null);
zos.putNextEntry(new ZipEntry(label + "/参会人导入模板.xlsx"));
zos.write(excelBytes);
zos.closeEntry();
} else if ("劳务协议".equals(label)) {
// 两种放法都兼容: 平铺 姓名_手机号.pdf 或 序号_姓名/ 子目录
zos.putNextEntry(new ZipEntry(label + "/说明.txt"));
zos.write("协议命名为「姓名_手机号.pdf」直接放在本目录下(如 张三_13800138000.pdf)".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
for (int i = 0; i < attendees.size(); i++) {
String dir = label + "/" + (i + 1) + "_" + laborAttendeeName(attendees.get(i).getName()) + "/";
zos.putNextEntry(new ZipEntry(dir));
zos.closeEntry();
}
} else if ("专家照片".equals(label)) {
zos.putNextEntry(new ZipEntry(label + "/说明.txt"));
zos.write("专家照片请铺放在本目录下,文件名用「姓名_手机号」命名。".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
}
} catch (IOException e) {
throw new ServiceException("生成劳务材料目录模板失败: " + e.getMessage());
}
return baos.toByteArray();
}
@Override
public String submitLaborMaterialsUpload(MultipartFile file, Long meetingId, String roleType) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
String jobId = UUID.randomUUID().toString().replace("-", "");
byte[] zipBytes = file.getBytes();
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
uploadProgressRegistry.start(jobId);
zipUploadExecutor.submit(() -> {
try {
Map<String, Object> result = doUploadLaborMaterials(zipBytes, meetingId, roleType, jobId, userId, username);
uploadProgressRegistry.finish(jobId, result);
}
catch (Exception e) {
log.warn("[material] 劳务材料后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
}
});
return jobId;
}
private Map<String, Object> doUploadLaborMaterials(byte[] zipBytes, Long meetingId, String roleType,
String jobId, Long userId, String username) throws Exception {
boolean cameraAllowed = "admin".equals(roleType) || "manager".equals(roleType);
// 1) 解压 + 按目录名分类
Map<String, List<LaborZipItem>> byCategory = new HashMap<>();
try {
readLaborZip(zipBytes, StandardCharsets.UTF_8, byCategory);
} catch (IllegalArgumentException | MalformedInputException e) {
// 中文 Windows 打包器用 GBK 编码条目名且不置 UTF-8 标志, 回退 GBK 重解析
log.warn("[material] 劳务材料 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
byCategory.clear();
readLaborZip(zipBytes, Charset.forName("GBK"), byCategory);
}
if (byCategory.isEmpty()) throw new ServiceException("未在 zip 中找到劳务材料目录, 请按下载模板的目录结构放置文件");
uploadProgressRegistry.setTotal(jobId, byCategory.size());
int processed = 0;
int updated = 0;
ImportResult detailResult = new ImportResult();
Map<String, Object> agreementResult = new HashMap<>();
Map<String, Object> expertResult = new HashMap<>();
List<String> generalErrors = new ArrayList<>();
// 2) 劳务明细表 → 单遍批量导入 (成功/失败行合并到 detailResult)
List<LaborZipItem> detailItems = byCategory.get("DETAIL");
if (detailItems != null) {
processed++;
boolean foundExcel = false;
for (LaborZipItem it : detailItems) {
if (!isExcelName(it.name)) continue; // 忽略说明.txt 等
foundExcel = true;
try {
ImportResult r = attendeeService.importFromExcelBytes(it.bytes, meetingId, username);
detailResult.setOkNum(detailResult.getOkNum() + r.getOkNum());
detailResult.setNgNum(detailResult.getNgNum() + r.getNgNum());
detailResult.getNgList().addAll(r.getNgList());
updated += r.getOkNum();
} catch (Exception e) {
detailResult.setNgNum(detailResult.getNgNum() + 1);
detailResult.getNgList().add(new ImportResult.NgRow(0, "文件「" + it.name + "」导入失败: "
+ (e.getMessage() == null ? "未知错误" : e.getMessage())));
}
}
if (!foundExcel) generalErrors.add("劳务明细表: 目录内未找到 Excel 文件");
uploadProgressRegistry.update(jobId, processed);
}
// 明细表导入会新增参会人, 这里再取最新名单供协议/照片匹配
List<BizMeetingAttendee> attendees = attendeeService.selectByMeetingId(meetingId);
// 3) 劳务协议 → 双规则匹配回填 (序号_姓名/ 子目录 + 平铺 姓名_手机号.pdf)
List<LaborZipItem> agreementItems = byCategory.get("AGREEMENT");
if (agreementItems != null) {
processed++;
agreementResult = backfillAgreements(agreementItems, attendees, meetingId, username);
updated += ((Number) agreementResult.getOrDefault("updated", 0)).intValue();
uploadProgressRegistry.update(jobId, processed);
}
// 4) 企业权益 → 材料回填
if (byCategory.get("ENTERPRISE_BENEFIT") != null) {
processed++;
updated += backfillLaborMaterial(byCategory.get("ENTERPRISE_BENEFIT"), meetingId, "L_ENTERPRISE_BENEFIT", userId, generalErrors);
uploadProgressRegistry.update(jobId, processed);
}
// 5) 电子签到表 → 复用 esign 追加逻辑
List<LaborZipItem> esignItems = byCategory.get("ESIGN_IN");
if (esignItems != null) {
processed++;
try {
List<FileBytes> excels = collectEsignExcels(esignItems);
if (excels.isEmpty()) {
generalErrors.add("电子签到表: 目录内未找到 Excel 文件");
} else {
Map<String, Object> r = appendEsignInFiles(excels, meetingId, userId);
updated += ((Number) r.getOrDefault("count", 0)).intValue();
}
} catch (Exception e) {
generalErrors.add("电子签到表: " + (e.getMessage() == null ? "处理失败" : e.getMessage()));
}
uploadProgressRegistry.update(jobId, processed);
}
// 6) 签到表/前全景/后全景 → 仅 admin/manager 回填 (其他角色 zip 内这三个目录直接忽略)
if (cameraAllowed) {
updated += backfillLaborMaterial(byCategory.get("SIGN_IN"), meetingId, "L_SIGN_IN", userId, generalErrors);
updated += backfillLaborMaterial(byCategory.get("PANORAMA_FRONT"), meetingId, "L_PANORAMA_FRONT", userId, generalErrors);
updated += backfillLaborMaterial(byCategory.get("PANORAMA_BACK"), meetingId, "L_PANORAMA_BACK", userId, generalErrors);
}
for (String c : new String[]{"SIGN_IN", "PANORAMA_FRONT", "PANORAMA_BACK"}) {
if (byCategory.get(c) != null) {
processed++;
uploadProgressRegistry.update(jobId, processed);
}
}
// 7) 专家照片 → 目录匹配回填 on_site_photos
List<LaborZipItem> expertItems = byCategory.get("EXPERT_PHOTO");
if (expertItems != null) {
processed++;
expertResult = backfillExpertPhotos(expertItems, attendees, meetingId, username);
updated += ((Number) expertResult.getOrDefault("updated", 0)).intValue();
uploadProgressRegistry.update(jobId, processed);
}
// 8) 日程海报 / 邀请函 → 单文件回填到会议字段
if (byCategory.get("SCHEDULE") != null) {
processed++;
updated += backfillSingleMeetingFile(byCategory.get("SCHEDULE"), meetingId, username, true, "日程海报", generalErrors);
uploadProgressRegistry.update(jobId, processed);
}
if (byCategory.get("INVITATION") != null) {
processed++;
updated += backfillSingleMeetingFile(byCategory.get("INVITATION"), meetingId, username, false, "邀请函", generalErrors);
uploadProgressRegistry.update(jobId, processed);
}
// 9) 汇总结果 (前端按 detail/agreement/expertPhoto 三个 tab 展示失败, errors 为其他目录错误)
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("detail", detailResult);
result.put("agreement", agreementResult);
result.put("expertPhoto", expertResult);
result.put("errors", generalErrors);
log.info("[material] 劳务材料回填完成 meetingId={} updated={} detailNg={} agreementFail={} expertFail={}",
meetingId, updated, detailResult.getNgNum(),
((List<?>) agreementResult.getOrDefault("failures", Collections.emptyList())).size(),
((List<?>) expertResult.getOrDefault("failures", Collections.emptyList())).size());
return result;
}
/** 解压劳务 zip, 按路径段命中 10 个目录名之一归类文件 (保留完整路径供 序号_姓名 目录匹配) */
private void readLaborZip(byte[] zipBytes, Charset charset, Map<String, List<LaborZipItem>> byCategory) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) continue;
String path = entry.getName();
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
String category = matchLaborCategory(path);
if (category == null) continue; // 不在劳务目录下, 忽略
byte[] data = readAllBytes(zis);
if (data.length == 0) continue;
byCategory.computeIfAbsent(category, k -> new ArrayList<>()).add(new LaborZipItem(path, lastSegment(path), data));
}
}
}
/** 从 zip 条目路径里匹配劳务类别 (扫描路径段命中目录名); 不匹配返回 null */
private static String matchLaborCategory(String path) {
if (path == null || path.isEmpty()) return null;
for (String seg : path.split("/")) {
if (seg.isEmpty()) continue;
String c = LABOR_CATEGORY_BY_LABEL.get(seg);
if (c != null) return c;
}
return null;
}
/** 劳务协议回填: 先按 序号_姓名/ 子目录匹配 (沿用协议批量逻辑), 再按平铺 姓名_手机号.pdf 匹配; 两者可混用 */
private Map<String, Object> backfillAgreements(List<LaborZipItem> items, List<BizMeetingAttendee> attendees,
Long meetingId, String username) throws Exception {
LinkedHashMap<String, List<LaborZipItem>> dirGroups = new LinkedHashMap<>();
List<LaborZipItem> flatItems = new ArrayList<>();
for (LaborZipItem it : items) {
if (isIgnorableName(it.name)) continue;
String[] token = parseAgreementDirSegment(it.path);
if (token != null) {
dirGroups.computeIfAbsent(token[0] + "_" + token[1], k -> new ArrayList<>()).add(it);
} else {
flatItems.add(it);
}
}
int updated = 0;
List<Map<String, Object>> failures = new ArrayList<>();
String subDir = "ry8080/meeting/" + meetingId + "/labor-protocol";
// 子目录模式: 序号 + 姓名 严格匹配 (名单变动/重名错位 → 跳过并给出原因)
for (Map.Entry<String, List<LaborZipItem>> e : dirGroups.entrySet()) {
String dir = e.getKey();
int seq = Integer.parseInt(dir.substring(0, dir.indexOf('_')));
String name = dir.substring(dir.indexOf('_') + 1);
BizMeetingAttendee a = matchAttendeeBySeqName(attendees, seq, name);
if (a == null) {
failures.add(failEntry(name, "目录[" + dir + "] 无法定位参会人"));
continue;
}
// 劳务协议可多文件: 目录内所有文件都上传 OSS (不打包 zip), 逗号拼接覆盖式回填 labor_protocol, 前端按逗号拆分逐个预览
StringBuilder sb = new StringBuilder();
for (LaborZipItem it : e.getValue()) {
String url = ossUploader.uploadWithOriginalName(it.bytes, it.name, subDir);
if (sb.length() > 0) sb.append(",");
sb.append(url);
}
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId());
entity.setLaborProtocol(sb.toString());
entity.setUpdateBy(username);
attendeeService.updateLaborProtocol(entity);
updated++;
}
// 平铺模式: 姓名_手机号.pdf
for (LaborZipItem it : flatItems) {
String[] np = parseFlatNamePhone(it.name);
if (np == null) {
failures.add(failEntry(it.name, "文件名应为「姓名_手机号.pdf」"));
continue;
}
BizMeetingAttendee a = matchAttendeeByNamePhone(attendees, np[0], np[1]);
if (a == null) {
failures.add(failEntry(np[0], "无法匹配参会人: 姓名=" + np[0] + " 手机号=" + np[1]));
continue;
}
String url = ossUploader.upload(it.bytes, it.name, subDir);
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId());
entity.setLaborProtocol(url);
entity.setUpdateBy(username);
attendeeService.updateLaborProtocol(entity);
updated++;
}
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", attendees.size());
result.put("failures", failures);
return result;
}
/** 专家照片回填: 两种放法都兼容 (序号_姓名/ 子目录 + 平铺 姓名_手机号[_后缀]), 全部照片逗号拼接覆盖式回填 on_site_photos */
private Map<String, Object> backfillExpertPhotos(List<LaborZipItem> items, List<BizMeetingAttendee> attendees,
Long meetingId, String username) throws Exception {
LinkedHashMap<Long, List<LaborZipItem>> groups = new LinkedHashMap<>();
List<Map<String, Object>> failures = new ArrayList<>();
for (LaborZipItem it : items) {
if (isIgnorableName(it.name)) continue;
BizMeetingAttendee a = null;
// 子目录模式: 序号 + 姓名 严格匹配 (沿用原有放法)
String[] token = parseAgreementDirSegment(it.path);
if (token != null) {
a = matchAttendeeBySeqName(attendees, Integer.parseInt(token[0]), token[1]);
if (a == null) failures.add(failEntry(token[1], "目录[" + token[0] + "_" + token[1] + "] 无法定位参会人"));
} else {
// 平铺模式: 姓名_手机号[.ext] / 姓名_手机号_后缀[.ext]
String[] np = parseFlatNamePhonePrefix(it.name);
if (np == null) {
failures.add(failEntry(it.name, "文件名应为「姓名_手机号」, 如 张三_13800138000.jpg"));
} else {
a = matchAttendeeByNamePhone(attendees, np[0], np[1]);
if (a == null) failures.add(failEntry(np[0], "无法匹配参会人: 姓名=" + np[0] + " 手机号=" + np[1]));
}
}
if (a != null) groups.computeIfAbsent(a.getId(), k -> new ArrayList<>()).add(it);
}
int updated = 0;
String subDir = "ry8080/meeting/" + meetingId + "/expert-photo";
for (Map.Entry<Long, List<LaborZipItem>> e : groups.entrySet()) {
StringBuilder sb = new StringBuilder();
for (LaborZipItem it : e.getValue()) {
String url = ossUploader.upload(it.bytes, it.name, subDir);
if (sb.length() > 0) sb.append(",");
sb.append(url);
}
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(e.getKey());
entity.setOnSitePhotos(sb.toString());
entity.setUpdateBy(username);
attendeeService.updateProfile(entity);
updated++;
}
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", attendees.size());
result.put("failures", failures);
return result;
}
/** 非发票劳务材料回填 (企业权益/签到表/前全景/后全景): 单文件直传 / 多文件打 zip, fee_status=1 不参与费用汇总 */
private int backfillLaborMaterial(List<LaborZipItem> items, Long meetingId, String subType, Long userId, List<String> errors) {
if (items == null || items.isEmpty()) return 0;
List<byte[]> files = new ArrayList<>();
List<String> names = new ArrayList<>();
for (LaborZipItem it : items) {
if (isIgnorableName(it.name)) continue;
files.add(it.bytes);
names.add(it.name);
}
if (files.isEmpty()) return 0;
String label = LABOR_SUBTYPE_LABEL.getOrDefault(subType, subType);
String fileName;
byte[] toUpload;
if (files.size() == 1) {
fileName = names.get(0);
toUpload = files.get(0);
} else {
fileName = label + ".zip";
try {
toUpload = zipFiles(files, names);
} catch (IOException e) {
errors.add(label + ": 多文件打 zip 失败 " + e.getMessage());
return 0;
}
}
try {
String ossUrl = ossUploader.upload(toUpload, fileName, "ry8080/meeting/" + meetingId + "/labor");
BizMeetingMaterial old = bizMeetingMaterialMapper.selectByMeetingAndSubType(meetingId, subType);
if (old != null) {
bizMeetingMaterialMapper.updateFile(old.getId(), ossUrl, fileName, 1);
} else {
BizMeetingMaterial ins = new BizMeetingMaterial();
ins.setMeetingId(meetingId);
ins.setMaterialType("LABOR");
ins.setSubType(subType);
ins.setOssUrl(ossUrl);
ins.setFileName(fileName);
ins.setAmount(null);
ins.setFeeStatus(1);
ins.setCreatorId(userId);
ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins));
}
return 1;
} catch (Exception e) {
errors.add(label + ": " + (e.getMessage() == null ? "上传失败" : e.getMessage()));
return 0;
}
}
/** 日程海报/邀请函 单文件回填到 biz_meeting 字段; 多文件 → 报错跳过 */
private int backfillSingleMeetingFile(List<LaborZipItem> items, Long meetingId, String username,
boolean schedule, String label, List<String> errors) {
if (items == null || items.isEmpty()) return 0;
List<LaborZipItem> real = new ArrayList<>();
for (LaborZipItem it : items) {
if (isIgnorableName(it.name)) continue;
real.add(it);
}
if (real.isEmpty()) return 0;
if (real.size() > 1) {
errors.add(label + ": 该目录只能放一个文件, 当前有 " + real.size() + " 个, 已跳过");
return 0;
}
LaborZipItem it = real.get(0);
try {
String ossUrl = ossUploader.upload(it.bytes, it.name,
"ry8080/meeting/" + meetingId + "/" + (schedule ? "schedule" : "invitation"));
BizMeeting upd = new BizMeeting();
upd.setMeetingId(meetingId);
if (schedule) upd.setScheduleUrl(ossUrl); else upd.setInvitationUrl(ossUrl);
upd.setUpdateBy(username);
upd.setUpdateTime(new Date());
bizMeetingMapper.updateByPrimaryKey(upd);
return 1;
} catch (Exception e) {
errors.add(label + ": " + (e.getMessage() == null ? "上传失败" : e.getMessage()));
return 0;
}
}
/** 电子签到表目录 → 收集所有 Excel (含嵌套 zip 内 Excel), 其他文件忽略 */
private List<FileBytes> collectEsignExcels(List<LaborZipItem> items) throws Exception {
List<FileBytes> excels = new ArrayList<>();
for (LaborZipItem it : items) {
if (isIgnorableName(it.name)) continue;
if (isZipName(it.name)) {
excels.addAll(unzipExcelFiles(it.bytes));
} else if (isExcelName(it.name)) {
excels.add(new FileBytes(it.name, it.bytes));
}
}
return excels;
}
/** 忽略说明/元数据类文件名 (说明.txt/README/.DS_Store 等) */
private static boolean isIgnorableName(String name) {
if (name == null) return true;
String n = name.trim();
return n.isEmpty() || n.startsWith(".") || n.startsWith("__")
|| "说明.txt".equals(n) || "readme.txt".equalsIgnoreCase(n) || "thumbs.db".equalsIgnoreCase(n);
}
/** 在 zip 路径的目录段 (不含末段文件名) 里找 {序号}_姓名 → [序号, 姓名]; 找不到返回 null */
private static String[] parseAgreementDirSegment(String path) {
if (path == null || path.isEmpty()) return null;
String[] segs = path.split("/");
if (segs.length < 2) return null;
for (int i = segs.length - 2; i >= 0; i--) {
String[] t = attendeeTokenOf(segs[i]);
if (t != null) return t;
}
return null;
}
/** 从单个路径段解析 {序号}_姓名 → [序号数字, 姓名段]; 不匹配返回 null */
private static String[] attendeeTokenOf(String s) {
if (s == null || s.isEmpty()) return null;
int underscore = s.indexOf('_');
if (underscore <= 0) return null;
String numPart = s.substring(0, underscore);
if (!numPart.matches("\\d+")) return null;
return new String[] { numPart, s.substring(underscore + 1) };
}
/** 解析平铺文件名 姓名_手机号[.ext] → [姓名, 手机号]; 不匹配返回 null */
private static String[] parseFlatNamePhone(String fileName) {
if (fileName == null || fileName.isEmpty()) return null;
String stem = fileName;
int dot = stem.lastIndexOf('.');
if (dot > 0) stem = stem.substring(0, dot);
Matcher m = Pattern.compile("^(.*)_(1\\d{10})$").matcher(stem);
if (!m.find()) return null;
return new String[] { m.group(1), m.group(2) };
}
/** 解析平铺文件名 姓名_手机号[_后缀][.ext] → [姓名, 手机号] (照片同人多张可加后缀); 找不到 1 开头的 11 位手机号返回 null */
private static String[] parseFlatNamePhonePrefix(String fileName) {
if (fileName == null || fileName.isEmpty()) return null;
String stem = fileName;
int dot = stem.lastIndexOf('.');
if (dot > 0) stem = stem.substring(0, dot);
Matcher m = Pattern.compile("1\\d{10}").matcher(stem);
if (!m.find()) return null;
String name = stem.substring(0, m.start()).replaceAll("[_\\-\\s]+$", "");
if (name.isEmpty()) return null;
return new String[] { name, m.group() };
}
/** 序号 (order by id 的 1-based 下标) + 姓名 严格匹配; 不符返回 null */
private static BizMeetingAttendee matchAttendeeBySeqName(List<BizMeetingAttendee> list, int seq, String name) {
int idx = seq - 1;
if (idx < 0 || idx >= list.size()) return null;
BizMeetingAttendee a = list.get(idx);
if (!laborAttendeeName(name).equals(laborAttendeeName(a.getName()))) return null;
return a;
}
/** 姓名 + 手机号 精确匹配 (平铺协议文件名解析) */
private static BizMeetingAttendee matchAttendeeByNamePhone(List<BizMeetingAttendee> list, String name, String phone) {
String want = laborAttendeeName(name);
for (BizMeetingAttendee a : list) {
if (phone.equals(a.getPhone()) && want.equals(laborAttendeeName(a.getName()))) return a;
}
return null;
}
/** 目录名里的姓名段剔除非法字符, 避免 Windows 解压异常 (空值 → 未知) */
private static String laborAttendeeName(String name) {
if (name == null || name.trim().isEmpty()) return "未知";
return safeName(name);
}
/** 失败明细条目 (name + reason) */
private static Map<String, Object> failEntry(String name, String reason) {
Map<String, Object> m = new HashMap<>();
m.put("name", name);
m.put("reason", reason);
return m;
}
/** 劳务"打包上传" zip 解出的一个文件条目 (保留完整路径用于 序号_姓名 目录匹配) */
private static final class LaborZipItem {
final String path;
final String name;
final byte[] bytes;
LaborZipItem(String path, String name, byte[] bytes) { this.path = path; this.name = name; this.bytes = bytes; }
} }
// =================================================================== // ===================================================================
@@ -1230,6 +1852,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
if (!isExcelName(originalName)) throw new ServiceException("仅支持 xls / xlsx / csv 或 zip 文件"); if (!isExcelName(originalName)) throw new ServiceException("仅支持 xls / xlsx / csv 或 zip 文件");
files.add(new FileBytes(originalName, bytes)); files.add(new FileBytes(originalName, bytes));
} }
return appendEsignInFiles(files, meetingId, SecurityUtils.getUserId());
}
/** 电子签到表追加保存核心 (parse all → upload all → merge → delete+insert), 供单文件上传与劳务"打包上传"复用. */
private Map<String, Object> appendEsignInFiles(List<FileBytes> files, Long meetingId, Long userId) throws Exception {
if (files.size() > ESIGN_MAX_FILES) throw new ServiceException("一次最多上传 " + ESIGN_MAX_FILES + " 个签到表文件"); if (files.size() > ESIGN_MAX_FILES) throw new ServiceException("一次最多上传 " + ESIGN_MAX_FILES + " 个签到表文件");
// 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件) // 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件)
@@ -1286,11 +1913,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
ins.setMaterialType("LABOR"); ins.setMaterialType("LABOR");
ins.setSubType("L_ESIGN_IN"); ins.setSubType("L_ESIGN_IN");
ins.setOssUrl(mergedOssUrl); ins.setOssUrl(mergedOssUrl);
ins.setFileName(originalName); ins.setFileName(files.get(0).name);
ins.setMaskedJson(mergedMaskedJson); ins.setMaskedJson(mergedMaskedJson);
ins.setAmount(null); ins.setAmount(null);
ins.setFeeStatus(1); // 非发票, 不参与费用汇总 ins.setFeeStatus(1); // 非发票, 不参与费用汇总
ins.setCreatorId(SecurityUtils.getUserId()); ins.setCreatorId(userId);
ins.setCreateTime(new Date()); ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insert(ins); bizMeetingMaterialMapper.insert(ins);
@@ -2,9 +2,13 @@ package com.ruoyi.business.service.impl;
import java.io.File; import java.io.File;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONUtil;
import com.ruoyi.business.service.IBizMeetingInvoiceService; import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
@@ -91,6 +95,9 @@ public class InvoiceOcrService
} }
// 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参 // 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参
boolean isZip = ossUrl.toLowerCase().endsWith(".zip"); boolean isZip = ossUrl.toLowerCase().endsWith(".zip");
// 是否多文件 (JSON 数组字符串, 会务材料多文件直传): 以 [ 开头
boolean isMulti = !isZip && ossUrl.trim().startsWith("[");
final List<String> multiUrls = isMulti ? parseUrlList(ossUrl) : null;
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0) // 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
if (oldMaterialId != null) if (oldMaterialId != null)
@@ -100,8 +107,8 @@ public class InvoiceOcrService
materialService.updateAmount(oldMaterialId, BigDecimal.ZERO); materialService.updateAmount(oldMaterialId, BigDecimal.ZERO);
} }
// 2. 单文件: 插 UNRECOGNIZED 占位行 (zip 路径跳过 — 解压才知道有几张) // 2. 单文件: 插 UNRECOGNIZED 占位行 (zip / 多文件 路径跳过 — 解压/遍历才知道有几张)
if (!isZip) if (!isZip && !isMulti)
{ {
BizMeetingInvoice placeholder = new BizMeetingInvoice(); BizMeetingInvoice placeholder = new BizMeetingInvoice();
placeholder.setMeetingId(meetingId); placeholder.setMeetingId(meetingId);
@@ -115,7 +122,7 @@ public class InvoiceOcrService
invoiceMapper.insert(placeholder); invoiceMapper.insert(placeholder);
} }
// 3. 后台执行 OCR (单文件或 zip) // 3. 后台执行 OCR (单文件 / zip / 多文件)
final String url = ossUrl; final String url = ossUrl;
ocrExecutor.submit(() -> ocrExecutor.submit(() ->
{ {
@@ -125,6 +132,10 @@ public class InvoiceOcrService
{ {
recognizeZip(materialId, meetingId, url, creatorId); recognizeZip(materialId, meetingId, url, creatorId);
} }
else if (isMulti)
{
recognizeMultiple(materialId, meetingId, multiUrls, creatorId);
}
else else
{ {
recognizeSingle(materialId, url); recognizeSingle(materialId, url);
@@ -132,8 +143,8 @@ public class InvoiceOcrService
} }
catch (Exception e) catch (Exception e)
{ {
log.warn("OCR 后台任务失败 materialId={} isZip={} err={}", materialId, isZip, e.getMessage(), e); log.warn("OCR 后台任务失败 materialId={} isZip={} isMulti={} err={}", materialId, isZip, isMulti, e.getMessage(), e);
if (!isZip) if (!isZip && !isMulti)
{ {
// 单文件: 占位行标 FAILED // 单文件: 占位行标 FAILED
invoiceMapper.updateStatusByMaterial(materialId, "FAILED", invoiceMapper.updateStatusByMaterial(materialId, "FAILED",
@@ -276,6 +287,93 @@ public class InvoiceOcrService
} }
} }
/**
* 多文件 OCR (会务材料 oss_url 存 JSON 数组): 逐个 URL 识别, 是发票 → 写 invoice 行 (直接复用原 URL, 无需重传),
* 累加 amount → material.amount = 总和. 不是发票 → 跳过 (不入 invoice 表).
*/
void recognizeMultiple(Long materialId, Long meetingId, List<String> urls, Long creatorId)
{
if (urls == null || urls.isEmpty()) return;
BigDecimal total = BigDecimal.ZERO;
int invoiceCount = 0;
int skipCount = 0;
int failCount = 0;
for (String u : urls)
{
try
{
InvoiceResult ir = recognizer.recognizeByUrl(u);
if (!Boolean.TRUE.equals(ir.getSuccess()) || ir.getFields() == null
|| !isRecognizedAsInvoice(ir.getFields()))
{
// 不是发票: 跳过, 不入库
skipCount++;
continue;
}
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
BizMeetingInvoice inv = new BizMeetingInvoice();
inv.setMeetingId(meetingId);
inv.setMaterialId(materialId);
inv.setOssUrl(u);
inv.setInvoiceType("SUB");
inv.setAmount(amount);
inv.setInvoiceNo(ir.getFields().getInvoiceNo());
inv.setInvoiceDate(ir.getFields().getInvoiceDate());
inv.setRecognizeStatus("RECOGNIZED");
inv.setSourceFilename(lastUrlSegment(u));
inv.setCreatorId(creatorId);
inv.setCreateTime(new Date());
inv.setUpdateTime(new Date());
invoiceMapper.insert(inv);
total = total.add(amount);
invoiceCount++;
}
catch (Exception e)
{
log.warn("多文件子文件识别失败 url={} err={}", u, e.getMessage());
failCount++;
}
}
// material.amount 只在"识别出至少 1 张发票"时回写求和; 0 张时不动 material
if (invoiceCount > 0)
{
materialService.updateAmount(materialId, total);
}
log.info("多文件 OCR 完成 materialId={} invoice={} skip={} fail={} total={}",
materialId, invoiceCount, skipCount, failCount, total);
}
/** JSON 数组字符串 (会务材料多文件) → OSS URL 列表; 解析失败返回空列表 */
private List<String> parseUrlList(String jsonUrls)
{
List<String> urls = new ArrayList<>();
try
{
JSONArray arr = JSONUtil.parseArray(jsonUrls);
for (int i = 0; i < arr.size(); i++)
{
String u = arr.getStr(i);
if (u != null && !u.isEmpty()) urls.add(u);
}
}
catch (Exception e)
{
log.warn("解析多文件 oss_url 失败: {}", jsonUrls);
}
return urls;
}
/** 取 URL 末段文件名 (去掉 query) */
private static String lastUrlSegment(String url)
{
if (url == null) return "";
String u = url.split("\\?")[0];
int i = u.lastIndexOf('/');
return i >= 0 ? u.substring(i + 1) : u;
}
/** /**
* 兜底 OCR (InvoiceOcrScheduler 调用): 已知 invoice 行 (UNRECOGNIZED), 重新识别 * 兜底 OCR (InvoiceOcrScheduler 调用): 已知 invoice 行 (UNRECOGNIZED), 重新识别
* <p> * <p>
@@ -308,6 +308,7 @@
where is_deleted = 0 where is_deleted = 0
and is_executed = 0 and is_executed = 0
and is_frozen = 0 and is_frozen = 0
and is_finished = 0
and start_time is not null and start_time is not null
and start_time &lt;= NOW() and start_time &lt;= NOW()
and end_time &gt; NOW() and end_time &gt; NOW()
@@ -323,6 +324,7 @@
where is_deleted = 0 where is_deleted = 0
and is_executed = 0 and is_executed = 0
and is_frozen = 0 and is_frozen = 0
and is_finished = 0
and end_time is not null and end_time is not null
and end_time &lt;= NOW() and end_time &lt;= NOW()
and labor_audit_stage = 'NOT_SUBMITTED' and labor_audit_stage = 'NOT_SUBMITTED'
@@ -337,6 +339,7 @@
current_stage = 'FROZEN' current_stage = 'FROZEN'
where is_deleted = 0 where is_deleted = 0
and is_frozen = 0 and is_frozen = 0
and is_finished = 0
and (labor_audit_stage in ('NOT_SUBMITTED', 'REJECTED') and (labor_audit_stage in ('NOT_SUBMITTED', 'REJECTED')
or service_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
@@ -56,10 +56,11 @@
(select count(*) from biz_meeting m (select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0 where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions, and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
<!-- 未执行会议数: 仅 NOT_STARTED --> <!-- 未执行 = 总数 - 已执行 (含未建会议/执行中的场次) -->
(select count(*) from biz_meeting m (greatest(0, p.total_sessions
where m.project_id = p.project_id and m.is_deleted = 0 - (select count(*) from biz_meeting m
and m.current_stage = 'NOT_STARTED') as todo_sessions, where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')))) as todo_sessions,
(select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount, (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount, (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0) (p.total_amount - ifnull(p.manage_fee, 0)
@@ -90,9 +91,10 @@
(select count(*) from biz_meeting m (select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0 where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions, and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
(select count(*) from biz_meeting m (greatest(0, p.total_sessions
where m.project_id = p.project_id and m.is_deleted = 0 - (select count(*) from biz_meeting m
and m.current_stage = 'NOT_STARTED') as todo_sessions, where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')))) as todo_sessions,
(select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount, (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount, (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0) (p.total_amount - ifnull(p.manage_fee, 0)
@@ -238,11 +240,15 @@
where m.project_id = p.project_id and m.is_deleted = 0 where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions, and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions,
<!-- 执行方级未执行 --> <!-- 执行方级未执行 = 分给本执行方的场次 - 已执行 (含未建会议/执行中的场次) -->
(select count(*) from biz_meeting m (greatest(0, (select coalesce(sum(bpa.sessions), 0)
where m.project_id = p.project_id and m.is_deleted = 0 from biz_project_assign bpa
and m.current_stage = 'NOT_STARTED' where bpa.project_id = p.project_id and bpa.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions and bpa.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
- (select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')))) as todo_sessions
from biz_project p from biz_project p
<!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 --> <!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
join biz_project_assign a on a.project_id = p.project_id join biz_project_assign a on a.project_id = p.project_id
@@ -329,10 +335,14 @@
where m.project_id = p.project_id and m.is_deleted = 0 where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS') and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions, and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions,
(select count(*) from biz_meeting m (greatest(0, (select coalesce(sum(bpa.sessions), 0)
where m.project_id = p.project_id and m.is_deleted = 0 from biz_project_assign bpa
and m.current_stage = 'NOT_STARTED' where bpa.project_id = p.project_id and bpa.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions and bpa.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
- (select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')))) as todo_sessions
from biz_project p from biz_project p
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor' left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id left join sys_user su on su.user_id = o.user_id
@@ -58,7 +58,9 @@ public class SysUser extends BaseEntity
/** 删除标志(0代表存在 2代表删除) */ /** 删除标志(0代表存在 2代表删除) */
private String delFlag; private String delFlag;
/** 账号类型 (MAIN=主账号 / SUB=子账号) */ /** 账号类型 (MAIN=主账号 / SUB=子账号, null 视为 SUB). 按角色语义复用:
* executor/sponsor: MAIN=机构主账号 / SUB=机构子账号(挂 parent_user_id);
* manager: MAIN=全局管理员(可见全量) / SUB 或 null=普通合规员(只看本人创建, parent_user_id 恒 NULL) */
@Excel(name = "账号类型") @Excel(name = "账号类型")
private String accountType; private String accountType;
@@ -70,7 +70,7 @@ public class HttpUtils
try try
{ {
String urlNameString = StringUtils.isNotBlank(param) ? url + "?" + param : url; String urlNameString = StringUtils.isNotBlank(param) ? url + "?" + param : url;
log.info("sendGet - {}", urlNameString); log.debug("sendGet - {}", urlNameString);
URL realUrl = new URL(urlNameString); URL realUrl = new URL(urlNameString);
URLConnection connection = realUrl.openConnection(); URLConnection connection = realUrl.openConnection();
// 连接/读超时: 防止外部接口不可达时无限挂起 (后台子任务会占死单线程调度器) // 连接/读超时: 防止外部接口不可达时无限挂起 (后台子任务会占死单线程调度器)
@@ -86,7 +86,7 @@ public class HttpUtils
{ {
result.append(line); result.append(line);
} }
log.info("recv - {}", result); log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200)));
} }
catch (ConnectException e) catch (ConnectException e)
{ {
@@ -148,7 +148,7 @@ public class HttpUtils
StringBuilder result = new StringBuilder(); StringBuilder result = new StringBuilder();
try try
{ {
log.info("sendPost - {}", url); log.debug("sendPost - {}", url);
URL realUrl = new URL(url); URL realUrl = new URL(url);
URLConnection conn = realUrl.openConnection(); URLConnection conn = realUrl.openConnection();
conn.setRequestProperty("accept", "*/*"); conn.setRequestProperty("accept", "*/*");
@@ -167,7 +167,7 @@ public class HttpUtils
{ {
result.append(line); result.append(line);
} }
log.info("recv - {}", result); log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200)));
} }
catch (ConnectException e) catch (ConnectException e)
{ {
@@ -217,7 +217,7 @@ public class HttpUtils
String urlNameString = url + "?" + param; String urlNameString = url + "?" + param;
try try
{ {
log.info("sendSSLPost - {}", urlNameString); log.debug("sendSSLPost - {}", urlNameString);
SSLContext sc = SSLContext.getInstance("SSL"); SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[] { new TrustAnyTrustManager() }, new java.security.SecureRandom()); sc.init(null, new TrustManager[] { new TrustAnyTrustManager() }, new java.security.SecureRandom());
URL console = new URL(urlNameString); URL console = new URL(urlNameString);
@@ -243,7 +243,7 @@ public class HttpUtils
result.append(new String(ret.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8)); result.append(new String(ret.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8));
} }
} }
log.info("recv - {}", result); log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200)));
conn.disconnect(); conn.disconnect();
br.close(); br.close();
} }
@@ -1,5 +1,6 @@
package com.ruoyi.common.utils.poi; package com.ruoyi.common.utils.poi;
import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
@@ -746,6 +747,36 @@ public class ExcelUtil<T>
exportExcel(response); exportExcel(response);
} }
/**
* 生成导入模板 Excel 的字节 (仅表头, 无数据行), 用于打进 zip 模板.
*
* @param sheetName 工作表的名称
* @param title 标题
* @return Excel 字节
*/
public byte[] importTemplateExcelBytes(String sheetName, String title)
{
ByteArrayOutputStream out = null;
try
{
this.init(null, sheetName, title, Type.IMPORT);
writeSheet();
out = new ByteArrayOutputStream();
wb.write(out);
return out.toByteArray();
}
catch (Exception e)
{
log.error("导出Excel异常{}", e.getMessage());
throw new UtilException("导出Excel失败,请联系网站管理员!");
}
finally
{
IOUtils.closeQuietly(wb);
IOUtils.closeQuietly(out);
}
}
/** /**
* 对list数据源将其里面的数据导入到excel表单 * 对list数据源将其里面的数据导入到excel表单
* *
@@ -17,6 +17,7 @@ import org.springframework.web.filter.CorsFilter;
import com.ruoyi.framework.config.properties.PermitAllUrlProperties; import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter; import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
import com.ruoyi.framework.security.filter.LeaderReadOnlyFilter; import com.ruoyi.framework.security.filter.LeaderReadOnlyFilter;
import com.ruoyi.framework.security.handle.AccessDeniedHandlerImpl;
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl; import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl; import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
@@ -32,6 +33,8 @@ public class SecurityConfig
@Autowired @Autowired
private AuthenticationEntryPointImpl unauthorizedHandler; private AuthenticationEntryPointImpl unauthorizedHandler;
@Autowired @Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler; private LogoutSuccessHandlerImpl logoutSuccessHandler;
@Autowired @Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter; private JwtAuthenticationTokenFilter authenticationTokenFilter;
@@ -54,7 +57,7 @@ public class SecurityConfig
.headers((headersCustomizer) -> { .headers((headersCustomizer) -> {
headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin()); headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
}) })
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler).accessDeniedHandler(accessDeniedHandler))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((requests) -> { .authorizeHttpRequests((requests) -> {
permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll()); permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll());
@@ -0,0 +1,33 @@
package com.ruoyi.framework.security.handle;
import java.io.IOException;
import java.io.Serializable;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.ServletUtils;
/**
* 授权失败处理类 返回 403
*
* @author ruoyi
*/
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler, Serializable
{
private static final long serialVersionUID = -8970718410437077606L;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException
{
int code = HttpStatus.FORBIDDEN;
String msg = "没有权限,请联系管理员授权";
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
@@ -37,7 +37,7 @@ public class GlobalExceptionHandler
public AjaxResult handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) public AjaxResult handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request)
{ {
String requestURI = request.getRequestURI(); String requestURI = request.getRequestURI();
log.error("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage()); log.warn("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage());
return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权"); return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
} }
@@ -239,6 +239,16 @@ public interface ISysUserService
*/ */
public int updateRoleType(Long userId, String roleType); public int updateRoleType(Long userId, String roleType);
/**
* 切换合规人员(manager)的「全局管理员」标记 (复用 account_type: MAIN=全局管理员可见全量 / SUB=普通合规员只看本人创建).
* 仅对 role_type=manager 生效, 其它角色抛异常 (executor/sponsor 的 account_type 走「更换管理员」流程).
*
* @param userId 用户ID
* @param accountType 'MAIN' / 'SUB'
* @return 影响行数
*/
public int updateManagerAdmin(Long userId, String accountType);
/** /**
* 导入用户数据 * 导入用户数据
* *
@@ -231,6 +231,28 @@ public class SysUserServiceImpl implements ISysUserService
return userMapper.updateRoleType(userId, roleType); return userMapper.updateRoleType(userId, roleType);
} }
/**
* 切换合规人员(manager)的「全局管理员」标记 (复用 account_type).
* 仅 manager 可切; parent_user_id 保持 NULL (manager 的 SUB 只是「受限」标记, 非真子账号).
* null 相当于 SUB (普通合规员), 兼容历史 null 数据.
*/
@Override
public int updateManagerAdmin(Long userId, String accountType)
{
SysUser target = userMapper.selectUserById(userId);
if (target == null)
{
throw new ServiceException("用户不存在");
}
if (!"manager".equals(target.getRoleType()))
{
throw new ServiceException("仅合规人员可切换全局管理员");
}
// 仅 MAIN 视为全局管理员; 其余 (null / SUB / '') 一律归一为 SUB
String effective = "MAIN".equals(accountType) ? "MAIN" : "SUB";
return userMapper.updateAccountType(userId, effective, null);
}
/** /**
* 校验手机号是否已注册 * 校验手机号是否已注册
*/ */
+1 -1
View File
@@ -45,7 +45,7 @@ create table sys_user (
user_name varchar(30) not null comment '用户账号', user_name varchar(30) not null comment '用户账号',
nick_name varchar(30) not null comment '用户昵称', nick_name varchar(30) not null comment '用户昵称',
user_type varchar(2) default '00' comment '用户类型(00系统用户)', user_type varchar(2) default '00' comment '用户类型(00系统用户)',
account_type varchar(8) default 'MAIN' comment '账号类型 (MAIN=主账号 / SUB=子账号)', account_type varchar(8) default 'MAIN' comment '账号类型 (MAIN=主账号/全局管理员 / SUB=子账号/受限, null 视为 SUB; 按角色复用: executor/sponsor=机构主/子账号, manager=全局管理员/普通合规员)',
parent_user_id bigint(20) default null comment '主账号ID (子账号指向其主账号的 user_id)', parent_user_id bigint(20) default null comment '主账号ID (子账号指向其主账号的 user_id)',
email varchar(50) default '' comment '用户邮箱', email varchar(50) default '' comment '用户邮箱',
phonenumber varchar(11) default '' comment '手机号码', phonenumber varchar(11) default '' comment '手机号码',
+2 -2
View File
@@ -84,10 +84,10 @@
import { useCamera } from '@/composables/useCamera' import { useCamera } from '@/composables/useCamera'
const videoId = 'signin-video' const videoId = 'signin-video'
// A4 纸: 从顶边到底边的一竖条, 水平位置在 60%~70% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏 // A4 纸: 从顶边到底边的一竖条, 水平位置在 75%~85% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
// 签到表支持连拍多张: shots 攒批 + 提交全部 // 签到表支持连拍多张: shots 攒批 + 提交全部
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, shots, addShot, removeShot, submitAll } = const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, shots, addShot, removeShot, submitAll } =
useCamera(videoId, { start: 0.6, end: 0.7, radius: 20 }) useCamera(videoId, { start: 0.75, end: 0.85, radius: 20 })
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
+1 -1
View File
@@ -1,7 +1,7 @@
# 测试环境 API 基路径 (nginx 反向代理前缀) # 测试环境 API 基路径 (nginx 反向代理前缀)
VITE_APP_BASE_API = '/hg-api' VITE_APP_BASE_API = '/hg-api'
# 测试环境静态资源子路径 (nginx 部署在 /hegui/) # 测试环境静态资源子路径 (nginx 部署在 /hg/)
VITE_BASE = '/hg/' VITE_BASE = '/hg/'
# 执行方(供应商)注册跳转地址 (测试环境) # 执行方(供应商)注册跳转地址 (测试环境)
+38
View File
@@ -38,4 +38,42 @@ export function downloadExpertImportTpl() {
method: 'get', method: 'get',
responseType: 'blob' responseType: 'blob'
}) })
}
/**
* 批量导入执业证书 (zip 压缩包或单文件): 后端异步处理, 返回 {jobId}.
* 文件名以手机号命名 (姓名唯一命中兜底), 后端匹配专家并回填 practice_cert_url.
* 上传成功后轮询 queryUploadProgress(jobId) 读进度与结果.
*/
export function importPracticeCert(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importPracticeCert',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 批量导入职称证明 (zip 压缩包或单文件): 同 importPracticeCert, 回填 title_cert_url.
*/
export function importTitleCert(file) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importTitleCert',
method: 'post',
data: form,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 查询批量上传任务进度 (后端异步处理, 前端轮询).
* @returns {Promise<{code,msg,data:{jobId,done,total,finished,error,message,result}}>}
*/
export function queryUploadProgress(jobId) {
return request({ url: `/business/upload/progress/${jobId}`, method: 'get' })
} }
+22 -18
View File
@@ -23,21 +23,24 @@
</el-button> </el-button>
<span v-if="photos.length" class="cam-count">{{ photos.length }} 张</span> <span v-if="photos.length" class="cam-count">{{ photos.length }} 张</span>
<span v-else class="cam-empty">未上传</span> <span v-else class="cam-empty">未上传</span>
<el-link v-if="photos.length" type="primary" :underline="false" @click="previewVisible = true">预览</el-link>
<span v-if="polling" class="cam-polling">等待手机回传…</span> <span v-if="polling" class="cam-polling">等待手机回传…</span>
</div> </div>
<!-- 多张缩略图: 点任意一张走 el-image 全屏轮播预览 (签到表多张 / 前后全景单张) --> <!-- 预览弹窗: 不直接铺缩略图, 点「预览」打开 el-dialog 查看 (签到表多张 / 前后全景单张) -->
<div v-if="photos.length" class="camera-thumbs"> <el-dialog v-model="previewVisible" :title="`${label}预览`" width="640px" append-to-body>
<el-image <div class="preview-grid">
v-for="(p, i) in photos" <el-image
:key="i" v-for="(p, i) in photos"
:src="p" :key="i"
:preview-src-list="photos" :src="p"
:initial-index="i" :preview-src-list="photos"
fit="cover" :initial-index="i"
class="thumb" fit="contain"
/> class="preview-img"
</div> />
</div>
</el-dialog>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
@@ -83,6 +86,7 @@ const PAGE_PATH = {
} }
const visible = ref(false) const visible = ref(false)
const previewVisible = ref(false)
const qrUrl = ref('') const qrUrl = ref('')
const qrLoading = ref(false) const qrLoading = ref(false)
const polling = ref(false) const polling = ref(false)
@@ -184,14 +188,14 @@ onBeforeUnmount(stopPolling)
.cam-count { color: #67c23a; font-size: 13px; } .cam-count { color: #67c23a; font-size: 13px; }
.cam-polling { color: #e6a23c; font-size: 12px; } .cam-polling { color: #e6a23c; font-size: 12px; }
.camera-thumbs { .preview-grid {
display: flex; flex-wrap: wrap; gap: 8px; display: flex; flex-wrap: wrap; gap: 12px;
margin-top: 8px; max-height: 70vh; overflow-y: auto;
} }
.thumb { .preview-img {
width: 56px; height: 56px; width: 180px; height: 180px;
border: 1px solid #ebeef5; border-radius: 4px; border: 1px solid #ebeef5; border-radius: 4px;
cursor: pointer; object-fit: contain; background: #f5f7fa;
} }
.qr-wrap { text-align: center; padding: 8px 0 4px; } .qr-wrap { text-align: center; padding: 8px 0 4px; }
@@ -0,0 +1,98 @@
<template>
<el-dialog
v-model="visible"
title="劳务打包上传结果"
width="680px"
append-to-body
:close-on-click-modal="false"
>
<div class="summary">
<span v-if="result && result.updated != null">共回填 {{ result.updated }} 项</span>
<span v-if="failTotal > 0" class="fail">,失败 {{ failTotal }} 项</span>
</div>
<el-alert
v-if="errors.length"
type="warning"
:closable="false"
show-icon
title="其他问题"
style="margin-bottom: 12px"
>
<div v-for="(e, i) in errors" :key="i" class="error-line">{{ e }}</div>
</el-alert>
<el-tabs v-model="activeTab">
<el-tab-pane v-if="detailRows.length" label="劳务明细表" name="detail">
<el-table :data="detailRows" border size="small" :height="340">
<el-table-column width="90" align="center" prop="rowNum" label="行号" />
<el-table-column label="失败原因" prop="message" />
</el-table>
</el-tab-pane>
<el-tab-pane v-if="agreementRows.length" label="劳务协议" name="agreement">
<el-table :data="agreementRows" border size="small" :height="340">
<el-table-column width="180" prop="name" label="姓名/文件" show-overflow-tooltip />
<el-table-column label="失败原因" prop="reason" />
</el-table>
</el-tab-pane>
<el-tab-pane v-if="expertRows.length" label="专家照片" name="expert">
<el-table :data="expertRows" border size="small" :height="340">
<el-table-column width="180" prop="name" label="姓名" show-overflow-tooltip />
<el-table-column label="失败原因" prop="reason" />
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button type="primary" @click="close">知道了</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
// v-model 默认绑 modelValue / update:modelValue (Vue 3 规范)
const props = defineProps({
modelValue: { type: Boolean, default: false },
result: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
// 打开时默认落到第一个有失败内容的 tab
const activeTab = ref('detail')
watch(() => props.result, (r) => {
const detail = (r && r.detail) || {}
const agreement = (r && r.agreement) || {}
const expert = (r && r.expertPhoto) || {}
if (detail.ngList && detail.ngList.length) activeTab.value = 'detail'
else if (agreement.failures && agreement.failures.length) activeTab.value = 'agreement'
else if (expert.failures && expert.failures.length) activeTab.value = 'expert'
else activeTab.value = 'detail'
}, { immediate: true })
const detailRows = computed(() => (props.result && props.result.detail && props.result.detail.ngList) || [])
const agreementRows = computed(() => (props.result && props.result.agreement && props.result.agreement.failures) || [])
const expertRows = computed(() => (props.result && props.result.expertPhoto && props.result.expertPhoto.failures) || [])
const errors = computed(() => (Array.isArray(props.result && props.result.errors) ? props.result.errors : []))
const failTotal = computed(() => {
const r = props.result || {}
const detailNg = (r.detail && r.detail.ngNum) || 0
const agreement = (r.agreement && r.agreement.failures) || []
const expert = (r.expertPhoto && r.expertPhoto.failures) || []
return detailNg + agreement.length + expert.length + errors.value.length
})
function close() {
visible.value = false
}
</script>
<style scoped lang="scss">
.summary { margin-bottom: 12px; font-size: 14px; }
.summary .fail { color: #f56c6c; }
.error-line { line-height: 22px; }
</style>
+8 -2
View File
@@ -35,7 +35,7 @@
</div> </div>
<div class="file-actions"> <div class="file-actions">
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemoveAt(i)">移除</el-link> <el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemoveAt(i)">移除</el-link>
<a class="download-btn" :href="u" target="_blank" rel="noopener" @click.stop>下载</a> <a v-if="downloadable" class="download-btn" :href="u" target="_blank" rel="noopener" @click.stop>下载</a>
</div> </div>
</div> </div>
<div v-if="!readonly && modelList.length" class="ht-file-add" @click.stop="handleClick"> <div v-if="!readonly && modelList.length" class="ht-file-add" @click.stop="handleClick">
@@ -66,7 +66,7 @@
</div> </div>
<div class="file-actions"> <div class="file-actions">
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link> <el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
<a class="download-btn" :href="modelValue" target="_blank" rel="noopener" @click.stop>下载</a> <a v-if="downloadable" class="download-btn" :href="modelValue" target="_blank" rel="noopener" @click.stop>下载</a>
</div> </div>
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) --> <!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
<div v-if="showPreview" class="preview-box"> <div v-if="showPreview" class="preview-box">
@@ -105,6 +105,8 @@ const props = defineProps({
maxSize: { type: Number, default: 200 }, // MB maxSize: { type: Number, default: 200 }, // MB
block: { type: Boolean, default: false }, block: { type: Boolean, default: false },
readonly: { type: Boolean, default: false }, readonly: { type: Boolean, default: false },
downloadable: { type: Boolean, default: true }, // 是否显示"下载"按钮 (如 sponsor 只读查看时不显示)
maxCount: { type: Number, default: 0 }, // 多文件模式最多文件数 (0 = 不限制)
showPreview: { type: Boolean, default: true }, // 右侧预览框 (图片/PDF/其他) showPreview: { type: Boolean, default: true }, // 右侧预览框 (图片/PDF/其他)
multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串 multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串
}) })
@@ -232,6 +234,10 @@ async function onFileChange(e) {
const list = [...modelList.value] const list = [...modelList.value]
let added = 0 let added = 0
for (const file of files) { for (const file of files) {
if (props.maxCount && list.length >= props.maxCount) {
ElMessage.warning(`最多上传 ${props.maxCount} 个文件`)
break
}
if (!validateFile(file)) continue if (!validateFile(file)) continue
uploading.value = true uploading.value = true
progress.value = 0 progress.value = 0
+29 -9
View File
@@ -43,8 +43,8 @@
<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 label="管理员" width="80" align="center"> <el-table-column label="管理员" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="isOrgAdmin(row) ? 'success' : 'danger'" disable-transitions> <el-tag :type="isAdminAccount(row) ? 'success' : 'danger'" disable-transitions>
{{ isOrgAdmin(row) ? '是' : '否' }} {{ isAdminAccount(row) ? '是' : '否' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -100,6 +100,12 @@
<el-form-item label="邮箱"> <el-form-item label="邮箱">
<el-input v-model="editForm.email" /> <el-input v-model="editForm.email" />
</el-form-item> </el-form-item>
<el-form-item v-if="editForm.roleType === 'manager'" label="全局管理员">
<el-radio-group v-model="editForm.accountType">
<el-radio value="SUB">否 (普通合规员)</el-radio>
<el-radio value="MAIN">是 (可见全量)</el-radio>
</el-radio-group>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="editVisible = false">取消</el-button> <el-button @click="editVisible = false">取消</el-button>
@@ -130,6 +136,12 @@
<template v-if="isSimpleRole"> <template v-if="isSimpleRole">
<el-form-item label="姓名"><el-input v-model="createForm.nickName" /></el-form-item> <el-form-item label="姓名"><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item> <el-form-item label="手机号"><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
<el-form-item v-if="createForm.roleType === 'manager'" label="全局管理员" required>
<el-radio-group v-model="createForm.accountType">
<el-radio value="SUB">否 (普通合规员)</el-radio>
<el-radio value="MAIN">是 (可见全量)</el-radio>
</el-radio-group>
</el-form-item>
</template> </template>
<!-- doctor: 评审专家 --> <!-- doctor: 评审专家 -->
@@ -201,10 +213,12 @@ const ROLE_LABEL = {
} }
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label })) const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
// 机构管理员 = 执行方/支持方单位的主账号。 // 管理员 = account_type 为 MAIN 的角色:
// admin/manager/doctor/leader 的 account_type 也是 'MAIN' (DB 默认值), 必须限定角色排除, 否则会被误判成管理员 // executor/sponsor: 机构主账号 (机构管理员)
function isOrgAdmin(row) { // manager: 全局管理员 (可见全量, 复用 account_type 标记)
return (row.roleType === 'executor' || row.roleType === 'sponsor') && row.accountType === 'MAIN' // admin/doctor/leader 的 account_type 也是 'MAIN' (DB 默认值), 必须限定角色排除, 否则会被误判成管理员
function isAdminAccount(row) {
return (row.roleType === 'executor' || row.roleType === 'sponsor' || row.roleType === 'manager') && row.accountType === 'MAIN'
} }
const q = reactive({ userName: '', nickName: '', phonenumber: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 }) const q = reactive({ userName: '', nickName: '', phonenumber: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
@@ -232,12 +246,13 @@ function reset() {
} }
const editVisible = ref(false) const editVisible = ref(false)
const editForm = reactive({ userId: null, userName: '', nickName: '', roleType: '', phonenumber: '', email: '' }) const editForm = reactive({ userId: null, userName: '', nickName: '', roleType: '', phonenumber: '', email: '', accountType: 'SUB' })
function openEdit(row) { function openEdit(row) {
Object.assign(editForm, { Object.assign(editForm, {
userId: row.userId, userName: row.userName, nickName: row.nickName, userId: row.userId, userName: row.userName, nickName: row.nickName,
roleType: row.roleType, phonenumber: row.phonenumber, email: row.email roleType: row.roleType, phonenumber: row.phonenumber, email: row.email,
accountType: row.accountType || 'SUB'
}) })
editVisible.value = true editVisible.value = true
} }
@@ -246,6 +261,10 @@ async function saveEdit() {
try { try {
// 2026-08-16: 角色统一存 sys_user.role_type, 单次 PUT 即可 (mapper updateUser 已包含 role_type 字段) // 2026-08-16: 角色统一存 sys_user.role_type, 单次 PUT 即可 (mapper updateUser 已包含 role_type 字段)
await request({ url: '/system/user', method: 'put', data: { ...editForm } }) await request({ url: '/system/user', method: 'put', data: { ...editForm } })
// 合规人员: 全局管理员标记走独立端点写 account_type (标准 updateUser 不含 account_type)
if (editForm.roleType === 'manager') {
await request({ url: '/system/user/changeManagerAdmin', method: 'put', data: { userId: editForm.userId, accountType: editForm.accountType } })
}
ElMessage.success('保存成功') ElMessage.success('保存成功')
editVisible.value = false editVisible.value = false
load() load()
@@ -330,7 +349,8 @@ function openCreate() {
} }
function onRoleChange() { function onRoleChange() {
createForm.accountType = 'MAIN' // manager 默认 SUB (普通合规员); executor/sponsor 默认 MAIN (新建单位)
createForm.accountType = createForm.roleType === 'manager' ? 'SUB' : 'MAIN'
orgOptions.value = [] orgOptions.value = []
} }
+16 -2
View File
@@ -20,7 +20,14 @@
</el-form> </el-form>
<el-table :data="rows" v-loading="loading" stripe border> <el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="170" /> <el-table-column prop="projectNo" label="项目编号" width="170">
<template #default="{ row }">
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onViewProject(row)">
{{ row.projectNo }}
</el-link>
<span v-else>{{ row.projectNo }}</span>
</template>
</el-table-column>
<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 }">
@@ -114,7 +121,7 @@
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { bizList } from '@/api/public' import { bizList } from '@/api/public'
@@ -124,6 +131,7 @@ import PdfViewer from '@/components/PdfViewer.vue'
// Home "待签署的协议" 跳转过来带 ?signedStatus=unsigned: 列表只显示未签劳务协议的会议 // Home "待签署的协议" 跳转过来带 ?signedStatus=unsigned: 列表只显示未签劳务协议的会议
// 后端 BizMeetingMapper.selectList 据此加 EXISTS 子查询过滤 attendee_labor_protocol IS NULL // 后端 BizMeetingMapper.selectList 据此加 EXISTS 子查询过滤 attendee_labor_protocol IS NULL
const route = useRoute() const route = useRoute()
const router = useRouter()
const signedStatus = ref(String(route.query.signedStatus || '') || '') const signedStatus = ref(String(route.query.signedStatus || '') || '')
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' }) const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
@@ -169,6 +177,12 @@ function onViewLabor(row) {
laborOpen.value = true laborOpen.value = true
} }
// 点击「项目编号」: 跳转到该项目详情页 (projectId 为 NULL 时不可点)
function onViewProject(row) {
if (row.projectId == null) return
router.push(`/doctor/projects/detail/${row.projectId}`)
}
// ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) ===== // ===== 日程 / 邀请函 预览 (与 publicity 同技术: OSS 代理重写 inline + iframe/img) =====
function isPdf(url) { function isPdf(url) {
if (!url) return false if (!url) return false
+1 -1
View File
@@ -253,7 +253,7 @@ async function load() {
// 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer) // 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer)
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) { if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
signed.value = true signed.value = true
laborPdfUrl.value = data.laborProtocol || '' laborPdfUrl.value = (data.laborProtocol || '').split(',')[0].trim()
meetingName.value = data.meetingName || '' meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null totalPeriods.value = data.totalPeriods ?? null
+14 -1
View File
@@ -37,7 +37,14 @@
<GrTable :data="rows" v-loading="loading" stripe border <GrTable :data="rows" v-loading="loading" stripe border
:main-cols="['projectNo', 'meetingName']" :main-cols="['projectNo', 'meetingName']"
> >
<el-table-column prop="projectNo" label="项目编号" width="170" fixed /> <el-table-column prop="projectNo" label="项目编号" width="170" fixed>
<template #default="{ row }">
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onViewProject(row)">
{{ row.projectNo }}
</el-link>
<span v-else>{{ row.projectNo }}</span>
</template>
</el-table-column>
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" /> <el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" /> <el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip> <el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip>
@@ -147,6 +154,12 @@ function reset() {
function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) } function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
// 点击「项目编号」: 跳转到该项目详情页 (projectId 为 NULL 时不可点)
function onViewProject(row) {
if (row.projectId == null) return
router.push(`/executor/projects/detail/${row.projectId}`)
}
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) } function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
// 冻结中 → 上传材料置灰不可点 (仅 executor 列表; 解冻后 isFrozen 翻 0 自动恢复) // 冻结中 → 上传材料置灰不可点 (仅 executor 列表; 解冻后 isFrozen 翻 0 自动恢复)
+3 -3
View File
@@ -56,12 +56,12 @@ async function loadStats() {
stats.value.settledProjects = await projectCount({ isSettled: 'Y' }) stats.value.settledProjects = await projectCount({ isSettled: 'Y' })
stats.value.completedProjects = await projectCount({ isFinished: 'Y' }) stats.value.completedProjects = await projectCount({ isFinished: 'Y' })
// 会议统计走 /business/meeting/list (后端已按 roleType MAIN/SUB 隔离) // 会议统计走 /business/meeting/list (后端已按 roleType MAIN/SUB 隔离)
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
const meetingTotal = ms.total || ms.data?.total || 0
// 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交) // 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交)
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' }) const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' })
stats.value.executedMeetings = em.total || em.data?.total || 0 stats.value.executedMeetings = em.total || em.data?.total || 0
stats.value.pendingMeetings = Math.max(0, meetingTotal - stats.value.executedMeetings) // 未执行会议 = currentStage='NOT_STARTED' (与跳转链接 /executor/meetings?currentStage=NOT_STARTED 口径一致, 不再用"总数-RUNNING"减法)
const pm = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'NOT_STARTED' })
stats.value.pendingMeetings = pm.total || pm.data?.total || 0
} catch (e) { } catch (e) {
console.warn('loadStats failed', e) console.warn('loadStats failed', e)
} }
+159 -37
View File
@@ -179,22 +179,50 @@
<ImportResultDialog v-model="importResultOpen" :result="importResult" /> <ImportResultDialog v-model="importResultOpen" :result="importResult" />
<!-- ========== 批量导入执业证书 ========== --> <!-- ========== 批量导入执业证书 ========== -->
<el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px"> <el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px" append-to-body :close-on-click-modal="false">
<p style="color:#606266;margin-bottom:8px">提示: 需将证书文件名称修改为手机号进行导入</p> <p class="cert-import-hint">上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg</p>
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" /> <el-upload
v-if="importCertOpen"
drag
:auto-upload="false"
:limit="1"
accept=".zip,.png,.jpg,.jpeg,.pdf,.webp"
:on-change="onPracticeCertChange"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将压缩包/文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="certSubmitting" class="cert-import-progress">
<el-progress :percentage="certProgress" :stroke-width="12" :show-text="false" />
<span class="cert-import-status">{{ certStatus || '上传中' }}</span>
</div>
<template #footer> <template #footer>
<el-button @click="importCertOpen=false">取消</el-button> <el-button @click="importCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button> <el-button type="primary" :loading="certSubmitting" @click="submitCertImport('practice')">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- ========== 批量导入职称证明 ========== --> <!-- ========== 批量导入职称证明 ========== -->
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px"> <el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px" append-to-body :close-on-click-modal="false">
<p style="color:#606266;margin-bottom:8px">提示: 需将证明文件名称修改为手机号进行导入</p> <p class="cert-import-hint">上传 .zip 压缩包(或单个图片/PDF); 文件名以手机号命名和姓名匹配。示例: 张三_13800138000.jpg、张三.pdf(若重名上传不成功)、13800138000.jpg</p>
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" /> <el-upload
v-if="importTitleCertOpen"
drag
:auto-upload="false"
:limit="1"
accept=".zip,.png,.jpg,.jpeg,.pdf,.webp"
:on-change="onTitleCertChange"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将压缩包/文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="certSubmitting" class="cert-import-progress">
<el-progress :percentage="certProgress" :stroke-width="12" :show-text="false" />
<span class="cert-import-status">{{ certStatus || '上传中' }}</span>
</div>
<template #footer> <template #footer>
<el-button @click="importTitleCertOpen=false">取消</el-button> <el-button @click="importTitleCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button> <el-button type="primary" :loading="certSubmitting" @click="submitCertImport('title')">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -204,7 +232,7 @@
<el-image <el-image
:src="previewUrl" :src="previewUrl"
fit="contain" fit="contain"
:preview-src-list="[previewUrl]" :preview-src-list="previewUrls"
style="width:100%;max-height:70vh" style="width:100%;max-height:70vh"
loading="lazy" loading="lazy"
> >
@@ -222,11 +250,13 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import GrTable from '@/components/GrTable.vue' import GrTable from '@/components/GrTable.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { bizList, bizUpdate } from '@/api/public' import { bizList, bizUpdate } from '@/api/public'
import request from '@/utils/request'
import { importPracticeCert, importTitleCert } from '@/api/business/expert'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue' import { Upload } from '@element-plus/icons-vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue' import AuditStatusTag from '@/components/AuditStatusTag.vue'
@@ -353,17 +383,25 @@ function onViewAuditOpinion(row) {
// ===== 证书查看/下载 ===== // ===== 证书查看/下载 =====
const previewOpen = ref(false) const previewOpen = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const previewUrls = ref([])
function splitUrls(url) {
return (url || '').split(',').map(s => s.trim()).filter(Boolean)
}
function onPreviewCert(url) { function onPreviewCert(url) {
if (!url) return const urls = splitUrls(url)
previewUrl.value = url if (!urls.length) return
previewUrl.value = urls[0]
previewUrls.value = urls
previewOpen.value = true previewOpen.value = true
} }
function onDownloadCert(url, phone) { function onDownloadCert(url, phone) {
if (!url) return const urls = splitUrls(url)
const a = document.createElement('a') urls.forEach((u, i) => {
a.href = url const a = document.createElement('a')
a.download = `${phone || 'cert'}_${Date.now()}` a.href = u
document.body.appendChild(a); a.click(); document.body.removeChild(a) a.download = urls.length > 1 ? `${phone || 'cert'}_${i + 1}_${Date.now()}` : `${phone || 'cert'}_${Date.now()}`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
})
} }
// ===== 批量导入专家 (沿用 manager 最佳实践) ===== // ===== 批量导入专家 (沿用 manager 最佳实践) =====
@@ -435,33 +473,112 @@ function onImportError(err) {
importResult.value = null importResult.value = null
} }
// ===== 批量导入执业证书 ===== // ===== 批量导入执业证书 / 职称证明 (zip 或单文件 → 后端异步回填, 前端轮询进度) =====
const importCertOpen = ref(false) const importCertOpen = ref(false)
const importCertUrl = ref('') const importTitleCertOpen = ref(false)
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true } const practiceCertFile = ref(null)
async function submitImportCert() { const titleCertFile = ref(null)
if (!importCertUrl.value) return ElMessage.warning('请选择文件') const certSubmitting = ref(false)
importing.value = true const certProgress = ref(0)
await new Promise(r => setTimeout(r, 500)) const certStatus = ref('')
importing.value = false let certPollTimer = null
ElMessage.info('已提交批量导入执业证书任务 — 待后端实现')
importCertOpen.value = false function onPracticeCertChange(f) { practiceCertFile.value = (f && f.raw) || null }
function onTitleCertChange(f) { titleCertFile.value = (f && f.raw) || null }
function openImportPracticeCert() {
practiceCertFile.value = null
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
importCertOpen.value = true
}
function openImportTitleCert() {
titleCertFile.value = null
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
importTitleCertOpen.value = true
} }
// ===== 批量导入职称证明 ===== async function submitCertImport(type) {
const importTitleCertOpen = ref(false) const file = type === 'practice' ? practiceCertFile.value : titleCertFile.value
const importTitleCertUrl = ref('') if (!file) return ElMessage.warning('请选择证书文件')
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true } certSubmitting.value = true
async function submitImportTitleCert() { certProgress.value = 0
if (!importTitleCertUrl.value) return ElMessage.warning('请选择文件') certStatus.value = ''
importing.value = true let jobId = null
await new Promise(r => setTimeout(r, 500)) try {
importing.value = false const resp = type === 'practice' ? await importPracticeCert(file) : await importTitleCert(file)
ElMessage.info('已提交批量导入职称证明任务 — 待后端实现') jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
certSubmitting.value = false
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
certSubmitting.value = false
return
}
certProgress.value = 100
pollCertProgress(jobId, type)
}
function pollCertProgress(jobId, type) {
clearInterval(certPollTimer)
certPollTimer = setInterval(async () => {
let p = null
try {
const resp = await request.get(`/business/upload/progress/${jobId}`, { __silentError: true })
p = (resp && resp.data) || null
} catch (e) {
clearInterval(certPollTimer)
ElMessage.error(e?.msg || '查询进度失败')
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
return
}
if (!p) { certStatus.value = '服务器准备中…'; return }
if (p.finished) {
clearInterval(certPollTimer)
certSubmitting.value = false
certProgress.value = 0
certStatus.value = ''
if (p.error) { ElMessage.error(p.message || '导入失败'); return }
showCertResult(type, p.result)
return
}
certStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
}, 800)
}
function showCertResult(type, result) {
const data = result || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
const label = type === 'practice' ? '执业证书' : '职称证明'
if (skipped.length) {
// 复用 ImportResultDialog 弹失败明细 (文件名 + 具体原因: 重名/手机号不存在/姓名不存在)
importResult.value = {
okNum: updated,
ngNum: skipped.length,
ngList: skipped.map((s, i) => ({ rowNum: i + 1, message: `${s.fileName || '未知文件'} —— ${s.reason || '未匹配'}` }))
}
importResultOpen.value = true
} else {
ElMessage.success(`${label}已回填 ${updated}/${total} 位专家`)
}
importCertOpen.value = false
importTitleCertOpen.value = false importTitleCertOpen.value = false
load()
} }
onMounted(load) onMounted(load)
onUnmounted(() => { clearInterval(certPollTimer) })
// 从 ExpertNew 跳回来时刷新列表 // 从 ExpertNew 跳回来时刷新列表
watch(() => route.fullPath, () => { watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load() if (route.path === listBasePath.value) load()
@@ -477,6 +594,11 @@ watch(() => route.fullPath, () => {
.cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; } .cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; }
.cert-preview-error { color: #909399; padding: 40px 0; } .cert-preview-error { color: #909399; padding: 40px 0; }
.cert-import-hint { color: #606266; margin-bottom: 10px; font-size: 13px; line-height: 1.6; }
.cert-import-hint em { color: #e6a23c; font-style: normal; font-weight: 500; }
.cert-import-progress { margin-top: 12px; }
.cert-import-status { font-size: 13px; color: #909399; margin-top: 4px; display: inline-block; }
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px)
- 卡片 padding 收窄 - 卡片 padding 收窄
+44 -9
View File
@@ -75,16 +75,17 @@
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="开始时间" prop="startTime"> <el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /> <el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择开始时间" style="width:100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="结束时间" prop="endTime"> <el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /> <el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择结束时间" style="width:100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<!-- ========== 第二区块:角色劳务设置 ========== --> <!-- ========== 第二区块:角色劳务设置 ========== -->
<div class="new-card-title">角色劳务设置</div> <div class="new-card-title">角色劳务设置</div>
<div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row"> <div v-for="(r, idx) in form.roleRows" :key="idx" class="role-row">
@@ -124,6 +125,15 @@
</div> </div>
</div> </div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="发布日期" prop="publishTime">
<el-date-picker v-model="form.publishTime" type="date" value-format="YYYY-MM-DD"
:disabled-date="disableFutureDate" placeholder="请选择发布日期" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 底部按钮 (skill: 用 el-form-item 包裹, 不另起 div.form-actions) --> <!-- 底部按钮 (skill: 用 el-form-item 包裹, 不另起 div.form-actions) -->
<el-form-item class="form-actions"> <el-form-item class="form-actions">
<el-button @click="confirmCancel">取消</el-button> <el-button @click="confirmCancel">取消</el-button>
@@ -153,11 +163,30 @@ const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } } function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } } function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 当前本地时间 "yyyy-MM-dd HH:mm:ss" (发布用, 与 el-date-picker 的本地时间口径一致; 不能用 toISOString 的 UTC) // 今天 "yyyy-MM-dd" (发布日期默认值/校验用, 取本地日期; 不能用 toISOString 的 UTC)
function nowLocalDatetime() { function todayLocalDate() {
const d = new Date() const d = new Date()
const p = n => String(n).padStart(2, '0') const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
// 发布日期: 禁用今天之后的日历日期 (未来日期不可选)
function disableFutureDate(date) {
const today = new Date()
today.setHours(0, 0, 0, 0)
return date.getTime() > today.getTime()
}
// 发布日期校验: 必填 + 不能晚于今天
// value 是 "YYYY-MM-DD" 定长补零字符串, 字典序 = 日期序, 直接字符串比较 (无需 Date 解析/时区)
function validatePublishTime(rule, value, callback) {
if (!value) {
callback(new Error('请选择发布日期'))
return
}
if (value > todayLocalDate()) {
callback(new Error('发布日期不能晚于今天'))
return
}
callback()
} }
// 项目负责人候选 (sys_user.role_type='leader') // 项目负责人候选 (sys_user.role_type='leader')
@@ -172,6 +201,8 @@ const form = reactive({
isBidProject: 'N', isBidProject: 'N',
totalSessions: 0, totalAmount: 0, manageFee: 0, totalSessions: 0, totalAmount: 0, manageFee: 0,
startTime: '', endTime: '', startTime: '', endTime: '',
// 发布日期: 默认今天, 用户可修改 (不可设未来日期)
publishTime: todayLocalDate(),
roleRows: [ roleRows: [
{ role: '主席', customName: '', amount: 0 }, { role: '主席', customName: '', amount: 0 },
{ role: '主持', customName: '', amount: 0 }, { role: '主持', customName: '', amount: 0 },
@@ -195,7 +226,8 @@ const rules = {
totalSessions: [{ required: true, message: '请输入总场次', trigger: 'blur' }], totalSessions: [{ required: true, message: '请输入总场次', trigger: 'blur' }],
totalAmount: [{ required: true, message: '请输入总金额', trigger: 'blur' }], totalAmount: [{ required: true, message: '请输入总金额', trigger: 'blur' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }], startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }] endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
publishTime: [{ validator: validatePublishTime, trigger: 'change' }]
} }
function onRoleSelectChange(r) { function onRoleSelectChange(r) {
@@ -289,6 +321,8 @@ async function loadProject() {
form.manageFee = p.manageFee || 0 form.manageFee = p.manageFee || 0
form.startTime = p.startTime || '' form.startTime = p.startTime || ''
form.endTime = p.endTime || '' form.endTime = p.endTime || ''
// 发布日期: 已有值反显 (后端 "yyyy-MM-dd HH:mm:ss", 取前 10 位日期), 否则默认今天
form.publishTime = (p.publishTime || '').slice(0, 10) || todayLocalDate()
form.leadUserId = p.leadUserId || null form.leadUserId = p.leadUserId || null
form.isBidProject = p.isBidProject || 'N' form.isBidProject = p.isBidProject || 'N'
// 角色劳务反显: biz_project.role_labor JSON 数组 [{role, customName, amount}] // 角色劳务反显: biz_project.role_labor JSON 数组 [{role, customName, amount}]
@@ -403,10 +437,11 @@ async function submit(mode = 'save') {
else if (n.key === 'notice') payload.publishUrl = n.url else if (n.key === 'notice') payload.publishUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url else if (n.key === 'schedule') payload.scheduleUrl = n.url
} }
// 发布时设置 is_published='Y' + publish_time (同步后端字段) // 发布时设置 is_published='Y' + publish_time (用表单里用户可编辑的发布日期)
// 表单是纯日期 "yyyy-MM-dd", 后端 publishTime 是 Date(@JsonFormat "yyyy-MM-dd HH:mm:ss"), 补 00:00:00 提交
if (mode === 'publish') { if (mode === 'publish') {
payload.isPublished = 'Y' payload.isPublished = 'Y'
payload.publishTime = nowLocalDatetime() payload.publishTime = form.publishTime ? `${form.publishTime} 00:00:00` : ''
} }
// 多执行方分配已移至"项目分配"页面单独管理 // 多执行方分配已移至"项目分配"页面单独管理
try { try {
+258 -46
View File
@@ -21,7 +21,7 @@
<div class="info-row"><span class="info-label">项目编号:</span><span class="info-value code">{{ row.projectNo || '-' }}</span></div> <div class="info-row"><span class="info-label">项目编号:</span><span class="info-value code">{{ row.projectNo || '-' }}</span></div>
<div class="info-row"><span class="info-label">会议名称:</span><span class="info-value">{{ row.meetingName || '-' }}</span></div> <div class="info-row"><span class="info-label">会议名称:</span><span class="info-value">{{ row.meetingName || '-' }}</span></div>
<div class="info-row"><span class="info-label">项目名称:</span><span class="info-value">{{ row.projectName || '-' }}</span></div> <div class="info-row"><span class="info-label">项目名称:</span><span class="info-value">{{ row.projectName || '-' }}</span></div>
<div class="info-row"><span class="info-label">期数:</span><span class="info-value">{{ periodDisplay }}</span></div> <div class="info-row"><span class="info-label">场次/期数:</span><span class="info-value">{{ periodDisplay }}</span></div>
<div class="info-row"><span class="info-label">总场次/总期数:</span><span class="info-value">{{ displayTotalPeriods ?? '-' }}</span></div> <div class="info-row"><span class="info-label">总场次/总期数:</span><span class="info-value">{{ displayTotalPeriods ?? '-' }}</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">{{ 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>
@@ -63,8 +63,9 @@
<div class="section-title">材料管理</div> <div class="section-title">材料管理</div>
<el-tabs v-model="activeTab" class="material-tabs"> <el-tabs v-model="activeTab" class="material-tabs">
<el-tab-pane label="劳务材料" name="labor"> <el-tab-pane label="劳务材料" name="labor">
<!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面, 无表题直接出表) --> <!-- 参会人管理 (放在 劳务明细 + 劳务协议 上面) -->
<div class="attendee-section"> <div class="attendee-section">
<div class="attendee-subtitle">劳务明细表</div>
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar"> <div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button> <el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button> <el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button>
@@ -94,9 +95,6 @@
<el-table-column prop="bankName" label="银行" min-width="100" show-overflow-tooltip /> <el-table-column prop="bankName" label="银行" min-width="100" show-overflow-tooltip />
<el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip /> <el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip />
<el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip /> <el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip />
<el-table-column prop="accountName" label="账户名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="bankRegion" label="开户行地址" min-width="120" show-overflow-tooltip />
<el-table-column prop="bankAddress" label="银行详细地址" min-width="140" show-overflow-tooltip />
<el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip /> <el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip />
<el-table-column label="应发金额" width="110" align="right"> <el-table-column label="应发金额" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template> <template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
@@ -112,6 +110,9 @@
</el-table-column> </el-table-column>
<el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip /> <el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip />
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip /> <el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column prop="accountName" label="账户名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="bankRegion" label="开户行地址" min-width="120" show-overflow-tooltip />
<el-table-column prop="bankAddress" label="银行详细地址" min-width="140" show-overflow-tooltip />
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center"> <el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link> <el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link>
@@ -120,7 +121,7 @@
</el-table-column> </el-table-column>
<el-table-column label="劳务协议" width="120" align="center"> <el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-link :underline="false" v-if="protocolUrl(row)" type="primary" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-link> <el-link :underline="false" v-if="laborProtocolFiles(row).length" type="primary" @click="openLaborProtocol(row)">查看</el-link>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -170,6 +171,10 @@
<span class="fee-label">劳务费合计:</span> <span class="fee-label">劳务费合计:</span>
<span class="fee-value">¥ {{ laborFeeTotal.toFixed(2) }}</span> <span class="fee-value">¥ {{ laborFeeTotal.toFixed(2) }}</span>
</div> </div>
<div v-if="isManager" class="attendee-legend">
<span class="legend-item"><span class="legend-name">红色姓名</span>:未确认参会意向</span>
<span class="legend-item"><span class="legend-row">淡红背景</span>:实发金额与所选角色配置金额不一致</span>
</div>
</div> </div>
<div class="file-list" style="margin-top: 16px;"> <div class="file-list" style="margin-top: 16px;">
@@ -195,15 +200,19 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" /> <div v-if="isCameraSubType(r.subType)" class="camera-upload-row">
<CameraQrUpload v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
<!-- 直接上传本地图片: 仅 manager/admin 可见; 其他角色只能扫码拍照 (隐藏控件而非置 readonly) -->
<OssFileUploader v-if="isManager || isAdmin" v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" accept=".png,.jpg,.jpeg,.gif,.webp,.bmp" placeholder="直接上传(本地图片)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
</div>
<template v-else-if="r.subType === 'L_ESIGN_IN'"> <template v-else-if="r.subType === 'L_ESIGN_IN'">
<div class="esign-actions"> <div class="esign-actions">
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" /> <OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" multiple block :max-size="20" placeholder="点击上传多个 Excel (xls/xlsx/csv)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" :downloadable="!isSponsor" />
<el-button v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" size="small" type="success" :loading="esignZipSubmitting" @click="onPickEsignZip">上传 zip</el-button> <el-button v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" size="small" type="success" :loading="esignZipSubmitting" @click="onPickEsignZip">上传 zip</el-button>
<input ref="esignZipInput" type="file" accept=".zip" style="display:none" @change="onEsignZipChange" /> <input ref="esignZipInput" type="file" accept=".zip" style="display:none" @change="onEsignZipChange" />
</div> </div>
</template> </template>
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" /> <OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" :downloadable="!isSponsor" />
<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>
</template> </template>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div> <div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
@@ -224,14 +233,28 @@
</div> </div>
<div class="file-row"> <div class="file-row">
<span class="file-label">日程海报:</span> <span class="file-label">日程海报:</span>
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" /> <OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" :downloadable="!isSponsor" />
<div class="file-hint">会议日程海报</div> <div class="file-hint">会议日程海报</div>
</div> </div>
<div class="file-row"> <div class="file-row">
<span class="file-label">邀请函:</span> <span class="file-label">邀请函:</span>
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" /> <OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly || laborLocked" :downloadable="!isSponsor" />
<div class="file-hint">会议邀请函</div> <div class="file-hint">会议邀请函</div>
</div> </div>
<!-- 打包上传: 下载空目录模板 + 上传劳务材料 (劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函) -->
<div v-if="!isSponsor && !isReadonly && laborEditable && !laborLocked" class="file-row">
<span class="file-label">打包上传:</span>
<div class="agreement-actions">
<el-button size="small" :loading="laborTplLoading" @click="onDownloadLaborTemplate">下载目录</el-button>
<el-button size="small" type="success" :loading="laborSubmitting" @click="onPickLaborZip">上传劳务材料</el-button>
<input ref="laborZipInput" type="file" accept=".zip" style="display:none" @change="onLaborZipChange" />
</div>
<div v-if="laborSubmitting" class="upload-progress">
<el-progress :percentage="laborProgress" :stroke-width="12" :show-text="false" />
<span class="upload-progress-status">{{ laborStatus || '上传中' }}</span>
</div>
<div class="file-hint">下载空目录模板后放入对应材料压缩上传回填</div>
</div>
<!-- 临时: manager / executor 详情页隐掉海报生成 (含 生成海报/预览海报) --> <!-- 临时: manager / executor 详情页隐掉海报生成 (含 生成海报/预览海报) -->
<div v-if="!isManager && !isExecutor && !isReadonly" class="file-row"> <div v-if="!isManager && !isExecutor && !isReadonly" class="file-row">
<span class="file-label">海报生成:</span> <span class="file-label">海报生成:</span>
@@ -270,7 +293,7 @@
</div> </div>
<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/`" :accept="r.accept || SERVICE_ACCEPT_ALL" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable || serviceLocked" @preview="(url) => openPreview([url], r.label)" /> <OssFileUploader v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :max-count="SERVICE_MAX_FILES" :dir="`ry8080/meeting/${meetingId}/service/`" :accept="r.accept || SERVICE_ACCEPT_ALL" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable || serviceLocked" :downloadable="!isSponsor" @preview="(url) => openPreview([url], r.label)" />
<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>
@@ -299,7 +322,7 @@
<div class="file-list"> <div class="file-list">
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row"> <div v-for="r in laborVoucherRows" :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}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" /> <OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" :downloadable="!isSponsor" />
<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> </div>
@@ -308,7 +331,7 @@
<div class="file-list"> <div class="file-list">
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row"> <div v-for="r in serviceVoucherRows" :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-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" /> <OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" :downloadable="!isSponsor" />
<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> </div>
@@ -437,7 +460,7 @@
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" /> <el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item> </el-form-item>
<el-form-item label="身份证号" prop="idCard" :error="mismatchError('idCard')"> <el-form-item label="身份证号" prop="idCard" :error="mismatchError('idCard')">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" /> <el-input v-model="attendeeDialog.form.idCard" placeholder="身份证号/护照号" />
</el-form-item> </el-form-item>
<el-form-item label="身份证附件" prop="idCardAttachments" style="grid-column: 1 / -1"> <el-form-item label="身份证附件" prop="idCardAttachments" style="grid-column: 1 / -1">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" /> <IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
@@ -549,6 +572,7 @@
<div v-else class="text-muted">尚未生成海报</div> <div v-else class="text-muted">尚未生成海报</div>
</el-dialog> </el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" /> <ImportResultDialog v-model="importResultOpen" :result="importResult" />
<LaborUploadResultDialog v-model="laborResultOpen" :result="laborResult" />
<!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) --> <!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) -->
<el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false"> <el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false">
@@ -583,6 +607,22 @@
<div v-else class="text-muted" style="padding: 24px 0; text-align: center;">暂无可预览的签到数据</div> <div v-else class="text-muted" style="padding: 24px 0; text-align: center;">暂无可预览的签到数据</div>
</el-dialog> </el-dialog>
<!-- 劳务协议多文件列表: 点击劳务协议列 (多文件) 弹出, 表格列出各文件可分别预览/下载 -->
<el-dialog v-model="protocolListOpen" :title="protocolListTitle" width="720px" append-to-body :close-on-click-modal="false">
<el-table :data="protocolListFiles" size="small" border max-height="480">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="文件名" min-width="300" show-overflow-tooltip>
<template #default="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="{ row }">
<el-link :underline="false" type="primary" @click="openPreview([row.url], '劳务协议')">预览</el-link>
<el-link v-if="!isSponsor" :underline="false" type="primary" style="margin-left: 12px;" @click="openNewTab(row.url)">下载</el-link>
</template>
</el-table-column>
</el-table>
</el-dialog>
<div class="form-actions"> <div class="form-actions">
<el-button @click="goBack">返回</el-button> <el-button @click="goBack">返回</el-button>
</div> </div>
@@ -602,6 +642,7 @@ import CameraQrUpload from '@/components/CameraQrUpload.vue'
import OssImageUploader from '@/components/OssImageUploader.vue' import OssImageUploader from '@/components/OssImageUploader.vue'
import IdCardUploader from '@/components/IdCardUploader.vue' import IdCardUploader from '@/components/IdCardUploader.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue' import ImportResultDialog from '@/components/ImportResultDialog.vue'
import LaborUploadResultDialog from '@/components/LaborUploadResultDialog.vue'
import Preview from '@/components/Preview.vue' import Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue' import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue' import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
@@ -634,6 +675,10 @@ const previewUrls = ref([])
const previewTitle = ref('附件预览') const previewTitle = ref('附件预览')
/** 电子签到表 (sponsor) 脱敏预览 dialog 状态 */ /** 电子签到表 (sponsor) 脱敏预览 dialog 状态 */
const esignPreview = reactive({ visible: false, sheets: [], activeName: 's0' }) const esignPreview = reactive({ visible: false, sheets: [], activeName: 's0' })
/** 劳务协议多文件列表 dialog 状态 (点击劳务协议列多文件时弹出) */
const protocolListOpen = ref(false)
const protocolListTitle = ref('劳务协议')
const protocolListFiles = ref([]) // [{url, name}]
const loading = ref(false) const loading = ref(false)
const activeTab = ref('labor') const activeTab = ref('labor')
const saving = ref(false) const saving = ref(false)
@@ -673,11 +718,7 @@ const displayTotalPeriods = computed(() => {
}) })
const periodDisplay = computed(() => { const periodDisplay = computed(() => {
const p = row.value.periodNo const p = row.value.periodNo
const t = displayTotalPeriods.value return p == null ? '-' : `${p}`
if (p == null && t == null) return '-'
if (p == null) return `${t}`
if (t == null) return `${p}`
return `${p}/${t}`
}) })
const AUDIT_STAGE_LABEL = { NOT_SUBMITTED: '未提交', SUBMITTED: '已提交', APPROVED: '审核通过', REJECTED: '审核驳回' } const AUDIT_STAGE_LABEL = { NOT_SUBMITTED: '未提交', SUBMITTED: '已提交', APPROVED: '审核通过', REJECTED: '审核驳回' }
function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v } function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v }
@@ -687,10 +728,15 @@ function fmtMoney(v) {
return (isNaN(n) ? 0 : n).toFixed(2) return (isNaN(n) ? 0 : n).toFixed(2)
} }
function isOne(v) { return v === 1 || v === '1' || v === true } function isOne(v) { return v === 1 || v === '1' || v === true }
/** sponsor 看脱敏 PDF, 其他角色看完整 PDF */ /** 劳务协议文件列表: 单 URL / 逗号分隔多 URL → [{url, name}] (sponsor 看脱敏版) */
function protocolUrl(row) { function laborProtocolFiles(row) {
if (isSponsor.value) return row.laborProtocolMasked || '' const raw = isSponsor.value ? (row.laborProtocolMasked || '') : (row.laborProtocol || '')
return row.laborProtocol || '' return raw.split(',').map(s => s.trim()).filter(Boolean).map(url => ({ url, name: fileNameFromUrl(url) }))
}
/** 从 OSS URL 末段还原文件名 (decodeURIComponent 处理中文) */
function fileNameFromUrl(url) {
const seg = ((url || '').split('?')[0]).split('/').pop() || ''
try { return decodeURIComponent(seg) } catch (e) { return seg }
} }
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */ /** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
function idCardFiles(row) { function idCardFiles(row) {
@@ -711,18 +757,37 @@ function openOnSitePhotos(row) {
openPreview(onSitePhotoFiles(row).map(u => ossThumb(u)), '现场照片') openPreview(onSitePhotoFiles(row).map(u => ossThumb(u)), '现场照片')
} }
/** 点击劳务协议列: 单文件直接预览, 多文件弹文件列表 dialog (表格内逐个预览/下载) */
function openLaborProtocol(row) {
const files = laborProtocolFiles(row)
if (!files.length) return
if (files.length === 1) {
openPreview(files.map(f => f.url), '劳务协议')
return
}
protocolListTitle.value = row.name ? `劳务协议 · ${row.name}` : '劳务协议'
protocolListFiles.value = files
protocolListOpen.value = true
}
/** 新窗口打开文件 (下载/预览, OSS Content-Disposition 为 inline 时可在新页直接查看) */
function openNewTab(url) {
if (!url) return
window.open(url, '_blank', 'noopener')
}
// ===================== 材料管理 4 个 tab ===================== // ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [ const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '' }, { type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '' }, { type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '' }, { type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '' }, { type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '' }, { type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '' }, { type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' }, { type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' }, { type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg' }, { type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg', multi: true },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' }, { type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '', multi: true },
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '', multi: true }, { 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_ESIGN_IN', label: '电子签到表', hint: '会议电子签到表', accept: '.xls,.xlsx,.csv' },
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' }, { type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
@@ -734,6 +799,8 @@ const ROW_CONFIG = [
/** 会务材料上传默认扩展名 (含 Office/txt); 个别行 (如盖章结算单) 用 r.accept 覆盖 */ /** 会务材料上传默认扩展名 (含 Office/txt); 个别行 (如盖章结算单) 用 r.accept 覆盖 */
const SERVICE_ACCEPT_ALL = '.pdf,.png,.jpg,.jpeg,.zip,.ppt,.pptx,.xls,.xlsx,.doc,.docx,.txt' const SERVICE_ACCEPT_ALL = '.pdf,.png,.jpg,.jpeg,.zip,.ppt,.pptx,.xls,.xlsx,.doc,.docx,.txt'
/** 会务材料单类最多文件数 (与后端 SERVICE_MAX_FILES 对齐, 多文件 JSON 数组写 oss_url) */
const SERVICE_MAX_FILES = 50
/** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */ /** 非发票 subType: 不会被 OCR 识别金额 (照片/现场材料误识别会产生脏 amount) */
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_ESIGN_IN', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK']) const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_ESIGN_IN', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
/** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */ /** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */
@@ -898,10 +965,10 @@ const frozen = computed(() => isOne(row.value.isFrozen))
/** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */ /** 单轨是否处于「可提交/可编辑」态: 执行方仅未提交/被驳回可编辑; 合规(manager)/管理员 永远可编辑 */
const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage)) const laborEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage)) const serviceEditable = computed(() => isManager.value || isAdmin.value || ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
// 提交审核后该轨锁定 (manager/admin 也跟着锁), 让 OssFileUploader 移除按钮消失 // 提交审核后该轨锁定执行方 (executor), 让 OssFileUploader 移除按钮消失; 合规/管理员 (manager/admin) 任何时候都可编辑保存, 故不锁
// (仅控制 OssFileUploader 的 readonly; 不影响 laborEditable (下载模板/参会人列表的判定)) // (仅控制 OssFileUploader 的 readonly; 不影响 laborEditable (下载模板/参会人列表的判定))
const laborLocked = computed(() => ['SUBMITTED', 'APPROVED'].includes(row.value.laborAuditStage)) const laborLocked = computed(() => !isManager.value && !isAdmin.value && ['SUBMITTED', 'APPROVED'].includes(row.value.laborAuditStage))
const serviceLocked = computed(() => ['SUBMITTED', 'APPROVED'].includes(row.value.serviceAuditStage)) const serviceLocked = computed(() => !isManager.value && !isAdmin.value && ['SUBMITTED', 'APPROVED'].includes(row.value.serviceAuditStage))
// 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改 // 参会人名单是否可改 (新增/导入/编辑/删除): sponsor/只读不可; executor 劳务提交后(SUBMITTED/APPROVED)锁定, 未提交/退回可改; manager/admin 恒可改
const attendeeEditable = computed(() => { const attendeeEditable = computed(() => {
if (isSponsor.value || isReadonly.value) return false if (isSponsor.value || isReadonly.value) return false
@@ -911,11 +978,17 @@ const attendeeEditable = computed(() => {
/** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */ /** 单轨状态判定: C0=已提交待合规审 / C1=已提交待支持方审 */
function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) } function isC0(stage, compliance) { return stage === 'SUBMITTED' && !isOne(compliance) }
function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) } function isC1(stage, compliance) { return stage === 'SUBMITTED' && isOne(compliance) }
// 材料可提交: 已执行 + 未冻结 + 对应轨可编辑 (三按钮按轨显隐) // 材料可提交: 执行方需"已执行"+未冻结; 合规员(manager 自建会议自传自审)不受"已执行"与"已冻结"两道时间门槛限制
const executorSubmitReady = computed(() => isExecutor.value && executed.value && !frozen.value) const executorSubmitReady = computed(() => {
const canSubmitLabor = computed(() => executorSubmitReady.value && laborEditable.value) if (isManager.value) return true
const canSubmitService = computed(() => executorSubmitReady.value && serviceEditable.value) return isExecutor.value && executed.value && !frozen.value
const canSubmitAll = computed(() => executorSubmitReady.value && laborEditable.value && serviceEditable.value) })
// 单轨"可提交"门槛 (区别于 laborEditable 的"可编辑"): 仅未提交/被驳回可提交, 提交后按钮隐藏
const laborSubmittable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.laborAuditStage))
const serviceSubmittable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.serviceAuditStage))
const canSubmitLabor = computed(() => executorSubmitReady.value && laborSubmittable.value)
const canSubmitService = computed(() => executorSubmitReady.value && serviceSubmittable.value)
const canSubmitAll = computed(() => executorSubmitReady.value && laborSubmittable.value && serviceSubmittable.value)
// 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定 // 材料可编辑 (保存修改): 任一轨未提交/被驳回 时可保存; 两轨都进入审核后锁定
const materialsEditable = computed(() => laborEditable.value || serviceEditable.value) const materialsEditable = computed(() => laborEditable.value || serviceEditable.value)
// 合规审核 (合规方/manager): 三按钮按轨 C0 显隐 // 合规审核 (合规方/manager): 三按钮按轨 C0 显隐
@@ -1076,8 +1149,7 @@ const attendeeRules = {
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }], name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }], workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
idCard: [ idCard: [
{ required: true, message: '请填写身份证号', trigger: 'blur' }, { required: true, message: '请填写身份证号/护照号', trigger: 'blur' }
{ pattern: /^\d{17}[\dXx]$/, message: '身份证号应为18位(末位可为X)', trigger: 'blur' }
], ],
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }], idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
department: [{ required: true, message: '请选择科室', trigger: 'change' }], department: [{ required: true, message: '请选择科室', trigger: 'change' }],
@@ -2175,9 +2247,19 @@ function isImageUrl(url) {
return /\.(jpe?g|png)$/i.test(url || '') return /\.(jpe?g|png)$/i.test(url || '')
} }
/** 判断是否可识别: 图片或 PDF */ /** 判断是否可识别: 图片/PDF/zip; 会务材料多文件 (oss_url 存 JSON 数组) 任一可识别即 true */
function isRecognizable(url) { function isRecognizable(url) {
return /\.(jpe?g|png|pdf|zip)$/i.test(url || '') if (!url) return false
const s = String(url).trim()
if (s.startsWith('[')) {
try {
const arr = JSON.parse(s)
return Array.isArray(arr) && arr.some(u => /\.(jpe?g|png|pdf|zip)$/i.test(String(u || '').split('?')[0]))
} catch (e) {
return false
}
}
return /\.(jpe?g|png|pdf|zip)$/i.test(s)
} }
/** /**
@@ -2531,6 +2613,127 @@ async function onServiceZipChange(ev) {
}) })
} }
// ===================== 劳务材料 打包上传 (下载空目录模板 + 上传按目录分类回填) =====================
const laborTplLoading = ref(false)
const laborSubmitting = ref(false)
const laborProgress = ref(0)
const laborStatus = ref('')
const laborZipInput = ref(null)
const laborResultOpen = ref(false)
const laborResult = ref(null)
/** 下载"劳务材料"空目录模板 zip (劳务明细表/劳务协议/…/邀请函; 非 admin/manager 不含签到表/前全景/后全景) */
async function onDownloadLaborTemplate() {
laborTplLoading.value = true
try {
const resp = await fetch(
import.meta.env.VITE_APP_BASE_API + `/business/meetingMaterial/laborTemplate/${meetingId.value}`,
{ headers: importHeaders.value }
)
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '劳务材料目录.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
ElMessage.error('模板下载失败')
} finally {
laborTplLoading.value = false
}
}
function onPickLaborZip() {
laborZipInput.value?.click()
}
async function onLaborZipChange(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许重复选择同一文件
if (!file) return
const fd = new FormData()
fd.append('file', file)
laborSubmitting.value = true
laborProgress.value = 0
laborStatus.value = ''
let jobId = null
try {
const resp = await request.post(
`/business/meetingMaterial/uploadLaborMaterials?meetingId=${meetingId.value}`,
fd,
{
timeout: 600000,
__silentError: true,
onUploadProgress: (e) => {
if (e.total) laborProgress.value = Math.min(100, Math.round((e.loaded / e.total) * 100))
}
}
)
jobId = (resp && resp.data && resp.data.jobId) || null
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
laborSubmitting.value = false
laborProgress.value = 0
laborStatus.value = ''
return
}
if (!jobId) {
ElMessage.error('上传失败: 未获取任务编号')
laborSubmitting.value = false
laborProgress.value = 0
laborStatus.value = ''
return
}
laborProgress.value = 100
pollUploadProgress(jobId, {
onTick: (p) => {
laborStatus.value = p.total > 0 ? `服务器处理中 ${p.done}/${p.total}` : '服务器处理中…'
},
onDone: async (result) => {
const data = result || {}
laborResult.value = data
// 刷新材料/参会人 (劳务协议·专家照片回填在参会人上); 日程海报/邀请函 回填在会议字段上, 单独重拉会议
loadMaterials()
loadAttendees()
try {
const info = await bizGet('meeting', meetingId.value)
const m = (info && (info.data || info)) || {}
scheduleUrl.value = m.scheduleUrl || ''
invitationUrl.value = m.invitationUrl || ''
row.value.scheduleUrl = m.scheduleUrl || ''
row.value.invitationUrl = m.invitationUrl || ''
} catch (e) { /* 会议字段刷新失败忽略 */ }
laborSubmitting.value = false
laborProgress.value = 0
laborStatus.value = ''
if (hasLaborFailures(data)) {
laborResultOpen.value = true
} else {
ElMessage.success(`已回填 ${data.updated ?? 0} 项劳务材料`)
}
},
onFail: (msg) => {
ElMessage.error(msg)
laborSubmitting.value = false
laborProgress.value = 0
laborStatus.value = ''
}
})
}
/** 是否存在需弹窗展示的失败 (劳务明细表行 / 劳务协议 / 专家照片 / 其他目录错误) */
function hasLaborFailures(data) {
const detail = (data && data.detail) || {}
const agreement = (data && data.agreement) || {}
const expert = (data && data.expertPhoto) || {}
const errors = Array.isArray(data && data.errors) ? data.errors : []
return (detail.ngNum || 0) > 0
|| (Array.isArray(agreement.failures) && agreement.failures.length > 0)
|| (Array.isArray(expert.failures) && expert.failures.length > 0)
|| errors.length > 0
}
// ===================== 导航 ===================== // ===================== 导航 =====================
function goBack() { function goBack() {
// 角色感知返回: 按 currentRole 跳各自会议列表 (admin/manager/executor/sponsor) // 角色感知返回: 按 currentRole 跳各自会议列表 (admin/manager/executor/sponsor)
@@ -2590,6 +2793,10 @@ onBeforeUnmount(() => {
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; } .agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* 电子签到表 多文件上传 + zip 按钮 纵向排列 */ /* 电子签到表 多文件上传 + zip 按钮 纵向排列 */
.esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; } .esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
/* 现场照片 (签到表/前全景/后全景): 扫码拍照 + 直接上传 左右并存; 与左侧 label 垂直居中对齐 */
.camera-upload-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.camera-upload-row :deep(.camera-qr-upload) { width: auto; flex: 0 0 auto; }
.camera-upload-row :deep(.ht-file-upload) { flex: 1 1 0; min-width: 0; }
.upload-progress { grid-column: 2; display: flex; align-items: center; gap: 8px; max-width: 400px; } .upload-progress { grid-column: 2; display: flex; align-items: center; gap: 8px; max-width: 400px; }
.upload-progress .el-progress { flex: 1; } .upload-progress .el-progress { flex: 1; }
.upload-progress-status { font-size: 12px; color: #8c8c8c; white-space: nowrap; } .upload-progress-status { font-size: 12px; color: #8c8c8c; white-space: nowrap; }
@@ -2638,6 +2845,11 @@ onBeforeUnmount(() => {
/* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */ /* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */
.attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; } .attendee-section { padding-bottom: 16px; border-bottom: 1px dashed #e8e8e8; }
.attendee-subtitle { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 12px; }
.attendee-legend { display: flex; flex-wrap: wrap; gap: 16px; margin: 10px 0 0; font-size: 12px; color: #8c8c8c; }
.attendee-legend .legend-item { display: inline-flex; align-items: center; gap: 4px; }
.attendee-legend .legend-name { color: #f56c6c; font-weight: 600; }
.attendee-legend .legend-row { color: #c45656; background: #fef0f0; border: 1px solid #fde2e2; padding: 0 6px; border-radius: 3px; }
.attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; } .attendee-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; } .form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }
+4 -8
View File
@@ -19,11 +19,11 @@
<el-form-item label="总场次/总期数"> <el-form-item label="总场次/总期数">
<span class="form-value">{{ snap.totalSessions }}</span> <span class="form-value">{{ snap.totalSessions }}</span>
</el-form-item> </el-form-item>
<el-form-item label="项目创建人"> <el-form-item label="项目开始时间">
<span class="form-value">{{ snap.createUserName }}</span> <span class="form-value">{{ snap.projectStartTime }}</span>
</el-form-item> </el-form-item>
<el-form-item label="项目创建时间"> <el-form-item label="项目结束时间">
<span class="form-value">{{ snap.createTime }}</span> <span class="form-value">{{ snap.projectEndTime }}</span>
</el-form-item> </el-form-item>
</div> </div>
@@ -109,8 +109,6 @@ const snap = reactive({
projectName: '', projectName: '',
totalSessions: '', totalSessions: '',
sponsorOrgName: '', sponsorOrgName: '',
createUserName: '',
createTime: '',
projectStartTime: '', projectStartTime: '',
projectEndTime: '' projectEndTime: ''
}) })
@@ -162,8 +160,6 @@ async function loadProjectSnapshot(pid) {
snap.projectName = d.projectName || '' snap.projectName = d.projectName || ''
snap.totalSessions = d.totalSessions ?? '' snap.totalSessions = d.totalSessions ?? ''
snap.sponsorOrgName = d.sponsorOrgName || '' snap.sponsorOrgName = d.sponsorOrgName || ''
snap.createUserName = d.createUserName || ''
snap.createTime = d.createTime || ''
snap.projectStartTime = d.startTime || '' snap.projectStartTime = d.startTime || ''
snap.projectEndTime = d.endTime || '' snap.projectEndTime = d.endTime || ''
// executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次 // executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次
+15 -1
View File
@@ -46,7 +46,14 @@
:main-cols="['projectNo', 'meetingName']" :main-cols="['projectNo', 'meetingName']"
> >
<el-table-column v-if="isRole('admin', 'manager')" type="selection" width="44" /> <el-table-column v-if="isRole('admin', 'manager')" type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" width="170" fixed /> <el-table-column prop="projectNo" label="项目编号" width="170" fixed>
<template #default="{ row }">
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onViewProject(row)">
{{ row.projectNo }}
</el-link>
<span v-else>{{ row.projectNo }}</span>
</template>
</el-table-column>
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" /> <el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" /> <el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip> <el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip>
@@ -191,6 +198,8 @@ const breadcrumb = computed(() =>
) )
// 详情路由前缀: admin 跳 admin-meetings-detail, manager 跳 manager-meetings-detail // 详情路由前缀: admin 跳 admin-meetings-detail, manager 跳 manager-meetings-detail
const detailBase = computed(() => `/${roleSegment.value}/meetings`) const detailBase = computed(() => `/${roleSegment.value}/meetings`)
// 项目详情路由前缀: 各角色跳各自 projects/detail (admin/manager/sponsor/leader 均有该路由)
const projectDetailBase = computed(() => `/${roleSegment.value}/projects/detail`)
// 修改/复制路由名: 按角色选 admin-meetings-new 或 manager-meetings-new // 修改/复制路由名: 按角色选 admin-meetings-new 或 manager-meetings-new
const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'manager-meetings-new') const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'manager-meetings-new')
@@ -260,6 +269,11 @@ function viewDetail(row) {
function viewOnly(row) { function viewOnly(row) {
router.push(`${detailBase.value}/view/${row.meetingId}`) router.push(`${detailBase.value}/view/${row.meetingId}`)
} }
// 点击「项目编号」: 跳转到该项目详情页 (projectId 为 NULL 时不可点)
function onViewProject(row) {
if (row.projectId == null) return
router.push(`${projectDetailBase.value}/${row.projectId}`)
}
// 操作列"更多"下拉分发: 把 cmd 映射到现有 handler // 操作列"更多"下拉分发: 把 cmd 映射到现有 handler
function onMoreAction(cmd, row) { function onMoreAction(cmd, row) {
if (cmd === 'view') viewOnly(row) if (cmd === 'view') viewOnly(row)
+7 -1
View File
@@ -90,7 +90,13 @@ async function load() {
} }
function onSearch() { page.pageNum = 1; load() } function onSearch() { page.pageNum = 1; load() }
function onReset() { q.value = { projectNo: '', title: '' }; onSearch() } function onReset() { q.value = { projectNo: '', title: '' }; onSearch() }
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' } // 只显示年月日, 不显示时分秒 (与 portal/Publicity.vue fmtDate 口径一致)
function fmt(t) {
if (!t) return ''
const d = new Date(t)
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const detailOpen = ref(false) const detailOpen = ref(false)
const current = ref(null) const current = ref(null)
+7 -1
View File
@@ -79,7 +79,13 @@ async function load() {
} }
function onSearch() { page.pageNum = 1; load() } function onSearch() { page.pageNum = 1; load() }
function onReset() { q.value = { projectNo: '', title: '' }; onSearch() } function onReset() { q.value = { projectNo: '', title: '' }; onSearch() }
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' } // 只显示年月日, 不显示时分秒 (与 portal/Publicity.vue fmtDate 口径一致)
function fmt(t) {
if (!t) return ''
const d = new Date(t)
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
const detailOpen = ref(false) const detailOpen = ref(false)
const current = ref(null) const current = ref(null)
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => {
// 开发 API 前缀 = /dev-api (Vite dev proxy), 生产 = /hg-api (nginx 反代) // 开发 API 前缀 = /dev-api (Vite dev proxy), 生产 = /hg-api (nginx 反代)
const apiPath = env.VITE_APP_BASE_API || '/dev-api' const apiPath = env.VITE_APP_BASE_API || '/dev-api'
return { return {
// 部署基路径: prod = / (.env.production), test = /hegui/ (.env.test), 开发默认 / // 部署基路径: prod = / (.env.production), test = /hg/ (.env.test), 开发默认 /
base: env.VITE_BASE || '/', base: env.VITE_BASE || '/',
plugins: [ plugins: [
vue(), vue(),