feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)

- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境
- 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost
- 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配
- 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度
- 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver
- 清理 biz_support_intent 旧表 (6 Java/XML 删除)
- ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传
- 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-24 01:00:09 +08:00
co-authored by Claude
parent 80a276e661
commit 904e28710f
123 changed files with 6008 additions and 1706 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,36 @@
package com.ruoyi.web.controller.common;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.core.domain.AjaxResult;
/**
* 扫码拍照相机配置服务
*
* <p>前端生成二维码前调用, 拿到相机 H5 网页基础地址 (ruoyi.camera.base-url),
* 再拼接 ry-h5 页面路由 + meetingId + subType.
*
* <p>公开访问, 无需 token (SecurityConfig 已对 /common/camera/** 公开).
*/
@RestController
@RequestMapping("/common/camera")
public class CameraController
{
@org.springframework.beans.factory.annotation.Autowired
private RuoYiConfig ruoyiConfig;
/**
* 返回相机网页基础地址
* @return {baseUrl: "http://localhost:8090/camera/"}
*/
@GetMapping("/config")
public AjaxResult config()
{
AjaxResult ajax = AjaxResult.success();
String baseUrl = ruoyiConfig.getCamera() != null ? ruoyiConfig.getCamera().getBaseUrl() : null;
ajax.put("baseUrl", baseUrl);
return ajax;
}
}
@@ -5,9 +5,9 @@ spring:
driverClassName: com.mysql.cj.jdbc.Driver driverClassName: com.mysql.cj.jdbc.Driver
druid: druid:
master: master:
url: jdbc:mysql://127.0.0.1:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root username: root
password: cu2oh2co3 password: Qljl1rh_
slave: slave:
enabled: false enabled: false
url: url:
@@ -30,12 +30,16 @@ ruoyi:
accessKeySecret: UIkwjMpmlYjgX5IMLjPj8FQNPthdlR accessKeySecret: UIkwjMpmlYjgX5IMLjPj8FQNPthdlR
signName: 北京仙仁掌医学科技发展 signName: 北京仙仁掌医学科技发展
template: SMS_321560247 template: SMS_321560247
inviteTemplate: SMS_492460505 esignTemplate: SMS_492460505
esignBaseUrl: https://ringdoctor.com/hg
endpoint: dysmsapi.aliyuncs.com endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou regionId: cn-hangzhou
# 发票 OCR (ry-ocr 微服务, PaddleOCR + FastAPI, 默认 http://127.0.0.1:8801) # 发票 OCR (ry-ocr 微服务, PaddleOCR + FastAPI, 默认 http://127.0.0.1:8801)
ocr: ocr:
base-url: http://127.0.0.1:8801 base-url: http://127.0.0.1:8801
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://ringdoctor.com/camera/
# 开发环境配置 # 开发环境配置
server: server:
@@ -58,9 +62,9 @@ server:
# 日志配置 # 日志配置
logging: logging:
level: level:
com.ruoyi: debug com.ruoyi: info
org.springframework: debug org.springframework: info
com.ruoyi.business: debug com.ruoyi.business: info
# 用户配置 # 用户配置
user: user:
@@ -27,4 +27,15 @@ public class OcrExecutorConfig
{ {
return Executors.newFixedThreadPool(16); return Executors.newFixedThreadPool(16);
} }
/**
* 费用汇总执行器 (FeeCalcScheduler 用): 8 线程并行汇总各会议.
* <p>
* 汇总本身纯 SUM 幂等, 无锁; 线程数 8 足够 (每会议 3 次轻量查询 + 1 次回写).
*/
@Bean(name = "feeCalcExecutor", destroyMethod = "shutdown")
public ExecutorService feeCalcExecutor()
{
return Executors.newFixedThreadPool(8);
}
} }
@@ -36,9 +36,17 @@ public class BizDashboardController extends BaseController {
map.put("totalProjects", projects.size()); map.put("totalProjects", projects.size());
map.put("totalMeetings", meetings.size()); map.put("totalMeetings", meetings.size());
map.put("totalExperts", experts.size()); map.put("totalExperts", experts.size());
map.put("todoMeetings", meetings.stream().filter(m -> "未执行".equals(m.getCurrentStage())).count()); // current_stage 是 10 值物理阶段 code (BizMeetingStageEnum), 不是中文 label (旧代码比对中文永远为 0).
map.put("doingMeetings", meetings.stream().filter(m -> "待监管".equals(m.getCurrentStage()) || "待整改".equals(m.getCurrentStage())).count()); map.put("todoMeetings", meetings.stream().filter(m -> "NOT_STARTED".equals(m.getCurrentStage())).count());
map.put("doneMeetings", meetings.stream().filter(m -> "已结算".equals(m.getCurrentStage()) || "已结题".equals(m.getCurrentStage())).count()); map.put("doingMeetings", meetings.stream().filter(m -> {
String s = m.getCurrentStage();
return "RUNNING".equals(s) || "AWAITING_COMPLIANCE".equals(s)
|| "AWAITING_SUPERVISION".equals(s) || "RECTIFYING".equals(s);
}).count());
map.put("doneMeetings", meetings.stream().filter(m -> {
String s = m.getCurrentStage();
return "SETTLED".equals(s) || "FINISHED".equals(s);
}).count());
return success(map); return success(map);
} }
@@ -1,8 +1,7 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.util.HashSet; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -48,7 +47,7 @@ public class BizMeetingAttendeeController extends BaseController {
private BizNotifyService bizNotifyService; private BizNotifyService bizNotifyService;
/** /**
* 当前登录用户的"待签署"列表 (handsign 或 labor_protocol 任一为空) * 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签)
* 用于 /doctor/home 工作台 * 用于 /doctor/home 工作台
*/ */
@GetMapping("/unsigned") @GetMapping("/unsigned")
@@ -58,6 +57,17 @@ public class BizMeetingAttendeeController extends BaseController {
return success(rows); return success(rows);
} }
/**
* 当前登录用户的"待参加"会议列表 (已邀请参会 is_invited=1)
* 用于 /doctor/home 工作台
*/
@GetMapping("/invited")
public AjaxResult listInvited() {
Long userId = SecurityUtils.getUserId();
List<BizMeetingAttendee> rows = attendeeService.selectInvitedByUserId(userId);
return success(rows);
}
/** /**
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用). * 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
* 返回全字段, 前端按需展示. * 返回全字段, 前端按需展示.
@@ -71,7 +81,7 @@ public class BizMeetingAttendeeController extends BaseController {
* 管理端: 按手机号新增参会人. * 管理端: 按手机号新增参会人.
* *
* <p>后端流程: 按 body.phone 查 sys_user → 查到用之, 查不到新建 (用户名=密码=phone, role_type='doctor') * <p>后端流程: 按 body.phone 查 sys_user → 查到用之, 查不到新建 (用户名=密码=phone, role_type='doctor')
* → 写完整档案行 (含 name/work_unit/fee...) → 触发 #5 会议邀请通知. * → 写完整档案行 (含 name/work_unit/fee...). 邀请参会已改为手动, 此处不再自动推.
* *
* <p>请求体示例: * <p>请求体示例:
* <pre> * <pre>
@@ -84,11 +94,9 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping @PostMapping
public AjaxResult add(@RequestBody BizMeetingAttendee body) { public AjaxResult add(@RequestBody BizMeetingAttendee body) {
Long attendeeId = attendeeService.insertByPhoneWithProfile(body); Long attendeeId = attendeeService.insertByPhoneWithProfile(body);
// #5 触发: 新参会人发邀请 (走 BizMeetingController.edit 同一路径, 不需要 dedup — 这是新行) // 人员变化 → 会议费用待重算
BizMeeting m = bizMeetingService.getById(body.getMeetingId()); bizMeetingService.markFeeCalcPending(body.getMeetingId());
bizNotifyService.meetingInvitation(body.getUserId(), body.getMeetingId(), // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发
m != null ? m.getMeetingName() : null,
m != null ? m.getStartTime() : null);
return success(attendeeId); return success(attendeeId);
} }
@@ -102,8 +110,14 @@ public class BizMeetingAttendeeController extends BaseController {
if (body.getId() == null) { if (body.getId() == null) {
return error("id 不能为空"); return error("id 不能为空");
} }
BizMeetingAttendee before = attendeeService.selectById(body.getId());
body.setUpdateBy(SecurityUtils.getUsername()); body.setUpdateBy(SecurityUtils.getUsername());
return toAjax(attendeeService.updateProfile(body)); int rows = attendeeService.updateProfile(body);
// 人员变化 → 会议费用待重算
if (before != null) {
bizMeetingService.markFeeCalcPending(before.getMeetingId());
}
return toAjax(rows);
} }
/** /**
@@ -113,7 +127,33 @@ public class BizMeetingAttendeeController extends BaseController {
@Log(title = "参会人", businessType = BusinessType.DELETE) @Log(title = "参会人", businessType = BusinessType.DELETE)
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public AjaxResult remove(@PathVariable("id") Long id) { public AjaxResult remove(@PathVariable("id") Long id) {
return toAjax(attendeeService.deleteByPrimaryKey(id)); BizMeetingAttendee before = attendeeService.selectById(id);
int rows = attendeeService.deleteByPrimaryKey(id);
// 人员变化 → 会议费用待重算
if (before != null) {
bizMeetingService.markFeeCalcPending(before.getMeetingId());
}
return toAjax(rows);
}
/**
* 推送电子签: body = attendee.id 数组 [1,2,3] (批量=勾选后传选中 id, 每行=传 [row.id]).
* 逐个发短信 + 推站内信 + 置 is_esigned=1, 返回成功条数.
*/
@Log(title = "推送电子签", businessType = BusinessType.UPDATE)
@PostMapping("/esign")
public AjaxResult esign(@RequestBody List<Long> attendeeIds) {
return success(attendeeService.pushEsign(attendeeIds));
}
/**
* 邀请参会: body = attendee.id 数组 [1,2,3] (批量=勾选后传选中 id, 每行=传 [row.id]).
* 逐个推"会议邀请"站内信 (不发短信) + 置 is_invited=1, 返回成功条数.
*/
@Log(title = "邀请参会", businessType = BusinessType.UPDATE)
@PostMapping("/invite")
public AjaxResult invite(@RequestBody List<Long> attendeeIds) {
return success(attendeeService.invite(attendeeIds));
} }
/** 更新手写签名 (Base64 字符串, 直接存 DB longtext) */ /** 更新手写签名 (Base64 字符串, 直接存 DB longtext) */
@@ -139,14 +179,10 @@ public class BizMeetingAttendeeController extends BaseController {
} }
/** /**
* 批量导入参会人 — 上传 Excel + 解析入库 + #5 会议邀请差集推送. * 批量导入参会人 — 上传 Excel + 解析入库.
* *
* <p>三步: * <p>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList.
* <ol> * 邀请参会已改为手动, 此处不再自动推"会议邀请".
* <li>查"导入前"该会议已存在的参会人 userIds (Set)</li>
* <li>调 {@link IBizMeetingAttendeeService#importFromExcel} 逐行处理, 失败的进 ngList</li>
* <li>查"导入后"该会议 userIds, 与"前"做差集 → 仅给"新加入"的 userId 推 #5 会议邀请</li>
* </ol>
* *
* <p>返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染. * <p>返回 ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }, 前端 ImportResultDialog 直接渲染.
* *
@@ -156,27 +192,50 @@ public class BizMeetingAttendeeController extends BaseController {
@PostMapping("/importData") @PostMapping("/importData")
public AjaxResult importData(@RequestParam("file") MultipartFile file, public AjaxResult importData(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception { @RequestParam("meetingId") Long meetingId) throws Exception {
// 1. 导入前快照 // 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
Set<Long> preUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId));
// 2. 解析 + 入库
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername()); ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 人员变化 → 会议费用待重算 (有成功导入才需重算, 但幂等, 直接标记)
// 3. 差集 → 仅对"新加入" userId 推 #5 邀请 if (result != null && result.getOkNum() > 0) {
Set<Long> postUserIds = new HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId)); bizMeetingService.markFeeCalcPending(meetingId);
postUserIds.removeAll(preUserIds);
if (!postUserIds.isEmpty()) {
BizMeeting m = bizMeetingService.getById(meetingId);
String meetingName = m != null ? m.getMeetingName() : null;
java.util.Date startTime = m != null ? m.getStartTime() : null;
for (Long uid : postUserIds) {
bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime);
} }
}
return success(result); return success(result);
} }
/**
* 导出某会议的参会人档案 (Excel, 复用导入 VO 的 @Excel 列头, 含账户名称/开户行地址/银行详细地址/身份证附件).
*/
@Log(title = "参会人导出", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, @RequestParam("meetingId") Long meetingId) {
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
List<BizMeetingAttendeeImportVo> exportList = new ArrayList<>(list.size());
for (BizMeetingAttendee a : list) {
BizMeetingAttendeeImportVo v = new BizMeetingAttendeeImportVo();
v.setName(a.getName());
v.setPhone(a.getPhone());
v.setWorkUnit(a.getWorkUnit());
v.setDepartment(a.getDepartment());
v.setTitle(a.getTitle());
v.setIdCard(a.getIdCard());
v.setBankName(a.getBankName());
v.setBankCard(a.getBankCard());
v.setBankBranch(a.getBankBranch());
v.setAccountName(a.getAccountName());
v.setBankRegion(a.getBankRegion());
v.setBankAddress(a.getBankAddress());
v.setIdCardAttachments(a.getIdCardAttachments());
v.setLaborForm(a.getLaborForm());
v.setFeePreTax(a.getFeePreTax());
v.setTax(a.getTax());
v.setVatAndSurcharge(a.getVatAndSurcharge());
v.setFee(a.getFee());
v.setSummary(a.getSummary());
exportList.add(v);
}
ExcelUtil<BizMeetingAttendeeImportVo> util = new ExcelUtil<>(BizMeetingAttendeeImportVo.class);
util.exportExcel(response, exportList, "参会人");
}
/** /**
* 更新劳务协议 URL (OSS 上传后调本接口). * 更新劳务协议 URL (OSS 上传后调本接口).
* *
@@ -2,6 +2,7 @@ package com.ruoyi.business.controller;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.domain.BizMeetingAuditLog; import com.ruoyi.business.domain.BizMeetingAuditLog;
import com.ruoyi.business.domain.BizMeetingSupervisor; import com.ruoyi.business.domain.BizMeetingSupervisor;
@@ -11,6 +12,7 @@ import com.ruoyi.business.service.IBizMeetingAuditLogService;
import com.ruoyi.business.service.IBizMeetingSupervisorService; import com.ruoyi.business.service.IBizMeetingSupervisorService;
import com.ruoyi.business.service.IBizMeetingExecutorService; import com.ruoyi.business.service.IBizMeetingExecutorService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
@@ -20,9 +22,15 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.StageDeriver;
import com.ruoyi.business.service.PosterService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.mapper.SysUserMapper;
/** /**
* 会议Controller * 会议Controller
@@ -45,13 +53,42 @@ public class BizMeetingController extends BaseController {
private IBizMeetingExecutorService bizMeetingExecutorService; private IBizMeetingExecutorService bizMeetingExecutorService;
@Autowired @Autowired
private BizNotifyService bizNotifyService; private BizNotifyService bizNotifyService;
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private IBizProjectService bizProjectService;
@Autowired
private StageDeriver stageDeriver;
@Autowired
private PosterService posterService;
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizMeeting bizMeeting) { public TableDataInfo list(BizMeeting bizMeeting) {
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表) Long uid = SecurityUtils.getUserId();
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
if ("doctor".equals(roleType) || "expert".equals(roleType)) { if ("doctor".equals(roleType) || "expert".equals(roleType)) {
bizMeeting.setUserId(SecurityUtils.getUserId()); bizMeeting.setUserId(uid);
}
// sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_admin_user_id, SUB 走 sponsor_assign.monitor_user_id).
// 与项目列表 selectSponsorList 的 MAIN/SUB 判定平行, 但刻意不带 biz_publicity_support_intent 关联.
else if ("sponsor".equals(roleType)) {
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
bizMeeting.getParams().put("monitorUserId", uid);
} else {
bizMeeting.getParams().put("sponsorAdminUserId", uid);
}
}
// executor 数据权限: 只看"我的项目"下的会议 (与项目列表 selectExecutorList/selectExecutorStaffList 同源).
// MAIN 走 biz_project_assign (exec_user_id / execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id.
else if ("executor".equals(roleType)) {
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
bizMeeting.getParams().put("executorStaffUserId", uid);
} else {
bizMeeting.getParams().put("executorUserId", uid);
}
} }
startPage(); startPage();
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting); List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
@@ -66,17 +103,37 @@ public class BizMeetingController extends BaseController {
@Log(title = "会议", businessType = BusinessType.INSERT) @Log(title = "会议", businessType = BusinessType.INSERT)
@PostMapping @PostMapping
public AjaxResult add(@RequestBody BizMeeting bizMeeting) { public AjaxResult add(@RequestBody BizMeeting bizMeeting) {
// executor 建会限额: 执行机构人员 (MAIN/SUB 只要能看到项目) 都可建会, 但该项目的会议数不得超过分配给本公司的场次.
// 场次是公司维度: SUB 执行人反查主账号 parent_user_id 聚合 (与项目列表 assigned_sessions 口径一致).
// 注意: 会议数按"该项目下全部未软删会议"计数 (biz_meeting 无执行方归属列, 无法区分是哪个执行方建的) — 见 memory [[ry-executor-staff-project-visibility]].
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("executor".equals(roleType)) {
Long uid = SecurityUtils.getUserId();
Long projectId = bizMeeting.getProjectId();
if (projectId == null) {
throw new ServiceException("建会必须指定 projectId");
}
Long aggUid = uid;
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
int assigned = bizProjectService.countAssignedSessions(projectId, aggUid);
int existing = bizMeetingService.countByProjectId(projectId);
if (existing >= assigned) {
throw new ServiceException("本项目分配给本公司的场次为 " + assigned + " 场, 已建 " + existing + " 场, 已达上限");
}
}
// 创建人/时间: DB 列无默认值, 需代码显式落库 (否则详情页 createBy/createTime 为空)
bizMeeting.setCreateBy(SecurityUtils.getUsername());
bizMeeting.setCreateTime(new Date());
bizMeeting.setUpdateBy(SecurityUtils.getUsername());
bizMeeting.setUpdateTime(new Date());
int rows = bizMeetingService.insert(bizMeeting); int rows = bizMeetingService.insert(bizMeeting);
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) { if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds); attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
// #5 会议邀请通知 (新增会议, 全部新参会人都要通知) // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发
String meetingName = bizMeeting.getMeetingName();
Date startTime = bizMeeting.getStartTime();
for (Long uid : attendeeUserIds) {
if (uid == null) continue;
bizNotifyService.meetingInvitation(uid, bizMeeting.getMeetingId(), meetingName, startTime);
}
} }
return toAjax(rows); return toAjax(rows);
} }
@@ -84,23 +141,32 @@ public class BizMeetingController extends BaseController {
@Log(title = "会议", businessType = BusinessType.UPDATE) @Log(title = "会议", businessType = BusinessType.UPDATE)
@PutMapping @PutMapping
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) { public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
bizMeeting.setUpdateBy(SecurityUtils.getUsername());
bizMeeting.setUpdateTime(new Date());
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting); int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds(); Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) { if (attendeeUserIds != null && attendeeUserIds.length > 0) {
Long meetingId = bizMeeting.getMeetingId(); Long meetingId = bizMeeting.getMeetingId();
// #5 dedup: 先拿已有的 userId 集合, 仅给"新增"的 userId 发通知, 避免重复打扰已参会医生
java.util.Set<Long> existingUids = new java.util.HashSet<>(attendeeService.selectUserIdsByMeetingId(meetingId));
attendeeService.insertBatch(meetingId, attendeeUserIds); attendeeService.insertBatch(meetingId, attendeeUserIds);
String meetingName = bizMeeting.getMeetingName(); // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发
Date startTime = bizMeeting.getStartTime();
for (Long uid : attendeeUserIds) {
if (uid == null || existingUids.contains(uid)) continue;
bizNotifyService.meetingInvitation(uid, meetingId, meetingName, startTime);
}
} }
return toAjax(rows); return toAjax(rows);
} }
/**
* 生成海报: 下载日程海报 (width=1200) → Java2D 叠加会议信息 → 上传 OSS → 回写 poster_url.
* 返回生成海报的 OSS URL.
*/
@Log(title = "生成海报", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/generate-poster")
public AjaxResult generatePoster(@PathVariable("meetingId") Long meetingId,
@RequestBody(required = false) Map<String, Object> body) {
boolean addText = body != null && Boolean.TRUE.equals(body.get("addText"));
String textColor = body != null && body.get("textColor") != null ? body.get("textColor").toString() : "#FFFFFF";
String url = posterService.generatePoster(meetingId, addText, textColor);
return AjaxResult.success("海报生成成功", url);
}
/** /**
* 软删除会议 (admin/manager 会议管理用, 后端强校验 role_type) * 软删除会议 (admin/manager 会议管理用, 后端强校验 role_type)
* 级联置 biz_meeting + 5 张子表 is_deleted=1, 数据保留审计追溯 * 级联置 biz_meeting + 5 张子表 is_deleted=1, 数据保留审计追溯
@@ -117,17 +183,18 @@ public class BizMeetingController extends BaseController {
} }
// =================================================================== // ===================================================================
// 审核流程端点 (5 个) // 审核流程端点 (事实模型)
// =================================================================== // ===================================================================
/** /**
* 执行人员提交材料 * 执行人员提交材料
* <ul> * <ul>
* <li>校验 1: 当前用户是该会议执行人员 (强校验)</li> * <li>校验 1: 当前用户是该会议执行方 (项目级归属, 强校验)</li>
* <li>校验 2: material_audit_stage = INIT</li> * <li>校验 2: 已执行 (is_executed=1) 且未冻结</li>
* <li>校验 3: biz_meeting_material 至少 1 条 L_* + 至少 1 条 M_*</li> * <li>校验 3: material_audit_stage ∈ {NOT_SUBMITTED, REJECTED}</li>
* <li>校验 4: biz_meeting_material 劳务(L_*)与会务(M_*)各至少 1 条, 不必全部子类型填满</li>
* </ul> * </ul>
* 通过后 material_audit_stage INIT → SUBMITTED, 记 audit_log. * 通过后 material → SUBMITTED (compliance_approved=0), 记 audit_log.
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/submit-material") @PostMapping("/{meetingId}/submit-material")
@@ -136,24 +203,29 @@ public class BizMeetingController extends BaseController {
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
boolean isExec = bizMeetingExecutorService.selectByMeetingId(meetingId).stream() if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) {
.anyMatch(e -> userId.equals(e.getUserId())); throw new ServiceException("您不是该项目的执行方, 无法提交材料");
if (!isExec) throw new ServiceException("您不是该会议执行人员, 无法提交材料"); }
if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交材料");
if (!"INIT".equals(m.getMaterialAuditStage())) { if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交材料");
throw new ServiceException("当前阶段 (" + m.getMaterialAuditStage() + ") 不允许提交材料"); String stage = m.getMaterialAuditStage();
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
throw new ServiceException("当前阶段 (" + stage + ") 不允许提交材料");
} }
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId); List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_")); boolean hasLabor = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("L_"));
boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_")); boolean hasService = mats.stream().anyMatch(x -> x.getSubType() != null && x.getSubType().startsWith("M_"));
if (!hasLabor || !hasService) { if (!hasLabor || !hasService) {
throw new ServiceException("请同时上传劳务材料和会务材料"); throw new ServiceException("劳务材料和会务材料各至少上传一条");
} }
m.setMaterialAuditStage("SUBMITTED"); m.setMaterialAuditStage("SUBMITTED");
m.setMaterialComplianceApproved(0);
m.setMaterialAuditTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, "MATERIAL", "SUBMITTED", "APPROVED", "执行人员提交材料"); appendAuditLog(m, "MATERIAL", "SUBMITTED", "执行人员提交材料");
return success("SUBMITTED"); return success("SUBMITTED");
} }
@@ -167,12 +239,14 @@ public class BizMeetingController extends BaseController {
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
boolean isExec = bizMeetingExecutorService.selectByMeetingId(meetingId).stream() if (!bizProjectService.isExecutorOfProject(m.getProjectId(), userId)) {
.anyMatch(e -> userId.equals(e.getUserId())); throw new ServiceException("您不是该项目的执行方, 无法提交凭证");
if (!isExec) throw new ServiceException("您不是该会议执行人员, 无法提交凭证"); }
if (!isExecuted(m)) throw new ServiceException("会议尚未执行, 不能提交凭证");
if (!"INIT".equals(m.getVoucherAuditStage())) { if (isFrozen(m)) throw new ServiceException("会议已冻结, 不能提交凭证");
throw new ServiceException("当前阶段 (" + m.getVoucherAuditStage() + ") 不允许提交凭证"); String stage = m.getVoucherAuditStage();
if (!"NOT_SUBMITTED".equals(stage) && !"REJECTED".equals(stage)) {
throw new ServiceException("当前阶段 (" + stage + ") 不允许提交凭证");
} }
List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId); List<BizMeetingMaterial> mats = bizMeetingMaterialService.selectByMeetingId(meetingId);
@@ -183,14 +257,19 @@ public class BizMeetingController extends BaseController {
} }
m.setVoucherAuditStage("SUBMITTED"); m.setVoucherAuditStage("SUBMITTED");
m.setVoucherComplianceApproved(0);
m.setVoucherAuditTime(new Date());
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, "VOUCHER", "SUBMITTED", "APPROVED", "执行人员提交凭证"); appendAuditLog(m, "VOUCHER", "SUBMITTED", "执行人员提交凭证");
return success("SUBMITTED"); return success("SUBMITTED");
} }
/** /**
* 合规审核 (role_type=manager) * 合规审核 (role_type=manager), 两级审核中的第一级.
* body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." } * <p>
* body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." }
* <p>合规审中判据: stage=SUBMITTED 且 compliance_approved=0.
* 通过 → compliance_approved=1 (转入支持方审), 拒绝 → REJECTED (退回执行方).
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-compliance") @PostMapping("/{meetingId}/audit-compliance")
@@ -201,67 +280,149 @@ public class BizMeetingController extends BaseController {
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
String auditType = body.getAuditType(); String[] types = resolveTypes(body.getAuditType());
if (!"MATERIAL".equals(auditType) && !"VOUCHER".equals(auditType)) { boolean approved = Boolean.TRUE.equals(body.getApproved());
throw new ServiceException("auditType 必须是 MATERIAL 或 VOUCHER"); if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
}
String currentStage = "MATERIAL".equals(auditType) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
if (!"SUBMITTED".equals(currentStage)) {
throw new ServiceException("当前阶段 (" + currentStage + ") 不允许合规审核");
}
if (Boolean.FALSE.equals(body.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空"); throw new ServiceException("拒绝时意见不能为空");
} }
String result = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "REJECTED"; String result = approved ? "APPROVED" : "REJECTED";
String newStage = Boolean.TRUE.equals(body.getApproved()) ? "COMPLIANCE_APPROVED" : "SUBMITTED"; for (String type : types) {
if ("MATERIAL".equals(auditType)) { String stage = stageOf(m, type);
m.setMaterialAuditStage(newStage); boolean complianceDone = complianceApprovedOf(m, type);
} else { if (!"SUBMITTED".equals(stage) || complianceDone) {
m.setVoucherAuditStage(newStage); throw new ServiceException(type + " 当前阶段不允许合规审核");
} }
if (approved) {
setComplianceApproved(m, type, 1);
} else {
setStage(m, type, "REJECTED");
}
setAuditTime(m, type, new Date());
}
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion()); for (String type : types) {
return success(newStage); appendAuditLog(m, type, result, body.getOpinion());
}
return success(result);
} }
/** /**
* 监察审核 (强校验: 当前用户必须是该会议监察员) * 支持方(监察员) 审核, 两级审核中的第二级.
* body: { "auditType": "MATERIAL"|"VOUCHER", "approved": true|false, "opinion": "..." } * <p>
* body: { "auditType": "MATERIAL"|"VOUCHER"|"BOTH", "approved": true|false, "opinion": "..." }
* <p>支持方审中判据: stage=SUBMITTED 且 compliance_approved=1.
* 通过 → APPROVED (材料通过时一并写监管意见), 拒绝 → REJECTED (退回执行方).
*/ */
@Log(title = "会议审核", businessType = BusinessType.UPDATE) @Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/audit-supervision") @PostMapping("/{meetingId}/audit-supervision")
public AjaxResult auditSupervision(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) { public AjaxResult auditSupervision(@PathVariable("meetingId") Long meetingId, @RequestBody AuditBody body) {
Long userId = SecurityUtils.getUserId(); Long userId = SecurityUtils.getUserId();
boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream()
.anyMatch(s -> userId.equals(s.getUserId()));
if (!isSupervisor) throw new ServiceException("您不是该会议监察员, 无权监察");
BizMeeting m = bizMeetingService.getById(meetingId); BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在"); if (m == null) throw new ServiceException("会议不存在");
String auditType = body.getAuditType(); // 授权: 监察员 (biz_meeting_supervisor) 或 支持方 MAIN 账号 (biz_project.sponsor_admin_user_id) 均可审
if (!"MATERIAL".equals(auditType) && !"VOUCHER".equals(auditType)) { boolean isSupervisor = bizMeetingSupervisorService.selectByMeetingId(meetingId).stream()
throw new ServiceException("auditType 必须是 MATERIAL 或 VOUCHER"); .anyMatch(s -> userId.equals(s.getUserId()));
BizProject project = m.getProjectId() == null ? null : bizProjectService.getById(m.getProjectId());
boolean isSponsorMain = project != null && userId.equals(project.getSponsorAdminUserId());
if (!isSupervisor && !isSponsorMain) {
throw new ServiceException("您不是该会议监察员, 无权监察");
} }
String currentStage = "MATERIAL".equals(auditType) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
if (!"COMPLIANCE_APPROVED".equals(currentStage)) { String[] types = resolveTypes(body.getAuditType());
throw new ServiceException("当前阶段 (" + currentStage + ") 不允许监察"); boolean approved = Boolean.TRUE.equals(body.getApproved());
} if (!approved && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
if (Boolean.FALSE.equals(body.getApproved()) && (body.getOpinion() == null || body.getOpinion().isEmpty())) {
throw new ServiceException("拒绝时意见不能为空"); throw new ServiceException("拒绝时意见不能为空");
} }
String result = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "REJECTED"; String result = approved ? "APPROVED" : "REJECTED";
String newStage = Boolean.TRUE.equals(body.getApproved()) ? "APPROVED" : "SUBMITTED"; for (String type : types) {
if ("MATERIAL".equals(auditType)) { String stage = stageOf(m, type);
m.setMaterialAuditStage(newStage); boolean complianceDone = complianceApprovedOf(m, type);
} else { if (!"SUBMITTED".equals(stage) || !complianceDone) {
m.setVoucherAuditStage(newStage); throw new ServiceException(type + " 当前阶段不允许监察审核");
} }
setStage(m, type, approved ? "APPROVED" : "REJECTED");
setAuditTime(m, type, new Date());
}
// 材料通过 → 写监管意见 (支持方的书面意见)
if (approved && java.util.Arrays.asList(types).contains("MATERIAL")) {
m.setSupervisionOpinion(body.getOpinion());
m.setSupervisionBy(SecurityUtils.getUsername());
m.setSupervisionTime(new Date());
}
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(meetingId, auditType, newStage, result, body.getOpinion()); for (String type : types) {
return success(newStage); appendAuditLog(m, type, result, body.getOpinion());
}
// 退回 → 通知执行方 (待整改 + 说明)
if (!approved) {
for (BizMeetingExecutor e : bizMeetingExecutorService.selectByMeetingId(meetingId)) {
bizNotifyService.meetingSupervisionRejected(e.getUserId(), meetingId, m.getMeetingName(), body.getOpinion());
}
}
return success(result);
}
/**
* 结算 (合规/管理员 手动点击). 前置: material+voucher 都 APPROVED.
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/settle")
@Transactional(rollbackFor = Exception.class)
public AjaxResult settle(@PathVariable("meetingId") Long meetingId) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
throw new ServiceException("只有合规或管理员可结算");
}
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
if (!"APPROVED".equals(m.getMaterialAuditStage()) || !"APPROVED".equals(m.getVoucherAuditStage())) {
throw new ServiceException("材料与凭证均审核通过后才能结算");
}
if (isSettled(m)) throw new ServiceException("会议已结算");
// 费用未汇总完 (fee_calc_status=0) 禁止结算: 此时 labor_fee/meeting_fee 可能为旧值/0, 直接回写会污染项目金额.
// 无发票的材料 fee_status 恒为 1, 调度器只会因"存在待 OCR 发票"而停在 0, 故无发票的会议天然不会被此校验误挡.
if (m.getFeeCalcStatus() == null || m.getFeeCalcStatus() != 1) {
throw new ServiceException("会议费用尚未汇总完成,无法结算");
}
m.setIsSettled(1);
m.setSettleTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
// 结算成功 → 触发项目金额重算 (全量 SUM 已结算会议, 幂等)
if (m.getProjectId() != null) {
bizProjectService.recomputeSettledAmounts(m.getProjectId());
}
appendAuditLog(m, "SETTLE", "APPROVED", "会议结算");
return success("SETTLED");
}
/**
* 完结 (合规/管理员 手动点击). 前置: 已结算 (is_settled=1).
*/
@Log(title = "会议审核", businessType = BusinessType.UPDATE)
@PostMapping("/{meetingId}/finish")
public AjaxResult finish(@PathVariable("meetingId") Long meetingId) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"manager".equals(roleType) && !"admin".equals(roleType)) {
throw new ServiceException("只有合规或管理员可完结");
}
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new ServiceException("会议不存在");
if (!isSettled(m)) throw new ServiceException("会议尚未结算, 不能完结");
if (isFinished(m)) throw new ServiceException("会议已完结");
m.setIsFinished(1);
m.setFinishTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "FINISH", "APPROVED", "会议完结");
return success("FINISHED");
} }
/** /**
@@ -275,25 +436,65 @@ public class BizMeetingController extends BaseController {
return success(list); return success(list);
} }
// ===================================================================
// 事实字段辅助 (null 安全)
// ===================================================================
private static boolean isExecuted(BizMeeting m) { return m.getIsExecuted() != null && m.getIsExecuted() == 1; }
private static boolean isFrozen(BizMeeting m) { return m.getIsFrozen() != null && m.getIsFrozen() == 1; }
private static boolean isSettled(BizMeeting m) { return m.getIsSettled() != null && m.getIsSettled() == 1; }
private static boolean isFinished(BizMeeting m) { return m.getIsFinished() != null && m.getIsFinished() == 1; }
/** 材料/凭证 子状态读取 */
private static String stageOf(BizMeeting m, String type) {
return "MATERIAL".equals(type) ? m.getMaterialAuditStage() : m.getVoucherAuditStage();
}
private static boolean complianceApprovedOf(BizMeeting m, String type) {
Integer v = "MATERIAL".equals(type) ? m.getMaterialComplianceApproved() : m.getVoucherComplianceApproved();
return v != null && v == 1;
}
private static void setStage(BizMeeting m, String type, String stage) {
if ("MATERIAL".equals(type)) m.setMaterialAuditStage(stage);
else m.setVoucherAuditStage(stage);
}
private static void setComplianceApproved(BizMeeting m, String type, int v) {
if ("MATERIAL".equals(type)) m.setMaterialComplianceApproved(v);
else m.setVoucherComplianceApproved(v);
}
private static void setAuditTime(BizMeeting m, String type, Date t) {
if ("MATERIAL".equals(type)) m.setMaterialAuditTime(t);
else m.setVoucherAuditTime(t);
}
/** auditType: MATERIAL / VOUCHER / BOTH → 处理类型数组 */
private static String[] resolveTypes(String auditType) {
if ("BOTH".equals(auditType)) return new String[] { "MATERIAL", "VOUCHER" };
if ("MATERIAL".equals(auditType) || "VOUCHER".equals(auditType)) return new String[] { auditType };
throw new ServiceException("auditType 必须是 MATERIAL / VOUCHER / BOTH");
}
/** /**
* 内部: 写一条 audit_log * 内部: 写一条 audit_log (4 列角色展示状态由 post-transition 事实推导).
*/ */
private void appendAuditLog(Long meetingId, String auditType, String stage, String result, String opinion) { private void appendAuditLog(BizMeeting m, String auditType, String result, String opinion) {
BizMeetingAuditLog log = new BizMeetingAuditLog(); BizMeetingAuditLog log = new BizMeetingAuditLog();
log.setMeetingId(meetingId); log.setMeetingId(m.getMeetingId());
log.setAuditor(SecurityUtils.getUsername()); log.setAuditor(SecurityUtils.getUsername());
log.setAuditType(auditType); log.setAuditType(auditType);
log.setCurrentStage(stage);
log.setAuditResult(result); log.setAuditResult(result);
log.setOpinion(opinion); log.setOpinion(opinion);
log.setCreateTime(new Date()); log.setCreateTime(new Date());
log.setAuditTime(new Date()); log.setAuditTime(new Date());
log.setExecutorStage(stageDeriver.deriveDisplay("executor", m));
log.setSponsorStage(stageDeriver.deriveDisplay("sponsor", m));
log.setManagerStage(stageDeriver.deriveDisplay("manager", m));
log.setAdminStage(stageDeriver.deriveDisplay("admin", m));
bizMeetingAuditLogService.insert(log); bizMeetingAuditLogService.insert(log);
} }
/** request body for audit endpoints */ /** request body for audit endpoints */
public static class AuditBody { public static class AuditBody {
private String auditType; // MATERIAL / VOUCHER private String auditType; // MATERIAL / VOUCHER / BOTH
private Boolean approved; // true=通过 false=拒绝 private Boolean approved; // true=通过 false=拒绝
private String opinion; // 意见 private String opinion; // 意见
public String getAuditType() { return auditType; } public String getAuditType() { return auditType; }
@@ -10,6 +10,7 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
/** /**
* 会议材料 Controller * 会议材料 Controller
@@ -22,6 +23,8 @@ public class BizMeetingMaterialController extends BaseController {
@Autowired @Autowired
private IBizMeetingMaterialService bizMeetingMaterialService; private IBizMeetingMaterialService bizMeetingMaterialService;
@Autowired
private IBizMeetingService bizMeetingService;
/** /**
* 查该会议的所有材料记录 * 查该会议的所有材料记录
@@ -50,6 +53,20 @@ public class BizMeetingMaterialController extends BaseController {
} }
} }
List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list); List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list);
// 材料变化 → 会议费用待重算 (FeeCalcScheduler 汇总回写)
bizMeetingService.markFeeCalcPending(meetingId);
return success(saved); return success(saved);
} }
/**
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
* <p>
* body: { meetingId, subType, ossUrl }. 后端白名单 subType + 会议存在校验.
* 照片类 NON_OCR, 不影响会议费用.
*/
@PostMapping("/cameraUpload")
public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) {
bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrl(), body.getExtraOssUrl());
return success();
}
} }
@@ -23,12 +23,16 @@ import com.ruoyi.business.domain.BizProjectRating;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizExecutionIntentService; import com.ruoyi.business.service.IBizExecutionIntentService;
import com.ruoyi.business.domain.BizProjectSponsorAssign; import com.ruoyi.business.domain.BizProjectSponsorAssign;
import com.ruoyi.business.domain.BizProjectExecutorAssign;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
import com.ruoyi.business.service.IBizProjectAssignService; import com.ruoyi.business.service.IBizProjectAssignService;
import com.ruoyi.business.service.IBizProjectRatingService; import com.ruoyi.business.service.IBizProjectRatingService;
import com.ruoyi.business.service.IBizProjectSponsorAssignService; import com.ruoyi.business.service.IBizProjectSponsorAssignService;
import com.ruoyi.business.service.IBizProjectExecutorAssignService;
import com.ruoyi.system.domain.vo.SysUserExtendVo; import com.ruoyi.system.domain.vo.SysUserExtendVo;
import com.ruoyi.business.mapper.BizSysUserQueryMapper; import com.ruoyi.business.mapper.BizSysUserQueryMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.mapper.SysUserMapper;
/** /**
* 项目Controller * 项目Controller
@@ -53,7 +57,11 @@ public class BizProjectController extends BaseController
@Autowired @Autowired
private IBizProjectSponsorAssignService bizProjectSponsorAssignService; private IBizProjectSponsorAssignService bizProjectSponsorAssignService;
@Autowired @Autowired
private IBizProjectExecutorAssignService bizProjectExecutorAssignService;
@Autowired
private BizSysUserQueryMapper bizSysUserQueryMapper; private BizSysUserQueryMapper bizSysUserQueryMapper;
@Autowired
private SysUserMapper sysUserMapper;
/** /**
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目) * 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
@@ -116,15 +124,26 @@ public class BizProjectController extends BaseController
} }
/** /**
* sponsor 专属项目列表 (按当前登录 sponsor 的 user_id 过滤) * sponsor 专属项目列表 (按当前登录 sponsor 的账号类型分两种过滤)
* GET /business/project/sponsorList * GET /business/project/sponsorList
* 注: biz_project.sponsor_admin_user_id 永远是主账号 user_id, 子账号登录也应能看主账号的项目 — 简化: 直接用当前 user_id 过滤 * <ul>
* 若需要子账号看主账号项目, 改 SQL 改为 (sponsor_admin_user_id = uid OR sponsor_admin_user_id IN (parent_user_id=uid 的子账号所属主账号)) * <li>MAIN 主账号: 看自己 sponsor_admin_user_id 下的项目 (用 biz_project.sponsor_admin_user_id)</li>
* <li>SUB 子账号: 看自己被分配 (作为监察员 monitor) 的项目 (走 biz_project_sponsor_assign.monitor_user_id)</li>
* </ul>
* 注: 支持方不能创建项目, 这里只看分配结果. 子账号不再回退到 MAIN 路径, 严格隔离.
*/ */
@GetMapping("/sponsorList") @GetMapping("/sponsorList")
public TableDataInfo sponsorList(BizProject bizProject) public TableDataInfo sponsorList(BizProject bizProject)
{ {
bizProject.getParams().put("sponsorAdminUserId", SecurityUtils.getUserId()); Long uid = SecurityUtils.getUserId();
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
// SUB 子账号视角: 走 sponsor_assign.monitor_user_id (监察员身份)
bizProject.getParams().put("monitorUserId", uid);
} else {
// MAIN 主账号视角 (含 admin / 其它兜底): 走 sponsor_admin_user_id
bizProject.getParams().put("sponsorAdminUserId", uid);
}
startPage(); startPage();
List<BizProject> list = bizProjectService.selectSponsorList(bizProject); List<BizProject> list = bizProjectService.selectSponsorList(bizProject);
return getDataTable(list); return getDataTable(list);
@@ -140,7 +159,20 @@ public class BizProjectController extends BaseController
@GetMapping("/executorList") @GetMapping("/executorList")
public TableDataInfo executorList(BizProject bizProject) public TableDataInfo executorList(BizProject bizProject)
{ {
bizProject.getParams().put("executorUserId", SecurityUtils.getUserId()); Long uid = SecurityUtils.getUserId();
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
// 执行人 (SUB 子账号) 视角: 反查 biz_project_executor_assign.staff_user_id, 严格隔离
// (不 JOIN biz_project_assign, 不 UNION intent — executor 与 biz_*_intent 完全无关)
// 场次/金额列按"本公司"聚合: executorUserId 传 parent_user_id(主账号), 执行人看到的是公司数据而非个人数据
bizProject.getParams().put("executorStaffUserId", uid);
bizProject.getParams().put("executorUserId", current.getParentUserId());
startPage();
List<BizProject> list = bizProjectService.selectExecutorStaffList(bizProject);
return getDataTable(list);
}
// 主账号视角 (原逻辑): biz_project_assign.exec_user_id / execution_unit_id
bizProject.getParams().put("executorUserId", uid);
startPage(); startPage();
List<BizProject> list = bizProjectService.selectExecutorList(bizProject); List<BizProject> list = bizProjectService.selectExecutorList(bizProject);
return getDataTable(list); return getDataTable(list);
@@ -232,6 +264,23 @@ public class BizProjectController extends BaseController
return oa.compareTo(na) == 0; return oa.compareTo(na) == 0;
} }
/**
* 比较两条 sponsor 分配是否对监察员而言"未变".
* 判定维度: assignDesc + assignPoints. (projectId 隐含相同, 旧数据就是本 projectId 的)
*/
private static boolean isSponsorAssignUnchanged(BizProjectSponsorAssign old, String assignDesc, String assignPoints) {
if (old == null) return false; // 新分配 → 算变化
if (!Objects.equals(old.getAssignDesc(), assignDesc)) return false;
if (!Objects.equals(old.getAssignPoints(), assignPoints)) return false;
return true;
}
/** String projectId → Long (sponsor_assign/executor_assign 表主键是 String, 查 biz_project 主表需 Long) */
private static Long parseProjectId(String s) {
if (s == null || s.isEmpty()) return null;
try { return Long.parseLong(s); } catch (NumberFormatException e) { return null; }
}
@Log(title = "项目执行方分配", businessType = BusinessType.DELETE) @Log(title = "项目执行方分配", businessType = BusinessType.DELETE)
@DeleteMapping("/{projectId}/assigns") @DeleteMapping("/{projectId}/assigns")
public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId) public AjaxResult clearAssigns(@PathVariable("projectId") Long projectId)
@@ -297,15 +346,101 @@ public class BizProjectController extends BaseController
/** /**
* 支持方分配监察员 (写 biz_project_sponsor_assign) * 支持方分配监察员 (写 biz_project_sponsor_assign)
* POST /business/project/sponsorAssign * POST /business/project/sponsorAssign
* body: { projectId, monitorUserIds: [Long, ...] } — 多选 (前端 sponsor/my-projects 走这条)
* 兼容单值 { projectId, monitorUserId: Long } (前端 sponsor/Projects.vue 仍走单值)
*/ */
@Log(title = "支持方分配监察员", businessType = BusinessType.INSERT) @Log(title = "支持方分配监察员", businessType = BusinessType.INSERT)
@PostMapping("/sponsorAssign") @PostMapping("/sponsorAssign")
public AjaxResult sponsorAssign(@RequestBody BizProjectSponsorAssign body) public AjaxResult sponsorAssign(@RequestBody BizProjectSponsorAssign body)
{ {
if (body.getProjectId() == null) {
return error("projectId 必填");
}
java.util.List<Long> mids = body.getMonitorUserIds();
if (mids == null || mids.isEmpty()) {
// 向后兼容: 单值 monitorUserId
if (body.getMonitorUserId() != null) {
mids = java.util.Collections.singletonList(body.getMonitorUserId());
} else {
return error("monitorUserIds / monitorUserId 必填");
}
}
// 通知去重: 拉旧监察员列表按 monitorUserId 索引, 同 (assignDesc, assignPoints) → 跳过 (修改不重复发)
List<BizProjectSponsorAssign> oldList = bizProjectSponsorAssignService.listByProjectId(body.getProjectId());
Map<Long, BizProjectSponsorAssign> oldByMonitor = new HashMap<>();
if (oldList != null) {
for (BizProjectSponsorAssign o : oldList) {
if (o.getMonitorUserId() != null) oldByMonitor.put(o.getMonitorUserId(), o);
}
}
body.setCreateBy(SecurityUtils.getUsername()); body.setCreateBy(SecurityUtils.getUsername());
body.setSponsorUserId(SecurityUtils.getUserId()); body.setSponsorUserId(SecurityUtils.getUserId());
int rows = bizProjectSponsorAssignService.insertAssign(body); // 一项目支持 N 监察员: service 内一次性 delete + 逐个 insert, 不会循环 delete
return toAjax(rows); int inserted = bizProjectSponsorAssignService.assignMonitorsForProject(body, mids);
// 通知被分配的监察员 (新增 / 说明或积分变化才发). projectId 在 sponsor_assign 是 String, 转 Long 查主表
Long projectIdLong = parseProjectId(body.getProjectId());
BizProject project = projectIdLong != null ? bizProjectService.getById(projectIdLong) : null;
String projectName = project != null ? project.getProjectName() : null;
for (Long mid : mids) {
if (mid == null) continue;
BizProjectSponsorAssign old = oldByMonitor.get(mid);
if (isSponsorAssignUnchanged(old, body.getAssignDesc(), body.getAssignPoints())) {
logger.debug("[sponsorAssign] monitorUserId={} (assignDesc, assignPoints) 未变, 跳过通知", mid);
continue;
}
bizNotifyService.projectAssignedToSponsor(mid, projectIdLong, projectName, body.getAssignDesc(), body.getAssignPoints());
}
return toAjax(inserted);
}
/**
* 查询项目已分配的监察员列表 (供前端 dialog 重开时回显)
* GET /business/project/{projectId}/sponsorAssigns
*/
@GetMapping("/{projectId}/sponsorAssigns")
public AjaxResult listSponsorAssigns(@PathVariable("projectId") String projectId)
{
return success(bizProjectSponsorAssignService.listByProjectId(projectId));
}
/**
* 执行方分配执行人 (写 biz_project_executor_assign)
* POST /business/project/executorAssign
* body: { projectId, staffUserIds: [Long, ...] } — 多选 (执行方给自己的项目分配执行人)
* 兼容单值 { projectId, staffUserId: Long }
*/
@Log(title = "执行方分配执行人", businessType = BusinessType.INSERT)
@PostMapping("/executorAssign")
public AjaxResult executorAssign(@RequestBody BizProjectExecutorAssign body)
{
if (body.getProjectId() == null) {
return error("projectId 必填");
}
java.util.List<Long> sids = body.getStaffUserIds();
if (sids == null || sids.isEmpty()) {
// 向后兼容: 单值 staffUserId
if (body.getStaffUserId() != null) {
sids = java.util.Collections.singletonList(body.getStaffUserId());
} else {
return error("staffUserIds / staffUserId 必填");
}
}
body.setCreateBy(SecurityUtils.getUsername());
body.setExecutorUserId(SecurityUtils.getUserId());
// 一项目支持 N 执行人: service 内一次性 delete + 逐个 insert, 不会循环 delete
int inserted = bizProjectExecutorAssignService.assignStaffForProject(body, sids);
return toAjax(inserted);
}
/**
* 查询项目已分配的执行人列表 (供前端 dialog 重开时回显)
* GET /business/project/{projectId}/executorAssigns
*/
@GetMapping("/{projectId}/executorAssigns")
public AjaxResult listExecutorAssigns(@PathVariable("projectId") String projectId)
{
return success(bizProjectExecutorAssignService.listByProjectId(projectId));
} }
/** /**
@@ -349,7 +484,22 @@ public class BizProjectController extends BaseController
} }
body.setCreateBy(loginName); body.setCreateBy(loginName);
body.setSponsorUserId(loginUid); body.setSponsorUserId(loginUid);
// 通知去重: 拉旧分配, 找同 monitorUserId, 比较 assignDesc/assignPoints 是否变化
BizProjectSponsorAssign old = null;
List<BizProjectSponsorAssign> oldList = bizProjectSponsorAssignService.listByProjectId(body.getProjectId());
if (oldList != null) {
for (BizProjectSponsorAssign o : oldList) {
if (Objects.equals(o.getMonitorUserId(), body.getMonitorUserId())) { old = o; break; }
}
}
boolean changed = !isSponsorAssignUnchanged(old, body.getAssignDesc(), body.getAssignPoints());
bizProjectSponsorAssignService.insertAssign(body); bizProjectSponsorAssignService.insertAssign(body);
if (changed) {
Long pid = parseProjectId(body.getProjectId());
BizProject project = pid != null ? bizProjectService.getById(pid) : null;
String projectName = project != null ? project.getProjectName() : null;
bizNotifyService.projectAssignedToSponsor(body.getMonitorUserId(), pid, projectName, body.getAssignDesc(), body.getAssignPoints());
}
ok++; ok++;
} catch (Exception e) { } catch (Exception e) {
errors.add("" + (i + 1) + "条 (projectId=" + body.getProjectId() + "): " + e.getMessage()); errors.add("" + (i + 1) + "条 (projectId=" + body.getProjectId() + "): " + e.getMessage());
@@ -32,6 +32,12 @@ public class BizSignController extends BaseController {
return success(signService.getSignInfo(attendeeId)); return success(signService.getSignInfo(attendeeId));
} }
/** 扫码入口: 只有 meetingId (无 attendeeId) 时, 返回 {meetingName, periodNo, totalPeriods, attendeeId} (不在邀请之列 attendeeId=null) */
@GetMapping("/resolve")
public AjaxResult resolveByMeeting(@RequestParam("meetingId") Long meetingId) {
return success(signService.resolveByMeeting(meetingId));
}
/** 医生点 "保存" / "下一步" 后调 */ /** 医生点 "保存" / "下一步" 后调 */
@PostMapping("/saveProfile") @PostMapping("/saveProfile")
public AjaxResult saveProfile(@RequestParam("attendeeId") Long attendeeId, public AjaxResult saveProfile(@RequestParam("attendeeId") Long attendeeId,
@@ -1,53 +0,0 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.business.domain.BizSupportIntent;
import com.ruoyi.business.service.IBizSupportIntentService;
/**
* 支持意向Controller
*/
@RestController
@RequestMapping("/business/supportIntent")
public class BizSupportIntentController extends BaseController
{
@Autowired
private IBizSupportIntentService bizSupportIntentService;
@GetMapping("/list")
public TableDataInfo list(BizSupportIntent bizSupportIntent)
{
startPage();
List<BizSupportIntent> list = bizSupportIntentService.selectList(bizSupportIntent);
return getDataTable(list);
}
@GetMapping("/{intentId}")
public AjaxResult getInfo(@PathVariable("intentId") String intentId)
{
return success(bizSupportIntentService.getById(intentId));
}
@Log(title = "支持意向", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizSupportIntent bizSupportIntent)
{
return toAjax(bizSupportIntentService.insert(bizSupportIntent));
}
@Log(title = "支持意向", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizSupportIntent bizSupportIntent)
{
return toAjax(bizSupportIntentService.updateByPrimaryKey(bizSupportIntent));
}
@Log(title = "支持意向", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable String[] ids)
{
return toAjax(bizSupportIntentService.deleteByPrimaryKeys(ids));
}
}
@@ -40,7 +40,7 @@ public class BizMeeting extends BaseEntity {
/** period_no */ /** period_no */
@Excel(name = "period_no") @Excel(name = "period_no")
private Long periodNo; private Long periodNo;
/** current_stage */ /** current_stage: 10 值枚举, 见 com.ruoyi.common.enums.BizMeetingStageEnum (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN). 字段类型保持 String 是因为 MyBatis 默认 EnumTypeHandler 需要额外注册, 直接 String + Enum 常量更简单. */
@Excel(name = "current_stage") @Excel(name = "current_stage")
private String currentStage; private String currentStage;
/** create_by */ /** create_by */
@@ -70,20 +70,64 @@ public class BizMeeting extends BaseEntity {
/** 监察时间 (与 DB datetime 对齐) */ /** 监察时间 (与 DB datetime 对齐) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date supervisionTime; private Date supervisionTime;
/** 材料审核阶段 (INIT=待提交, 后续阶段开发中定) */ /** 材料审核阶段 (NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED, 见 MeetingAuditStageEnum) */
private String materialAuditStage; private String materialAuditStage;
/** 凭证审核阶段 (INIT=待提交, 后续阶段开发中定) */ /** 凭证审核阶段 (同上) */
private String voucherAuditStage; private String voucherAuditStage;
/** 是否执行 0否1是 (会议开始时间到, scheduler 置1) */
private Integer isExecuted;
/** 执行时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date executeTime;
/** 是否结算 0否1是 (合规点击结算置1) */
private Integer isSettled;
/** 结算时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date settleTime;
/** 是否完结 0否1是 (合规/管理员点击完结置1) */
private Integer isFinished;
/** 完结时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date finishTime;
/** 是否冻结 0否1是 (逾期未提交, scheduler 置1) */
private Integer isFrozen;
/** 冻结时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date freezeTime;
/** 材料最近一次审核动作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date materialAuditTime;
/** 凭证最近一次审核动作时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date voucherAuditTime;
/** 材料合规是否已通过 0否1是 (区分 SUBMITTED 内合规审/支持方审) */
private Integer materialComplianceApproved;
/** 凭证合规是否已通过 0否1是 */
private Integer voucherComplianceApproved;
/** 邀请函URL */ /** 邀请函URL */
private String invitationUrl; private String invitationUrl;
/** 日程海报URL */ /** 日程海报URL */
private String scheduleUrl; private String scheduleUrl;
/** 生成的海报URL (生成海报按钮产出) */
private String posterUrl;
/** 签署劳务 0未签 1已签 */ /** 签署劳务 0未签 1已签 */
private String laborSigned; private String laborSigned;
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */ /** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
private transient Long userId; private transient Long userId;
/** 当前登录医生/专家在本会议的参会人记录 id (非持久化, mapper 子查询填充; 用于 /doctor/meetings 签署劳务链接) */
private transient Long attendeeId;
/** 当前登录医生/专家在本会议的已签劳务 PDF URL (非持久化, mapper 子查询填充; null=未签) */
private transient String attendeeLaborProtocol;
/** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */ /** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */
private Long[] attendeeUserIds; private Long[] attendeeUserIds;
/** 劳务费用 = 参会人应发金额 (fee_pre_tax) 合计 (后台定时任务汇总回写) */
private BigDecimal laborFee;
/** 会务费用 = 总发票(M_INVOICE)覆盖 SUB 子类发票金额 (后台定时任务汇总回写) */
private BigDecimal meetingFee;
/** 总费用 = 劳务费用 + 会务费用 */
private BigDecimal totalFee;
/** 费用汇总状态 0未汇总 1已汇总 (经费变化置0, 定时任务汇总后置1) */
private Integer feeCalcStatus;
/** 软删除标记 0否1是 (admin 删除会议时置1, 查询需 WHERE is_deleted=0) */ /** 软删除标记 0否1是 (admin 删除会议时置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted; private Integer isDeleted;
public Long getMeetingId() { return meetingId; } public Long getMeetingId() { return meetingId; }
@@ -133,16 +177,54 @@ public class BizMeeting extends BaseEntity {
public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; } public void setInvitationUrl(String invitationUrl) { this.invitationUrl = invitationUrl; }
public String getScheduleUrl() { return scheduleUrl; } public String getScheduleUrl() { return scheduleUrl; }
public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; } public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; }
public String getPosterUrl() { return posterUrl; }
public void setPosterUrl(String posterUrl) { this.posterUrl = posterUrl; }
public String getLaborSigned() { return laborSigned; } public String getLaborSigned() { return laborSigned; }
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; } public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public String getMaterialAuditStage() { return materialAuditStage; } public String getMaterialAuditStage() { return materialAuditStage; }
public void setMaterialAuditStage(String materialAuditStage) { this.materialAuditStage = materialAuditStage; } public void setMaterialAuditStage(String materialAuditStage) { this.materialAuditStage = materialAuditStage; }
public String getVoucherAuditStage() { return voucherAuditStage; } public String getVoucherAuditStage() { return voucherAuditStage; }
public void setVoucherAuditStage(String voucherAuditStage) { this.voucherAuditStage = voucherAuditStage; } public void setVoucherAuditStage(String voucherAuditStage) { this.voucherAuditStage = voucherAuditStage; }
public Integer getIsExecuted() { return isExecuted; }
public void setIsExecuted(Integer isExecuted) { this.isExecuted = isExecuted; }
public Date getExecuteTime() { return executeTime; }
public void setExecuteTime(Date executeTime) { this.executeTime = executeTime; }
public Integer getIsSettled() { return isSettled; }
public void setIsSettled(Integer isSettled) { this.isSettled = isSettled; }
public Date getSettleTime() { return settleTime; }
public void setSettleTime(Date settleTime) { this.settleTime = settleTime; }
public Integer getIsFinished() { return isFinished; }
public void setIsFinished(Integer isFinished) { this.isFinished = isFinished; }
public Date getFinishTime() { return finishTime; }
public void setFinishTime(Date finishTime) { this.finishTime = finishTime; }
public Integer getIsFrozen() { return isFrozen; }
public void setIsFrozen(Integer isFrozen) { this.isFrozen = isFrozen; }
public Date getFreezeTime() { return freezeTime; }
public void setFreezeTime(Date freezeTime) { this.freezeTime = freezeTime; }
public Date getMaterialAuditTime() { return materialAuditTime; }
public void setMaterialAuditTime(Date materialAuditTime) { this.materialAuditTime = materialAuditTime; }
public Date getVoucherAuditTime() { return voucherAuditTime; }
public void setVoucherAuditTime(Date voucherAuditTime) { this.voucherAuditTime = voucherAuditTime; }
public Integer getMaterialComplianceApproved() { return materialComplianceApproved; }
public void setMaterialComplianceApproved(Integer materialComplianceApproved) { this.materialComplianceApproved = materialComplianceApproved; }
public Integer getVoucherComplianceApproved() { return voucherComplianceApproved; }
public void setVoucherComplianceApproved(Integer voucherComplianceApproved) { this.voucherComplianceApproved = voucherComplianceApproved; }
public Long getUserId() { return userId; } public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; } public void setUserId(Long userId) { this.userId = userId; }
public Long getAttendeeId() { return attendeeId; }
public void setAttendeeId(Long attendeeId) { this.attendeeId = attendeeId; }
public String getAttendeeLaborProtocol() { return attendeeLaborProtocol; }
public void setAttendeeLaborProtocol(String attendeeLaborProtocol) { this.attendeeLaborProtocol = attendeeLaborProtocol; }
public Integer getIsDeleted() { return isDeleted; } public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
public Long[] getAttendeeUserIds() { return attendeeUserIds; } public Long[] getAttendeeUserIds() { return attendeeUserIds; }
public void setAttendeeUserIds(Long[] attendeeUserIds) { this.attendeeUserIds = attendeeUserIds; } public void setAttendeeUserIds(Long[] attendeeUserIds) { this.attendeeUserIds = attendeeUserIds; }
public BigDecimal getLaborFee() { return laborFee; }
public void setLaborFee(BigDecimal laborFee) { this.laborFee = laborFee; }
public BigDecimal getMeetingFee() { return meetingFee; }
public void setMeetingFee(BigDecimal meetingFee) { this.meetingFee = meetingFee; }
public BigDecimal getTotalFee() { return totalFee; }
public void setTotalFee(BigDecimal totalFee) { this.totalFee = totalFee; }
public Integer getFeeCalcStatus() { return feeCalcStatus; }
public void setFeeCalcStatus(Integer feeCalcStatus) { this.feeCalcStatus = feeCalcStatus; }
} }
@@ -135,4 +135,14 @@ public class BizMeetingAttendee extends BaseEntity {
private Integer isDeleted; private Integer isDeleted;
public Integer getIsDeleted() { return isDeleted; } public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
/** 是否已推送电子签 0否1是 (admin/manager 点"推送电子签"后置1) */
private Integer isEsigned;
public Integer getIsEsigned() { return isEsigned; }
public void setIsEsigned(Integer isEsigned) { this.isEsigned = isEsigned; }
/** 是否已邀请参会 0否1是 (点"邀请参会"后置1) */
private Integer isInvited;
public Integer getIsInvited() { return isInvited; }
public void setIsInvited(Integer isInvited) { this.isInvited = isInvited; }
} }
@@ -25,8 +25,17 @@ public class BizMeetingAuditLog {
/** 审核意见 */ /** 审核意见 */
private String opinion; private String opinion;
/** 当前阶段 (INIT / ... 后续开发中定) */ /** 执行方当时展示状态 */
private String currentStage; private String executorStage;
/** 支持方当时展示状态 */
private String sponsorStage;
/** 合规当时展示状态 */
private String managerStage;
/** 管理员当时展示状态 */
private String adminStage;
/** 创建时间 */ /** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -57,8 +66,17 @@ public class BizMeetingAuditLog {
public String getOpinion() { return opinion; } public String getOpinion() { return opinion; }
public void setOpinion(String opinion) { this.opinion = opinion; } public void setOpinion(String opinion) { this.opinion = opinion; }
public String getCurrentStage() { return currentStage; } public String getExecutorStage() { return executorStage; }
public void setCurrentStage(String currentStage) { this.currentStage = currentStage; } public void setExecutorStage(String executorStage) { this.executorStage = executorStage; }
public String getSponsorStage() { return sponsorStage; }
public void setSponsorStage(String sponsorStage) { this.sponsorStage = sponsorStage; }
public String getManagerStage() { return managerStage; }
public void setManagerStage(String managerStage) { this.managerStage = managerStage; }
public String getAdminStage() { return adminStage; }
public void setAdminStage(String adminStage) { this.adminStage = adminStage; }
public Date getCreateTime() { return createTime; } public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; }
@@ -7,10 +7,10 @@ import com.fasterxml.jackson.annotation.JsonFormat;
/** /**
* 会议材料对象 biz_meeting_material (单表) * 会议材料对象 biz_meeting_material (单表)
* <p> * <p>
* 包含 4 大类 13 子类: * 包含 4 大类 17 子类:
* <ul> * <ul>
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li> * <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / SV_PAYMENT / LV_PAYMENT</li> * <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT</li>
* </ul> * </ul>
* <p> * <p>
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段. * 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
@@ -29,7 +29,7 @@ public class BizMeetingMaterial {
/** 资料类型 (4 种): SERVICE / LABOR / SERVICE_VOUCHER / LABOR_VOUCHER */ /** 资料类型 (4 种): SERVICE / LABOR / SERVICE_VOUCHER / LABOR_VOUCHER */
private String materialType; private String materialType;
/** 子分类 (13 种): M_MATERIAL / M_HOTEL / ... / SV_PAYMENT / LV_PAYMENT */ /** 子分类 (17 种): M_MATERIAL / M_HOTEL / ... / SV_PAYMENT / LV_PAYMENT */
private String subType; private String subType;
/** 文件名称 */ /** 文件名称 */
@@ -38,6 +38,9 @@ public class BizMeetingMaterial {
/** OSS URL */ /** OSS URL */
private String ossUrl; private String ossUrl;
/** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */
private String extraOssUrl;
/** 金额 (发票专用, 其他类型 = 0) */ /** 金额 (发票专用, 其他类型 = 0) */
private BigDecimal amount; private BigDecimal amount;
@@ -51,6 +54,9 @@ public class BizMeetingMaterial {
/** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */ /** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted; private Integer isDeleted;
/** 该材料发票金额是否已计算 0未计算(待OCR) 1已计算(OCR完 或 本就不需OCR) */
private Integer feeStatus;
public Long getId() { return id; } public Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
@@ -69,6 +75,9 @@ public class BizMeetingMaterial {
public String getOssUrl() { return ossUrl; } public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; } public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public String getExtraOssUrl() { return extraOssUrl; }
public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; }
public BigDecimal getAmount() { return amount; } public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; } public void setAmount(BigDecimal amount) { this.amount = amount; }
@@ -80,4 +89,7 @@ public class BizMeetingMaterial {
public Integer getIsDeleted() { return isDeleted; } public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
public Integer getFeeStatus() { return feeStatus; }
public void setFeeStatus(Integer feeStatus) { this.feeStatus = feeStatus; }
} }
@@ -57,6 +57,9 @@ public class BizPerson extends BaseEntity {
private String accountType; private String accountType;
/** 主账号ID (来自 sys_user.parent_user_id, 子账号指向其主账号) - 仅展示用 */ /** 主账号ID (来自 sys_user.parent_user_id, 子账号指向其主账号) - 仅展示用 */
private Long parentUserId; private Long parentUserId;
/** 登录账号 (来自 sys_user.user_name, 跟 accountType / parentUserId 一样仅展示, 不入库) */
@com.fasterxml.jackson.annotation.JsonProperty("account")
private String account;
/** 子账号登录账号 (前端传入, 用于创建 sys_user 子账号) - 非持久化字段 */ /** 子账号登录账号 (前端传入, 用于创建 sys_user 子账号) - 非持久化字段 */
@com.fasterxml.jackson.annotation.JsonProperty("userName") @com.fasterxml.jackson.annotation.JsonProperty("userName")
private transient String loginUsername; private transient String loginUsername;
@@ -102,6 +105,8 @@ public class BizPerson extends BaseEntity {
public void setAccountType(String accountType) { this.accountType = accountType; } public void setAccountType(String accountType) { this.accountType = accountType; }
public Long getParentUserId() { return parentUserId; } public Long getParentUserId() { return parentUserId; }
public void setParentUserId(Long parentUserId) { this.parentUserId = parentUserId; } public void setParentUserId(Long parentUserId) { this.parentUserId = parentUserId; }
public String getAccount() { return account; }
public void setAccount(String account) { this.account = account; }
public String getLoginUsername() { return loginUsername; } public String getLoginUsername() { return loginUsername; }
public void setLoginUsername(String loginUsername) { this.loginUsername = loginUsername; } public void setLoginUsername(String loginUsername) { this.loginUsername = loginUsername; }
public String getLoginPassword() { return loginPassword; } public String getLoginPassword() { return loginPassword; }
@@ -25,6 +25,8 @@ public class BizProject extends BaseEntity {
private Long assignedSessions; private Long assignedSessions;
/** 分配给当前执行方的金额 (biz_project_assign.amount 之和, 仅 executor 端使用) */ /** 分配给当前执行方的金额 (biz_project_assign.amount 之和, 仅 executor 端使用) */
private java.math.BigDecimal assignedAmount; private java.math.BigDecimal assignedAmount;
/** 该项目下已建的会议数 (biz_meeting 计数, 仅 executor 端建会限额用) */
private Long meetingCount;
/** done_sessions */ /** done_sessions */
@Excel(name = "done_sessions") @Excel(name = "done_sessions")
private Long doneSessions; private Long doneSessions;
@@ -152,6 +154,8 @@ public class BizProject extends BaseEntity {
public void setAssignedSessions(Long assignedSessions) { this.assignedSessions = assignedSessions; } public void setAssignedSessions(Long assignedSessions) { this.assignedSessions = assignedSessions; }
public java.math.BigDecimal getAssignedAmount() { return assignedAmount; } public java.math.BigDecimal getAssignedAmount() { return assignedAmount; }
public void setAssignedAmount(java.math.BigDecimal assignedAmount) { this.assignedAmount = assignedAmount; } public void setAssignedAmount(java.math.BigDecimal assignedAmount) { this.assignedAmount = assignedAmount; }
public Long getMeetingCount() { return meetingCount; }
public void setMeetingCount(Long meetingCount) { this.meetingCount = meetingCount; }
public Long getDoneSessions() { return doneSessions; } public Long getDoneSessions() { return doneSessions; }
public void setDoneSessions(Long doneSessions) { this.doneSessions = doneSessions; } public void setDoneSessions(Long doneSessions) { this.doneSessions = doneSessions; }
public Long getTodoSessions() { return todoSessions; } public Long getTodoSessions() { return todoSessions; }
@@ -0,0 +1,54 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/** 项目-执行方-执行人分配记录 (biz_project_executor_assign) */
public class BizProjectExecutorAssign extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private String projectId;
/** 执行方主账号 user_id (分配人, 审计) */
private Long executorUserId;
/** 执行人 user_id (被分配) */
private Long staffUserId;
/** 多个执行人 userId (前端 multi-select 传入, 控制器循环 insert, 不入库) */
@com.fasterxml.jackson.annotation.JsonProperty("staffUserIds")
private java.util.List<Long> staffUserIds;
private String assignDesc;
private String assignPoints;
/** 关联展示字段 (非持久化) */
private String executorUserName;
private String staffUserName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** 软删除标记 0否1是 (项目级联删除时按 project_id (String) 置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted;
public Integer getIsDeleted() { return isDeleted; }
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getProjectId() { return projectId; }
public void setProjectId(String projectId) { this.projectId = projectId; }
public Long getExecutorUserId() { return executorUserId; }
public void setExecutorUserId(Long executorUserId) { this.executorUserId = executorUserId; }
public Long getStaffUserId() { return staffUserId; }
public void setStaffUserId(Long staffUserId) { this.staffUserId = staffUserId; }
public java.util.List<Long> getStaffUserIds() { return staffUserIds; }
public void setStaffUserIds(java.util.List<Long> staffUserIds) { this.staffUserIds = staffUserIds; }
public String getAssignDesc() { return assignDesc; }
public void setAssignDesc(String assignDesc) { this.assignDesc = assignDesc; }
public String getAssignPoints() { return assignPoints; }
public void setAssignPoints(String assignPoints) { this.assignPoints = assignPoints; }
public String getExecutorUserName() { return executorUserName; }
public void setExecutorUserName(String executorUserName) { this.executorUserName = executorUserName; }
public String getStaffUserName() { return staffUserName; }
public void setStaffUserName(String staffUserName) { this.staffUserName = staffUserName; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -11,6 +11,9 @@ public class BizProjectSponsorAssign extends BaseEntity {
private String projectId; private String projectId;
private Long sponsorUserId; private Long sponsorUserId;
private Long monitorUserId; private Long monitorUserId;
/** 多个监察员 userId (前端 multi-select 传入, 控制器循环 insert, 不入库) */
@com.fasterxml.jackson.annotation.JsonProperty("monitorUserIds")
private java.util.List<Long> monitorUserIds;
private String assignDesc; private String assignDesc;
private String assignPoints; private String assignPoints;
@@ -34,6 +37,8 @@ public class BizProjectSponsorAssign extends BaseEntity {
public void setSponsorUserId(Long sponsorUserId) { this.sponsorUserId = sponsorUserId; } public void setSponsorUserId(Long sponsorUserId) { this.sponsorUserId = sponsorUserId; }
public Long getMonitorUserId() { return monitorUserId; } public Long getMonitorUserId() { return monitorUserId; }
public void setMonitorUserId(Long monitorUserId) { this.monitorUserId = monitorUserId; } public void setMonitorUserId(Long monitorUserId) { this.monitorUserId = monitorUserId; }
public java.util.List<Long> getMonitorUserIds() { return monitorUserIds; }
public void setMonitorUserIds(java.util.List<Long> monitorUserIds) { this.monitorUserIds = monitorUserIds; }
public String getAssignDesc() { return assignDesc; } public String getAssignDesc() { return assignDesc; }
public void setAssignDesc(String assignDesc) { this.assignDesc = assignDesc; } public void setAssignDesc(String assignDesc) { this.assignDesc = assignDesc; }
public String getAssignPoints() { return assignPoints; } public String getAssignPoints() { return assignPoints; }
@@ -8,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
/** /**
* 公示页-支持意向 (匿名快照, 与 sys_user 解耦) * 公示页-支持意向 (匿名快照, 与 sys_user 解耦)
* 数据源: /publicity/:projectId 页面 "表达支持意向" 按钮 * 数据源: /publicity/:projectId 页面 "表达支持意向" 按钮
* 与 biz_support_intent (旧表, 已登录用户流程) 语义不同, 物理表独立 * 与 biz_support_intent (已删除) 语义不同: 本表允许 user_id=NULL (匿名), 旧表强绑已登录用户
*/ */
public class BizPublicitySupportIntent extends BaseEntity { public class BizPublicitySupportIntent extends BaseEntity {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@@ -1,78 +0,0 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/** 支持意向对象 BizSupportIntent */
public class BizSupportIntent extends BaseEntity {
private static final long serialVersionUID = 1L;
/** intentId */
private String intentId;
/** project_no */
@Excel(name = "project_no")
private String projectNo;
/** project_name */
@Excel(name = "project_name")
private String projectName;
/** name */
@Excel(name = "name")
private String name;
/** work_unit */
@Excel(name = "work_unit")
private String workUnit;
/** department */
@Excel(name = "department")
private String department;
/** position */
@Excel(name = "position")
private String position;
/** phone */
@Excel(name = "phone")
private String phone;
/** account_status */
@Excel(name = "account_status")
private String accountStatus;
/** create_by */
@Excel(name = "create_by")
private String createBy;
/** create_time */
@Excel(name = "create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/** update_by */
@Excel(name = "update_by")
private String updateBy;
/** update_time */
@Excel(name = "update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
public String getIntentId() { return intentId; }
public void setIntentId(String intentId) { this.intentId = intentId; }
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getWorkUnit() { return workUnit; }
public void setWorkUnit(String workUnit) { this.workUnit = workUnit; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
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 getAccountStatus() { return accountStatus; }
public void setAccountStatus(String accountStatus) { this.accountStatus = accountStatus; }
public String getCreateBy() { return createBy; }
public void setCreateBy(String createBy) { this.createBy = createBy; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getUpdateBy() { return updateBy; }
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
}
@@ -80,6 +80,22 @@ public class BizMeetingAttendeeImportVo {
@Excel(name = "摘要", sort = 15) @Excel(name = "摘要", sort = 15)
private String summary; private String summary;
/** 账户名称(持卡人姓名) */
@Excel(name = "账户名称(持卡人姓名)", sort = 16)
private String accountName;
/** 开户银行地址(省/市) */
@Excel(name = "开户银行地址", sort = 17)
private String bankRegion;
/** 银行详细地址 */
@Excel(name = "银行详细地址", sort = 18)
private String bankAddress;
/** 身份证附件(正反面 URL CSV) */
@Excel(name = "身份证附件", sort = 19)
private String idCardAttachments;
public String getPhone() { return phone; } public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; } public void setPhone(String phone) { this.phone = phone; }
public String getName() { return name; } public String getName() { return name; }
@@ -110,4 +126,12 @@ public class BizMeetingAttendeeImportVo {
public void setFee(BigDecimal fee) { this.fee = fee; } public void setFee(BigDecimal fee) { this.fee = fee; }
public String getSummary() { return summary; } public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; } public void setSummary(String summary) { this.summary = summary; }
public String getAccountName() { return accountName; }
public void setAccountName(String accountName) { this.accountName = accountName; }
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 String getIdCardAttachments() { return idCardAttachments; }
public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; }
} }
@@ -6,8 +6,8 @@ import com.ruoyi.business.domain.BizMeetingAttendee;
public interface BizMeetingAttendeeMapper { public interface BizMeetingAttendeeMapper {
int insert(BizMeetingAttendee entity); int insert(BizMeetingAttendee entity);
/** 批量插入参会人 (BizMeetingController.add 调用) */ /** 批量插入参会人 (BizMeetingController.add/edit 调用), id 由调用方雪花 ID 填好 */
int insertBatch(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, @Param("createBy") String createBy); int insertBatch(@Param("list") List<BizMeetingAttendee> list);
/** /**
* 管理端"新增参会人"用: 一次性插入完整档案 (含 name/phone/workUnit 等). * 管理端"新增参会人"用: 一次性插入完整档案 (含 name/phone/workUnit 等).
* 与 {@link #insert} 区别: insert 只写 meeting_id+user_id+create_by (医生端"刚被加入"零信息行), * 与 {@link #insert} 区别: insert 只写 meeting_id+user_id+create_by (医生端"刚被加入"零信息行),
@@ -20,6 +20,10 @@ public interface BizMeetingAttendeeMapper {
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */ /** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity); int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity); int updateLaborProtocol(BizMeetingAttendee entity);
/** 推送电子签后置 is_esigned=1 (单条) */
int markEsignedById(@Param("id") Long id);
/** 邀请参会后置 is_invited=1 (单条) */
int markInvitedById(@Param("id") Long id);
int deleteByMeetingId(Long meetingId); int deleteByMeetingId(Long meetingId);
/** 管理端按 attendee.id 单删 (MeetingDetail 参会人 CRUD 用) */ /** 管理端按 attendee.id 单删 (MeetingDetail 参会人 CRUD 用) */
int deleteByPrimaryKey(Long id); int deleteByPrimaryKey(Long id);
@@ -30,9 +34,16 @@ public interface BizMeetingAttendeeMapper {
List<BizMeetingAttendee> selectByUserId(Long userId); List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id); BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId); List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
/** /**
* 拿某会议已存在的参会人 userId 列表 (用于 add/edit 时 diff 新加入的人, 仅通知增量) * 拿某会议已存在的参会人 userId 列表 (用于 add/edit 时 diff 新加入的人, 仅通知增量)
* 性能: 只查 user_id 一列, 走 meeting_id 索引; meeting 参会人通常 < 100, 无压力. * 性能: 只查 user_id 一列, 走 meeting_id 索引; meeting 参会人通常 < 100, 无压力.
*/ */
List<Long> selectUserIdsByMeetingId(Long meetingId); List<Long> selectUserIdsByMeetingId(Long meetingId);
/**
* 费用汇总用: 某会议所有参会人应发金额 (fee_pre_tax) 之和 (is_deleted=0).
* 空/无参会人 → 返回 0 (SQL ifnull 兜底).
*/
java.math.BigDecimal sumFeePreTaxByMeetingId(Long meetingId);
} }
@@ -1,5 +1,7 @@
package com.ruoyi.business.mapper; package com.ruoyi.business.mapper;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
/** /**
@@ -17,4 +19,36 @@ public interface BizMeetingMapper
int softDeleteByPrimaryKey(Long meetingId); int softDeleteByPrimaryKey(Long meetingId);
/** 项目级联删除时用: 查项目下所有 meeting_id (不过滤 is_deleted, 软删 idempotent) */ /** 项目级联删除时用: 查项目下所有 meeting_id (不过滤 is_deleted, 软删 idempotent) */
List<Long> selectIdListByProjectId(Long projectId); List<Long> selectIdListByProjectId(Long projectId);
/** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */
int countByProjectId(Long projectId);
/**
* 自动流转: start_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING.
* <p>由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写.
*/
int markExecuted();
/**
* 自动流转: material 未提交 且 end_time + biz_project.submit_deadline_days 已过 → 置 is_frozen=1 并转 FROZEN.
* <p>由 MeetingStageScheduler 每分钟触发.
*/
int markFrozen();
/**
* 自动流转: material/voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 且 current_stage 仍为 SUPERVISION_APPROVED → AWAITING_SETTLEMENT.
* <p>由 MeetingStageScheduler 每分钟触发 (待结算的 24h 慢路径).
*/
int markSettlementReady();
/**
* 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表.
*/
List<Long> selectPendingFeeCalcIds();
/**
* 置未汇总 (人员/材料变化触发, 幂等).
*/
int markFeeCalcPending(Long meetingId);
/**
* 汇总回写 labor_fee/meeting_fee/total_fee 并置 fee_calc_status=1.
*/
int updateFeeSummary(@Param("meetingId") Long meetingId,
@Param("laborFee") BigDecimal laborFee,
@Param("meetingFee") BigDecimal meetingFee,
@Param("totalFee") BigDecimal totalFee);
} }
@@ -34,4 +34,7 @@ public interface BizMeetingMaterialMapper {
/** 单条更新 amount (OCR 识别为发票后回写, 不动其他字段) */ /** 单条更新 amount (OCR 识别为发票后回写, 不动其他字段) */
int updateAmount(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount); int updateAmount(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
/** 单条更新 fee_status (OCR 完成/材料保存时设置 0未算 1已算) */
int updateFeeStatus(@org.apache.ibatis.annotations.Param("id") Long id, @org.apache.ibatis.annotations.Param("feeStatus") Integer feeStatus);
} }
@@ -0,0 +1,13 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizProjectExecutorAssign;
public interface BizProjectExecutorAssignMapper {
int insertAssign(BizProjectExecutorAssign entity);
List<BizProjectExecutorAssign> selectByProjectId(String projectId);
/** 按 project_id 全删 (执行方分配: 先删后插策略) */
int deleteByProjectId(String projectId);
/** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */
int softDeleteByProjectId(String projectId);
}
@@ -13,6 +13,8 @@ public interface BizProjectMapper
List<BizProject> selectSponsorList(BizProject entity); List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
List<BizProject> selectExecutorList(BizProject entity); List<BizProject> selectExecutorList(BizProject entity);
/** executor 执行人 (SUB 子账号) 专属: 反查 biz_project_executor_assign.staff_user_id, 只看自己被派到的项目 */
List<BizProject> selectExecutorStaffList(BizProject entity);
int insert(BizProject entity); int insert(BizProject entity);
int updateByPrimaryKey(BizProject entity); int updateByPrimaryKey(BizProject entity);
int deleteByPrimaryKey(Long projectId); int deleteByPrimaryKey(Long projectId);
@@ -21,4 +23,15 @@ public interface BizProjectMapper
int softDeleteByProjectId(Long projectId); int softDeleteByProjectId(Long projectId);
/** 级联删除时用: 查 project_no (不过滤 is_deleted, 避免已软删项目查不到 projectNo) */ /** 级联删除时用: 查 project_no (不过滤 is_deleted, 避免已软删项目查不到 projectNo) */
String selectProjectNoById(Long projectId); String selectProjectNoById(Long projectId);
/** 建会限额用: 统计某项目分配给该执行方 (MAIN) 的总场次 biz_project_assign.sessions 之和 */
int countAssignedSessions(@org.apache.ibatis.annotations.Param("projectId") Long projectId,
@org.apache.ibatis.annotations.Param("executorUserId") Long executorUserId);
/** 提交权限用: 判断 user 是否该项目的执行方 (MAIN biz_project_assign 或 SUB biz_project_executor_assign), >0 即命中 */
int countExecutorOfProject(@org.apache.ibatis.annotations.Param("projectId") Long projectId,
@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 会议结算后重算项目金额: 按"所有已结算会议"全量 SUM 回写 paid_labor_amount / paid_meeting_amount,
* 并重算 available_amount = total_amount - manage_fee - 已支付劳务 - 已支付会务 (幂等, 无累计副作用).
*/
int recomputeSettledAmounts(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
} }
@@ -1,16 +0,0 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizSupportIntent;
/**
* 支持意向Mapper接口
*/
public interface BizSupportIntentMapper
{
BizSupportIntent selectByPrimaryKey(String intentId);
List<BizSupportIntent> selectList(BizSupportIntent entity);
int insert(BizSupportIntent entity);
int updateByPrimaryKey(BizSupportIntent entity);
int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentIds);
}
@@ -195,6 +195,40 @@ public class BizNotifyService
log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId); log.info("[notify] meetingInvitation 已发 uid={} meetingId={}", userId, meetingId);
} }
/**
* 支持方(监察员) 审批退回 → 通知执行方 (待整改 + 说明).
*
* <p>调用方: {@link com.ruoyi.business.controller.BizMeetingController#supervisionOpinion}
* 退回分支内, 对每个 biz_meeting_executor 逐条调 (待办: 需要整改后重新提交).
*
* @param execUserId 执行方 sys_user.user_id (nullable, 跳过)
* @param meetingId biz_meeting.meeting_id
* @param meetingName 会议名 (可空, 兜底)
* @param opinion 退回意见 (可空)
*/
public void meetingSupervisionRejected(Long execUserId, Long meetingId, String meetingName, String opinion)
{
if (execUserId == null) {
log.warn("[notify] meetingSupervisionRejected: execUserId 为空, 跳过 (meetingId={})", meetingId);
return;
}
String name = meetingName != null ? meetingName : ("会议 #" + meetingId);
StringBuilder content = new StringBuilder("会议【").append(name).append("】监管未通过, 请整改后重新提交");
if (opinion != null && !opinion.isEmpty()) content.append("。意见: ").append(opinion);
content.append("");
BizMessage msg = new BizMessage();
msg.setReceiverUserId(execUserId);
msg.setMsgType(TYPE_TODO); // 待办: 执行方需要整改后重新提交
msg.setTitle("会议监管退回: " + name);
msg.setContent(content.toString());
msg.setBizType(BIZ_MEETING);
msg.setBizId(meetingId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] meetingSupervisionRejected 已发 uid={} meetingId={}", execUserId, meetingId);
}
/** /**
* #6 劳务协议待签 → 通知参会人协议已生成, 请手写签字. * #6 劳务协议待签 → 通知参会人协议已生成, 请手写签字.
* *
@@ -225,6 +259,41 @@ public class BizNotifyService
log.info("[notify] agreementAwaitingSign 已发 uid={} attendeeId={}", userId, attendeeId); log.info("[notify] agreementAwaitingSign 已发 uid={} attendeeId={}", userId, attendeeId);
} }
/**
* #7 推送电子签 → 通知参会人劳务协议待签署 (带签署链接).
*
* <p>调用方: {@link com.ruoyi.business.service.impl.BizMeetingAttendeeServiceImpl#pushEsign},
* 与短信同批推送, 站内信附带签署链接 (与短信同一链接).
*
* @param userId 被通知人 sys_user.user_id (参会人的 user_id)
* @param attendeeId biz_meeting_attendee.id (用于 bizId 跳转 + 拼链接)
* @param meetingId biz_meeting.meeting_id (会议 ID, 用于 title/兜底)
* @param meetingName 会议名 (可空, 兜底)
* @param link 签署链接 (可空, 空则不展示)
*/
public void esignPushed(Long userId, Long attendeeId, Long meetingId, String meetingName, String link)
{
if (userId == null) {
log.warn("[notify] esignPushed: userId 为空, 跳过 (attendeeId={})", attendeeId);
return;
}
String name = meetingName != null ? meetingName : ("会议 #" + meetingId);
StringBuilder content = new StringBuilder("会议【").append(name).append("】的劳务协议已生成, 请点击链接签署: ");
if (link != null && !link.isEmpty()) {
content.append(link);
}
BizMessage msg = new BizMessage();
msg.setReceiverUserId(userId);
msg.setMsgType(TYPE_TODO); // 待办: 医生需要签署
msg.setTitle("劳务协议待签署: " + name);
msg.setContent(content.toString());
msg.setBizType(BIZ_AGREEMENT);
msg.setBizId(attendeeId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] esignPushed 已发 uid={} attendeeId={}", userId, attendeeId);
}
/** /**
* #3 项目分配执行方 → 通知被分配的 executor (待办: 去承接). * #3 项目分配执行方 → 通知被分配的 executor (待办: 去承接).
* *
@@ -264,4 +333,46 @@ public class BizNotifyService
bizMessageService.insert(msg); bizMessageService.insert(msg);
log.info("[notify] projectAssignedToExecutor 已发 uid={} projectId={}", execUserId, projectId); log.info("[notify] projectAssignedToExecutor 已发 uid={} projectId={}", execUserId, projectId);
} }
/**
* #3b 项目分配监察员 → 通知被分配的 sponsor/监察员 (待办: 去查看项目).
*
* <p>调用方: {@link com.ruoyi.business.controller.BizProjectController#sponsorAssign} /
* {@code sponsorAssignBatch}, 在 biz_project_sponsor_assign 写入后, 对"新增"或"内容变化"的
* 监察员逐条调本方法. 去重由调用方保证 (旧数据差集 + assignDesc/assignPoints 未变跳过), 本方法不再判断.
*
* @param monitorUserId 被分配的监察员 sys_user.user_id (nullable, 跳过)
* @param projectId biz_project.project_id (Long, 用于 bizId; sponsor_assign 表里是 String, 调用方负责转)
* @param projectName 项目名 (可空, 兜底)
* @param assignDesc 分配说明 (可空)
* @param assignPoints 分配积分 (可空)
*/
public void projectAssignedToSponsor(Long monitorUserId, Long projectId, String projectName,
String assignDesc, String assignPoints)
{
if (monitorUserId == null) {
log.warn("[notify] projectAssignedToSponsor: monitorUserId 为空, 跳过 (projectId={})", projectId);
return;
}
String name = projectName != null ? projectName : ("项目 #" + projectId);
StringBuilder content = new StringBuilder("您被分配为【").append(name).append("】的监察员");
if (assignDesc != null && !assignDesc.trim().isEmpty()) {
content.append(",说明: ").append(assignDesc.trim());
}
if (assignPoints != null && !assignPoints.trim().isEmpty()) {
content.append(",积分: ").append(assignPoints.trim());
}
content.append("。请登录系统查看。");
BizMessage msg = new BizMessage();
msg.setReceiverUserId(monitorUserId);
msg.setMsgType(TYPE_TODO);
msg.setTitle("项目监察分配: " + name);
msg.setContent(content.toString());
msg.setBizType(BIZ_PROJECT);
msg.setBizId(projectId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId);
}
} }
@@ -30,10 +30,11 @@ public class OssConfMeta
{ {
throw new IllegalStateException("OSS 未配置 (application.yml 缺 ruoyi.oss.*)"); throw new IllegalStateException("OSS 未配置 (application.yml 缺 ruoyi.oss.*)");
} }
this.endpoint = stripScheme(p.getEndpoint());
this.bucket = p.getBucket(); this.bucket = p.getBucket();
this.accessKeyId = p.getAccessKeyId(); this.accessKeyId = p.getAccessKeyId();
this.accessKeySecret = p.getAccessKeySecret(); this.accessKeySecret = p.getAccessKeySecret();
// endpoint: 剥协议头 + 剥 bucket 前缀 → 纯 OSS endpoint (OSSClient 构造/URL 拼接都要求裸 endpoint, 不带 bucket)
this.endpoint = stripBucketPrefix(stripScheme(p.getEndpoint()), this.bucket);
} }
/** /**
@@ -50,6 +51,16 @@ public class OssConfMeta
return slash >= 0 ? s.substring(0, slash) : s; return slash >= 0 ? s.substring(0, slash) : s;
} }
/**
* 剥 bucket 前缀, 得到纯 OSS endpoint
* 例: hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com + bucket=hwtossbamlorgcn → oss-cn-beijing.aliyuncs.com
*/
private static String stripBucketPrefix(String endpoint, String bucket)
{
if (endpoint == null || bucket == null) return endpoint;
return endpoint.startsWith(bucket + ".") ? endpoint.substring(bucket.length() + 1) : endpoint;
}
public String getEndpoint() { return endpoint; } public String getEndpoint() { return endpoint; }
public String getBucket() { return bucket; } public String getBucket() { return bucket; }
public String getAccessKeyId() { return accessKeyId; } public String getAccessKeyId() { return accessKeyId; }
@@ -0,0 +1,129 @@
package com.ruoyi.business.scheduler;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.ExecutorService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import lombok.extern.slf4j.Slf4j;
/**
* 会议费用汇总调度器 (每分钟一次, 多线程无锁).
* <p>
* 两态设计 (不用抢占锁): 会议 fee_calc_status (0未汇总/1已汇总) + 材料 fee_status (0未计算/1已计算).
* <pre>
* 每分钟: 查 fee_calc_status=0 的会议
* → 任一材料 fee_status=0 (发票还没 OCR 完) → 跳过, 等下轮
* → 全部 fee_status=1 → SUM 汇总 labor_fee/meeting_fee/total_fee → fee_calc_status=1
* </pre>
* 为什么不需要锁: 汇总前已检查"材料全算完", 天然防半成品; 汇总纯 SUM 幂等, 并发重复无副作用;
* 置 1 后不再被扫到, 天然去重. 重算 = 只对 material + attendee 重新求和, 不碰 OCR.
*/
@Slf4j
@Component
public class FeeCalcScheduler
{
@Autowired
private BizMeetingMapper meetingMapper;
@Autowired
private BizMeetingMaterialMapper materialMapper;
@Autowired
private BizMeetingAttendeeMapper attendeeMapper;
@Autowired
@Qualifier("feeCalcExecutor")
private ExecutorService feeCalcExecutor;
/**
* 每分钟: 扫描 fee_calc_status=0 的会议, 并行汇总.
*/
@Scheduled(fixedRate = 60_000, initialDelay = 60_000)
public void calcFees()
{
try
{
List<Long> ids = meetingMapper.selectPendingFeeCalcIds();
if (ids == null || ids.isEmpty())
{
return;
}
log.info("[FeeCalcScheduler] 待汇总会议 {} 个", ids.size());
for (Long meetingId : ids)
{
if (meetingId == null) continue;
feeCalcExecutor.submit(() -> processMeeting(meetingId));
}
}
catch (Exception e)
{
log.warn("[FeeCalcScheduler] 扫描异常 (跳过, 下分钟再试)", e);
}
}
private void processMeeting(Long meetingId)
{
try
{
List<BizMeetingMaterial> mats = materialMapper.selectByMeetingId(meetingId);
// 任一材料 fee_status=0 (发票待 OCR) → 跳过
if (mats != null)
{
for (BizMeetingMaterial m : mats)
{
if (m.getFeeStatus() != null && m.getFeeStatus() == 0)
{
return;
}
}
}
BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId);
if (laborFee == null) laborFee = BigDecimal.ZERO;
BigDecimal meetingFee = computeMeetingFee(mats);
BigDecimal totalFee = laborFee.add(meetingFee);
meetingMapper.updateFeeSummary(meetingId, laborFee, meetingFee, totalFee);
log.info("[FeeCalcScheduler] 汇总完成 meetingId={} labor={} meeting={} total={}",
meetingId, laborFee, meetingFee, totalFee);
}
catch (Exception e)
{
log.warn("[FeeCalcScheduler] 汇总失败 meetingId={} err={}", meetingId, e.getMessage(), e);
}
}
/**
* 会务费口径: 有总发票 (M_INVOICE 金额>0) 则只用总发票; 否则各 SUB 子类 (M_ 开头) 金额之和,
* 排除 M_INVOICE / M_SETTLEMENT (两者是汇总单据, 非子类发票).
*/
private BigDecimal computeMeetingFee(List<BizMeetingMaterial> mats)
{
if (mats == null || mats.isEmpty()) return BigDecimal.ZERO;
BigDecimal main = null;
for (BizMeetingMaterial m : mats)
{
if ("M_INVOICE".equals(m.getSubType()))
{
main = m.getAmount();
break;
}
}
if (main != null && main.compareTo(BigDecimal.ZERO) > 0)
{
return main;
}
BigDecimal sum = BigDecimal.ZERO;
for (BizMeetingMaterial m : mats)
{
if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue;
if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType())) continue;
if (m.getAmount() != null) sum = sum.add(m.getAmount());
}
return sum;
}
}
@@ -0,0 +1,90 @@
package com.ruoyi.business.scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.business.mapper.BizMeetingMapper;
import lombok.extern.slf4j.Slf4j;
/**
* 会议事实/阶段 自动流转调度器 (每分钟一次).
* <p>
* 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实
* (is_executed / is_frozen / material_audit_stage / voucher_audit_stage / 审核时间 …),
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的三类事实落地:
* <pre>
* 1) start_time 到 → is_executed=1 (执行中)
* 2) end_time + submit_deadline_days 到 且 material 未提交 → is_frozen=1 (冻结)
* 3) material+voucher 都通过 且 最晚审核时间过 24h → 待结算 (current_stage 缓存翻 AWAITING_SETTLEMENT)
* </pre>
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围.
* <p>
* 需要启动类加 {@code @EnableScheduling} (RuoyiApplication 已有).
*/
@Slf4j
@Component
public class MeetingStageScheduler
{
@Autowired
private BizMeetingMapper meetingMapper;
/**
* 每分钟: start_time 已过 且 material 未提交 且未执行 → 置执行中.
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markExecuted()
{
try
{
int affected = meetingMapper.markExecuted();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动置执行中: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 置执行中异常 (跳过, 下分钟再试)", e);
}
}
/**
* 每分钟: material 未提交 且 提交截止时间 (end_time + submit_deadline_days) 已过 → 冻结.
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markFrozen()
{
try
{
int affected = meetingMapper.markFrozen();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动冻结: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e);
}
}
/**
* 每分钟: material+voucher 都 APPROVED 且最晚审核时间已过 1 自然日 → 待结算 (24h 慢路径).
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markSettlementReady()
{
try
{
int affected = meetingMapper.markSettlementReady();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动转待结算: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 转待结算异常 (跳过, 下分钟再试)", e);
}
}
}
@@ -11,6 +11,8 @@ import java.util.Map;
public interface BizSignService { public interface BizSignService {
/** 医生填写页 GET /info?attendeeId=X: 返回默认值 (biz_expert 预填) + 已存 attendee 字段 + 选项 */ /** 医生填写页 GET /info?attendeeId=X: 返回默认值 (biz_expert 预填) + 已存 attendee 字段 + 选项 */
Map<String, Object> getSignInfo(Long attendeeId); Map<String, Object> getSignInfo(Long attendeeId);
/** 扫码直登: 只有 meetingId (无 attendeeId) 时, 校验当前用户是否在会议人员列表, 返回 {meetingName, periodNo, totalPeriods, attendeeId} */
Map<String, Object> resolveByMeeting(Long meetingId);
/** 医生填写页 POST /saveProfile: 批量 UPDATE attendee 字段 (不含签名) */ /** 医生填写页 POST /saveProfile: 批量 UPDATE attendee 字段 (不含签名) */
void saveProfile(Long attendeeId, BizMeetingAttendee form); void saveProfile(Long attendeeId, BizMeetingAttendee form);
/** 签署页 GET /contract?attendeeId=X: 渲染完整 HTML (占位符替换 + 身份证附件 + 手写签名) */ /** 签署页 GET /contract?attendeeId=X: 渲染完整 HTML (占位符替换 + 身份证附件 + 手写签名) */
@@ -42,6 +42,8 @@ public interface IBizMeetingAttendeeService {
List<BizMeetingAttendee> selectByUserId(Long userId); List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id); BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId); List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1) */
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
/** /**
* 拿某会议已存在的参会人 userId 列表 (#5 会议邀请 dedup 用). * 拿某会议已存在的参会人 userId 列表 (#5 会议邀请 dedup 用).
* 列表实现层直接返 mapper 结果; 业务方通常用 {@code new HashSet<>(service.selectUserIdsByMeetingId(mid))} 做 contains 判断. * 列表实现层直接返 mapper 结果; 业务方通常用 {@code new HashSet<>(service.selectUserIdsByMeetingId(mid))} 做 contains 判断.
@@ -60,4 +62,26 @@ public interface IBizMeetingAttendeeService {
* @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] } * @return ImportResult { okNum, ngNum, ngList: [{rowNum, message}] }
*/ */
ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception; ImportResult importFromExcel(MultipartFile file, Long meetingId, String operName) throws Exception;
/**
* 推送电子签 (批量/单条通用): 对给定 attendee.id 列表逐个
* 发短信 (电子签模板) + 推站内信 + 置 is_esigned=1.
*
* <p>单条失败 (手机号空/短信异常) 不中断其它人, 返回成功推送条数.
*
* @param attendeeIds biz_meeting_attendee.id 列表
* @return 成功推送 (短信+站内信+标记) 的人数
*/
int pushEsign(List<Long> attendeeIds);
/**
* 邀请参会 (批量/单条通用): 对给定 attendee.id 列表逐个
* 推"会议邀请"站内信 (不发短信) + 置 is_invited=1.
*
* <p>单条失败不中断其它人, 返回成功邀请条数.
*
* @param attendeeIds biz_meeting_attendee.id 列表
* @return 成功邀请 (站内信+标记) 的人数
*/
int invite(List<Long> attendeeIds);
} }
@@ -32,4 +32,18 @@ public interface IBizMeetingMaterialService {
* 不动其他字段, 不抛异常 (失败仅 log). * 不动其他字段, 不抛异常 (失败仅 log).
*/ */
int updateAmount(Long materialId, java.math.BigDecimal amount); int updateAmount(Long materialId, java.math.BigDecimal amount);
/**
* 单条更新 fee_status (0未计算 1已计算).
* OCR 完成后置 1; 材料保存时按需置 0/1.
*/
int updateFeeStatus(Long materialId, Integer feeStatus);
/**
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库.
* 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert).
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL (sponsor 只看这个), 其他 subType 传空.
* 白名单 subType + 会议存在校验 (公开端点防滥用).
*/
void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl);
} }
@@ -22,4 +22,8 @@ public interface IBizMeetingService
void softDeleteCascade(Long meetingId); void softDeleteCascade(Long meetingId);
/** 批量软删 (admin 会议管理页一次选多个) */ /** 批量软删 (admin 会议管理页一次选多个) */
void softDeleteCascadeBatch(Long[] meetingIds); void softDeleteCascadeBatch(Long[] meetingIds);
/** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */
int countByProjectId(Long projectId);
/** 标记会议费用待重算 (人员/材料变化触发, 幂等; 由 FeeCalcScheduler 汇总回写) */
void markFeeCalcPending(Long meetingId);
} }
@@ -0,0 +1,13 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizProjectExecutorAssign;
public interface IBizProjectExecutorAssignService {
/** 执行方单条分配 (策略: 按 project_id 先删后插) */
int insertAssign(BizProjectExecutorAssign entity);
/** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds);
List<BizProjectExecutorAssign> listByProjectId(String projectId);
int deleteByProjectId(String projectId);
}
@@ -14,6 +14,8 @@ public interface IBizProjectService
List<BizProject> selectSponsorList(BizProject entity); List<BizProject> selectSponsorList(BizProject entity);
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */ /** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
List<BizProject> selectExecutorList(BizProject entity); List<BizProject> selectExecutorList(BizProject entity);
/** executor 执行人 (SUB 子账号) 专属: 反查 biz_project_executor_assign.staff_user_id, 只看自己被派到的项目 */
List<BizProject> selectExecutorStaffList(BizProject entity);
int insert(BizProject entity); int insert(BizProject entity);
int updateByPrimaryKey(BizProject entity); int updateByPrimaryKey(BizProject entity);
int deleteByPrimaryKey(Long projectId); int deleteByPrimaryKey(Long projectId);
@@ -28,4 +30,19 @@ public interface IBizProjectService
/** 软删除项目 (批量): 逐条 cascade, 失败粒度细 */ /** 软删除项目 (批量): 逐条 cascade, 失败粒度细 */
void softDeleteCascadeBatch(Long[] projectIds); void softDeleteCascadeBatch(Long[] projectIds);
/** 建会限额用: 统计某项目分配给该执行方 (MAIN) 的总场次 biz_project_assign.sessions 之和 */
int countAssignedSessions(Long projectId, Long executorUserId);
/**
* 提交材料/凭证权限用: 判断当前 user 是否该项目的执行方 (MAIN 走 biz_project_assign, SUB 走 biz_project_executor_assign).
* 与会议列表 executor 可见性同源, 替代原来的 biz_meeting_executor (会议级执行人员) 判定.
*/
boolean isExecutorOfProject(Long projectId, Long userId);
/**
* 会议结算后重算项目金额: 按"所有已结算会议"全量 SUM 回写 paid_labor_amount / paid_meeting_amount,
* 并重算 available_amount. 幂等 (每次全量重算), 无累计副作用.
*/
void recomputeSettledAmounts(Long projectId);
} }
@@ -4,8 +4,10 @@ import java.util.List;
import com.ruoyi.business.domain.BizProjectSponsorAssign; import com.ruoyi.business.domain.BizProjectSponsorAssign;
public interface IBizProjectSponsorAssignService { public interface IBizProjectSponsorAssignService {
/** 支持方分配 (策略: 按 project_id 先删后插, 一个项目只分配一个 sponsor) */ /** 支持方单条分配 (策略: 按 project_id 先删后插, 一个项目只分配一个 sponsor) */
int insertAssign(BizProjectSponsorAssign entity); int insertAssign(BizProjectSponsorAssign entity);
/** 支持方多条分配 (一个项目 ↔ N 监察员: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
int assignMonitorsForProject(BizProjectSponsorAssign body, List<Long> monitorUserIds);
List<BizProjectSponsorAssign> listByProjectId(String projectId); List<BizProjectSponsorAssign> listByProjectId(String projectId);
int deleteByProjectId(String projectId); int deleteByProjectId(String projectId);
} }
@@ -1,17 +0,0 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizSupportIntent;
/**
* 支持意向Service接口
*/
public interface IBizSupportIntentService
{
BizSupportIntent getById(String intentId);
List<BizSupportIntent> selectList(BizSupportIntent entity);
int insert(BizSupportIntent entity);
int updateByPrimaryKey(BizSupportIntent entity);
int deleteByPrimaryKey(String intentId);
int deleteByPrimaryKeys(String[] intentId);
}
@@ -9,20 +9,16 @@ import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.font.FontProvider; import com.itextpdf.layout.font.FontProvider;
import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.business.oss.OssUploader;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
/** /**
* HTML 转 PDF 服务 * HTML 转 PDF 服务
* 用 iText 7 html2pdf (HtmlConverter) + 内置宋体 (simsun.ttc) 渲染中文 * 用 iText 7 html2pdf (HtmlConverter) + 内置宋体 (simsun.ttc) 渲染中文
* 输入: HTML 字符串 → 输出: PDF 文件 (存到 ruoyi.profile 目录, 返回 URL) * 输入: HTML 字符串 → 输出: PDF 字节 (上传 OSS, 返回完整 URL)
* 说明: 相比 Flying Saucer (xhtmlrenderer 严格 XML 解析), html2pdf 走 jsoup HTML 解析, * 说明: 相比 Flying Saucer (xhtmlrenderer 严格 XML 解析), html2pdf 走 jsoup HTML 解析,
* 能容忍前端拼出的非 XHTML 内容 (如 <img> 未自闭合), 不会报 SAXParseException。 * 能容忍前端拼出的非 XHTML 内容 (如 <img> 未自闭合), 不会报 SAXParseException。
*/ */
@@ -30,7 +26,7 @@ import java.util.Date;
public class PdfService { public class PdfService {
@Autowired @Autowired
private RuoYiConfig ruoyiConfig; private OssUploader ossUploader;
/** /**
* HTML 字符串 → PDF 字节流 * HTML 字符串 → PDF 字节流
@@ -61,31 +57,17 @@ public class PdfService {
} }
/** /**
* HTML → PDF 文件 (存到本地) * HTML → PDF 文件 (上传 OSS)
* @return 完整 URL (前端可直接打开) * @return 完整 OSS URL (前端可直接打开, 与身份证附件/现场照片等字段一致)
*/ */
public String htmlToPdfFile(String htmlContent, String bizPath) { public String htmlToPdfFile(String htmlContent, String bizPath) {
byte[] pdfBytes = htmlToPdf(htmlContent); byte[] pdfBytes = htmlToPdf(htmlContent);
// 按 RuoYi 风格分目录: profile/labor/{date}/{filename} String filename = System.currentTimeMillis() + "_" + (int) (Math.random() * 1000) + ".pdf";
SimpleDateFormat dateDir = new SimpleDateFormat("yyyy-MM-dd"); // bizPath 直接作为 OSS key 前缀 (例 "labor/123", 去掉首尾斜杠)
String today = dateDir.format(new Date()); String subDir = (bizPath == null || bizPath.trim().isEmpty())
String datePath = (bizPath == null || bizPath.isEmpty() ? "labor" : bizPath) + "/" + today; ? "labor"
String filename = System.currentTimeMillis() + "_" + (int)(Math.random() * 1000) + ".pdf"; : bizPath.replaceAll("^/+|/+$", "");
return ossUploader.upload(pdfBytes, filename, subDir);
String profilePath = ruoyiConfig.getProfile();
File dir = new File(profilePath + File.separator + datePath);
if (!dir.exists()) {
dir.mkdirs();
}
File pdfFile = new File(dir, filename);
try (FileOutputStream fos = new FileOutputStream(pdfFile)) {
fos.write(pdfBytes);
} catch (Exception e) {
throw new RuntimeException("保存 PDF 失败: " + e.getMessage(), e);
}
// 返回 URL 路径 (前端拼 origin)
String url = "/profile/" + datePath + "/" + filename;
return url;
} }
/** /**
@@ -0,0 +1,191 @@
package com.ruoyi.business.service;
import cn.hutool.http.HttpUtil;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.oss.OssUploader;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 会议海报生成服务 (日程海报 → 叠加会议信息 → 上传 OSS).
*
* <p>流程 (前端"生成海报"按钮触发):
* <ol>
* <li>按 {@code schedule_url} 下载源海报, OSS 图片处理参数 width 固定 1200</li>
* <li>Java2D 在海报底部画半透明信息条: 会议名称 / 期数 / 起止时间 / 支持单位</li>
* <li>编码 PNG → {@link OssUploader} 上传 OSS</li>
* <li>回写 {@code biz_meeting.poster_url}, 前端"预览海报"用该 URL 弹 dialog</li>
* </ol>
*/
@Slf4j
@Service
public class PosterService {
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private OssUploader ossUploader;
/**
* 生成海报并回写 poster_url.
*
* @return 生成海报的 OSS URL
*/
public String generatePoster(Long meetingId, boolean addText, String textColor) {
BizMeeting m = bizMeetingService.getById(meetingId);
if (m == null) throw new RuntimeException("会议不存在");
if (m.getScheduleUrl() == null || m.getScheduleUrl().trim().isEmpty()) {
throw new RuntimeException("请先上传日程海报");
}
// 1. 下载源海报 (OSS 图片处理: width=1200)
byte[] imgBytes = download(m.getScheduleUrl().trim(), 1200);
// 2. 解析图片
BufferedImage img;
try {
img = ImageIO.read(new ByteArrayInputStream(imgBytes));
} catch (Exception e) {
throw new RuntimeException("海报图片解析失败: " + e.getMessage(), e);
}
if (img == null) throw new RuntimeException("海报图片解析失败 (不支持的格式)");
// 3. (可选) 画会议信息 (addText=false 时只输出处理后的背景海报)
if (addText) {
drawInfo(img, m, textColor);
}
// 4. 编码 PNG
byte[] out;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ImageIO.write(img, "png", baos);
out = baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("海报编码失败: " + e.getMessage(), e);
}
// 5. 上传 OSS
String url = ossUploader.upload(out, "poster.png", "meeting/poster");
// 6. 回写 poster_url
BizMeeting upd = new BizMeeting();
upd.setMeetingId(meetingId);
upd.setPosterUrl(url);
bizMeetingService.updateByPrimaryKey(upd);
log.info("[poster] 生成海报完成 meetingId={} url={}", meetingId, url);
return url;
}
/** 下载海报 (OSS 图片处理: 缩宽 + 压质量 + 转 JPEG, 减小体积) */
private byte[] download(String url, int width) {
String sep = url.contains("?") ? "&" : "?";
// resize,w_1200 只缩宽且默认不放大、保持原格式 (PNG 无损, 体积不缩);
// 加 quality,q_80 + format,jpg 强制转 JPEG 并压质量, 显著减小体积 (海报无透明需求)
String dl = url + sep + "x-oss-process=image/resize,w_" + width + "/quality,q_80/format,jpg";
log.info("[poster] 下载海报 width={}: {}", width, dl);
try {
return HttpUtil.downloadBytes(dl);
} catch (Exception e) {
throw new RuntimeException("下载海报失败: " + e.getMessage(), e);
}
}
/** 在海报顶部 (距顶 200px) 居中打印会议信息 (会议名称/期数/起止时间/支持单位) */
private void drawInfo(BufferedImage img, BizMeeting m, String textColor) {
int w = img.getWidth();
Graphics2D g = img.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 字号按宽度 1200 等比缩放 (下载时已 resize 到 1200, 但留个余量)
float scale = w / 1200.0f;
float titleSize = 44 * scale;
float infoSize = 28 * scale;
String title = (m.getMeetingName() == null || m.getMeetingName().trim().isEmpty())
? "会议" : m.getMeetingName().trim();
String period = m.getPeriodNo() != null ? "" + m.getPeriodNo() + "" : null;
String time = buildTime(m.getStartTime(), m.getEndTime());
String org = (m.getOrgName() != null && !m.getOrgName().trim().isEmpty())
? "支持单位:" + m.getOrgName().trim() : null;
List<String> lines = new ArrayList<>();
if (period != null) lines.add(period);
if (time != null) lines.add(time);
if (org != null) lines.add(org);
// 文字距顶端 200px (等比缩放), 每行水平居中, 无背景条
int lineGap = Math.round(6 * scale);
int infoLineH = Math.round(infoSize * 1.5f);
int y = Math.round(200 * scale);
Color fg = parseColor(textColor, Color.WHITE);
g.setColor(fg);
g.setFont(loadFont(titleSize, Font.BOLD));
g.drawString(title, centerX(g, title, w), y + titleSize);
y += titleSize + lineGap;
g.setFont(loadFont(infoSize, Font.PLAIN));
for (String line : lines) {
y += infoLineH + lineGap;
g.drawString(line, centerX(g, line, w), y);
}
} finally {
g.dispose();
}
}
/** 文字水平居中: 用 FontMetrics 测宽算 x */
private int centerX(Graphics2D g, String text, int w) {
int textW = g.getFontMetrics().stringWidth(text);
return (w - textW) / 2;
}
/** 解析前端传来的 hex 文字颜色 (如 #FFFFFF), 非法则回退 fallback */
private Color parseColor(String hex, Color fallback) {
if (hex == null || hex.trim().isEmpty()) return fallback;
try {
return Color.decode(hex.trim());
} catch (Exception e) {
log.warn("[poster] 非法文字颜色 {}: {}", hex, e.getMessage());
return fallback;
}
}
private String buildTime(Date start, Date end) {
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm");
if (start != null && end != null) return fmt.format(start) + " ~ " + fmt.format(end);
if (start != null) return fmt.format(start);
if (end != null) return fmt.format(end);
return null;
}
/** 加载中文字体 (优先项目内 simhei.ttc 黑体; 失败降级系统黑体) */
private Font loadFont(float size, int style) {
try {
Font base = Font.createFont(Font.TRUETYPE_FONT, new File("simhei.ttc"));
return base.deriveFont(style, size);
} catch (Exception e) {
log.warn("[poster] 加载 simhei.ttc 失败, 降级系统字体: {}", e.getMessage());
return new Font("黑体", style, Math.round(size));
}
}
}
@@ -0,0 +1,125 @@
package com.ruoyi.business.service;
import java.util.Date;
import org.springframework.stereotype.Component;
import com.ruoyi.business.domain.BizMeeting;
/**
* 会议阶段推导器 (单一可信源).
* <p>
* 事实与展示分离: {@code biz_meeting} 只存事实 (is_executed/is_settled/is_finished/is_frozen
* + material_audit_stage/voucher_audit_stage + 审核时间 + compliance_approved), 各角色看到的
* 「阶段名称」由本类实时计算.
* <ul>
* <li>{@link #derivePhysicalStage(BizMeeting)}: 10 值物理阶段 (current_stage 缓存 + 列表筛选).</li>
* <li>{@link #deriveDisplay(String, BizMeeting)}: 各角色展示名 (audit_log 4 列 + 前端镜像).</li>
* </ul>
*
* @author guoju
*/
@Component
public class StageDeriver
{
private static final long H24 = 24L * 3600 * 1000;
/** 待结算: 材料+凭证都审核通过 且 最晚通过时间已超 24h (用户拍板口径) */
private boolean settlementReady(BizMeeting m)
{
if (!"APPROVED".equals(m.getVoucherAuditStage())) return false;
Date later = later(m.getMaterialAuditTime(), m.getVoucherAuditTime());
if (later == null) return false;
return System.currentTimeMillis() - later.getTime() >= H24;
}
private Date later(Date a, Date b)
{
if (a == null) return b;
if (b == null) return a;
return a.after(b) ? a : b;
}
private static boolean t(Integer v)
{
return v != null && v == 1;
}
/**
* 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/
* SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导.
* 用于 current_stage 缓存 (列表筛选仍按物理态精确匹配).
*/
public String derivePhysicalStage(BizMeeting m)
{
if (t(m.getIsFrozen())) return "FROZEN";
if (t(m.getIsFinished())) return "FINISHED";
if (t(m.getIsSettled())) return "SETTLED";
String material = m.getMaterialAuditStage();
if ("REJECTED".equals(material)) return "RECTIFYING";
if ("APPROVED".equals(material))
{
return settlementReady(m) ? "AWAITING_SETTLEMENT" : "SUPERVISION_APPROVED";
}
if ("SUBMITTED".equals(material))
{
return t(m.getMaterialComplianceApproved()) ? "AWAITING_SUPERVISION" : "AWAITING_COMPLIANCE";
}
// NOT_SUBMITTED (或 null 兜底)
return t(m.getIsExecuted()) ? "RUNNING" : "NOT_STARTED";
}
/**
* 各角色展示阶段名. role ∈ {executor, sponsor, manager, admin}; doctor/expert 等非流程角色按 admin 中性.
* <p>
* 优先级自上而下命中即返回:
* <pre>
* 冻结 → 完结 → 已结算 → (材料) 审核驳回 → 审核通过 → (材料) APPROVED → SUBMITTED → NOT_SUBMITTED
* </pre>
*/
public String deriveDisplay(String role, BizMeeting m)
{
if (t(m.getIsFrozen())) return "冻结中";
if (t(m.getIsFinished())) return "已完结";
if (t(m.getIsSettled())) return "已结算";
String material = m.getMaterialAuditStage();
// 退回: 执行方看「已退回」, 其他方看「待整改」
if ("REJECTED".equals(material))
{
return "executor".equals(role) ? "已退回" : "待整改";
}
// 材料已支持方通过
if ("APPROVED".equals(material))
{
return settlementReady(m) ? "待结算" : "审核通过";
}
// 材料在审 (SUBMITTED)
if ("SUBMITTED".equals(material))
{
if (t(m.getMaterialComplianceApproved()))
{
// 支持方审中
if ("manager".equals(role)) return "审核通过";
if ("sponsor".equals(role)) return "待审核";
return "待审核"; // executor / admin
}
else
{
// 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
if ("manager".equals(role)) return "待审核";
return "待审核"; // executor / admin
}
}
// 未提交
if (t(m.getIsExecuted()))
{
return "executor".equals(role) ? "执行中" : "已执行未传材料";
}
return "未执行";
}
}
@@ -1,20 +1,36 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.dto.ImportResult; import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo; import com.ruoyi.business.domain.vo.BizMeetingAttendeeImportVo;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; 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.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.business.sms.AliyunSmsSender;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.id.IdGenerator;
import com.ruoyi.common.utils.poi.ExcelUtil; import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.system.mapper.SysUserMapper; import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService; import com.ruoyi.system.service.ISysUserService;
@@ -27,19 +43,46 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
@Autowired @Autowired
private BizMeetingAttendeeMapper mapper; private BizMeetingAttendeeMapper mapper;
@Autowired @Autowired
private BizMeetingMapper meetingMapper;
@Autowired
private BizProjectMapper projectMapper;
@Autowired
private SysUserMapper sysUserMapper; private SysUserMapper sysUserMapper;
@Autowired @Autowired
private ISysUserService sysUserService; private ISysUserService sysUserService;
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private AliyunSmsSender aliyunSmsSender;
@Autowired
private BizNotifyService bizNotifyService;
@Autowired
private IBizMeetingService bizMeetingService;
@Override @Override
public int insert(BizMeetingAttendee entity) { public int insert(BizMeetingAttendee entity) {
if (entity.getId() == null) {
entity.setId(IdGenerator.generateId());
}
return mapper.insert(entity); return mapper.insert(entity);
} }
@Override @Override
public int insertBatch(Long meetingId, Long[] userIds) { public int insertBatch(Long meetingId, Long[] userIds) {
if (userIds == null || userIds.length == 0) return 0; if (userIds == null || userIds.length == 0) return 0;
return mapper.insertBatch(meetingId, userIds, SecurityUtils.getUsername()); String createBy = SecurityUtils.getUsername();
List<BizMeetingAttendee> list = new ArrayList<>(userIds.length);
for (Long uid : userIds) {
BizMeetingAttendee a = new BizMeetingAttendee();
a.setId(IdGenerator.generateId());
a.setMeetingId(meetingId);
a.setUserId(uid);
a.setCreateBy(createBy);
list.add(a);
}
return mapper.insertBatch(list);
} }
/** /**
@@ -96,9 +139,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加"); throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
} }
// 4. 写完整档案行 // 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
body.setUserId(userId); body.setUserId(userId);
body.setCreateBy(SecurityUtils.getUsername()); body.setCreateBy(SecurityUtils.getUsername());
body.setId(IdGenerator.generateId());
mapper.insertWithProfile(body); mapper.insertWithProfile(body);
Long newId = body.getId(); Long newId = body.getId();
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId); log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
@@ -160,11 +204,123 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return mapper.selectUnsignedByUserId(userId); return mapper.selectUnsignedByUserId(userId);
} }
@Override
public List<BizMeetingAttendee> selectInvitedByUserId(Long userId) {
return mapper.selectInvitedByUserId(userId);
}
@Override @Override
public List<Long> selectUserIdsByMeetingId(Long meetingId) { public List<Long> selectUserIdsByMeetingId(Long meetingId) {
return mapper.selectUserIdsByMeetingId(meetingId); return mapper.selectUserIdsByMeetingId(meetingId);
} }
/**
* 劳务报酬个税累进计算 (照搬 hwt BizActGatherService.calcLaborTax).
* 阈值 800/3360/21000/49500 是"实发(税后)"分界点, 对应税前 800/4000/25000/62500.
*
* @param fee 实发金额(税后), ≥ 0
* @return 个税税金, 保留 2 位小数 (HALF_UP)
*/
private BigDecimal calcLaborTax(BigDecimal fee) {
if (fee == null || fee.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
BigDecimal tax;
if (fee.compareTo(new BigDecimal("800")) <= 0) {
tax = BigDecimal.ZERO;
} else if (fee.compareTo(new BigDecimal("3360")) <= 0) {
tax = fee.subtract(new BigDecimal("800"))
.divide(new BigDecimal("4"), 2, RoundingMode.HALF_UP);
} else if (fee.compareTo(new BigDecimal("21000")) <= 0) {
tax = fee.multiply(new BigDecimal("0.16"))
.divide(new BigDecimal("0.84"), 2, RoundingMode.HALF_UP);
} else if (fee.compareTo(new BigDecimal("49500")) <= 0) {
tax = fee.multiply(new BigDecimal("0.24")).subtract(new BigDecimal("2000"))
.divide(new BigDecimal("0.76"), 2, RoundingMode.HALF_UP);
} else {
tax = fee.multiply(new BigDecimal("0.32")).subtract(new BigDecimal("7000"))
.divide(new BigDecimal("0.68"), 2, RoundingMode.HALF_UP);
}
return tax.setScale(2, RoundingMode.HALF_UP);
}
/**
* 由应发金额(feePreTax)反推实发金额(fee), 按劳务报酬个税累进公式分段求解
* (照搬 hwt BizActGatherService.reverseCalcFeeFromFee2).
* 关系式: feePreTax = (fee + tax) * 1.0151, 其中 1.0151 = 1 + 1.51%(增值税及附加).
*
* @param fee2 应发金额(税前)
* @return 反推的实发金额(税后); fee2 为空/负时返回 null
*/
private BigDecimal reverseCalcFeeFromFee2(BigDecimal fee2) {
if (fee2 == null || fee2.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
BigDecimal r10151 = new BigDecimal("1.0151");
// 段1: fee ≤ 800, tax=0, fee2 = fee * 1.0151
BigDecimal fee = fee2.divide(r10151, 6, RoundingMode.HALF_UP);
if (fee.compareTo(new BigDecimal("800")) <= 0) {
return fee;
}
// 段2: 800 < fee ≤ 3360, fee = (4*fee2/1.0151 + 800)/5
fee = fee2.multiply(new BigDecimal("4"))
.divide(r10151, 6, RoundingMode.HALF_UP)
.add(new BigDecimal("800"))
.divide(new BigDecimal("5"), 6, RoundingMode.HALF_UP);
if (fee.compareTo(new BigDecimal("800")) > 0 && fee.compareTo(new BigDecimal("3360")) <= 0) {
return fee;
}
// 段3: 3360 < fee ≤ 21000, fee = fee2*84/(100*1.0151)
fee = fee2.multiply(new BigDecimal("84"))
.divide(new BigDecimal("101.51"), 6, RoundingMode.HALF_UP);
if (fee.compareTo(new BigDecimal("3360")) > 0 && fee.compareTo(new BigDecimal("21000")) <= 0) {
return fee;
}
// 段4: 21000 < fee ≤ 49500, fee = 0.76*fee2/1.0151 + 2000
fee = fee2.multiply(new BigDecimal("0.76"))
.divide(r10151, 6, RoundingMode.HALF_UP)
.add(new BigDecimal("2000"));
if (fee.compareTo(new BigDecimal("21000")) > 0 && fee.compareTo(new BigDecimal("49500")) <= 0) {
return fee;
}
// 段5: fee > 49500, fee = 0.68*fee2/1.0151 + 7000
fee = fee2.multiply(new BigDecimal("0.68"))
.divide(r10151, 6, RoundingMode.HALF_UP)
.add(new BigDecimal("7000"));
return fee;
}
/**
* 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按角色名匹配劳务金额.
* 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role.
*
* @param nodes 已解析的 role_labor JSON (可为 null/非数组)
* @param laborForm 参会人填的角色名 (可为 null/空)
* @return 匹配到的 amount (BigDecimal); 没匹配到或 amount 非法 → null
*/
private BigDecimal findRoleAmount(JsonNode nodes, String laborForm) {
if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) {
return null;
}
String target = laborForm.trim();
for (JsonNode n : nodes) {
if (n == null || n.isNull()) continue;
String role = n.path("role").asText("");
String customName = n.path("customName").asText("");
String label = "其他".equals(role) ? customName.trim() : role;
if (!target.equals(label)) continue;
JsonNode amountNode = n.get("amount");
if (amountNode == null || amountNode.isNull()) return null;
try {
if (amountNode.isNumber()) return amountNode.decimalValue();
return new BigDecimal(amountNode.asText());
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/** /**
* 批量导入参会人 (Excel → biz_meeting_attendee). * 批量导入参会人 (Excel → biz_meeting_attendee).
* *
@@ -184,6 +340,20 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
throw new ServiceException("导入数据不能为空"); throw new ServiceException("导入数据不能为空");
} }
// 项目角色劳务 (只解析一次): 导入行只填角色、没填任何金额时, 按角色带出 amount 补算
JsonNode roleLaborNodes = null;
try {
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
if (meeting != null && meeting.getProjectId() != null) {
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
if (project != null && project.getRoleLabor() != null && !project.getRoleLabor().trim().isEmpty()) {
roleLaborNodes = objectMapper.readTree(project.getRoleLabor());
}
}
} catch (Exception e) {
log.warn("[attendee] 解析项目角色劳务失败, 跳过按角色补算金额 meetingId={}", meetingId, e);
}
ImportResult result = new ImportResult(); ImportResult result = new ImportResult();
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
BizMeetingAttendeeImportVo vo = rows.get(i); BizMeetingAttendeeImportVo vo = rows.get(i);
@@ -195,7 +365,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
continue; continue;
} }
String phone = vo.getPhone().trim(); String phone = vo.getPhone().trim();
if (!phone.matches("^1[3-9]\\d{9}$")) { if (!phone.matches("^1\\d{10}$")) {
result.fail(rowNo, "手机号格式不正确: " + phone); result.fail(rowNo, "手机号格式不正确: " + phone);
continue; continue;
} }
@@ -212,11 +382,43 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
body.setBankName(vo.getBankName()); body.setBankName(vo.getBankName());
body.setBankCard(vo.getBankCard()); body.setBankCard(vo.getBankCard());
body.setBankBranch(vo.getBankBranch()); body.setBankBranch(vo.getBankBranch());
body.setAccountName(vo.getAccountName());
body.setBankRegion(vo.getBankRegion());
body.setBankAddress(vo.getBankAddress());
body.setIdCardAttachments(vo.getIdCardAttachments());
body.setLaborForm(vo.getLaborForm()); body.setLaborForm(vo.getLaborForm());
body.setFeePreTax(vo.getFeePreTax()); // 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写
body.setTax(vo.getTax()); BigDecimal fee = vo.getFee();
body.setVatAndSurcharge(vo.getVatAndSurcharge()); BigDecimal tax = vo.getTax();
body.setFee(vo.getFee()); BigDecimal vat = vo.getVatAndSurcharge();
BigDecimal feePreTax = vo.getFeePreTax();
if (fee != null) {
// 用户填了实发金额 → 正向: fee → tax → vat → feePreTax
if (tax == null) tax = calcLaborTax(fee);
if (vat == null) vat = fee.add(tax).multiply(new BigDecimal("0.0151")).setScale(2, RoundingMode.HALF_UP);
if (feePreTax == null) feePreTax = fee.add(tax).add(vat).setScale(2, RoundingMode.HALF_UP);
} else if (feePreTax != null) {
// 用户只填了应发金额 → 反推实发, 再正向补 tax/vat (保留用户原填 feePreTax)
BigDecimal feeBd = reverseCalcFeeFromFee2(feePreTax);
if (feeBd != null) {
fee = feeBd;
if (tax == null) tax = calcLaborTax(fee);
if (vat == null) vat = fee.add(tax).multiply(new BigDecimal("0.0151")).setScale(2, RoundingMode.HALF_UP);
}
} else if (vo.getLaborForm() != null && !vo.getLaborForm().trim().isEmpty() && roleLaborNodes != null) {
// 什么金额都没填, 只填了角色 → 按项目角色劳务 amount 带出实发, 再正向补 tax/vat/feePreTax
BigDecimal amount = findRoleAmount(roleLaborNodes, vo.getLaborForm());
if (amount != null) {
fee = amount;
tax = calcLaborTax(fee);
vat = fee.add(tax).multiply(new BigDecimal("0.0151")).setScale(2, RoundingMode.HALF_UP);
feePreTax = fee.add(tax).add(vat).setScale(2, RoundingMode.HALF_UP);
}
}
body.setFeePreTax(feePreTax);
body.setTax(tax);
body.setVatAndSurcharge(vat);
body.setFee(fee);
body.setSummary(vo.getSummary()); body.setSummary(vo.getSummary());
insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch
@@ -231,4 +433,78 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return result; return result;
} }
@Override
public int pushEsign(List<Long> attendeeIds) {
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
int sent = 0;
Map<Long, BizMeeting> meetingCache = new HashMap<>();
for (Long id : attendeeIds) {
if (id == null) continue;
try {
BizMeetingAttendee a = mapper.selectById(id);
if (a == null) continue;
Long mid = a.getMeetingId();
BizMeeting m = null;
if (mid != null) {
m = meetingCache.get(mid);
if (m == null) {
m = bizMeetingService.getById(mid);
meetingCache.put(mid, m);
}
}
String meetingName = m != null ? m.getMeetingName() : null;
Date startTime = m != null ? m.getStartTime() : null;
// 1. 短信 (电子签模板: name=参会人姓名, date=会议日期, link=签署链接)
String phone = a.getPhone();
if (phone != null && !phone.trim().isEmpty()) {
aliyunSmsSender.sendEsign(phone.trim(), a.getName(), startTime, aliyunSmsSender.esignLink(id));
}
// 2. 站内信 (劳务协议待签署 + 签署链接, 与短信同一链接)
bizNotifyService.esignPushed(a.getUserId(), id, mid, meetingName, aliyunSmsSender.esignLink(id));
// 3. 标记已推送电子签
mapper.markEsignedById(id);
sent++;
} catch (Exception e) {
log.warn("[attendee] 推送电子签失败 attendeeId={}: {}", id, e.getMessage());
}
}
log.info("[attendee] 推送电子签完成 共{}条, 成功{}", attendeeIds.size(), sent);
return sent;
}
@Override
public int invite(List<Long> attendeeIds) {
if (attendeeIds == null || attendeeIds.isEmpty()) return 0;
int sent = 0;
Map<Long, BizMeeting> meetingCache = new HashMap<>();
for (Long id : attendeeIds) {
if (id == null) continue;
try {
BizMeetingAttendee a = mapper.selectById(id);
if (a == null) continue;
Long mid = a.getMeetingId();
BizMeeting m = null;
if (mid != null) {
m = meetingCache.get(mid);
if (m == null) {
m = bizMeetingService.getById(mid);
meetingCache.put(mid, m);
}
}
String meetingName = m != null ? m.getMeetingName() : null;
Date startTime = m != null ? m.getStartTime() : null;
// 推"会议邀请"站内信 (不发短信) + 置 is_invited=1
bizNotifyService.meetingInvitation(a.getUserId(), mid, meetingName, startTime);
mapper.markInvitedById(id);
sent++;
} catch (Exception e) {
log.warn("[attendee] 邀请参会失败 attendeeId={}: {}", id, e.getMessage());
}
}
log.info("[attendee] 邀请参会完成 共{}条, 成功{}", attendeeIds.size(), sent);
return sent;
}
} }
@@ -1,20 +1,44 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingMaterialService;
@Service @Service
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService { public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
/** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */
private static final Set<String> NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList(
"L_ENTERPRISE_BENEFIT", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO"));
/** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
"L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK"));
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE);
@Autowired @Autowired
private BizMeetingMaterialMapper bizMeetingMaterialMapper; private BizMeetingMaterialMapper bizMeetingMaterialMapper;
@Autowired
private BizMeetingMapper bizMeetingMapper;
@Override @Override
public BizMeetingMaterial getById(Long id) { public BizMeetingMaterial getById(Long id) {
@@ -34,10 +58,25 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
* 翻译成友好中文提示, 避免暴露 SQL 堆栈. * 翻译成友好中文提示, 避免暴露 SQL 堆栈.
* <p> * <p>
* 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR. * 返回插入后的 list (各元素 id 字段被 useGeneratedKeys 回填), 前端可借此触发 OCR.
* <p>
* <b>金额保留</b>: 前端全删全插只传 ossUrl 不传 amount. 为避免"重新上传一张发票导致其余未变发票金额被清零",
* 先快照旧材料, 对 (subType + ossUrl) 未变的材料回填旧 amount 并置 fee_status=1 (无需重算);
* 新增/替换的会 OCR 发票置 fee_status=0 (等 OCR 回写金额后置 1).
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list) { public List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list) {
// 快照旧材料: subType -> 旧材料 (用于回填 amount + 判未变)
List<BizMeetingMaterial> oldList = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
Map<String, BizMeetingMaterial> oldBySubType = new HashMap<>();
if (oldList != null) {
for (BizMeetingMaterial o : oldList) {
if (o.getSubType() != null) {
oldBySubType.put(o.getSubType(), o);
}
}
}
bizMeetingMaterialMapper.deleteByMeetingId(meetingId); bizMeetingMaterialMapper.deleteByMeetingId(meetingId);
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return list; return list;
@@ -46,6 +85,18 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
for (BizMeetingMaterial m : list) { for (BizMeetingMaterial m : list) {
m.setId(null); m.setId(null);
m.setMeetingId(meetingId); m.setMeetingId(meetingId);
BizMeetingMaterial old = oldBySubType.get(m.getSubType());
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
if (unchanged) {
// 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版, 前端全删全插不传 extraOssUrl)
m.setAmount(old.getAmount());
m.setFeeStatus(1);
m.setExtraOssUrl(old.getExtraOssUrl());
} else {
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算
m.setAmount(null);
m.setFeeStatus(needsOcr(m) ? 0 : 1);
}
} }
try { try {
bizMeetingMaterialMapper.insertBatch(list); bizMeetingMaterialMapper.insertBatch(list);
@@ -56,8 +107,68 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
} }
@Override @Override
public int updateAmount(Long materialId, java.math.BigDecimal amount) { public int updateAmount(Long materialId, BigDecimal amount) {
if (materialId == null || amount == null) return 0; if (materialId == null || amount == null) return 0;
return bizMeetingMaterialMapper.updateAmount(materialId, amount); return bizMeetingMaterialMapper.updateAmount(materialId, amount);
} }
@Override
public int updateFeeStatus(Long materialId, Integer feeStatus) {
if (materialId == null || feeStatus == null) return 0;
return bizMeetingMaterialMapper.updateFeeStatus(materialId, feeStatus);
}
/**
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行.
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不 markFeeCalcPending.
* extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl) {
if (meetingId == null) throw new ServiceException("缺少 meetingId");
if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型");
if (ossUrl == null || ossUrl.isEmpty()) throw new ServiceException("缺少照片 URL");
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
// 找该 subType 现有行 (同会议同 subType 唯一)
BizMeetingMaterial row = null;
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
if (existing != null) {
for (BizMeetingMaterial m : existing) {
if (subType.equals(m.getSubType())) { row = m; break; }
}
}
if (row != null) {
// 更新 ossUrl (updateByPrimaryKey 只改非空字段, 不动 amount/fee_status)
BizMeetingMaterial upd = new BizMeetingMaterial();
upd.setId(row.getId());
upd.setOssUrl(ossUrl);
upd.setExtraOssUrl(extraOssUrl);
bizMeetingMaterialMapper.updateByPrimaryKey(upd);
} else {
BizMeetingMaterial ins = new BizMeetingMaterial();
ins.setMeetingId(meetingId);
ins.setMaterialType(subType.startsWith("L_") ? "LABOR" : "SERVICE");
ins.setSubType(subType);
ins.setOssUrl(ossUrl);
ins.setExtraOssUrl(extraOssUrl);
ins.setCreateTime(new Date());
bizMeetingMaterialMapper.insert(ins);
}
}
/**
* 是否会被前端提交 OCR (与 MeetingDetail.saveMaterials 门控一致):
* 文件可识别 (jpg/jpeg/png/pdf) 且 不在非发票 subType 集合里.
*/
private boolean needsOcr(BizMeetingMaterial m) {
if (m == null || m.getOssUrl() == null || m.getOssUrl().isEmpty()) return false;
if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false;
return RECOGNIZABLE.matcher(m.getOssUrl()).find();
}
} }
@@ -12,6 +12,7 @@ import com.ruoyi.business.mapper.BizMeetingExecutorMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.mapper.BizMeetingAuditLogMapper; import com.ruoyi.business.mapper.BizMeetingAuditLogMapper;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.common.enums.BizMeetingStageEnum;
import com.ruoyi.common.utils.id.IdGenerator; import com.ruoyi.common.utils.id.IdGenerator;
@Service @Service
@@ -43,6 +44,18 @@ public class BizMeetingServiceImpl implements IBizMeetingService
if (entity.getBusinessId() == null || entity.getBusinessId().isEmpty()) { if (entity.getBusinessId() == null || entity.getBusinessId().isEmpty()) {
entity.setBusinessId(String.valueOf(IdGenerator.generateId())); entity.setBusinessId(String.valueOf(IdGenerator.generateId()));
} }
// 新建会议初始状态: DB 列默认值 '0' 会让前端 stageLabel 显示成 0 而不是 enum 项, 这里显式兜底成 enum.
// current_stage = NOT_STARTED (未执行, 等 scheduler 过 startTime 置 is_executed 转 RUNNING)
// 材料/凭证审核子状态 = NOT_SUBMITTED (未提交, 供执行方 submit-material/submit-voucher 校验)
if (entity.getCurrentStage() == null || entity.getCurrentStage().isEmpty()) {
entity.setCurrentStage(BizMeetingStageEnum.NOT_STARTED.getCode());
}
if (entity.getMaterialAuditStage() == null || entity.getMaterialAuditStage().isEmpty()) {
entity.setMaterialAuditStage("NOT_SUBMITTED");
}
if (entity.getVoucherAuditStage() == null || entity.getVoucherAuditStage().isEmpty()) {
entity.setVoucherAuditStage("NOT_SUBMITTED");
}
return bizMeetingMapper.insert(entity); return bizMeetingMapper.insert(entity);
} }
@Override @Override
@@ -79,4 +92,15 @@ public class BizMeetingServiceImpl implements IBizMeetingService
if (id != null) softDeleteCascade(id); if (id != null) softDeleteCascade(id);
} }
} }
@Override
public int countByProjectId(Long projectId)
{ return bizMeetingMapper.countByProjectId(projectId); }
@Override
public void markFeeCalcPending(Long meetingId) {
if (meetingId != null) {
bizMeetingMapper.markFeeCalcPending(meetingId);
}
}
} }
@@ -0,0 +1,57 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizProjectExecutorAssign;
import com.ruoyi.business.mapper.BizProjectExecutorAssignMapper;
import com.ruoyi.business.service.IBizProjectExecutorAssignService;
@Service
public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorAssignService {
@Autowired
private BizProjectExecutorAssignMapper mapper;
@Override
public int insertAssign(BizProjectExecutorAssign entity) {
// 执行方分配策略: 先按 project_id 删, 再插
mapper.deleteByProjectId(entity.getProjectId());
return mapper.insertAssign(entity);
}
/**
* 多执行人分配 (一个项目 ↔ N 执行人).
* <p>
* 策略: 先按 project_id 物理删除旧分配, 再逐个插入.
* 注意 ⚠️ 不能直接复用 insertAssign() — insertAssign() 内部会 deleteByProjectId, 循环里第二次 delete 会把刚 insert 的清掉.
*/
@Override
public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> staffUserIds) {
if (body == null || body.getProjectId() == null) return 0;
if (staffUserIds == null || staffUserIds.isEmpty()) return 0;
// 一次性清旧, 不在循环里清
mapper.deleteByProjectId(body.getProjectId());
int inserted = 0;
for (Long sid : staffUserIds) {
BizProjectExecutorAssign item = new BizProjectExecutorAssign();
item.setProjectId(body.getProjectId());
item.setStaffUserId(sid);
item.setAssignDesc(body.getAssignDesc());
item.setAssignPoints(body.getAssignPoints());
item.setCreateBy(body.getCreateBy());
item.setExecutorUserId(body.getExecutorUserId());
inserted += mapper.insertAssign(item);
}
return inserted;
}
@Override
public List<BizProjectExecutorAssign> listByProjectId(String projectId) {
return mapper.selectByProjectId(projectId);
}
@Override
public int deleteByProjectId(String projectId) {
return mapper.deleteByProjectId(projectId);
}
}
@@ -9,6 +9,7 @@ import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.mapper.BizProjectPlanMapper; import com.ruoyi.business.mapper.BizProjectPlanMapper;
import com.ruoyi.business.mapper.BizProjectAssignMapper; import com.ruoyi.business.mapper.BizProjectAssignMapper;
import com.ruoyi.business.mapper.BizProjectSponsorAssignMapper; import com.ruoyi.business.mapper.BizProjectSponsorAssignMapper;
import com.ruoyi.business.mapper.BizProjectExecutorAssignMapper;
import com.ruoyi.business.mapper.BizProjectRatingMapper; import com.ruoyi.business.mapper.BizProjectRatingMapper;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.service.IBizProjectService; import com.ruoyi.business.service.IBizProjectService;
@@ -27,6 +28,8 @@ public class BizProjectServiceImpl implements IBizProjectService
@Autowired @Autowired
private BizProjectSponsorAssignMapper bizProjectSponsorAssignMapper; private BizProjectSponsorAssignMapper bizProjectSponsorAssignMapper;
@Autowired @Autowired
private BizProjectExecutorAssignMapper bizProjectExecutorAssignMapper;
@Autowired
private BizProjectRatingMapper bizProjectRatingMapper; private BizProjectRatingMapper bizProjectRatingMapper;
@Autowired @Autowired
private BizMeetingMapper bizMeetingMapper; private BizMeetingMapper bizMeetingMapper;
@@ -46,6 +49,9 @@ public class BizProjectServiceImpl implements IBizProjectService
public List<BizProject> selectExecutorList(BizProject entity) public List<BizProject> selectExecutorList(BizProject entity)
{ return bizProjectMapper.selectExecutorList(entity); } { return bizProjectMapper.selectExecutorList(entity); }
@Override @Override
public List<BizProject> selectExecutorStaffList(BizProject entity)
{ return bizProjectMapper.selectExecutorStaffList(entity); }
@Override
// 注: biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入; // 注: biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入;
// 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全. // 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全.
// create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制) // create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制)
@@ -97,6 +103,8 @@ public class BizProjectServiceImpl implements IBizProjectService
bizProjectAssignMapper.softDeleteByProjectId(projectId); bizProjectAssignMapper.softDeleteByProjectId(projectId);
// 5) sponsor assign (String) // 5) sponsor assign (String)
bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId)); bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
// 5.5) executor assign (String)
bizProjectExecutorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
// 6) rating // 6) rating
bizProjectRatingMapper.softDeleteByProjectId(projectId); bizProjectRatingMapper.softDeleteByProjectId(projectId);
// 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch // 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch
@@ -115,4 +123,19 @@ public class BizProjectServiceImpl implements IBizProjectService
if (id != null) softDeleteCascade(id); if (id != null) softDeleteCascade(id);
} }
} }
@Override
public int countAssignedSessions(Long projectId, Long executorUserId)
{ return bizProjectMapper.countAssignedSessions(projectId, executorUserId); }
@Override
public boolean isExecutorOfProject(Long projectId, Long userId)
{ return bizProjectMapper.countExecutorOfProject(projectId, userId) > 0; }
/** 会议结算后重算项目金额 (全量 SUM 已结算会议, 幂等) */
@Override
public void recomputeSettledAmounts(Long projectId) {
if (projectId == null) return;
bizProjectMapper.recomputeSettledAmounts(projectId);
}
} }
@@ -19,6 +19,32 @@ public class BizProjectSponsorAssignServiceImpl implements IBizProjectSponsorAss
return mapper.insertAssign(entity); return mapper.insertAssign(entity);
} }
/**
* 多监察员分配 (一个项目 ↔ N 监察员).
* <p>
* 策略: 先按 project_id 物理删除旧分配, 再逐个插入 (事务内由 Service 默认单 insert 即可, 失败单条不影响其它).
* 注意 ⚠️ 不能直接复用 insertAssign() — insertAssign() 内部会 deleteByProjectId, 循环里第二次 delete 会把刚 insert 的清掉, 提交后只剩最后 1 条.
*/
@Override
public int assignMonitorsForProject(BizProjectSponsorAssign body, java.util.List<Long> monitorUserIds) {
if (body == null || body.getProjectId() == null) return 0;
if (monitorUserIds == null || monitorUserIds.isEmpty()) return 0;
// 一次性清旧, 不在循环里清
mapper.deleteByProjectId(body.getProjectId());
int inserted = 0;
for (Long mid : monitorUserIds) {
BizProjectSponsorAssign item = new BizProjectSponsorAssign();
item.setProjectId(body.getProjectId());
item.setMonitorUserId(mid);
item.setAssignDesc(body.getAssignDesc());
item.setAssignPoints(body.getAssignPoints());
item.setCreateBy(body.getCreateBy());
item.setSponsorUserId(body.getSponsorUserId());
inserted += mapper.insertAssign(item);
}
return inserted;
}
@Override @Override
public List<BizProjectSponsorAssign> listByProjectId(String projectId) { public List<BizProjectSponsorAssign> listByProjectId(String projectId) {
return mapper.selectByProjectId(projectId); return mapper.selectByProjectId(projectId);
@@ -1,6 +1,11 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -11,9 +16,11 @@ import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizLaborProtocolTemplate; import com.ruoyi.business.domain.BizLaborProtocolTemplate;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee; import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.mapper.BizExpertMapper; import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper; import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper;
import com.ruoyi.business.service.BizSignService; import com.ruoyi.business.service.BizSignService;
import com.ruoyi.business.service.IBizMeetingAttendeeService; import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.PdfService; import com.ruoyi.business.service.PdfService;
@@ -33,6 +40,8 @@ public class BizSignServiceImpl implements BizSignService {
private BizLaborProtocolTemplateMapper templateMapper; private BizLaborProtocolTemplateMapper templateMapper;
@Autowired @Autowired
private PdfService pdfService; private PdfService pdfService;
@Autowired
private BizProjectMapper projectMapper;
@Override @Override
public Map<String, Object> getSignInfo(Long attendeeId) { public Map<String, Object> getSignInfo(Long attendeeId) {
@@ -79,10 +88,37 @@ public class BizSignServiceImpl implements BizSignService {
current.put("tax", attendee.getTax()); current.put("tax", attendee.getTax());
current.put("fee", attendee.getFee()); current.put("fee", attendee.getFee());
List<Map<String, String>> laborFormOptions = Arrays.asList( // 会议信息 (会议名称 + 期数), 用于签署页大标题; 也用于取 projectId 拉项目角色
map("讲课", "讲课"), map("讨论", "讨论"), BizMeeting meeting = meetingMapper.selectByPrimaryKey(attendee.getMeetingId());
map("主持", "主持"), map("主席", "主席"),
map("__other__", "其他")); // 劳务形式选项: 从项目角色 (biz_project.role_labor) 读, 末尾追加"其他"逃生口
List<Map<String, String>> laborFormOptions = new ArrayList<>();
Long projectId = meeting != null ? meeting.getProjectId() : null;
if (projectId != null) {
BizProject project = projectMapper.selectByPrimaryKey(projectId);
String roleLabor = project != null ? project.getRoleLabor() : null;
if (roleLabor != null && !roleLabor.trim().isEmpty()) {
try {
JSONArray arr = JSONUtil.parseArray(roleLabor);
for (int i = 0; i < arr.size(); i++) {
JSONObject node = arr.getJSONObject(i);
if (node == null) continue;
String role = node.getStr("role");
if (role == null || role.trim().isEmpty()) continue;
// role === '其他' → label = customName; 否则 label = role
String customName = node.getStr("customName");
String label = "其他".equals(role)
? (customName != null && !customName.trim().isEmpty() ? customName.trim() : "其他")
: role;
laborFormOptions.add(map(label, label));
}
} catch (Exception e) {
// role_labor JSON 解析失败 → 空列表兜底 (仍保留"其他"逃生口)
}
}
}
laborFormOptions.add(map("__other__", "其他"));
List<String> titleOptions = Arrays.asList( List<String> titleOptions = Arrays.asList(
"主任医师", "副主任医师", "主治(主管)医师", "医士", "主任医师", "副主任医师", "主治(主管)医师", "医士",
"主任药师", "药师", "药士", "主任药师", "药师", "药士",
@@ -96,6 +132,35 @@ public class BizSignServiceImpl implements BizSignService {
result.put("laborFormOptions", laborFormOptions); result.put("laborFormOptions", laborFormOptions);
result.put("titleOptions", titleOptions); result.put("titleOptions", titleOptions);
result.put("attendeeId", attendeeId); result.put("attendeeId", attendeeId);
result.put("meetingName", meeting != null ? meeting.getMeetingName() : "");
result.put("periodNo", meeting != null ? meeting.getPeriodNo() : null);
result.put("totalPeriods", meeting != null ? meeting.getTotalPeriods() : null);
return result;
}
@Override
public Map<String, Object> resolveByMeeting(Long meetingId) {
// 有 meetingId: 直接按 meetingId 查会议 (名称 + 期数), 再查当前用户是否在会议人员列表里
Long userId = SecurityUtils.getUserId();
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) {
throw new ServiceException("会议不存在");
}
Long attendeeId = null;
if (userId != null) {
// 受邀判定: 只要在 biz_meeting_attendee 人员列表里 (is_deleted=0), 不管有没有点邀请/电子签, 都能签
for (BizMeetingAttendee a : attendeeService.selectByMeetingId(meetingId)) {
if (a != null && userId.equals(a.getUserId())) {
attendeeId = a.getId();
break;
}
}
}
Map<String, Object> result = new HashMap<>();
result.put("meetingName", meeting.getMeetingName());
result.put("periodNo", meeting.getPeriodNo());
result.put("totalPeriods", meeting.getTotalPeriods());
result.put("attendeeId", attendeeId);
return result; return result;
} }
@@ -1,33 +0,0 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizSupportIntent;
import com.ruoyi.business.mapper.BizSupportIntentMapper;
import com.ruoyi.business.service.IBizSupportIntentService;
@Service
public class BizSupportIntentServiceImpl implements IBizSupportIntentService
{
@Autowired
private BizSupportIntentMapper bizSupportIntentMapper;
@Override
public BizSupportIntent getById(String intentId)
{ return bizSupportIntentMapper.selectByPrimaryKey(intentId); }
@Override
public List<BizSupportIntent> selectList(BizSupportIntent entity)
{ return bizSupportIntentMapper.selectList(entity); }
@Override
public int insert(BizSupportIntent entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "intentId"); return bizSupportIntentMapper.insert(entity); }
@Override
public int updateByPrimaryKey(BizSupportIntent entity)
{ return bizSupportIntentMapper.updateByPrimaryKey(entity); }
@Override
public int deleteByPrimaryKey(String intentId)
{ return bizSupportIntentMapper.deleteByPrimaryKey(intentId); }
@Override
public int deleteByPrimaryKeys(String[] intentId)
{ return bizSupportIntentMapper.deleteByPrimaryKeys(intentId); }
}
@@ -134,6 +134,11 @@ public class InvoiceOcrService
e.getMessage() == null ? "OCR 异常" : e.getMessage()); e.getMessage() == null ? "OCR 异常" : e.getMessage());
} }
} }
finally
{
// OCR 处理完毕 (成功/非发票/失败), 该材料金额最终确定 → fee_status=1
materialService.updateFeeStatus(materialId, 1);
}
}); });
out.setSubmitted(true); out.setSubmitted(true);
@@ -293,6 +298,11 @@ public class InvoiceOcrService
e.getMessage() == null ? "OCR 异常" : e.getMessage()); e.getMessage() == null ? "OCR 异常" : e.getMessage());
log.warn("兜底识别失败 invoiceId={} err={}", inv.getId(), e.getMessage()); log.warn("兜底识别失败 invoiceId={} err={}", inv.getId(), e.getMessage());
} }
finally
{
// 兜底 OCR 处理完毕, 金额最终确定 → fee_status=1
materialService.updateFeeStatus(inv.getMaterialId(), 1);
}
} }
// ==================== 工具方法 ==================== // ==================== 工具方法 ====================
@@ -5,6 +5,11 @@ import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.profile.DefaultProfile; import com.aliyuncs.profile.DefaultProfile;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.text.SimpleDateFormat;
import cn.hutool.json.JSONUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -37,6 +42,12 @@ public class AliyunSmsSender {
@Value("${ruoyi.sms.template}") @Value("${ruoyi.sms.template}")
private String template; private String template;
@Value("${ruoyi.sms.esignTemplate}")
private String esignTemplate;
@Value("${ruoyi.sms.esignBaseUrl:https://ringdoctor.com/hg}")
private String esignBaseUrl;
@Value("${ruoyi.sms.regionId:cn-hangzhou}") @Value("${ruoyi.sms.regionId:cn-hangzhou}")
private String regionId; private String regionId;
@@ -63,6 +74,45 @@ public class AliyunSmsSender {
return client; return client;
} }
/**
* 发送电子签短信 (模板占位符 name=姓名, date=日期 MM月dd日, link=签署链接).
* 与验证码模板 (ruoyi.sms.template) 分离, 成功返回 true.
*/
public boolean sendEsign(String phone, String name, Date date, String link) {
try {
SendSmsRequest req = new SendSmsRequest();
req.setPhoneNumbers(phone);
req.setSignName(signName);
req.setTemplateCode(esignTemplate);
Map<String, String> params = new LinkedHashMap<>();
params.put("name", name != null ? name : "");
params.put("date", date != null ? new SimpleDateFormat("MM月dd日").format(date) : "");
params.put("link", link != null ? link : "");
req.setTemplateParam(JSONUtil.toJsonStr(params));
SendSmsResponse resp = getClient().getAcsResponse(req);
if ("OK".equalsIgnoreCase(resp.getCode())) {
log.info("[SMS] 电子签短信发送成功 phone={}, bizId={}", phone, resp.getBizId());
return true;
}
log.error("[SMS] 电子签短信发送失败 phone={}, code={}, msg={}, requestId={}",
phone, resp.getCode(), resp.getMessage(), resp.getRequestId());
return false;
} catch (Exception e) {
log.error("[SMS] 电子签短信异常 phone={}", phone, e);
return false;
}
}
/**
* 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId}
* 例: https://ringdoctor.com/hg/#/doctor/sign-fill?attendeeId=123
* (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式,
* 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId)
*/
public String esignLink(Long attendeeId) {
return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId;
}
/** /**
* 真发送短信验证码, 成功返回 true * 真发送短信验证码, 成功返回 true
*/ */
@@ -39,34 +39,35 @@
<result property="projectName" column="project_name" /> <result property="projectName" column="project_name" />
<result property="projectNo" column="project_no" /> <result property="projectNo" column="project_no" />
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
<result property="isEsigned" column="is_esigned" />
<result property="isInvited" column="is_invited" />
</resultMap> </resultMap>
<insert id="insert" parameterType="BizMeetingAttendee"> <insert id="insert" parameterType="BizMeetingAttendee">
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time) insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time)
values(#{meetingId}, #{userId}, #{createBy}, sysdate()) values(#{id}, #{meetingId}, #{userId}, #{createBy}, sysdate())
</insert> </insert>
<!-- <!--
管理端新增参会人 (MeetingDetail 参会人 CRUD 用): 管理端新增参会人 (MeetingDetail 参会人 CRUD 用):
一次性写入 meeting_id+user_id+档案字段 (name/phone/work_unit/...). 一次性写入 id+meeting_id+user_id+档案字段 (name/phone/work_unit/...).
若档案字段为 NULL 则不写 (COALESCE 在调用方给空字符串兜底). id 由调用方用雪花 ID (IdGenerator) 生成, 不走 DB 自增.
useGeneratedKeys 让调用方能拿到新 attendee.id (用于 #5 触发邀请通知).
--> -->
<insert id="insertWithProfile" parameterType="BizMeetingAttendee" useGeneratedKeys="true" keyProperty="id"> <insert id="insertWithProfile" parameterType="BizMeetingAttendee">
insert into biz_meeting_attendee(meeting_id, user_id, name, phone, work_unit, department, title, insert into biz_meeting_attendee(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, 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, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos,
create_by, create_time) create_by, create_time)
values(#{meetingId}, #{userId}, #{name}, #{phone}, #{workUnit}, #{department}, #{title}, values(#{id}, #{meetingId}, #{userId}, #{name}, #{phone}, #{workUnit}, #{department}, #{title},
#{idCard}, #{bankCard}, #{bankName}, #{bankBranch}, #{bankRegion}, #{bankAddress}, #{accountName}, #{idCard}, #{bankCard}, #{bankName}, #{bankBranch}, #{bankRegion}, #{bankAddress}, #{accountName},
#{idCardAttachments}, #{laborForm}, #{feePreTax}, #{tax}, #{fee}, #{idCardAttachments}, #{laborForm}, #{feePreTax}, #{tax}, #{fee},
#{vatAndSurcharge}, #{summary}, #{onSitePhotos}, #{vatAndSurcharge}, #{summary}, #{onSitePhotos},
#{createBy}, sysdate()) #{createBy}, sysdate())
</insert> </insert>
<!-- 批量插入参会人 (BizMeetingController.add 调用) --> <!-- 批量插入参会人 (BizMeetingController.add/edit 调用), id 由调用方雪花 ID 填好 -->
<insert id="insertBatch"> <insert id="insertBatch">
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time) insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time)
values values
<foreach collection="userIds" item="userId" separator=","> <foreach collection="list" item="a" separator=",">
(#{meetingId}, #{userId}, #{createBy}, sysdate()) (#{a.id}, #{a.meetingId}, #{a.userId}, #{a.createBy}, sysdate())
</foreach> </foreach>
</insert> </insert>
<!-- 医生填写信息保存草稿: 更新所有签字字段 + 劳务信息 (不含签名/PDF) --> <!-- 医生填写信息保存草稿: 更新所有签字字段 + 劳务信息 (不含签名/PDF) -->
@@ -124,6 +125,18 @@
update_time = sysdate() update_time = sysdate()
where id = #{id} where id = #{id}
</update> </update>
<!-- 推送电子签后置 is_esigned=1 -->
<update id="markEsignedById" parameterType="Long">
update biz_meeting_attendee
set is_esigned = 1, update_time = sysdate()
where id = #{id}
</update>
<!-- 邀请参会后置 is_invited=1 -->
<update id="markInvitedById" parameterType="Long">
update biz_meeting_attendee
set is_invited = 1, update_time = sysdate()
where id = #{id}
</update>
<delete id="deleteByMeetingId" parameterType="Long"> <delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_attendee where meeting_id = #{meetingId} delete from biz_meeting_attendee where meeting_id = #{meetingId}
</delete> </delete>
@@ -139,34 +152,51 @@
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId} delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete> </delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long"> <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, create_by, create_time, is_deleted 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
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0 from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
</select> </select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long"> <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 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
from biz_meeting_attendee where user_id = #{userId} and is_deleted = 0 from biz_meeting_attendee where user_id = #{userId} and is_deleted = 0
</select> </select>
<select id="selectById" resultMap="BizMeetingAttendeeResult" parameterType="Long"> <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 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
from biz_meeting_attendee where id = #{id} and is_deleted = 0 from biz_meeting_attendee where id = #{id} and is_deleted = 0
</select> </select>
<!-- <!--
当前用户的"待签署"会议列表 (任一未签: handsign 或 labor_protocol 为 NULL) 当前用户的"待签署协议"列表: 已推送电子签 (is_esigned=1) 且 任一未签 (handsign 或 labor_protocol 为)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台 INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
字段别名 + resultMap 上面的 transient property 接收 字段别名 + resultMap 上面的 transient property 接收
两表都需 is_deleted=0 过滤: 删除会议后, 参会人的待签署列表也不显示 两表都需 is_deleted=0 过滤: 删除会议后, 参会人的待签署列表也不显示
--> -->
<select id="selectUnsignedByUserId" resultType="BizMeetingAttendee" parameterType="Long"> <select id="selectUnsignedByUserId" resultType="BizMeetingAttendee" parameterType="Long">
select a.id, a.meeting_id, a.user_id, a.handsign, a.labor_protocol, a.create_by, a.create_time, select a.id, a.meeting_id, a.user_id, a.handsign, a.labor_protocol, a.create_by, a.create_time,
a.is_esigned as isEsigned,
m.meeting_name as meetingName, m.start_time as startTime, m.meeting_name as meetingName, m.start_time as startTime,
m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo
from biz_meeting_attendee a from biz_meeting_attendee a
inner join biz_meeting m on m.meeting_id = a.meeting_id inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId} where a.user_id = #{userId}
and a.is_deleted = 0 and m.is_deleted = 0 and a.is_deleted = 0 and m.is_deleted = 0
and a.is_esigned = 1
and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '') and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '')
order by m.start_time asc order by m.start_time asc
</select> </select>
<!--
当前用户的"待参加"会议列表: 已邀请参会 (is_invited=1)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
-->
<select id="selectInvitedByUserId" resultType="BizMeetingAttendee" parameterType="Long">
select a.id, a.meeting_id, a.user_id, a.create_by, a.create_time,
m.meeting_name as meetingName, m.start_time as startTime,
m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo
from biz_meeting_attendee a
inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId}
and a.is_deleted = 0 and m.is_deleted = 0
and a.is_invited = 1
order by m.start_time asc
</select>
<!-- <!--
给 BizMeetingController.edit 做差集用: 查该会议已存在的参会人 userId 列表. 给 BizMeetingController.edit 做差集用: 查该会议已存在的参会人 userId 列表.
用于 #5 会议邀请: 仅给"新加入"的 userId 发通知, 已存在的用户不重发. 用于 #5 会议邀请: 仅给"新加入"的 userId 发通知, 已存在的用户不重发.
@@ -175,4 +205,10 @@
<select id="selectUserIdsByMeetingId" resultType="java.lang.Long" parameterType="Long"> <select id="selectUserIdsByMeetingId" resultType="java.lang.Long" parameterType="Long">
select user_id from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0 select user_id from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
</select> </select>
<!-- 费用汇总用: 某会议应发金额 (fee_pre_tax) 合计 -->
<select id="sumFeePreTaxByMeetingId" resultType="java.math.BigDecimal" parameterType="Long">
select ifnull(sum(fee_pre_tax), 0)
from biz_meeting_attendee
where meeting_id = #{meetingId} and is_deleted = 0
</select>
</mapper> </mapper>
@@ -7,7 +7,10 @@
<result property="meetingId" column="meeting_id" /> <result property="meetingId" column="meeting_id" />
<result property="auditor" column="auditor" /> <result property="auditor" column="auditor" />
<result property="opinion" column="opinion" /> <result property="opinion" column="opinion" />
<result property="currentStage" column="current_stage" /> <result property="executorStage" column="executor_stage" />
<result property="sponsorStage" column="sponsor_stage" />
<result property="managerStage" column="manager_stage" />
<result property="adminStage" column="admin_stage" />
<result property="createTime" column="create_time" /> <result property="createTime" column="create_time" />
<result property="auditTime" column="audit_time" /> <result property="auditTime" column="audit_time" />
<result property="auditType" column="audit_type" /> <result property="auditType" column="audit_type" />
@@ -16,7 +19,7 @@
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select id, meeting_id, auditor, opinion, current_stage, create_time, audit_time, audit_type, audit_result, is_deleted select id, meeting_id, auditor, opinion, executor_stage, sponsor_stage, manager_stage, admin_stage, create_time, audit_time, audit_type, audit_result, is_deleted
from biz_meeting_audit_log from biz_meeting_audit_log
</sql> </sql>
@@ -30,7 +33,6 @@
<where> <where>
is_deleted = 0 is_deleted = 0
<if test="meetingId != null">and meeting_id = #{meetingId}</if> <if test="meetingId != null">and meeting_id = #{meetingId}</if>
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="auditor != null and auditor != ''">and auditor = #{auditor}</if> <if test="auditor != null and auditor != ''">and auditor = #{auditor}</if>
</where> </where>
order by id desc order by id desc
@@ -42,7 +44,10 @@
<if test="meetingId != null">meeting_id,</if> <if test="meetingId != null">meeting_id,</if>
<if test="auditor != null and auditor != ''">auditor,</if> <if test="auditor != null and auditor != ''">auditor,</if>
<if test="opinion != null and opinion != ''">opinion,</if> <if test="opinion != null and opinion != ''">opinion,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if> <if test="executorStage != null and executorStage != ''">executor_stage,</if>
<if test="sponsorStage != null and sponsorStage != ''">sponsor_stage,</if>
<if test="managerStage != null and managerStage != ''">manager_stage,</if>
<if test="adminStage != null and adminStage != ''">admin_stage,</if>
<if test="createTime != null">create_time,</if> <if test="createTime != null">create_time,</if>
<if test="auditTime != null">audit_time,</if> <if test="auditTime != null">audit_time,</if>
<if test="auditType != null and auditType != ''">audit_type,</if> <if test="auditType != null and auditType != ''">audit_type,</if>
@@ -52,7 +57,10 @@
<if test="meetingId != null">#{meetingId},</if> <if test="meetingId != null">#{meetingId},</if>
<if test="auditor != null and auditor != ''">#{auditor},</if> <if test="auditor != null and auditor != ''">#{auditor},</if>
<if test="opinion != null and opinion != ''">#{opinion},</if> <if test="opinion != null and opinion != ''">#{opinion},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if> <if test="executorStage != null and executorStage != ''">#{executorStage},</if>
<if test="sponsorStage != null and sponsorStage != ''">#{sponsorStage},</if>
<if test="managerStage != null and managerStage != ''">#{managerStage},</if>
<if test="adminStage != null and adminStage != ''">#{adminStage},</if>
<if test="createTime != null">#{createTime},</if> <if test="createTime != null">#{createTime},</if>
<if test="auditTime != null">#{auditTime},</if> <if test="auditTime != null">#{auditTime},</if>
<if test="auditType != null and auditType != ''">#{auditType},</if> <if test="auditType != null and auditType != ''">#{auditType},</if>
@@ -65,7 +73,10 @@
<trim prefix="SET" suffixOverrides=","> <trim prefix="SET" suffixOverrides=",">
<if test="auditor != null and auditor != ''">auditor = #{auditor},</if> <if test="auditor != null and auditor != ''">auditor = #{auditor},</if>
<if test="opinion != null and opinion != ''">opinion = #{opinion},</if> <if test="opinion != null and opinion != ''">opinion = #{opinion},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if> <if test="executorStage != null and executorStage != ''">executor_stage = #{executorStage},</if>
<if test="sponsorStage != null and sponsorStage != ''">sponsor_stage = #{sponsorStage},</if>
<if test="managerStage != null and managerStage != ''">manager_stage = #{managerStage},</if>
<if test="adminStage != null and adminStage != ''">admin_stage = #{adminStage},</if>
<if test="createTime != null">create_time = #{createTime},</if> <if test="createTime != null">create_time = #{createTime},</if>
<if test="auditTime != null">audit_time = #{auditTime},</if> <if test="auditTime != null">audit_time = #{auditTime},</if>
<if test="auditType != null and auditType != ''">audit_type = #{auditType},</if> <if test="auditType != null and auditType != ''">audit_type = #{auditType},</if>
@@ -21,9 +21,28 @@
<result property="supervisionTime" column="supervision_time" /> <result property="supervisionTime" column="supervision_time" />
<result property="materialAuditStage" column="material_audit_stage" /> <result property="materialAuditStage" column="material_audit_stage" />
<result property="voucherAuditStage" column="voucher_audit_stage" /> <result property="voucherAuditStage" column="voucher_audit_stage" />
<result property="isExecuted" column="is_executed" />
<result property="executeTime" column="execute_time" />
<result property="isSettled" column="is_settled" />
<result property="settleTime" column="settle_time" />
<result property="isFinished" column="is_finished" />
<result property="finishTime" column="finish_time" />
<result property="isFrozen" column="is_frozen" />
<result property="freezeTime" column="freeze_time" />
<result property="materialAuditTime" column="material_audit_time" />
<result property="voucherAuditTime" column="voucher_audit_time" />
<result property="materialComplianceApproved" column="material_compliance_approved" />
<result property="voucherComplianceApproved" column="voucher_compliance_approved" />
<result property="invitationUrl" column="invitation_url" /> <result property="invitationUrl" column="invitation_url" />
<result property="scheduleUrl" column="schedule_url" /> <result property="scheduleUrl" column="schedule_url" />
<result property="posterUrl" column="poster_url" />
<result property="laborSigned" column="labor_signed" /> <result property="laborSigned" column="labor_signed" />
<result property="laborFee" column="labor_fee" />
<result property="meetingFee" column="meeting_fee" />
<result property="totalFee" column="total_fee" />
<result property="feeCalcStatus" column="fee_calc_status" />
<result property="attendeeId" column="attendee_id" />
<result property="attendeeLaborProtocol" column="attendee_labor_protocol" />
<result property="createBy" column="create_by" /> <result property="createBy" column="create_by" />
<result property="createTime" column="create_time" /> <result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" /> <result property="updateBy" column="update_by" />
@@ -31,15 +50,19 @@
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, voucher_audit_stage, invitation_url, schedule_url, labor_signed, create_by, create_time, update_by, update_time, is_deleted meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, org_name, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, voucher_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, voucher_audit_time, material_compliance_approved, voucher_compliance_approved, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted
from biz_meeting
</sql> </sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long"> <select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
<include refid="selectFields"/> select <include refid="selectFields"/>
from biz_meeting
where meeting_id = #{meetingId} and is_deleted = 0 where meeting_id = #{meetingId} and is_deleted = 0
</select> </select>
<select id="selectList" resultMap="BizMeetingResult" parameterType="BizMeeting"> <select id="selectList" resultMap="BizMeetingResult" parameterType="BizMeeting">
select
<if test="userId != null">(select a.id from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0 limit 1) as attendee_id,</if>
<if test="userId != null">(select a.labor_protocol from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0 limit 1) as attendee_labor_protocol,</if>
<include refid="selectFields"/> <include refid="selectFields"/>
from biz_meeting
<where> <where>
is_deleted = 0 is_deleted = 0
<if test="projectNo != null and projectNo != ''">and project_no like concat('%', #{projectNo}, '%')</if> <if test="projectNo != null and projectNo != ''">and project_no like concat('%', #{projectNo}, '%')</if>
@@ -52,6 +75,21 @@
<if test="endTime != null">and end_time &lt;= #{endTime}</if> <if test="endTime != null">and end_time &lt;= #{endTime}</if>
<!-- doctor 角色按 user_id 过滤 (走 biz_meeting_attendee 中间表, 同时 attendee 也需 is_deleted=0) --> <!-- doctor 角色按 user_id 过滤 (走 biz_meeting_attendee 中间表, 同时 attendee 也需 is_deleted=0) -->
<if test="userId != null">and exists (select 1 from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0)</if> <if test="userId != null">and exists (select 1 from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId} and a.is_deleted = 0)</if>
<!-- sponsor 数据权限: 只看"我的项目"下的会议 (project_id ∈ 我的项目). MAIN 走 sponsor_admin_user_id, SUB 走 sponsor_assign.monitor_user_id.
刻意不带 biz_publicity_support_intent 关联 (与项目列表 selectSponsorList 的区别点) -->
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_admin_user_id = #{params.sponsorAdminUserId} and is_deleted = 0)</if>
<if test="params.monitorUserId != null">and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0)</if>
<!-- executor 数据权限: 只看"我的项目"下的会议. MAIN 走 biz_project_assign (exec_user_id / execution_unit_id), SUB(执行人) 走 biz_project_executor_assign.staff_user_id -->
<if test="params.executorUserId != null">and project_id in (
select distinct a.project_id from biz_project_assign a
where a.is_deleted = 0
and (a.exec_user_id = #{params.executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
)</if>
<if test="params.executorStaffUserId != null">and project_id in (
select distinct a.project_id from biz_project_executor_assign a
where a.is_deleted = 0 and a.staff_user_id = #{params.executorStaffUserId}
)</if>
</where> </where>
order by meeting_id desc order by meeting_id desc
</select> </select>
@@ -79,7 +117,12 @@
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage,</if> <if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage,</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if> <if test="invitationUrl != null and invitationUrl != ''">invitation_url,</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if> <if test="scheduleUrl != null and scheduleUrl != ''">schedule_url,</if>
<if test="posterUrl != null and posterUrl != ''">poster_url,</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed,</if> <if test="laborSigned != null and laborSigned != ''">labor_signed,</if>
<if test="createBy != null and createBy != ''">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null and updateBy != ''">update_by,</if>
<if test="updateTime != null">update_time,</if>
</trim> </trim>
<trim prefix="values (" suffix=")" suffixOverrides=","> <trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if> <if test="meetingId != null">#{meetingId},</if>
@@ -103,33 +146,54 @@
<if test="voucherAuditStage != null and voucherAuditStage != ''">#{voucherAuditStage},</if> <if test="voucherAuditStage != null and voucherAuditStage != ''">#{voucherAuditStage},</if>
<if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if> <if test="invitationUrl != null and invitationUrl != ''">#{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if> <if test="scheduleUrl != null and scheduleUrl != ''">#{scheduleUrl},</if>
<if test="posterUrl != null and posterUrl != ''">#{posterUrl},</if>
<if test="laborSigned != null and laborSigned != ''">#{laborSigned},</if> <if test="laborSigned != null and laborSigned != ''">#{laborSigned},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null and updateBy != ''">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim> </trim>
</insert> </insert>
<update id="updateByPrimaryKey" parameterType="BizMeeting"> <update id="updateByPrimaryKey" parameterType="BizMeeting">
update biz_meeting update biz_meeting
<trim prefix="SET" suffixOverrides=","> <trim prefix="SET" suffixOverrides=",">
<if test="businessId != null and businessId != ''">business_id = #{businessId},</if> <if test="businessId != null and businessId != ''">business_id = #{businessId},</if>
<if test="projectId != null and projectId != ''">project_id = #{projectId},</if> <if test="projectId != null">project_id = #{projectId},</if>
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if> <if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="projectName != null and projectName != ''">project_name = #{projectName},</if> <if test="projectName != null and projectName != ''">project_name = #{projectName},</if>
<if test="meetingName != null and meetingName != ''">meeting_name = #{meetingName},</if> <if test="meetingName != null and meetingName != ''">meeting_name = #{meetingName},</if>
<if test="periodNo != null and periodNo != ''">period_no = #{periodNo},</if> <if test="periodNo != null">period_no = #{periodNo},</if>
<if test="totalPeriods != null and totalPeriods != ''">total_periods = #{totalPeriods},</if> <if test="totalPeriods != null">total_periods = #{totalPeriods},</if>
<if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if> <if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if>
<if test="startTime != null and startTime != ''">start_time = #{startTime},</if> <!-- Date/Long 字段不能用 != '' (OGNL 会把 Date 和 String 做非法比较), 只判 null -->
<if test="endTime != null and endTime != ''">end_time = #{endTime},</if> <if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="orgName != null and orgName != ''">org_name = #{orgName},</if> <if test="orgName != null and orgName != ''">org_name = #{orgName},</if>
<if test="address != null and address != ''">address = #{address},</if> <if test="address != null and address != ''">address = #{address},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if> <if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if> <if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if> <if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
<if test="supervisionTime != null and supervisionTime != ''">supervision_time = #{supervisionTime},</if> <if test="supervisionTime != null">supervision_time = #{supervisionTime},</if>
<if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if> <if test="materialAuditStage != null and materialAuditStage != ''">material_audit_stage = #{materialAuditStage},</if>
<if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage = #{voucherAuditStage},</if> <if test="voucherAuditStage != null and voucherAuditStage != ''">voucher_audit_stage = #{voucherAuditStage},</if>
<if test="isExecuted != null">is_executed = #{isExecuted},</if>
<if test="executeTime != null">execute_time = #{executeTime},</if>
<if test="isSettled != null">is_settled = #{isSettled},</if>
<if test="settleTime != null">settle_time = #{settleTime},</if>
<if test="isFinished != null">is_finished = #{isFinished},</if>
<if test="finishTime != null">finish_time = #{finishTime},</if>
<if test="isFrozen != null">is_frozen = #{isFrozen},</if>
<if test="freezeTime != null">freeze_time = #{freezeTime},</if>
<if test="materialAuditTime != null">material_audit_time = #{materialAuditTime},</if>
<if test="voucherAuditTime != null">voucher_audit_time = #{voucherAuditTime},</if>
<if test="materialComplianceApproved != null">material_compliance_approved = #{materialComplianceApproved},</if>
<if test="voucherComplianceApproved != null">voucher_compliance_approved = #{voucherComplianceApproved},</if>
<if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if> <if test="invitationUrl != null and invitationUrl != ''">invitation_url = #{invitationUrl},</if>
<if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if> <if test="scheduleUrl != null and scheduleUrl != ''">schedule_url = #{scheduleUrl},</if>
<if test="posterUrl != null and posterUrl != ''">poster_url = #{posterUrl},</if>
<if test="laborSigned != null and laborSigned != ''">labor_signed = #{laborSigned},</if> <if test="laborSigned != null and laborSigned != ''">labor_signed = #{laborSigned},</if>
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim> </trim>
where meeting_id = #{meetingId} where meeting_id = #{meetingId}
</update> </update>
@@ -150,4 +214,66 @@
<select id="selectIdListByProjectId" resultType="Long" parameterType="Long"> <select id="selectIdListByProjectId" resultType="Long" parameterType="Long">
select meeting_id from biz_meeting where project_id = #{projectId} select meeting_id from biz_meeting where project_id = #{projectId}
</select> </select>
<!-- 建会限额用: 某项目下未软删的会议数 -->
<select id="countByProjectId" resultType="int" parameterType="Long">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
</select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING. 已软删/已冻结/已提交材料的不动. -->
<update id="markExecuted">
update biz_meeting
set is_executed = 1,
execute_time = NOW(),
current_stage = 'RUNNING'
where is_deleted = 0
and is_executed = 0
and is_frozen = 0
and start_time is not null
and start_time &lt;= NOW()
and material_audit_stage = 'NOT_SUBMITTED'
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material 未提交 且 end_time + submit_deadline_days 已过 → 冻结 -->
<update id="markFrozen">
update biz_meeting m
join biz_project p on p.project_id = m.project_id and p.is_deleted = 0
set m.is_frozen = 1,
m.freeze_time = NOW(),
m.current_stage = 'FROZEN'
where m.is_deleted = 0
and m.is_frozen = 0
and m.material_audit_stage = 'NOT_SUBMITTED'
and m.end_time is not null
and p.submit_deadline_days is not null
and date_add(m.end_time, interval p.submit_deadline_days day) &lt;= NOW()
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): material+voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 → AWAITING_SETTLEMENT (待结算 24h 慢路径) -->
<update id="markSettlementReady">
update biz_meeting
set current_stage = 'AWAITING_SETTLEMENT'
where is_deleted = 0
and is_frozen = 0
and is_settled = 0
and is_finished = 0
and material_audit_stage = 'APPROVED'
and voucher_audit_stage = 'APPROVED'
and current_stage = 'SUPERVISION_APPROVED'
and greatest(material_audit_time, voucher_audit_time) is not null
and greatest(material_audit_time, voucher_audit_time) &lt;= (NOW() - INTERVAL 1 DAY)
</update>
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
<select id="selectPendingFeeCalcIds" resultType="Long">
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
</select>
<!-- 置未汇总 (人员/材料变化触发, 幂等) -->
<update id="markFeeCalcPending" parameterType="Long">
update biz_meeting set fee_calc_status = 0 where meeting_id = #{meetingId}
</update>
<!-- 汇总回写 3 个费用字段 + 置已汇总 -->
<update id="updateFeeSummary">
update biz_meeting
set labor_fee = #{laborFee},
meeting_fee = #{meetingFee},
total_fee = #{totalFee},
fee_calc_status = 1
where meeting_id = #{meetingId}
</update>
</mapper> </mapper>
@@ -9,14 +9,16 @@
<result property="subType" column="sub_type" /> <result property="subType" column="sub_type" />
<result property="fileName" column="file_name" /> <result property="fileName" column="file_name" />
<result property="ossUrl" column="oss_url" /> <result property="ossUrl" column="oss_url" />
<result property="extraOssUrl" column="extra_oss_url" />
<result property="amount" column="amount" /> <result property="amount" column="amount" />
<result property="creatorId" column="creator_id" /> <result property="creatorId" column="creator_id" />
<result property="createTime" column="create_time" /> <result property="createTime" column="create_time" />
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
<result property="feeStatus" column="fee_status" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select id, meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time, is_deleted select id, meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, is_deleted, fee_status
from biz_meeting_material from biz_meeting_material
</sql> </sql>
@@ -39,6 +41,7 @@
<if test="subType != null and subType != ''">sub_type,</if> <if test="subType != null and subType != ''">sub_type,</if>
<if test="fileName != null and fileName != ''">file_name,</if> <if test="fileName != null and fileName != ''">file_name,</if>
<if test="ossUrl != null and ossUrl != ''">oss_url,</if> <if test="ossUrl != null and ossUrl != ''">oss_url,</if>
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url,</if>
<if test="amount != null">amount,</if> <if test="amount != null">amount,</if>
<if test="creatorId != null">creator_id,</if> <if test="creatorId != null">creator_id,</if>
<if test="createTime != null">create_time,</if> <if test="createTime != null">create_time,</if>
@@ -49,6 +52,7 @@
<if test="subType != null and subType != ''">#{subType},</if> <if test="subType != null and subType != ''">#{subType},</if>
<if test="fileName != null and fileName != ''">#{fileName},</if> <if test="fileName != null and fileName != ''">#{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if> <if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
<if test="extraOssUrl != null and extraOssUrl != ''">#{extraOssUrl},</if>
<if test="amount != null">#{amount},</if> <if test="amount != null">#{amount},</if>
<if test="creatorId != null">#{creatorId},</if> <if test="creatorId != null">#{creatorId},</if>
<if test="createTime != null">#{createTime},</if> <if test="createTime != null">#{createTime},</if>
@@ -56,11 +60,11 @@
</insert> </insert>
<insert id="insertBatch" parameterType="java.util.List"> <insert id="insertBatch" parameterType="java.util.List">
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, amount, creator_id, create_time) insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, fee_status)
values values
<foreach collection="list" item="item" separator=","> <foreach collection="list" item="item" separator=",">
(#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl}, (#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl},
#{item.amount}, #{item.creatorId}, #{item.createTime}) #{item.extraOssUrl}, #{item.amount}, #{item.creatorId}, #{item.createTime}, #{item.feeStatus})
</foreach> </foreach>
</insert> </insert>
@@ -71,6 +75,7 @@
<if test="subType != null and subType != ''">sub_type = #{subType},</if> <if test="subType != null and subType != ''">sub_type = #{subType},</if>
<if test="fileName != null and fileName != ''">file_name = #{fileName},</if> <if test="fileName != null and fileName != ''">file_name = #{fileName},</if>
<if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if> <if test="ossUrl != null and ossUrl != ''">oss_url = #{ossUrl},</if>
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url = #{extraOssUrl},</if>
<if test="amount != null">amount = #{amount},</if> <if test="amount != null">amount = #{amount},</if>
</trim> </trim>
where id = #{id} where id = #{id}
@@ -80,6 +85,10 @@
update biz_meeting_material set amount = #{amount} where id = #{id} update biz_meeting_material set amount = #{amount} where id = #{id}
</update> </update>
<update id="updateFeeStatus">
update biz_meeting_material set fee_status = #{feeStatus} where id = #{id}
</update>
<delete id="deleteByPrimaryKey" parameterType="Long"> <delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_meeting_material where id = #{id} delete from biz_meeting_material where id = #{id}
</delete> </delete>
@@ -15,6 +15,7 @@
<result property="unitType" column="unit_type" /> <result property="unitType" column="unit_type" />
<result property="accountType" column="account_type" /> <result property="accountType" column="account_type" />
<result property="parentUserId" column="parent_user_id" /> <result property="parentUserId" column="parent_user_id" />
<result property="account" column="user_name" />
<result property="createBy" column="create_by" /> <result property="createBy" column="create_by" />
<result property="createTime" column="create_time" /> <result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" /> <result property="updateBy" column="update_by" />
@@ -31,7 +32,8 @@
select p.person_id, p.name, p.phone, p.org_id, o.org_name, o.org_type, select p.person_id, p.name, p.phone, p.org_id, o.org_name, o.org_type,
p.department, p.position, p.role, p.unit_type, p.user_id, p.department, p.position, p.role, p.unit_type, p.user_id,
p.create_by, p.create_time, p.update_by, p.update_time, p.create_by, p.create_time, p.update_by, p.update_time,
u.account_type, u.parent_user_id, u.status, u.del_flag as user_del_flag u.account_type, u.parent_user_id, u.status, u.del_flag as user_del_flag,
u.user_name as user_name
from biz_person p from biz_person p
left join biz_org o on p.org_id = o.org_id left join biz_org o on p.org_id = o.org_id
left join sys_user u on p.user_id = u.user_id left join sys_user u on p.user_id = u.user_id
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizProjectExecutorAssignMapper">
<resultMap id="BaseResultMap" type="BizProjectExecutorAssign">
<id property="id" column="id" />
<result property="projectId" column="project_id" />
<result property="executorUserId" column="executor_user_id" />
<result property="staffUserId" column="staff_user_id" />
<result property="assignDesc" column="assign_desc" />
<result property="assignPoints" column="assign_points" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="executorUserName" column="executor_user_name" />
<result property="staffUserName" column="staff_user_name" />
<result property="isDeleted" column="is_deleted" />
</resultMap>
<insert id="insertAssign" parameterType="BizProjectExecutorAssign" useGeneratedKeys="true" keyProperty="id">
INSERT INTO biz_project_executor_assign
(project_id, executor_user_id, staff_user_id, assign_desc, assign_points, create_by, create_time)
VALUES
(#{projectId}, #{executorUserId}, #{staffUserId}, #{assignDesc}, #{assignPoints}, #{createBy}, sysdate())
</insert>
<!-- 按 project_id 全删 (执行方分配策略: 先删后插) -->
<delete id="deleteByProjectId" parameterType="String">
delete from biz_project_executor_assign where project_id = #{projectId}
</delete>
<!-- 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 -->
<update id="softDeleteByProjectId" parameterType="String">
update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId}
</update>
<select id="selectByProjectId" resultMap="BaseResultMap">
SELECT a.*,
e.user_name AS executor_user_name,
s.user_name AS staff_user_name
FROM biz_project_executor_assign a
LEFT JOIN sys_user e ON a.executor_user_id = e.user_id
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC
</select>
</mapper>
@@ -8,6 +8,7 @@
<result property="totalSessions" column="total_sessions" /> <result property="totalSessions" column="total_sessions" />
<result property="assignedSessions" column="assigned_sessions" /> <result property="assignedSessions" column="assigned_sessions" />
<result property="assignedAmount" column="assigned_amount" /> <result property="assignedAmount" column="assigned_amount" />
<result property="meetingCount" column="meeting_count" />
<result property="doneSessions" column="done_sessions" /> <result property="doneSessions" column="done_sessions" />
<result property="todoSessions" column="todo_sessions" /> <result property="todoSessions" column="todo_sessions" />
<result property="totalAmount" column="total_amount" /> <result property="totalAmount" column="total_amount" />
@@ -96,7 +97,32 @@
#{id} #{id}
</foreach> </foreach>
</if> </if>
<if test="params.sponsorAdminUserId != null">and p.sponsor_admin_user_id = #{params.sponsorAdminUserId}</if> <!--
sponsor 视角: 可见项目 = MAIN/ADMIN 默认 (sponsor_admin_user_id) OR SUB/监察员 (sponsor_assign.monitor_user_id), 任一路径同时 UNION biz_publicity_support_intent
业务: sponsor (无论 MAIN/SUB) 提交过支持意向的项目, 同样要在 /sponsor/my-projects 看到, 跟被分配监察员同等地位
分页: PageHelper 加 LIMIT 到外层, 自动生成 COUNT(*) FROM biz_project WHERE ... — 子查询内 DISTINCT 避免重复 count
-->
<if test="params.sponsorAdminUserId != null">and (
p.sponsor_admin_user_id = #{params.sponsorAdminUserId}
or p.project_id in (
select distinct i.project_id
from biz_publicity_support_intent i
where i.user_id = #{params.sponsorAdminUserId}
)
)</if>
<if test="params.monitorUserId != null">and (
p.project_id in (
select distinct a.project_id
from biz_project_sponsor_assign a
where a.is_deleted = 0
and a.monitor_user_id = #{params.monitorUserId}
)
or p.project_id in (
select distinct i.project_id
from biz_publicity_support_intent i
where i.user_id = #{params.monitorUserId}
)
)</if>
<if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if> <if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if> <if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="startTime != null">and p.start_time &gt;= #{startTime}</if> <if test="startTime != null">and p.start_time &gt;= #{startTime}</if>
@@ -133,16 +159,67 @@
where bpa4.project_id = p.project_id where bpa4.project_id = p.project_id
and bpa4.is_deleted = 0 and bpa4.is_deleted = 0
and (bpa4.exec_user_id = #{params.executorUserId} and (bpa4.exec_user_id = #{params.executorUserId}
or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count
from biz_project p from biz_project p
join biz_project_assign a on a.project_id = p.project_id and a.is_deleted = 0 <!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
join biz_project_assign a on a.project_id = p.project_id
and a.is_deleted = 0
and (a.exec_user_id = #{params.executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor' left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor'
left join sys_user lu on lu.user_id = p.lead_user_id left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id left join biz_person bp on bp.user_id = p.create_user_id
<where> <where>
p.is_deleted = 0 p.is_deleted = 0
(a.exec_user_id = #{params.executorUserId} <if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if>
or a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) <if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="startTime != null">and p.start_time &gt;= #{startTime}</if>
<if test="endTime != null">and p.end_time &lt;= #{endTime}</if>
<if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if>
</where>
order by p.project_id desc
</select>
<!--
executor 执行人 (SUB 子账号) 专属列表: 反查 biz_project_executor_assign.staff_user_id
(主账号把执行人派到项目上后, 执行人登录看自己被派到的项目)
严格隔离: 不 JOIN biz_project_assign (避免主账号过滤), 不 UNION intent (executor 与 biz_*_intent 完全无关)
场次/金额列 assigned_sessions/assigned_amount 按 params.executorUserId (主账号/本公司) 聚合 — 执行人看到的是公司数据, 不是个人数据
-->
<select id="selectExecutorStaffList" resultMap="BizProjectResult" parameterType="BizProject">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled, p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
o.org_name as sponsor_org_name,
lu.user_name as lead_user_name,
bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa2
join biz_org o2 on o2.org_id = bpa2.execution_unit_id and o2.org_type = 'executor'
where bpa2.project_id = p.project_id and bpa2.is_deleted = 0) as exec_org_names,
(select coalesce(sum(bpa3.sessions), 0)
from biz_project_assign bpa3
where bpa3.project_id = p.project_id
and bpa3.is_deleted = 0
and (bpa3.exec_user_id = #{params.executorUserId}
or bpa3.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_sessions,
(select coalesce(sum(bpa4.amount), 0)
from biz_project_assign bpa4
where bpa4.project_id = p.project_id
and bpa4.is_deleted = 0
and (bpa4.exec_user_id = #{params.executorUserId}
or bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))) as assigned_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count
from biz_project p
left join biz_org o on o.user_id = p.sponsor_admin_user_id and o.org_type = 'sponsor'
left join sys_user lu on lu.user_id = p.lead_user_id
left join biz_person bp on bp.user_id = p.create_user_id
<where>
p.is_deleted = 0
and p.project_id in (
select distinct a.project_id
from biz_project_executor_assign a
where a.is_deleted = 0 and a.staff_user_id = #{params.executorStaffUserId}
)
<if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if> <if test="projectNo != null and projectNo != ''">and p.project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if> <if test="projectName != null and projectName != ''">and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="startTime != null">and p.start_time &gt;= #{startTime}</if> <if test="startTime != null">and p.start_time &gt;= #{startTime}</if>
@@ -324,4 +401,57 @@
<select id="selectProjectNoById" resultType="String" parameterType="Long"> <select id="selectProjectNoById" resultType="String" parameterType="Long">
select project_no from biz_project where project_id = #{projectId} select project_no from biz_project where project_id = #{projectId}
</select> </select>
<!-- 建会限额用: 某项目分配给该执行方 (MAIN) 的总场次 = biz_project_assign.sessions 之和 (exec_user_id 或 其执行单位 org) -->
<select id="countAssignedSessions" resultType="int">
select coalesce(sum(a.sessions), 0)
from biz_project_assign a
where a.project_id = #{projectId}
and a.is_deleted = 0
and (a.exec_user_id = #{executorUserId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{executorUserId} and org_type = 'executor'))
</select>
<!-- 提交权限用: 判断 user 是否该项目的执行方.
MAIN: biz_project_assign (exec_user_id 或 其执行单位 org); SUB(执行人): biz_project_executor_assign.staff_user_id.
与会议列表 executor 可见性 (BizMeetingMapper.selectList) 同源, 替代原来的 biz_meeting_executor (会议级执行人员) 判定.
注: biz_project_executor_assign.project_id 是 varchar, 与 Long #{projectId} 比较走 MySQL 隐式转换 (与 selectExecutorStaffList 同款). -->
<select id="countExecutorOfProject" resultType="int">
select count(*) from (
select 1
from biz_project_assign a
where a.project_id = #{projectId}
and a.is_deleted = 0
and (a.exec_user_id = #{userId}
or a.execution_unit_id = (select org_id from biz_org where user_id = #{userId} and org_type = 'executor'))
union
select 1
from biz_project_executor_assign b
where b.project_id = #{projectId}
and b.is_deleted = 0
and b.staff_user_id = #{userId}
) t
</select>
<!-- 会议结算后重算项目金额: 全量 SUM 已结算会议 (is_settled=1) 的 labor_fee/meeting_fee,
幂等回写 paid_labor_amount / paid_meeting_amount, 并重算 available_amount = 总金额 - 管理费 - 已支付劳务 - 已支付会务.
单条 UPDATE 原子执行, 多会议并发结算同一项目时无丢失更新 (每次都是全量重算). -->
<update id="recomputeSettledAmounts" parameterType="Long">
update biz_project p
set p.paid_labor_amount = (
select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0
),
p.paid_meeting_amount = (
select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0
),
p.available_amount = p.total_amount - ifnull(p.manage_fee, 0)
- (select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
- (select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
where p.project_id = #{projectId}
</update>
</mapper> </mapper>
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizSupportIntentMapper">
<resultMap type="BizSupportIntent" id="BizSupportIntentResult">
<id property="intentId" column="intent_id" />
<result property="projectNo" column="project_no" />
<result property="projectName" column="project_name" />
<result property="name" column="name" />
<result property="workUnit" column="work_unit" />
<result property="department" column="department" />
<result property="position" column="position" />
<result property="phone" column="phone" />
<result property="accountStatus" column="account_status" />
<result property="createTime" column="create_time" />
</resultMap>
<sql id="selectFields">
select intent_id, project_no, project_name, name, work_unit, department, position, phone, account_status, create_by, create_time, update_by, update_time
from biz_support_intent
</sql>
<select id="selectByPrimaryKey" resultMap="BizSupportIntentResult" parameterType="String">
<include refid="selectFields"/>
where intent_id = #{intentId}
</select>
<select id="selectList" resultMap="BizSupportIntentResult" parameterType="BizSupportIntent">
<include refid="selectFields"/>
<where>
<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>
<insert id="insert" parameterType="BizSupportIntent">
insert into biz_support_intent
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="intentId != null and intentId != ''">intent_id,</if>
<if test="name != null">name,</if>
<if test="department != null">department,</if>
<if test="position != null">position,</if>
<if test="phone != null">phone,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="intentId != null and intentId != ''">#{intentId},</if>
<if test="name != null">#{name},</if>
<if test="department != null">#{department},</if>
<if test="position != null">#{position},</if>
<if test="phone != null">#{phone},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizSupportIntent">
update biz_support_intent
<trim prefix="SET" suffixOverrides=",">
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="projectName != null and projectName != ''">project_name = #{projectName},</if>
<if test="workUnit != null and workUnit != ''">work_unit = #{workUnit},</if>
<if test="accountStatus != null and accountStatus != ''">account_status = #{accountStatus},</if>
<if test="name != null">name = #{name},</if>
<if test="department != null">department = #{department},</if>
<if test="position != null">position = #{position},</if>
<if test="phone != null">phone = #{phone},</if>
</trim>
where intent_id = #{intentId}
</update>
<delete id="deleteByPrimaryKey" parameterType="String">
delete from biz_support_intent where intent_id = #{intentId}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="String">
delete from biz_support_intent where intent_id in
<foreach collection="intentIds" item="intentId" open="(" separator="," close=")">
#{intentId}
</foreach>
</delete>
</mapper>
@@ -33,6 +33,9 @@ public class RuoYiConfig
/** OSS 配置 (可选) */ /** OSS 配置 (可选) */
private OssProperties oss = new OssProperties(); private OssProperties oss = new OssProperties();
/** 扫码拍照相机网页配置 (可选) */
private CameraProperties camera = new CameraProperties();
public String getName() public String getName()
{ {
return name; return name;
@@ -133,6 +136,28 @@ public class RuoYiConfig
this.oss = oss; this.oss = oss;
} }
public CameraProperties getCamera()
{
return camera;
}
public void setCamera(CameraProperties camera)
{
this.camera = camera;
}
/**
* 扫码拍照相机网页配置 (前端二维码目标 URL)
*/
public static class CameraProperties
{
/** 相机 H5 网页基础地址, 例如 http://localhost:8090/camera/ */
private String baseUrl;
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
}
/** /**
* OSS 配置 (前端直传签名用) * OSS 配置 (前端直传签名用)
*/ */
@@ -0,0 +1,109 @@
package com.ruoyi.common.enums;
/**
* 会议 当前阶段 (biz_meeting.current_stage)
* <p>
* 10 值统一物理状态机 (单一可信源). 每个角色看到的是它的一个显示投影, 见前端 stageLabel(role, code):
* <pre>
* NOT_STARTED 未执行 会议开始时间前
* ↓ (过 startTime, scheduler)
* RUNNING 执行中 已开始, 执行方未提交材料
* ↓ (执行方提交材料)
* AWAITING_COMPLIANCE 待合规审核 执行方已提交, 等合规人员审核 (支持方此时只读, 不能审)
* ↓ (合规审通过) ↘ (合规退回)
* AWAITING_SUPERVISION 待支持方审核 合规审通过, 等支持方审核(填监管意见)
* ↓ (支持方审通过) ↘ (支持方退回)
* SUPERVISION_APPROVED 审核通过 支持方审通过, 等 1 自然日 → 待结算
* ↓ (过 1 自然日, scheduler)
* AWAITING_SETTLEMENT 待结算 支持方审通过 + 1 自然日, 等合规结算 + 上传付款凭证
* ↓ (合规结算 + 上传凭证)
* SETTLED 已结算 材料+凭证均通过, 已结算
* ↓ (合规/管理员手动完结)
* FINISHED 已完结 流程终止
*
* RECTIFYING 待整改 合规或支持方退回, 执行方整改后重新提交 → AWAITING_COMPLIANCE
* FROZEN 冻结中 过期未提交, 不可再提交
* </pre>
*
* <p>code 用英文单词而非数字, 原因:
* <ol>
* <li>B-tree 索引比较更快 (ASCII 7-19B vs UTF-8 中文 9B/字符)</li>
* <li>日志/SQL 直接可读, 不需要 decode</li>
* <li>URL 查询参数无需 URL-encode (解决 ?currentStage=执行中 编码乱问题)</li>
* </ol>
*
* <p>前端 dropdown / KPI 卡 / 列表筛选: value 用 code, 显示用 info (中文 label).
*
* @author guoju
*/
public enum BizMeetingStageEnum
{
/** 会议开始时间前 */
NOT_STARTED("NOT_STARTED", "未执行", "会议开始时间前"),
/** 已开始, 执行方未提交材料 */
RUNNING("RUNNING", "执行中", "已开始, 执行方未提交"),
/** 执行方已提交, 等合规人员审核 (支持方此阶段只读, 不可审) */
AWAITING_COMPLIANCE("AWAITING_COMPLIANCE", "待合规审核", "执行方提交, 等合规审"),
/** 合规审通过, 等支持方审核 (填监管意见) */
AWAITING_SUPERVISION("AWAITING_SUPERVISION", "待支持方审核", "合规审通过, 等支持方审"),
/** 支持方审通过, 等 1 自然日 → 待结算 */
SUPERVISION_APPROVED("SUPERVISION_APPROVED", "审核通过", "支持方审通过, 等结算"),
/** 合规或支持方退回, 执行方整改后重新提交 */
RECTIFYING("RECTIFYING", "待整改", "审核退回, 执行方整改"),
/** 支持方审通过 + 1 自然日, 等合规结算 + 上传付款凭证 */
AWAITING_SETTLEMENT("AWAITING_SETTLEMENT", "待结算", "支持方审通过, 等结算"),
/** 合规点击结算 + 上传付款凭证, 全部完成 */
SETTLED("SETTLED", "已结算", "已结算 + 凭证已上传"),
/** 已结算后, 合规/管理员手动完结, 流程终止 */
FINISHED("FINISHED", "已完结", "已结算后手动完结"),
/** 会议结束后逾期未提交, 冻结 (不可再提交) */
FROZEN("FROZEN", "冻结中", "过期未提交, 不可再提交");
private final String code;
private final String info;
private final String description;
BizMeetingStageEnum(String code, String info, String description)
{
this.code = code;
this.info = info;
this.description = description;
}
public String getCode()
{
return code;
}
public String getInfo()
{
return info;
}
public String getDescription()
{
return description;
}
/**
* 根据 code 解析枚举 (找不到返回 null, 调用方自行 fallback)
*/
public static BizMeetingStageEnum of(String code)
{
if (code == null) return null;
for (BizMeetingStageEnum e : values())
{
if (e.code.equals(code)) return e;
}
return null;
}
}
@@ -0,0 +1,59 @@
package com.ruoyi.common.enums;
/**
* 材料/凭证 审核阶段 (biz_meeting.material_audit_stage / voucher_audit_stage)
* <p>
* 4 值 (用户拍板「已提交/待审核」合并): 提交后进入 SUBMITTED, 两级审核(合规先-支持方后)
* 用 material_compliance_approved / voucher_compliance_approved 布尔区分「合规审中 vs 支持方审中」.
* <pre>
* NOT_SUBMITTED 未提交 执行方还没交
* ↓ (执行方提交)
* SUBMITTED 已提交 已提交, 在审 (合规审中=compliance_approved=0, 支持方审中=1)
* ↓ (支持方审通过)
* APPROVED 审核通过 支持方已通过
*
* REJECTED 审核驳回 被合规/支持方退回 (执行方重提 → SUBMITTED)
* </pre>
*
* @author guoju
*/
public enum MeetingAuditStageEnum
{
/** 未提交 */
NOT_SUBMITTED("NOT_SUBMITTED", "未提交"),
/** 已提交 (含两级审核中, 层级由 compliance_approved 区分) */
SUBMITTED("SUBMITTED", "已提交"),
/** 审核通过 (支持方已通过) */
APPROVED("APPROVED", "审核通过"),
/** 审核驳回 (退回) */
REJECTED("REJECTED", "审核驳回");
private final String code;
private final String info;
MeetingAuditStageEnum(String code, String info)
{
this.code = code;
this.info = info;
}
public String getCode()
{
return code;
}
public String getInfo()
{
return info;
}
public static MeetingAuditStageEnum of(String code)
{
if (code == null) return null;
for (MeetingAuditStageEnum e : values())
{
if (e.code.equals(code)) return e;
}
return null;
}
}
@@ -63,6 +63,10 @@ public class SecurityConfig
// OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token) // OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token)
// 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖 // 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖
.requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll() .requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll()
// 扫码拍照相机网页基础地址 (前端生成二维码用, 只读公开)
.requestMatchers(HttpMethod.GET, "/common/camera/config").permitAll()
// 扫码拍照回传 (ry-h5 手机端匿名上传照片 URL, 后端白名单 subType + 会议存在校验兜底)
.requestMatchers(HttpMethod.POST, "/business/meetingMaterial/cameraUpload").permitAll()
// 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限 // 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限
.requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll() .requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll()
// 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据 // 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据
BIN
View File
Binary file not shown.
+42 -6
View File
@@ -8,11 +8,27 @@
* - 返回 ref 和方法,业务页只需负责 UI 布局 * - 返回 ref 和方法,业务页只需负责 UI 布局
*/ */
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { openCamera, stopCamera, captureFrame, describeCameraError, type Facing } from '@/utils/camera' import {
openCamera,
stopCamera,
captureFrame,
captureFrameWithBlur,
describeCameraError,
type Facing,
} from '@/utils/camera'
import { uploadCameraPhoto } from '@/utils/upload'
export function useCamera(videoId: string) { /** 条带高斯模糊配置 (签到表脱敏用): 对截图竖直 [start, end] 比例区间做高斯模糊 */
export type BlurConfig = {
start: number
end: number
radius: number
}
export function useCamera(videoId: string, blur?: BlurConfig) {
const facing = ref<Facing>('environment') const facing = ref<Facing>('environment')
const captured = ref<string>('') const captured = ref<string>('')
const blurred = ref<string>('')
const errorMsg = ref<string>('') const errorMsg = ref<string>('')
function getVideoEl(): HTMLVideoElement | null { function getVideoEl(): HTMLVideoElement | null {
@@ -43,7 +59,14 @@ export function useCamera(videoId: string) {
const video = getVideoEl() const video = getVideoEl()
if (!video) return if (!video) return
try { try {
if (blur) {
const r = captureFrameWithBlur(video, blur.start, blur.end, blur.radius)
captured.value = r.sharp
blurred.value = r.blurred
} else {
captured.value = captureFrame(video) captured.value = captureFrame(video)
blurred.value = ''
}
} catch (err) { } catch (err) {
uni.showToast({ uni.showToast({
title: (err as Error).message || '截图失败', title: (err as Error).message || '截图失败',
@@ -54,19 +77,32 @@ export function useCamera(videoId: string) {
function retake() { function retake() {
captured.value = '' captured.value = ''
blurred.value = ''
} }
async function confirm() { async function confirm() {
// POC: 假上传 + loading 给用户完整仪式感 if (!captured.value) return
uni.showLoading({ title: '上传中...' }) uni.showLoading({ title: '上传中...' })
await new Promise<void>((resolve) => setTimeout(resolve, 800)) try {
// 直传 OSS + 回传 URL 到后端 (公开端点, 无需登录)
// 签到表额外上传一张高斯模糊版 (blurred) 作为 extraOssUrl, sponsor 只看这个
await uploadCameraPhoto(captured.value, blurred.value || '')
uni.hideLoading() uni.hideLoading()
uni.showToast({ uni.showToast({
title: '已保存 (POC 未上传)', title: '已上传',
icon: 'none', icon: 'success',
duration: 1500, duration: 1500,
}) })
captured.value = '' captured.value = ''
blurred.value = ''
} catch (err) {
uni.hideLoading()
uni.showToast({
title: (err as Error).message || '上传失败',
icon: 'none',
duration: 2500,
})
}
} }
onMounted(() => { onMounted(() => {
+8 -1
View File
@@ -5,7 +5,7 @@
<view class="back-btn" @click="goBack"> <view class="back-btn" @click="goBack">
<text class="back-icon"></text> <text class="back-icon"></text>
</view> </view>
<text class="topbar-title">全景取景 (16:9)</text> <text class="topbar-title">{{ title }}</text>
<view class="placeholder" /> <view class="placeholder" />
</view> </view>
@@ -19,6 +19,7 @@
playsinline playsinline
/> />
<image <image
v-if="isFront"
class="frame panorama-frame" class="frame panorama-frame"
src="/static/overlay/panorama.svg" src="/static/overlay/panorama.svg"
mode="widthFix" mode="widthFix"
@@ -65,11 +66,17 @@
<script setup lang="uts"> <script setup lang="uts">
import { useCamera } from '@/composables/useCamera' import { useCamera } from '@/composables/useCamera'
import { getCameraParams } from '@/utils/upload'
const videoId = 'panorama-video' const videoId = 'panorama-video'
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
useCamera(videoId) useCamera(videoId)
// 前全景 (L_PANORAMA_FRONT) 带定位框, 后全景 (L_PANORAMA_BACK) 不带
const subType = getCameraParams().subType
const isFront = subType === 'L_PANORAMA_FRONT'
const title = isFront ? '前全景取景 (带定位框)' : '后全景取景'
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
} }
+2 -1
View File
@@ -67,8 +67,9 @@
import { useCamera } from '@/composables/useCamera' import { useCamera } from '@/composables/useCamera'
const videoId = 'signin-video' const videoId = 'signin-video'
// A4 纸 20%~50% 区间 (签到人手机号/身份证号所在的姓名栏) 做高斯模糊脱敏
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
useCamera(videoId) useCamera(videoId, { start: 0.2, end: 0.5, radius: 20 })
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
+149 -5
View File
@@ -52,6 +52,11 @@ video.setAttribute('playsinline', 'true')
// iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放) // iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放)
video.muted = true video.muted = true
// 显式 play(): <video autoplay> 只在元素首次加载时触发一次, 而 srcObject 是
// 异步挂上的, 此时视频还没源, 浏览器不会自动重放 → 黑屏 + 播放按钮.
// 静音 + playsinline 下, 现代浏览器默认放行静音自动播放, 直接 play() 起流.
video.play()
const fallbackTimer = setTimeout(() => { const fallbackTimer = setTimeout(() => {
if (video.readyState < 1) { if (video.readyState < 1) {
console.warn('[camera] srcObject 没生效,尝试 Object.defineProperty 强写') console.warn('[camera] srcObject 没生效,尝试 Object.defineProperty 强写')
@@ -82,14 +87,11 @@ video.addEventListener(
() => { () => {
clearTimeout(fallbackTimer) clearTimeout(fallbackTimer)
console.log('[camera] loadedmetadata 触发, readyState:', video.readyState, 'isStream:', video.srcObject === stream) console.log('[camera] loadedmetadata 触发, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
// 数据真正就绪后再补一次 play(), 兜底个别浏览器首次 play() 因无数据被打断
video.play()
}, },
{ once: true } { once: true }
) )
// 不手动调 video.play():
// 1. UTS 把 HTMLVideoElement.play() 类型当 void,链式 .catch 会报 undefined.catch
// 2. <video autoplay muted playsinline> + srcObject 已让浏览器自动起流
// 若某些浏览器不自动播放,在用户点击 shutter 等交互中再触发 play()
} }
export function stopCamera(video: HTMLVideoElement): void { export function stopCamera(video: HTMLVideoElement): void {
@@ -115,6 +117,148 @@ export function captureFrame(video: HTMLVideoElement): string {
return canvas.toDataURL('image/jpeg', 0.85) return canvas.toDataURL('image/jpeg', 0.85)
} }
/** 生成归一化一维高斯核 (半径 radius) */
function buildGaussianKernel(radius: number): number[] {
const size = radius * 2 + 1
const sigma = radius / 2
const kernel: number[] = new Array(size)
let sum = 0
for (let i = 0; i < size; i++) {
const x = i - radius
const v = Math.exp(-(x * x) / (2 * sigma * sigma))
kernel[i] = v
sum += v
}
for (let i = 0; i < size; i++) kernel[i] = kernel[i] / sum
return kernel
}
/**
* 对 RGBA 数组的竖直条带 [y0, y1) 做可分离高斯模糊, 返回新数组 (不修改原数组).
* 水平卷积范围向外扩 radius, 保证条带边界处垂直卷积有正确的邻域上下文.
*/
function gaussianBlurBand(
data: Uint8ClampedArray,
width: number,
height: number,
y0: number,
y1: number,
radius: number
): Uint8ClampedArray {
const kernel = buildGaussianKernel(radius)
const r = radius
const kSize = kernel.length
const hy0 = Math.max(0, y0 - r)
const hy1 = Math.min(height, y1 + r)
// 1) 水平卷积: 读 data, 写 h1 (仅 [hy0, hy1) 行, 其余照抄)
const h1 = new Uint8ClampedArray(data)
const row = new Array<number>(width * 4)
for (let y = hy0; y < hy1; y++) {
const base = y * width * 4
for (let x = 0; x < width; x++) {
let rr = 0
let gg = 0
let bb = 0
let aa = 0
for (let k = 0; k < kSize; k++) {
let sx = x + k - r
if (sx < 0) sx = 0
if (sx >= width) sx = width - 1
const idx = base + sx * 4
const wt = kernel[k]
rr += data[idx] * wt
gg += data[idx + 1] * wt
bb += data[idx + 2] * wt
aa += data[idx + 3] * wt
}
const o = x * 4
row[o] = rr
row[o + 1] = gg
row[o + 2] = bb
row[o + 3] = aa
}
for (let i = 0; i < width * 4; i++) h1[base + i] = row[i]
}
// 2) 垂直卷积: 读 h1, 写 h2 (仅 [y0, y1) 行, 其余照抄 h1)
const h2 = new Uint8ClampedArray(h1)
const band = y1 - y0
const col = new Array<number>(band * 4)
for (let x = 0; x < width; x++) {
for (let yy = 0; yy < band; yy++) {
const y = y0 + yy
let rr = 0
let gg = 0
let bb = 0
let aa = 0
for (let k = 0; k < kSize; k++) {
let sy = y + k - r
if (sy < 0) sy = 0
if (sy >= height) sy = height - 1
const idx = (sy * width + x) * 4
const wt = kernel[k]
rr += h1[idx] * wt
gg += h1[idx + 1] * wt
bb += h1[idx + 2] * wt
aa += h1[idx + 3] * wt
}
const o = yy * 4
col[o] = rr
col[o + 1] = gg
col[o + 2] = bb
col[o + 3] = aa
}
for (let yy = 0; yy < band; yy++) {
const idx = ((y0 + yy) * width + x) * 4
const o = yy * 4
h2[idx] = col[o]
h2[idx + 1] = col[o + 1]
h2[idx + 2] = col[o + 2]
h2[idx + 3] = col[o + 3]
}
}
return h2
}
/**
* 截图并同时生成两张图: 清晰版 + 竖直条带 [start, end] 高斯模糊版 (签到表脱敏用).
* 同一帧出两张, 避免两次截图不一致.
*/
export function captureFrameWithBlur(
video: HTMLVideoElement,
start: number,
end: number,
radius: number
): { sharp: string; blurred: string } {
const w = video.videoWidth || video.clientWidth
const h = video.videoHeight || video.clientHeight
if (!w || !h) {
throw new Error('视频流尚未就绪,无法截图')
}
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法获取 canvas 2D 上下文')
ctx.drawImage(video, 0, 0, w, h)
const sharp = canvas.toDataURL('image/jpeg', 0.85)
const y0 = Math.max(0, Math.floor(h * start))
const y1 = Math.min(h, Math.floor(h * end))
let blurred = sharp
if (y1 - y0 > 1) {
const imgData = ctx.getImageData(0, 0, w, h)
const dst = gaussianBlurBand(imgData.data, w, h, y0, y1, radius)
imgData.data.set(dst)
ctx.putImageData(imgData, 0, 0)
blurred = canvas.toDataURL('image/jpeg', 0.85)
}
return { sharp, blurred }
}
export function describeCameraError(err: unknown): string { export function describeCameraError(err: unknown): string {
const name = (err as DOMException)?.name || '' const name = (err as DOMException)?.name || ''
switch (name) { switch (name) {
+108
View File
@@ -0,0 +1,108 @@
/**
* 扫码拍照回传工具 (H5 only)
*
* 二维码把会议上下文塞进 URL query (ry-h5 hash 路由, base=/camera/):
* http://host:8090/camera/#/pages/panorama/index?meetingId=1&subType=L_PANORAMA_FRONT&apiBase=http%3A%2F%2Fhost%3A5173%2Fdev-api
*
* - getCameraParams(): 从 hash 里解析 meetingId / subType / apiBase
* - uploadCameraPhoto(base64): 拍照 Base64 → 直传 OSS → 回传 URL 到后端 /business/meetingMaterial/cameraUpload
*
* 公开上传, 无需 token (后端 SecurityConfig 已 permitAll + 白名单 subType 兜底).
*/
export type CameraParams = {
apiBase: string
meetingId: string
subType: string
}
/** 从 hash 路由的 query 里解析会议上下文 */
export function getCameraParams(): CameraParams {
const hash = window.location.hash || ''
const qs = hash.includes('?') ? hash.split('?')[1] : ''
const search = qs || (window.location.search || '').replace(/^\?/, '')
const sp = new URLSearchParams(search)
return {
apiBase: sp.get('apiBase') || '',
meetingId: sp.get('meetingId') || '',
subType: sp.get('subType') || '',
}
}
type OssSign = {
code: number
msg: string
host: string
dir: string
accessKeyId: string
policy: string
signature: string
expire: number
}
/** 拿 OSS 直传签名 (GET /common/oss/sign?dir=...) */
async function getOssSign(apiBase: string, dir: string): Promise<OssSign> {
const url = `${apiBase}/common/oss/sign?dir=${encodeURIComponent(dir)}`
const resp = await fetch(url)
const data = (await resp.json()) as OssSign
if (data.code !== 200) throw new Error(data.msg || 'OSS 签名失败')
return data
}
/** Base64 JPEG → 直传 OSS bucket, 返回完整 URL */
async function uploadBase64ToOss(apiBase: string, base64: string, dir: string): Promise<string> {
const sign = await getOssSign(apiBase, dir)
const key = sign.dir + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.jpg'
// data:image/jpeg;base64,... → Blob (fetch data URL 最省事, 免手写 base64 解码)
const blobResp = await fetch(base64)
const blob = await blobResp.blob()
const fd = new FormData()
fd.append('key', key)
fd.append('policy', sign.policy)
fd.append('OSSAccessKeyId', sign.accessKeyId)
fd.append('signature', sign.signature)
fd.append('success_action_status', '200')
fd.append('Content-Type', 'image/jpeg')
fd.append('file', blob, 'camera.jpg')
const resp = await fetch(sign.host, { method: 'POST', body: fd })
if (!resp.ok) throw new Error('OSS 上传失败: HTTP ' + resp.status)
return sign.host + '/' + key
}
/** 回传 OSS URL 到后端 (POST /business/meetingMaterial/cameraUpload) */
async function cameraUpload(
apiBase: string,
meetingId: string,
subType: string,
ossUrl: string,
extraOssUrl: string
): Promise<void> {
const resp = await fetch(`${apiBase}/business/meetingMaterial/cameraUpload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meetingId: Number(meetingId), subType, ossUrl, extraOssUrl }),
})
const data = (await resp.json()) as { code: number; msg: string }
if (data.code !== 200) throw new Error(data.msg || '照片回传失败')
}
/** 拍照后调用: 直传 OSS + 回传 URL 存库. 签到表额外上传高斯模糊版作为 extraOssUrl. */
export async function uploadCameraPhoto(base64: string, blurredBase64: string): Promise<void> {
const p = getCameraParams()
if (!p.apiBase) throw new Error('缺少 apiBase 参数')
if (!p.meetingId) throw new Error('缺少 meetingId 参数')
if (!p.subType) throw new Error('缺少 subType 参数')
const dir = `ry8080/meeting/${p.meetingId}/camera/`
const ossUrl = await uploadBase64ToOss(p.apiBase, base64, dir)
// 签到表: 额外上传脱敏版 (A4 20%~50% 高斯模糊), sponsor 只看这个隐藏手机号/身份证号
let extraOssUrl = ''
if (p.subType === 'L_SIGN_IN' && blurredBase64) {
extraOssUrl = await uploadBase64ToOss(p.apiBase, blurredBase64, dir + 'masked/')
}
await cameraUpload(p.apiBase, p.meetingId, p.subType, ossUrl, extraOssUrl)
}
+2
View File
@@ -0,0 +1,2 @@
# 开发环境 API 基路径 (Vite dev server proxy 前缀)
VITE_APP_BASE_API = '/dev-api'
+5
View File
@@ -0,0 +1,5 @@
# 生产环境 API 基路径 (nginx 反向代理前缀)
VITE_APP_BASE_API = '/hg-api'
# 生产环境静态资源子路径 (nginx 部署在 /hg/)
VITE_BASE = '/hg/'
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+10 -2
View File
@@ -1,13 +1,21 @@
import request from '@/utils/request' import request from '@/utils/request'
/** /**
* 当前登录用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空) * 当前登录用户的"待签署协议"会议列表 (已推送电子签 is_esigned=1 且 任一未签)
* 返回 [{id, meetingId, meetingName, startTime, ...}, ...] * 返回 [{id, meetingId, meetingName, startTime, isEsigned, ...}, ...]
*/ */
export function listUnsignedMeetingProtocols() { export function listUnsignedMeetingProtocols() {
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' }) return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
} }
/**
* 当前登录用户的"待参加"会议列表 (已邀请参会 is_invited=1)
* 返回 [{id, meetingId, meetingName, startTime, ...}, ...]
*/
export function listInvitedMeetings() {
return request({ url: '/business/meetingAttendee/invited', method: 'get' })
}
/** /**
* 更新手写签名 (Base64 字符串, 直接存 DB longtext) * 更新手写签名 (Base64 字符串, 直接存 DB longtext)
* @param {number|string} id 中间表主键 * @param {number|string} id 中间表主键
+27
View File
@@ -23,6 +23,33 @@ export function sponsorAssignProject(data) {
return request({ url: '/business/project/sponsorAssign', method: 'post', data }) return request({ url: '/business/project/sponsorAssign', method: 'post', data })
} }
/**
* 执行方分配执行人 (写 biz_project_executor_assign)
* POST /business/project/executorAssign
* body: { projectId, staffUserIds: [Long, ...] }
*/
export function executorAssignProject(data) {
return request({ url: '/business/project/executorAssign', method: 'post', data })
}
/**
* 查询项目已分配的执行人列表 (dialog 重开回显用)
* GET /business/project/{projectId}/executorAssigns
* 返回 BizProjectExecutorAssign[] 含 { staffUserId, staffUserName, ... }
*/
export function getExecutorAssigns(projectId) {
return request({ url: `/business/project/${projectId}/executorAssigns`, method: 'get' })
}
/**
* 查询项目已分配的监察员列表 (dialog 重开回显用)
* GET /business/project/{projectId}/sponsorAssigns
* 返回 BizProjectSponsorAssign[] 含 { monitorUserId, monitorUserName, ... }
*/
export function getSponsorAssigns(projectId) {
return request({ url: `/business/project/${projectId}/sponsorAssigns`, method: 'get' })
}
/** /**
* 支持方批量分配 (多个项目同一个监察员 + 同一份说明) * 支持方批量分配 (多个项目同一个监察员 + 同一份说明)
* body: [{projectId, monitorUserId, assignDesc, assignPoints}, ...] * body: [{projectId, monitorUserId, assignDesc, assignPoints}, ...]
+3
View File
@@ -8,6 +8,9 @@ import request from '@/utils/request'
export function getSignInfo(attendeeId) { export function getSignInfo(attendeeId) {
return request({ url: '/business/sign/info', method: 'get', params: { attendeeId } }) return request({ url: '/business/sign/info', method: 'get', params: { attendeeId } })
} }
export function resolveSign(meetingId) {
return request({ url: '/business/sign/resolve', method: 'get', params: { meetingId }, __silentError: true })
}
export function saveSignProfile(attendeeId, form) { export function saveSignProfile(attendeeId, form) {
return request({ url: '/business/sign/saveProfile', method: 'post', params: { attendeeId }, data: form, __silentError: true }) return request({ url: '/business/sign/saveProfile', method: 'post', params: { attendeeId }, data: form, __silentError: true })
} }
+166
View File
@@ -0,0 +1,166 @@
<!--
扫码拍照上传控件 (会议现场照片: 签到表 / 前全景 / 后全景)
区别于 OssFileUploader: 不本地选文件, 而是点按钮弹出二维码,
手机扫二维码进入 ry-h5 相机页拍照, 拍照后直传 OSS 并回传 URL 到后端
(后端 upsert biz_meeting_material), 本控件轮询 material 列表,
一旦该 subType ossUrl 出现即回写 v-model (同步到页面 r.url).
用法:
<CameraQrUpload
v-model="r.url"
:meeting-id="meetingId"
:sub-type="r.subType"
:label="r.label"
:readonly="isSponsor"
class="file-uploader"
/>
-->
<template>
<div class="camera-qr-upload" :class="{ readonly }">
<div class="camera-row">
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
<el-icon><CameraFilled /></el-icon>&nbsp;扫码拍照
</el-button>
<a v-if="modelValue" :href="modelValue" target="_blank" class="cam-link">查看照片</a>
<span v-else class="cam-empty">未上传</span>
<span v-if="polling" class="cam-polling">等待手机回传</span>
</div>
<el-dialog
v-model="visible"
:title="`扫码拍照 - ${label}`"
width="400px"
align-center
:close-on-click-modal="false"
@closed="onClosed"
>
<div class="qr-wrap">
<div v-if="qrLoading" class="qr-loading" v-loading="true"></div>
<img v-else-if="qrUrl" :src="qrUrl" class="qr-img" />
<div v-else class="qr-error">二维码生成失败, 请关闭重试</div>
<div class="qr-tip">用手机相机扫一扫, 进入{{ label }}拍照页</div>
<div class="qr-tip-sub">拍完点使用, 照片自动回传到本会议</div>
<div v-if="polling" class="qr-status">等待照片回传, 请保持本弹窗打开</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import request from '@/utils/request'
import { ElMessage } from 'element-plus'
import { CameraFilled } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
const props = defineProps({
modelValue: { type: String, default: '' },
meetingId: { type: [Number, String], default: '' },
subType: { type: String, required: true },
label: { type: String, default: '现场照片' },
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
/** subType → ry-h5 页面路由 (ry-h5 hash router, base=/camera/) */
const PAGE_PATH = {
L_SIGN_IN: '/pages/signin/index',
L_PANORAMA_FRONT: '/pages/panorama/index',
L_PANORAMA_BACK: '/pages/panorama/index'
}
const visible = ref(false)
const qrUrl = ref('')
const qrLoading = ref(false)
const polling = ref(false)
let pollTimer = null
let baseline = ''
/**
* 生成二维码并开始轮询.
* 目标 URL = baseUrl (后端 yml ruoyi.camera.base-url) + '#' + ry-h5 路由 + query.
* apiBase 用当前站点 origin + VITE_APP_BASE_API (与 request.js 一致), 手机端据此调后端接口.
* ⚠️ 开发环境要求: 执行方须用「局域网 IP」打开 ry-vue3 (如 http://192.168.x.x:5173),
* 否则二维码里的 apiBase 是 localhost, 手机扫出来 localhost 指向手机自身, 无法回传.
*/
async function openDialog() {
baseline = props.modelValue || ''
visible.value = true
qrLoading.value = true
qrUrl.value = ''
try {
const cfg = await request.get('/common/camera/config')
const base = (cfg && cfg.baseUrl) || ''
if (!base) throw new Error('相机网页地址未配置 (ruoyi.camera.base-url)')
const page = PAGE_PATH[props.subType] || '/pages/index/index'
const apiBase = encodeURIComponent(window.location.origin + import.meta.env.VITE_APP_BASE_API)
const target = `${base}#${page}?meetingId=${props.meetingId}&subType=${props.subType}&apiBase=${apiBase}`
qrUrl.value = await QRCode.toDataURL(target, {
width: 240, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' }
})
startPolling()
} catch (e) {
console.error('[camera-qr] gen qr failed', e)
ElMessage.error(e?.msg || e?.message || '二维码生成失败')
visible.value = false
} finally {
qrLoading.value = false
}
}
function startPolling() {
stopPolling()
polling.value = true
pollTimer = setInterval(pollOnce, 3000)
pollOnce()
}
/** 拉 material 列表, 该 subType 出现新 ossUrl (≠ 打开时基线) 即回写并关闭 */
async function pollOnce() {
try {
const resp = await request.get(`/business/meetingMaterial/${props.meetingId}`)
const list = (resp && (resp.data || resp)) || []
const row = (Array.isArray(list) ? list : []).find(m => m.subType === props.subType)
const url = row && row.ossUrl ? row.ossUrl : ''
if (url && url !== baseline) {
emit('update:modelValue', url)
ElMessage.success(`${props.label}已回传`)
stopPolling()
visible.value = false
}
} catch (e) {
/* 静默重试, 弹窗保持打开 */
}
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
polling.value = false
}
function onClosed() {
stopPolling()
}
onBeforeUnmount(stopPolling)
</script>
<style scoped>
.camera-qr-upload { width: 100%; min-width: 0; }
.camera-row {
display: flex; align-items: center; gap: 12px;
min-height: 36px;
}
.cam-link { color: var(--brand-primary); text-decoration: none; font-size: 13px; }
.cam-link:hover { text-decoration: underline; }
.cam-empty { color: #c0c4cc; font-size: 13px; }
.cam-polling { color: #e6a23c; font-size: 12px; }
.qr-wrap { text-align: center; padding: 8px 0 4px; }
.qr-loading { width: 240px; height: 240px; margin: 0 auto; }
.qr-img { width: 240px; height: 240px; }
.qr-error { width: 240px; height: 240px; margin: 0 auto; display: flex; align-items: center; justify-content: center; color: #f56c6c; font-size: 13px; }
.qr-tip { font-size: 14px; color: #1a1a1a; margin-top: 14px; }
.qr-tip-sub { font-size: 12px; color: #999; margin-top: 6px; }
.qr-status { font-size: 12px; color: #e6a23c; margin-top: 10px; }
</style>
+2 -2
View File
@@ -64,8 +64,8 @@ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
dir: { type: String, default: 'ry8080/idcard/' }, dir: { type: String, default: 'ry8080/idcard/' },
readonly: { type: Boolean, default: false }, readonly: { type: Boolean, default: false },
frontPlaceholder: { type: String, default: '/images/id-front.png' }, frontPlaceholder: { type: String, default: import.meta.env.BASE_URL + 'images/id-front.png' },
backPlaceholder: { type: String, default: '/images/id-back.png' } backPlaceholder: { type: String, default: import.meta.env.BASE_URL + 'images/id-back.png' }
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
+297
View File
@@ -0,0 +1,297 @@
<!--
共享通知列表组件
------------------------------------------------------------
自包含: 数据拉取 / SSE 实时刷新 / 详情弹窗 / 全部已读 全部封装在内.
任何角色页面 (workbench / home / 独立 messages ) 只需:
<NoticeList :limit="5" /> // 嵌入式小列表 (默认有"全部已读"顶部按钮)
<NoticeList :limit="50" :show-category="true" /> // 完整页列表 (显示分类标签)
<NoticeList :limit="5" :show-header="false" /> // 嵌入式,父 section-title 自己带按钮
Props:
limit Number 拉取条数,默认 5
showCategory Boolean 是否显示 [通知/待办/系统] 分类标签,默认 false
showHeader Boolean 是否显示组件自己的"全部已读"顶部按钮,默认 true
emptyText String 空列表文案,默认 '暂无通知'
复用: 调用方不再写 notice-item / dot / SSE 订阅 / 详情 dialog,3 处变 1 .
-->
<template>
<div class="notice-list-wrap">
<div v-if="showHeader" class="notice-list-header">
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
</div>
<ul class="notice-list">
<li
v-for="n in rows"
:key="n.id"
class="notice-item"
:class="{ read: n.read }"
@click="openDetail(n)"
>
<div class="dot"></div>
<div class="body">
<div class="title">
<span v-if="showCategory && n.category" class="cat" :class="catClass(n)">{{ n.category }}</span>
{{ n.title }}
</div>
<div class="text">{{ n.text }}</div>
</div>
<span class="time">{{ n.time }}</span>
</li>
<li v-if="!rows.length" class="empty">{{ emptyText }}</li>
</ul>
<el-dialog
v-model="detailOpen"
:title="detail.title || '消息详情'"
width="520px"
align-center
destroy-on-close
>
<div class="detail-body">
<div class="meta">
<span v-if="detail.category" class="cat" :class="catClass(detail)">{{ detail.category }}</span>
<span class="time">{{ detail.time }}</span>
</div>
<div class="text">{{ detail.content || detail.text || '-' }}</div>
<div v-if="detailLink" class="detail-link">
<img v-if="detailQrUrl" :src="detailQrUrl" class="detail-qr" alt="链接二维码" />
<div class="detail-link-row">
<el-link type="primary" :href="detailLink" target="_blank" :underline="false">打开链接</el-link>
<el-link type="primary" :underline="false" @click="copyDetailLink">复制链接</el-link>
</div>
</div>
</div>
<template #footer>
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
import { onGlobal } from '@/utils/sseClient'
const props = defineProps({
limit: { type: Number, default: 5 },
showCategory: { type: Boolean, default: false },
showHeader: { type: Boolean, default: true },
emptyText: { type: String, default: '暂无通知' }
})
const rows = ref([])
const detailOpen = ref(false)
const detail = ref({})
const detailLink = ref('')
const detailQrUrl = ref('')
const hasUnread = computed(() => rows.value.some(n => !n.read))
let unsubscribeNewMessage = null
async function load() {
try {
const r = await listMyMessages({ limit: props.limit })
rows.value = r.rows || []
} catch (e) {
rows.value = []
}
}
/** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见) */
async function refresh() {
try {
const r = await listMyMessages({ limit: props.limit })
rows.value = r.rows || []
} catch (e) { /* swallow */ }
}
/** 从消息正文里抽取第一个 http(s) 链接, 去尾随标点 */
function extractLink(content) {
if (!content) return ''
const m = String(content).match(/https?:\/\/[^\s"'<>]+/)
if (!m) return ''
return m[0].replace(/[。,、;:,.!?;:)\]]+$/, '')
}
/** 点列表项: 打开详情 + 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
function openDetail(n) {
detail.value = n
detailOpen.value = true
const link = extractLink(n.content)
detailLink.value = link
detailQrUrl.value = ''
if (link) {
QRCode.toDataURL(link, { width: 200, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' } })
.then(url => { detailQrUrl.value = url })
.catch(() => { detailQrUrl.value = '' })
}
if (!n.read) {
n.read = true
markMessageRead(n.id).catch(() => { n.read = false })
}
}
/** 复制消息正文中的链接 */
async function copyDetailLink() {
if (!detailLink.value) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(detailLink.value)
} else {
const ta = document.createElement('textarea')
ta.value = detailLink.value
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
ElMessage.success('链接已复制')
} catch (e) {
ElMessage.error('复制失败, 请手动复制')
}
}
/** 全部已读: 后端 markAllRead (后端会推 silent=true SSE, 角标自动清零) + 本地乐观更新 */
async function markAllRead() {
try {
await markAllMessagesRead()
rows.value = rows.value.map(n => ({ ...n, read: true }))
} catch (e) {
ElMessage.error('全部已读失败, 请重试')
}
}
/** type 字段 (后端 BizMessage.msgType): '1'通知 '2'待办 '3'系统 */
function catClass(n) {
if (n.type === '1') return 'cat-notif'
if (n.type === '2') return 'cat-todo'
if (n.type === '3') return 'cat-sys'
return ''
}
onMounted(() => {
load()
// SSE 全局总线 (AdminLayout 维护连接, 这里订阅刷新本组件列表即可)
// toast 由 AdminLayout 统一弹, 这里只负责本组件 list 实时刷新
unsubscribeNewMessage = onGlobal(() => {
refresh()
})
})
onBeforeUnmount(() => {
if (unsubscribeNewMessage) unsubscribeNewMessage()
})
</script>
<style scoped>
.notice-list-wrap { display: flex; flex-direction: column; }
.notice-list-header {
display: flex;
justify-content: flex-end;
padding: 4px 0 8px;
}
/* 列表 (跟 doctor/Home.vue 旧内联样式保持像素一致) */
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
.notice-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.notice-item:last-child { border-bottom: none; }
.notice-item:hover .title { color: var(--brand-primary); }
.notice-item .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--brand-primary);
flex-shrink: 0;
}
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
.notice-item .body { flex: 1; min-width: 0; }
.notice-item .title {
font-size: 13px;
color: #1a1a1a;
display: flex;
align-items: center;
gap: 8px;
}
.notice-item.read .title { color: #8c8c8c; }
.notice-item .text {
font-size: 12px;
color: #595959;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.notice-item .time {
font-size: 12px;
color: #bfbfbf;
flex-shrink: 0;
}
/* 分类标签 (按后端 msgType 染色: 1=通知蓝 2=待办橙 3=系统紫) */
.cat {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 2px;
background: #f0f0f0;
color: #595959;
flex-shrink: 0;
}
.cat-sys { background: #f9f0ff; color: #722ed1; }
.cat-todo { background: #fff7e6; color: #fa8c16; }
.cat-notif { background: #e6f7ff; color: var(--brand-primary); }
.empty {
padding: 24px 0;
text-align: center;
color: #bfbfbf;
font-size: 13px;
}
/* 详情 dialog */
.detail-body .meta {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.detail-body .meta .time {
font-size: 12px;
color: #8c8c8c;
}
.detail-body .text {
font-size: 14px;
color: #1a1a1a;
line-height: 1.6;
white-space: pre-wrap;
}
.detail-link {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
text-align: center;
}
.detail-link .detail-qr {
width: 200px;
height: 200px;
display: block;
margin: 0 auto 12px;
}
.detail-link-row {
display: flex;
gap: 20px;
justify-content: center;
}
</style>
@@ -15,6 +15,9 @@
<span class="placeholder-text">{{ placeholder }}</span> <span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span> <span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div> </div>
<div v-else-if="!modelValue && readonly" class="ht-file-placeholder readonly-empty">
<span class="placeholder-text">未上传</span>
</div>
<div v-else-if="modelValue" class="ht-file-info"> <div v-else-if="modelValue" class="ht-file-info">
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor"> <el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/> <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
+1 -3
View File
@@ -78,9 +78,7 @@
</div> </div>
<div class="footer-qr"> <div class="footer-qr">
<div class="qr-image"> <div class="qr-image">
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor"> <img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
</svg>
</div> </div>
<div class="qr-label">公众号二维码</div> <div class="qr-label">公众号二维码</div>
</div> </div>
@@ -0,0 +1,128 @@
<template>
<!--
项目级角色多选控件 ("其他"逃生口)
数据来源: 父组件传入 props.roles (biz_project.role_labor JSON, 形如 [{role, customName, amount}])
v-model = 逗号分隔字符串 ( "主席,主持,特约嘉宾"), "其他"自定义角色作为普通项存储.
行为契约:
- 勾选项目角色 该项进入 modelValue (用角色名 label)
- 勾选 "其他" (控件自己虚拟追加) 下方 el-input 出现 输入内容作为普通项进入 modelValue
- 回填: modelValue 里不在项目角色列表里的项 自动按 "其他 + input" 显示
- 兼容旧数据: modelValue 若为 JSON 数组字符串 (["主席","__other__:xx"]) 也能解析
用法:
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
-->
<div class="project-role-multi">
<el-checkbox-group v-model="selectedKeys" @change="onToggle">
<el-checkbox v-for="o in displayOptions" :key="o.key" :value="o.key" :label="o.label" />
</el-checkbox-group>
<el-input
v-if="showOtherInput"
v-model="otherText"
placeholder="请输入其他角色"
class="other-input"
maxlength="50"
@input="emitValue"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
roles: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'change'])
// "其他" 虚拟项内部 key — 不暴露给父组件, 仅用于区分选中状态
const OTHER_KEY = '__other__'
// 把 props.roles 投影成 {key, label} 数组 (label = role 或"其他"的 customName)
const projectOptions = computed(() => {
return (props.roles || [])
.filter(r => r && (r.role || r.customName))
.map(r => {
if (r.role === '其他') {
const customName = (r.customName || '').trim()
return { key: customName, label: customName || '其他' }
}
return { key: r.role, label: r.role }
})
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
.filter(o => o.key !== OTHER_KEY)
})
const selectedKeys = ref([])
const otherText = ref('')
const showOtherInput = computed(() => selectedKeys.value.includes(OTHER_KEY))
const displayOptions = computed(() => [...projectOptions.value, { key: OTHER_KEY, label: '其他' }])
/** 构建逗号分隔字符串 ("其他"自定义内容作为普通项) */
function buildValue() {
return selectedKeys.value
.map(k => (k === OTHER_KEY ? (otherText.value || '').trim() : k))
.filter(x => x !== '')
.join(',')
}
/** 解析 modelValue (兼容 JSON 数组 + 逗号分隔) → selectedKeys + otherText */
function syncFromModelValue(v) {
let items = []
if (typeof v === 'string' && v.trim().startsWith('[')) {
try { const p = JSON.parse(v); if (Array.isArray(p)) items = p } catch (e) { items = [v] }
} else if (v) {
items = String(v).split(',').map(s => s.trim()).filter(Boolean)
}
const selected = []
let other = ''
const known = new Set(projectOptions.value.map(o => o.key))
items.forEach(it => {
const s = String(it)
if (s === OTHER_KEY || s.startsWith(OTHER_KEY + ':')) {
if (!selected.includes(OTHER_KEY)) selected.push(OTHER_KEY)
if (s.startsWith(OTHER_KEY + ':')) other = s.substring((OTHER_KEY + ':').length)
} else if (known.has(s)) {
if (!selected.includes(s)) selected.push(s)
} else {
// 不在项目角色列表里 → "其他"自定义
if (!selected.includes(OTHER_KEY)) selected.push(OTHER_KEY)
other = s
}
})
selectedKeys.value = selected
otherText.value = other
}
const lastEmitted = ref('')
function emitValue() {
const v = buildValue()
lastEmitted.value = v
emit('update:modelValue', v)
emit('change', v)
}
function onToggle() { emitValue() }
// 初始化 + 响应外部变化 (跳过自己 emit 的回显, 避免重解析覆盖用户输入)
syncFromModelValue(props.modelValue)
watch(() => props.modelValue, v => {
if (v === lastEmitted.value) return
syncFromModelValue(v)
})
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
</script>
<style scoped>
.project-role-multi { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.other-input { width: 100%; }
</style>
@@ -1,153 +0,0 @@
<template>
<!--
项目级角色下拉控件 ("其他"逃生口)
数据来源: 父组件传入 props.roles (来自 biz_project.role_labor JSON, 形如 [{role, customName, amount}])
- role === '其他' 显示 customName 作为 label
- role !== '其他' 显示 role 作为 label
行为契约 (前端控件自管理, 父组件无感知):
- 标准项目角色: 用户从下拉选 v-model = 该角色名
- "其他" (控件自己虚拟追加, 不来自项目定义): 下方出现 el-input v-model = input 内容
- 回填: modelValue 不在项目角色列表里 自动按 "其他 + input" 显示, input 预填原值
用法:
<project-role-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
-->
<div class="project-role-select">
<el-select
v-model="selectedKey"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="filterable"
style="width: 100%"
@change="handleSelectChange"
@clear="handleClear"
>
<el-option
v-for="o in displayOptions"
:key="o.key"
:label="o.label"
:value="o.key"
/>
</el-select>
<el-input
v-if="showOtherInput"
v-model="otherText"
:placeholder="otherPlaceholder"
class="other-input"
maxlength="50"
@input="emitValue"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
roles: { type: Array, default: () => [] },
placeholder: { type: String, default: '请选择角色' },
otherPlaceholder: { type: String, default: '请输入角色名' },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
filterable: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'change'])
// "其他" 虚拟项的内部 key — 不暴露给父组件, 仅用于区分 selectedKey 状态
const OTHER_KEY = '__other_role__'
// 把 props.roles 投影成 {key, label} 数组
// - role === '其他' → label = customName (项目里实际填的"自定义角色名")
// - role !== '其他' → label = role
const projectOptions = computed(() => {
return (props.roles || [])
.filter(r => r && (r.role || r.customName))
.map(r => {
if (r.role === '其他') {
const customName = (r.customName || '').trim()
return { key: customName, label: customName || '其他' }
}
return { key: r.role, label: r.role }
})
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
.filter(o => o.key !== OTHER_KEY)
})
const selectedKey = ref(props.modelValue || null)
const otherText = ref(props.modelValue || '')
const showOtherInput = computed(() => selectedKey.value === OTHER_KEY)
// 给 el-select 渲染的 options = 项目角色 + 末尾虚拟"其他"
const displayOptions = computed(() => [
...projectOptions.value,
{ key: OTHER_KEY, label: '其他' }
])
function isValueInOptions(v) {
if (v == null || v === '') return false
return projectOptions.value.some(o => o.key === v)
}
function syncFromModelValue(v) {
if (v == null || v === '') {
selectedKey.value = null
otherText.value = ''
return
}
if (isValueInOptions(v)) {
selectedKey.value = v
otherText.value = ''
} else {
// 不在项目角色列表里 → 走 "其他" 路径
selectedKey.value = OTHER_KEY
otherText.value = v
}
}
function emitValue() {
let v
if (selectedKey.value === OTHER_KEY) {
v = otherText.value || ''
} else if (selectedKey.value == null) {
v = ''
} else {
v = selectedKey.value
}
emit('update:modelValue', v)
emit('change', v)
}
function handleSelectChange(key) {
if (key === OTHER_KEY && selectedKey.value !== OTHER_KEY) {
otherText.value = ''
}
selectedKey.value = key
emitValue()
}
function handleClear() {
selectedKey.value = null
otherText.value = ''
emitValue()
}
// 初始化 + 响应外部变化
syncFromModelValue(props.modelValue)
watch(() => props.modelValue, v => syncFromModelValue(v))
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
</script>
<style scoped>
.project-role-select { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.other-input { width: 100%; }
</style>
+8 -4
View File
@@ -94,11 +94,11 @@ async function loadInitialUnread() {
/** 各角色点 bell 跳到自己的消息页 */ /** 各角色点 bell 跳到自己的消息页 */
const NOTICE_PATH = { const NOTICE_PATH = {
admin: '/admin/workbench', // admin 暂未建独立消息页, 落工作台 admin: '/admin/messages',
manager: '/manager/workbench', // manager 同上 (Phase 7 补 /manager/messages) manager: '/manager/messages',
doctor: '/doctor/messages', doctor: '/doctor/messages',
executor: '/executor/overview', // executor 同 admin/manager 暂用工作台 executor: '/executor/messages',
sponsor: '/sponsor/home' // sponsor 同上 sponsor: '/sponsor/messages'
} }
function goNotice() { function goNotice() {
const p = NOTICE_PATH[role.value] || '/' const p = NOTICE_PATH[role.value] || '/'
@@ -177,6 +177,7 @@ const MENU = {
{ path: '/admin/special-plan', title: '专项计划管理', icon: Compass }, { path: '/admin/special-plan', title: '专项计划管理', icon: Compass },
{ path: '/admin/labor-protocol', title: '劳务协议配置', icon: Document } { path: '/admin/labor-protocol', title: '劳务协议配置', icon: Document }
]}, ]},
{ path: '/admin/messages', title: '消息通知', icon: Bell },
{ path: '/admin/account', title: '账号信息', icon: User } { path: '/admin/account', title: '账号信息', icon: User }
], ],
manager: [ manager: [
@@ -189,6 +190,7 @@ const MENU = {
{ path: '/manager/executor-orgs', title: '服务机构管理', icon: OfficeBuilding }, { path: '/manager/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets }, { path: '/manager/support-intent', title: '支持意向', icon: Tickets },
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets }, { path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
{ path: '/manager/messages', title: '消息通知', icon: Bell },
{ path: '/manager/accounts', title: '账号管理', icon: Setting } { path: '/manager/accounts', title: '账号管理', icon: Setting }
], ],
doctor: [ doctor: [
@@ -206,6 +208,7 @@ const MENU = {
{ path: '/executor/projects', title: '项目列表', icon: Document }, { path: '/executor/projects', title: '项目列表', icon: Document },
{ path: '/executor/people', title: '人员管理', icon: User, requireMain: true }, { path: '/executor/people', title: '人员管理', icon: User, requireMain: true },
{ path: '/executor/labor', title: '劳务凭证', icon: EditPen }, { path: '/executor/labor', title: '劳务凭证', icon: EditPen },
{ path: '/executor/messages', title: '消息通知', icon: Bell },
{ path: '/executor/account', title: '账号信息', icon: Setting } { path: '/executor/account', title: '账号信息', icon: Setting }
], ],
sponsor: [ sponsor: [
@@ -213,6 +216,7 @@ const MENU = {
{ path: '/sponsor/my-projects', title: '我的项目', icon: Document }, { path: '/sponsor/my-projects', title: '我的项目', icon: Document },
{ path: '/sponsor/meetings', title: '会议列表', icon: Calendar }, { path: '/sponsor/meetings', title: '会议列表', icon: Calendar },
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true }, { path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
{ path: '/sponsor/messages', title: '消息通知', icon: Bell },
{ path: '/sponsor/account', title: '账号信息', icon: Setting } { path: '/sponsor/account', title: '账号信息', icon: Setting }
] ]
} }
+15 -4
View File
@@ -53,6 +53,7 @@ const routes = [
{ path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } }, { path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } },
{ path: 'labor-protocol/new', name: 'admin-labor-protocol-new', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '新建劳务协议模板' } }, { path: 'labor-protocol/new', name: 'admin-labor-protocol-new', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '新建劳务协议模板' } },
{ path: 'labor-protocol/edit/:id', name: 'admin-labor-protocol-edit', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '编辑劳务协议模板' } }, { path: 'labor-protocol/edit/:id', name: 'admin-labor-protocol-edit', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '编辑劳务协议模板' } },
{ path: 'messages', name: 'admin-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } } { path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
] ]
}, },
@@ -84,6 +85,7 @@ const routes = [
{ path: 'executor-people/view/:id', name: 'manager-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } }, { path: 'executor-people/view/:id', name: 'manager-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'support-intent', name: 'manager-support-intent', component: () => import('@/views/manager/SupportIntent.vue'), meta: { title: '支持意向' } }, { path: 'support-intent', name: 'manager-support-intent', component: () => import('@/views/manager/SupportIntent.vue'), meta: { title: '支持意向' } },
{ path: 'exec-intent', name: 'manager-exec-intent', component: () => import('@/views/manager/ExecIntent.vue'), meta: { title: '执行意向' } }, { path: 'exec-intent', name: 'manager-exec-intent', component: () => import('@/views/manager/ExecIntent.vue'), meta: { title: '执行意向' } },
{ path: 'messages', name: 'manager-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
{ path: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } } { path: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } }
] ]
}, },
@@ -92,7 +94,7 @@ const routes = [
{ path: 'home', name: 'doctor-home', component: () => import('@/views/doctor/Home.vue'), meta: { title: '首页' } }, { path: 'home', name: 'doctor-home', component: () => import('@/views/doctor/Home.vue'), meta: { title: '首页' } },
{ path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } }, { path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } },
{ path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } }, { path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } },
{ path: 'messages', name: 'doctor-messages', component: () => import('@/views/doctor/Messages.vue'), meta: { title: '消息通知' } }, { path: 'messages', name: 'doctor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
{ path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目设计投稿' } }, { path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目设计投稿' } },
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目设计投稿' } }, { path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目设计投稿' } },
{ path: 'submission/detail/:planId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '投稿详情' } }, { path: 'submission/detail/:planId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '投稿详情' } },
@@ -107,13 +109,17 @@ const routes = [
{ path: '', redirect: { name: 'executor-overview' } }, { path: '', redirect: { name: 'executor-overview' } },
{ path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } }, { path: 'overview', name: 'executor-overview', component: () => import('@/views/executor/Overview.vue'), meta: { title: '首页' } },
{ path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议执行' } }, { path: 'meetings', name: 'executor-meetings', component: () => import('@/views/executor/Meetings.vue'), meta: { title: '会议执行' } },
{ path: 'meetings/new', name: 'executor-meetings-new', component: () => import('@/views/meetings/MeetingNew.vue'), meta: { title: '新建会议' } },
{ path: 'meetings/detail/:meetingId', name: 'executor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'projects', name: 'executor-projects', component: () => import('@/views/executor/Projects.vue'), meta: { title: '项目列表' } }, { path: 'projects', name: 'executor-projects', component: () => import('@/views/executor/Projects.vue'), meta: { title: '项目列表' } },
{ path: 'projects/detail/:projectId', name: 'executor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'people', name: 'executor-people', component: () => import('@/views/executor/People.vue'), meta: { title: '人员管理' } }, { path: 'people', name: 'executor-people', component: () => import('@/views/executor/People.vue'), meta: { title: '人员管理' } },
{ path: 'people/new', name: 'executor-people-new', component: () => import('@/views/executor/NewPerson.vue'), meta: { title: '新建人员' } }, { path: 'people/new', name: 'executor-people-new', component: () => import('@/views/executor/NewPerson.vue'), meta: { title: '新建人员' } },
{ path: 'people/edit/:id', name: 'executor-people-edit', component: () => import('@/views/executor/NewPerson.vue'), meta: { title: '编辑人员' } }, { path: 'people/edit/:id', name: 'executor-people-edit', component: () => import('@/views/executor/NewPerson.vue'), meta: { title: '编辑人员' } },
{ path: 'people/detail/:id', name: 'executor-people-detail', component: () => import('@/views/executor/PersonDetail.vue'), meta: { title: '人员详情' } }, { path: 'people/detail/:id', name: 'executor-people-detail', component: () => import('@/views/executor/PersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'account', name: 'executor-account', component: () => import('@/views/executor/Account.vue'), meta: { title: '账号信息' } }, { path: 'account', name: 'executor-account', component: () => import('@/views/executor/Account.vue'), meta: { title: '账号信息' } },
{ path: 'labor', name: 'executor-labor', component: () => import('@/views/executor/Labor.vue'), meta: { title: '劳务凭证' } } { path: 'labor', name: 'executor-labor', component: () => import('@/views/executor/Labor.vue'), meta: { title: '劳务凭证' } },
{ path: 'messages', name: 'executor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } }
] ]
}, },
{ path: '/sponsor', component: AdminLayout, meta: { role: 'sponsor' }, children: [ { path: '/sponsor', component: AdminLayout, meta: { role: 'sponsor' }, children: [
@@ -121,12 +127,15 @@ const routes = [
{ path: 'home', name: 'sponsor-home', component: () => import('@/views/sponsor/Home.vue'), meta: { title: '首页' } }, { path: 'home', name: 'sponsor-home', component: () => import('@/views/sponsor/Home.vue'), meta: { title: '首页' } },
{ path: 'projects', name: 'sponsor-projects', component: () => import('@/views/sponsor/Projects.vue'), meta: { title: '项目管理' } }, { path: 'projects', name: 'sponsor-projects', component: () => import('@/views/sponsor/Projects.vue'), meta: { title: '项目管理' } },
{ path: 'my-projects', name: 'sponsor-my-projects', component: () => import('@/views/sponsor/SponsorProjects.vue'), meta: { title: '我的项目' } }, { path: 'my-projects', name: 'sponsor-my-projects', component: () => import('@/views/sponsor/SponsorProjects.vue'), meta: { title: '我的项目' } },
{ path: 'meetings', name: 'sponsor-meetings', component: () => import('@/views/sponsor/Meetings.vue'), meta: { title: '会议列表' } }, { path: 'projects/detail/:projectId', name: 'sponsor-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'meetings', name: 'sponsor-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议列表' } },
{ path: 'meetings/detail/:meetingId', name: 'sponsor-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'people', name: 'sponsor-people', component: () => import('@/views/sponsor/SponsorPeople.vue'), meta: { title: '人员管理' } }, { path: 'people', name: 'sponsor-people', component: () => import('@/views/sponsor/SponsorPeople.vue'), meta: { title: '人员管理' } },
{ path: 'people/new', name: 'sponsor-people-new', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '新建人员' } }, { path: 'people/new', name: 'sponsor-people-new', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '新建人员' } },
{ path: 'people/edit/:id', name: 'sponsor-people-edit', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '编辑人员' } }, { path: 'people/edit/:id', name: 'sponsor-people-edit', component: () => import('@/views/sponsor/NewPerson.vue'), meta: { title: '编辑人员' } },
{ path: 'people/detail/:id', name: 'sponsor-people-detail', component: () => import('@/views/sponsor/PersonDetail.vue'), meta: { title: '人员详情' } }, { path: 'people/detail/:id', name: 'sponsor-people-detail', component: () => import('@/views/sponsor/PersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'account', name: 'sponsor-account', component: () => import('@/views/sponsor/Account.vue'), meta: { title: '账号信息' } } { path: 'account', name: 'sponsor-account', component: () => import('@/views/sponsor/Account.vue'), meta: { title: '账号信息' } },
{ path: 'messages', name: 'sponsor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } }
] ]
}, },
{ path: '/:pathMatch(.*)*', redirect: '/' } { path: '/:pathMatch(.*)*', redirect: '/' }
@@ -143,6 +152,8 @@ router.beforeEach((to, from, next) => {
if (!to.meta?.role) return next() if (!to.meta?.role) return next()
// 需要登录的路由: 未登录跳登录 (带 redirect) // 需要登录的路由: 未登录跳登录 (带 redirect)
const user = JSON.parse(localStorage.getItem('ry_user') || 'null') const user = JSON.parse(localStorage.getItem('ry_user') || 'null')
// 扫码带 token 直登 (签劳务): 放行, 由页面 onMounted 用 token 完成登录
if (!user && to.query?.token) return next()
if (!user) return next({ name: 'login', query: { redirect: to.fullPath } }) if (!user) return next({ name: 'login', query: { redirect: to.fullPath } })
// 已登录: role 不匹配由后端 401 拦截, 不在前端强跳 (避免误判让用户卡死) // 已登录: role 不匹配由后端 401 拦截, 不在前端强跳 (避免误判让用户卡死)
next() next()
+131
View File
@@ -0,0 +1,131 @@
/**
* 会议阶段显示 (事实驱动, 与后端 StageDeriver 镜像).
*
* biz_meeting 现在存「事实」: is_executed / is_frozen / is_settled / is_finished
* + material_audit_stage / voucher_audit_stage (4 值: NOT_SUBMITTED/SUBMITTED/APPROVED/REJECTED)
* + material/voucher_compliance_approved (区分两级审核) + 审核时间.
*
* 各角色看到的「阶段名称」由这些事实实时推导, 不再是单一 current_stage 枚举投影.
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
*/
const H24 = 24 * 3600 * 1000
function isTrue(v) {
return v === 1 || v === '1' || v === true
}
/** 待结算: 材料+凭证都通过 且 最晚审核时间已超 24h */
function settlementReady(row) {
if (row.voucherAuditStage !== 'APPROVED') return false
const mat = row.materialAuditTime ? new Date(row.materialAuditTime).getTime() : 0
const vch = row.voucherAuditTime ? new Date(row.voucherAuditTime).getTime() : 0
const later = Math.max(mat, vch)
if (!later) return false
return Date.now() - later >= H24
}
/**
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
*/
export function derivePhysicalStage(row) {
if (!row) return 'NOT_STARTED'
if (isTrue(row.isFrozen)) return 'FROZEN'
if (isTrue(row.isFinished)) return 'FINISHED'
if (isTrue(row.isSettled)) return 'SETTLED'
const material = row.materialAuditStage
if (material === 'REJECTED') return 'RECTIFYING'
if (material === 'APPROVED') return settlementReady(row) ? 'AWAITING_SETTLEMENT' : 'SUPERVISION_APPROVED'
if (material === 'SUBMITTED') return isTrue(row.materialComplianceApproved) ? 'AWAITING_SUPERVISION' : 'AWAITING_COMPLIANCE'
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
}
/**
* 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay).
* role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性.
*/
export function deriveStage(role, row) {
if (!row) return '-'
if (isTrue(row.isFrozen)) return '冻结中'
if (isTrue(row.isFinished)) return '已完结'
if (isTrue(row.isSettled)) return '已结算'
const material = row.materialAuditStage
// 退回: 执行方看「已退回」, 其他方看「待整改」
if (material === 'REJECTED') {
return role === 'executor' ? '已退回' : '待整改'
}
// 材料已支持方通过
if (material === 'APPROVED') {
return settlementReady(row) ? '待结算' : '审核通过'
}
// 材料在审 (SUBMITTED)
if (material === 'SUBMITTED') {
if (isTrue(row.materialComplianceApproved)) {
// 支持方审中
return role === 'manager' ? '审核通过' : '待审核'
}
// 合规审中
return role === 'sponsor' ? '已执行未传材料' : '待审核'
}
// 未提交
if (isTrue(row.isExecuted)) {
return role === 'executor' ? '执行中' : '已执行未传材料'
}
return '未执行'
}
/**
* 兼容旧签名: stageLabel(role, row) — row 现在传完整会议对象 (含事实字段).
*/
export function stageLabel(role, row) {
return deriveStage(role, row)
}
/** 颜色 class (业务专用 tag), 基于物理阶段 */
export function stageClass(row) {
const s = derivePhysicalStage(row)
if (s === 'NOT_STARTED') return 'pending'
if (s === 'RUNNING') return 'running'
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'reviewing'
if (s === 'SUPERVISION_APPROVED') return 'done'
if (s === 'RECTIFYING') return 'waiting'
if (s === 'AWAITING_SETTLEMENT') return 'waiting'
if (s === 'SETTLED') return 'done'
if (s === 'FINISHED') return 'done'
if (s === 'FROZEN') return 'frozen'
return 'default'
}
/** el-tag type (doctor 列表用), 基于物理阶段 */
export function stageTag(row) {
const s = derivePhysicalStage(row)
if (s === 'NOT_STARTED') return 'info'
if (s === 'RUNNING' || s === 'RECTIFYING') return 'primary'
if (s === 'AWAITING_COMPLIANCE' || s === 'AWAITING_SUPERVISION') return 'warning'
if (s === 'SUPERVISION_APPROVED' || s === 'SETTLED' || s === 'FINISHED') return 'success'
if (s === 'AWAITING_SETTLEMENT') return 'warning'
if (s === 'FROZEN') return 'info'
return 'info'
}
/**
* 物理状态机全量 10 值 (用于筛选下拉: label=中性名, value=physical code).
* 筛选按物理状态精确匹配, 显示层才按角色折叠, 避免多值 IN 复杂化.
*/
export const STAGE_OPTIONS = [
{ label: '未执行', value: 'NOT_STARTED' },
{ label: '执行中', value: 'RUNNING' },
{ label: '待合规审核', value: 'AWAITING_COMPLIANCE' },
{ label: '待支持方审核', value: 'AWAITING_SUPERVISION' },
{ label: '审核通过', value: 'SUPERVISION_APPROVED' },
{ label: '待整改', value: 'RECTIFYING' },
{ label: '待结算', value: 'AWAITING_SETTLEMENT' },
{ label: '已结算', value: 'SETTLED' },
{ label: '已完结', value: 'FINISHED' },
{ label: '冻结中', value: 'FROZEN' },
]
+2 -2
View File
@@ -2,7 +2,7 @@ import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const request = axios.create({ const request = axios.create({
baseURL: '/dev-api', baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 15000 timeout: 15000
}) })
@@ -34,7 +34,7 @@ request.interceptors.response.use(
localStorage.removeItem('ry_user') localStorage.removeItem('ry_user')
// 带 redirect 跳登录, 登录成功后回跳原页面 // 带 redirect 跳登录, 登录成功后回跳原页面
const redirect = window.location.hash.slice(1) || '' const redirect = window.location.hash.slice(1) || ''
window.location.href = '/#/login' + (redirect ? '?redirect=' + encodeURIComponent(redirect) : '') window.location.href = import.meta.env.BASE_URL + '#/login' + (redirect ? '?redirect=' + encodeURIComponent(redirect) : '')
} }
// __silentError: 调用方已经接管错误 toast (如把红错改成黄警), 拦截器不再重复弹 // __silentError: 调用方已经接管错误 toast (如把红错改成黄警), 拦截器不再重复弹
if (!res.config?.__silentError) { if (!res.config?.__silentError) {
+1 -1
View File
@@ -8,7 +8,7 @@
import { fetchEventSource } from '@microsoft/fetch-event-source' import { fetchEventSource } from '@microsoft/fetch-event-source'
const SSE_PATH = '/dev-api/business/message/stream' const SSE_PATH = import.meta.env.VITE_APP_BASE_API + '/business/message/stream'
// 监听器表 (允许多组件订阅) // 监听器表 (允许多组件订阅)
const listeners = { new_message: new Set(), connected: new Set() } const listeners = { new_message: new Set(), connected: new Set() }
+1 -1
View File
@@ -88,7 +88,7 @@ function reset() {
function preview(row) { function preview(row) {
// 启用的文章才允许预览(实际注册页只看启用的), 这里做软拦截 // 启用的文章才允许预览(实际注册页只看启用的), 这里做软拦截
window.open(`/#/article/${row.type}`, '_blank') window.open(`${import.meta.env.BASE_URL}#/article/${row.type}`, '_blank')
} }
onMounted(load) onMounted(load)
@@ -88,7 +88,7 @@ function reset() {
} }
function preview(row) { function preview(row) {
window.open(`/#/special-plan/${row.id}`, '_blank') window.open(`${import.meta.env.BASE_URL}#/special-plan/${row.id}`, '_blank')
} }
onMounted(load) onMounted(load)
+4
View File
@@ -513,7 +513,11 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
cursor: pointer;
text-decoration: none;
transition: opacity 0.2s;
} }
.login-page .logo:hover{ opacity: 0.7; }
.login-page .logo-icon{ .login-page .logo-icon{
width: 36px; width: 36px;
+21 -124
View File
@@ -37,52 +37,32 @@
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a> <a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
</h2> </h2>
<ul class="simple-list"> <ul class="simple-list">
<li class="simple-item" v-for="(s, idx) in pendingAgreements" :key="s.id" @click="showQrcode(s, idx)"> <li
class="simple-item"
:class="{ disabled: s.isEsigned !== 1 }"
v-for="(s, idx) in pendingAgreements"
:key="s.id"
@click="s.isEsigned === 1 && showQrcode(s, idx)"
>
<div class="item-main"> <div class="item-main">
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span> <span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div> </div>
<span class="item-status">待签署</span> <span class="item-status">{{ s.isEsigned === 1 ? '待签署' : '未推送' }}</span>
</li> </li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li> <li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul> </ul>
</div> </div>
</div> </div>
<!-- 通知消息 --> <!-- 通知消息 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
<section class="section"> <section class="section">
<h2 class="section-title"> <h2 class="section-title">
通知消息 通知消息
<span class="title-actions">
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 </a> <a class="more" @click.prevent="$router.push('/doctor/messages')">更多 </a>
</span>
</h2> </h2>
<ul class="notice-list"> <NoticeList :limit="5" :show-header="false" />
<li class="notice-item" :class="{ read: n.read }" v-for="n in notices" :key="n.id" @click="openDetail(n)">
<div class="dot"></div>
<div class="body">
<div class="title">{{ n.title }}</div>
<div class="text">{{ n.text }}</div>
</div>
<span class="time">{{ n.time }}</span>
</li>
<li v-if="!notices.length" class="empty">暂无通知</li>
</ul>
</section> </section>
<!-- 消息详情 dialog (点列表项触发) -->
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px" align-center destroy-on-close>
<div class="detail-body">
<div class="meta">
<span class="time">{{ detail.time }}</span>
</div>
<div class="text">{{ detail.text || detail.content || '-' }}</div>
</div>
<template #footer>
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
</template>
</el-dialog>
<!-- 二维码弹窗 --> <!-- 二维码弹窗 -->
<el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close> <el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close>
<div class="qrcode-wrap"> <div class="qrcode-wrap">
@@ -106,16 +86,14 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { bizList, listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
import { getMyExpertProfile } from '@/api/business/expert' import { getMyExpertProfile } from '@/api/business/expert'
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee' import { listUnsignedMeetingProtocols, listInvitedMeetings } from '@/api/business/meetingAttendee'
import { onGlobal } from '@/utils/sseClient' import NoticeList from '@/components/NoticeList.vue'
const store = useUserStore() const store = useUserStore()
const upcomingMeetings = ref([]) const upcomingMeetings = ref([])
const pendingAgreements = ref([]) const pendingAgreements = ref([])
const notices = ref([])
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号)) // 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
const expertName = ref('') const expertName = ref('')
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家' // 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
@@ -140,7 +118,7 @@ async function showQrcode(row, idx) {
try { try {
// 二维码内容 = 该会议对应的填写页 URL (扫码直达, 医生已登录后直接进入) // 二维码内容 = 该会议对应的填写页 URL (扫码直达, 医生已登录后直接进入)
// Vue Router hash 模式: URL 必须带 #/ (如 http://localhost:5173/#/doctor/sign-fill?attendeeId=3) // Vue Router hash 模式: URL 必须带 #/ (如 http://localhost:5173/#/doctor/sign-fill?attendeeId=3)
const fullUrl = window.location.origin + '/#/doctor/sign-fill?attendeeId=' + row.id const fullUrl = window.location.origin + import.meta.env.BASE_URL + '#/doctor/sign-fill?attendeeId=' + row.id
qrcodeMobileUrl.value = fullUrl qrcodeMobileUrl.value = fullUrl
qrcodeUrl.value = await QRCode.toDataURL(fullUrl, { qrcodeUrl.value = await QRCode.toDataURL(fullUrl, {
width: 240, width: 240,
@@ -202,8 +180,8 @@ async function load() {
// 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏) // 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏)
if (store.expertAuditApproved) { if (store.expertAuditApproved) {
try { try {
const { data } = await bizList('meeting', { pageNum: 1, pageSize: 5 }) const { data } = await listInvitedMeetings()
upcomingMeetings.value = (data?.rows || []).slice(0, 5) upcomingMeetings.value = (Array.isArray(data) ? data : []).slice(0, 5)
} catch (e) { upcomingMeetings.value = [] } } catch (e) { upcomingMeetings.value = [] }
try { try {
@@ -212,91 +190,24 @@ async function load() {
id: s.id, id: s.id,
meetingId: s.meetingId, meetingId: s.meetingId,
meetingName: s.meetingName, meetingName: s.meetingName,
startTime: s.startTime startTime: s.startTime,
isEsigned: s.isEsigned
})) }))
} catch (e) { pendingAgreements.value = [] } } catch (e) { pendingAgreements.value = [] }
} else { } else {
upcomingMeetings.value = [] upcomingMeetings.value = []
pendingAgreements.value = [] pendingAgreements.value = []
} }
// 通知列表已抽到 <NoticeList> 组件, 本页不再处理
// 通知
try {
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
id: a.id,
title: a.title,
text: a.text,
time: a.time,
read: a.read // 严格按 DB is_read, 不再用 i>=2 硬编码"第3条算旧"误导用户
}))
} catch (e) { notices.value = [] }
} }
/** 仅重拉通知列表 (SSE new_message 事件触发, AdminLayout 已统一弹 toast, 这里只刷新本页面 list) */
async function refreshNotices() {
try {
notices.value = (await listMyMessages({ limit: 5 })).rows.map((a, i) => ({
id: a.id,
title: a.title,
text: a.text,
time: a.time,
read: a.read
}))
} catch (e) { /* SSE 重拉失败静默, 用户下次手动刷新可见 */ }
}
// SSE 订阅句柄, 卸载时解订阅避免内存泄漏
let unsubscribeNewMessage = null
// ===== 消息详情 dialog =====
const detailOpen = ref(false)
const detail = ref({})
/** 点通知项: 打开 dialog + 调 markRead (后端会再触发 SSE 推新未读, navbar 角标自动减) */
async function openDetail(n) {
detail.value = n
detailOpen.value = true
if (!n.read) {
try {
await markMessageRead(n.id)
n.read = true // 本地乐观更新, 避免再调 refreshNotices 闪屏
} catch (e) {
// markRead 失败不阻塞 dialog, 用户下次点还会重试
}
}
}
/** 全部已读: 调后端 markAllRead (后端会推 silent=true 的 SSE, 角标自动清零) */
async function markAllRead() {
try {
await markAllMessagesRead()
// 乐观本地全标已读, 不等 SSE 推送 (push 过来后 refreshNotices 也会全 read)
notices.value = notices.value.map(n => ({ ...n, read: true }))
} catch (e) {
ElMessage.error('全部已读失败, 请重试')
}
}
/** 列表里只要有未读就显示"全部已读"按钮 */
const hasUnread = computed(() => notices.value.some(n => !n.read))
onMounted(() => { onMounted(() => {
updateClock() updateClock()
timer = setInterval(updateClock, 60000) timer = setInterval(updateClock, 60000)
load() load()
// 订阅全局总线 (AdminLayout 把 SSE 事件扇出到这里), 跨路由不掉, 但页面 unmount 时仍需解订阅
// toast 弹窗由 AdminLayout 统一处理 (silent flag), 这里只做本页 list 刷新 + 医生审核状态联动
unsubscribeNewMessage = onGlobal(() => {
refreshNotices()
// manager 审核通过事件过来: 重新拉 store 触发的 audit 状态, 若刚转 approved, 把 pannel 数据拉出来
if (!store.expertAuditApproved && store.role === 'doctor') {
load()
}
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timer) clearInterval(timer) if (timer) clearInterval(timer)
if (unsubscribeNewMessage) unsubscribeNewMessage()
}) })
</script> </script>
@@ -312,7 +223,6 @@ onBeforeUnmount(() => {
.section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; } .section-title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin: 0 0 12px; display: flex; align-items: center; justify-content: space-between; }
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; font-weight: normal; cursor: pointer; } .more { font-size: 12px; color: var(--brand-primary); text-decoration: none; font-weight: normal; cursor: pointer; }
.more:hover { text-decoration: underline; } .more:hover { text-decoration: underline; }
.title-actions { display: inline-flex; align-items: center; gap: 12px; font-weight: normal; }
.cols-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .cols-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.cols-row .section { margin-bottom: 16px; } .cols-row .section { margin-bottom: 16px; }
.simple-list { list-style: none; border-top: 1px solid #f0f0f0; } .simple-list { list-style: none; border-top: 1px solid #f0f0f0; }
@@ -323,17 +233,9 @@ onBeforeUnmount(() => {
.item-title { font-size: 13px; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .item-title { font-size: 13px; color: #1a1a1a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.item-status { flex-shrink: 0; font-size: 12px; color: #8c8c8c; } .item-status { flex-shrink: 0; font-size: 12px; color: #8c8c8c; }
.item-status.done { color: #52c41a; } .item-status.done { color: #52c41a; }
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; } .simple-item.disabled { cursor: default; }
.notice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; cursor: pointer; } .simple-item.disabled .item-title { color: #bfbfbf; }
.notice-item:last-child { border-bottom: none; } .simple-item.disabled:hover .item-title { color: #bfbfbf; }
.notice-item:hover .title { color: var(--brand-primary); }
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
.notice-item .body { flex: 1; min-width: 0; }
.notice-item .title { font-size: 13px; color: #1a1a1a; }
.notice-item.read .title { color: #8c8c8c; }
.notice-item .text { font-size: 12px; color: #595959; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; } .empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } } @media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
@@ -344,9 +246,4 @@ onBeforeUnmount(() => {
.qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; } .qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; }
.qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; } .qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; }
.qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; } .qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
/* 消息详情 dialog */
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.detail-body .time { font-size: 12px; color: #8c8c8c; }
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
</style> </style>
+64 -82
View File
@@ -7,9 +7,7 @@
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item> <el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></el-form-item>
<el-form-item label="当前阶段"> <el-form-item label="当前阶段">
<el-select v-model="q.currentStage" placeholder="请选择" clearable> <el-select v-model="q.currentStage" placeholder="请选择" clearable>
<el-option label="未开始" value="未开始" /> <el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
<el-option label="执行中" value="执行中" />
<el-option label="已结束" value="已结束" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item> <el-form-item><el-button type="primary" @click="load">查找</el-button><el-button @click="reset">重置</el-button></el-form-item>
@@ -18,16 +16,16 @@
<el-table :data="rows" v-loading="loading" stripe border> <el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="160" /> <el-table-column prop="projectNo" label="项目编号" width="160" />
<el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip /> <el-table-column prop="meetingName" label="会议名称" min-width="280" show-overflow-tooltip />
<el-table-column prop="currentStage" label="当前阶段" width="120"> <el-table-column prop="currentStage" label="当前阶段" width="140">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="stageTag(row.currentStage)" size="small">{{ row.currentStage || '-' }}</el-tag> <el-tag :type="stageTag(row)" size="small">{{ stageLabel('doctor', row) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button> <el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button> <el-button link type="primary" :disabled="!row.invitationUrl" @click="onDownloadFile(row)">下载</el-button>
<el-button v-if="row.laborSigned !== '1'" link type="primary" @click="onSign(row)">签署劳务</el-button> <el-button v-if="!row.attendeeLaborProtocol" link type="primary" @click="onSign(row)">签署劳务</el-button>
<el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button> <el-button v-else link type="primary" @click="onViewLabor(row)">查看劳务</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -44,30 +42,16 @@
style="margin-top: 12px; text-align: right;" style="margin-top: 12px; text-align: right;"
/> />
<!-- 签署劳务 dialog: 未签状态显示, PDF 上传 --> <!-- 签署劳务 dialog: 二维码 ( token, 手机扫码可直接登录填写) -->
<el-dialog v-model="signOpen" title="签署劳务" width="560px"> <el-dialog v-model="signOpen" title="签署劳务" width="380px" align-center destroy-on-close>
<el-form label-width="100px"> <div class="sign-qr-wrap">
<el-form-item label="项目编号">{{ signForm.projectNo }}</el-form-item> <div v-if="signQrLoading" v-loading="true" class="sign-qr-loading"></div>
<el-form-item label="会议名称">{{ signForm.meetingName }}</el-form-item> <img v-else-if="signQrUrl" :src="signQrUrl" class="sign-qr-img" />
<el-form-item label="签字 PDF"> <div class="sign-link-tip">会议{{ signForm.meetingName }}</div>
<el-upload <el-link type="primary" :underline="false" class="sign-copy" @click="copySignLink">复制链接</el-link>
drag </div>
action="#"
:before-upload="beforeSignUpload"
:http-request="uploadSignPdf"
:file-list="signFileList"
:on-remove="onSignRemove"
accept=".pdf"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将签字 PDF 拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">仅支持 PDF, 20MB</div>
</el-upload>
</el-form-item>
</el-form>
<template #footer> <template #footer>
<el-button @click="signOpen=false">取消</el-button> <el-button @click="signOpen=false">关闭</el-button>
<el-button type="primary" :loading="submitting" :disabled="!signForm.signedPdfUrl" @click="submitSign">签署劳务</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -80,11 +64,11 @@
<el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item> <el-descriptions-item label="签署时间">{{ detail.updateTime || '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div class="pdf-preview"> <div class="pdf-preview">
<iframe v-if="detail.signedPdfUrl" :src="detail.signedPdfUrl" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe> <iframe v-if="detail.attendeeLaborProtocol" :src="detail.attendeeLaborProtocol" style="width:100%;height:420px;border:1px solid #ebeef5"></iframe>
<div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div> <div v-else style="padding:24px;color:#909399;text-align:center">暂无签字 PDF</div>
</div> </div>
<template #footer> <template #footer>
<el-button v-if="detail.signedPdfUrl" link type="primary" @click="downloadPdf(detail.signedPdfUrl, detail.meetingName)">下载</el-button> <el-button v-if="detail.attendeeLaborProtocol" link type="primary" @click="downloadPdf(detail.attendeeLaborProtocol, detail.meetingName)">下载</el-button>
<el-button @click="laborOpen=false">关闭</el-button> <el-button @click="laborOpen=false">关闭</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -94,8 +78,9 @@
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { bizList, bizUpdate } from '@/api/public' import QRCode from 'qrcode'
import { uploadToOss } from '@/utils/oss' import { bizList } from '@/api/public'
import { stageLabel, stageTag, STAGE_OPTIONS } from '@/utils/meetingStage'
const q = reactive({ projectNo: '', meetingName: '', currentStage: '' }) const q = reactive({ projectNo: '', meetingName: '', currentStage: '' })
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 }) const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -104,9 +89,10 @@ const loading = ref(false)
const detail = ref({}) const detail = ref({})
const laborOpen = ref(false) const laborOpen = ref(false)
const signOpen = ref(false) const signOpen = ref(false)
const signForm = reactive({ meetingId: '', projectNo: '', meetingName: '', signedPdfUrl: '' }) const signForm = reactive({ meetingName: '' })
const signFileList = ref([]) const signLink = ref('')
const submitting = ref(false) const signQrUrl = ref('')
const signQrLoading = ref(false)
async function load() { async function load() {
loading.value = true loading.value = true
@@ -128,12 +114,6 @@ function reset() {
load() load()
} }
function stageTag(s) {
if (s === '已结束') return 'info'
if (s === '执行中') return 'success'
return 'warning'
}
function onViewLabor(row) { function onViewLabor(row) {
detail.value = row detail.value = row
laborOpen.value = true laborOpen.value = true
@@ -144,7 +124,7 @@ function onView(row) {
// 复用 detail dialog: 复用 meetingInfo 字段直接展示 // 复用 detail dialog: 复用 meetingInfo 字段直接展示
// 这里直接展示一个只读 dialog, 没有劳务签署按钮 // 这里直接展示一个只读 dialog, 没有劳务签署按钮
ElMessageBox.alert( ElMessageBox.alert(
`项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${row.currentStage || '-'}`, `项目编号: ${row.projectNo || '-'}\n会议名称: ${row.meetingName || '-'}\n当前阶段: ${stageLabel('doctor', row)}`,
'会议详情', '会议详情',
{ confirmButtonText: '关闭' } { confirmButtonText: '关闭' }
) )
@@ -161,54 +141,49 @@ function onDownloadFile(row) {
document.body.removeChild(a) document.body.removeChild(a)
} }
function onSign(row) { async function onSign(row) {
signForm.meetingId = row.meetingId
signForm.projectNo = row.projectNo || ''
signForm.meetingName = row.meetingName || '' signForm.meetingName = row.meetingName || ''
signForm.signedPdfUrl = '' const attendeeId = row.attendeeId
signFileList.value = [] if (!attendeeId) { ElMessage.warning('缺少参会记录, 无法生成签署链接'); return }
const token = localStorage.getItem('ry_token') || ''
const url = window.location.origin + import.meta.env.BASE_URL + '#/doctor/sign-fill?attendeeId=' + attendeeId
+ (row.meetingId != null ? '&meetingId=' + row.meetingId : '')
+ '&token=' + encodeURIComponent(token)
signLink.value = url
signOpen.value = true signOpen.value = true
} signQrUrl.value = ''
signQrLoading.value = true
function beforeSignUpload(file) {
const max = 20 * 1024 * 1024
if (file.size > max) { ElMessage.error('文件不能超过 20MB'); return false }
if (!file.name.toLowerCase().endsWith('.pdf')) { ElMessage.error('仅支持 PDF 格式'); return false }
return true
}
async function uploadSignPdf(opts) {
try { try {
const url = await uploadToOss(opts.file, 'ry8080/labor/signed/') signQrUrl.value = await QRCode.toDataURL(url, {
signForm.signedPdfUrl = url width: 240,
ElMessage.success('签字 PDF 上传成功') margin: 2,
color: { dark: '#1a1a1a', light: '#ffffff' }
})
} catch (e) { } catch (e) {
ElMessage.error('上传失败: ' + (e?.message || e)) ElMessage.error('生成二维码失败')
} finally {
signQrLoading.value = false
} }
} }
function onSignRemove() { function copySignLink() {
signForm.signedPdfUrl = '' if (!signLink.value) return
signFileList.value = []
}
async function submitSign() {
if (!signForm.signedPdfUrl) return ElMessage.warning('请先上传签字 PDF')
submitting.value = true
try { try {
await bizUpdate('meeting', { if (navigator.clipboard && window.isSecureContext) {
meetingId: signForm.meetingId, navigator.clipboard.writeText(signLink.value)
laborSigned: '1', } else {
signedPdfUrl: signForm.signedPdfUrl, const ta = document.createElement('textarea')
signedTime: new Date().toISOString().slice(0, 19).replace('T', ' ') ta.value = signLink.value
}) ta.style.position = 'fixed'
ElMessage.success('签署成功') ta.style.opacity = '0'
signOpen.value = false document.body.appendChild(ta)
load() ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
ElMessage.success('已复制链接')
} catch (e) { } catch (e) {
ElMessage.error(e?.msg || '签署失败') ElMessage.error('复制失败, 请手动复制')
} finally {
submitting.value = false
} }
} }
@@ -231,6 +206,13 @@ load()
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; } .filter-form { display: flex; flex-wrap: wrap; gap: 0 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; margin-bottom: 12px; }
.pdf-preview { margin-top: 12px; } .pdf-preview { margin-top: 12px; }
.sign-link-tip { font-size: 13px; color: #606266; margin-bottom: 10px; }
.sign-link-hint { font-size: 12px; color: #909399; margin-top: 10px; }
.sign-qr-wrap { text-align: center; padding: 12px 0; }
.sign-qr-loading { width: 240px; height: 240px; margin: 0 auto; }
.sign-qr-img { width: 240px; height: 240px; }
.sign-qr-wrap .sign-link-tip { margin-top: 16px; }
.sign-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; } :deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); } :deep(.el-button--primary:hover) { background: var(--brand-primary-deep); border-color: var(--brand-primary-deep); }
:deep(.el-table .el-button) { border-radius: 4px; } :deep(.el-table .el-button) { border-radius: 4px; }
-126
View File
@@ -1,126 +0,0 @@
<template>
<div class="page-card">
<div class="breadcrumb">
首页 / 消息通知
<el-button v-if="hasUnread" link size="small" type="primary" class="mark-all" @click="markAllRead">全部已读</el-button>
</div>
<ul class="notice-list">
<li v-for="n in rows" :key="n.id" class="notice-item" :class="{ read: n.read }">
<div class="dot"></div>
<div class="body" @click="openDetail(n)">
<div class="title"><span class="cat" :class="catClass(n.category)">{{ n.category }}</span>{{ n.title }}</div>
<div class="text">{{ n.text }}</div>
</div>
<span class="time">{{ n.time }}</span>
</li>
<li v-if="!rows.length" class="empty">暂无消息</li>
</ul>
<el-dialog v-model="detailOpen" :title="detail.title || '消息详情'" width="520px">
<div class="detail-body">
<div class="meta">
<span class="cat" :class="catClass(detail.category)">{{ detail.category }}</span>
<span class="time">{{ detail.time }}</span>
</div>
<div class="text">{{ detail.text || '-' }}</div>
</div>
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
const rows = ref([])
const detail = ref({})
const detailOpen = ref(false)
const hasUnread = computed(() => rows.value.some(n => !n.read))
async function load() {
try {
const r = await listMyMessages({ limit: 50 }); const list = (r.data && r.data.rows) || r.rows || []
rows.value = list.map(a => ({
id: a.id,
category: a.category,
title: a.title,
text: a.text,
time: a.time,
read: a.read
}))
} catch (e) { rows.value = [] }
}
function mapCategory(t) {
if (t === 'system') return '系统通知'
if (t === 'audit') return '审核结果'
if (t === 'project') return '项目动态'
if (t === 'meeting') return '会议提醒'
return '通知'
}
function catClass(c) {
if (c === '系统通知') return 'sys'
if (c === '审核结果') return 'audit'
if (c === '项目动态') return 'proj'
if (c === '会议提醒') return 'meet'
return ''
}
function formatAgo(t) {
const diff = (Date.now() - new Date(t).getTime()) / 1000
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
return new Date(t).toLocaleDateString('zh-CN')
}
function openDetail(n) {
detail.value = n
detailOpen.value = true
if (!n.read) {
// 乐观本地置已读 + 后端 markRead (后端触发 SSE 推新未读, navbar 角标自动减)
n.read = true
markMessageRead(n.id).catch(() => { n.read = false })
}
}
async function markAllRead() {
try {
await markAllMessagesRead()
rows.value = rows.value.map(n => ({ ...n, read: true }))
} catch (e) {
ElMessage.error('全部已读失败, 请重试')
}
}
onMounted(load)
</script>
<style scoped>
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
.mark-all { font-size: 13px; }
.notice-list { list-style: none; }
.notice-item { padding: 14px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 12px; }
.notice-item:last-child { border-bottom: none; }
.notice-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-primary); flex-shrink: 0; }
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
.notice-item .body { flex: 1; min-width: 0; cursor: pointer; }
.notice-item .title { font-size: 13px; color: #1a1a1a; display: flex; align-items: center; gap: 8px; }
.notice-item.read .title { color: #8c8c8c; }
.notice-item .text { font-size: 12px; color: #595959; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
.notice-item:hover .title { color: var(--brand-primary); }
.cat { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 2px; background: #f0f0f0; color: #595959; }
.cat.sys { background: #e6f7ff; color: var(--brand-primary); }
.cat.audit { background: #f9f0ff; color: #722ed1; }
.cat.proj { background: #fff7e6; color: #fa8c16; }
.cat.meet { background: #f6ffed; color: #52c41a; }
.empty { padding: 24px; text-align: center; color: #bfbfbf; font-size: 13px; }
.detail-body .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.detail-body .text { font-size: 14px; color: #1a1a1a; line-height: 1.6; white-space: pre-wrap; }
</style>
+149 -49
View File
@@ -1,9 +1,16 @@
<template> <template>
<div class="sign-fill"> <div class="sign-fill">
<div v-loading="loading"> <div v-if="notInvited" class="not-invited">
<div class="not-invited-text">您暂未被邀请请联系执会人员</div>
<el-button type="primary" @click="goBack">返回</el-button>
</div>
<div v-else v-loading="loading">
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form"> <el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
<!-- 参会人信息 --> <!-- 大标题: 会议名称 + 期数 -->
<div class="section-title">参会人信息</div> <div class="sign-header">
<div class="sign-header-title">{{ meetingName || '劳务协议签署' }}</div>
<div class="sign-header-period">期数{{ periodDisplay || '-' }}</div>
</div>
<el-form-item label="手机号" prop="phone"> <el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" /> <el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item> </el-form-item>
@@ -16,9 +23,15 @@
<el-form-item label="科室" prop="department"> <el-form-item label="科室" prop="department">
<el-input v-model="form.department" placeholder="请输入科室" maxlength="100" /> <el-input v-model="form.department" placeholder="请输入科室" maxlength="100" />
</el-form-item> </el-form-item>
<el-form-item label="医务职称">
<!-- 银行账户 --> <el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 100%">
<div class="section-title">银行账户</div> <el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
<template #footer>
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
</template>
</el-select>
</el-form-item>
<el-form-item label="账户名称(持卡人姓名)" prop="accountName"> <el-form-item label="账户名称(持卡人姓名)" prop="accountName">
<el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" /> <el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" />
</el-form-item> </el-form-item>
@@ -53,8 +66,6 @@
/> />
</el-form-item> </el-form-item>
<!-- 劳务信息 (只读, admin 预填) -->
<div class="section-title">本次劳务</div>
<el-form-item label="劳务形式"> <el-form-item label="劳务形式">
<div class="labor-form-cell"> <div class="labor-form-cell">
<el-checkbox-group v-model="laborFormSelected"> <el-checkbox-group v-model="laborFormSelected">
@@ -65,15 +76,6 @@
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="医务职称">
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 240px">
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
<template #footer>
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
</template>
</el-select>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">下一步</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">下一步</el-button>
@@ -88,13 +90,18 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getSignInfo, saveSignProfile } from '@/api/business/sign' import { getSignInfo, saveSignProfile, resolveSign } from '@/api/business/sign'
import { getInfo } from '@/api/auth'
import { useUserStore } from '@/store/user'
import IdCardUploader from '@/components/IdCardUploader.vue' import IdCardUploader from '@/components/IdCardUploader.vue'
import AreaCascader from '@/components/AreaCascader.vue' import AreaCascader from '@/components/AreaCascader.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const attendeeId = computed(() => Number(route.query.attendeeId) || null) const userStore = useUserStore()
const attendeeId = ref(null)
const meetingId = computed(() => route.query.meetingId ? Number(route.query.meetingId) : null)
const notInvited = ref(false)
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -120,6 +127,19 @@ const form = reactive({
}) })
const feeDisplay = reactive({ preTax: '—', tax: '—', fee: '—' }) const feeDisplay = reactive({ preTax: '—', tax: '—', fee: '—' })
// 会议大标题 (会议名称 + 期数), 由后端 getSignInfo 返回
const meetingName = ref('')
const periodNo = ref(null)
const totalPeriods = ref(null)
const periodDisplay = computed(() => {
const p = periodNo.value
const t = totalPeriods.value
if (p == null && t == null) return ''
if (p == null) return `${t}`
if (t == null) return `${p}`
return `${p}/${t}`
})
const rules = { const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [ phone: [
@@ -135,6 +155,73 @@ const rules = {
bankBranch: [{ required: true, message: '请输入开户行支行', trigger: 'blur' }] bankBranch: [{ required: true, message: '请输入开户行支行', trigger: 'blur' }]
} }
/** 扫码带 token 直登 / 无 token 校验登录; 返回 true 表示已就绪 */
async function ensureLogin() {
const token = route.query.token
if (token) {
// 扫码带 token: 用新 token 重新登录 (覆盖本地已登录态)
userStore.setToken(token)
try {
const info = await getInfo()
const u = info.user || {}
const role = u.roleType
if (!role) {
ElMessage.error('账号角色未配置, 请联系管理员')
router.replace({ name: 'login' })
return false
}
userStore.setUser({
userId: u.userId,
userName: u.userName || u.nickName || '',
nickName: u.nickName || u.userName || '',
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
return true
} catch (e) {
ElMessage.error('登录已失效, 请重新登录')
router.replace({ name: 'login' })
return false
}
}
// 无 token: 必须已登录
if (userStore.user) return true
ElMessage.error('请先登录')
router.replace({ name: 'login', query: { redirect: route.fullPath } })
return false
}
async function init() {
loading.value = true
const ok = await ensureLogin()
if (!ok) return
// 解析 attendeeId: 优先 URL 里的 attendeeId; 没有则按 meetingId 查
let aid = route.query.attendeeId ? Number(route.query.attendeeId) : null
if (!aid && meetingId.value) {
try {
const { data } = await resolveSign(meetingId.value)
if (!data || !data.attendeeId) {
notInvited.value = true
loading.value = false
return
}
aid = data.attendeeId
// 标题: 有 meetingId → 按 meetingId 查 (resolve 已返回会议名/期数)
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
} catch (e) {
ElMessage.error(e?.msg || '加载失败')
loading.value = false
return
}
}
attendeeId.value = aid
await load()
}
async function load() { async function load() {
if (!attendeeId.value) { if (!attendeeId.value) {
ElMessage.error('参数缺失, 请从工作台进入') ElMessage.error('参数缺失, 请从工作台进入')
@@ -162,25 +249,43 @@ async function load() {
idCardAttachments: cur.idCardAttachments || '', idCardAttachments: cur.idCardAttachments || '',
title: cur.title || def.title || '' title: cur.title || def.title || ''
}) })
// 先设置选项 (项目角色 + "其他"), 解析劳务形式需要用它区分"项目角色"和"其他自定义"
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
titleOptions.value = data.titleOptions || []
// 劳务形式回显: 兼容旧 JSON 数组 (["主席","__other__:xx"]) + 新逗号分隔 ("主席,主持,xx")
laborFormSelected.value = []
laborFormOther.value = ''
if (cur.laborForm) { if (cur.laborForm) {
try { let items = []
const parsed = JSON.parse(cur.laborForm) if (typeof cur.laborForm === 'string' && cur.laborForm.trim().startsWith('[')) {
if (Array.isArray(parsed)) { try { const p = JSON.parse(cur.laborForm); if (Array.isArray(p)) items = p } catch (e) { items = [cur.laborForm] }
laborFormSelected.value = parsed.filter(x => x !== '__other__' && !x.startsWith('__other__:')) } else {
const otherItem = parsed.find(x => x === '__other__' || x.startsWith('__other__:')) items = String(cur.laborForm).split(',').map(s => s.trim()).filter(Boolean)
if (otherItem) {
laborFormSelected.value.push('__other__')
laborFormOther.value = otherItem.startsWith('__other__:') ? otherItem.substring('__other__:'.length) : ''
} }
const known = new Set(laborFormOptions.value.map(o => o.value))
items.forEach(it => {
const s = String(it)
if (s === '__other__' || s.startsWith('__other__:')) {
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
if (s.startsWith('__other__:')) laborFormOther.value = s.substring('__other__:'.length)
} else if (known.has(s)) {
if (!laborFormSelected.value.includes(s)) laborFormSelected.value.push(s)
} else {
// 不在项目角色里 → 当作"其他"自定义
if (!laborFormSelected.value.includes('__other__')) laborFormSelected.value.push('__other__')
laborFormOther.value = s
} }
} catch (e) { /* ignore */ } })
} }
feeDisplay.preTax = cur.feePreTax || '—' feeDisplay.preTax = cur.feePreTax || '—'
feeDisplay.tax = cur.tax || '—' feeDisplay.tax = cur.tax || '—'
feeDisplay.fee = cur.fee || '—' feeDisplay.fee = cur.fee || '—'
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label })) meetingName.value = data.meetingName || ''
titleOptions.value = data.titleOptions || [] periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
} catch (e) { } catch (e) {
ElMessage.error(e?.msg || '加载失败') ElMessage.error(e?.msg || '加载失败')
} finally { } finally {
@@ -195,16 +300,13 @@ async function onSubmit() {
ElMessage.warning('请上传身份证正反面') ElMessage.warning('请上传身份证正反面')
return return
} }
// 劳务形式序列化 (JSON 数组字符串, 含 __other__ + 自定义内容) // 劳务形式序列化: 逗号分隔字符串, "其他"自定义内容作为普通项
let laborForm = '' let laborForm = ''
if (laborFormSelected.value.length) { if (laborFormSelected.value.length) {
const arr = laborFormSelected.value.map(x => { laborForm = laborFormSelected.value
if (x === '__other__' && laborFormOther.value) { .map(x => (x === '__other__' ? (laborFormOther.value || '').trim() : x))
return '__other__:' + laborFormOther.value .filter(x => x !== '' && x !== '__other__')
} .join(',')
return x
})
laborForm = JSON.stringify(arr)
} }
submitting.value = true submitting.value = true
@@ -228,23 +330,21 @@ function goBack() {
router.push('/doctor/home') router.push('/doctor/home')
} }
onMounted(load) onMounted(init)
</script> </script>
<style scoped> <style scoped>
/* 手机端全屏表单 (无 navbar / 无 page-card) */ /* 手机端全屏表单 (无 navbar / 无 page-card) */
.sign-fill { padding: 16px; } .sign-fill { padding: 16px; }
/* 章节标题: 左边 3px 蓝竖线 */ /* 标题: 会议名称 + 期数 */
.section-title { .sign-header { margin: 4px 0 20px; padding-bottom: 14px; border-bottom: 1px solid #f0f0f0; }
font-size: 14px; .sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; }
font-weight: 600; .sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; }
color: #1a1a1a;
margin: 16px 0 12px; /* 未受邀提示 */
padding-left: 10px; .not-invited { padding: 60px 20px; text-align: center; }
border-left: 3px solid var(--brand-primary); .not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; }
line-height: 1;
}
/* 开户地址 3 列布局 */ /* 开户地址 3 列布局 */
.region-row { .region-row {
+32 -65
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="page-card executor-meetings"> <div class="page-card executor-meetings">
<div class="breadcrumb">首页 / 会议列表</div> <div class="breadcrumb">首页 / 会议列表</div>
<p class="page-sub">查看执行方参与的会议, 提交监督意见</p> <p class="page-sub">查看执行方参与的会议</p>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== --> <!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form"> <el-form inline :model="q" class="filter-form">
@@ -19,6 +19,11 @@
<el-option label="未结题" value="0" /> <el-option label="未结题" value="0" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="当前阶段">
<el-select v-model="q.currentStage" clearable placeholder="请选择" style="width:140px">
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="期数"><el-input v-model="q.sessionNo" clearable placeholder="输入期数" style="width:100px" /></el-form-item> <el-form-item label="期数"><el-input v-model="q.sessionNo" clearable placeholder="输入期数" style="width:100px" /></el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="load">查找</el-button> <el-button type="primary" @click="load">查找</el-button>
@@ -42,9 +47,9 @@
<el-table-column label="期数/总期数" width="110" align="center"> <el-table-column label="期数/总期数" width="110" align="center">
<template #default="{ row }">{{ row.periodNo || 0 }}/{{ row.totalPeriods || 0 }}</template> <template #default="{ row }">{{ row.periodNo || 0 }}/{{ row.totalPeriods || 0 }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="100" align="center"> <el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.currentStage === '执行中' ? 'success' : 'info'" size="small">{{ row.currentStage || '-' }}</el-tag> <el-tag :type="row.currentStage === 'RUNNING' ? 'success' : 'info'" size="small">{{ stageLabel('executor', row) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="isFinished" label="是否结题" width="90" align="center"> <el-table-column prop="isFinished" label="是否结题" width="90" align="center">
@@ -67,53 +72,26 @@
@current-change="load" @size-change="load" /> @current-change="load" @size-change="load" />
</div> </div>
<!-- 监督意见 弹窗 --> <!-- 监督意见 (只读: 由支持方/监察员在审批时填写, 执行方仅可查看) -->
<el-dialog v-model="supOpen" title="监督意见" width="560px"> <el-dialog v-model="supOpen" title="监督意见" width="560px">
<el-form label-width="100px"> <el-descriptions :column="1" border>
<el-form-item label="会议名称"> <el-descriptions-item label="会议名称">{{ supRow.meetingName }}</el-descriptions-item>
<span>{{ supRow.meetingName }}</span> <el-descriptions-item label="当前阶段">{{ stageLabel('executor', supRow) }}</el-descriptions-item>
</el-form-item> <el-descriptions-item label="监督意见">{{ supRow.supervisionOpinion || '暂无' }}</el-descriptions-item>
<el-form-item label="监督结论"> </el-descriptions>
<el-radio-group v-model="supForm.rating"> <template #footer><el-button @click="supOpen=false">关闭</el-button></template>
<el-radio value="good">满意</el-radio>
<el-radio value="normal">一般</el-radio>
<el-radio value="bad">存在需整改的问题</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="监督意见">
<el-input v-model="supForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入监督意见(0/500" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="supOpen=false">取消</el-button>
<el-button type="primary" @click="onSaveSup">确定</el-button>
</template>
</el-dialog> </el-dialog>
<el-dialog v-model="viewOpen" title="会议详情" width="640px">
<el-descriptions :column="2" border>
<el-descriptions-item label="项目编号">{{ view.projectNo }}</el-descriptions-item>
<el-descriptions-item label="会议ID">{{ view.meetingId }}</el-descriptions-item>
<el-descriptions-item label="会议名称" :span="2">{{ view.meetingName }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ view.projectForm }}</el-descriptions-item>
<el-descriptions-item label="当前阶段">{{ view.currentStage }}</el-descriptions-item>
<el-descriptions-item label="期数/总期数">{{ view.periodNo }}/{{ view.totalPeriods }}</el-descriptions-item>
<el-descriptions-item label="是否系列会">{{ view.isSeries }}</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">{{ view.startTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="结束时间" :span="2">{{ view.endTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="监督意见" :span="2">{{ view.supervisionOpinion || '暂无' }}</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="viewOpen=false">关闭</el-button></template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate } from '@/api/public' import { bizList } from '@/api/public'
import { stageLabel, STAGE_OPTIONS } from '@/utils/meetingStage'
const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '' }) const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' })
const rows = ref([]) const rows = ref([])
const loading = ref(false) const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 }) const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -121,10 +99,6 @@ const selected = ref([])
const supOpen = ref(false) const supOpen = ref(false)
const supRow = ref({}) const supRow = ref({})
const supForm = reactive({ rating: 'good', opinion: '' })
const viewOpen = ref(false)
const view = ref({})
async function load() { async function load() {
loading.value = true loading.value = true
@@ -136,39 +110,32 @@ async function load() {
finally { loading.value = false } finally { loading.value = false }
} }
// 读 URL query 写入 q (Overview KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED + ?isFinished=1, 让列表页自动应用筛选)
// 只读不改 URL — Overview 是 source of truth, 列表页内 reset()/search 不反向写 URL
const route = useRoute()
const router = useRouter()
function readQueryFromRoute() {
const q2 = route.query
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
}
function reset() { function reset() {
q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '' } q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' }
page.pageNum = 1 page.pageNum = 1
load() load()
} }
function onSelect(arr) { selected.value = arr } function onSelect(arr) { selected.value = arr }
function onView(row) { view.value = row; viewOpen.value = true } function onView(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
function onSupervision(row) { function onSupervision(row) {
supRow.value = row supRow.value = row
supForm.rating = 'good'
supForm.opinion = row.supervisionOpinion || ''
supOpen.value = true supOpen.value = true
} }
async function onSaveSup() { onMounted(() => { readQueryFromRoute(); load() })
try {
await bizUpdate('meeting', {
meetingId: supRow.value.meetingId,
supervisionOpinion: supForm.opinion,
supervisionRating: supForm.rating
})
ElMessage.success('已保存监督意见')
supOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
}
}
onMounted(load)
</script> </script>
<style scoped> <style scoped>

Some files were not shown because too many files have changed in this diff Show More