Compare commits

...
1 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
39 changed files with 1544 additions and 192 deletions
+12
View File
@@ -98,3 +98,15 @@ oalogin/
11.pdf
11_extracted.txt
proto/合规系统0904/
# ===== 密钥 / 敏感数据 (严禁入库) =====
key.txt
*.rdb
*.key
# ===== 本地临时 / 会话脚本 =====
tmp/
claude.sh
# ===== 本地工具链 (自包含 git 等) =====
opt/
@@ -216,6 +216,19 @@ public class SysUserController extends BaseController
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()));
}
/**
* 根据用户编号获取授权角色
*/
@@ -43,10 +43,3 @@ bidding-api:
detail-url: https://zbpublic.guojustar.com/#/detail/
# notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
# detail-url: https://zbpublictest.guojustar.com/#/detail/
# 生产环境日志级别: 覆盖 application.yml 的 debug 默认值, 生产只打印 info 及以上, 减少日志噪音
logging:
level:
com.ruoyi: info
com.ruoyi.business: info
org.springframework: warn
@@ -215,6 +215,6 @@ server:
# 日志配置
logging:
level:
com.ruoyi: debug
org.springframework: debug
com.ruoyi.business: debug
com.ruoyi: info
org.springframework: warn
com.ruoyi.business: info
@@ -284,8 +284,10 @@ public class BizMeetingAttendeeController extends BaseController {
public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) {
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
List<BizMeetingAttendeeImportVo> exportList = new ArrayList<>(list.size());
for (BizMeetingAttendee a : list) {
exportList.add(BizMeetingAttendeeImportVo.from(a));
for (int i = 0; i < list.size(); i++) {
BizMeetingAttendeeImportVo vo = BizMeetingAttendeeImportVo.from(list.get(i));
vo.setSeq(String.valueOf(i + 1));
exportList.add(vo);
}
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
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.StageDeriver;
import com.ruoyi.business.service.PosterService;
import com.ruoyi.business.service.SuperManagerChecker;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.mapper.SysUserMapper;
@@ -72,6 +73,8 @@ public class BizMeetingController extends BaseController {
private StageDeriver stageDeriver;
@Autowired
private PosterService posterService;
@Autowired
private SuperManagerChecker superManagerChecker;
@GetMapping("/list")
public TableDataInfo list(BizMeeting bizMeeting,
@@ -115,7 +118,8 @@ public class BizMeetingController extends BaseController {
bizMeeting.getParams().put("assignedExecutorUserId", aggUid);
}
// 合规人员(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);
}
// 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己)
@@ -205,6 +205,35 @@ public class BizMeetingMaterialController extends BaseController {
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 上传 (同步, 追加式保存).
* <p>
@@ -39,6 +39,7 @@ import com.ruoyi.business.service.IBizProjectAssignService;
import com.ruoyi.business.service.IBizProjectRatingService;
import com.ruoyi.business.service.IBizProjectSponsorAssignService;
import com.ruoyi.business.service.IBizProjectExecutorAssignService;
import com.ruoyi.business.service.SuperManagerChecker;
import com.ruoyi.system.domain.vo.SysUserExtendVo;
import com.ruoyi.business.mapper.BizSysUserQueryMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
@@ -78,6 +79,8 @@ public class BizProjectController extends BaseController
private IBizExpertService bizExpertService;
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private SuperManagerChecker superManagerChecker;
/**
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
@@ -110,8 +113,9 @@ public class BizProjectController extends BaseController
public TableDataInfo list(BizProject bizProject)
{
// 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制
// manager01 等白名单超级合规员不受此限制 (等同 admin 可见全部项目)
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) {
if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
} else if ("leader".equals(roleType)) {
bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId());
@@ -817,11 +821,11 @@ public class BizProjectController extends BaseController
util.exportExcel(response, exportList, "报名专家");
}
/** 与 list() 一致的导出数据范围: manager 只看本人创建的项目; 勾选时按 projectIds 过滤 */
/** 与 list() 一致的导出数据范围: manager 只看本人创建的项目 (白名单超级合规员不受限); 勾选时按 projectIds 过滤 */
private void applyManagerScope(BizProject bizProject)
{
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) {
if ("manager".equals(roleType) && !superManagerChecker.isUnrestrictedManager()) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
}
// 勾选的项目: 前端勾选时传 projectIds, 转 params.projectIds 做 IN 过滤
@@ -34,8 +34,9 @@ public class AdminUserCreateBody {
private String practiceCertUrl;
private String titleCertUrl;
// ===== executor / sponsor 专用 =====
/** MAIN=新建单位(主账号) / SUB=选已有单位(子账号) */
// ===== executor / sponsor / manager 专用 =====
/** executor/sponsor: MAIN=新建单位(主账号) / SUB=选已有单位(子账号);
* manager: MAIN=全局管理员(可见全量) / SUB=普通合规员(只看本人创建), 默认 SUB */
private String accountType;
/** SUB: 所属单位 biz_org.org_id */
private Long orgId;
@@ -21,29 +21,29 @@ import com.ruoyi.common.annotation.Excel;
*/
public class BizMeetingAttendeeImportVo {
/** 序号 (导入模板展示行号 / 导出自动填行号; 导入时读取但忽略, 不落库) */
@Excel(name = "序号", sort = 1)
private String seq;
/** 医生 (原"姓名") */
@Excel(name = "医生##ysxm", sort = 1)
@Excel(name = "医生##ysxm", sort = 2)
private String name;
/** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */
@Excel(name = "联系电话##lxdh", sort = 2)
private String phone;
/** 医院 (原"工作单位") */
@Excel(name = "医院##yy", sort = 3)
private String workUnit;
/** 职称 */
@Excel(name = "职称##zc", sort = 3)
private String title;
/** 科室 */
@Excel(name = "科室##ks", sort = 4)
private String department;
/** 职称 */
@Excel(name = "职称##zc", sort = 5)
private String title;
/** 医院 (原"工作单位") */
@Excel(name = "医院##yy", sort = 5)
private String workUnit;
/** 身份证号 */
@Excel(name = "身份证号##sfzh", sort = 6)
private String idCard;
/** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */
@Excel(name = "联系电话##lxdh", sort = 6)
private String phone;
/** 银行 */
@Excel(name = "银行##yh", sort = 7)
@@ -57,46 +57,52 @@ public class BizMeetingAttendeeImportVo {
@Excel(name = "开户行##khh", sort = 9)
private String bankBranch;
/** 角色 (原"劳务形式": 授课/主持/评审...) */
@Excel(name = "角色", sort = 10)
private String laborForm;
/** 应发金额 (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;
/** 身份证号 */
@Excel(name = "身份证号##sfzh", sort = 10)
private String idCard;
/** 实发金额 (decimal, 元) */
@Excel(name = "实发金额##sfje", sort = 14)
@Excel(name = "实发金额##sfje", sort = 11)
private BigDecimal fee;
/** 摘要 */
@Excel(name = "摘要##zy", sort = 15)
@Excel(name = "摘要##zy", sort = 12)
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;
/** 开户银行地址(省/市) */
@Excel(name = "开户银行地址", sort = 17)
@Excel(name = "开户银行地址", sort = 18)
private String bankRegion;
/** 银行详细地址 */
@Excel(name = "银行详细地址", sort = 18)
@Excel(name = "银行详细地址", sort = 19)
private String bankAddress;
/** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */
@Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT)
@Excel(name = "身份证附件", sort = 20, type = Excel.Type.EXPORT)
private String idCardAttachments;
public String getSeq() { return seq; }
public void setSeq(String seq) { this.seq = seq; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getName() { return name; }
@@ -74,6 +74,15 @@ public interface IBizMeetingAttendeeService {
*/
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) 档案, 返回不一致字段列表.
*
@@ -128,6 +128,35 @@ public interface IBizMeetingMaterialService {
*/
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 上传 (同步, 追加式保存).
* <p>
@@ -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 反查得到姓名) */
private SysUser insertPlain(AdminUserCreateBody b, String role, String 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);
if ("manager".equals(role)) {
BizPerson p = new BizPerson();
@@ -476,9 +476,19 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
@Override
public ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception {
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);
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
List<BizMeetingAttendeeImportVo> rows = util.importExcel(in);
if (rows == null || rows.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
@@ -512,12 +522,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
result.fail(rowNo, "手机号格式不正确: " + phone);
continue;
}
// 身份证号位数校验 (18位, 末位可为 X/x); 空值跳过 (导入不强制必填, 与前端 dialog 的必填规则分开)
// 身份证号不校验格式 (可能为护照号等非18位证件号); 仅 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 不能为空;
// 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库)
String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim();
@@ -789,8 +795,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
public byte[] buildAttendeeInfoExcel(List<BizMeetingAttendee> list) {
List<BizMeetingAttendeeImportVo> voList = new ArrayList<>();
if (list != null) {
for (BizMeetingAttendee a : list) {
voList.add(BizMeetingAttendeeImportVo.from(a));
for (int i = 0; i < list.size(); i++) {
BizMeetingAttendeeImportVo vo = BizMeetingAttendeeImportVo.from(list.get(i));
vo.setSeq(String.valueOf(i + 1));
voList.add(vo);
}
}
SXSSFWorkbook wb = new SXSSFWorkbook(500);
@@ -899,13 +907,18 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
uploadProgressRegistry.update(jobId, processed);
continue;
}
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
String fileName = (!f.fileNames.isEmpty()) ? f.fileNames.get(0) : "labor-protocol.pdf";
String url = ossUploader.upload(f.files.get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
// 劳务协议可多文件: 目录内所有文件都上传 OSS (不打包 zip), 逗号拼接覆盖式回填 labor_protocol, 前端按逗号拆分逐个预览
StringBuilder sb = new StringBuilder();
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();
entity.setId(m.attendee.getId());
entity.setLaborProtocol(url);
entity.setLaborProtocol(sb.toString());
entity.setUpdateBy(username);
updateLaborProtocol(entity);
updated++;
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.text.SimpleDateFormat;
import java.util.HashSet;
import java.util.List;
@@ -12,6 +13,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.net.URI;
import java.io.ByteArrayInputStream;
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.BizMeetingAttendee;
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.BizMeetingInvoiceMapper;
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_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 按目录名匹配 */
private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>();
static {
@@ -127,6 +135,29 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
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";
@Autowired
@@ -217,6 +248,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
for (BizMeetingMaterial m : list) {
m.setId(null);
m.setMeetingId(meetingId);
checkServiceFileCount(m);
BizMeetingMaterial old = oldBySubType.get(m.getSubType());
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
if (unchanged) {
@@ -876,30 +908,37 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
BizMeetingMaterial old = existingBySubType.get(subType);
// 先算好要上传的字节 + 文件名 (单文件原样 / 多文件先打 zip), 便于做"内容未变"判断
String fileName;
byte[] toUpload;
boolean isZip = files.size() > 1;
// 多文件数量上限 (JSON 数组写 oss_url, 防超列宽): 超限直接失败, 前端轮询展示错误
if (files.size() > SERVICE_MAX_FILES) {
throw new ServiceException(label + ": 单个材料分类最多上传 " + SERVICE_MAX_FILES + " 个文件, 当前 " + files.size() + " 个");
}
// 单文件: 原样直传; 多文件: 逐个直传 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) {
fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg");
toUpload = files.get(0);
byte[] 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 {
fileName = label + ".zip";
toUpload = zipFiles(files, names);
List<String> urls = new ArrayList<>();
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, 保留旧金额, 不触发会务费重算
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());
// 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单/多文件里任一可识别 (jpg/png/pdf/zip) → 待 OCR(0), 其余已计算(1)
int feeStatus = ocrNeeded ? 0 : 1;
Long materialId;
@@ -1021,7 +1060,590 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
private boolean needsOcr(BizMeetingMaterial m) {
if (m == null || m.getOssUrl() == null || m.getOssUrl().isEmpty()) 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 文件");
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 + " 个签到表文件");
// 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件)
@@ -1286,11 +1913,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
ins.setMaterialType("LABOR");
ins.setSubType("L_ESIGN_IN");
ins.setOssUrl(mergedOssUrl);
ins.setFileName(originalName);
ins.setFileName(files.get(0).name);
ins.setMaskedJson(mergedMaskedJson);
ins.setAmount(null);
ins.setFeeStatus(1); // 非发票, 不参与费用汇总
ins.setCreatorId(SecurityUtils.getUserId());
ins.setCreatorId(userId);
ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insert(ins);
@@ -2,9 +2,13 @@ package com.ruoyi.business.service.impl;
import java.io.File;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
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.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
@@ -91,6 +95,9 @@ public class InvoiceOcrService
}
// 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参
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)
if (oldMaterialId != null)
@@ -100,8 +107,8 @@ public class InvoiceOcrService
materialService.updateAmount(oldMaterialId, BigDecimal.ZERO);
}
// 2. 单文件: 插 UNRECOGNIZED 占位行 (zip 路径跳过 — 解压才知道有几张)
if (!isZip)
// 2. 单文件: 插 UNRECOGNIZED 占位行 (zip / 多文件 路径跳过 — 解压/遍历才知道有几张)
if (!isZip && !isMulti)
{
BizMeetingInvoice placeholder = new BizMeetingInvoice();
placeholder.setMeetingId(meetingId);
@@ -115,7 +122,7 @@ public class InvoiceOcrService
invoiceMapper.insert(placeholder);
}
// 3. 后台执行 OCR (单文件或 zip)
// 3. 后台执行 OCR (单文件 / zip / 多文件)
final String url = ossUrl;
ocrExecutor.submit(() ->
{
@@ -125,6 +132,10 @@ public class InvoiceOcrService
{
recognizeZip(materialId, meetingId, url, creatorId);
}
else if (isMulti)
{
recognizeMultiple(materialId, meetingId, multiUrls, creatorId);
}
else
{
recognizeSingle(materialId, url);
@@ -132,8 +143,8 @@ public class InvoiceOcrService
}
catch (Exception e)
{
log.warn("OCR 后台任务失败 materialId={} isZip={} err={}", materialId, isZip, e.getMessage(), e);
if (!isZip)
log.warn("OCR 后台任务失败 materialId={} isZip={} isMulti={} err={}", materialId, isZip, isMulti, e.getMessage(), e);
if (!isZip && !isMulti)
{
// 单文件: 占位行标 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), 重新识别
* <p>
@@ -308,6 +308,7 @@
where is_deleted = 0
and is_executed = 0
and is_frozen = 0
and is_finished = 0
and start_time is not null
and start_time &lt;= NOW()
and end_time &gt; NOW()
@@ -323,6 +324,7 @@
where is_deleted = 0
and is_executed = 0
and is_frozen = 0
and is_finished = 0
and end_time is not null
and end_time &lt;= NOW()
and labor_audit_stage = 'NOT_SUBMITTED'
@@ -337,6 +339,7 @@
current_stage = 'FROZEN'
where is_deleted = 0
and is_frozen = 0
and is_finished = 0
and (labor_audit_stage in ('NOT_SUBMITTED', 'REJECTED')
or service_audit_stage in ('NOT_SUBMITTED', 'REJECTED'))
and submit_deadline is not null
@@ -56,10 +56,11 @@
(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')) as done_sessions,
<!-- 未执行会议数: 仅 NOT_STARTED -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
<!-- 未执行 = 总数 - 已执行 (含未建会议/执行中的场次) -->
(greatest(0, p.total_sessions
- (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')))) 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.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)
@@ -90,9 +91,10 @@
(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')) as done_sessions,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
(greatest(0, p.total_sessions
- (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')))) 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.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)
@@ -238,11 +240,15 @@
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 done_sessions,
<!-- 执行方级未执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED'
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions
<!-- 执行方级未执行 = 分给本执行方的场次 - 已执行 (含未建会议/执行中的场次) -->
(greatest(0, (select coalesce(sum(bpa.sessions), 0)
from biz_project_assign bpa
where bpa.project_id = p.project_id and bpa.is_deleted = 0
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
<!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
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
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,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED'
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions
(greatest(0, (select coalesce(sum(bpa.sessions), 0)
from biz_project_assign bpa
where bpa.project_id = p.project_id and bpa.is_deleted = 0
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
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
@@ -58,7 +58,9 @@ public class SysUser extends BaseEntity
/** 删除标志(0代表存在 2代表删除) */
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 = "账号类型")
private String accountType;
@@ -70,7 +70,7 @@ public class HttpUtils
try
{
String urlNameString = StringUtils.isNotBlank(param) ? url + "?" + param : url;
log.info("sendGet - {}", urlNameString);
log.debug("sendGet - {}", urlNameString);
URL realUrl = new URL(urlNameString);
URLConnection connection = realUrl.openConnection();
// 连接/读超时: 防止外部接口不可达时无限挂起 (后台子任务会占死单线程调度器)
@@ -86,7 +86,7 @@ public class HttpUtils
{
result.append(line);
}
log.info("recv - {}", result);
log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200)));
}
catch (ConnectException e)
{
@@ -148,7 +148,7 @@ public class HttpUtils
StringBuilder result = new StringBuilder();
try
{
log.info("sendPost - {}", url);
log.debug("sendPost - {}", url);
URL realUrl = new URL(url);
URLConnection conn = realUrl.openConnection();
conn.setRequestProperty("accept", "*/*");
@@ -167,7 +167,7 @@ public class HttpUtils
{
result.append(line);
}
log.info("recv - {}", result);
log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200)));
}
catch (ConnectException e)
{
@@ -217,7 +217,7 @@ public class HttpUtils
String urlNameString = url + "?" + param;
try
{
log.info("sendSSLPost - {}", urlNameString);
log.debug("sendSSLPost - {}", urlNameString);
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[] { new TrustAnyTrustManager() }, new java.security.SecureRandom());
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));
}
}
log.info("recv - {}", result);
log.info("recv(len={}) - {}", result.length(), result.substring(0, Math.min(result.length(), 200)));
conn.disconnect();
br.close();
}
@@ -1,5 +1,6 @@
package com.ruoyi.common.utils.poi;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -746,6 +747,36 @@ public class ExcelUtil<T>
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表单
*
@@ -17,6 +17,7 @@ import org.springframework.web.filter.CorsFilter;
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
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.LogoutSuccessHandlerImpl;
@@ -32,6 +33,8 @@ public class SecurityConfig
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
@@ -54,7 +57,7 @@ public class SecurityConfig
.headers((headersCustomizer) -> {
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))
.authorizeHttpRequests((requests) -> {
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)
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage());
log.warn("请求地址'{}',权限校验失败'{}'", requestURI, e.getMessage());
return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
}
@@ -239,6 +239,16 @@ public interface ISysUserService
*/
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);
}
/**
* 切换合规人员(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 '用户账号',
nick_name varchar(30) not null comment '用户昵称',
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)',
email varchar(50) default '' comment '用户邮箱',
phonenumber varchar(11) default '' comment '手机号码',
+22 -18
View File
@@ -23,21 +23,24 @@
</el-button>
<span v-if="photos.length" class="cam-count">{{ photos.length }} 张</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>
</div>
<!-- 多张缩略图: 点任意一张走 el-image 全屏轮播预览 (签到表多张 / 前后全景单张) -->
<div v-if="photos.length" class="camera-thumbs">
<el-image
v-for="(p, i) in photos"
:key="i"
:src="p"
:preview-src-list="photos"
:initial-index="i"
fit="cover"
class="thumb"
/>
</div>
<!-- 预览弹窗: 不直接铺缩略图, 点「预览」打开 el-dialog 查看 (签到表多张 / 前后全景单张) -->
<el-dialog v-model="previewVisible" :title="`${label}预览`" width="640px" append-to-body>
<div class="preview-grid">
<el-image
v-for="(p, i) in photos"
:key="i"
:src="p"
:preview-src-list="photos"
:initial-index="i"
fit="contain"
class="preview-img"
/>
</div>
</el-dialog>
<el-dialog
v-model="visible"
@@ -83,6 +86,7 @@ const PAGE_PATH = {
}
const visible = ref(false)
const previewVisible = ref(false)
const qrUrl = ref('')
const qrLoading = ref(false)
const polling = ref(false)
@@ -184,14 +188,14 @@ onBeforeUnmount(stopPolling)
.cam-count { color: #67c23a; font-size: 13px; }
.cam-polling { color: #e6a23c; font-size: 12px; }
.camera-thumbs {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 8px;
.preview-grid {
display: flex; flex-wrap: wrap; gap: 12px;
max-height: 70vh; overflow-y: auto;
}
.thumb {
width: 56px; height: 56px;
.preview-img {
width: 180px; height: 180px;
border: 1px solid #ebeef5; border-radius: 4px;
cursor: pointer;
object-fit: contain; background: #f5f7fa;
}
.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 class="file-actions">
<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 v-if="!readonly && modelList.length" class="ht-file-add" @click.stop="handleClick">
@@ -66,7 +66,7 @@
</div>
<div class="file-actions">
<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>
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
<div v-if="showPreview" class="preview-box">
@@ -105,6 +105,8 @@ const props = defineProps({
maxSize: { type: Number, default: 200 }, // MB
block: { 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/其他)
multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串
})
@@ -232,6 +234,10 @@ async function onFileChange(e) {
const list = [...modelList.value]
let added = 0
for (const file of files) {
if (props.maxCount && list.length >= props.maxCount) {
ElMessage.warning(`最多上传 ${props.maxCount} 个文件`)
break
}
if (!validateFile(file)) continue
uploading.value = true
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 label="管理员" width="80" align="center">
<template #default="{ row }">
<el-tag :type="isOrgAdmin(row) ? 'success' : 'danger'" disable-transitions>
{{ isOrgAdmin(row) ? '是' : '否' }}
<el-tag :type="isAdminAccount(row) ? 'success' : 'danger'" disable-transitions>
{{ isAdminAccount(row) ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
@@ -100,6 +100,12 @@
<el-form-item label="邮箱">
<el-input v-model="editForm.email" />
</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>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
@@ -130,6 +136,12 @@
<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.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>
<!-- doctor: 评审专家 -->
@@ -201,10 +213,12 @@ const ROLE_LABEL = {
}
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
// 机构管理员 = 执行方/支持方单位的主账号。
// admin/manager/doctor/leader 的 account_type 也是 'MAIN' (DB 默认值), 必须限定角色排除, 否则会被误判成管理员
function isOrgAdmin(row) {
return (row.roleType === 'executor' || row.roleType === 'sponsor') && row.accountType === 'MAIN'
// 管理员 = account_type 为 MAIN 的角色:
// executor/sponsor: 机构主账号 (机构管理员)
// manager: 全局管理员 (可见全量, 复用 account_type 标记)
// 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 })
@@ -232,12 +246,13 @@ function reset() {
}
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) {
Object.assign(editForm, {
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
}
@@ -246,6 +261,10 @@ async function saveEdit() {
try {
// 2026-08-16: 角色统一存 sys_user.role_type, 单次 PUT 即可 (mapper updateUser 已包含 role_type 字段)
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('保存成功')
editVisible.value = false
load()
@@ -330,7 +349,8 @@ function openCreate() {
}
function onRoleChange() {
createForm.accountType = 'MAIN'
// manager 默认 SUB (普通合规员); executor/sponsor 默认 MAIN (新建单位)
createForm.accountType = createForm.roleType === 'manager' ? 'SUB' : 'MAIN'
orgOptions.value = []
}
+16 -2
View File
@@ -20,7 +20,14 @@
</el-form>
<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="currentStage" label="当前阶段" width="140">
<template #default="{ row }">
@@ -114,7 +121,7 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList } from '@/api/public'
@@ -124,6 +131,7 @@ import PdfViewer from '@/components/PdfViewer.vue'
// Home "待签署的协议" 跳转过来带 ?signedStatus=unsigned: 列表只显示未签劳务协议的会议
// 后端 BizMeetingMapper.selectList 据此加 EXISTS 子查询过滤 attendee_labor_protocol IS NULL
const route = useRoute()
const router = useRouter()
const signedStatus = ref(String(route.query.signedStatus || '') || '')
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
@@ -169,6 +177,12 @@ function onViewLabor(row) {
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) =====
function isPdf(url) {
if (!url) return false
+1 -1
View File
@@ -253,7 +253,7 @@ async function load() {
// 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer)
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
signed.value = true
laborPdfUrl.value = data.laborProtocol || ''
laborPdfUrl.value = (data.laborProtocol || '').split(',')[0].trim()
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
+14 -1
View File
@@ -37,7 +37,14 @@
<GrTable :data="rows" v-loading="loading" stripe border
: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="projectForm" label="项目形式" width="100" align="center" />
<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}`) }
// 点击「项目编号」: 跳转到该项目详情页 (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}`) }
// 冻结中 → 上传材料置灰不可点 (仅 executor 列表; 解冻后 isFrozen 翻 0 自动恢复)
+236 -39
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">{{ 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">{{ 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">{{ fmtDateTime(row.startTime) }}</span></div>
<div class="info-row"><span class="info-label">支持单位:</span><span class="info-value">{{ row.orgName || '-' }}</span></div>
@@ -95,9 +95,6 @@
<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="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 label="应发金额" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
@@ -113,6 +110,9 @@
</el-table-column>
<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="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">
<template #default="{ row }">
<el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link>
@@ -121,7 +121,7 @@
</el-table-column>
<el-table-column label="劳务协议" width="120" align="center">
<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>
</template>
</el-table-column>
@@ -171,6 +171,10 @@
<span class="fee-label">劳务费合计:</span>
<span class="fee-value">¥ {{ laborFeeTotal.toFixed(2) }}</span>
</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 class="file-list" style="margin-top: 16px;">
@@ -198,16 +202,17 @@
<template v-else>
<div v-if="isCameraSubType(r.subType)" class="camera-upload-row">
<CameraQrUpload v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" accept=".png,.jpg,.jpeg,.gif,.webp,.bmp" placeholder="直接上传(本地图片)" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" class="file-uploader" />
<!-- 直接上传本地图片: 仅 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'">
<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>
<input ref="esignZipInput" type="file" accept=".zip" style="display:none" @change="onEsignZipChange" />
</div>
</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>
</template>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
@@ -228,14 +233,28 @@
</div>
<div class="file-row">
<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>
<div class="file-row">
<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>
<!-- 打包上传: 下载空目录模板 + 上传劳务材料 (劳务明细表/劳务协议/企业权益/电子签到表/签到表/前全景/后全景/专家照片/日程海报/邀请函) -->
<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 详情页隐掉海报生成 (含 生成海报/预览海报) -->
<div v-if="!isManager && !isExecutor && !isReadonly" class="file-row">
<span class="file-label">海报生成:</span>
@@ -274,7 +293,7 @@
</div>
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<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>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
@@ -303,7 +322,7 @@
<div class="file-list">
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
<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>
</div>
@@ -312,7 +331,7 @@
<div class="file-list">
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
<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>
</div>
@@ -441,7 +460,7 @@
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
<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 label="身份证附件" prop="idCardAttachments" style="grid-column: 1 / -1">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
@@ -553,6 +572,7 @@
<div v-else class="text-muted">尚未生成海报</div>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<LaborUploadResultDialog v-model="laborResultOpen" :result="laborResult" />
<!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) -->
<el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false">
@@ -587,6 +607,22 @@
<div v-else class="text-muted" style="padding: 24px 0; text-align: center;">暂无可预览的签到数据</div>
</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">
<el-button @click="goBack">返回</el-button>
</div>
@@ -606,6 +642,7 @@ import CameraQrUpload from '@/components/CameraQrUpload.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
import LaborUploadResultDialog from '@/components/LaborUploadResultDialog.vue'
import Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
@@ -638,6 +675,10 @@ const previewUrls = ref([])
const previewTitle = ref('附件预览')
/** 电子签到表 (sponsor) 脱敏预览 dialog 状态 */
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 activeTab = ref('labor')
const saving = ref(false)
@@ -677,11 +718,7 @@ const displayTotalPeriods = computed(() => {
})
const periodDisplay = computed(() => {
const p = row.value.periodNo
const t = displayTotalPeriods.value
if (p == null && t == null) return '-'
if (p == null) return `${t}`
if (t == null) return `${p}`
return `${p}/${t}`
return p == null ? '-' : `${p}`
})
const AUDIT_STAGE_LABEL = { NOT_SUBMITTED: '未提交', SUBMITTED: '已提交', APPROVED: '审核通过', REJECTED: '审核驳回' }
function fmtAuditStage(v) { if (!v) return '-'; return AUDIT_STAGE_LABEL[v] || v }
@@ -691,10 +728,15 @@ function fmtMoney(v) {
return (isNaN(n) ? 0 : n).toFixed(2)
}
function isOne(v) { return v === 1 || v === '1' || v === true }
/** sponsor 看脱敏 PDF, 其他角色看完整 PDF */
function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
return row.laborProtocol || ''
/** 劳务协议文件列表: 单 URL / 逗号分隔多 URL → [{url, name}] (sponsor 看脱敏版) */
function laborProtocolFiles(row) {
const raw = isSponsor.value ? (row.laborProtocolMasked || '') : (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" 拆成非空数组 (正反面) */
function idCardFiles(row) {
@@ -715,18 +757,37 @@ function openOnSitePhotos(row) {
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 =====================
const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '' },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '' },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '' },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '' },
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '' },
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg' },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' },
{ type: 'SERVICE', subType: 'M_MATERIAL', label: '物料制作', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_HOTEL', label: '酒店', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_TRAFFIC_BIG', label: '大交通', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_TRAFFIC_SMALL', label: '小交通', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_EXECUTION', label: '执行费', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_DESIGN', label: '设计费', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_OTHER', label: '其他', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '', multi: true },
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg', multi: true },
{ type: 'SERVICE', subType: 'M_INVOICE', 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_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
@@ -738,6 +799,8 @@ const ROW_CONFIG = [
/** 会务材料上传默认扩展名 (含 Office/txt); 个别行 (如盖章结算单) 用 r.accept 覆盖 */
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) */
const NON_OCR_SUBTYPES = new Set(['L_ENTERPRISE_BENEFIT', 'L_ESIGN_IN', 'L_SIGN_IN', 'L_PANORAMA_FRONT', 'L_PANORAMA_BACK'])
/** 扫码拍照 subType: 这些行用 CameraQrUpload 而非 OssFileUploader */
@@ -1086,8 +1149,7 @@ const attendeeRules = {
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
idCard: [
{ required: true, message: '请填写身份证号', trigger: 'blur' },
{ pattern: /^\d{17}[\dXx]$/, message: '身份证号应为18位(末位可为X)', trigger: 'blur' }
{ required: true, message: '请填写身份证号/护照号', trigger: 'blur' }
],
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
@@ -2185,9 +2247,19 @@ function isImageUrl(url) {
return /\.(jpe?g|png)$/i.test(url || '')
}
/** 判断是否可识别: 图片或 PDF */
/** 判断是否可识别: 图片/PDF/zip; 会务材料多文件 (oss_url 存 JSON 数组) 任一可识别即 true */
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)
}
/**
@@ -2541,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() {
// 角色感知返回: 按 currentRole 跳各自会议列表 (admin/manager/executor/sponsor)
@@ -2600,8 +2793,8 @@ onBeforeUnmount(() => {
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* 电子签到表 多文件上传 + zip 按钮 纵向排列 */
.esign-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
/* 现场照片 (签到表/前全景/后全景): 扫码拍照 + 直接上传 左右并存 */
.camera-upload-row { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
/* 现场照片 (签到表/前全景/后全景): 扫码拍照 + 直接上传 左右并存; 与左侧 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; }
@@ -2653,6 +2846,10 @@ onBeforeUnmount(() => {
/* 参会人管理 (放在 劳务材料 tab 顶部, 在 劳务明细 + 劳务协议 上面) */
.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; }
.form-hint { font-size: 12px; color: #909399; margin-top: -8px; margin-bottom: 8px; line-height: 1.5; grid-column: 1 / -1; }
+15 -1
View File
@@ -46,7 +46,14 @@
:main-cols="['projectNo', 'meetingName']"
>
<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="projectForm" label="项目形式" width="100" align="center" />
<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
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
const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'manager-meetings-new')
@@ -260,6 +269,11 @@ function viewDetail(row) {
function viewOnly(row) {
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
function onMoreAction(cmd, row) {
if (cmd === 'view') viewOnly(row)