feat: OA(ecology)项目拉取 + 会议邀请函PDF/微信分享 + 参会人/执行意向增强

OA ecology:
- BizEcologyController + EcologyProjectService: 项目编号搜索/详情回填, 负责人静默建档
- ComplianceLoginService: 合规/负责人登录校验 + 视图 role 匹配
- application-prod/test.yml: ecology 只读库数据源

会议邀请函/分享:
- InvitationPdfService + WxShareService: 邀请函 PDF 生成 + 微信分享
- PdfViewer.vue (pdfjs-dist) + MeetingInvitation.vue: PDF 预览 + 邀请函页

参会人/执行意向:
- BizMeetingAttendee*: 参会人模块增强
- BizExecutionIntent*: 执行意向增强

签到/劳务/导入:
- BizSignServiceImpl + SignFill.vue: 签到劳务
- FieldMismatch/ImportMismatch: 导入校验 VO

公示/会议详情:
- PublicityDetail.vue / MeetingDetail.vue 等前端增强

配置:
- test 短信 mock + 供应商/招标接口切生产域名
This commit is contained in:
郭庆泰
2026-09-03 20:12:15 +08:00
parent beb9799a06
commit 56615f9806
55 changed files with 3296 additions and 516 deletions
@@ -20,6 +20,8 @@ spring:
# 生产环境外部接口地址
ruoyi:
sms:
# 生产环境: aliyun 真发短信
mock-enabled: false
esignBaseUrl: https://hegui.bahim.org.cn
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
@@ -16,18 +16,20 @@ spring:
# 测试环境外部接口地址
ruoyi:
sms:
# 测试环境: 验证码固定 1234 且不真发短信
mock-enabled: true
esignBaseUrl: https://risingdoctor.com/hg
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://risingdoctor.com/camera/
supplier-account-api:
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
bidding-api:
notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
detail-url: https://zbpublictest.guojustar.com/#/detail/
notice-list-url: https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list
detail-url: https://zbpublic.guojustar.com/#/detail/
@@ -1,93 +1,3 @@
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
profile: D:/ruoyi/uploadPath
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
sign-token-secret: hwt-sign-secret-2026
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
captchaType: math
# OSS 配置 (前端直传签名, secretKey 仅后端使用)
oss:
enabled: true
endpoint: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
bucket: hwtossbamlorgcn
accessKeyId: LTAI5tQKWWu96ihmg2VChQsY
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
dirPrefix: ry8080/
expireSeconds: 3600
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
# 阿里云短信配置 (hwt-code ali.sms 模式)
# dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发
sms:
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
signName: 北京整合医学学会
template: SMS_291440833
esignTemplate: SMS_512040098
endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
ocr:
version: 0.1.0
# 模型目录: 绝对路径 → 直读; 相对路径 → 相对进程工作目录 (java -jar 启动目录 = jar 同目录, 部署时 models/ 与 jar 同级)
models-dir: models
# 模型版本: v5_mobile (H=48, 20MB, 推荐) / v5_server (90MB+) / v4_mobile (15MB, 最快)
model-version: v5_mobile
# 单页 OCR 超时 / 整流程超时 (秒)
page-timeout-s: 15
total-timeout-s: 60
# QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段)
qr-full-ocr: false
lang: ch
# PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX)
use-pdf-text-first: true
pdf-text-min-chars: 30
upload:
max-mb: 20
pdf-dpi: 150
# 供应商账号接口 (拉取/解密) 配置 (base-url 按环境拆分到 application-test/prod.yml)
supplier-account-api:
# 每页条数
page-size: 20
# 每次拉取最近 N 分钟更新的数据
pull-minutes: 5
# 供应商账号接口 AES 密钥 (supplier-account-api-aes.key 按环境拆分到 application-test/prod.yml)
# 招标公告查询接口 (notice-list-url / detail-url 按环境拆分到 application-test/prod.yml)
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: 8080
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数,默认为100
accept-count: 1000
threads:
# tomcat最大线程数,默认为200
max: 800
# Tomcat启动初始化的线程数,默认值10
min-spare: 100
# 日志配置
logging:
level:
com.ruoyi: debug
org.springframework: debug
com.ruoyi.business: debug
# Spring配置
spring:
@@ -192,4 +102,99 @@ xss:
# 排除链接(多个用逗号分隔)
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
urlPatterns: /system/*,/monitor/*,/tool/*
# 项目相关配置
ruoyi:
# 名称
name: RuoYi
# 版本
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
profile: D:/ruoyi/uploadPath
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
sign-token-secret: hwt-sign-secret-2026
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
captchaType: math
# OSS 配置 (前端直传签名, secretKey 仅后端使用)
oss:
enabled: true
endpoint: https://hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com
bucket: hwtossbamlorgcn
accessKeyId: LTAI5tQKWWu96ihmg2VChQsY
accessKeySecret: gA0fTFa8arUYaYA5Us2ClPKVSM1Ypv
dirPrefix: ry8080/
expireSeconds: 3600
# 阿里云函数计算 (FC) 打 zip 端点 (与 hwt-serve 共用同一 bucket, 参考 OssApi.ossDownload)
zip-func-url: https://zip-oss-func-zip-oss-swqpembfsl.cn-beijing.fcapp.run
# 阿里云短信配置 (hwt-code ali.sms 模式)
# mock-enabled 按环境拆分到 application-test/prod.yml: test=true 固定码 1234 不真发, prod=false aliyun 真发
sms:
accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
signName: 北京整合医学学会
template: SMS_291440833
esignTemplate: SMS_512040098
endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
ocr:
version: 0.1.0
# 模型目录: 绝对路径 → 直读; 相对路径 → 相对进程工作目录 (java -jar 启动目录 = jar 同目录, 部署时 models/ 与 jar 同级)
models-dir: models
# 模型版本: v5_mobile (H=48, 20MB, 推荐) / v5_server (90MB+) / v4_mobile (15MB, 最快)
model-version: v5_mobile
# 单页 OCR 超时 / 整流程超时 (秒)
page-timeout-s: 15
total-timeout-s: 60
# QR 命中后是否继续跑全量 OCR (false = 快路径只返回 QR 3 字段)
qr-full-ocr: false
lang: ch
# PDF 优先抽内嵌文本层 (电子发票秒出, 扫描件自动回退 ONNX)
use-pdf-text-first: true
pdf-text-min-chars: 30
upload:
max-mb: 20
pdf-dpi: 150
# 供应商账号接口 (拉取/解密) 配置 (base-url 按环境拆分到 application-test/prod.yml)
supplier-account-api:
# 每页条数
page-size: 20
# 每次拉取最近 N 分钟更新的数据
pull-minutes: 5
# 供应商账号接口 AES 密钥 (supplier-account-api-aes.key 按环境拆分到 application-test/prod.yml)
# 招标公告查询接口 (notice-list-url / detail-url 按环境拆分到 application-test/prod.yml)
# 公众号 JS-SDK 分享签名 (appId/appSecret 与 hwt-serve biz_conf 表一致)
wx:
app-id: wx2414cddf4bd56092
app-secret: 792300428a4998da9294e263ec55e71a
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: 8080
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数,默认为100
accept-count: 1000
threads:
# tomcat最大线程数,默认为200
max: 800
# Tomcat启动初始化的线程数,默认值10
min-spare: 100
# 日志配置
logging:
level:
com.ruoyi: debug
org.springframework: debug
com.ruoyi.business: debug
@@ -0,0 +1,55 @@
package com.ruoyi.business.controller;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.service.EcologyProjectService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.exception.ServiceException;
/**
* ecology 项目信息拉取 (admin/projects/new 按项目编号自动回填).
* <p>
* 读接口开放给已登录用户 (admin/manager 新建项目用), 与 /business/org/*Options 下拉一致, 不加 @PreAuthorize.
*/
@RestController
@RequestMapping("/business/ecology")
public class BizEcologyController extends BaseController
{
@Autowired
private EcologyProjectService ecologyProjectService;
/**
* 项目编号模糊搜索 (下拉选项): GET /business/ecology/project/search?xmbh=xxx
* 返回 [{projectNo, projectName}].
*/
@GetMapping("/project/search")
public AjaxResult searchProjects(@RequestParam(value = "xmbh", required = false) String xmbh)
{
List<Map<String, Object>> list = ecologyProjectService.searchProjectNos(xmbh);
return success(list);
}
/**
* 按项目编号拉取完整信息 + 负责人静默建档: GET /business/ecology/project/byNo?projectNo=xxx
* 返回 {projectNo, projectName, projectForm, startTime, endTime, totalAmount, manageFee,
* leaderResolved, leaderName, leaderPhone, leaderUserId}.
*/
@GetMapping("/project/byNo")
public AjaxResult fetchProject(@RequestParam(value = "projectNo", required = false) String projectNo)
{
try
{
return success(ecologyProjectService.fetchProjectByNo(projectNo));
}
catch (ServiceException e)
{
return error(e.getMessage());
}
}
}
@@ -64,6 +64,8 @@ public class BizExecutionIntentController extends BaseController
q.setProjectNo(proj.getProjectNo());
List<BizExecutionIntent> exists = bizExecutionIntentService.selectList(q);
if (exists != null && !exists.isEmpty()) return error("您已报名过该项目,无需重复报名");
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1')
bizExecutionIntentService.hardDeletePlaceholder(SecurityUtils.getUserId(), proj.getProjectNo());
bizExecutionIntent.setProjectNo(proj.getProjectNo());
bizExecutionIntent.setProjectName(proj.getProjectName());
Long uid = SecurityUtils.getUserId();
@@ -140,7 +142,7 @@ public class BizExecutionIntentController extends BaseController
if (bizExecutionIntent.getProjectNo() == null || bizExecutionIntent.getProjectNo().isEmpty()) {
throw new IllegalArgumentException("projectNo 不能为空");
}
List<BizExecutionIntent> list = bizExecutionIntentService.selectList(bizExecutionIntent);
List<BizExecutionIntent> list = bizExecutionIntentService.selectListIncludingDeleted(bizExecutionIntent);
List<BizSignupExpertExportVo> exportList = new ArrayList<>(list.size());
for (BizExecutionIntent e : list) {
BizSignupExpertExportVo v = new BizSignupExpertExportVo();
@@ -158,13 +160,6 @@ public class BizExecutionIntentController extends BaseController
if (department == null || department.isEmpty()) department = expert.getDepartment();
if (workUnit == null || workUnit.isEmpty()) workUnit = expert.getWorkUnit();
if (position == null || position.isEmpty()) position = expert.getTitle();
v.setRegion(expert.getRegion());
v.setIdCard(expert.getIdCard());
v.setBankCard(expert.getBankCard());
v.setBankName(expert.getBankName());
v.setBankBranch(expert.getBankBranch());
v.setBankRegion(expert.getBankRegion());
v.setBankAddress(expert.getBankAddress());
}
v.setDepartment(department);
v.setWorkUnit(workUnit);
@@ -1,7 +1,9 @@
package com.ruoyi.business.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -17,6 +19,7 @@ import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService;
@@ -45,6 +48,8 @@ public class BizMeetingAttendeeController extends BaseController {
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private IBizExecutionIntentService bizExecutionIntentService;
@Autowired
private BizNotifyService bizNotifyService;
/**
@@ -69,6 +74,40 @@ public class BizMeetingAttendeeController extends BaseController {
return success(rows);
}
/**
* 会议邀请函详情 (参会人/医生点开站内信里的邀请链接后查看).
* <p>安全: 仅参会人本人可见 (attendee.userId == 当前登录用户), 防止横向越权.
* <p>返回 {attendeeName, title, laborForm, projectName, meetingName, meetingTime, scheduleUrl, invitationUrl}.
*/
@GetMapping("/invitation/{attendeeId}")
public AjaxResult invitationDetail(@PathVariable("attendeeId") Long attendeeId) {
if (attendeeId == null) return error("缺少参会记录");
BizMeetingAttendee a = attendeeService.selectById(attendeeId);
if (a == null) return error("参会记录不存在");
Long uid = SecurityUtils.getUserId();
if (a.getUserId() == null || !a.getUserId().equals(uid)) {
return error("无权限查看该邀请函");
}
BizMeeting m = a.getMeetingId() == null ? null : bizMeetingService.getById(a.getMeetingId());
// 点开邀请函 = 立即报名: 确保该专家已"报名"该项目 (幂等, 已报名则跳过)
if (m != null && m.getProjectNo() != null && !m.getProjectNo().trim().isEmpty()) {
bizExecutionIntentService.signupByProject(m.getProjectNo(), m.getProjectName());
}
Map<String, Object> data = new HashMap<>();
data.put("attendeeName", a.getName());
data.put("title", a.getTitle());
data.put("laborForm", a.getLaborForm());
data.put("projectName", m != null ? m.getProjectName() : null);
data.put("meetingName", m != null ? m.getMeetingName() : null);
data.put("meetingTime", m != null ? m.getStartTime() : null);
data.put("scheduleUrl", m != null ? m.getScheduleUrl() : null);
// 优先参会人个性化邀请函 (盖章+姓名); 未生成回退会议级模板
data.put("invitationUrl", a.getInvitationUrl() != null && !a.getInvitationUrl().trim().isEmpty()
? a.getInvitationUrl()
: (m != null ? m.getInvitationUrl() : null));
return success(data);
}
/**
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
* 返回全字段, 前端按需展示.
@@ -133,6 +172,15 @@ public class BizMeetingAttendeeController extends BaseController {
return toAjax(rows);
}
/**
* 新增/编辑参会人保存前的预检: 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
* <p>前端据此弹窗 + 标红 + 二次确认, 确认后才真正 POST/PUT. 无差异返回空数组.
*/
@PostMapping("/diff")
public AjaxResult diff(@RequestBody BizMeetingAttendee body) {
return success(attendeeService.diffAgainstExpert(body));
}
/**
* 管理端: 按 id 删除参会人 (单条).
* 注意: 仅删 biz_meeting_attendee 行, 不删 sys_user (参会人可能是真实用户, 不能因参会关系删号).
@@ -194,21 +242,28 @@ public class BizMeetingAttendeeController extends BaseController {
}
/**
* 批量导入参会人 — 上传 Excel + 解析入库.
* 批量导入参会人 — 两段式: 默认 dry-run (force 缺省=false) 只解析 + 比对专家表, 不落库;
* force=true 才真正入库.
*
* <p>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList.
* 邀请参会已改为手动, 此处不再自动推"会议邀请".
* <p>dry-run 返回 { total, mismatches: [{rowNo, phone, name, fields:[{field,label,expertValue,inputValue}]}] },
* 前端据此弹「二次确认」; 确认后带 force=true 重发同一文件.
*
* <p>返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染.
* <p>force=true 调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行入库, 失败进 ngList,
* 返回 ImportResult { okNum, ngNum, ngList }.
*
* @param meetingId 会议 id (query 传, 决定导入到哪个会议)
*/
@Log(title = "参会人批量导入", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
@RequestParam("meetingId") Long meetingId,
@RequestParam(value = "force", required = false, defaultValue = "false") boolean force) throws Exception {
// 参会人名单写操作拦截: executor 劳务提交后禁止 (导入)
checkAttendeeEditable(meetingId);
if (!force) {
// dry-run: 解析 + 比对专家表, 不落库
return success(attendeeService.previewImport(file, meetingId));
}
// 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 人员变化 → 立即重算劳务费 (有成功导入才需重算)
@@ -287,8 +342,8 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping("/uploadAgreements")
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
int updated = attendeeService.uploadAgreements(file, meetingId);
return success(updated);
Map<String, Object> result = attendeeService.uploadAgreements(file, meetingId);
return success(result);
}
/**
@@ -312,8 +367,8 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping("/uploadExpertPhotos")
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
int updated = attendeeService.uploadExpertPhotos(file, meetingId);
return success(updated);
Map<String, Object> result = attendeeService.uploadExpertPhotos(file, meetingId);
return success(result);
}
/**
@@ -713,7 +713,7 @@ public class BizProjectController extends BaseController
}
/**
* 导出已报名专家 (跟随当前筛选的项目范围, manager)
* 导出已报名专家 (跟随当前筛选的项目范围, manager + admin)
* POST /business/project/signupExpertExport
* 先按筛选取项目 → 收集 projectNo → 查 biz_execution_intent 报名专家
*/
@@ -722,8 +722,8 @@ public class BizProjectController extends BaseController
public void exportSignupExpert(HttpServletResponse response, BizProject bizProject)
{
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType)) {
throw new ServiceException("只有合规管理员可导出报名专家");
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
throw new ServiceException("只有合规管理员或后台管理员可导出报名专家");
}
applyManagerScope(bizProject);
List<BizProject> projects = bizProjectService.selectList(bizProject);
@@ -735,7 +735,8 @@ public class BizProjectController extends BaseController
if (!projectNos.isEmpty()) {
BizExecutionIntent q = new BizExecutionIntent();
q.getParams().put("projectNos", projectNos);
intents = bizExecutionIntentService.selectList(q);
// 已报名专家导出: 唯一一处导出"含幽灵占位(del_flag='1')"的报名记录, 不标记哪些是幽灵
intents = bizExecutionIntentService.selectListIncludingDeleted(q);
}
List<BizSignupExpertExportVo> exportList = new ArrayList<>(intents.size());
for (BizExecutionIntent e : intents) {
@@ -754,13 +755,6 @@ public class BizProjectController extends BaseController
if (department == null || department.isEmpty()) department = expert.getDepartment();
if (workUnit == null || workUnit.isEmpty()) workUnit = expert.getWorkUnit();
if (position == null || position.isEmpty()) position = expert.getTitle();
v.setRegion(expert.getRegion());
v.setIdCard(expert.getIdCard());
v.setBankCard(expert.getBankCard());
v.setBankName(expert.getBankName());
v.setBankBranch(expert.getBankBranch());
v.setBankRegion(expert.getBankRegion());
v.setBankAddress(expert.getBankAddress());
}
v.setDepartment(department);
v.setWorkUnit(workUnit);
@@ -29,6 +29,7 @@ import com.ruoyi.business.service.IBizSupportLetterService;
import com.ruoyi.business.service.IBizInvitationService;
import com.ruoyi.business.service.IBizProjectPlanService;
import com.ruoyi.business.service.IBizSpecialPlanService;
import com.ruoyi.business.service.WxShareService;
import com.ruoyi.business.domain.BizSpecialPlan;
/**
@@ -44,6 +45,7 @@ public class BizPublicController extends BaseController {
@Autowired private IBizProjectPlanService projectPlanService;
@Autowired private IBizArticleService articleService;
@Autowired private IBizSpecialPlanService specialPlanService;
@Autowired private WxShareService wxShareService;
@Autowired private Environment environment;
private static final Logger log = LoggerFactory.getLogger(BizPublicController.class);
@@ -153,6 +155,20 @@ public class BizPublicController extends BaseController {
}
}
/**
* 微信 JS-SDK 分享签名配置 (公开, 供公示详情页微信内分享)
* url 必须是当前页面地址去掉 # 及之后 fragment 的部分
*/
@GetMapping("/wx/jssdk")
public AjaxResult wxJssdk(@RequestParam String url)
{
if (url == null || url.trim().isEmpty())
{
return error("url不能为空");
}
return success(wxShareService.buildShareConfig(url));
}
@GetMapping("/supportLetter/{annId}")
public AjaxResult supportLetterDetail(@PathVariable String annId) {
return success(supportLetterService.getById(annId));
@@ -51,6 +51,8 @@ public class BizExecutionIntent extends BaseEntity {
@Excel(name = "update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/** 删除标记 (0正常 1已删除, 幽灵占位记录用) */
private String delFlag;
public String getIntentId() { return intentId; }
public void setIntentId(String intentId) { this.intentId = intentId; }
public Long getUserId() { return userId; }
@@ -79,4 +81,6 @@ public class BizExecutionIntent extends BaseEntity {
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public String getDelFlag() { return delFlag; }
public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
}
@@ -54,6 +54,8 @@ public class BizMeetingAttendee extends BaseEntity {
private String laborProtocol;
/** 脱敏版协议 URL (身份证/银行卡用 * 替换) */
private String laborProtocolMasked;
/** 盖章后个性化邀请函 URL (OSS, 发送邀请函时生成; null=未生成, 回退会议级模板) */
private String invitationUrl;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
@@ -121,6 +123,8 @@ public class BizMeetingAttendee extends BaseEntity {
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
public String getLaborProtocolMasked() { return laborProtocolMasked; }
public void setLaborProtocolMasked(String laborProtocolMasked) { this.laborProtocolMasked = laborProtocolMasked; }
public String getInvitationUrl() { return invitationUrl; }
public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getMeetingName() { return meetingName; }
@@ -8,9 +8,11 @@ import com.ruoyi.common.annotation.Excel;
* 报名专家导出 VO (中文列头)
*
* <p>数据源: biz_execution_intent (公开门户"立即报名"写入的执行意向表, 即已报名专家)
* + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全银行/证件/地区/科室/医院/职称).
* + biz_expert (医生档案, 报名专家 userId → biz_expert.userId 反查补全科室/医院/职称).
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* <p>字段: 项目编号 项目名称 专家姓名 科室 医院 职称 报名时间 手机号
*
* @author guoju
*/
public class BizSignupExpertExportVo {
@@ -33,34 +35,13 @@ public class BizSignupExpertExportVo {
@Excel(name = "职称", sort = 6)
private String position;
@Excel(name = "手机号", sort = 7)
private String phone;
@Excel(name = "地区", sort = 8)
private String region;
@Excel(name = "身份证件号码", sort = 9)
private String idCard;
@Excel(name = "银行卡号", sort = 10)
private String bankCard;
@Excel(name = "银行名称", sort = 11)
private String bankName;
@Excel(name = "开户行支行", sort = 12)
private String bankBranch;
@Excel(name = "开户行省/市", sort = 13)
private String bankRegion;
@Excel(name = "开户行地址", sort = 14)
private String bankAddress;
@Excel(name = "报名时间", sort = 15, dateFormat = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "报名时间", sort = 7, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@Excel(name = "手机号", sort = 8)
private String phone;
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
@@ -73,22 +54,8 @@ public class BizSignupExpertExportVo {
public void setWorkUnit(String workUnit) { this.workUnit = workUnit; }
public String getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
public String getIdCard() { return idCard; }
public void setIdCard(String idCard) { this.idCard = idCard; }
public String getBankCard() { return bankCard; }
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankBranch() { return bankBranch; }
public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
}
@@ -0,0 +1,32 @@
package com.ruoyi.business.domain.vo;
/**
* 参会人表单字段与专家表 (biz_expert) 档案的不一致项.
*
* <p>用于「新增/编辑参会人」和「批量导入」保存前的二次确认:
* 后端按 手机号→sys_user→biz_expert 解析出专家档案, 与用户输入比对,
* 专家表已有值且与输入不一致时生成一条 mismatch.
*/
public class FieldMismatch {
/** 字段 key (name/workUnit/...) */
private String field;
/** 字段中文名 (姓名/医院/...) */
private String label;
/** 专家表当前值 */
private String expertValue;
/** 用户输入值 */
private String inputValue;
public String getField() { return field; }
public void setField(String field) { this.field = field; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public String getExpertValue() { return expertValue; }
public void setExpertValue(String expertValue) { this.expertValue = expertValue; }
public String getInputValue() { return inputValue; }
public void setInputValue(String inputValue) { this.inputValue = inputValue; }
}
@@ -0,0 +1,28 @@
package com.ruoyi.business.domain.vo;
import java.util.List;
/**
* 批量导入 dry-run 中某一行与专家表 (biz_expert) 档案的不一致 (导入二次确认用).
*/
public class ImportMismatch {
/** Excel 行号 (1=表头) */
private int rowNo;
private String phone;
private String name;
/** 该行不一致的字段列表 */
private List<FieldMismatch> fields;
public int getRowNo() { return rowNo; }
public void setRowNo(int rowNo) { this.rowNo = rowNo; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<FieldMismatch> getFields() { return fields; }
public void setFields(List<FieldMismatch> fields) { this.fields = fields; }
}
@@ -1,5 +1,6 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizExecutionIntent;
/**
@@ -9,8 +10,14 @@ public interface BizExecutionIntentMapper
{
BizExecutionIntent selectByPrimaryKey(String intentId);
List<BizExecutionIntent> selectList(BizExecutionIntent entity);
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
int insert(BizExecutionIntent entity);
int updateByPrimaryKey(BizExecutionIntent entity);
int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentIds);
/** 物理删除幽灵占位记录 (del_flag='1'), 真报名时调用 */
int hardDeletePlaceholder(@Param("userId") Long userId, @Param("projectNo") String projectNo);
/** 该用户+项目是否已存在意向记录 (真/假都算), 建占位前去重 */
int countByUserAndProject(@Param("userId") Long userId, @Param("projectNo") String projectNo);
}
@@ -20,6 +20,8 @@ public interface BizMeetingAttendeeMapper {
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
/** 发送邀请函后回写盖章个性化邀请函 URL (单条) */
int updateInvitationUrl(@Param("id") Long id, @Param("invitationUrl") String invitationUrl);
/** 推送电子签后置 is_esigned=1 (单条) */
int markEsignedById(@Param("id") Long id);
/** 邀请参会后置 is_invited=1 (单条) */
@@ -59,6 +59,8 @@ public class BizNotifyService
public static final String BIZ_PROJECT = "project";
public static final String BIZ_PROJECT_PLAN = "projectPlan";
public static final String BIZ_MEETING = "meeting";
/** 会议邀请函 (参会人点击通知直接跳转邀请函详情页, bizId=attendeeId) */
public static final String BIZ_MEETING_INVITATION = "meetingInvitation";
public static final String BIZ_AGREEMENT = "agreement";
public static final String BIZ_PUBLICITY = "publicity";
public static final String BIZ_ORG = "org";
@@ -166,11 +168,12 @@ public class BizNotifyService
* 业务去重由调用方保证 (差集), 本方法不再判断.
*
* @param userId 被邀请人 sys_user.user_id
* @param attendeeId biz_meeting_attendee.id (存 bizId, 前端点击通知跳转到邀请函页)
* @param meetingId biz_meeting.meeting_id
* @param meetingName 会议名 (可空, 兜底 "会议 #ID")
* @param startTime 会议开始时间 (可空, 不展示具体时间)
*/
public void meetingInvitation(Long userId, Long meetingId, String meetingName, Date startTime)
public void meetingInvitation(Long userId, Long attendeeId, Long meetingId, String meetingName, Date startTime)
{
if (userId == null) {
log.warn("[notify] meetingInvitation: userId 为空, 跳过 (meetingId={})", meetingId);
@@ -181,18 +184,18 @@ public class BizNotifyService
if (startTime != null) {
content.append(", 时间: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(startTime));
}
content.append("。请登录系统查看详情");
content.append("。请点击查看邀请函");
BizMessage msg = new BizMessage();
msg.setReceiverUserId(userId);
msg.setMsgType(TYPE_NOTIFY); // 会议邀请 = 普通通知 (医生只需知道被邀请, 不是必须立即操作)
msg.setTitle("会议邀请: " + name);
msg.setContent(content.toString());
msg.setBizType(BIZ_MEETING);
msg.setBizId(meetingId);
msg.setBizType(BIZ_MEETING_INVITATION); // 独立类型: 前端点击直接跳邀请函页, 不弹 dialog
msg.setBizId(attendeeId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId);
log.info("[notify] meetingInvitation 已发 uid={} meetingId={} attendeeId={}", userId, meetingId, attendeeId);
}
/**
@@ -2,6 +2,7 @@ package com.ruoyi.business.service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,6 +40,9 @@ public class ComplianceLoginService
private static final String ECOLOGY_LOOKUP_SQL =
"SELECT lastname, type FROM v_compliance_project_manager WHERE mobile = ? LIMIT 1";
/** 登录白名单: 这些账号绕过 ecology 视图校验直接放行 (测试账号 manager01 / leader01) */
private static final Set<String> LOGIN_WHITELIST = Set.of("manager01", "leader01");
@Autowired
@Qualifier("ecologyJdbcTemplate")
private JdbcTemplate ecologyJdbcTemplate;
@@ -60,6 +64,10 @@ public class ComplianceLoginService
public SysUser reconcileForLogin(String phone)
{
SysUser user = findUserByPhone(phone);
if (isWhitelisted(user))
{
return user;
}
String role = user == null ? null : user.getRoleType();
boolean needEcology = user == null || "manager".equals(role) || "leader".equals(role);
if (!needEcology)
@@ -96,6 +104,10 @@ public class ComplianceLoginService
@Transactional(rollbackFor = Exception.class)
public String gateForPasswordLogin(SysUser user)
{
if (isWhitelisted(user))
{
return null;
}
String phone = user.getPhonenumber();
EcologyPerson p = queryEcology(phone == null ? "" : phone);
if (p == null)
@@ -121,6 +133,35 @@ public class ComplianceLoginService
return queryEcology(phone) != null;
}
/**
* 项目负责人(leader)静默查号/建号: 按手机号查 sys_user, 没有则按 name/phone 建 leader 账号.
* 用于 admin/projects/new 从 ecology 拉取项目负责人时自动建档 (用户要求静默, 无则新建).
* 返回 sys_user.userId (已存在或新建后的); 手机号缺失/异常返回 null.
*/
@Transactional(rollbackFor = Exception.class)
public Long ensureLeaderAccount(String name, String phone)
{
if (phone == null || phone.trim().isEmpty())
{
return null;
}
phone = phone.trim();
SysUser existing = findUserByPhone(phone);
if (existing != null)
{
return existing.getUserId();
}
EcologyPerson p = new EcologyPerson();
p.type = 2;
p.name = name;
return provision(p, phone, "leader").getUserId();
}
private boolean isWhitelisted(SysUser user)
{
return user != null && user.getUserName() != null && LOGIN_WHITELIST.contains(user.getUserName());
}
private SysUser findUserByPhone(String phone)
{
SysUser probe = userMapper.checkPhoneUnique(phone);
@@ -0,0 +1,230 @@
package com.ruoyi.business.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
/**
* 从泛微 ecology 只读视图拉取项目信息 (admin/projects/new 按项目编号自动回填).
* <p>
* 数据源: {@code v_compliance_project_list} (项目列表) + {@code v_compliance_project_manager} (负责人姓名/手机号).
* <p>
* 字段映射:
* xmbh→项目编号, xmmc→项目名称, xmqssj/xmjssj→开始/结束时间(补全 00:00:00 / 23:59:59),
* xmlx_name→项目形式(线上加线下→线上+线下), yqsry→总金额, glfsfbl→管理费=总金额×比例%,
* xmfzr→项目负责人(通过 v_compliance_project_manager 解析姓名+手机号, 再查/建 sys_user 静默建档).
*/
@Service
public class EcologyProjectService
{
private static final Logger log = LoggerFactory.getLogger(EcologyProjectService.class);
private static final String PROJECT_BY_NO_SQL =
"SELECT id, xmbh, xmmc, xmfzr, yqsry, xmqssj, xmjssj, glfsfbl, xmlx_name " +
"FROM v_compliance_project_list WHERE xmbh = ? ORDER BY id LIMIT 1";
private static final String PROJECT_SEARCH_SQL =
"SELECT xmbh, xmmc FROM v_compliance_project_list WHERE xmbh LIKE ? " +
"GROUP BY xmbh, xmmc ORDER BY xmbh LIMIT 50";
private static final String LEADER_BY_ID_SQL =
"SELECT lastname, mobile FROM v_compliance_project_manager WHERE id = ? LIMIT 1";
@Autowired
@Qualifier("ecologyJdbcTemplate")
private JdbcTemplate ecologyJdbcTemplate;
@Autowired
private ComplianceLoginService complianceLoginService;
/**
* 项目编号模糊搜索 (下拉选项用): 返回 [{projectNo, projectName}], 空关键词返回空列表.
*/
public List<Map<String, Object>> searchProjectNos(String kw)
{
if (kw == null || kw.trim().isEmpty())
{
return new ArrayList<>();
}
String like = "%" + escapeLike(kw.trim()) + "%";
try
{
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_SEARCH_SQL, like);
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> row : rows)
{
Map<String, Object> m = new LinkedHashMap<>();
m.put("projectNo", str(row, "xmbh"));
m.put("projectName", str(row, "xmmc"));
result.add(m);
}
return result;
}
catch (Exception e)
{
log.warn("查询 ecology 项目编号失败: {}", e.getMessage());
return new ArrayList<>();
}
}
/**
* 按项目编号拉取完整项目信息 + 负责人静默查号/建号.
* 返回字段: projectNo/projectName/projectForm/startTime/endTime/totalAmount/manageFee/
* leaderResolved/leaderName/leaderPhone/leaderUserId.
* 项目不存在或 ecology 不可达抛 {@link ServiceException}.
*/
public Map<String, Object> fetchProjectByNo(String projectNo)
{
if (projectNo == null || projectNo.trim().isEmpty())
{
throw new ServiceException("项目编号不能为空");
}
Map<String, Object> row;
try
{
List<Map<String, Object>> rows = ecologyJdbcTemplate.queryForList(PROJECT_BY_NO_SQL, projectNo.trim());
if (rows == null || rows.isEmpty())
{
throw new ServiceException("ecology 中未找到该项目编号");
}
row = rows.get(0);
}
catch (ServiceException e)
{
throw e;
}
catch (Exception e)
{
log.warn("查询 ecology 项目信息失败: {}", e.getMessage());
throw new ServiceException("ecology 数据源不可用");
}
BigDecimal yqsry = decimal(row, "yqsry");
BigDecimal ratio = decimal(row, "glfsfbl");
Map<String, Object> result = new LinkedHashMap<>();
result.put("projectNo", str(row, "xmbh"));
result.put("projectName", str(row, "xmmc"));
result.put("projectForm", mapProjectForm(str(row, "xmlx_name")));
result.put("startTime", toStartTime(str(row, "xmqssj")));
result.put("endTime", toEndTime(str(row, "xmjssj")));
result.put("totalAmount", yqsry);
result.put("manageFee", calcManageFee(yqsry, ratio));
resolveLeader(row, result);
return result;
}
/**
* 负责人解析: xmfzr(int, hrmresource.id) → v_compliance_project_manager 查姓名/手机号 →
* 静默查/建 sys_user(role_type=leader). 解析不到(负责人不在视图/无手机号)则 leaderResolved=false.
*/
private void resolveLeader(Map<String, Object> row, Map<String, Object> result)
{
Object xmfzr = row.get("xmfzr");
result.put("leaderResolved", false);
result.put("leaderName", null);
result.put("leaderPhone", null);
result.put("leaderUserId", null);
if (xmfzr == null)
{
return;
}
try
{
List<Map<String, Object>> lr = ecologyJdbcTemplate.queryForList(
LEADER_BY_ID_SQL, ((Number) xmfzr).longValue());
if (lr == null || lr.isEmpty())
{
return;
}
String name = str(lr.get(0), "lastname");
String phone = str(lr.get(0), "mobile");
if (phone == null || phone.isEmpty())
{
return;
}
Long userId = complianceLoginService.ensureLeaderAccount(name, phone);
result.put("leaderResolved", true);
result.put("leaderName", name);
result.put("leaderPhone", phone);
result.put("leaderUserId", userId);
}
catch (Exception e)
{
log.warn("解析 ecology 项目负责人失败 (xmfzr={}): {}", xmfzr, e.getMessage());
}
}
private String str(Map<String, Object> row, String key)
{
Object v = row.get(key);
if (v == null)
{
return null;
}
String s = v.toString().trim();
return s.isEmpty() ? null : s;
}
private BigDecimal decimal(Map<String, Object> row, String key)
{
Object v = row.get(key);
if (v == null)
{
return null;
}
try
{
return new BigDecimal(v.toString());
}
catch (NumberFormatException e)
{
return null;
}
}
/** 项目形式: 线上加线下 → 线上+线下 (表单口径), 其余原样返回. */
private String mapProjectForm(String xmlxName)
{
if (xmlxName == null)
{
return null;
}
return "线上加线下".equals(xmlxName) ? "线上+线下" : xmlxName;
}
private String toStartTime(String day)
{
return day == null ? null : day + " 00:00:00";
}
private String toEndTime(String day)
{
return day == null ? null : day + " 23:59:59";
}
/** 管理费 = 总金额 × 比例 / 100, 保留 2 位小数. */
private BigDecimal calcManageFee(BigDecimal yqsry, BigDecimal ratio)
{
if (yqsry == null || ratio == null)
{
return null;
}
return yqsry.multiply(ratio).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
private String escapeLike(String s)
{
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
@@ -10,8 +10,22 @@ public interface IBizExecutionIntentService
{
BizExecutionIntent getById(String intentId);
List<BizExecutionIntent> selectList(BizExecutionIntent entity);
/** 查询全部记录 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用 */
List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity);
int insert(BizExecutionIntent entity);
int updateByPrimaryKey(BizExecutionIntent entity);
int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentId);
/**
* 当前登录用户对某项目「报名」 (幂等): 按 userId + projectNo 查重, 已报名返回 null, 新报名返回新建记录.
* name/phone 从 sys_user 兜底 (与"立即报名"口径一致), onboardStatus=未入库.
*/
BizExecutionIntent signupByProject(String projectNo, String projectName);
/** 物理删除幽灵占位记录 (del_flag='1'), 真报名时调用 */
void hardDeletePlaceholder(Long userId, String projectNo);
/** 该用户+项目是否已存在意向记录 (真/假都算), 建占位前去重 */
boolean existsAny(Long userId, String projectNo);
}
@@ -1,9 +1,12 @@
package com.ruoyi.business.service;
import java.util.List;
import java.util.Map;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.FieldMismatch;
public interface IBizMeetingAttendeeService {
int insert(BizMeetingAttendee entity);
@@ -63,6 +66,27 @@ public interface IBizMeetingAttendeeService {
*/
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
/**
* 新增/编辑参会人前, 比对表单与专家表 (biz_expert) 档案, 返回不一致字段列表.
*
* <p>解析口径与 {@link #insertByPhoneWithProfile} 一致: 编辑按 body.id 反查 userId,
* 新增按 body.phone 查 sys_user 得 userId (不建号), 再 userId→biz_expert. 查不到专家返回空列表.
*
* @param body 参会人表单 (含 id 或 phone 定位)
* @return 不一致字段 (专家表已有值且与输入不同); 无差异/无专家 → 空列表
*/
List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body);
/**
* 批量导入 dry-run: 解析 Excel + 逐行比对专家表, 不落库.
*
* @param file Excel 文件
* @param meetingId 会议 id
* @return { total: 数据行数, mismatches: [ImportMismatch] }
* @throws Exception 文件为空 / 解析异常
*/
Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception;
/**
* 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个
* 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1.
@@ -106,15 +130,15 @@ public interface IBizMeetingAttendeeService {
byte[] buildAgreementTemplateZip(Long meetingId);
/**
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人,
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底),
* 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式).
*
* @param file 用户重新压缩的 zip
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
* @return 成功回填的参会人数
* @return { updated: 成功回填数, total: 参会人数, skipped: 跳过数, results: 每个目录的匹配明细 }
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
*/
int uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
Map<String, Object> uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
/**
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
@@ -126,13 +150,13 @@ public interface IBizMeetingAttendeeService {
byte[] buildExpertPhotoTemplateZip(Long meetingId);
/**
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人,
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底),
* 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos.
*
* @param file 用户重新压缩的 zip
* @param meetingId 会议 id
* @return 成功回填的参会人数
* @return { updated, total, skipped, results } (同 uploadAgreements)
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
*/
int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
Map<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
}
@@ -0,0 +1,292 @@
package com.ruoyi.business.service;
import cn.hutool.http.HttpUtil;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.io.image.ImageData;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.LineSegment;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.geom.Vector;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.ITextExtractionStrategy;
import com.ruoyi.business.oss.OssUploader;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 会议邀请函个性化 PDF 生成服务.
*
* <p>流程 (「发送邀请函」触发): 下载会议邀请函模板 PDF → 把模板里的 {@code xxx}/{@code XXX}
* 替换为医生姓名 → 找到<strong>最后一个</strong>「北京整合医学学会」在其位置盖公章 → 上传 OSS.
*
* <p>文本定位用 iText7 的 {@link PdfTextExtractor} + 自定义 {@link ITextExtractionStrategy},
* 逐字形拿到 baseline / ascent / descent / 字号, 白底盖住占位符后在原 baseline 处写姓名.
* 模板若为扫描图片 PDF (无文本层), 抽取不到目标文本 → 仅记 warn、原样返回 (不盖名章).
*/
@Slf4j
@Service
public class InvitationPdfService {
@Autowired
private OssUploader ossUploader;
/** 公章图片相对路径 (运行目录, 与 simsun.ttc / 劳务协议盖章同图) */
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
/** 公章尺寸 (宽高, px), 复用 PdfService 劳务协议章口径 */
private static final float SEAL_WIDTH = 120f;
private static final float SEAL_HEIGHT = 120f;
/** 需盖章的落款机构名 (取全文最后一个) */
private static final String ORG_TEXT = "北京整合医学学会";
/** 姓名占位符 (忽略大小写, 兼容 xxx / XXX) */
private static final String PLACEHOLDER = "xxx";
/** 按 URL 下载邀请函模板 PDF 字节 (批量发送时对同一会议只调用一次) */
public byte[] downloadTemplate(String url) {
log.info("[invitation] 下载邀请函模板: {}", url);
try {
return HttpUtil.downloadBytes(url);
} catch (Exception e) {
throw new RuntimeException("下载邀请函模板失败: " + e.getMessage(), e);
}
}
/** 生成个性化 PDF 并上传 OSS, 返回完整 URL */
public String generateAndUpload(byte[] template, String name) {
if (template == null || template.length == 0) {
throw new IllegalArgumentException("邀请函模板为空");
}
if (name == null || name.trim().isEmpty()) {
name = "专家";
}
byte[] out = generate(template, name);
String filename = System.currentTimeMillis() + "_" + (int) (Math.random() * 1000) + ".pdf";
return ossUploader.upload(out, filename, "meeting/invitation");
}
/** 模板 PDF → 个性化 PDF (替换姓名 + 盖章) */
public byte[] generate(byte[] template, String name) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PdfDocument doc = new PdfDocument(
new PdfReader(new ByteArrayInputStream(template)),
new PdfWriter(baos));
try {
render(doc, name);
} finally {
doc.close();
}
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("生成邀请函 PDF 失败: " + e.getMessage(), e);
}
}
/** 第一遍抽取文本定位 → 第二遍叠加姓名 + 公章 */
private void render(PdfDocument doc, String name) throws IOException {
List<NameTarget> nameTargets = new ArrayList<>();
SealTarget seal = null;
int pageCount = doc.getNumberOfPages();
// 第一遍: 逐页抽取文本, 记录 xxx 位置 + 最后一个「北京整合医学学会」位置
for (int p = 1; p <= pageCount; p++) {
PdfPage page = doc.getPage(p);
TextPositionStrategy strategy = new TextPositionStrategy();
PdfTextExtractor.getTextFromPage(page, strategy);
// 展平为「字符 → 字形」列表, 供子串定位 (要求 1 字符 = 1 字形, CJK/拉丁模板恒成立)
List<TextRenderInfo> charInfos = new ArrayList<>();
StringBuilder sb = new StringBuilder();
for (TextRenderInfo info : strategy.infos()) {
List<TextRenderInfo> chars = info.getCharacterRenderInfos();
if (chars != null && !chars.isEmpty()) {
for (TextRenderInfo c : chars) {
String ch = c.getText();
if (ch != null && !ch.isEmpty()) {
charInfos.add(c);
sb.append(ch);
}
}
} else {
String t = info.getText();
if (t != null && !t.isEmpty()) {
charInfos.add(info);
sb.append(t);
}
}
}
String full = sb.toString();
if (full.isEmpty()) continue;
if (charInfos.size() != full.length()) {
log.warn("[invitation] 第{}页字形/字符数不一致 ({}!={}), 跳过定位", p, charInfos.size(), full.length());
continue;
}
String lower = full.toLowerCase(Locale.ROOT);
int i = 0;
while ((i = lower.indexOf(PLACEHOLDER, i)) >= 0) {
nameTargets.add(buildNameTarget(charInfos, i, PLACEHOLDER.length(), p));
i += PLACEHOLDER.length();
}
int j = 0;
while ((j = full.indexOf(ORG_TEXT, j)) >= 0) {
seal = buildSealTarget(charInfos, j, ORG_TEXT.length(), p);
j += ORG_TEXT.length();
}
}
// 第二遍: 叠加
if (nameTargets.isEmpty()) {
log.warn("[invitation] 未找到占位符 xxx, 跳过姓名替换");
} else {
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
for (NameTarget t : nameTargets) {
drawName(doc, cjkFont, t, name);
}
}
if (seal == null) {
log.warn("[invitation] 未找到 '{}', 跳过盖章", ORG_TEXT);
} else {
drawSeal(doc, seal);
}
}
private NameTarget buildNameTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
TextRenderInfo first = charInfos.get(start);
LineSegment firstBase = first.getBaseline();
float x = firstBase.getStartPoint().get(Vector.I1);
float baselineY = firstBase.getStartPoint().get(Vector.I2);
float fontSize = first.getFontSize();
float width = 0f;
for (int k = start; k < start + len && k < charInfos.size(); k++) {
width += charInfos.get(k).getBaseline().getLength();
}
return new NameTarget(page, x, baselineY, fontSize, width);
}
private SealTarget buildSealTarget(List<TextRenderInfo> charInfos, int start, int len, int page) {
TextRenderInfo first = charInfos.get(start);
TextRenderInfo last = charInfos.get(start + len - 1);
float x = first.getBaseline().getStartPoint().get(Vector.I1);
float endX = last.getBaseline().getEndPoint().get(Vector.I1);
float ascent = first.getAscentLine().getStartPoint().get(Vector.I2);
float descent = first.getDescentLine().getStartPoint().get(Vector.I2);
float cx = (x + endX) / 2f;
float cy = (ascent + descent) / 2f;
return new SealTarget(page, cx, cy);
}
/** 白底盖住 xxx, 在原 baseline 处写医生姓名 (左对齐) */
private void drawName(PdfDocument doc, PdfFont font, NameTarget t, String name) {
PdfPage page = doc.getPage(t.page);
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
float pad = t.fontSize * 0.1f;
float y = t.baselineY - t.fontSize * 0.28f;
float h = t.fontSize * 1.2f;
canvas.setFillColor(DeviceRgb.WHITE);
canvas.rectangle(t.x - pad, y, t.width + 2 * pad, h);
canvas.fill();
canvas.setFillColor(DeviceRgb.BLACK);
canvas.beginText();
canvas.setFontAndSize(font, t.fontSize);
canvas.setTextMatrix(t.x, t.baselineY);
canvas.showText(name);
canvas.endText();
}
/** 公章居中叠加到最后一个「北京整合医学学会」文本中心 */
private void drawSeal(PdfDocument doc, SealTarget s) {
PdfPage page = doc.getPage(s.page);
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), doc);
try {
ImageData imageData = ImageDataFactory.create(SEAL_IMAGE_PATH);
float x = s.cx - SEAL_WIDTH / 2f;
// 公章中心相对落款文本中心再往下偏 1/2 个公章半径 (半径 = SEAL_HEIGHT/2, 即下移 SEAL_HEIGHT/4)
// PDF 坐标系 Y 轴向上为正, "往下" 即减小 Y
float y = s.cy - SEAL_HEIGHT / 2f - SEAL_HEIGHT / 4f;
canvas.addImage(imageData, new Rectangle(x, y, SEAL_WIDTH, SEAL_HEIGHT), false);
} catch (Exception e) {
log.warn("[invitation] 公章图片加载失败, 跳过盖章: {}", SEAL_IMAGE_PATH, e);
}
}
/** 收集所有 RENDER_TEXT 事件的 TextRenderInfo (字形级定位) */
private static class TextPositionStrategy implements ITextExtractionStrategy {
private final List<TextRenderInfo> infos = new ArrayList<>();
@Override
public void eventOccurred(IEventData data, EventType type) {
if (type == EventType.RENDER_TEXT && data instanceof TextRenderInfo) {
TextRenderInfo info = (TextRenderInfo) data;
// 必须保留 graphics state, 否则抽取结束后 getBaseline()/getFontSize()/
// getCharacterRenderInfos() 等会抛 IllegalStateException (iText7 默认丢弃)
info.preserveGraphicsState();
infos.add(info);
}
}
@Override
public Set<EventType> getSupportedEvents() {
return Collections.singleton(EventType.RENDER_TEXT);
}
@Override
public String getResultantText() {
return "";
}
List<TextRenderInfo> infos() {
return infos;
}
}
private static class NameTarget {
final int page;
final float x;
final float baselineY;
final float fontSize;
final float width;
NameTarget(int page, float x, float baselineY, float fontSize, float width) {
this.page = page;
this.x = x;
this.baselineY = baselineY;
this.fontSize = fontSize;
this.width = width;
}
}
private static class SealTarget {
final int page;
final float cx;
final float cy;
SealTarget(int page, float cx, float cy) {
this.page = page;
this.cx = cx;
this.cy = cy;
}
}
}
@@ -6,6 +6,7 @@ import com.ruoyi.common.core.redis.RedisCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.UUID;
@@ -38,12 +39,13 @@ public class SysSmsService {
@Autowired
private AliyunSmsSender aliyunSmsSender;
/**
* dev 模式判断: 手机号以 "10" 开头视为开发测试 (固定码 1234), 其它走 aliyun 真发
* 该逻辑不依赖 application.yml 配置, 确保开发/生产两种手机号能并存
*/
private boolean isDev(String phone) {
return phone.startsWith("10");
/** 测试环境短信开关: true=固定码 1234 且不真发 (test), false=aliyun 真发 (prod) */
@Value("${ruoyi.sms.mock-enabled:false}")
private boolean mockEnabled;
/** 生产测试账号: 手机号以 "10" 开头视为测试号, 固定码 1234 且不真发 (生产环境测试用) */
private boolean isTestAccount(String phone) {
return phone != null && phone.startsWith("10");
}
/** 发送短信验证码, 返回 uuid (前端提交验证时回传) */
@@ -56,12 +58,12 @@ public class SysSmsService {
}
String code;
if (isDev(phone)) {
// dev 模式 (10 开头): 固定码 1234 (供内部测试)
if (mockEnabled || isTestAccount(phone)) {
// 测试环境 / 生产测试账号(手机号 10 开头): 固定码 1234 (不真发)
code = "1234";
log.info("[SMS][DEV][phone-prefix=10] 向 {} 发送验证码: {}", phone, code);
log.info("[SMS][MOCK] 向 {} 发送验证码(测试固定码): {}", phone, code);
} else {
// prod 模式: aliyun 真发
// 生产环境: aliyun 真发
code = generateCode(4);
log.info("[SMS][PROD] 向 {} 发送验证码: {}", phone, code);
boolean ok = aliyunSmsSender.sendCode(phone, code);
@@ -0,0 +1,153 @@
package com.ruoyi.business.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.http.HttpUtils;
/**
* 公众号 JS-SDK 分享签名服务
* <p>链路: appId + appSecret → access_token → jsapi_ticket → signature(sha1)
* <p>参考 hwt-serve WxService.getAccessToken/getTicket, 但签名改在后端完整计算,
* 不把 jsapi_ticket 暴露给前端.
*/
@Service
public class WxShareService
{
private static final Logger log = LoggerFactory.getLogger(WxShareService.class);
/** access_token / jsapi_ticket 缓存有效期 (官方 7200s, 提前 200s 刷新) */
private static final int CACHE_TTL_SECONDS = 7000;
@Autowired
private Environment environment;
@Autowired
private RedisCache redisCache;
private String appId()
{
return environment.getProperty("wx.app-id", "wx2414cddf4bd56092");
}
private String appSecret()
{
return environment.getProperty("wx.app-secret");
}
/**
* 获取公众号 access_token (Redis 缓存, 避免频繁调用被限流 2000 次/日)
*/
private String getAccessToken()
{
String appId = appId();
String key = "wx:access_token:" + appId;
String token = redisCache.getCacheObject(key);
if (token != null && !token.isEmpty())
{
return token;
}
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
+ appId + "&secret=" + appSecret();
String resp = HttpUtils.sendGet(url);
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
if (obj == null || obj.getString("access_token") == null)
{
log.error("获取微信 access_token 失败, resp={}", resp);
throw new RuntimeException("获取微信 access_token 失败: "
+ (obj == null ? resp : obj.getString("errmsg")));
}
token = obj.getString("access_token");
redisCache.setCacheObject(key, token, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
return token;
}
/**
* 获取 jsapi_ticket (Redis 缓存)
*/
private String getJsapiTicket(String accessToken)
{
String appId = appId();
String key = "wx:jsapi_ticket:" + appId;
String ticket = redisCache.getCacheObject(key);
if (ticket != null && !ticket.isEmpty())
{
return ticket;
}
String url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token="
+ accessToken + "&type=jsapi";
String resp = HttpUtils.sendGet(url);
JSONObject obj = (resp == null || resp.isEmpty()) ? null : JSONObject.parseObject(resp);
if (obj == null || !"0".equals(obj.getString("errcode")) || obj.getString("ticket") == null)
{
log.error("获取微信 jsapi_ticket 失败, resp={}", resp);
throw new RuntimeException("获取微信 jsapi_ticket 失败: "
+ (obj == null ? resp : obj.getString("errmsg")));
}
ticket = obj.getString("ticket");
redisCache.setCacheObject(key, ticket, CACHE_TTL_SECONDS, TimeUnit.SECONDS);
return ticket;
}
/**
* 生成前端 wx.config 所需参数
*
* @param url 当前页面地址 (必须不含 # 及之后 fragment, 微信签名校验会忽略 fragment)
* @return {appId, timestamp, nonceStr, signature}
*/
public Map<String, Object> buildShareConfig(String url)
{
String ticket = getJsapiTicket(getAccessToken());
long timestamp = System.currentTimeMillis() / 1000;
String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
// 官方签名算法: key 按字典序 (jsapi_ticket < noncestr < timestamp < url) 拼接后 sha1
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr
+ "&timestamp=" + timestamp + "&url=" + url;
String signature = sha1(string1);
Map<String, Object> ret = new HashMap<>();
ret.put("appId", appId());
ret.put("timestamp", timestamp);
ret.put("nonceStr", nonceStr);
ret.put("signature", signature);
return ret;
}
/**
* SHA-1 十六进制小写摘要
*/
private static String sha1(String str)
{
try
{
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] bytes = digest.digest(str.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes)
{
String hex = Integer.toHexString(b & 0xFF);
if (hex.length() < 2)
{
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
}
catch (NoSuchAlgorithmException e)
{
throw new RuntimeException("SHA-1 算法不可用", e);
}
}
}
@@ -6,12 +6,17 @@ import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.mapper.BizExecutionIntentMapper;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.mapper.SysUserMapper;
@Service
public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
{
@Autowired
private BizExecutionIntentMapper bizExecutionIntentMapper;
@Autowired
private SysUserMapper sysUserMapper;
@Override
public BizExecutionIntent getById(String intentId)
@@ -20,6 +25,9 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
public List<BizExecutionIntent> selectList(BizExecutionIntent entity)
{ return bizExecutionIntentMapper.selectList(entity); }
@Override
public List<BizExecutionIntent> selectListIncludingDeleted(BizExecutionIntent entity)
{ return bizExecutionIntentMapper.selectListIncludingDeleted(entity); }
@Override
public int insert(BizExecutionIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizExecutionIntentMapper.insert(entity); }
@Override
public int updateByPrimaryKey(BizExecutionIntent entity)
@@ -30,4 +38,47 @@ public class BizExecutionIntentServiceImpl implements IBizExecutionIntentService
@Override
public int deleteByPrimaryKeys(String[] intentId)
{ return bizExecutionIntentMapper.deleteByPrimaryKeys(intentId); }
@Override
public void hardDeletePlaceholder(Long userId, String projectNo)
{
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return;
bizExecutionIntentMapper.hardDeletePlaceholder(userId, projectNo);
}
@Override
public boolean existsAny(Long userId, String projectNo)
{
if (userId == null || projectNo == null || projectNo.trim().isEmpty()) return false;
return bizExecutionIntentMapper.countByUserAndProject(userId, projectNo) > 0;
}
@Override
public BizExecutionIntent signupByProject(String projectNo, String projectName)
{
if (projectNo == null || projectNo.trim().isEmpty()) return null;
Long uid = SecurityUtils.getUserId();
BizExecutionIntent q = new BizExecutionIntent();
q.setUserId(uid);
q.setProjectNo(projectNo);
List<BizExecutionIntent> exists = bizExecutionIntentMapper.selectList(q);
if (exists != null && !exists.isEmpty()) return null;
// 真报名: 先物理删掉幽灵占位记录 (del_flag='1'), 避免残留
hardDeletePlaceholder(uid, projectNo);
BizExecutionIntent e = new BizExecutionIntent();
e.setUserId(uid);
e.setProjectNo(projectNo);
e.setProjectName(projectName);
SysUser u = sysUserMapper.selectUserById(uid);
if (u != null) {
e.setName(u.getNickName());
e.setPhone(u.getPhonenumber());
}
e.setOnboardStatus("未入库");
e.setCreateBy(SecurityUtils.getUsername());
insert(e);
return e;
}
}
@@ -8,12 +8,18 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@@ -25,20 +31,25 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.domain.BizExecutionIntent;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.business.domain.vo.FieldMismatch;
import com.ruoyi.business.domain.vo.ImportMismatch;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.service.InvitationPdfService;
import com.ruoyi.business.sms.AliyunSmsSender;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
@@ -65,6 +76,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private ISysUserService sysUserService;
@Autowired
private BizExpertMapper bizExpertMapper;
@Autowired
private IBizExecutionIntentService bizExecutionIntentService;
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -77,6 +90,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private IBizMeetingService bizMeetingService;
@Autowired
private OssUploader ossUploader;
@Autowired
private InvitationPdfService invitationPdfService;
@Override
public int insert(BizMeetingAttendee entity) {
@@ -156,9 +171,6 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
}
// 3.5 确保专家档案存在 (不存在则新建, 存在则补齐空字段), 让参会人在专家库可见
ensureExpertProfile(body, userId);
// 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
body.setUserId(userId);
body.setCreateBy(SecurityUtils.getUsername());
@@ -166,64 +178,60 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
mapper.insertWithProfile(body);
Long newId = body.getId();
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
// 5. 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口
createFakeSignup(body.getMeetingId(), userId, phone, body);
return newId;
}
/**
* 确保参会人对应的 biz_expert 记录存在且字段尽量完整:
* - 不存在 → 新建 (status='Y' 正常, auditStatus='2' 通过, 与 importExpert 约定一致)
* - 存在但部分字段为空 → 只用参会人档案里的非空值补齐空字段, 不覆盖已有值
* 新增/导入参会人时, 同步给该医生建一条"报名项目"幽灵占位记录 (del_flag='1'):
* - 纯占位, 报名列表/导出/报名查重/参会人 has_intent 均按 del_flag='0' 过滤, 不会露出;
* - 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00);
* - 真报名(报名/邀请函)时会先物理删除这条占位, 再写真实报名.
* 失败只记日志, 不阻断参会人新增/导入.
*/
private void ensureExpertProfile(BizMeetingAttendee body, Long userId) {
if (userId == null) return;
BizExpert existed = bizExpertMapper.selectByUserId(userId);
if (existed == null) {
BizExpert e = new BizExpert();
e.setExpertId(IdGenerator.generateId());
private void createFakeSignup(Long meetingId, Long userId, String phone, BizMeetingAttendee body) {
try {
if (meetingId == null || userId == null) return;
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null || meeting.getProjectId() == null) return;
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
if (project == null || project.getProjectNo() == null || project.getProjectNo().trim().isEmpty()) return;
String projectNo = project.getProjectNo();
// 去重: 该 user+project 已有意向(真/假都算)则不再建占位
if (bizExecutionIntentService.existsAny(userId, projectNo)) return;
String name = (body.getName() != null && !body.getName().trim().isEmpty()) ? body.getName().trim() : phone;
BizExecutionIntent e = new BizExecutionIntent();
e.setUserId(userId);
e.setName(isBlank(body.getName()) ? body.getPhone() : body.getName());
e.setPhone(body.getPhone());
e.setWorkUnit(body.getWorkUnit());
e.setDepartment(body.getDepartment());
e.setTitle(body.getTitle());
e.setIdCard(body.getIdCard());
e.setBankCard(body.getBankCard());
e.setBankName(body.getBankName());
e.setBankBranch(body.getBankBranch());
e.setBankRegion(body.getBankRegion());
e.setBankAddress(body.getBankAddress());
e.setIdCardAttachments(body.getIdCardAttachments());
e.setStatus("Y");
e.setAuditStatus("2");
e.setAuditBy(SecurityUtils.getUsername());
e.setAuditTime(new Date());
e.setProjectNo(projectNo);
e.setProjectName(project.getProjectName());
e.setName(name);
e.setPhone(phone);
e.setOnboardStatus("未入库");
e.setDelFlag("1");
e.setCreateTime(randomSignupTime(project.getStartTime()));
e.setCreateBy(SecurityUtils.getUsername());
e.setCreateTime(new Date());
bizExpertMapper.insert(e);
return;
}
// 补充: 只填 biz_expert 里为空的字段, 已有值不覆盖
BizExpert u = new BizExpert();
u.setUserId(userId);
boolean changed = false;
if (isBlank(existed.getName()) && !isBlank(body.getName())) { u.setName(body.getName()); changed = true; }
if (isBlank(existed.getPhone()) && !isBlank(body.getPhone())) { u.setPhone(body.getPhone()); changed = true; }
if (isBlank(existed.getWorkUnit()) && !isBlank(body.getWorkUnit())) { u.setWorkUnit(body.getWorkUnit()); changed = true; }
if (isBlank(existed.getDepartment()) && !isBlank(body.getDepartment())) { u.setDepartment(body.getDepartment()); changed = true; }
if (isBlank(existed.getTitle()) && !isBlank(body.getTitle())) { u.setTitle(body.getTitle()); changed = true; }
if (isBlank(existed.getIdCard()) && !isBlank(body.getIdCard())) { u.setIdCard(body.getIdCard()); changed = true; }
if (isBlank(existed.getBankCard()) && !isBlank(body.getBankCard())) { u.setBankCard(body.getBankCard()); changed = true; }
if (isBlank(existed.getBankName()) && !isBlank(body.getBankName())) { u.setBankName(body.getBankName()); changed = true; }
if (isBlank(existed.getBankBranch()) && !isBlank(body.getBankBranch())) { u.setBankBranch(body.getBankBranch()); changed = true; }
if (isBlank(existed.getBankRegion()) && !isBlank(body.getBankRegion())) { u.setBankRegion(body.getBankRegion()); changed = true; }
if (isBlank(existed.getBankAddress()) && !isBlank(body.getBankAddress())) { u.setBankAddress(body.getBankAddress()); changed = true; }
if (isBlank(existed.getIdCardAttachments()) && !isBlank(body.getIdCardAttachments())) { u.setIdCardAttachments(body.getIdCardAttachments()); changed = true; }
if (changed) {
bizExpertMapper.updateByUserId(u);
bizExecutionIntentService.insert(e);
log.info("[attendee] 建报名占位记录 userId={} projectNo={}", userId, projectNo);
} catch (Exception ex) {
log.warn("[attendee] 建报名占位记录失败 meetingId={} userId={}: {}", meetingId, userId, ex.getMessage());
}
}
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
/** 报名时间随机: 日期 ∈ [项目开始日, 今天], 时间 ∈ [07:00, 21:00) (分钟粒度) */
private Date randomSignupTime(Date projectStart) {
LocalDate today = LocalDate.now();
LocalDate from = projectStart == null ? today
: projectStart.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
if (from.isAfter(today)) from = today;
long days = ChronoUnit.DAYS.between(from, today) + 1;
LocalDate day = from.plusDays(ThreadLocalRandom.current().nextLong(days));
int minute = ThreadLocalRandom.current().nextInt(7 * 60, 21 * 60); // 420..1259
LocalDateTime dt = day.atTime(minute / 60, minute % 60);
return Date.from(dt.atZone(ZoneId.systemDefault()).toInstant());
}
@Override
public int updateProfile(BizMeetingAttendee entity) {
@@ -458,6 +466,12 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
result.fail(rowNo, "手机号格式不正确: " + phone);
continue;
}
// 身份证号位数校验 (18位, 末位可为 X/x); 空值跳过 (导入不强制必填, 与前端 dialog 的必填规则分开)
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;
}
// 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee)
BizMeetingAttendee body = new BizMeetingAttendee();
@@ -467,7 +481,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
body.setWorkUnit(vo.getWorkUnit());
body.setDepartment(vo.getDepartment());
body.setTitle(vo.getTitle());
body.setIdCard(vo.getIdCard());
body.setIdCard(idCard);
body.setBankName(vo.getBankName());
body.setBankCard(vo.getBankCard());
body.setBankBranch(vo.getBankBranch());
@@ -522,6 +536,111 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return result;
}
@Override
public List<FieldMismatch> diffAgainstExpert(BizMeetingAttendee body) {
Long userId = null;
if (body.getId() != null) {
BizMeetingAttendee existing = mapper.selectById(body.getId());
if (existing != null) userId = existing.getUserId();
} else if (body.getPhone() != null && !body.getPhone().trim().isEmpty()) {
String phone = body.getPhone().trim();
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
if (hits != null && !hits.isEmpty()) userId = hits.get(0).getUserId();
}
if (userId == null) return Collections.emptyList();
BizExpert expert = bizExpertMapper.selectByUserId(userId);
if (expert == null) return Collections.emptyList();
return compareExpertProfile(expert, body);
}
@Override
public Map<String, Object> previewImport(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
List<BizMeetingAttendeeImportVo> rows = util.importExcel(file.getInputStream());
if (rows == null || rows.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
List<ImportMismatch> mismatches = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
BizMeetingAttendeeImportVo vo = rows.get(i);
int rowNo = i + 2; // Excel 行号: 1=表头, 2=第一行数据
String phone = vo.getPhone() == null ? null : vo.getPhone().trim();
// 手机号非法/缺失的行走真导入时会被拦截, dry-run 不参与比对
if (phone == null || phone.isEmpty() || !phone.matches("^1\\d{10}$")) continue;
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
Long userId = (hits != null && !hits.isEmpty()) ? hits.get(0).getUserId() : null;
if (userId == null) continue;
BizExpert expert = bizExpertMapper.selectByUserId(userId);
if (expert == null) continue;
BizMeetingAttendee body = new BizMeetingAttendee();
body.setPhone(phone);
body.setName(vo.getName());
body.setWorkUnit(vo.getWorkUnit());
body.setDepartment(vo.getDepartment());
body.setTitle(vo.getTitle());
body.setIdCard(vo.getIdCard());
body.setBankName(vo.getBankName());
body.setBankCard(vo.getBankCard());
body.setBankBranch(vo.getBankBranch());
body.setBankRegion(vo.getBankRegion());
body.setBankAddress(vo.getBankAddress());
body.setIdCardAttachments(vo.getIdCardAttachments());
List<FieldMismatch> fields = compareExpertProfile(expert, body);
if (!fields.isEmpty()) {
ImportMismatch im = new ImportMismatch();
im.setRowNo(rowNo);
im.setPhone(phone);
im.setName(vo.getName());
im.setFields(fields);
mismatches.add(im);
}
}
Map<String, Object> result = new HashMap<>();
result.put("total", rows.size());
result.put("mismatches", mismatches);
return result;
}
/**
* 比对参会人表单与专家表档案的公共字段 (两表都有的文本档案字段, 手机号是定位键不比对,
* 身份证附件是 OSS 文件 URL 不比对). 规则: 专家表已有值 (非空) 且与输入不一致 (含输入为空)
* → 记一条 mismatch; 专家表为空不算冲突.
*/
private List<FieldMismatch> compareExpertProfile(BizExpert expert, BizMeetingAttendee form) {
List<FieldMismatch> out = new ArrayList<>();
addMismatch(out, "name", "姓名", expert.getName(), form.getName());
addMismatch(out, "workUnit", "医院", expert.getWorkUnit(), form.getWorkUnit());
addMismatch(out, "department", "科室", expert.getDepartment(), form.getDepartment());
addMismatch(out, "title", "职称", expert.getTitle(), form.getTitle());
addMismatch(out, "idCard", "身份证号", expert.getIdCard(), form.getIdCard());
addMismatch(out, "bankCard", "银行卡号", expert.getBankCard(), form.getBankCard());
addMismatch(out, "bankName", "银行名称", expert.getBankName(), form.getBankName());
addMismatch(out, "bankBranch", "开户行支行", expert.getBankBranch(), form.getBankBranch());
addMismatch(out, "bankRegion", "开户行地址", expert.getBankRegion(), form.getBankRegion());
addMismatch(out, "bankAddress", "银行详细地址", expert.getBankAddress(), form.getBankAddress());
// 身份证附件(idCardAttachments)是 OSS 文件 URL, 重新上传即变, 逐字符比对无意义 → 不参与比对
return out;
}
private void addMismatch(List<FieldMismatch> out, String field, String label, String expertValue, String inputValue) {
String ev = expertValue == null ? "" : expertValue.trim();
if (ev.isEmpty()) return; // 专家表为空 → 无"现有数据"可比, 不算冲突
String iv = inputValue == null ? "" : inputValue.trim();
if (ev.equals(iv)) return;
FieldMismatch m = new FieldMismatch();
m.setField(field);
m.setLabel(label);
m.setExpertValue(expertValue);
m.setInputValue(inputValue == null ? "" : inputValue);
out.add(m);
}
@Override
public int pushEsign(List<Long> attendeeIds) {
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
@@ -568,6 +687,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
int sent = 0;
Map<Long, BizMeeting> meetingCache = new HashMap<>();
Map<Long, byte[]> templateCache = new HashMap<>();
for (Long id : attendeeIds) {
if (id == null) continue;
try {
@@ -585,8 +705,23 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
String meetingName = m != null ? m.getMeetingName() : null;
Date startTime = m != null ? m.getStartTime() : null;
// 生成个性化邀请函 (替换姓名 + 盖章 + 上传): 模板按会议只下载一次; 失败不阻断站内信
try {
if (m != null && m.getInvitationUrl() != null && !m.getInvitationUrl().trim().isEmpty()) {
byte[] template = templateCache.get(mid);
if (template == null) {
template = invitationPdfService.downloadTemplate(m.getInvitationUrl().trim());
templateCache.put(mid, template);
}
String url = invitationPdfService.generateAndUpload(template, a.getName());
mapper.updateInvitationUrl(a.getId(), url);
}
} catch (Exception e) {
log.warn("[attendee] 生成个性化邀请函失败 attendeeId={}: {}", id, e.getMessage());
}
// 推"会议邀请"站内信 (不发短信) + 置 is_invited=1
bizNotifyService.meetingInvitation(a.getUserId(), mid, meetingName, startTime);
bizNotifyService.meetingInvitation(a.getUserId(), a.getId(), mid, meetingName, startTime);
mapper.markInvitedById(id);
sent++;
} catch (Exception e) {
@@ -665,107 +800,109 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
}
@Override
public int uploadAgreements(MultipartFile file, Long meetingId) throws Exception {
public Map<String, Object> uploadAgreements(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
byte[] zipBytes = file.getBytes();
// 目录序号 (下划线前数字, 0-based) → 该目录下文件字节 + 文件名
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
Map<Integer, List<String>> folderNames = new HashMap<>();
try {
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
// 中文 Windows 打包器 (WinRAR/资源管理器"发送到压缩文件夹") 用 GBK 编码条目名且不置 UTF-8 标志,
// 强制 UTF-8 解码会抛 malformed input → 回退 GBK 重新解析
log.warn("[attendee] 协议 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
int updated = 0;
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
int idx = e.getKey();
if (idx < 0 || idx >= list.size()) {
log.warn("[attendee] 协议 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
List<Map<String, Object>> results = new ArrayList<>();
for (ZipFolder f : folders.values()) {
MatchResult m = matchAttendee(list, f);
Map<String, Object> r = new HashMap<>();
r.put("dir", f.dir);
r.put("name", f.name);
if (m.attendee == null) {
r.put("matched", false);
r.put("reason", m.reason);
results.add(r);
log.warn("[attendee] 协议目录 {} 无法定位参会人, 跳过", f.dir);
continue;
}
BizMeetingAttendee a = list.get(idx);
List<String> names = folderNames.get(idx);
String fileName = (names != null && !names.isEmpty()) ? names.get(0) : "labor-protocol.pdf";
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
String url = ossUploader.upload(e.getValue().get(0), fileName, "ry8080/meeting/" + meetingId + "/labor-protocol");
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");
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId());
entity.setId(m.attendee.getId());
entity.setLaborProtocol(url);
entity.setUpdateBy(SecurityUtils.getUsername());
updateLaborProtocol(entity);
updated++;
r.put("matched", true);
r.put("attendeeName", m.attendee.getName());
results.add(r);
}
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个", meetingId, updated);
return updated;
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
return buildZipResult(updated, list.size(), results);
}
@Override
public int uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
public Map<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
byte[] zipBytes = file.getBytes();
Map<Integer, List<byte[]>> folderBytes = new HashMap<>();
Map<Integer, List<String>> folderNames = new HashMap<>();
try {
readAttendeeZip(zipBytes, StandardCharsets.UTF_8, folderBytes, folderNames);
} catch (IllegalArgumentException e) {
log.warn("[attendee] 专家照片 zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
folderBytes.clear();
folderNames.clear();
readAttendeeZip(zipBytes, Charset.forName("GBK"), folderBytes, folderNames);
}
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
int updated = 0;
for (Map.Entry<Integer, List<byte[]>> e : folderBytes.entrySet()) {
int idx = e.getKey();
if (idx < 0 || idx >= list.size()) {
log.warn("[attendee] 专家照片 zip 目录序号 {} 越界 (参会人共 {}), 跳过", idx + 1, list.size());
List<Map<String, Object>> results = new ArrayList<>();
for (ZipFolder f : folders.values()) {
MatchResult m = matchAttendee(list, f);
Map<String, Object> r = new HashMap<>();
r.put("dir", f.dir);
r.put("name", f.name);
if (m.attendee == null) {
r.put("matched", false);
r.put("reason", m.reason);
results.add(r);
log.warn("[attendee] 专家照片目录 {} 无法定位参会人, 跳过", f.dir);
continue;
}
BizMeetingAttendee a = list.get(idx);
List<byte[]> files = e.getValue();
List<String> names = folderNames.get(idx);
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
StringBuilder sb = new StringBuilder();
for (int i = 0; i < files.size(); i++) {
String fileName = (names != null && i < names.size()) ? names.get(i) : ("photo-" + (i + 1) + ".jpg");
String url = ossUploader.upload(files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
for (int i = 0; i < f.files.size(); i++) {
String fileName = (i < f.fileNames.size()) ? f.fileNames.get(i) : ("photo-" + (i + 1) + ".jpg");
String url = ossUploader.upload(f.files.get(i), fileName, "ry8080/meeting/" + meetingId + "/expert-photo");
if (sb.length() > 0) sb.append(",");
sb.append(url);
}
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(a.getId());
entity.setId(m.attendee.getId());
entity.setOnSitePhotos(sb.toString());
entity.setUpdateBy(SecurityUtils.getUsername());
updateProfile(entity);
updated++;
r.put("matched", true);
r.put("attendeeName", m.attendee.getName());
results.add(r);
}
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个", meetingId, updated);
return updated;
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
return buildZipResult(updated, list.size(), results);
}
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应参会人序号 */
private void readAttendeeZip(byte[] zipBytes, Charset charset,
Map<Integer, List<byte[]>> folderBytes,
Map<Integer, List<String>> folderNames) throws IOException {
/** 解压 zip, 归集每个 {序号}_姓名 目录下的文件; UTF-8 解析失败回退 GBK (中文 Windows 打包器). */
private LinkedHashMap<String, ZipFolder> parseAttendeeZip(byte[] zipBytes) throws IOException {
try {
return readAttendeeZip(zipBytes, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
log.warn("[attendee] zip UTF-8 解析失败, 回退 GBK: {}", e.getMessage());
return readAttendeeZip(zipBytes, Charset.forName("GBK"));
}
}
/** 按指定字符集解压 zip, 把 {根目录}/{序号}_姓名/ 目录下的文件归集到对应目录 */
private LinkedHashMap<String, ZipFolder> readAttendeeZip(byte[] zipBytes, Charset charset) throws IOException {
LinkedHashMap<String, ZipFolder> folders = new LinkedHashMap<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
@@ -773,31 +910,81 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
String path = entry.getName();
// 跳过 macOS 打包产生的元数据
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
Integer idx = parseAgreementIndex(path);
if (idx == null) continue; // 不在 {序号}_姓名 目录下, 忽略
byte[] data = readAllBytes(zis);
folderBytes.computeIfAbsent(idx, k -> new ArrayList<>()).add(data);
folderNames.computeIfAbsent(idx, k -> new ArrayList<>()).add(lastSegment(path));
String[] seg = parseAgreementDir(path);
if (seg == null) continue; // 文件不在 {序号}_姓名 目录下, 忽略
int seq;
try {
seq = Integer.parseInt(seg[0]);
} catch (NumberFormatException ex) {
continue; // 数字前缀溢出, 视为非法目录
}
String dir = seg[0] + "_" + seg[1];
ZipFolder f = folders.get(dir);
if (f == null) {
f = new ZipFolder(dir, seq, seg[1]);
folders.put(dir, f);
}
f.files.add(readAllBytes(zis));
f.fileNames.add(lastSegment(path));
}
}
return folders;
}
/** 从 zip 条目路径里解析 {序号}_姓名 目录的 0-based 序号; 不匹配返回 null */
private static Integer parseAgreementIndex(String path) {
/** 从 zip 条目路径里解析父目录段 {序号}_姓名 → [序号数字, 姓名段]; 文件不在该结构下返回 null.
* 父目录 = 文件名前最后一段目录 (路径倒数第二段), 与模板生成的 {序号}_姓名/ 一致. */
private static String[] parseAgreementDir(String path) {
if (path == null || path.isEmpty()) return null;
for (String seg : path.split("/")) {
if (seg.isEmpty()) continue;
int underscore = seg.indexOf('_');
if (underscore <= 0) continue;
String numPart = seg.substring(0, underscore);
if (!numPart.matches("\\d+")) continue;
try {
return Integer.parseInt(numPart) - 1;
} catch (NumberFormatException ex) {
return null;
}
String[] segs = path.split("/");
if (segs.length < 2) return null;
String dir = segs[segs.length - 2];
int underscore = dir.indexOf('_');
if (underscore <= 0) return null;
String numPart = dir.substring(0, underscore);
if (!numPart.matches("\\d+")) return null;
return new String[] { numPart, dir.substring(underscore + 1) };
}
/** 严格匹配: 序号 (order by id 后的 1-based 下标) 必须落位, 且该位姓名与目录姓名一致才回填.
* 两者任一不符 → 视为名单变动/重名错位, 跳过并给出原因, 避免把协议错挂到别人身上. */
private MatchResult matchAttendee(List<BizMeetingAttendee> list, ZipFolder f) {
int idx = f.seq - 1;
if (idx < 0 || idx >= list.size()) {
return new MatchResult(null, "序号" + f.seq + "越界(参会人共" + list.size() + "位)");
}
return null;
BizMeetingAttendee a = list.get(idx);
String want = safeAttendeeName(f.name);
String actual = safeAttendeeName(a.getName());
if (!want.equals(actual)) {
return new MatchResult(null, "姓名不一致: 目录[" + f.name + "] ≠ 序号" + f.seq + "指向的[" + actual + "]");
}
return new MatchResult(a, null);
}
private Map<String, Object> buildZipResult(int updated, int total, List<Map<String, Object>> results) {
Map<String, Object> result = new HashMap<>();
result.put("updated", updated);
result.put("total", total);
result.put("skipped", results.size() - updated);
result.put("results", results);
return result;
}
/** zip 里解析出的一个 {序号}_姓名 目录 */
private static class ZipFolder {
final String dir; // 目录段原文, 如 "1_张三"
final int seq; // 1-based 序号
final String name; // 姓名段 (下划线后)
final List<byte[]> files = new ArrayList<>();
final List<String> fileNames = new ArrayList<>();
ZipFolder(String dir, int seq, String name) { this.dir = dir; this.seq = seq; this.name = name; }
}
/** 目录 → 参会人匹配结果 */
private static class MatchResult {
final BizMeetingAttendee attendee; // null 表示未匹配
final String reason; // null=匹配成功; 非空=失败原因
MatchResult(BizMeetingAttendee attendee, String reason) { this.attendee = attendee; this.reason = reason; }
}
private static String lastSegment(String path) {
@@ -31,6 +31,10 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
// 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序)
if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername());
if (entity.getCreateTime() == null) entity.setCreateTime(new Date());
// "保存并提交" 直接以 status='1' 入库: 与 update 提交口径一致, 写入提交时间 (0→1 不发通知)
if ("1".equals(entity.getStatus()) && entity.getSubmitTime() == null) {
entity.setSubmitTime(new Date());
}
return bizProjectPlanMapper.insert(entity);
}
@@ -23,6 +23,7 @@ import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.service.BizSignService;
import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.PdfService;
@@ -37,6 +38,8 @@ public class BizSignServiceImpl implements BizSignService {
@Autowired
private BizExpertMapper expertMapper;
@Autowired
private IBizExpertService bizExpertService;
@Autowired
private BizMeetingMapper meetingMapper;
@Autowired
private BizLaborProtocolTemplateMapper templateMapper;
@@ -224,6 +227,25 @@ public class BizSignServiceImpl implements BizSignService {
entity.setLaborForm(form.getLaborForm());
entity.setUpdateBy(SecurityUtils.getUsername());
attendeeService.updateProfile(entity);
// 双写 biz_expert: 医生自己填写的档案覆盖专家表 (医生是可信源).
// 只写两表共有的 11 个档案字段; 不动 status/auditStatus/practiceCertUrl/titleCertUrl/region.
// accountName/laborForm 是参会表专属, 不进专家表.
BizExpert expertUpsert = new BizExpert();
expertUpsert.setUserId(attendee.getUserId());
expertUpsert.setName(form.getName());
expertUpsert.setPhone(form.getPhone());
expertUpsert.setWorkUnit(form.getWorkUnit());
expertUpsert.setDepartment(form.getDepartment());
expertUpsert.setTitle(form.getTitle());
expertUpsert.setIdCard(form.getIdCard());
expertUpsert.setBankCard(form.getBankCard());
expertUpsert.setBankName(form.getBankName());
expertUpsert.setBankBranch(form.getBankBranch());
expertUpsert.setBankRegion(form.getBankRegion());
expertUpsert.setBankAddress(form.getBankAddress());
expertUpsert.setIdCardAttachments(form.getIdCardAttachments());
bizExpertService.updateProfileByUserId(expertUpsert);
}
@Override
@@ -13,9 +13,10 @@
<result property="phone" column="phone" />
<result property="onboardStatus" column="onboard_status" />
<result property="createTime" column="create_time" />
<result property="delFlag" column="del_flag" />
</resultMap>
<sql id="selectFields">
select intent_id, user_id, project_no, project_name, name, work_unit, department, position, phone, onboard_status, create_by, create_time, update_by, update_time
select intent_id, user_id, project_no, project_name, name, work_unit, department, position, phone, onboard_status, create_by, create_time, update_by, update_time, del_flag
from biz_execution_intent
</sql>
<select id="selectByPrimaryKey" resultMap="BizExecutionIntentResult" parameterType="String">
@@ -23,6 +24,27 @@
where intent_id = #{intentId}
</select>
<select id="selectList" resultMap="BizExecutionIntentResult" parameterType="BizExecutionIntent">
<include refid="selectFields"/>
<where>
del_flag = '0'
<if test="userId != null"> and user_id = #{userId}</if>
<if test="projectNo != null and projectNo != ''"> and project_no = #{projectNo}</if>
<if test="params.projectNos != null and params.projectNos.size() > 0">
and project_no in
<foreach collection="params.projectNos" item="no" open="(" separator="," close=")">
#{no}
</foreach>
</if>
<if test="projectName != null and projectName != ''"> and project_name = #{projectName}</if>
<if test="name != null and name != ''"> and name = #{name}</if>
<if test="department != null and department != ''"> and department = #{department}</if>
<if test="position != null and position != ''"> and position = #{position}</if>
<if test="phone != null and phone != ''"> and phone = #{phone}</if>
</where>
order by intent_id desc
</select>
<!-- 查询全部 (含 del_flag='1' 幽灵占位), 仅"已报名专家导出"用: 不加 del_flag='0' 过滤 -->
<select id="selectListIncludingDeleted" resultMap="BizExecutionIntentResult" parameterType="BizExecutionIntent">
<include refid="selectFields"/>
<where>
<if test="userId != null"> and user_id = #{userId}</if>
@@ -55,6 +77,7 @@
<if test="position != null">position,</if>
<if test="phone != null">phone,</if>
<if test="createBy != null">create_by,</if>
<if test="delFlag != null and delFlag != ''">del_flag,</if>
create_time,
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
@@ -69,7 +92,9 @@
<if test="position != null">#{position},</if>
<if test="phone != null">#{phone},</if>
<if test="createBy != null">#{createBy},</if>
sysdate(),
<if test="delFlag != null and delFlag != ''">#{delFlag},</if>
<if test="createTime != null">#{createTime},</if>
<if test="createTime == null">sysdate(),</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizExecutionIntent">
@@ -95,4 +120,14 @@
#{intentId}
</foreach>
</delete>
<!-- 物理删除幽灵占位记录: 真报名(报名/邀请函)时先删掉 del_flag='1' 的假记录 -->
<delete id="hardDeletePlaceholder">
delete from biz_execution_intent
where user_id = #{userId} and project_no = #{projectNo} and del_flag = '1'
</delete>
<!-- 该 user+project 是否已有意向(真/假都算), 建占位记录前去重 -->
<select id="countByUserAndProject" resultType="int">
select count(1) from biz_execution_intent
where user_id = #{userId} and project_no = #{projectNo}
</select>
</mapper>
@@ -30,6 +30,7 @@
<result property="handsign" column="handsign" />
<result property="laborProtocol" column="labor_protocol" />
<result property="laborProtocolMasked" column="labor_protocol_masked" />
<result property="invitationUrl" column="invitation_url" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<!-- 联表字段 (非持久化, entity transient property 接收) -->
@@ -126,6 +127,13 @@
update_time = sysdate()
where id = #{id}
</update>
<!-- 发送邀请函后回写盖章个性化邀请函 URL -->
<update id="updateInvitationUrl">
update biz_meeting_attendee
set invitation_url = #{invitationUrl},
update_time = sysdate()
where id = #{id}
</update>
<!-- 推送电子签后置 is_esigned=1 -->
<update id="markEsignedById" parameterType="Long">
update biz_meeting_attendee
@@ -153,21 +161,22 @@
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited,
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited,
(select if(exists(
select 1 from biz_execution_intent e
where e.user_id = biz_meeting_attendee.user_id
and e.del_flag = '0'
and e.project_no collate utf8mb4_unicode_ci = (select p.project_no collate utf8mb4_unicode_ci from biz_meeting m join biz_project p on m.project_id = p.project_id where m.meeting_id = #{meetingId})
), 1, 0)) as has_intent
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
order by id
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited
from biz_meeting_attendee where user_id = #{userId} and is_deleted = 0
</select>
<select id="selectById" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time, is_deleted, is_esigned, is_invited
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited
from biz_meeting_attendee where id = #{id} and is_deleted = 0
</select>
<!--
+586 -2
View File
@@ -14,6 +14,7 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.0",
"element-plus": "^2.4.0",
"pdfjs-dist": "^3.11.174",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
@@ -497,6 +498,26 @@
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@microsoft/fetch-event-source": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
@@ -1561,6 +1582,12 @@
"snabbdom": "^3.1.0"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"optional": true
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
@@ -1631,6 +1658,26 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"optional": true
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"optional": true,
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@@ -1656,7 +1703,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"devOptional": true
},
"node_modules/binary-extensions": {
"version": "2.3.0",
@@ -1711,6 +1758,21 @@
"node": ">=6"
}
},
"node_modules/canvas": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/canvas/-/canvas-2.11.2.tgz",
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"nan": "^2.17.0",
"simple-get": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
@@ -1726,6 +1788,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
@@ -1752,6 +1823,15 @@
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"optional": true,
"bin": {
"color-support": "bin.js"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1768,12 +1848,24 @@
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"optional": true
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"optional": true
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -1820,6 +1912,18 @@
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-4.2.1.tgz",
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
"optional": true,
"dependencies": {
"mimic-response": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1828,11 +1932,16 @@
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"optional": true
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"optional": true,
"engines": {
"node": ">=8"
@@ -2158,6 +2267,36 @@
"node": ">= 6"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"optional": true
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -2180,6 +2319,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"optional": true,
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -2223,6 +2383,27 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -2235,6 +2416,28 @@
"node": ">= 6"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
@@ -2271,6 +2474,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"optional": true
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
@@ -2326,6 +2535,23 @@
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"dev": true
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"optional": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"optional": true
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2506,6 +2732,30 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"optional": true,
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"optional": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2580,6 +2830,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
"optional": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
@@ -2595,6 +2857,52 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"optional": true,
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"optional": true,
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
@@ -2617,6 +2925,12 @@
"resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g=="
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
@@ -2646,6 +2960,41 @@
"dev": true,
"optional": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"optional": true,
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -2660,6 +3009,37 @@
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw=="
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"optional": true,
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
@@ -2701,18 +3081,48 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/path2d-polyfill": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
"node_modules/pdfjs-dist": {
"version": "3.11.174",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"canvas": "^2.11.2",
"path2d-polyfill": "^2.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2882,6 +3292,20 @@
}
]
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz",
@@ -2939,6 +3363,22 @@
"node": ">=0.10.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"optional": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup": {
"version": "4.62.4",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz",
@@ -3007,6 +3447,26 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true
},
"node_modules/sass": {
"version": "1.102.0",
"resolved": "https://registry.npmmirror.com/sass/-/sass-1.102.0.tgz",
@@ -3041,11 +3501,60 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"optional": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"optional": true
},
"node_modules/simple-get": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-3.1.1.tgz",
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
"optional": true,
"dependencies": {
"decompress-response": "^4.2.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/slate": {
"version": "0.72.8",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
@@ -3088,6 +3597,15 @@
"resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA=="
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
@@ -3136,6 +3654,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"optional": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -3153,6 +3688,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"optional": true
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz",
@@ -3373,6 +3914,12 @@
"node": ">=8.10.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"optional": true
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
@@ -3496,17 +4043,42 @@
"vue": "^3.5.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"optional": true
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"optional": true,
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
@@ -3525,11 +4097,23 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"optional": true
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"optional": true
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.0",
"element-plus": "^2.4.0",
"pdfjs-dist": "^3.11.174",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
@@ -30,4 +30,14 @@ export function updateHandsign(id, handsign) {
*/
export function updateLaborProtocol(id, laborProtocol) {
return request({ url: `/business/meetingAttendee/${id}/laborProtocol`, method: 'put', data: { laborProtocol } })
}
/**
* 会议邀请函详情 (参会人/医生点开站内信邀请链接后查看)
* @param {number|string} attendeeId 参会人中间表主键
* 返回 { attendeeName, title, laborForm, projectName, meetingName, meetingTime, scheduleUrl, invitationUrl }
*/
export function getMeetingInvitation(attendeeId) {
// __silentError: GET 拦截器默认兜底 toast, 这里由调用方 catch 用后端具体 msg 弹, 避免双 toast
return request({ url: `/business/meetingAttendee/invitation/${attendeeId}`, method: 'get', __silentError: true })
}
+10
View File
@@ -170,6 +170,16 @@ export function exportPublicitySupportIntent(params) {
return request.post('/business/publicitySupportIntent/export', null, { params, responseType: 'blob' })
}
// ========== ecology 项目拉取 (admin/projects/new 按项目编号自动回填) ==========
// 项目编号模糊搜索下拉: 返回 [{projectNo, projectName}]
export function searchEcologyProjects(query) {
return request.get('/business/ecology/project/search', { params: query })
}
// 按项目编号拉取完整信息 + 负责人静默建档; opts 可传 { __silentError: true } 让拦截器不弹错
export function pullEcologyProject(projectNo, opts) {
return request.get('/business/ecology/project/byNo', { params: { projectNo }, ...(opts || {}) })
}
// ========== 项目导出 (manager/Projects.vue, 跟随当前筛选) ==========
export function exportProjectList(params) {
return request.post('/business/project/export', null, { params, responseType: 'blob' })
+31 -5
View File
@@ -84,11 +84,14 @@
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
import { onGlobal } from '@/utils/sseClient'
const router = useRouter()
const props = defineProps({
limit: { type: Number, default: 5 },
showCategory: { type: Boolean, default: false },
@@ -149,8 +152,34 @@ function extractLink(content) {
return m[0].replace(/[。,、;:,.!?;:)\]]+$/, '')
}
/** 点列表项: 打开详情 + 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
// bizType → 路由映射: 这类通知点击后直接进入对应页面 (带 layout), 不弹 dialog 二次点击.
// 后端约定: bizId 存路由参数 (如会议邀请函 bizId = attendeeId). 后续新类型在此扩展.
const BIZ_NAV = {
meetingInvitation: (bizId) => `/doctor/invitation/${bizId}`
}
function navigateTarget(n) {
if (!n.bizType || n.bizId == null) return ''
const fn = BIZ_NAV[n.bizType]
return fn ? fn(n.bizId) : ''
}
/** 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
function markRead(n) {
if (!n.read) {
n.read = true
markMessageRead(n.id).catch(() => { n.read = false })
}
}
/** 点列表项: 特定类型直接跳页面; 其余打开详情 dialog */
function openDetail(n) {
const target = navigateTarget(n)
if (target) {
markRead(n)
router.push(target)
return
}
detail.value = n
detailOpen.value = true
const link = extractLink(n.content)
@@ -161,10 +190,7 @@ function openDetail(n) {
.then(url => { detailQrUrl.value = url })
.catch(() => { detailQrUrl.value = '' })
}
if (!n.read) {
n.read = true
markMessageRead(n.id).catch(() => { n.read = false })
}
markRead(n)
}
/** 复制消息正文中的链接 */
+130
View File
@@ -0,0 +1,130 @@
<template>
<div ref="wrapRef" class="pdf-viewer">
<div v-if="status === 'loading'" class="pdf-tip">PDF 加载中</div>
<div v-else-if="status === 'error'" class="pdf-tip pdf-error">
<p>PDF 加载失败</p>
<p class="hint">{{ errorMsg }}</p>
<el-button size="small" type="primary" style="margin-top: 12px" @click="render">重新加载</el-button>
</div>
<div v-show="status === 'done'" ref="hostRef" class="pdf-host"></div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
const props = defineProps({
url: { type: String, default: '' }
})
const wrapRef = ref(null)
const hostRef = ref(null)
const status = ref('') // '' | 'loading' | 'done' | 'error'
const errorMsg = ref('')
let renderToken = 0
let pdfDoc = null
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
// PDF.js 需要干净的字节流, 走 /common/oss/proxy 后端代理 (重写为 inline), 不带 #toolbar fragment.
function pdfProxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url)
}
return url
}
async function render() {
const url = pdfProxyUrl(props.url)
if (!url) return
const token = ++renderToken
// 释放上一份文档
if (pdfDoc) { try { await pdfDoc.destroy() } catch (e) { /* ignore */ } pdfDoc = null }
status.value = 'loading'
errorMsg.value = ''
const host = hostRef.value
if (host) host.innerHTML = ''
try {
const doc = await pdfjsLib.getDocument({ url }).promise
if (token !== renderToken) { await doc.destroy(); return }
pdfDoc = doc
await nextTick() // 等容器完成布局再量宽度 (dialog 打开动画 / v-show 切换后)
const wrapWidth = wrapRef.value?.clientWidth || (window.innerWidth - 32) || 800
const first = await doc.getPage(1)
const base = first.getViewport({ scale: 1 })
const dpr = Math.min(window.devicePixelRatio || 1, 2) // 高清屏放大绘制, 上限 2 避免内存过大
const scale = (wrapWidth / base.width) * dpr
let shown = false
for (let i = 1; i <= doc.numPages; i++) {
if (token !== renderToken) break
const page = await doc.getPage(i)
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width)
canvas.height = Math.floor(viewport.height)
// 动态创建的 canvas 不受 scoped CSS 作用, 内联设置样式 (display:block + margin auto 居中)
canvas.style.display = 'block'
canvas.style.margin = '8px auto'
canvas.style.background = '#fff'
canvas.style.boxShadow = '0 1px 4px rgba(0,0,0,.15)'
canvas.style.width = Math.floor(viewport.width / dpr) + 'px'
canvas.style.height = Math.floor(viewport.height / dpr) + 'px'
host.appendChild(canvas)
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
// 第一页渲染完立即显示, 后续页后台追加, 避免等全部页都画完才可见
if (!shown && token === renderToken) {
shown = true
status.value = 'done'
}
}
if (token === renderToken) status.value = 'done'
} catch (e) {
console.error('[PdfViewer] render failed', e)
if (token === renderToken) {
status.value = 'error'
errorMsg.value = e?.message || 'PDF 加载失败'
}
}
}
watch(() => props.url, (url) => {
if (url) {
nextTick(() => render())
} else {
status.value = ''
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
}
}, { immediate: true })
onBeforeUnmount(() => {
renderToken++ // 让进行中的渲染尽早中断
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
})
</script>
<style scoped>
.pdf-viewer {
width: 100%;
min-width: 0;
max-height: 72vh;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
background: #f2f3f5;
border-radius: 4px;
}
.pdf-host { width: 100%; padding: 8px 0; }
.pdf-tip { padding: 60px 20px; text-align: center; color: #909399; font-size: 14px; }
.pdf-error p { margin: 6px 0 0; }
.pdf-error .hint { font-size: 12px; color: #c0c4cc; }
/* H5: 移动端 dialog 全屏化后, 容器高度跟随可用视口, 避免套两层滚动条 */
@media (max-width: 640px) {
.pdf-viewer { max-height: calc(100vh - 200px); border-radius: 0; }
}
</style>
+6 -6
View File
@@ -15,8 +15,8 @@
<div v-else-if="primary" class="preview-box">
<!-- 图片 -->
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
<!-- PDFiframe 预览浏览器内置 viewer -->
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(primary)" class="preview-iframe" />
<!-- PDFPDF.js canvas 渲染微信/移动端浏览器无 Chrome 内置 PDF viewer -->
<PdfViewer v-else-if="kind === 'pdf'" :url="primary" class="preview-pdf" />
<!-- 其它doc/xls/zip 给下载链接 -->
<div v-else class="preview-fallback">
<el-icon class="fallback-icon"><Document /></el-icon>
@@ -37,6 +37,7 @@
import { ref, computed, watch } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PdfViewer from '@/components/PdfViewer.vue'
const props = defineProps({
modelValue: Boolean,
@@ -68,9 +69,8 @@ function kindOf(u) {
}
const kind = computed(() => kindOf(primary.value))
// OSS bucket 设置了 Content-Disposition: attachment, iframe 直接访问会被强制下载 (空白+下载)
// PDF 走 /common/oss/proxy 后端代理重写为 inline (与 publicity/{id} / doctor/Meetings / manager/Plans 同技术)
// #toolbar=0 隐藏 Chrome PDF viewer 工具栏, #zoom=page-width 强制 PDF 撑满 iframe 宽度
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
// 「在新窗口打开」走 /common/oss/proxy 后端代理重写为 inline (页内 PDF 预览已改用 PdfViewer/pdf.js)
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
@@ -97,7 +97,7 @@ async function copyUrl() {
<style scoped>
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
.preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
.preview-pdf { width: 100%; }
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
.preview-fallback p { margin: 12px 0 24px; }
+5 -1
View File
@@ -27,6 +27,9 @@ const routes = [
{ path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } },
{ path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } },
{ path: 'projects/detail/:projectId', name: 'admin-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'projects/new', name: 'admin-projects-new', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '新建项目' } },
{ path: 'projects/edit/:projectId', name: 'admin-projects-edit', component: () => import('@/views/manager/ProjectsNew.vue'), meta: { title: '编辑项目' } },
{ path: 'projects/assign', name: 'admin-projects-assign', component: () => import('@/views/manager/ManagerProjectsAssign.vue'), meta: { title: '项目分配' } },
{ path: 'meetings', name: 'admin-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } },
{ path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/view/:meetingId', name: 'admin-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
@@ -107,7 +110,8 @@ const routes = [
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } },
{ path: 'sign-fill', name: 'doctor-sign-fill', component: () => import('@/views/doctor/SignFill.vue'), meta: { title: '填写劳务信息', role: 'doctor', hideMenu: true } },
{ path: 'sign-contract', name: 'doctor-sign-contract', component: () => import('@/views/doctor/SignContract.vue'), meta: { title: '签署劳务协议', role: 'doctor', hideMenu: true } },
{ path: 'sign-success', name: 'doctor-sign-success', component: () => import('@/views/doctor/SignSuccess.vue'), meta: { title: '签字成功', role: 'doctor', hideMenu: true } }
{ path: 'sign-success', name: 'doctor-sign-success', component: () => import('@/views/doctor/SignSuccess.vue'), meta: { title: '签字成功', role: 'doctor', hideMenu: true } },
{ path: 'invitation/:attendeeId', name: 'doctor-meeting-invitation', component: () => import('@/views/doctor/MeetingInvitation.vue'), meta: { title: '会议邀请函', role: 'doctor' } }
]
},
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
+6 -1
View File
@@ -32,6 +32,9 @@ function serviceState(row) {
/**
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
* 两轨取最小进度 (流程最靠前的一轨决定会议物理态).
* 注意: "已执行"此处含时间兜底 (executionPhase: is_executed=1 或 now≥end_time), 与后端纯事实镜像略有差异 —
* 后端由调度器 markExecuted 落 is_executed=1, 前端在调度器未翻之前提前视作 RUNNING, 仅为让详情时间轴
* 与列表"已执行"文案/绿点一致, 不参与数据/筛选.
*/
export function derivePhysicalStage(row) {
if (!row) return 'NOT_STARTED'
@@ -43,7 +46,9 @@ export function derivePhysicalStage(row) {
if (s === 2) return 'AWAITING_COMPLIANCE'
if (s === 3) return 'AWAITING_SUPERVISION'
if (s === 4) return 'AWAITING_SETTLEMENT'
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
// 已执行 = is_executed=1 或 时间已过 end_time (与 executionPhase/列表展示对齐):
// 否则详情时间轴"会议已执行"绿点只在调度器 markExecuted 翻 is_executed 后才变绿, 列表却已显示"已执行" → 两边错位.
return executionPhase(row) === 2 ? 'RUNNING' : 'NOT_STARTED'
}
/** manager 优先级: C0=0, R=1, N=2, C1=3, A=4 (越小越优先). C0=有活最优先; 无活时按最早环节优先(R<N<C1<A). */
+13 -6
View File
@@ -182,7 +182,7 @@ const userStore = useUserStore()
const form = reactive({ username: '', password: '', code: '', uuid: '', role: 'admin' })
const captchaImg = ref('')
const captchaEnabled = ref(true)
const captchaEnabled = ref(false)
const loading = ref(false)
const showRoleModal = ref(false)
const registerUserType = ref('')
@@ -558,9 +558,10 @@ body { margin: 0; padding: 0; }
/* 主体 */
.login-page .main-content{
flex: 1;
flex: 0 0 auto;
margin: auto 0;
display: flex;
align-items: center;
align-items: stretch;
justify-content: center;
padding: 60px 80px;
gap: 80px;
@@ -569,6 +570,7 @@ body { margin: 0; padding: 0; }
.login-page .banner-section{
flex: 1;
max-width: 640px;
display: flex;
}
.login-page .banner-inner{
@@ -577,7 +579,10 @@ body { margin: 0; padding: 0; }
background-size: cover;
background-position: center;
background-repeat: no-repeat;
height: 520px;
flex: 1;
/* 稳定基准高度: banner 高度不再只靠 align-items:stretch 兜, 给个确定 min-height,
避免刷新首帧 stretch 未定高时 banner 被拉得过大、等图片/验证码加载完重排才恢复 */
min-height: 440px;
border-radius: 16px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
overflow: hidden;
@@ -595,6 +600,8 @@ body { margin: 0; padding: 0; }
border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.1);
/* 与 banner 同高下限, 让左右两栏高度确定、贴合 (stretch 仍会在内容更高时同步拉高) */
min-height: 440px;
}
.login-page .login-title{
@@ -891,8 +898,8 @@ body { margin: 0; padding: 0; }
.footer { padding: 16px 20px; }
}
/* === 用户要求: 两栏在 main-content 中居中, 卡片高度自适应 === */
.login-page .main-content { align-items: center; }
/* === 用户要求: 左 banner 高度贴齐右侧卡片 (改左侧, 不动卡片) === */
.login-page .main-content { align-items: stretch; }
/* === 注册选择类别弹窗 (原型 1:1 抄 /home/john/ry8080/proto/html/登录.html) === */
.modal-overlay {
@@ -0,0 +1,267 @@
<template>
<div v-loading="loading" class="page-card invitation-page">
<!-- 面包屑 -->
<div class="breadcrumb">首页 / 会议邀请函</div>
<!-- 称谓 -->
<p class="salutation">尊敬的 {{ attendeeName || '专家' }}</p>
<!-- 正文 -->
<p class="body-text">
您好由衷感谢您长期以来对北京整合医学学会工作的鼎力支持您报名参与的
<span class="highlight">{{ projectName || '(项目待定)' }}</span>现定于
<span class="highlight">{{ meetingDate || '(会议日期待定)' }}</span>正式举办我们诚挚邀请您担任
<span class="highlight">{{ laborForm || '(角色待定)' }}</span>
</p>
<!-- 落款 -->
<div class="signature">
<div>北京整合医学学会</div>
<div>{{ issueDate }}</div>
</div>
<!-- 附件日程海报 + 邀请函 缩略图 -->
<div class="attach-row">
<div class="attach-card">
<div class="attach-label">日程海报</div>
<div v-if="scheduleUrl" class="attach-thumb" @click="preview(scheduleUrl, '日程海报')">
<img v-if="scheduleThumb" :src="scheduleThumb" alt="日程海报" />
<div v-else class="thumb-loading">缩略图生成中</div>
</div>
<div v-else class="attach-missing">未上传请联系会务方</div>
<div v-if="scheduleUrl" class="attach-actions">
<el-button size="small" @click="preview(scheduleUrl, '日程海报')">预览</el-button>
<el-button size="small" type="primary" @click="download(scheduleUrl, '日程海报')">下载</el-button>
</div>
</div>
<div class="attach-card">
<div class="attach-label">邀请函</div>
<div v-if="invitationUrl" class="attach-thumb" @click="preview(invitationUrl, '邀请函')">
<img v-if="invitationThumb" :src="invitationThumb" alt="邀请函" />
<div v-else class="thumb-loading">缩略图生成中</div>
</div>
<div v-else class="attach-missing">未上传请联系会务方</div>
<div v-if="invitationUrl" class="attach-actions">
<el-button size="small" @click="preview(invitationUrl, '邀请函')">预览</el-button>
<el-button size="small" type="primary" @click="download(invitationUrl, '邀请函')">下载</el-button>
</div>
</div>
</div>
<!-- 预览 dialog (PDF PDF.js canvas 渲染, 图片走 el-image) -->
<el-dialog v-model="previewOpen" :title="previewTitle" width="780px" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div v-if="previewUrl" class="file-preview">
<PdfViewer v-if="isPdf(previewUrl)" :url="previewUrl" class="file-preview-pdf" />
<el-image v-else-if="isImg(previewUrl)" :src="previewUrl" :preview-src-list="[previewUrl]" fit="contain" class="file-preview-img" />
<div v-else class="file-preview-fallback">
<p>该附件暂不支持在线预览</p>
</div>
</div>
<template #footer>
<el-button @click="previewOpen = false">关闭</el-button>
<el-button v-if="previewUrl" type="primary" @click="download(previewUrl, previewTitle)">下载</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getMeetingInvitation } from '@/api/business/meetingAttendee'
import PdfViewer from '@/components/PdfViewer.vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
const route = useRoute()
const loading = ref(false)
const attendeeName = ref('')
const laborForm = ref('')
const projectName = ref('')
const meetingDate = ref('')
const issueDate = ref('')
const scheduleUrl = ref('')
const invitationUrl = ref('')
const scheduleThumb = ref('')
const invitationThumb = ref('')
const previewOpen = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
// 缩略图渲染令牌: 路由切换/卸载时作废, 避免异步回写串页
let renderToken = 0
function pad(n) { return String(n).padStart(2, '0') }
function isPdf(url) {
if (!url) return false
return /\.pdf$/.test(url.split('?')[0].toLowerCase())
}
function isImg(url) {
if (!url) return false
return /\.(png|jpg|jpeg|gif|webp)$/.test(url.split('?')[0].toLowerCase())
}
// PDF.js 专用代理 URL: 不带 #toolbar fragment (PDF.js 需要干净字节流)
function pdfProxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url)
}
return url
}
function fmtMeetingDate(v) {
if (!v) return ''
const d = new Date(String(v).replace(/-/g, '/'))
if (isNaN(d.getTime())) return v
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function fmtCnDate(d) {
if (!d) return ''
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
}
/** PDF 首帧转缩略图 (canvas → dataURL); 图片直接返回原 url */
async function renderThumb(url) {
if (isImg(url)) return url
if (!isPdf(url)) return ''
const doc = await pdfjsLib.getDocument({ url: pdfProxyUrl(url) }).promise
const page = await doc.getPage(1)
const base = page.getViewport({ scale: 1 })
const scale = Math.min(280 / base.width, 1.6) // 缩略图目标宽 ~280px
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width)
canvas.height = Math.floor(viewport.height)
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
const dataUrl = canvas.toDataURL('image/png')
await doc.destroy()
return dataUrl
}
async function loadThumbs() {
const token = renderToken
if (scheduleUrl.value) {
scheduleThumb.value = await renderThumb(scheduleUrl.value).catch(() => '')
}
if (token !== renderToken) return
if (invitationUrl.value) {
invitationThumb.value = await renderThumb(invitationUrl.value).catch(() => '')
}
}
async function load() {
loading.value = true
try {
const resp = await getMeetingInvitation(route.params.attendeeId)
const d = resp?.data || {}
attendeeName.value = d.attendeeName || ''
laborForm.value = d.laborForm || ''
projectName.value = d.projectName || ''
meetingDate.value = fmtMeetingDate(d.meetingTime)
issueDate.value = fmtCnDate(new Date())
scheduleUrl.value = d.scheduleUrl || ''
invitationUrl.value = d.invitationUrl || ''
scheduleThumb.value = ''
invitationThumb.value = ''
await loadThumbs()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '加载邀请函失败')
} finally {
loading.value = false
}
}
function preview(url, name) {
if (!url) { ElMessage.warning('该附件尚未上传'); return }
previewUrl.value = url
previewTitle.value = name
previewOpen.value = true
}
function download(url, name) {
if (!url) { ElMessage.warning('该附件尚未上传'); return }
const a = document.createElement('a')
a.href = url
a.download = name
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
onMounted(() => { renderToken++; load() })
onBeforeUnmount(() => { renderToken++ })
</script>
<style scoped>
/* 整页面板 (与 doctor/SubmissionDetail.vue 一致: 单卡片, max-width 1200px, 不套小框) */
.invitation-page { max-width: 1200px; padding: 16px 20px; }
.salutation { font-size: 16px; color: #1a1a1a; margin: 20px 0 18px; }
.body-text { font-size: 15px; color: #303133; margin: 0 0 28px; line-height: 1.9; text-indent: 2em; }
.highlight { color: var(--brand-primary); font-weight: 600; }
.signature { text-align: right; font-size: 15px; color: #303133; margin-bottom: 40px; }
.signature div + div { margin-top: 4px; }
.attach-row {
display: flex;
gap: 20px;
border-top: 1px solid #f0f0f0;
padding-top: 24px;
}
.attach-card {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.attach-label { font-size: 14px; color: #303133; font-weight: 600; }
.attach-thumb {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
}
.attach-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.thumb-loading { font-size: 12px; color: #909399; }
.attach-missing {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #fdf6ec;
border: 1px dashed #f3d19e;
border-radius: 6px;
color: #e6a23c;
font-size: 13px;
}
.attach-actions { display: flex; gap: 8px; }
.file-preview { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.file-preview-pdf { width: 100%; }
.file-preview-img { width: 100%; }
.file-preview-img :deep(.el-image__inner) { width: 100%; height: auto; max-height: 70vh; object-fit: contain; }
.file-preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
@media (max-width: 640px) {
.invitation-page { padding: 12px 14px; }
.attach-row { flex-direction: column; }
}
</style>
+4 -3
View File
@@ -64,7 +64,7 @@
<!-- 日程 / 邀请函 预览 dialog (PDF iframe / 图片 img, 复用 publicity OSS 代理预览技术) -->
<el-dialog v-model="previewOpen" :title="previewTitle" width="780px" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div v-if="previewUrl" class="file-preview">
<iframe v-if="isPdf(previewUrl)" :src="proxyUrl(previewUrl)" class="file-preview-iframe"></iframe>
<PdfViewer v-if="isPdf(previewUrl)" :url="previewUrl" class="file-preview-pdf" />
<el-image v-else-if="isImg(previewUrl)" :src="previewUrl" :preview-src-list="[previewUrl]" fit="contain" class="file-preview-img" />
<div v-else class="file-preview-fallback">
<p>该文件类型暂不支持页内预览, 请在新窗口打开查看</p>
@@ -94,7 +94,7 @@
<!-- 查看劳务 dialog: 已签状态显示, PDF iframe 预览 -->
<el-dialog v-model="laborOpen" title="查看劳务" width="720px">
<div class="pdf-preview">
<iframe v-if="detail.attendeeLaborProtocol" :src="proxyUrl(detail.attendeeLaborProtocol)" style="width:100%;height:70vh;border:1px solid #ebeef5"></iframe>
<PdfViewer v-if="detail.attendeeLaborProtocol" :url="detail.attendeeLaborProtocol" class="file-preview-pdf" />
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
</div>
<template #footer>
@@ -111,6 +111,7 @@ import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList } from '@/api/public'
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
import PdfViewer from '@/components/PdfViewer.vue'
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
const page = reactive({ pageNum: 1, pageSize: 20, total: 0 })
@@ -259,7 +260,7 @@ load()
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.pdf-preview { margin-top: 12px; }
.file-preview { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
.file-preview-iframe { width: 100%; height: 70vh; border: 1px solid #ebeef5; border-radius: 4px; }
.file-preview-pdf { width: 100%; }
.file-preview-img { width: 100%; }
.file-preview-img :deep(.el-image__inner) { width: 100%; height: auto; max-height: 70vh; object-fit: contain; }
.file-preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
+7 -20
View File
@@ -5,13 +5,13 @@
<el-button type="primary" @click="goBack">返回</el-button>
</div>
<div v-else v-loading="loading">
<!-- 已签署: 再次打开签署链接, 直接展示劳务协议 PDF (复用 publicity OSS 代理预览) -->
<!-- 已签署: 再次打开签署链接, 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer) -->
<div v-if="signed" class="signed-pdf">
<div class="sign-header">
<div class="sign-header-title">{{ meetingName || '劳务协议' }}</div>
<div v-if="periodDisplay" class="sign-header-period">期数{{ periodDisplay }}</div>
</div>
<iframe v-if="laborPdfUrl" :src="proxyUrl(laborPdfUrl)" class="signed-pdf-frame"></iframe>
<PdfViewer v-if="laborPdfUrl" :url="laborPdfUrl" class="signed-pdf-viewer" />
<div v-else class="signed-pdf-empty">协议已签署暂无可展示的 PDF</div>
<div class="signed-pdf-actions">
<el-button v-if="laborPdfUrl" type="primary" @click="downloadPdf">下载劳务协议</el-button>
@@ -108,6 +108,7 @@ import { getInfo } from '@/api/auth'
import { useUserStore } from '@/store/user'
import IdCardUploader from '@/components/IdCardUploader.vue'
import AreaCascader from '@/components/AreaCascader.vue'
import PdfViewer from '@/components/PdfViewer.vue'
const route = useRoute()
const router = useRouter()
@@ -247,16 +248,10 @@ async function load() {
loading.value = true
try {
const { data } = await getSignInfo(attendeeId.value)
// 已签署: 再次打开签署链接 → 整页跳转到 OSS proxy 的 PDF 地址 (全屏显示, 不用 iframe 内嵌)
// 已签署: 再次打开签署链接 → 本页内嵌 PDF.js 渲染 (微信/移动端无 Chrome 内置 PDF viewer)
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
const url = data.laborProtocol
if (url) {
window.location.href = proxyUrl(url)
return
}
// 无 PDF URL 兜底: 仍留在本页显示"已签署但无可展示 PDF"
signed.value = true
laborPdfUrl.value = ''
laborPdfUrl.value = data.laborProtocol || ''
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
@@ -357,14 +352,6 @@ async function onSubmit() {
}
}
// OSS 代理预览 (同 publicity / doctor/Meetings.vue): 重写 Content-Disposition 为 inline, 隐藏工具栏撑满宽度
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function downloadPdf() {
if (!laborPdfUrl.value) return
const a = document.createElement('a')
@@ -391,9 +378,9 @@ onMounted(init)
.sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; }
.sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; }
/* 已签署: 直接展示 PDF (宽度 100%, 高度不限制) */
/* 已签署: 直接展示 PDF (PdfViewer 内部自适应宽度/高度 + 滚动) */
.signed-pdf { display: flex; flex-direction: column; }
.signed-pdf-frame { width: 100%; height: 424vw; border: 0; }
.signed-pdf-viewer { width: 100%; }
.signed-pdf-empty { padding: 48px 16px; text-align: center; color: #909399; }
.signed-pdf-actions { margin-top: 16px; display: flex; gap: 12px; }
+15 -7
View File
@@ -65,6 +65,7 @@
<!-- 操作按钮 el-form 提出来, 避免 Element Plus form-item 容器影响按钮布局 -->
<div class="detail-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button v-if="showSaveAndSubmit" type="primary" :loading="submitting" @click="onSaveAndSubmit">保存并提交</el-button>
<el-button @click="confirmCancel">取消</el-button>
</div>
</div>
@@ -88,8 +89,11 @@ const base = computed(() => `/${route.meta.role || 'doctor'}`)
const formRef = ref(null)
const saving = ref(false)
const submitting = ref(false)
const loadingDetail = ref(false)
const isEdit = !!route.params.planId
// 保存并提交按钮: 仅 专家(doctor)/执行方(executor) 新建时显示 (点击直接提交审核)
const showSaveAndSubmit = computed(() => !isEdit && ['doctor', 'executor'].includes(route.meta.role))
// 项目方向 options (复用 manager 侧接口)
const planDirectionOptions = ref([])
@@ -160,19 +164,20 @@ function confirmCancel() {
.catch(() => {})
}
async function onSave() {
async function doSave(submit = false) {
try {
await formRef.value.validate()
} catch (e) {
ElMessage.warning('请填写必填项')
return
}
saving.value = true
const loadingRef = submit ? submitting : saving
loadingRef.value = true
try {
let payload = { ...form }
if (!isEdit) {
// 新建: 强制 status='0' (未提交), submitter 后端兜底
payload.status = '0'
// 新建: 默认 status='0' (未提交); "保存并提交" 直接 status='1' (待审核), submitter 后端兜底
payload.status = submit ? '1' : '0'
payload.submitterId = userStore.user?.userId || null
} else if (!payload.status) {
payload.status = '0'
@@ -182,16 +187,19 @@ async function onSave() {
} else {
await bizAdd('projectPlan', payload, { __silentError: true })
}
ElMessage.success(isEdit ? '修改成功' : '新建成功')
ElMessage.success(submit ? '已保存并提交审核' : (isEdit ? '修改成功' : '新建成功'))
goBack()
} catch (e) {
console.error('[submission-new] save failed', e)
ElMessage.error(e?.msg || (isEdit ? '修改失败' : '保存失败'))
ElMessage.error(e?.msg || (submit ? '保存并提交失败' : (isEdit ? '修改失败' : '保存失败')))
} finally {
saving.value = false
loadingRef.value = false
}
}
async function onSave() { return doSave(false) }
async function onSaveAndSubmit() { return doSave(true) }
onMounted(() => {
loadPlanDirectionOptions()
if (isEdit) loadDetail()
@@ -50,8 +50,8 @@
<el-table-column prop="orgName" label="企业名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="部门" min-width="100" />
<el-table-column prop="position" label="职务" min-width="100" />
<!-- 管理员 switch: admin 可见, 指定机构管理员 (MAIN), 逻辑与 sponsor 管理员一致 -->
<el-table-column v-if="isAdmin" label="管理员" width="90" align="center">
<!-- 管理员 switch: admin/manager 可见, 指定机构管理员 (MAIN), 逻辑与 sponsor 管理员一致 -->
<el-table-column v-if="isAdmin || isManager" label="管理员" width="90" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.accountType === 'MAIN'"
@@ -135,6 +135,7 @@ const router = useRouter()
// 角色判定 + 路由基础路径 (admin/manager 共用页面, 但跳回各自 list)
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const isManager = computed(() => route.path.startsWith('/manager/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-people')
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
+17 -1
View File
@@ -52,7 +52,12 @@
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
</el-table-column>
<el-table-column label="总期数" width="80" align="center">
<template #default="{ row }">{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}</template>
<template #default="{ row }">
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onFilterByProject(row)">
{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}
</el-link>
<span v-else>{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}</span>
</template>
</el-table-column>
<el-table-column label="期数" width="80" align="center">
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
@@ -153,6 +158,17 @@ function onCopy(row) {
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '', mode: 'copy' } })
}
// ========== 点击「总期数」: 过滤到该项目下本执行方的会议 ==========
// projectId 精确过滤为准; 后端 list 已按执行方叠加数据范围 (execution_unit_id / create_by), 前端无需分支.
// 显示值 assignedSessions = 分配给本执行方的场次, 但点击仍是"按项目筛选" (语义不变).
function onFilterByProject(row) {
if (row.projectId == null) return
q.value.projectId = row.projectId
q.value.projectNo = row.projectNo || '' // 回填「项目编号」输入框做视觉反馈 (projectId 精确过滤为准)
page.pageNum = 1
load()
}
onMounted(() => { readQueryFromRoute(); load() })
</script>
@@ -365,9 +365,10 @@ function goBack() {
if (role === 'sponsor') router.push('/sponsor/my-projects')
else if (role === 'executor') router.push('/executor/projects')
else if (role === 'admin') router.push('/admin/projects')
else if (role === 'leader') router.push('/leader/projects')
else router.push('/manager/projects')
}
function goEdit() { router.push(`/manager/projects/edit/${projectId.value}`) }
function goEdit() { router.push(`/${roleType.value}/projects/edit/${projectId.value}`) }
// ===================== 预览 =====================
function openPreview(url, title) {
@@ -40,7 +40,7 @@
<div class="new-card-title">项目分配</div>
<el-form-item label="支持单位">
<el-select v-model="assignForm.sponsorOrgId" placeholder="请选择支持单位" filterable
<el-select v-model="assignForm.sponsorOrgId" placeholder="输入支持单位名称搜索" filterable
:filter-method="searchSponsorOrgs" clearable style="width:100%" @change="onSponsorOrgPick">
<el-option v-for="u in sponsorOrgOptions" :key="u.orgId"
:label="u.orgName + (isOrgDisabled(u) ? '(已禁用)' : '')" :value="u.orgId"
@@ -76,7 +76,7 @@
<el-select v-model="row.executionUnitId" filterable
:filter-method="q => searchExecutors(q, row)"
:loading="row._loading"
placeholder="搜索执行单位"
placeholder="输入执行单位名称搜索"
style="width:100%"
@change="v => onExecUserPick(row, v)">
<el-option v-for="u in execOptions(row)" :key="u.orgId"
@@ -243,11 +243,15 @@ function addExecRow() {
// 加载支持方 (sponsor 角色) - 按公司名查, JOIN biz_org + sys_user
async function loadSponsorOrgs(query) {
if (_supporterTimer) clearTimeout(_supporterTimer)
const kw = (query || '').trim()
// 空关键词不拉数据: 下拉清空, 只有用户输入时才边输边搜 (不预加载全量支持单位)
if (!kw) {
sponsorOrgOptions.value = []
return
}
_supporterTimer = setTimeout(async () => {
try {
const params = {}
if (query) params.orgName = query
const r = await listSponsorOrgs(params)
const r = await listSponsorOrgs({ orgName: kw })
sponsorOrgOptions.value = r.data || []
// 已选项不在结果里时按 orgId 单独拉取并加首位
if (assignForm.sponsorOrgId && !sponsorOrgOptions.value.find(u => u.orgId === assignForm.sponsorOrgId)) {
@@ -260,6 +264,17 @@ async function loadSponsorOrgs(query) {
}
}, 200)
}
// 仅按 orgId 拉回已选支持单位的 label (初始化回显用, 不拉全量列表)
async function loadSelectedSponsorOption() {
if (!assignForm.sponsorOrgId) return
try {
const r2 = await listSponsorOrgs({ orgId: assignForm.sponsorOrgId })
const sel = (r2.data || []).find(u => u.orgId === assignForm.sponsorOrgId)
if (sel) sponsorOrgOptions.value = [sel]
} catch (e) {
sponsorOrgOptions.value = []
}
}
function searchSponsorOrgs(q) { loadSponsorOrgs(q) }
// 选中支持方后回填 user_name (sponsorAdminUserName 后端已改派生, 此处仅缓存展示用)
@@ -309,12 +324,17 @@ async function loadSelectedExecOption(row) {
// label=orgName (执行单位名称), value=org_id (直接写 biz_project_assign.execution_unit_id)
async function searchExecutors(query, row) {
if (row._timer) clearTimeout(row._timer)
const kw = (query || '').trim()
// 空关键词不拉数据: 下拉清空, 只有用户输入时才边输边搜 (不预加载全量执行单位)
if (!kw) {
row._options = []
row._loading = false
return
}
row._loading = true
row._timer = setTimeout(async () => {
try {
const params = {}
if (query) params.orgName = query
const r = await listExecutorOrgs(params)
const r = await listExecutorOrgs({ orgName: kw })
row._options = r.data || []
// 选中的项如果不在结果里, 按 orgId 单独拉取并加到首位
if (row.executionUnitId && !row._options.find(u => u.orgId === row.executionUnitId)) {
@@ -362,8 +382,8 @@ async function loadSingleProject() {
assignForm.deadlineDays = Number(p.submitDeadlineDays || 30)
// 拉已分配记录
await loadAssigns(singleProjectId)
// 拉支持方候选 (含已选项回拉)
loadSponsorOrgs('')
// 拉支持方候选: 仅按 orgId 回显已选 label, 不预加载全量
loadSelectedSponsorOption()
} catch (e) {
ElMessage.error('加载项目数据失败: ' + (e?.msg || e?.message || e))
} finally {
@@ -411,10 +431,9 @@ async function loadBatchProjects() {
assignForm.sponsorOrgId = list[0].sponsorOrgId || null
assignForm.sponsorAdminUserName = list[0].sponsorAdminUserName || ''
}
loadSponsorOrgs('')
// 批量模式: 行默认空 1 行 (各项目共用, 保存时按各项目循环)
loadSelectedSponsorOption()
// 批量模式: 行默认空 1 行 (各项目共用, 保存时按各项目循环); 执行单位不预加载, 输入时才搜索
assignForm.execRows = [makeEmptyExecRow()]
assignForm.execRows.forEach(r => searchExecutors('', r))
} catch (e) {
ElMessage.error('加载批量项目失败: ' + (e?.msg || e?.message || e))
} finally {
@@ -430,7 +449,9 @@ onMounted(() => {
// ========== 返回列表 ==========
function goBack() {
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
// 本页被 admin/manager 复用, 按当前路由前缀回各自项目列表 (否则 admin 会被角色守卫拦回工作台)
const base = '/' + route.path.split('/')[1]
router.push({ path: `${base}/projects`, query: { _t: Date.now() } })
}
function confirmCancel() {
ElMessageBox.confirm('确定取消项目分配?未保存的内容将丢失', '提示', { type: 'warning' })
+14 -13
View File
@@ -65,7 +65,7 @@
<el-button @click="reset">重置</el-button>
<el-button v-if="!isLeader" @click="doExport">导出</el-button>
<el-button v-if="!isLeader" @click="doExportRating">项目评价导出</el-button>
<el-button v-if="isManager" @click="doExportSignupExpert">已报名专家导出</el-button>
<el-button v-if="isManager || isAdmin" @click="doExportSignupExpert">已报名专家导出</el-button>
</el-form-item>
</el-form>
@@ -155,7 +155,7 @@
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="activate">开通</el-dropdown-item>
<el-dropdown-item v-if="false" command="activate">开通</el-dropdown-item> <!-- 暂时隐藏开通功能 (勿删, 需要时去掉 v-if="false") -->
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
@@ -300,6 +300,8 @@ import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const router = useRouter()
const route = useRoute()
// 本列表页被 admin/manager/leader 三角色共用, 跳子页面须按当前角色前缀 (否则被 beforeEach 角色守卫拦回工作台)
const basePath = computed(() => userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager')
const q = ref({ sponsorOrgIds: [], execOrgIds: [], projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '' })
const rows = ref([])
@@ -336,14 +338,14 @@ const canDelete = computed(() => {
const r = userStore.role
return r === 'admin'
})
function doCreateMeeting(row) { router.push(`/manager/meetings/new?projectId=${row.projectId}`) }
function doCreateMeeting(row) { router.push(`${basePath.value}/meetings/new?projectId=${row.projectId}`) }
function isFinishedRow(row) { return row.isFinished === '1' || row.isFinished === 1 }
function doAssign(row) {
if (isFinishedRow(row)) return ElMessage.warning('已结题项目不能分配')
// 跳转独立子页面 /manager/projects/assign?projectId=X
router.push({ path: '/manager/projects/assign', query: { projectId: row.projectId } })
router.push({ path: `${basePath.value}/projects/assign`, query: { projectId: row.projectId } })
}
function doEdit(row) { router.push(`/manager/projects/edit/${row.projectId}`) }
function doEdit(row) { router.push(`${basePath.value}/projects/edit/${row.projectId}`) }
// 删除项目 (软删除 + 级联置 5 张项目表 + 项目下所有会议 is_deleted=1)
// 后端 BizProjectController.remove 已强制仅 admin, 前端只做可见性 v-if
@@ -395,8 +397,9 @@ function readQueryFromRoute() {
if (q2.isSettled != null && q2.isSettled !== '') q.value.isSettled = String(q2.isSettled)
}
// 仅 manager 可见「已报名专家导出」 (前端 v-if, 后端 controller 再兜底 role 校验)
// 「已报名专家导出」: manager + admin 可见 (前端 v-if, 后端 controller 再兜底 role 校验)
const isManager = computed(() => userStore.role === 'manager')
const isAdmin = computed(() => userStore.role === 'admin')
// 项目负责人(leader): 只读, 复用本列表页 (数据由后端 list 按 lead_user_id 隔离), 隐藏所有写操作
const isLeader = computed(() => userStore.role === 'leader')
@@ -549,18 +552,16 @@ async function confirmSingleScore() {
}
function viewDetail(row) {
// admin/leader 复用本列表页, 详情须跳到各自前缀 (否则被 beforeEach 角色守卫拦截)
const base = userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager'
router.push(`${base}/projects/detail/${row.projectId}`)
router.push(`${basePath.value}/projects/detail/${row.projectId}`)
}
// 点击「总场次/已执行/未执行」列 → 跳对应角色的会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限)
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行)
function goMeetings(row, scope) {
const base = userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager'
const q = { projectId: row.projectId }
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
router.push({ path: `${base}/meetings`, query: q })
router.push({ path: `${basePath.value}/meetings`, query: q })
}
// 点击列表「执行单位评分(合规/支持)」列 → 只读显示该角色 4 维度评分明细
@@ -626,7 +627,7 @@ const scoreDetailTotal = ref('—')
// 新建项目已迁到独立页面 /manager/projects/new (ManagerProjectsNew.vue)
function openNewProject() {
router.push('/manager/projects/new')
router.push(`${basePath.value}/projects/new`)
}
function openAssign() {
if (!selection.value.length) {
@@ -641,10 +642,10 @@ function openAssign() {
// 单条: ?projectId=X → 子页面 v-if 单条分支
// 批量: ?projectIds=1,2,3 → 子页面 v-else 批量分支
if (targets.length === 1) {
router.push({ path: '/manager/projects/assign', query: { projectId: targets[0].projectId } })
router.push({ path: `${basePath.value}/projects/assign`, query: { projectId: targets[0].projectId } })
} else {
const ids = targets.map(r => r.projectId).join(',')
router.push({ path: '/manager/projects/assign', query: { projectIds: ids } })
router.push({ path: `${basePath.value}/projects/assign`, query: { projectIds: ids } })
}
if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`)
}
+70 -6
View File
@@ -10,13 +10,18 @@
<div class="new-card-title">项目信息</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="form.projectName" placeholder="输入项目名称" />
<el-form-item label="项目编号" prop="projectNo">
<el-select v-model="form.projectNo" placeholder="输入项目编号搜索" filterable allow-create default-first-option
:filter-method="searchProjectNos" :loading="projectNoLoading" clearable style="width:100%"
@change="onProjectNoChange">
<el-option v-for="p in projectNoOptions" :key="p.projectNo"
:label="p.projectName ? `${p.projectNo}${p.projectName}` : p.projectNo" :value="p.projectNo" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目编号" prop="projectNo">
<el-input v-model="form.projectNo" placeholder="请输入项目编号" />
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="form.projectName" placeholder="请输入项目名称" />
</el-form-item>
</el-col>
</el-row>
@@ -132,7 +137,7 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
import { bizAdd, bizUpdate, bizGet, searchEcologyProjects, pullEcologyProject } from '@/api/public'
import { listExecutor, listLeaders } from '@/api/system'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
@@ -211,6 +216,61 @@ function syncProjectData() {
ElMessage.info('同步数据:从外部数据源拉取最新项目信息 (后端支持时启用)')
}
// ===== ecology 项目编号远程搜索 + 自动拉取 =====
const projectNoOptions = ref([])
const projectNoLoading = ref(false)
let _projectNoTimer = null
// 项目编号边输入边搜索 (空关键词清空, 不发请求)
function searchProjectNos(query) {
if (_projectNoTimer) clearTimeout(_projectNoTimer)
_projectNoTimer = setTimeout(async () => {
const kw = (query || '').trim()
if (!kw) {
projectNoOptions.value = []
projectNoLoading.value = false
return
}
projectNoLoading.value = true
try {
const r = await searchEcologyProjects({ xmbh: kw })
projectNoOptions.value = (r?.data) || []
} catch (e) {
projectNoOptions.value = []
} finally {
projectNoLoading.value = false
}
}, 200)
}
// 选中项目编号 → 拉取完整信息并覆盖表单 (负责人静默建档由后端完成)
async function onProjectNoChange(val) {
if (!val) return
// 仅当所选值来自 ecology 下拉时才拉取 (allow-create 自由输入不拉, 避免无谓请求)
const matched = projectNoOptions.value.find(p => p.projectNo === val)
if (!matched) return
try {
const res = await pullEcologyProject(val, { __silentError: true })
const d = res?.data || res
if (!d) return
form.projectName = d.projectName || ''
form.projectForm = d.projectForm || ''
form.totalAmount = d.totalAmount != null ? Number(d.totalAmount) : 0
form.manageFee = d.manageFee != null ? Number(d.manageFee) : 0
form.startTime = d.startTime || ''
form.endTime = d.endTime || ''
if (d.leaderUserId != null) {
form.leadUserId = d.leaderUserId
// 把自动建档的负责人塞进 leaderOptions, 避免 el-select 选中后 label 空白
if (!leaderOptions.value.find(u => u.userId === d.leaderUserId)) {
leaderOptions.value = [{ userId: d.leaderUserId, nickName: d.leaderName, userName: d.leaderPhone }, ...leaderOptions.value]
}
}
} catch (e) {
// ecology 未找到/不可达: 静默, 保留用户手填内容
}
}
// 编辑模式:加载已有项目数据并反显到表单
async function loadProject() {
if (!isEdit || !projectId) return
@@ -221,6 +281,8 @@ async function loadProject() {
if (!p) return
form.projectName = p.projectName || ''
form.projectNo = p.projectNo || ''
// 编辑模式: 项目编号 select 预置当前项, 避免远程搜索 select 无选项时 label 空白
if (form.projectNo) projectNoOptions.value = [{ projectNo: form.projectNo, projectName: form.projectName }]
form.projectForm = p.projectForm || ''
form.totalSessions = p.totalSessions || 0
form.totalAmount = p.totalAmount || 0
@@ -293,7 +355,9 @@ onMounted(() => {
})
function goBack() {
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
// 本页被 admin/manager 复用, 按当前路由前缀回各自项目列表 (否则 admin 会被角色守卫拦回工作台)
const base = '/' + route.path.split('/')[1]
router.push({ path: `${base}/projects`, query: { _t: Date.now() } })
}
function confirmCancel() {
ElMessageBox.confirm(`确定取消${isEdit ? '编辑' : '新建'}项目?未保存的内容将丢失`, '提示', { type: 'warning' })
+247 -85
View File
@@ -68,7 +68,7 @@
<div v-if="!isSponsor && !isReadonly" class="attendee-toolbar">
<el-button type="primary" size="small" :loading="attendeeLoading" :disabled="!attendeeEditable" @click="openAttendeeDialog()">+ 新增参会人</el-button>
<el-button size="small" :disabled="!attendeeEditable" @click="openAttendeeImport">批量导入</el-button>
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">邀请参会</el-button>
<el-button size="small" :disabled="!selectedAttendees.length" :loading="inviteSending" @click="onBatchInvite">发送邀请函</el-button>
<el-button size="small" :disabled="!selectedAttendees.length" :loading="esignSending" @click="onBatchEsign">推送电子签</el-button>
<el-button size="small" :loading="exporting" @click="onExportAttendees">导出</el-button>
</div>
@@ -152,7 +152,7 @@
</el-link>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">邀请参会</el-dropdown-item>
<el-dropdown-item command="invite" :disabled="inviteLoadingId === row.id">发送邀请函</el-dropdown-item>
<el-dropdown-item command="esign" :disabled="esignLoadingId === row.id">推送电子签</el-dropdown-item>
<el-dropdown-item command="delete" :disabled="!attendeeEditable" divided><span style="color: #f56c6c;">删除</span></el-dropdown-item>
</el-dropdown-menu>
@@ -205,7 +205,7 @@
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
<div class="file-hint">会议日程海报</div>
</div>
<div v-if="!isExecutor" class="file-row">
<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" />
<div class="file-hint">会议邀请函</div>
@@ -294,13 +294,13 @@
<el-button v-if="canSubmitLabor && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR'])">提交劳务</el-button>
<el-button v-if="canSubmitService && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['SERVICE'])">提交会务</el-button>
<el-button v-if="canSubmitAll && !isReadonly" type="primary" :loading="busy.submitMaterial" @click="onSubmitMaterials(['LABOR', 'SERVICE'])">提交全部</el-button>
<!-- 合规 / 监察审核 (材料, 分轨三按钮) -->
<el-button v-if="isManager && !isReadonly && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
<el-button v-if="isManager && !isReadonly && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
<el-button v-if="isManager && !isReadonly && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
<!-- 合规 / 监察审核 (材料, 分轨三按钮); 只读 view 页同样可审 (去掉 !isReadonly 挡板) -->
<el-button v-if="isManager && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
<el-button v-if="isManager && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
<el-button v-if="isManager && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
<el-button v-if="supervisorAuditor && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
<el-button v-if="supervisorAuditor && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
<el-button v-if="supervisorAuditor && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
@@ -410,41 +410,41 @@
</template>
</el-input>
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
<el-form-item label="姓名" prop="name" :error="mismatchError('name')">
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="医院" prop="workUnit">
<el-form-item label="医院" prop="workUnit" :error="mismatchError('workUnit')">
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
<el-form-item label="身份证号" prop="idCard">
<el-form-item label="身份证号" prop="idCard" :error="mismatchError('idCard')">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
</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/`" />
</el-form-item>
<el-form-item label="科室" prop="department">
<el-form-item label="科室" prop="department" :error="mismatchError('department')">
<doctor-dept-select v-model="attendeeDialog.form.department" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
</el-form-item>
<el-form-item label="职称" prop="title">
<el-form-item label="职称" prop="title" :error="mismatchError('title')">
<doctor-title-select v-model="attendeeDialog.form.title" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
</el-form-item>
<el-form-item label="银行" prop="bankName">
<el-form-item label="银行" prop="bankName" :error="mismatchError('bankName')">
<el-input v-model="attendeeDialog.form.bankName" placeholder="如: 中国工商银行" />
</el-form-item>
<el-form-item label="银行卡号码" prop="bankCard">
<el-form-item label="银行卡号码" prop="bankCard" :error="mismatchError('bankCard')">
<el-input v-model="attendeeDialog.form.bankCard" placeholder="持卡人银行卡号" maxlength="25" />
</el-form-item>
<!-- Col 2: 财务 + 备注 (8 字段) -->
<el-form-item label="开户行" prop="bankBranch">
<el-form-item label="开户行" prop="bankBranch" :error="mismatchError('bankBranch')">
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
</el-form-item>
<el-form-item label="账户名称" prop="accountName">
<el-input v-model="attendeeDialog.form.accountName" placeholder="持卡人姓名" />
</el-form-item>
<el-form-item label="开户行地址" prop="bankRegion">
<el-form-item label="开户行地址" prop="bankRegion" :error="mismatchError('bankRegion')">
<el-input v-model="attendeeDialog.form.bankRegion" placeholder="如: 北京市东城区" />
</el-form-item>
<el-form-item label="银行详细地址" prop="bankAddress">
<el-form-item label="银行详细地址" prop="bankAddress" :error="mismatchError('bankAddress')">
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
</el-form-item>
<el-form-item label="角色" prop="laborForm" style="grid-column: 1 / -1">
@@ -480,18 +480,15 @@
</template>
</el-dialog>
<!-- 参会人 Excel 批量导入 (沿用 dialog-import SKILL: v-if + on-error + 模板下载在框外 + ImportResultDialog) -->
<!-- 参会人 Excel 批量导入 (两段式: dry-run 比对 二次确认 force 入库; 模板下载在框外 + ImportResultDialog) -->
<el-dialog v-model="importOpen" title="批量导入参会人" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
v-if="importOpen"
ref="importUploadRef"
:limit="1"
:headers="importHeaders"
accept=".xlsx, .xls"
:action="importAction"
:on-progress="onImportProgress"
:on-success="onImportSuccess"
:on-error="onImportError"
:on-change="onImportFileChange"
:on-remove="onImportFileRemove"
:auto-upload="false"
drag
>
@@ -532,6 +529,23 @@
<div v-else class="text-muted">尚未生成海报</div>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<!-- 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) -->
<el-dialog v-model="mismatchDialog.show" :title="mismatchDialog.title" width="640px" append-to-body :close-on-click-modal="false">
<div class="mismatch-intro">{{ mismatchDialog.intro }}</div>
<div v-for="(entry, ei) in mismatchDialog.entries" :key="ei" class="mismatch-entry">
<div class="mismatch-entry-title">{{ entry.title }}</div>
<el-table :data="entry.fields" size="small" border>
<el-table-column prop="label" label="字段" width="110" />
<el-table-column prop="expertValue" label="专家档案" min-width="160" show-overflow-tooltip />
<el-table-column prop="inputValue" label="您输入" min-width="160" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<el-button @click="mismatchDialog.show = false">{{ mismatchDialog.cancelText }}</el-button>
<el-button type="primary" @click="confirmMismatch">{{ mismatchDialog.confirmText }}</el-button>
</template>
</el-dialog>
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
<div class="form-actions">
@@ -595,7 +609,7 @@ const currentRole = computed(() => userStore.role)
const isManager = computed(() => currentRole.value === 'manager')
const isAdmin = computed(() => currentRole.value === 'admin')
const isSponsor = computed(() => currentRole.value === 'sponsor')
/** 只读模式: 从会议列表「查看」跳入 (route.meta.readonly), 仅展示内容 + 返回, 无任何操作按钮 */
/** 只读模式: 从会议列表「查看」/「审核」跳入 (route.meta.readonly), 仅展示内容 + 返回, 除审核/监察按钮外无其他操作按钮 */
const isReadonly = computed(() => route.meta.readonly === true)
/** 合规/管理员 可随时上传付款凭证 (结算前必须先已保存); 其他角色只读查看 */
const canUploadVoucher = computed(() => (isManager.value || isAdmin.value) && !isReadonly.value)
@@ -882,6 +896,16 @@ async function loadMaterials() {
} catch (e) { console.error('[meeting-detail] loadMaterials failed', e) }
}
/** 只刷新 materialsLoaded 金额/fee_status (供 refreshFee 轮询用).
* 与 loadMaterials 区别: 不重写各行 r.url/r.fileName, 避免轮询时覆盖「已上传未保存」的本地文件. */
async function refreshMaterialAmounts() {
try {
const resp = await request.get(`/business/meetingMaterial/${meetingId.value}`, { __silentError: true })
const list = (resp && (resp.data || resp)) || []
materialsLoaded.value = Array.isArray(list) ? list : []
} catch (e) { console.error('[meeting-detail] refreshMaterialAmounts failed', e) }
}
// ===================== 未保存改动检测 (结算前拦截用) =====================
/** 已落库快照 (load 后 + 保存后各刷新一次): 4 组上传行 url + 海报/邀请函 */
const savedSnapshot = ref(null)
@@ -990,7 +1014,10 @@ const attendeeRules = {
phone: [{ required: true, message: '请填写联系方式', trigger: 'blur' }],
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
idCard: [{ required: true, message: '请填写身份证号', trigger: 'blur' }],
idCard: [
{ required: true, message: '请填写身份证号', trigger: 'blur' },
{ pattern: /^\d{17}[\dXx]$/, message: '身份证号应为18位(末位可为X)', trigger: 'blur' }
],
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
title: [{ required: true, message: '请选择职称', trigger: 'change' }],
@@ -1007,6 +1034,42 @@ const attendeeRules = {
fee: [{ required: true, message: '请填写实发金额', trigger: 'change' }]
}
/** 参会人表单字段与专家档案不一致标记 (field → {expertValue, inputValue}), 用于输入框标红 */
const mismatchMap = reactive({})
function mismatchError(field) {
const m = mismatchMap[field]
return m ? `专家档案为「${m.expertValue || '空'}` : ''
}
function clearMismatchMap() {
Object.keys(mismatchMap).forEach(k => delete mismatchMap[k])
}
/** 专家档案不一致 二次确认 dialog (新增/编辑参会人 + 批量导入共用) */
const mismatchDialog = reactive({
show: false,
title: '检测到与专家档案不一致',
intro: '',
entries: [],
confirmText: '确认提交',
cancelText: '返回修改',
onConfirm: null
})
function openMismatchDialog(entries, opts = {}) {
mismatchDialog.entries = entries || []
mismatchDialog.title = opts.title || '检测到与专家档案不一致'
mismatchDialog.intro = opts.intro || '以下字段与专家档案不一致,请确认是否仍按当前输入提交。'
mismatchDialog.confirmText = opts.confirmText || '确认提交'
mismatchDialog.cancelText = opts.cancelText || '返回修改'
mismatchDialog.onConfirm = opts.onConfirm || null
mismatchDialog.show = true
}
function confirmMismatch() {
const fn = mismatchDialog.onConfirm
mismatchDialog.show = false
mismatchDialog.onConfirm = null
if (fn) fn()
}
function resetAttendeeForm() {
attendeeDialog.value = {
show: false, title: '', editing: false,
@@ -1014,6 +1077,7 @@ function resetAttendeeForm() {
}
// 清掉上次的校验错误 (防止关掉后重开还残留"实发金额不能超过..."红字)
attendeeFormRef.value?.clearValidate()
clearMismatchMap()
}
/** 参会人表单 ref (el-form validate 用) */
@@ -1238,6 +1302,7 @@ function onBatchInvite() {
/** 打开新增/编辑 dialog; row=null → 新增 */
function openAttendeeDialog(row) {
suppressFeeLink = true
clearMismatchMap()
if (row) {
attendeeDialog.value = {
show: true,
@@ -1320,23 +1385,9 @@ async function lookupExpertByPhone() {
}
}
/** 提交 dialog (新增 → POST; 编辑 → PUT) */
async function confirmAttendee() {
/** 真正落库 (新增 → POST; 编辑 → PUT), 二次确认通过后调用 */
async function doSubmitAttendee() {
const { editing, form } = attendeeDialog.value
if (!form.phone || !form.phone.trim()) {
ElMessage.warning('请输入手机号')
return
}
if (!form.phone.trim().match(/^1\d{10}$/)) {
ElMessage.warning('手机号格式不正确')
return
}
// 校验实发金额不超所选角色劳务金额合计 (未选角色时跳过)
try {
await attendeeFormRef.value.validate()
} catch {
return
}
attendeeDialog.value.saving = true
try {
if (editing) {
@@ -1355,6 +1406,44 @@ async function confirmAttendee() {
}
}
/** 提交 dialog: 校验 → 预检比对专家表 → 无差异直接提交 / 有差异标红 + 二次确认 */
async function confirmAttendee() {
const { form } = attendeeDialog.value
if (!form.phone || !form.phone.trim()) {
ElMessage.warning('请输入手机号')
return
}
if (!form.phone.trim().match(/^1\d{10}$/)) {
ElMessage.warning('手机号格式不正确')
return
}
try {
await attendeeFormRef.value.validate()
} catch {
return
}
try {
const resp = await request.post('/business/meetingAttendee/diff', form, { __silentError: true })
const mismatches = Array.isArray(resp && resp.data) ? resp.data : []
if (!mismatches.length) {
doSubmitAttendee()
return
}
// 标红 + 二次确认
clearMismatchMap()
mismatches.forEach(m => { if (m && m.field) mismatchMap[m.field] = m })
openMismatchDialog(
[{
title: `${form.name || ''} ${form.phone || ''}`.trim() || '参会人',
fields: mismatches.map(f => ({ label: f.label, expertValue: f.expertValue, inputValue: f.inputValue }))
}],
{ confirmText: '确认提交', cancelText: '返回修改', onConfirm: doSubmitAttendee }
)
} catch (e) {
ElMessage.error(e?.msg || e?.message || '校验失败')
}
}
/** 删除参会人 (单条确认) */
async function confirmDeleteAttendee(row) {
try {
@@ -1376,20 +1465,21 @@ function onAttendeeCommand(cmd, row) {
else if (cmd === 'delete') confirmDeleteAttendee(row)
}
// ===================== 参会人 Excel 批量导入 (沿用 dialog-import SKILL) =====================
// ===================== 参会人 Excel 批量导入 (两段式: dry-run 比对专家表 → 二次确认 → force 入库) =====================
const importOpen = ref(false)
const importing = ref(false)
const importResultOpen = ref(false)
const importResult = ref(null)
const exporting = ref(false)
const importUploadRef = ref(null)
// el-upload 用原生 XHR, 不走 axios interceptor, 必须显式带 Authorization
// 下载模板用 (fetch 流式下载, 不走 axios, 需显式带 Authorization)
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
// 后端 baseURL 与 request.js 一致 (/dev-api), 拼绝对路径给 :action; meetingId 走 query 串
const importAction = computed(() => `${import.meta.env.VITE_APP_BASE_API}/business/meetingAttendee/importData?meetingId=${meetingId.value}`)
/** 待导入的 Excel 文件 (on-change 捕获, 同一文件 dry-run + force 各发一次) */
const importFile = ref(null)
function openAttendeeImport() {
importResult.value = null
importFile.value = null
importOpen.value = true
}
@@ -1472,8 +1562,17 @@ async function onAgreementZipChange(ev) {
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 位参会人的劳务协议`)
const data = (resp && resp.data) || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
if (skipped.length) {
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
} else {
ElMessage.success(`已回填 ${updated}/${total} 位参会人的劳务协议`)
}
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
@@ -1526,8 +1625,17 @@ async function onExpertPhotoZipChange(ev) {
fd,
{ timeout: 120000, __silentError: true }
)
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 位参会人的专家照片`)
const data = (resp && resp.data) || {}
const updated = data.updated ?? 0
const total = data.total ?? 0
const results = Array.isArray(data.results) ? data.results : []
const skipped = results.filter(r => r && r.matched === false)
if (skipped.length) {
const dirs = skipped.map(s => s.dir || s.name || '未知目录').join('、')
ElMessage.warning(`已回填 ${updated}/${total} 位; ${skipped.length} 个目录未匹配被跳过: ${dirs}`)
} else {
ElMessage.success(`已回填 ${updated}/${total} 位参会人的专家照片`)
}
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
@@ -1536,40 +1644,74 @@ async function onExpertPhotoZipChange(ev) {
}
}
function submitImportFile() {
importUploadRef.value?.submit()
function onImportFileChange(uploadFile) {
importFile.value = uploadFile.raw || null
}
function onImportFileRemove() {
importFile.value = null
}
function onImportProgress() { importing.value = true }
function onImportSuccess(res) {
importing.value = false
const result = (res && res.data) ? res.data : res
if (!result || typeof result.okNum !== 'number') {
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
importResult.value = null
/** 确定导入: 先 dry-run 比对专家表, 有冲突弹二次确认, 确认后 force 入库 */
async function submitImportFile() {
const file = importFile.value
if (!file) {
ElMessage.warning('请先选择要导入的文件')
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
// 刷新列表 (新参会人会出现)
loadAttendees()
importing.value = true
try {
const fd = new FormData()
fd.append('file', file)
// 1. dry-run (缺省 force=false): 只解析 + 比对专家表, 不落库
const resp = await request.post(
`/business/meetingAttendee/importData?meetingId=${meetingId.value}`,
fd,
{ timeout: 120000, __silentError: true }
)
const data = (resp && resp.data) || {}
const mismatches = Array.isArray(data.mismatches) ? data.mismatches : []
if (!mismatches.length) {
await doForceImport(file)
return
}
// 2. 有冲突 → 二次确认
openMismatchDialog(
mismatches.map(m => ({
title: `${m.rowNo}${m.name || ''} ${m.phone || ''}`.trim(),
fields: (m.fields || []).map(f => ({ label: f.label, expertValue: f.expertValue, inputValue: f.inputValue }))
})),
{ confirmText: '确认导入', cancelText: '取消', onConfirm: () => doForceImport(file) }
)
} catch (e) {
ElMessage.error(e?.msg || e?.message || '导入失败')
} finally {
importing.value = false
}
}
function onImportError(err) {
importing.value = false
let msg = '上传失败, 请重试'
if (err && err.response != null) {
try {
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
msg = body.msg || msg
} catch { /* JSON 解析失败用兜底 */ }
} else if (err && err.message) {
msg = err.message
/** force=true 真正入库, 成功展示 ImportResultDialog */
async function doForceImport(file) {
try {
const fd = new FormData()
fd.append('file', file)
const resp = await request.post(
`/business/meetingAttendee/importData?meetingId=${meetingId.value}&force=true`,
fd,
{ timeout: 120000, __silentError: true }
)
const result = (resp && resp.data) ? resp.data : null
if (!result || typeof result.okNum !== 'number') {
ElMessage.error(resp?.msg || '导入失败, 返回数据异常')
importResult.value = null
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
loadAttendees()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '导入失败')
}
ElMessage.error(msg)
importResult.value = null
}
async function loadStaff() {
try {
@@ -1598,6 +1740,11 @@ async function load() {
invitationUrl.value = row.value.invitationUrl || ''
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles(), loadInvoices()])
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
// 从列表跳入带 ?tab=xxx: 结算→laborVoucher, 审核→labor/service; 不传则保持默认 (劳务材料)
const targetTab = route.query.tab
if (['labor', 'service', 'laborVoucher'].includes(targetTab)) {
activeTab.value = targetTab
}
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
if (route.query.settle === '1' && canSettle.value) {
router.replace(route.path)
@@ -1686,8 +1833,10 @@ async function saveMaterials() {
submitOcrForMaterials(toOcr)
}
// 5. 保存即刷新发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id,
// 移除材料后立即可见减少 (doSave 走 refreshFee, onSubmitMaterials 走这里)
// 5. 保存即刷新材料 + 发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id.
// 会务费合计(meetingFee)与单行金额(materialAmount)都读 materialsLoaded, 移除材料时 toOcr=0,
// 若不重拉 loadMaterials 会残留旧金额/旧行 → 这里无条件重拉 (doSave 走 refreshFee, onSubmitMaterials 走这里)
loadMaterials()
loadInvoices()
// 6. 1.5s 后重拉 materials + 发票表, 让 OCR 回写的 amount/发票号 更新可见
@@ -1713,7 +1862,7 @@ async function saveVouchers() {
async function saveSchedulePoster() {
const cur = row.value.scheduleUrl || ''
if (scheduleUrl.value === cur) return
await request.put('/business/meeting', { meetingId: Number(meetingId.value), scheduleUrl: scheduleUrl.value }, { __silentError: true })
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, scheduleUrl: scheduleUrl.value }, { __silentError: true })
row.value.scheduleUrl = scheduleUrl.value
}
@@ -1721,7 +1870,7 @@ async function saveSchedulePoster() {
async function saveInvitation() {
const cur = row.value.invitationUrl || ''
if (invitationUrl.value === cur) return
await request.put('/business/meeting', { meetingId: Number(meetingId.value), invitationUrl: invitationUrl.value }, { __silentError: true })
await request.put('/business/meeting', { meetingId: Number(meetingId.value), projectId: row.value.projectId, invitationUrl: invitationUrl.value }, { __silentError: true })
row.value.invitationUrl = invitationUrl.value
}
@@ -2045,7 +2194,12 @@ async function refreshFee() {
row.value = { ...row.value, laborFee: m.laborFee, meetingFee: m.meetingFee, totalFee: m.totalFee, feeCalcStatus: m.feeCalcStatus }
} catch (e) { /* 拉取失败忽略, 下轮再试 */ }
loadInvoices()
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
// 会务费合计(tab)读 materialsLoaded.amount — OCR 回写金额后必须刷新, 否则 zip 上传后金额不更新
await refreshMaterialAmounts()
// 继续轮询条件: 费用未汇总完(feeCalcStatus=0) 或 仍有材料待 OCR(feeStatus=0).
// 后者覆盖「已算过的会议重传 zip」场景: 上传瞬间 feeCalcStatus 仍是 1, 但材料 feeStatus=0 在等 OCR.
const ocrPending = (materialsLoaded.value || []).some(m => m.feeStatus === 0)
if (row.value.feeCalcStatus === 0 || ocrPending) scheduleFeePoll()
else stopFeePolling()
}
function scheduleFeePoll() {
@@ -2438,3 +2592,11 @@ onBeforeUnmount(stopFeePolling)
}
}
</style>
<style>
/* 专家档案不一致 二次确认 dialog (append-to-body 脱离 scoped, 用全局样式) */
.mismatch-intro { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.mismatch-entry { margin-bottom: 16px; }
.mismatch-entry:last-child { margin-bottom: 0; }
.mismatch-entry-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 6px; }
</style>
+13 -2
View File
@@ -38,7 +38,7 @@
不用 el-input-number: 移动端其 +/- absolute ,
容器 100% 后按钮飘到行尾. 改用 type="number" 原生 input, 浏览器自带步进按钮在 input 内右贴边
-->
<el-input v-model.number="form.periodNo" type="number" :min="1" :max="totalSessionsNum" placeholder="请输入期数" />
<el-input v-model.number="form.periodNo" type="number" step="1" :min="1" :max="totalSessionsNum" placeholder="请输入期数" />
</el-form-item>
<el-form-item label="会议开始时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择开始时间" />
@@ -134,8 +134,19 @@ const form = reactive({
remark: ''
})
// 期数必须是正整数 (第几期), 小数/负数会触发后端 Long 反序列化报错
function validatePeriodNo(_rule, value, cb) {
if (value === null || value === undefined || value === '') { cb(); return }
const n = Number(value)
if (!Number.isInteger(n) || n < 1) { cb(new Error('期数必须为正整数')); return }
cb()
}
const rules = {
periodNo: [{ required: true, message: '请填写期数', trigger: 'blur' }],
periodNo: [
{ required: true, message: '请填写期数', trigger: 'blur' },
{ validator: validatePeriodNo, trigger: 'blur' }
],
startTime: [{ required: true, message: '请选择会议开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择会议结束时间', trigger: 'change' }],
address: [{ required: true, message: '请填写会议地点', trigger: 'blur' }]
+33 -6
View File
@@ -60,7 +60,14 @@
<el-table-column label="会议结束时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
</el-table-column>
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
<el-table-column label="总期数" width="80" align="center">
<template #default="{ row }">
<el-link v-if="row.projectId != null" :underline="false" type="primary" @click="onFilterByProject(row)">
{{ row.totalPeriods ?? '-' }}
</el-link>
<span v-else>{{ row.totalPeriods ?? '-' }}</span>
</template>
</el-table-column>
<el-table-column label="期数" width="80" align="center">
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
</el-table-column>
@@ -275,6 +282,17 @@ function onCopy(row) {
router.push({ name: newRouteName.value, query: { meetingId: row.meetingId, projectId: row.projectId || '', mode: 'copy' } })
}
// ========== 点击「总期数」: 过滤到该项目下的会议 ==========
// projectId 精确过滤为准; 后端 list 已按角色叠加数据范围 (manager/sponsor/executor/leader), 前端无需按角色分支.
// 一次性过滤, 不清除其它筛选条件 (会议名称/阶段等), 清除走「重置」.
function onFilterByProject(row) {
if (row.projectId == null) return
q.value.projectId = row.projectId
q.value.projectNo = row.projectNo || '' // 回填「项目编号」输入框做视觉反馈 (projectId 精确过滤为准)
page.pageNum = 1
load()
}
// ========== 删除会议 (软删除, 后端强校验 admin/manager) ==========
async function onDelete(row) {
try {
@@ -375,8 +393,8 @@ function canSettleRow(row) {
return row.laborAuditStage === 'APPROVED' && row.serviceAuditStage === 'APPROVED' && !isOneVal(row.isSettled)
}
function onSettle(row) {
// 结算: 跳详情页即可, 不自动弹结算 dialog
router.push(`${detailBase.value}/detail/${row.meetingId}`)
// 结算: 跳详情页并切到劳务凭证 tab (结算前需确认付款凭证), 不自动弹结算 dialog
router.push({ path: `${detailBase.value}/detail/${row.meetingId}`, query: { tab: 'laborVoucher' } })
}
// 列表完结按钮显隐: 已结算 且 未完结
function canFinishRow(row) {
@@ -405,15 +423,24 @@ async function onUnfreeze(row) {
} catch (e) { ElMessage.error(e?.msg || e?.message || '解冻失败') }
}
// ========== 支持方(监察员) 审核: 跳详情页 (详情页内按 C1 轨给 审核劳务/会务/全部) ==========
// ========== 支持方(监察员) 审核: 跳只读 view 页 (view 页内按 C1 轨给 审核劳务/会务/全部) ==========
// 有任一轨处于 C1 (已提交待支持方审) 即显示「审核」; 会务被退回时物理阶段=待整改, 但劳务 C1 仍需审, 不能用物理阶段判
function isSponsorPending(row) {
return (row.laborAuditStage === 'SUBMITTED' && isOneVal(row.laborComplianceApproved))
|| (row.serviceAuditStage === 'SUBMITTED' && isOneVal(row.serviceComplianceApproved))
}
// 审核: 跳详情页 (非 readonly), 详情页按角色控制显示三按钮
// 审核: 跳只读 view 页并按待审轨切 tab (只审劳务→劳务材料, 只审会务→会务材料, 两轨都要审→不传 tab 保持默认)
function onAudit(row) {
router.push(`${detailBase.value}/detail/${row.meetingId}`)
// sponsor 监察看 C1 (合规已通过待支持方审), manager/admin 合规看 C0 (合规未通过)
const needC1 = isRole('sponsor')
const laborPending = row.laborAuditStage === 'SUBMITTED'
&& (needC1 ? isOneVal(row.laborComplianceApproved) : !isOneVal(row.laborComplianceApproved))
const servicePending = row.serviceAuditStage === 'SUBMITTED'
&& (needC1 ? isOneVal(row.serviceComplianceApproved) : !isOneVal(row.serviceComplianceApproved))
const query = {}
if (laborPending && !servicePending) query.tab = 'labor'
else if (servicePending && !laborPending) query.tab = 'service'
router.push({ path: `${detailBase.value}/view/${row.meetingId}`, query })
}
// ========== 合规人员(manager) 批量审核 ==========
+185 -9
View File
@@ -47,11 +47,16 @@
<div class="skeleton-loading-tip">公示信息加载中...</div>
</div>
<div v-else class="main-content">
<iframe v-if="isPdf(currentTab?.fileUrl)"
:src="proxyUrl(currentTab.fileUrl)"
class="content-frame"
style="width: 100%; height: 700px; border: 0;"
frameborder="0"></iframe>
<!-- PDF PDF.js 渲染 (微信/移动端浏览器无 Chrome 内置 PDF viewer) -->
<div v-if="isPdf(currentTab?.fileUrl)" ref="pdfWrap" class="pdf-wrap">
<div v-if="pdfStatus === 'loading'" class="pdf-tip">PDF 加载中</div>
<div v-else-if="pdfStatus === 'error'" class="pdf-tip pdf-error">
<p>PDF 加载失败</p>
<p class="hint">{{ pdfErrorMsg }}</p>
<button class="action-btn primary" style="margin-top: 12px;" @click="renderCurrentPdf">重新加载</button>
</div>
<div v-show="pdfStatus === 'done'" ref="pdfViewer" class="pdf-viewer"></div>
</div>
<img v-else-if="isImg(currentTab?.fileUrl)"
:src="proxyUrl(currentTab.fileUrl)"
class="content-img"
@@ -87,12 +92,12 @@
<button v-if="activeTab === 'notice' && isBidProject" class="action-btn primary" @click="onViewBidAnnouncement">
<span>查看招标公告</span>
</button>
<!-- 分享二维码: 所有 tab 均显示 -->
<button class="action-btn primary share-btn" @click="showQr = true">
<!-- 分享: 微信环境走微信分享, 其他环境走二维码 -->
<button class="action-btn primary share-btn" @click="onShare">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4z"/>
</svg>
<span>分享二维码</span>
<span>{{ isWechat ? '微信分享' : '分享二维码' }}</span>
</button>
</aside>
</main>
@@ -157,7 +162,7 @@
</template>
<script setup>
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, reactive, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
@@ -172,6 +177,9 @@ import {
} from '@/api/public'
import request from '@/utils/request'
import QRCode from 'qrcode'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.js?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
const route = useRoute()
const router = useRouter()
@@ -203,6 +211,8 @@ const showQr = ref(false)
const qrUrl = ref('')
// 分享 URL: 当前页面地址 (window.location.href)
const shareUrl = computed(() => window.location.href)
// 是否微信内置浏览器 (MicroMessenger UA)
const isWechat = computed(() => /MicroMessenger/i.test(navigator.userAgent))
// 打开二维码弹窗时生成二维码 (避免页面加载时就调 QRCode.toDataURL)
watch(showQr, async (v) => {
@@ -290,6 +300,92 @@ function proxyUrl(url) {
return url
}
// PDF.js 专用代理 URL: 不带 Chrome viewer 的 #toolbar fragment (PDF.js 需要干净的文件字节流)
function pdfProxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url)
}
return url
}
// ============ PDF.js 渲染 (替代 Chrome 内置 PDF viewer, 兼容微信/移动端浏览器) ============
const pdfWrap = ref(null)
const pdfViewer = ref(null)
const pdfStatus = ref('') // '' | 'loading' | 'done' | 'error'
const pdfErrorMsg = ref('')
let pdfRenderToken = 0
let pdfDoc = null
async function renderCurrentPdf() {
const url = pdfProxyUrl(currentTab.value?.fileUrl)
if (!url) return
const token = ++pdfRenderToken
// 释放上一份文档
if (pdfDoc) { try { await pdfDoc.destroy() } catch (e) { /* ignore */ } pdfDoc = null }
pdfStatus.value = 'loading'
pdfErrorMsg.value = ''
const host = pdfViewer.value
if (host) host.innerHTML = ''
const t0 = performance.now()
try {
const doc = await pdfjsLib.getDocument({ url }).promise
if (token !== pdfRenderToken) { await doc.destroy(); return }
pdfDoc = doc
// 按容器宽度缩放: 让每页正文占满预览宽度 (移动端也自适应)
const wrapWidth = pdfWrap.value?.clientWidth || (window.innerWidth - 32) || 800
const first = await doc.getPage(1)
const base = first.getViewport({ scale: 1 })
const dpr = Math.min(window.devicePixelRatio || 1, 2) // 高清屏放大绘制, 上限 2 避免内存过大
const scale = (wrapWidth / base.width) * dpr
console.log(`[pdf] 下载+解析 ${Math.round(performance.now() - t0)}ms, 页数 ${doc.numPages}, dpr ${dpr}`)
let shown = false
for (let i = 1; i <= doc.numPages; i++) {
if (token !== pdfRenderToken) break
const page = await doc.getPage(i)
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width)
canvas.height = Math.floor(viewport.height)
// 动态创建的 canvas 不受 scoped CSS 作用, 必须内联设置样式 (display:block + margin auto 才能水平居中)
canvas.style.display = 'block'
canvas.style.margin = '8px auto'
canvas.style.background = '#fff'
canvas.style.boxShadow = '0 1px 4px rgba(0,0,0,.15)'
canvas.style.width = Math.floor(viewport.width / dpr) + 'px'
canvas.style.height = Math.floor(viewport.height / dpr) + 'px'
host.appendChild(canvas)
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
// 第一页渲染完立即显示 (结束 loading), 后续页继续后台渲染并追加, 避免等全部页都画完才可见
if (!shown && token === pdfRenderToken) {
shown = true
pdfStatus.value = 'done'
}
}
if (token === pdfRenderToken) {
pdfStatus.value = 'done'
console.log(`[pdf] 全部渲染完成 ${Math.round(performance.now() - t0)}ms`)
}
} catch (e) {
console.error('[pdf] render failed', e)
if (token === pdfRenderToken) {
pdfStatus.value = 'error'
pdfErrorMsg.value = e?.message || 'PDF 加载失败'
}
}
}
// 切换 tab / 项目加载完成后, 若当前是 PDF 则渲染
watch(() => currentTab.value?.fileUrl, async (url) => {
if (isPdf(url)) {
await nextTick()
renderCurrentPdf()
} else {
pdfStatus.value = ''
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
}
}, { immediate: true })
function onView() {
const url = proxyUrl(currentTab.value?.fileUrl)
if (!url) return ElMessage.warning('该公告暂无文件')
@@ -569,12 +665,86 @@ function onDownloadQr() {
document.body.removeChild(a)
}
// ============ 微信 JS-SDK 分享 (仅微信环境) ============
let wxSdkPromise = null
function loadWxSdk() {
if (window.wx) return Promise.resolve(window.wx)
if (wxSdkPromise) return wxSdkPromise
wxSdkPromise = new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
s.onload = () => resolve(window.wx)
s.onerror = () => { wxSdkPromise = null; reject(new Error('微信 SDK 加载失败')) }
document.head.appendChild(s)
})
return wxSdkPromise
}
// 分享入口: 微信环境走微信分享, 否则弹二维码
function onShare() {
if (isWechat.value) onWechatShare()
else showQr.value = true
}
const wxSharing = ref(false)
async function onWechatShare() {
if (wxSharing.value) return
wxSharing.value = true
try {
const wx = await loadWxSdk()
// 微信签名只校验不含 # fragment 的 url (hash 路由下 split('#')[0] 会丢失路由, 但签名必须这样)
const url = window.location.href.split('#')[0]
// __silentError: 拦截器不自动 toast, 由下方 catch 统一提示 (避免双 toast)
const res = await request({ url: '/business/public/wx/jssdk', method: 'get', params: { url }, __silentError: true })
const cfg = res?.data || res
if (!cfg || !cfg.signature) {
ElMessage.error(res?.msg || '微信分享配置获取失败')
return
}
const title = ann.value?.projectName || '项目公示'
const desc = '北京整合医学学会 · 项目公示'
const link = window.location.href
const imgUrl = window.location.origin + (import.meta.env.VITE_BASE || '/') + 'logo.png'
wx.config({
debug: false,
appId: cfg.appId,
timestamp: cfg.timestamp,
nonceStr: cfg.nonceStr,
signature: cfg.signature,
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData']
})
wx.ready(function () {
wx.updateAppMessageShareData({
title,
desc,
link,
imgUrl,
success: () => ElMessage.success('已配置分享,请点击右上角「…」分享'),
fail: () => ElMessage.warning('分享配置失败,请重试')
})
wx.updateTimelineShareData({ title, link, imgUrl })
})
wx.error(function (err) {
console.error('[wx-share] config error', err)
ElMessage.error('微信分享初始化失败,请确认已配置 JS 安全域名')
})
} catch (e) {
console.error('[wx-share] failed', e)
ElMessage.error(e?.msg || '微信分享失败,请重试')
} finally {
wxSharing.value = false
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
load()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
pdfRenderToken++ // 让进行中的渲染尽早中断
if (pdfDoc) { pdfDoc.destroy().catch(() => {}); pdfDoc = null }
})
</script>
@@ -647,6 +817,12 @@ onBeforeUnmount(() => {
}
.content-frame { width: 100%; height: 700px; display: block; border: 0; min-width: 0; }
.content-img { width: 100%; max-width: 100%; display: block; margin: 0 auto; }
/* PDF.js 渲染容器 */
.pdf-wrap { width: 100%; min-width: 0; max-height: 72vh; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
.pdf-tip { padding: 80px 20px; text-align: center; color: #909399; font-size: 14px; }
.pdf-tip .hint { font-size: 12px; color: #c0c4cc; }
.pdf-error p { margin: 6px 0 0; }
.pdf-viewer { width: 100%; background: #f2f3f5; padding: 8px 0; }
.content-empty {
padding: 80px 20px;
text-align: center;
@@ -50,10 +50,9 @@
<!-- 上传文件: PDF / PNG -->
<div v-else class="plan-body plan-body-file">
<iframe v-if="isPdf(plan.fileUrl)"
:src="proxyUrl(plan.fileUrl)"
class="content-frame"
frameborder="0"></iframe>
<PdfViewer v-if="isPdf(plan.fileUrl)"
:url="plan.fileUrl"
class="content-pdf" />
<img v-else-if="isImg(plan.fileUrl)"
:src="proxyUrl(plan.fileUrl)"
class="content-img"
@@ -78,6 +77,7 @@ import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
import PdfViewer from '@/components/PdfViewer.vue'
const route = useRoute()
const router = useRouter()
@@ -257,7 +257,7 @@ a { color: inherit; text-decoration: none; }
/* 文件预览 */
.plan-body-file { padding: 0; min-height: 600px; }
.content-frame { width: 100%; height: 700px; display: block; border: 0; }
.content-pdf { width: 100%; }
.content-img { width: 100%; max-width: 100%; display: block; margin: 0 auto; }
.content-empty { padding: 80px 20px; text-align: center; color: #c0c4cc; }
.content-empty p { margin: 8px 0 0; }
+28 -3
View File
@@ -61,9 +61,24 @@
<el-link :underline="false" type="primary" @click="onView(row)">{{ row.projectName }}</el-link>
</template>
</el-table-column>
<el-table-column prop="totalSessions" label="总场次" width="80" align="center" />
<el-table-column prop="doneSessions" label="已执行" width="80" align="center" />
<el-table-column prop="todoSessions" label="未执行" width="80" align="center" />
<el-table-column prop="totalSessions" label="总场次" width="80" align="center">
<template #default="{ row }">
<el-link v-if="row.totalSessions" :underline="false" type="primary" @click="goMeetings(row, 'all')">{{ row.totalSessions }}</el-link>
<span v-else style="color:#c0c4cc">0</span>
</template>
</el-table-column>
<el-table-column prop="doneSessions" label="已执行" width="80" align="center">
<template #default="{ row }">
<el-link v-if="row.doneSessions" :underline="false" type="primary" @click="goMeetings(row, 'done')">{{ row.doneSessions }}</el-link>
<span v-else style="color:#c0c4cc">0</span>
</template>
</el-table-column>
<el-table-column prop="todoSessions" label="未执行" width="80" align="center">
<template #default="{ row }">
<el-link v-if="row.todoSessions" :underline="false" type="primary" @click="goMeetings(row, 'todo')">{{ row.todoSessions }}</el-link>
<span v-else style="color:#c0c4cc">0</span>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="120" align="right" :formatter="fmtMoney" />
<el-table-column prop="availableAmount" label="可用金额" width="120" align="right" :formatter="fmtMoney" />
<el-table-column prop="paidLaborAmount" label="劳务费" width="120" align="right" :formatter="fmtMoney" />
@@ -259,6 +274,16 @@ function onView(row) {
router.push(`/sponsor/projects/detail/${row.projectId}`)
}
// 点击「总场次/已执行/未执行」列 → 跳 sponsor 会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限).
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行) — 与 manager/Projects.vue 口径一致:
// 已执行 = 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中); 未执行 = NOT_STARTED.
function goMeetings(row, scope) {
const q = { projectId: row.projectId }
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
router.push({ path: '/sponsor/meetings', query: q })
}
// ========== 执行单位评分 (与 manager/Projects.vue 完全相同的样式和逻辑) ==========
const singleScoreModalOpen = ref(false)
const singleScoreTargetRow = ref(null)