批量推送代码

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-29 17:52:05 +08:00
co-authored by Claude
parent 36528b6e0c
commit e47f76478b
130 changed files with 3972 additions and 2546 deletions
Binary file not shown.
+147
View File
@@ -0,0 +1,147 @@
package com.bidding.supplier.security.openapi;
import com.alibaba.fastjson2.JSON;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
/**
* Supplier account API codec delivered to the customer for payload decryption
* and BCrypt password verification.
*/
public class BiddingSupplierAccountApiCodec
{
private static final String PROTOCOL = "BSA";
private static final String VERSION = "v1";
private static final int IV_LENGTH = 12;
private static final int GCM_TAG_BITS = 128;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();
private final String keyId;
private final String prefix;
private final byte[] aesKey;
public BiddingSupplierAccountApiCodec(String keyId, String aesKeyBase64)
{
if (keyId == null || !keyId.matches("^[A-Za-z0-9_-]{1,32}$"))
{
throw new IllegalArgumentException("Invalid supplier account API key id");
}
try
{
this.aesKey = Base64.getDecoder().decode(aesKeyBase64 == null ? "" : aesKeyBase64.trim());
}
catch (IllegalArgumentException exception)
{
throw new IllegalArgumentException("Invalid supplier account API AES key", exception);
}
if (aesKey.length != 32)
{
throw new IllegalArgumentException("Supplier account API AES key must be 32 bytes");
}
this.keyId = keyId;
this.prefix = PROTOCOL + "." + VERSION + "." + keyId + ".";
}
public String decryptPayload(String payload)
{
try
{
String[] parts = parsePayload(payload);
byte[] iv = Base64.getUrlDecoder().decode(parts[3]);
byte[] ciphertextAndTag = Base64.getUrlDecoder().decode(parts[4]);
if (iv.length != IV_LENGTH || ciphertextAndTag.length <= GCM_TAG_BITS / 8)
{
throw new IllegalArgumentException("Invalid supplier account API payload");
}
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"),
new GCMParameterSpec(GCM_TAG_BITS, iv));
return new String(cipher.doFinal(ciphertextAndTag), StandardCharsets.UTF_8);
}
catch (IllegalArgumentException exception)
{
throw exception;
}
catch (Exception exception)
{
throw new IllegalArgumentException("Unable to decrypt supplier account API payload", exception);
}
}
public <T> T decryptPayload(String payload, Class<T> targetType)
{
if (targetType == null)
{
throw new IllegalArgumentException("Target type is required");
}
return JSON.parseObject(decryptPayload(payload), targetType);
}
/**
* Generate a BCrypt hash for a new or changed customer-platform password.
*/
public String encodePassword(String rawPassword)
{
if (rawPassword == null)
{
throw new IllegalArgumentException("Raw password is required");
}
return PASSWORD_ENCODER.encode(rawPassword);
}
public boolean matchesPassword(String rawPassword, String bcryptPasswordHash)
{
if (rawPassword == null || bcryptPasswordHash == null || bcryptPasswordHash.isBlank())
{
return false;
}
try
{
return PASSWORD_ENCODER.matches(rawPassword, bcryptPasswordHash);
}
catch (IllegalArgumentException exception)
{
return false;
}
}
String encryptPayload(Object payload)
{
try
{
byte[] iv = new byte[IV_LENGTH];
SECURE_RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"),
new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] ciphertextAndTag = cipher.doFinal(JSON.toJSONBytes(payload));
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
return prefix + encoder.encodeToString(iv) + "." + encoder.encodeToString(ciphertextAndTag);
}
catch (Exception exception)
{
throw new IllegalStateException("Unable to encrypt supplier account API payload", exception);
}
}
private String[] parsePayload(String payload)
{
if (payload == null || !payload.startsWith(prefix))
{
throw new IllegalArgumentException("Invalid supplier account API payload");
}
String[] parts = payload.split("\\.", 5);
if (parts.length != 5 || !PROTOCOL.equals(parts[0]) || !VERSION.equals(parts[1])
|| !keyId.equals(parts[2]))
{
throw new IllegalArgumentException("Invalid supplier account API payload");
}
return parts;
}
}
@@ -23,6 +23,9 @@ import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.framework.web.service.TokenService; import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysConfigService; import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysMenuService; import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.service.IBizExpertService;
/** /**
* 登录验证 * 登录验证
@@ -47,6 +50,12 @@ public class SysLoginController
@Autowired @Autowired
private ISysConfigService configService; private ISysConfigService configService;
@Autowired
private ISysUserService userService;
@Autowired
private IBizExpertService expertService;
/** /**
* 登录方法 * 登录方法
* *
@@ -56,6 +65,11 @@ public class SysLoginController
@PostMapping("/login") @PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody) public AjaxResult login(@RequestBody LoginBody loginBody)
{ {
// 待审核专家拦截: role_type=doctor 且 biz_expert.audit_status='1' → 禁止登录
if (isPendingExpert(loginBody.getUsername()))
{
return AjaxResult.error("您的信息正在审核中");
}
AjaxResult ajax = AjaxResult.success(); AjaxResult ajax = AjaxResult.success();
// 生成令牌 // 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(), String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
@@ -64,6 +78,29 @@ public class SysLoginController
return ajax; return ajax;
} }
/**
* 判断用户是否为待审核专家 (role_type=doctor 且 biz_expert.audit_status='1')
*/
private boolean isPendingExpert(String username)
{
if (username == null || username.isEmpty())
{
return false;
}
SysUser probe = userService.selectUserByUserName(username);
// 邮箱登录兜底 (用户名查不到且含 @ 时按邮箱反查)
if (probe == null && username.contains("@"))
{
probe = userService.selectUserByEmail(username);
}
if (probe == null || probe.getUserId() == null || !"doctor".equals(probe.getRoleType()))
{
return false;
}
BizExpert expert = expertService.getByUserId(probe.getUserId());
return expert != null && "1".equals(expert.getAuditStatus());
}
/** /**
* 获取用户信息 * 获取用户信息
* *
@@ -28,12 +28,12 @@ ruoyi:
# 阿里云短信配置 (hwt-code ali.sms 模式) # 阿里云短信配置 (hwt-code ali.sms 模式)
# dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发 # dev/prod 区分走代码: phone 以 "10" 开头视为 dev 测试 (固定码 1234), 其它走 aliyun 真发
sms: sms:
accessKeyId: LTAI5t7S88DmdTxHPzPtJTwG accessKeyId: LTAI5tAgeAUviVdPSsxYCkPY
accessKeySecret: UIkwjMpmlYjgX5IMLjPj8FQNPthdlR accessKeySecret: 556LX6mXnJIPZgf0vFXcl4mKCzzgKq
signName: 北京仙仁掌医学科技发展 signName: 北京整合医学学会
template: SMS_321560247 template: SMS_291440833
esignTemplate: SMS_492460505 esignTemplate: SMS_512040098
esignBaseUrl: https://risingdoctor.com/hg esignBaseUrl: https://hegui.bahim.org.cn
endpoint: dysmsapi.aliyuncs.com endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou regionId: cn-hangzhou
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务) # 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
@@ -14,8 +14,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.domain.BizOrg; import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson; import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.dto.SmsValidForm; import com.ruoyi.business.dto.SmsValidForm;
import com.ruoyi.business.mapper.BizPersonMapper; import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.IBizOrgService; import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.business.service.SysSmsService; import com.ruoyi.business.service.SysSmsService;
import com.ruoyi.common.utils.id.SnowflakeId; import com.ruoyi.common.utils.id.SnowflakeId;
@@ -64,6 +66,9 @@ public class BizAuthController extends BaseController {
@Autowired @Autowired
private IBizOrgService bizOrgService; private IBizOrgService bizOrgService;
@Autowired
private IBizExpertService expertService;
@Autowired @Autowired
private BizPersonMapper bizPersonMapper; private BizPersonMapper bizPersonMapper;
@@ -165,6 +170,14 @@ public class BizAuthController extends BaseController {
} }
} }
// 4.6 待审核专家拦截: role_type=doctor 且 biz_expert.audit_status='1' → 禁止登录
if ("doctor".equals(user.getRoleType())) {
BizExpert expert = expertService.getByUserId(user.getUserId());
if (expert != null && "1".equals(expert.getAuditStatus())) {
return error("您的信息正在审核中");
}
}
// 5. 构造 LoginUser 并设 SecurityContext (兼容后续 spring security 鉴权) // 5. 构造 LoginUser 并设 SecurityContext (兼容后续 spring security 鉴权)
LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user)); LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
Authentication authentication = new UsernamePasswordAuthenticationToken( Authentication authentication = new UsernamePasswordAuthenticationToken(
@@ -288,6 +301,7 @@ public class BizAuthController extends BaseController {
public AjaxResult registerSponsor(@RequestBody Map<String, Object> body) { public AjaxResult registerSponsor(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username"); String username = (String) body.get("username");
String orgIdStr = body.get("orgId") == null ? null : body.get("orgId").toString(); String orgIdStr = body.get("orgId") == null ? null : body.get("orgId").toString();
String realName = (String) body.get("realName");
String phone = (String) body.get("phone"); String phone = (String) body.get("phone");
String code = (String) body.get("smsCode"); String code = (String) body.get("smsCode");
String password = (String) body.get("password"); String password = (String) body.get("password");
@@ -298,6 +312,7 @@ public class BizAuthController extends BaseController {
if (username == null || username.length() < 4 || username.length() > 20) return error("用户名长度 4-20 位"); if (username == null || username.length() < 4 || username.length() > 20) return error("用户名长度 4-20 位");
if (!username.matches("^[A-Za-z0-9_]+$")) return error("用户名只能包含字母/数字/下划线"); if (!username.matches("^[A-Za-z0-9_]+$")) return error("用户名只能包含字母/数字/下划线");
if (orgIdStr == null || orgIdStr.isEmpty()) return error("请选择企业"); if (orgIdStr == null || orgIdStr.isEmpty()) return error("请选择企业");
if (realName == null || realName.trim().isEmpty()) return error("请输入联系人姓名");
if (phone == null || !phone.matches("^1\\d{10}$")) return error("手机号格式错误"); if (phone == null || !phone.matches("^1\\d{10}$")) return error("手机号格式错误");
if (code == null || code.isEmpty()) return error("请输入短信验证码"); if (code == null || code.isEmpty()) return error("请输入短信验证码");
if (password == null || password.length() < 6 || password.length() > 20) return error("密码长度 6-20 位"); if (password == null || password.length() < 6 || password.length() > 20) return error("密码长度 6-20 位");
@@ -340,7 +355,7 @@ public class BizAuthController extends BaseController {
// role_type 显式写 sponsor, 避免被 sys_user.role_type DB DEFAULT 'executor' 覆盖 // role_type 显式写 sponsor, 避免被 sys_user.role_type DB DEFAULT 'executor' 覆盖
SysUser user = new SysUser(); SysUser user = new SysUser();
user.setUserName(username); user.setUserName(username);
user.setNickName(org.getOrgName()); // 昵称用所选企业 user.setNickName(realName); // 昵称用联系人姓
user.setPhonenumber(phone); user.setPhonenumber(phone);
user.setPassword(passwordEncoder.encode(password)); user.setPassword(passwordEncoder.encode(password));
user.setStatus("0"); user.setStatus("0");
@@ -353,7 +368,7 @@ public class BizAuthController extends BaseController {
// 6. 写 biz_person (关联到所选企业, 不再新建 biz_org) // 6. 写 biz_person (关联到所选企业, 不再新建 biz_org)
BizPerson self = new BizPerson(); BizPerson self = new BizPerson();
SnowflakeId.injectIfEmpty(self, "personId"); SnowflakeId.injectIfEmpty(self, "personId");
self.setName(username); // 表单无姓名字段, 用登录用户名占位 self.setName(realName);
self.setPhone(phone); self.setPhone(phone);
self.setOrgId(orgId); self.setOrgId(orgId);
self.setDepartment("待分配"); self.setDepartment("待分配");
@@ -68,7 +68,7 @@ public class BizExecutionIntentController extends BaseController
SysUser u = sysUserMapper.selectUserById(uid); SysUser u = sysUserMapper.selectUserById(uid);
if (u != null) { if (u != null) {
if (bizExecutionIntent.getName() == null || bizExecutionIntent.getName().isEmpty()) if (bizExecutionIntent.getName() == null || bizExecutionIntent.getName().isEmpty())
bizExecutionIntent.setName(u.getUserName()); bizExecutionIntent.setName(u.getNickName());
if (bizExecutionIntent.getPhone() == null || bizExecutionIntent.getPhone().isEmpty()) if (bizExecutionIntent.getPhone() == null || bizExecutionIntent.getPhone().isEmpty())
bizExecutionIntent.setPhone(u.getPhonenumber()); bizExecutionIntent.setPhone(u.getPhonenumber());
} }
@@ -94,8 +94,8 @@ 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);
// 人员变化 → 会议费用待重算 // 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
bizMeetingService.markFeeCalcPending(body.getMeetingId()); bizMeetingService.recomputeLaborFee(body.getMeetingId());
// 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发 // 邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发
return success(attendeeId); return success(attendeeId);
} }
@@ -113,9 +113,9 @@ public class BizMeetingAttendeeController extends BaseController {
BizMeetingAttendee before = attendeeService.selectById(body.getId()); BizMeetingAttendee before = attendeeService.selectById(body.getId());
body.setUpdateBy(SecurityUtils.getUsername()); body.setUpdateBy(SecurityUtils.getUsername());
int rows = attendeeService.updateProfile(body); int rows = attendeeService.updateProfile(body);
// 人员变化 → 会议费用待重算 // 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
if (before != null) { if (before != null) {
bizMeetingService.markFeeCalcPending(before.getMeetingId()); bizMeetingService.recomputeLaborFee(before.getMeetingId());
} }
return toAjax(rows); return toAjax(rows);
} }
@@ -129,9 +129,9 @@ public class BizMeetingAttendeeController extends BaseController {
public AjaxResult remove(@PathVariable("id") Long id) { public AjaxResult remove(@PathVariable("id") Long id) {
BizMeetingAttendee before = attendeeService.selectById(id); BizMeetingAttendee before = attendeeService.selectById(id);
int rows = attendeeService.deleteByPrimaryKey(id); int rows = attendeeService.deleteByPrimaryKey(id);
// 人员变化 → 会议费用待重算 // 人员变化 → 立即重算劳务费 (不碰会务费/不置统计中)
if (before != null) { if (before != null) {
bizMeetingService.markFeeCalcPending(before.getMeetingId()); bizMeetingService.recomputeLaborFee(before.getMeetingId());
} }
return toAjax(rows); return toAjax(rows);
} }
@@ -194,9 +194,9 @@ public class BizMeetingAttendeeController extends BaseController {
@RequestParam("meetingId") Long meetingId) throws Exception { @RequestParam("meetingId") Long meetingId) throws Exception {
// 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发) // 解析 + 入库 (邀请参会已改为手动, 不再自动推"会议邀请", 由前端"邀请参会"按钮触发)
ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername()); ImportResult result = attendeeService.importFromExcel(file, meetingId, SecurityUtils.getUsername());
// 人员变化 → 会议费用待重算 (有成功导入才需重算, 但幂等, 直接标记) // 人员变化 → 立即重算劳务费 (有成功导入才需重算)
if (result != null && result.getOkNum() > 0) { if (result != null && result.getOkNum() > 0) {
bizMeetingService.markFeeCalcPending(meetingId); bizMeetingService.recomputeLaborFee(meetingId);
} }
return success(result); return success(result);
} }
@@ -99,6 +99,12 @@ public class BizMeetingController extends BaseController {
} else { } else {
bizMeeting.getParams().put("executorUserId", uid); bizMeeting.getParams().put("executorUserId", uid);
} }
// assignedSessions 聚合用 (公司维度): MAIN 用自己, SUB 反查主账号 parent_user_id; 与过滤参数分离, 避免改变 SUB 可见会议范围
Long aggUid = uid;
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
bizMeeting.getParams().put("assignedExecutorUserId", aggUid);
} }
// 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目) // 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目)
else if ("manager".equals(roleType)) { else if ("manager".equals(roleType)) {
@@ -111,7 +117,19 @@ public class BizMeetingController extends BaseController {
@GetMapping("/{meetingId}") @GetMapping("/{meetingId}")
public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) { public AjaxResult getInfo(@PathVariable("meetingId") Long meetingId) {
return success(bizMeetingService.getById(meetingId)); BizMeeting m = bizMeetingService.getById(meetingId);
// executor 详情: "总期数/期数分母" = 分配给本执行方的场次, 而非项目总场次 (total_periods 对执行方不可见)
if (m != null && m.getProjectId() != null
&& "executor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
Long uid = SecurityUtils.getUserId();
Long aggUid = uid;
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
m.setAssignedSessions((long) bizProjectService.countAssignedSessions(m.getProjectId(), aggUid));
}
return success(m);
} }
@Log(title = "会议", businessType = BusinessType.INSERT) @Log(title = "会议", businessType = BusinessType.INSERT)
@@ -183,6 +201,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) {
// executor 修改会议同样校验 (与 add 一致, 只是期数冲突检测排除自身): 期数不得超过分配给本公司的场次 + 相同期数不重复.
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();
}
Long executionUnitId = bizOrgService.selectOrgIdByUserId(aggUid);
int assigned = bizProjectService.countAssignedSessions(projectId, aggUid);
Long periodNo = bizMeeting.getPeriodNo();
if (periodNo != null && periodNo > assigned) {
throw new ServiceException("期数不能超过分配给本公司的场次 (共 " + assigned + " 场)");
}
if (periodNo != null) {
int dup = bizMeetingService.countByProjectIdExecutionUnitPeriodExclude(projectId, executionUnitId, periodNo, bizMeeting.getMeetingId());
if (dup > 0) {
throw new ServiceException("本机构已创建第 " + periodNo + " 期会议, 请勿重复");
}
}
}
bizMeeting.setUpdateBy(SecurityUtils.getUsername()); bizMeeting.setUpdateBy(SecurityUtils.getUsername());
bizMeeting.setUpdateTime(new Date()); bizMeeting.setUpdateTime(new Date());
// 修改会议时项目形式也从项目继承 (与 add 一致, 避免 project_form 残留为空) // 修改会议时项目形式也从项目继承 (与 add 一致, 避免 project_form 残留为空)
@@ -534,6 +578,9 @@ public class BizMeetingController extends BaseController {
m.setIsSettled(1); m.setIsSettled(1);
m.setSettleTime(new Date()); m.setSettleTime(new Date());
// 结算即终态: 直接落完结标记, 省去二次「完结」动作 (auto-finish)
m.setIsFinished(1);
m.setFinishTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m)); m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m); bizMeetingService.updateByPrimaryKey(m);
appendAuditLog(m, "SETTLE", "APPROVED", "会议结算", null); appendAuditLog(m, "SETTLE", "APPROVED", "会议结算", null);
@@ -1,5 +1,6 @@
package com.ruoyi.business.controller; package com.ruoyi.business.controller;
import java.io.InputStream;
import java.util.List; import java.util.List;
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.*;
@@ -10,7 +11,9 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType; 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.common.utils.file.FileUtils;
import com.ruoyi.business.domain.BizMeetingMaterial; import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.oss.OssZipService;
import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
@@ -29,6 +32,8 @@ public class BizMeetingMaterialController extends BaseController {
private IBizMeetingMaterialService bizMeetingMaterialService; private IBizMeetingMaterialService bizMeetingMaterialService;
@Autowired @Autowired
private IBizMeetingService bizMeetingService; private IBizMeetingService bizMeetingService;
@Autowired
private OssZipService ossZipService;
/** /**
* 查该会议的所有材料记录 * 查该会议的所有材料记录
@@ -56,9 +61,14 @@ public class BizMeetingMaterialController extends BaseController {
} }
} }
} }
// 先判断材料是否实际变化 (必须在 replaceByMeetingId 之前, 后者会先删旧记录).
// 会务材料没变时不置"统计中", 避免无谓重算 + 前端"统计中"闪烁.
boolean changed = bizMeetingMaterialService.isMaterialSetChanged(meetingId, list);
List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list); List<BizMeetingMaterial> saved = bizMeetingMaterialService.replaceByMeetingId(meetingId, list);
// 材料变化 → 会议费用待重算 (FeeCalcScheduler 汇总回写) if (changed) {
bizMeetingService.markFeeCalcPending(meetingId); // 材料变化 → 立即重算会议费用 (发票未 OCR 完则回滚 0 走调度器兜底)
bizMeetingService.recomputeMeetingFee(meetingId);
}
return success(saved); return success(saved);
} }
@@ -92,58 +102,79 @@ public class BizMeetingMaterialController extends BaseController {
} }
/** /**
* 会务下载: 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打 zip, 返回下载 URL. * 会务下载: 把该会议所有"会务"材料 (SERVICE + SERVICE_VOUCHER) 在 OSS 端打 zip, 后端代理改名下发.
* 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮). * 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮).
* 文件名: 项目编号_项目名称_第N期_会务.zip
*/ */
@GetMapping("/{meetingId}/downloadZip") @GetMapping("/{meetingId}/downloadZip")
public AjaxResult downloadZip(@PathVariable("meetingId") Long meetingId) { public void downloadZip(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) { if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载会务材料"); throw new ServiceException("只有管理员或合规经理可下载会务材料");
} }
// 注意: 不能用 success(String), 否则 URL 会被塞进 msg 字段 (BaseController.success(String) 重载陷阱) String url = bizMeetingMaterialService.buildServiceZipUrl(meetingId);
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildServiceZipUrl(meetingId)); streamZip(url, bizMeetingMaterialService.serviceZipFilename(meetingId), response);
} }
/** /**
* 劳务下载: 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息在 OSS 端打 zip, 返回下载 URL. * 劳务下载: 把该会议所有"劳务"材料 (LABOR + LABOR_VOUCHER) + 参会人信息在 OSS 端打 zip, 后端代理改名下发.
* 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮). * 仅 admin/manager 可触发 (前端 manager/meetings 列表操作栏按钮).
* 文件名: 项目编号_项目名称_第N期_劳务.zip
*/ */
@GetMapping("/{meetingId}/downloadLaborZip") @GetMapping("/{meetingId}/downloadLaborZip")
public AjaxResult downloadLaborZip(@PathVariable("meetingId") Long meetingId) { public void downloadLaborZip(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) { if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载劳务材料"); throw new ServiceException("只有管理员或合规经理可下载劳务材料");
} }
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildLaborZipUrl(meetingId)); String url = bizMeetingMaterialService.buildLaborZipUrl(meetingId);
streamZip(url, bizMeetingMaterialService.laborZipFilename(meetingId), response);
} }
/** /**
* 批量会务下载: 多会议会务材料合并打一个 zip, 返回下载 URL. * 批量会务下载: 多会议会务材料合并打一个 zip, 后端代理改名下发.
* body: { "meetingIds": [1,2,3] }, 仅 admin/manager. * body: { "meetingIds": [1,2,3] }, 仅 admin/manager.
* 文件名: 会务_下载时间.zip
*/ */
@PostMapping("/batchDownloadZip") @PostMapping("/batchDownloadZip")
public AjaxResult batchDownloadZip(@RequestBody(required = false) BatchDownloadBody body) { public void batchDownloadZip(@RequestBody(required = false) BatchDownloadBody body, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) { if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载会务材料"); throw new ServiceException("只有管理员或合规经理可下载会务材料");
} }
List<Long> meetingIds = body == null ? null : body.getMeetingIds(); List<Long> meetingIds = body == null ? null : body.getMeetingIds();
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildBatchServiceZipUrl(meetingIds)); String url = bizMeetingMaterialService.buildBatchServiceZipUrl(meetingIds);
streamZip(url, bizMeetingMaterialService.batchServiceZipFilename(), response);
} }
/** /**
* 批量劳务下载: 多会议劳务材料(含参会人信息)合并打一个 zip, 返回下载 URL. * 批量劳务下载: 多会议劳务材料(含参会人信息)合并打一个 zip, 后端代理改名下发.
* body: { "meetingIds": [1,2,3] }, 仅 admin/manager. * body: { "meetingIds": [1,2,3] }, 仅 admin/manager.
* 文件名: 劳务_下载时间.zip
*/ */
@PostMapping("/batchDownloadLaborZip") @PostMapping("/batchDownloadLaborZip")
public AjaxResult batchDownloadLaborZip(@RequestBody(required = false) BatchDownloadBody body) { public void batchDownloadLaborZip(@RequestBody(required = false) BatchDownloadBody body, HttpServletResponse response) throws Exception {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) { if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可下载劳务材料"); throw new ServiceException("只有管理员或合规经理可下载劳务材料");
} }
List<Long> meetingIds = body == null ? null : body.getMeetingIds(); List<Long> meetingIds = body == null ? null : body.getMeetingIds();
return AjaxResult.success("操作成功", bizMeetingMaterialService.buildBatchLaborZipUrl(meetingIds)); String url = bizMeetingMaterialService.buildBatchLaborZipUrl(meetingIds);
streamZip(url, bizMeetingMaterialService.batchLaborZipFilename(), response);
}
/** 从 FC 打包返回的签名 URL 拉取 zip 字节流, 以自定义文件名写回 response (Content-Disposition 走 UTF-8 编码) */
private void streamZip(String url, String filename, HttpServletResponse response) throws Exception {
response.setContentType("application/zip");
FileUtils.setAttachmentResponseHeader(response, filename);
try (InputStream in = ossZipService.openZipStream(url)) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
response.getOutputStream().write(buf, 0, n);
}
response.getOutputStream().flush();
}
} }
/** /**
@@ -167,7 +198,10 @@ public class BizMeetingMaterialController extends BaseController {
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file, public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception { @RequestParam("meetingId") Long meetingId) throws Exception {
int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId); int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId);
bizMeetingService.markFeeCalcPending(meetingId); // 仅当真有材料被替换 (内容变化, updated>0) 才立即重算会务费; 全量未变不置"统计中"
if (updated > 0) {
bizMeetingService.recomputeMeetingFee(meetingId);
}
return success(updated); return success(updated);
} }
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.mapper.SysUserMapper;
/**
* 会议阶段统计端点 — 独立 Controller (仅 sponsor/Home KPI 用).
*
* <p>独立成 Controller 的原因: BizMeetingController 有 `GET /{meetingId}` 详情端点,
* Spring 6.x 会把同 Controller 内的字面量 `/stageStats` 路由到 `/{meetingId}` 上
* (报 "参数类型不匹配"). 拆到独立 Controller 后两条路径不在同一个映射表竞争, 零冲突.
*/
@RestController
@RequestMapping("/business/meeting")
public class BizMeetingStageStatsController extends BaseController
{
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired
private SysUserMapper sysUserMapper;
/**
* 按 current_stage 分组计数, 只做 sponsor 数据权限隔离 (MAIN=本支持单位全部, SUB=自己负责的项目).
* 返回 { stage: cnt, ... }, 前端据此算: 未执行=NOT_STARTED, 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN).
* 其他角色的统计端点单独实现, 此端点非 sponsor 调用返回空 map (不泄露全量数据).
*/
@GetMapping("/stageStats")
public AjaxResult stageStats() {
Map<String, Long> result = new HashMap<>();
if (!"sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
return success(result);
}
BizMeeting q = new BizMeeting();
Long uid = SecurityUtils.getUserId();
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType())) {
q.getParams().put("monitorUserId", uid);
} else {
q.getParams().put("sponsorAdminUserId", uid);
}
for (Map<String, Object> r : bizMeetingService.selectStageStats(q)) {
// resultType=map 的 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0"
String stage = null;
long cnt = 0L;
for (Map.Entry<String, Object> e : r.entrySet()) {
String k = e.getKey();
if (k == null) continue;
if (k.equalsIgnoreCase("currentStage") || k.equalsIgnoreCase("stage")) {
stage = e.getValue() == null ? null : String.valueOf(e.getValue());
} else if (k.equalsIgnoreCase("cnt") || k.equalsIgnoreCase("count")) {
cnt = e.getValue() == null ? 0L : ((Number) e.getValue()).longValue();
}
}
if (stage != null) {
result.put(stage, cnt);
}
}
return success(result);
}
}
@@ -40,20 +40,34 @@ public class BizMessageController extends BaseController
/** /**
* 当前登录用户的最近消息 (含未读统计) * 当前登录用户的最近消息 (含未读统计)
* GET /business/message/my?limit=50 * 两种模式:
* 返回 {rows: [...], unread: 12} * - 分页: GET /business/message/my?pageNum=1&pageSize=20 (返回真实 total)
* unread 用 countUnread 跟 limit 解耦, SSE 推的也是真值 * - 快捷: GET /business/message/my?limit=50 (嵌入式列表, total=rows.size)
* 返回 {rows: [...], unread: 12, total: n}
* unread 用 countUnread 跟分页解耦, SSE 推的也是真值
*/ */
@GetMapping("/my") @GetMapping("/my")
public AjaxResult my(@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) public AjaxResult my(@RequestParam(value = "pageNum", required = false) Integer pageNum,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "limit", required = false) Integer limit)
{ {
Long uid = SecurityUtils.getUserId(); Long uid = SecurityUtils.getUserId();
List<BizMessage> rows = bizMessageService.selectMyRecent(uid, limit); List<BizMessage> rows;
int total;
if (pageNum != null && pageSize != null && pageNum > 0 && pageSize > 0) {
int offset = (pageNum - 1) * pageSize;
rows = bizMessageService.selectMyPage(uid, offset, pageSize);
total = bizMessageService.countMy(uid);
} else {
int lim = (limit == null || limit <= 0) ? 50 : limit;
rows = bizMessageService.selectMyRecent(uid, lim);
total = rows.size();
}
int unread = bizMessageService.countUnread(uid); int unread = bizMessageService.countUnread(uid);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("rows", rows); data.put("rows", rows);
data.put("unread", unread); data.put("unread", unread);
data.put("total", rows.size()); data.put("total", total);
return success(data); return success(data);
} }
@@ -15,6 +15,7 @@ import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizPerson; import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.domain.BizPersonExecutorImportVO; import com.ruoyi.business.domain.BizPersonExecutorImportVO;
import com.ruoyi.business.domain.BizPersonImportVO; import com.ruoyi.business.domain.BizPersonImportVO;
import com.ruoyi.business.mapper.BizOrgMapper;
import com.ruoyi.business.service.IBizPersonService; import com.ruoyi.business.service.IBizPersonService;
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;
@@ -33,6 +34,9 @@ public class BizPersonController extends BaseController
@Autowired @Autowired
private SysUserMapper sysUserMapper; private SysUserMapper sysUserMapper;
@Autowired
private BizOrgMapper bizOrgMapper;
/** /**
* 业务角色白名单: 这些 roleType 的 MAIN 账号调 /list 会自动按"本机构子账号"隔离 * 业务角色白名单: 这些 roleType 的 MAIN 账号调 /list 会自动按"本机构子账号"隔离
* (sys_user.parent_user_id → sys_user.user_id → biz_person.user_id 链路过滤) * (sys_user.parent_user_id → sys_user.user_id → biz_person.user_id 链路过滤)
@@ -69,8 +73,9 @@ public class BizPersonController extends BaseController
@GetMapping("/sponsorList") @GetMapping("/sponsorList")
public TableDataInfo sponsorList(BizPerson bizPerson) public TableDataInfo sponsorList(BizPerson bizPerson)
{ {
Long mainUid = getUserId(); // 严格按 org_id 圈本机构所有用户 (MAIN + SUB), 而非 parent_user_id 归属
bizPerson.getParams().put("sponsorOwnerUid", mainUid); Long orgId = bizOrgMapper.selectOrgIdByUserId(getUserId());
bizPerson.getParams().put("sponsorOrgId", orgId);
startPage(); startPage();
List<BizPerson> list = bizPersonService.selectSponsorList(bizPerson); List<BizPerson> list = bizPersonService.selectSponsorList(bizPerson);
return getDataTable(list); return getDataTable(list);
@@ -82,8 +87,9 @@ public class BizPersonController extends BaseController
@GetMapping("/executorList") @GetMapping("/executorList")
public TableDataInfo executorList(BizPerson bizPerson) public TableDataInfo executorList(BizPerson bizPerson)
{ {
Long mainUid = getUserId(); // 严格按 org_id 圈本机构所有用户 (MAIN + SUB), 而非 parent_user_id 归属
bizPerson.getParams().put("executorOwnerUid", mainUid); Long orgId = bizOrgMapper.selectOrgIdByUserId(getUserId());
bizPerson.getParams().put("executorOrgId", orgId);
startPage(); startPage();
List<BizPerson> list = bizPersonService.selectExecutorList(bizPerson); List<BizPerson> list = bizPersonService.selectExecutorList(bizPerson);
return getDataTable(list); return getDataTable(list);
@@ -114,6 +120,17 @@ public class BizPersonController extends BaseController
bizPerson.setUpdateBy(getUsername()); bizPerson.setUpdateBy(getUsername());
return toAjax(bizPersonService.updateByPrimaryKey(bizPerson)); return toAjax(bizPersonService.updateByPrimaryKey(bizPerson));
} }
/**
* 个人资料编辑 (sponsor/executor 账号管理页): 只改当前登录人自己的姓名/手机号
* 按 user_id 反查 personId, 同步写 biz_person.name + sys_user.nick_name (单一可信源)
*/
@Log(title = "个人资料", businessType = BusinessType.UPDATE)
@PutMapping("/profile")
public AjaxResult updateProfile(@RequestBody BizPerson bizPerson)
{
bizPerson.setUserId(getUserId());
return toAjax(bizPersonService.updateProfileByUserId(bizPerson));
}
/** /**
* 更换机构管理员 (admin/sponsor-people 管理员 switch) * 更换机构管理员 (admin/sponsor-people 管理员 switch)
* body: { personId } — 把该人员晋升为机构 MAIN, 原管理员降为 SUB * body: { personId } — 把该人员晋升为机构 MAIN, 原管理员降为 SUB
@@ -252,7 +252,15 @@ public class BizProjectController extends BaseController
@GetMapping("/{projectId}/assigns") @GetMapping("/{projectId}/assigns")
public AjaxResult getAssigns(@PathVariable("projectId") Long projectId) public AjaxResult getAssigns(@PathVariable("projectId") Long projectId)
{ {
return success(bizProjectAssignService.selectByProjectId(projectId)); List<BizProjectAssign> list = bizProjectAssignService.selectByProjectId(projectId);
// 执行方只看本单位 (严格隔离, 不依赖前端): 过滤掉其它执行单位
if ("executor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
Long myOrgId = bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId());
if (myOrgId != null) {
list.removeIf(a -> a.getExecutionUnitId() == null || !a.getExecutionUnitId().equals(myOrgId));
}
}
return success(list);
} }
@Log(title = "项目执行方分配", businessType = BusinessType.INSERT) @Log(title = "项目执行方分配", businessType = BusinessType.INSERT)
@@ -350,46 +358,77 @@ public class BizProjectController extends BaseController
} }
/** /**
* 通用评分 upsert (任何角色: sponsor / executor / compliance) * 评分 upsert
* POST /business/project/rate * POST /business/project/rate
* 不做可见性校验 — 评分公开 * 评分公开可读; 写入仅限 admin/manager → 'manager', sponsor → 'sponsor'
* 评分写入 biz_project_rating 后, 同步回写 biz_project.manager_score * raterRole 由后端按登录人 role_type 派生, 不信任前端 (杜绝医生/执行方伪造评分)
* = 该项目所有 compliance 角色评分的 4 维度总分之平均 (decimal(3,1) 1 位小数) * 评分写入 biz_project_rating 后, 同步回写聚合分:
* manager → biz_project.manager_score
* sponsor → biz_project.sponsor_score
* 聚合口径 = 该角色所有评分记录 4 个维度值的平均 (decimal(3,1) 1 位小数), 多人评分取平均而非最后一次覆盖
*/ */
@Log(title = "项目评分", businessType = BusinessType.UPDATE) @Log(title = "项目评分", businessType = BusinessType.UPDATE)
@PostMapping("/rate") @PostMapping("/rate")
public AjaxResult rate(@RequestBody BizProjectRating body) public AjaxResult rate(@RequestBody BizProjectRating body)
{ {
if (body.getProjectId() == null || body.getRaterRole() == null) { if (body.getProjectId() == null) {
return error("projectId / raterRole 必填"); return error("projectId 必填");
} }
// 评分人以当前登录用户为准, 不信任前端传入的 raterId // 评分人以当前登录用户为准, 不信任前端传入的 raterId
body.setRaterId(SecurityUtils.getUserId()); body.setRaterId(SecurityUtils.getUserId());
body.setCreateBy(SecurityUtils.getUsername()); body.setCreateBy(SecurityUtils.getUsername());
body.setUpdateBy(SecurityUtils.getUsername()); body.setUpdateBy(SecurityUtils.getUsername());
// raterRole 由登录人真实角色派生 (role_type 单一可信源), 杜绝医生/执行方伪造 manager/sponsor 评分
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
String raterRole;
if ("admin".equals(roleType) || "manager".equals(roleType)) {
raterRole = "manager";
}
else if ("sponsor".equals(roleType)) {
raterRole = "sponsor";
}
else {
return error("当前角色不可评分");
}
body.setRaterRole(raterRole);
int rows = bizProjectRatingService.upsertRating(body); int rows = bizProjectRatingService.upsertRating(body);
// 同步回写 biz_project.manager_score (仅 compliance 角色聚合) // 写明细后重算聚合分 (聚合分只由后端从明细算, 前端不再手写单值)
if ("compliance".equals(body.getRaterRole())) { recomputeScore(body.getProjectId(), body.getRaterRole());
BizProjectRating q = new BizProjectRating();
q.setProjectId(body.getProjectId());
q.setRaterRole("compliance");
List<BizProjectRating> all = bizProjectRatingService.selectList(q);
BigDecimal sum = BigDecimal.ZERO;
int cnt = 0;
for (BizProjectRating r : all) {
long s = safeLong(r.getQualityScore()) + safeLong(r.getResponseScore())
+ safeLong(r.getCooperationScore()) + safeLong(r.getComplianceScore());
if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; }
}
BizProject p = new BizProject();
p.setProjectId(body.getProjectId());
p.setManagerScore(cnt == 0 ? null : sum.divide(BigDecimal.valueOf(cnt), 1, RoundingMode.HALF_UP));
bizProjectService.updateByPrimaryKey(p); // mapper `<if test="managerScore != null">` 仅更新这一列
}
return toAjax(rows); return toAjax(rows);
} }
/**
* 从 biz_project_rating 实时重算某项目某角色的聚合分 (4 维度平均, 覆盖该角色所有评分人)
* 聚合 = 该角色所有评分记录 4 维度值之和 / (记录数 × 4)
*/
private void recomputeScore(Long projectId, String raterRole)
{
BizProjectRating q = new BizProjectRating();
q.setProjectId(projectId);
q.setRaterRole(raterRole);
List<BizProjectRating> all = bizProjectRatingService.selectList(q);
BigDecimal sum = BigDecimal.ZERO;
int cnt = 0;
for (BizProjectRating r : all) {
long s = safeLong(r.getQualityScore()) + safeLong(r.getResponseScore())
+ safeLong(r.getCooperationScore()) + safeLong(r.getComplianceScore());
if (s > 0) { sum = sum.add(BigDecimal.valueOf(s)); cnt++; }
}
if (cnt == 0) return; // 无有效评分, 不动聚合分
BigDecimal avg = sum.divide(BigDecimal.valueOf(cnt * 4L), 1, RoundingMode.HALF_UP);
BizProject p = new BizProject();
p.setProjectId(projectId);
if ("sponsor".equals(raterRole)) {
p.setSponsorScore(avg);
} else {
p.setManagerScore(avg);
}
bizProjectService.updateByPrimaryKey(p); // mapper `<if test="...Score != null">` 仅更新对应列
}
private static long safeLong(Long v) { return v == null ? 0 : v; } private static long safeLong(Long v) { return v == null ? 0 : v; }
/** /**
@@ -17,7 +17,7 @@ import com.ruoyi.business.service.IBizProjectPlanService;
* *
* 角色权限: * 角色权限:
* - 投稿角色 (doctor/executor/sponsor): 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0' * - 投稿角色 (doctor/executor/sponsor): 只看自己投的稿 (submitter_id = 当前用户); 新建/编辑强制 submitter_id 写自己, status 默认 '0'
* - 管理角色 (admin/manager): 全部可见, 不强制 submitter (用于审核/结算) * - 管理角色 (admin/manager): 排除未提交草稿 (status='0'), 不强制 submitter (用于审核/结算)
*/ */
@RestController @RestController
@RequestMapping("/business/projectPlan") @RequestMapping("/business/projectPlan")
@@ -32,6 +32,9 @@ public class BizProjectPlanController extends BaseController
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType(); String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (isSubmitterRole(roleType)) { if (isSubmitterRole(roleType)) {
BizProjectPlan.setSubmitterId(SecurityUtils.getUserId()); BizProjectPlan.setSubmitterId(SecurityUtils.getUserId());
} else {
// 管理角色 (admin/manager): 未提交的草稿 (status='0') 不进列表, 只审已提交的稿
BizProjectPlan.getParams().put("excludeDraft", true);
} }
startPage(); startPage();
List<BizProjectPlan> list = BizProjectPlanService.selectList(BizProjectPlan); List<BizProjectPlan> list = BizProjectPlanService.selectList(BizProjectPlan);
@@ -232,7 +232,6 @@ public class BizPublicityIntentController extends BaseController {
v.setPosition(e.getPosition()); v.setPosition(e.getPosition());
v.setPhone(e.getPhone()); v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在"); v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime()); v.setCreateTime(e.getCreateTime());
exportList.add(v); exportList.add(v);
} }
@@ -259,7 +258,6 @@ public class BizPublicityIntentController extends BaseController {
v.setPosition(e.getPosition()); v.setPosition(e.getPosition());
v.setPhone(e.getPhone()); v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在"); v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime()); v.setCreateTime(e.getCreateTime());
exportList.add(v); exportList.add(v);
} }
@@ -66,6 +66,8 @@ public class BizExpert extends BaseEntity {
private String bankCard; private String bankCard;
/** 银行名称 */ /** 银行名称 */
private String bankName; private String bankName;
/** 开户行 (支行) */
private String bankBranch;
/** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */ /** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */
private String bankRegion; private String bankRegion;
/** 开户行地址 */ /** 开户行地址 */
@@ -120,6 +122,8 @@ public class BizExpert extends BaseEntity {
public void setBankCard(String bankCard) { this.bankCard = bankCard; } public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; } public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; } public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankBranch() { return bankBranch; }
public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; }
public String getBankRegion() { return bankRegion; } public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; } public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; } public String getBankAddress() { return bankAddress; }
@@ -118,10 +118,14 @@ public class BizMeeting extends BaseEntity {
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;
/** 会议列表筛选: current_stage NOT IN (非持久化, 逗号分隔; 前端 sponsor Home "已执行会议"跳转传 'NOT_STARTED,IN_PROGRESS') */
private transient String currentStageNotIn;
/** 当前登录医生/专家在本会议的参会人记录 id (非持久化, mapper 子查询填充; 用于 /doctor/meetings 签署劳务链接) */ /** 当前登录医生/专家在本会议的参会人记录 id (非持久化, mapper 子查询填充; 用于 /doctor/meetings 签署劳务链接) */
private transient Long attendeeId; private transient Long attendeeId;
/** 当前登录医生/专家在本会议的已签劳务 PDF URL (非持久化, mapper 子查询填充; null=未签) */ /** 当前登录医生/专家在本会议的已签劳务 PDF URL (非持久化, mapper 子查询填充; null=未签) */
private transient String attendeeLaborProtocol; private transient String attendeeLaborProtocol;
/** 分配给本执行方的场次 (非持久化; executor 会议详情用, controller 按登录执行方反查 biz_project_assign.sessions 之和, 作为"期数"分母/总期数) */
private transient Long assignedSessions;
/** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */ /** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */
private Long[] attendeeUserIds; private Long[] attendeeUserIds;
/** 劳务费用 = 参会人应发金额 (fee_pre_tax) 合计 (后台定时任务汇总回写) */ /** 劳务费用 = 参会人应发金额 (fee_pre_tax) 合计 (后台定时任务汇总回写) */
@@ -223,10 +227,14 @@ public class BizMeeting extends BaseEntity {
public void setSubmitDeadline(Date submitDeadline) { this.submitDeadline = submitDeadline; } public void setSubmitDeadline(Date submitDeadline) { this.submitDeadline = submitDeadline; }
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 String getCurrentStageNotIn() { return currentStageNotIn; }
public void setCurrentStageNotIn(String currentStageNotIn) { this.currentStageNotIn = currentStageNotIn; }
public Long getAttendeeId() { return attendeeId; } public Long getAttendeeId() { return attendeeId; }
public void setAttendeeId(Long attendeeId) { this.attendeeId = attendeeId; } public void setAttendeeId(Long attendeeId) { this.attendeeId = attendeeId; }
public String getAttendeeLaborProtocol() { return attendeeLaborProtocol; } public String getAttendeeLaborProtocol() { return attendeeLaborProtocol; }
public void setAttendeeLaborProtocol(String attendeeLaborProtocol) { this.attendeeLaborProtocol = attendeeLaborProtocol; } public void setAttendeeLaborProtocol(String attendeeLaborProtocol) { this.attendeeLaborProtocol = attendeeLaborProtocol; }
public Long getAssignedSessions() { return assignedSessions; }
public void setAssignedSessions(Long assignedSessions) { this.assignedSessions = assignedSessions; }
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; }
@@ -62,6 +62,8 @@ public class BizMeetingAttendee extends BaseEntity {
private transient Date endTime; private transient Date endTime;
private transient String projectName; private transient String projectName;
private transient String projectNo; private transient String projectNo;
/** 该参会人是否已报名该项目 (biz_execution_intent 存在 user_id + project_no): 1已报名 0未报名; 会议详情参会人列表用, 未报名姓名标红 */
private transient Integer hasIntent;
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; }
@@ -131,6 +133,8 @@ public class BizMeetingAttendee extends BaseEntity {
public void setProjectName(String projectName) { this.projectName = projectName; } public void setProjectName(String projectName) { this.projectName = projectName; }
public String getProjectNo() { return projectNo; } public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; } public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public Integer getHasIntent() { return hasIntent; }
public void setHasIntent(Integer hasIntent) { this.hasIntent = hasIntent; }
/** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */ /** 软删除标记 0否1是 (admin 删除会议时级联置1, 查询需 WHERE is_deleted=0) */
private Integer isDeleted; private Integer isDeleted;
public Integer getIsDeleted() { return isDeleted; } public Integer getIsDeleted() { return isDeleted; }
@@ -45,6 +45,8 @@ public class BizPerson extends BaseEntity {
private Date updateTime; private Date updateTime;
/** 关联系统用户ID */ /** 关联系统用户ID */
private Long userId; private Long userId;
/** 是否供应商同步过来的用户 (1=是, 0=否) */
private Integer isSynced;
/** 启停状态 '0'/'1' (前端 toggle 用, BizPersonServiceImpl 同步到 sys_user.status) - 非持久化字段 */ /** 启停状态 '0'/'1' (前端 toggle 用, BizPersonServiceImpl 同步到 sys_user.status) - 非持久化字段 */
@com.fasterxml.jackson.annotation.JsonProperty("status") @com.fasterxml.jackson.annotation.JsonProperty("status")
private transient String status; private transient String status;
@@ -94,6 +96,8 @@ public class BizPerson extends BaseEntity {
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
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 Integer getIsSynced() { return isSynced; }
public void setIsSynced(Integer isSynced) { this.isSynced = isSynced; }
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
public String getUnitType() { return unitType; } public String getUnitType() { return unitType; }
@@ -63,6 +63,9 @@ public class BizProjectPlan extends BaseEntity {
@Excel(name = "update_time") @Excel(name = "update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
/** 提交时间 (状态→待审核 '1' 时写入, 列表按此倒序) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date submitTime;
/** 审核意见 */ /** 审核意见 */
private String auditOpinion; private String auditOpinion;
/** 审核人 */ /** 审核人 */
@@ -115,6 +118,8 @@ public class BizProjectPlan extends BaseEntity {
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; } public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public Date getUpdateTime() { return updateTime; } public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; } public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public Date getSubmitTime() { return submitTime; }
public void setSubmitTime(Date submitTime) { this.submitTime = submitTime; }
public String getAuditOpinion() { return auditOpinion; } public String getAuditOpinion() { return auditOpinion; }
public void setAuditOpinion(String auditOpinion) { this.auditOpinion = auditOpinion; } public void setAuditOpinion(String auditOpinion) { this.auditOpinion = auditOpinion; }
public String getAuditBy() { return auditBy; } public String getAuditBy() { return auditBy; }
@@ -38,10 +38,7 @@ public class BizPublicityExecutionIntentExportVo {
@Excel(name = "账号状态", sort = 8) @Excel(name = "账号状态", sort = 8)
private String userStatus; private String userStatus;
@Excel(name = "审核状态", sort = 9) @Excel(name = "创建时间", sort = 9, dateFormat = "yyyy-MM-dd HH:mm:ss")
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@@ -61,8 +58,6 @@ public class BizPublicityExecutionIntentExportVo {
public void setPhone(String phone) { this.phone = phone; } public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; } public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; } public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
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; }
} }
@@ -38,10 +38,7 @@ public class BizPublicitySupportIntentExportVo {
@Excel(name = "账号状态", sort = 8) @Excel(name = "账号状态", sort = 8)
private String userStatus; private String userStatus;
@Excel(name = "审核状态", sort = 9) @Excel(name = "创建时间", sort = 9, dateFormat = "yyyy-MM-dd HH:mm:ss")
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@@ -61,8 +58,6 @@ public class BizPublicitySupportIntentExportVo {
public void setPhone(String phone) { this.phone = phone; } public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; } public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; } public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
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; }
} }
@@ -1,6 +1,7 @@
package com.ruoyi.business.mapper; package com.ruoyi.business.mapper;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
@@ -11,6 +12,11 @@ public interface BizMeetingMapper
{ {
BizMeeting selectByPrimaryKey(Long meetingId); BizMeeting selectByPrimaryKey(Long meetingId);
List<BizMeeting> selectList(BizMeeting entity); List<BizMeeting> selectList(BizMeeting entity);
/**
* 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限 (不参与分页).
* 返回 [{currentStage: 'RUNNING', cnt: 10}, ...]. 其他角色的统计另行实现, 不在此复用多角色过滤.
*/
List<Map<String, Object>> selectStageStats(BizMeeting entity);
int insert(BizMeeting entity); int insert(BizMeeting entity);
int updateByPrimaryKey(BizMeeting entity); int updateByPrimaryKey(BizMeeting entity);
int deleteByPrimaryKey(Long meetingId); int deleteByPrimaryKey(Long meetingId);
@@ -25,8 +31,15 @@ public interface BizMeetingMapper
int countByProjectIdAndExecutionUnit(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId); int countByProjectIdAndExecutionUnit(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId);
/** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */ /** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */
int countByProjectIdExecutionUnitPeriod(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId, @Param("periodNo") Long periodNo); int countByProjectIdExecutionUnitPeriod(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId, @Param("periodNo") Long periodNo);
/** 修改校验用 (执行方隔离): 同上, 但排除指定 meetingId (修改自身会议不触发相同期数误报) */
int countByProjectIdExecutionUnitPeriodExclude(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId, @Param("periodNo") Long periodNo, @Param("excludeMeetingId") Long excludeMeetingId);
/** /**
* 自动流转: start_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING. * 自动流转: start_time 已过 且 end_time 未到 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 current_stage = IN_PROGRESS (执行中).
* <p>由 MeetingStageScheduler 每分钟触发. 只写 current_stage 缓存, 不动 is_executed 事实.
*/
int markInProgress();
/**
* 自动流转: end_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING (已执行).
* <p>由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写. * <p>由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写.
*/ */
int markExecuted(); int markExecuted();
@@ -40,9 +53,10 @@ public interface BizMeetingMapper
*/ */
List<Long> selectPendingFeeCalcIds(); List<Long> selectPendingFeeCalcIds();
/** /**
* 置未汇总 (人员/材料变化触发, 幂等). * 费用重算状态机用: 直接置 fee_calc_status = #{status} (-1 计算中 / 0 待算兜底).
* <p>成功(1) 由 {@link #updateFeeSummary} 一并写入, 不单独置.
*/ */
int markFeeCalcPending(Long meetingId); int updateFeeCalcStatus(@Param("meetingId") Long meetingId, @Param("status") int status);
/** /**
* 汇总回写 labor_fee/meeting_fee/total_fee 并置 fee_calc_status=1. * 汇总回写 labor_fee/meeting_fee/total_fee 并置 fee_calc_status=1.
*/ */
@@ -50,4 +64,11 @@ public interface BizMeetingMapper
@Param("laborFee") BigDecimal laborFee, @Param("laborFee") BigDecimal laborFee,
@Param("meetingFee") BigDecimal meetingFee, @Param("meetingFee") BigDecimal meetingFee,
@Param("totalFee") BigDecimal totalFee); @Param("totalFee") BigDecimal totalFee);
/**
* 同步重算劳务费 (参会人增删改后立即调用): 只回写 labor_fee + total_fee,
* 不动 meeting_fee / fee_calc_status (参会人变化不影响会务费, 也不触发整体重算).
*/
int updateLaborFee(@Param("meetingId") Long meetingId,
@Param("laborFee") BigDecimal laborFee,
@Param("totalFee") BigDecimal totalFee);
} }
@@ -18,6 +18,9 @@ public interface BizMessageMapper
/** 收件人未读总数 (SSE 推送用, 跟 limit 解耦) */ /** 收件人未读总数 (SSE 推送用, 跟 limit 解耦) */
int countUnread(Long receiverUserId); int countUnread(Long receiverUserId);
/** 收件人的全部消息总数 (分页 total 用, 跟未读解耦) */
int countMy(Long receiverUserId);
int insert(BizMessage entity); int insert(BizMessage entity);
int updateByPrimaryKey(BizMessage entity); int updateByPrimaryKey(BizMessage entity);
@@ -31,4 +31,6 @@ public interface BizOrgMapper {
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id) /** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */ * MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
Long selectOrgIdByUserId(Long userId); Long selectOrgIdByUserId(Long userId);
/** 单位名称查重: 同 orgType 下 org_name 精确匹配的条数 (新增单位前判重) */
int countByOrgName(BizOrg entity);
} }
@@ -13,6 +13,10 @@ public interface BizPersonMapper
List<BizPerson> selectSponsorList(BizPerson entity); List<BizPerson> selectSponsorList(BizPerson entity);
/** executor 专属: 同 sponsor, SQL 硬编码 unit_type='executor' (前端绕不开) */ /** executor 专属: 同 sponsor, SQL 硬编码 unit_type='executor' (前端绕不开) */
List<BizPerson> selectExecutorList(BizPerson entity); List<BizPerson> selectExecutorList(BizPerson entity);
/** 按 user_id 反查人员档案 (个人资料编辑: 拿 personId 再走 updateByPrimaryKey) */
BizPerson selectByUserId(Long userId);
/** 按 org_id 反查该机构主账号 user_id (sys_user.account_type='MAIN' + biz_person.org_id 绑定), 不走 biz_org.user_id (可能脏) */
Long selectMainUserIdByOrgId(Long orgId);
int insert(BizPerson entity); int insert(BizPerson entity);
int updateByPrimaryKey(BizPerson entity); int updateByPrimaryKey(BizPerson entity);
int deleteByPrimaryKey(String personId); int deleteByPrimaryKey(String personId);
@@ -1,6 +1,10 @@
package com.ruoyi.business.oss; package com.ruoyi.business.oss;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
@@ -199,4 +203,18 @@ public class OssZipService
} }
return location.replaceFirst("^http://", "https://"); return location.replaceFirst("^http://", "https://");
} }
/**
* 下载已打包好的 zip 字节流 (从 FC 返回的签名 URL 拉取).
* 后端代理改名下发用: 先拿到 zip 字节流, 再以自定义文件名写回 response (见 BizMeetingMaterialController),
* 绕开 FC 固定命名 output_1-xxx.zip 的问题.
*/
public InputStream openZipStream(String signedUrl) throws IOException
{
URI uri = URI.create(signedUrl);
URLConnection conn = uri.toURL().openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(120000);
return conn.getInputStream();
}
} }
@@ -1,29 +1,25 @@
package com.ruoyi.business.scheduler; package com.ruoyi.business.scheduler;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; 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.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper; import com.ruoyi.business.service.IBizMeetingService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* 会议费用汇总调度器 (每分钟一次, 多线程无锁). * 会议费用汇总调度器 (每分钟一次, 多线程无锁) — 兜底通道.
* <p> * <p>
* 两态设计 (不用抢占锁): 会议 fee_calc_status (0未汇总/1已汇总) + 材料 fee_status (0未计算/1已计算). * 主通道: 材料保存/上传、发票 OCR 完成后由 {@link IBizMeetingService#recomputeMeetingFee} 立即同步重算,
* 状态机 -1(计算中) → 1(成功) / 0(失败回滚). 本调度器只兜底扫描 fee_calc_status=0 的会议重试,
* 覆盖"立即算时发票还没 OCR 完 / 算错"留下的 0 态.
* <pre> * <pre>
* 每分钟: 查 fee_calc_status=0 的会议 * 每分钟: 查 fee_calc_status=0 的会议 → 逐个交给 recomputeMeetingFee 重算
* → 任一材料 fee_status=0 (发票还没 OCR 完) → 跳过, 等下轮
* → 全部 fee_status=1 → SUM 汇总 labor_fee/meeting_fee/total_fee → fee_calc_status=1
* </pre> * </pre>
* 为什么不需要锁: 汇总前已检查"材料全算完", 天然防半成品; 汇总纯 SUM 幂等, 并发重复无副作用; * 为什么不需要锁: 立即算/兜底都是纯 SUM 幂等, 置 1 后不再被扫到, 天然去重; -1 期间也不会被扫 (=0 才扫).
* 置 1 后不再被扫到, 天然去重. 重算 = 只对 material + attendee 重新求和, 不碰 OCR.
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -32,15 +28,13 @@ public class FeeCalcScheduler
@Autowired @Autowired
private BizMeetingMapper meetingMapper; private BizMeetingMapper meetingMapper;
@Autowired @Autowired
private BizMeetingMaterialMapper materialMapper; private IBizMeetingService bizMeetingService;
@Autowired
private BizMeetingAttendeeMapper attendeeMapper;
@Autowired @Autowired
@Qualifier("feeCalcExecutor") @Qualifier("feeCalcExecutor")
private ExecutorService feeCalcExecutor; private ExecutorService feeCalcExecutor;
/** /**
* 每分钟: 扫描 fee_calc_status=0 的会议, 并行汇总. * 每分钟: 扫描 fee_calc_status=0 的会议, 并行兜底重算.
*/ */
@Scheduled(fixedRate = 60_000, initialDelay = 60_000) @Scheduled(fixedRate = 60_000, initialDelay = 60_000)
public void calcFees() public void calcFees()
@@ -52,11 +46,11 @@ public class FeeCalcScheduler
{ {
return; return;
} }
log.info("[FeeCalcScheduler] 待汇总会议 {} 个", ids.size()); log.info("[FeeCalcScheduler] 待兜底汇总会议 {} 个", ids.size());
for (Long meetingId : ids) for (Long meetingId : ids)
{ {
if (meetingId == null) continue; if (meetingId == null) continue;
feeCalcExecutor.submit(() -> processMeeting(meetingId)); feeCalcExecutor.submit(() -> bizMeetingService.recomputeMeetingFee(meetingId));
} }
} }
catch (Exception e) catch (Exception e)
@@ -64,66 +58,4 @@ public class FeeCalcScheduler
log.warn("[FeeCalcScheduler] 扫描异常 (跳过, 下分钟再试)", 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;
}
} }
@@ -11,10 +11,11 @@ import lombok.extern.slf4j.Slf4j;
* <p> * <p>
* 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实 * 状态机已改为「事实 + 推导」模型 (见 {@code StageDeriver}): biz_meeting 存事实
* (is_executed / is_frozen / 劳务·会务两轨 audit_stage / 审核时间 …), * (is_executed / is_frozen / 劳务·会务两轨 audit_stage / 审核时间 …),
* 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的类事实落地: * 各角色看到的阶段名称由推导得出. 本调度器只负责「时间驱动」的类事实落地:
* <pre> * <pre>
* 1) start_time 到 → is_executed=1 (执行中) * 1) start_time 到 → current_stage = IN_PROGRESS (执行中)
* 2) submit_deadline 到 且 任一轨未提交/已退回 → is_frozen=1 (冻结) * 2) end_time 到 → is_executed=1 (已执行)
* 3) submit_deadline 到 且 任一轨未提交/已退回 → is_frozen=1 (冻结)
* </pre> * </pre>
* 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围. * 其余阶段流转由执行方提交 / 审核动作触发 (BizMeetingController), 不在此调度器范围.
* <p> * <p>
@@ -28,7 +29,27 @@ public class MeetingStageScheduler
private BizMeetingMapper meetingMapper; private BizMeetingMapper meetingMapper;
/** /**
* 每分钟: start_time 已过 且 劳务·会务两轨均未提交 且未执行 → 置执行中. * 每分钟: start_time 已过 且 end_time 未到 且 劳务·会务两轨均未提交 且未执行 → 置执行中 (IN_PROGRESS).
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markInProgress()
{
try
{
int affected = meetingMapper.markInProgress();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动置执行中: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 置执行中异常 (跳过, 下分钟再试)", e);
}
}
/**
* 每分钟: end_time 已过 且 劳务·会务两轨均未提交 且未执行 → 置已执行 (is_executed=1).
*/ */
@Scheduled(fixedRate = 60_000, initialDelay = 30_000) @Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markExecuted() public void markExecuted()
@@ -38,12 +59,12 @@ public class MeetingStageScheduler
int affected = meetingMapper.markExecuted(); int affected = meetingMapper.markExecuted();
if (affected > 0) if (affected > 0)
{ {
log.info("[MeetingStageScheduler] 自动置执行: 本次更新 {} 行", affected); log.info("[MeetingStageScheduler] 自动置执行: 本次更新 {} 行", affected);
} }
} }
catch (Exception e) catch (Exception e)
{ {
log.warn("[MeetingStageScheduler] 置执行异常 (跳过, 下分钟再试)", e); log.warn("[MeetingStageScheduler] 置执行异常 (跳过, 下分钟再试)", e);
} }
} }
@@ -3,27 +3,31 @@ package com.ruoyi.business.scheduler;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.supplier.SupplierAccount;
import com.ruoyi.business.supplier.SupplierAccountApiCodec; import com.ruoyi.business.supplier.SupplierAccountApiCodec;
import com.ruoyi.business.supplier.SupplierAccountSyncService;
import com.ruoyi.common.utils.http.HttpUtils; import com.ruoyi.common.utils.http.HttpUtils;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* 供应商账号数据拉取调度器: 每分钟拉取最近 5 分钟更新的账号, 解密后打印. * 供应商账号数据拉取调度器: 每分钟拉取最近 N 分钟更新的账号, 解密后同步到执行方侧.
* <p> * <p>
* 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts} * 数据源: {@code GET /supplier-api/bidding/supplier/openapi/accounts}
* 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回. * 入参 lastUpdatedTime(最后更新时间) / pageNum / pageSize, 按更新时间倒序返回.
* 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密. * 返回 data 字段为 AES-256-GCM 加密串, 用 {@link SupplierAccountApiCodec} 解密.
* <p> * <p>
* 说明: 只打印不落库 (后续需要持久化时再扩展). * 解密后按邮箱 upsert 到 sys_user / biz_org / biz_person (见 {@link SupplierAccountSyncService}).
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -44,6 +48,9 @@ public class SupplierAccountPullScheduler
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private SupplierAccountSyncService supplierAccountSyncService;
@Scheduled(fixedRate = 60_000, initialDelay = 30_000) @Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void pullAccounts() public void pullAccounts()
{ {
@@ -87,8 +94,14 @@ public class SupplierAccountPullScheduler
int size = rows.isArray() ? rows.size() : 0; int size = rows.isArray() ? rows.size() : 0;
fetched += size; fetched += size;
// 只打印即可: 整页明文 JSON 打出来 (供观察/后续落库) // 同步到执行方 sys_user / biz_org / biz_person (按邮箱 upsert)
log.info("[SupplierAccountPull] page={} total={} 本页={} 明文: {}", pageNum, total, size, plain); if (size > 0)
{
List<SupplierAccount> accounts =
objectMapper.convertValue(rows, new TypeReference<List<SupplierAccount>>() {});
supplierAccountSyncService.sync(accounts);
}
log.info("[SupplierAccountPull] page={} total={} 本页={} 同步完成", pageNum, total, size);
if (size == 0 || fetched >= total) if (size == 0 || fetched >= total)
{ {
@@ -28,6 +28,15 @@ public interface IBizMeetingMaterialService {
*/ */
List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list); List<BizMeetingMaterial> replaceByMeetingId(Long meetingId, List<BizMeetingMaterial> list);
/**
* 判断保存的材料集合相对库里是否有实际变化 (增/删 subType, 或同 subType 的 ossUrl 变化).
* <p>
* 用于"保存"按钮: 会务材料没变时不应把会议费用置"统计中" (fee_calc_status=0),
* 避免无谓的汇总重算与前端"统计中"闪烁.
* 必须在 {@link #replaceByMeetingId} 之前调用 (后者会先删旧记录).
*/
boolean isMaterialSetChanged(Long meetingId, List<BizMeetingMaterial> list);
/** /**
* 单条更新 amount (OCR 识别为发票后回写). * 单条更新 amount (OCR 识别为发票后回写).
* 不动其他字段, 不抛异常 (失败仅 log). * 不动其他字段, 不抛异常 (失败仅 log).
@@ -81,6 +90,18 @@ public interface IBizMeetingMaterialService {
*/ */
String buildBatchLaborZipUrl(List<Long> meetingIds); String buildBatchLaborZipUrl(List<Long> meetingIds);
/** 单个会务下载文件名 (项目编号_项目名称_第N期_会务.zip) */
String serviceZipFilename(Long meetingId);
/** 单个劳务下载文件名 (项目编号_项目名称_第N期_劳务.zip) */
String laborZipFilename(Long meetingId);
/** 批量会务下载文件名 (会务_下载时间.zip) */
String batchServiceZipFilename();
/** 批量劳务下载文件名 (劳务_下载时间.zip) */
String batchLaborZipFilename();
/** /**
* 生成"会务材料"空目录模板 zip (会务材料 tab "打包上传" 的"下载目录"按钮). * 生成"会务材料"空目录模板 zip (会务材料 tab "打包上传" 的"下载目录"按钮).
* <p> * <p>
@@ -1,6 +1,7 @@
package com.ruoyi.business.service; package com.ruoyi.business.service;
import java.util.List; import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
/** /**
@@ -10,6 +11,11 @@ public interface IBizMeetingService
{ {
BizMeeting getById(Long meetingId); BizMeeting getById(Long meetingId);
List<BizMeeting> selectList(BizMeeting entity); List<BizMeeting> selectList(BizMeeting entity);
/**
* 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限.
* 返回 [{currentStage: 'RUNNING', cnt: 10}, ...]. 其他角色的统计另行实现.
*/
List<Map<String, Object>> selectStageStats(BizMeeting entity);
int insert(BizMeeting entity); int insert(BizMeeting entity);
int updateByPrimaryKey(BizMeeting entity); int updateByPrimaryKey(BizMeeting entity);
int deleteByPrimaryKey(Long meetingId); int deleteByPrimaryKey(Long meetingId);
@@ -28,6 +34,19 @@ public interface IBizMeetingService
int countByProjectIdAndExecutionUnit(Long projectId, Long executionUnitId); int countByProjectIdAndExecutionUnit(Long projectId, Long executionUnitId);
/** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */ /** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */
int countByProjectIdExecutionUnitPeriod(Long projectId, Long executionUnitId, Long periodNo); int countByProjectIdExecutionUnitPeriod(Long projectId, Long executionUnitId, Long periodNo);
/** 标记会议费用待重算 (人员/材料变化触发, 幂等; 由 FeeCalcScheduler 汇总回写) */ /** 修改校验用 (执行方隔离): 同上, 但排除指定 meetingId (修改自身会议不触发相同期数误报) */
void markFeeCalcPending(Long meetingId); int countByProjectIdExecutionUnitPeriodExclude(Long projectId, Long executionUnitId, Long periodNo, Long excludeMeetingId);
/**
* 立即重算会议费用 (会务材料保存/上传、发票 OCR 完成后同步触发, 不再等 FeeCalcScheduler 每分钟扫).
* <p>状态机: -1(计算中) → 算成功 1 / 材料发票仍未 OCR 完或异常 回滚 0 走 FeeCalcScheduler 兜底.
* 全程一个事务, 失败不落 -1 残留.
*/
void recomputeMeetingFee(Long meetingId);
/**
* 同步重算劳务费 (参会人增删改后立即调用).
* <p>labor_fee = 参会人应发金额之和, 纯 DB 求和不依赖 OCR, 可当场算;
* total_fee = labor_fee + 当前 meeting_fee. 不动 meeting_fee / fee_calc_status
* (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器).
*/
void recomputeLaborFee(Long meetingId);
} }
@@ -15,6 +15,12 @@ public interface IBizMessageService
/** 收件人的最近消息 (含未读) */ /** 收件人的最近消息 (含未读) */
List<BizMessage> selectMyRecent(Long receiverUserId, Integer limit); List<BizMessage> selectMyRecent(Long receiverUserId, Integer limit);
/** 收件人的分页消息 (offset/limit, 未读在前) */
List<BizMessage> selectMyPage(Long receiverUserId, int offset, int pageSize);
/** 收件人的全部消息总数 (分页 total) */
int countMy(Long receiverUserId);
/** 收件人未读总数 (SSE 推送用) */ /** 收件人未读总数 (SSE 推送用) */
int countUnread(Long receiverUserId); int countUnread(Long receiverUserId);
@@ -21,6 +21,8 @@ public interface IBizPersonService
List<BizPerson> selectExecutorList(BizPerson entity); List<BizPerson> selectExecutorList(BizPerson entity);
SysUser insert(BizPerson entity, Long mainUserId); SysUser insert(BizPerson entity, Long mainUserId);
int updateByPrimaryKey(BizPerson entity); int updateByPrimaryKey(BizPerson entity);
/** 个人资料编辑: 按 user_id 反查 personId 后走 updateByPrimaryKey (同步 nick_name/phonenumber/email) */
int updateProfileByUserId(BizPerson entity);
int deleteByPrimaryKey(String personId); int deleteByPrimaryKey(String personId);
int deleteByPrimaryKeys(String[] personId); int deleteByPrimaryKeys(String[] personId);
/** /**
@@ -1,5 +1,7 @@
package com.ruoyi.business.service; package com.ruoyi.business.service;
import java.util.Date;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
@@ -50,6 +52,8 @@ public class StageDeriver
* 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/ * 10 值物理阶段 (NOT_STARTED/RUNNING/AWAITING_COMPLIANCE/AWAITING_SUPERVISION/
* SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导. * SUPERVISION_APPROVED/RECTIFYING/AWAITING_SETTLEMENT/SETTLED/FINISHED/FROZEN), 由事实推导.
* 两轨取最小进度 (流程最靠前的一轨决定会议物理态), 用于 current_stage 缓存 (列表筛选按物理态精确匹配). * 两轨取最小进度 (流程最靠前的一轨决定会议物理态), 用于 current_stage 缓存 (列表筛选按物理态精确匹配).
* <p>注意: IN_PROGRESS(执行中) 是时间窗口态, 由 MeetingStageScheduler 在 start_time 到点直接写 current_stage,
* 不在此处由事实推导 (本函数只在已执行后的材料动作里被调, 那时早已越过该窗口).
*/ */
public String derivePhysicalStage(BizMeeting m) public String derivePhysicalStage(BizMeeting m)
{ {
@@ -85,7 +89,7 @@ public class StageDeriver
if (t(m.getIsSettled())) return "已结算"; if (t(m.getIsSettled())) return "已结算";
int chosen = chooseState(role, laborState(m), serviceState(m)); int chosen = chooseState(role, laborState(m), serviceState(m));
return render(role, chosen, t(m.getIsExecuted())); return render(role, chosen, executionPhase(m));
} }
/** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */ /** 按角色优先级选代表轨 (两轨中优先级更高的那轨). */
@@ -130,15 +134,16 @@ public class StageDeriver
} }
/** 单轨措辞 (代表轨状态 + 角色 → 展示名). */ /** 单轨措辞 (代表轨状态 + 角色 → 展示名). */
private static String render(String role, int s, boolean executed) private static String render(String role, int s, int phase)
{ {
switch (s) switch (s)
{ {
case 0: // R 退回 case 0: // R 退回
return "executor".equals(role) ? "已退回" : "待整改"; return "executor".equals(role) ? "已退回" : "待整改";
case 1: // N 未提交 case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一)
if (!executed) return "未执行"; if (phase == 0) return "未执行";
return "executor".equals(role) ? "执行中" : "执行未传材料"; if (phase == 1) return "执行";
return "已执行";
case 2: // C0 合规审中 case 2: // C0 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读 if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
return "待审核"; // executor / manager / admin return "待审核"; // executor / manager / admin
@@ -149,4 +154,19 @@ public class StageDeriver
return "待结算"; return "待结算";
} }
} }
/**
* 执行进度三态: 0=未执行 (now &lt; start_time), 1=执行中 (start_time ≤ now &lt; end_time),
* 2=已执行 (now ≥ end_time 或 is_executed=1). 仅用于材料未提交 (N) 的展示措辞.
* <p>is_executed=1 是 scheduler 在 end_time 到点落库的「已执行」事实, 优先采信;
* 未落库前用 start_time/end_time 现场判断.
*/
private static int executionPhase(BizMeeting m)
{
if (t(m.getIsExecuted())) return 2;
Date now = new Date();
if (m.getEndTime() != null && !m.getEndTime().after(now)) return 2;
if (m.getStartTime() != null && !m.getStartTime().after(now)) return 1;
return 0;
}
} }
@@ -108,10 +108,23 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
return u; return u;
} }
/** admin / manager: 纯 sys_user */ /** admin: 纯 sys_user; manager: sys_user + biz_person (unit_type='manager', 让 biz_project.create_user_name 反查得到姓名) */
private SysUser insertPlain(AdminUserCreateBody b, String role, String encPwd) { private SysUser insertPlain(AdminUserCreateBody b, String role, String encPwd) {
SysUser u = baseUser(b, role, encPwd); SysUser u = baseUser(b, role, encPwd);
sysUserService.insertUser(u); sysUserService.insertUser(u);
if ("manager".equals(role)) {
BizPerson p = new BizPerson();
SnowflakeId.injectIfEmpty(p, "personId");
p.setName(u.getNickName() == null || u.getNickName().isEmpty() ? "用户" : u.getNickName());
p.setPhone(b.getPhonenumber() == null ? "" : b.getPhonenumber());
p.setDepartment("合规部");
p.setPosition("合规员");
p.setUnitType("manager");
p.setUserId(u.getUserId());
p.setCreateBy(SecurityUtils.getUsername());
p.setUpdateBy(SecurityUtils.getUsername());
bizPersonMapper.insert(p);
}
return u; return u;
} }
@@ -170,7 +183,7 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
BizPerson self = new BizPerson(); BizPerson self = new BizPerson();
SnowflakeId.injectIfEmpty(self, "personId"); SnowflakeId.injectIfEmpty(self, "personId");
self.setName(contactName); self.setName(u.getNickName());
self.setPhone(contactPhone); self.setPhone(contactPhone);
self.setOrgId(org.getOrgId()); self.setOrgId(org.getOrgId());
self.setDepartment("管理部"); self.setDepartment("管理部");
@@ -195,13 +208,18 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
if (!role.equals(org.getOrgType())) { if (!role.equals(org.getOrgType())) {
throw new ServiceException("所选单位类型与角色不匹配"); throw new ServiceException("所选单位类型与角色不匹配");
} }
// 主账号 user_id: 按 biz_person.org_id + sys_user.account_type='MAIN' 反查, 不取 biz_org.user_id (可能脏/过期)
Long mainUserId = bizPersonMapper.selectMainUserIdByOrgId(b.getOrgId());
if (mainUserId == null) {
throw new ServiceException("该单位暂无主账号,请先创建主账号");
}
if (b.getNickName() == null || b.getNickName().isEmpty()) { if (b.getNickName() == null || b.getNickName().isEmpty()) {
throw new ServiceException("姓名不能为空"); throw new ServiceException("姓名不能为空");
} }
SysUser u = baseUser(b, role, encPwd); SysUser u = baseUser(b, role, encPwd);
u.setAccountType("SUB"); u.setAccountType("SUB");
u.setParentUserId(org.getUserId()); // 主账号 user_id, 可为 null (单位暂无主账号时留空待分配) u.setParentUserId(mainUserId); // 主账号 user_id (按 account_type='MAIN' 反查)
sysUserService.insertUser(u); sysUserService.insertUser(u);
BizPerson p = new BizPerson(); BizPerson p = new BizPerson();
@@ -121,14 +121,21 @@ public class BizExpertServiceImpl implements IBizExpertService
@Override @Override
public int updateByPrimaryKey(BizExpert entity) public int updateByPrimaryKey(BizExpert entity)
{ {
BizExpert existed = null;
String oldAuditStatus = null; String oldAuditStatus = null;
if (entity.getExpertId() != null) { if (entity.getExpertId() != null) {
BizExpert existed = bizExpertMapper.selectByPrimaryKey(entity.getExpertId()); existed = bizExpertMapper.selectByPrimaryKey(entity.getExpertId());
if (existed != null) { if (existed != null) {
oldAuditStatus = existed.getAuditStatus(); oldAuditStatus = existed.getAuditStatus();
} }
} }
int n = bizExpertMapper.updateByPrimaryKey(entity); int n = bizExpertMapper.updateByPrimaryKey(entity);
// 姓名变更 → 同步 sys_user.nick_name (单一可信源 nick_name, biz_expert.name 作镜像)
if (n > 0 && entity.getName() != null && !entity.getName().isEmpty()) {
Long uid = entity.getUserId() != null ? entity.getUserId()
: (existed != null ? existed.getUserId() : null);
syncNickName(uid, entity.getName());
}
if (n > 0 && entity.getAuditStatus() != null if (n > 0 && entity.getAuditStatus() != null
&& !entity.getAuditStatus().equals(oldAuditStatus)) { && !entity.getAuditStatus().equals(oldAuditStatus)) {
// 重新读一次拿 userId (entity 可能只传了 expertId+auditStatus) // 重新读一次拿 userId (entity 可能只传了 expertId+auditStatus)
@@ -174,12 +181,31 @@ public class BizExpertServiceImpl implements IBizExpertService
@Override @Override
public int updateProfileByUserId(BizExpert entity) public int updateProfileByUserId(BizExpert entity)
{ {
int n;
BizExpert existed = bizExpertMapper.selectByUserId(entity.getUserId()); BizExpert existed = bizExpertMapper.selectByUserId(entity.getUserId());
if (existed == null) { if (existed == null) {
return bizExpertMapper.insertWithUserId(entity); n = bizExpertMapper.insertWithUserId(entity);
} else {
n = bizExpertMapper.updateByUserId(entity);
} }
return bizExpertMapper.updateByUserId(entity); // 姓名变更 → 同步 sys_user.nick_name
if (n > 0 && entity.getUserId() != null
&& entity.getName() != null && !entity.getName().isEmpty()) {
syncNickName(entity.getUserId(), entity.getName());
}
return n;
} }
/** 同步 sys_user.nick_name (单一可信源), userId/name 为空时静默跳过 */
private void syncNickName(Long userId, String name) {
if (userId == null || name == null || name.isEmpty()) return;
SysUser u = new SysUser();
u.setUserId(userId);
u.setNickName(name);
u.setUpdateBy(SecurityUtils.getUsername());
sysUserService.updateUser(u);
}
@Override @Override
public int deleteByPrimaryKey(Long expertId) public int deleteByPrimaryKey(Long expertId)
{ return bizExpertMapper.deleteByPrimaryKey(expertId); } { return bizExpertMapper.deleteByPrimaryKey(expertId); }
@@ -25,11 +25,13 @@ 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.business.domain.BizExpert;
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.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.BizExpertMapper;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper; import com.ruoyi.business.mapper.BizProjectMapper;
@@ -61,6 +63,8 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
private SysUserMapper sysUserMapper; private SysUserMapper sysUserMapper;
@Autowired @Autowired
private ISysUserService sysUserService; private ISysUserService sysUserService;
@Autowired
private BizExpertMapper bizExpertMapper;
/** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */ /** Jackson (Spring Boot 自带), 解析 biz_project.role_labor JSON 数组 [{role, customName, amount}] */
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@@ -116,6 +120,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
} }
phone = phone.trim(); phone = phone.trim();
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致;
// 无角色/无金额/项目无 role_labor 时跳过)
validateFeeAgainstRole(loadRoleLabor(body.getMeetingId()), body.getLaborForm(), body.getFee());
// 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List<String>) // 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List<String>)
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone)); List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
Long userId; Long userId;
@@ -152,6 +160,9 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加"); throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
} }
// 3.5 确保专家档案存在 (不存在则新建, 存在则补齐空字段), 让参会人在专家库可见
ensureExpertProfile(body, userId);
// 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增) // 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
body.setUserId(userId); body.setUserId(userId);
body.setCreateBy(SecurityUtils.getUsername()); body.setCreateBy(SecurityUtils.getUsername());
@@ -162,8 +173,71 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return newId; return newId;
} }
/**
* 确保参会人对应的 biz_expert 记录存在且字段尽量完整:
* - 不存在 → 新建 (status='Y' 正常, auditStatus='2' 通过, 与 importExpert 约定一致)
* - 存在但部分字段为空 → 只用参会人档案里的非空值补齐空字段, 不覆盖已有值
*/
private void ensureExpertProfile(BizMeetingAttendee body, Long userId) {
if (userId == null) return;
BizExpert existed = bizExpertMapper.selectByUserId(userId);
if (existed == null) {
BizExpert e = new BizExpert();
e.setExpertId(IdGenerator.generateId());
e.setUserId(userId);
e.setName(isBlank(body.getName()) ? body.getPhone() : body.getName());
e.setPhone(body.getPhone());
e.setWorkUnit(body.getWorkUnit());
e.setDepartment(body.getDepartment());
e.setTitle(body.getTitle());
e.setIdCard(body.getIdCard());
e.setBankCard(body.getBankCard());
e.setBankName(body.getBankName());
e.setBankBranch(body.getBankBranch());
e.setBankRegion(body.getBankRegion());
e.setBankAddress(body.getBankAddress());
e.setIdCardAttachments(body.getIdCardAttachments());
e.setStatus("Y");
e.setAuditStatus("2");
e.setAuditBy(SecurityUtils.getUsername());
e.setAuditTime(new Date());
e.setCreateBy(SecurityUtils.getUsername());
e.setCreateTime(new Date());
bizExpertMapper.insert(e);
return;
}
// 补充: 只填 biz_expert 里为空的字段, 已有值不覆盖
BizExpert u = new BizExpert();
u.setUserId(userId);
boolean changed = false;
if (isBlank(existed.getName()) && !isBlank(body.getName())) { u.setName(body.getName()); changed = true; }
if (isBlank(existed.getPhone()) && !isBlank(body.getPhone())) { u.setPhone(body.getPhone()); changed = true; }
if (isBlank(existed.getWorkUnit()) && !isBlank(body.getWorkUnit())) { u.setWorkUnit(body.getWorkUnit()); changed = true; }
if (isBlank(existed.getDepartment()) && !isBlank(body.getDepartment())) { u.setDepartment(body.getDepartment()); changed = true; }
if (isBlank(existed.getTitle()) && !isBlank(body.getTitle())) { u.setTitle(body.getTitle()); changed = true; }
if (isBlank(existed.getIdCard()) && !isBlank(body.getIdCard())) { u.setIdCard(body.getIdCard()); changed = true; }
if (isBlank(existed.getBankCard()) && !isBlank(body.getBankCard())) { u.setBankCard(body.getBankCard()); changed = true; }
if (isBlank(existed.getBankName()) && !isBlank(body.getBankName())) { u.setBankName(body.getBankName()); changed = true; }
if (isBlank(existed.getBankBranch()) && !isBlank(body.getBankBranch())) { u.setBankBranch(body.getBankBranch()); changed = true; }
if (isBlank(existed.getBankRegion()) && !isBlank(body.getBankRegion())) { u.setBankRegion(body.getBankRegion()); changed = true; }
if (isBlank(existed.getBankAddress()) && !isBlank(body.getBankAddress())) { u.setBankAddress(body.getBankAddress()); changed = true; }
if (isBlank(existed.getIdCardAttachments()) && !isBlank(body.getIdCardAttachments())) { u.setIdCardAttachments(body.getIdCardAttachments()); changed = true; }
if (changed) {
bizExpertMapper.updateByUserId(u);
}
}
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
@Override @Override
public int updateProfile(BizMeetingAttendee entity) { public int updateProfile(BizMeetingAttendee entity) {
Long meetingId = entity.getMeetingId();
if (meetingId == null) {
BizMeetingAttendee old = mapper.selectById(entity.getId());
if (old != null) meetingId = old.getMeetingId();
}
// 校验实发金额不超所选角色劳务金额合计 (与手动新增弹窗 MeetingDetail.validateFee 一致)
validateFeeAgainstRole(loadRoleLabor(meetingId), entity.getLaborForm(), entity.getFee());
return mapper.updateProfile(entity); return mapper.updateProfile(entity);
} }
@@ -304,18 +378,36 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
} }
/** /**
* 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按角色名匹配劳务金额. * 解析某会议的 project.role_labor JSON 数组, 失败/无数据返回 null.
* 供"实发金额 ≤ 角色设置金额"校验复用 (单条 add/edit 各自加载).
*/
private JsonNode loadRoleLabor(Long meetingId) {
if (meetingId == null) return null;
try {
BizMeeting meeting = meetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null || meeting.getProjectId() == null) return null;
BizProject project = projectMapper.selectByPrimaryKey(meeting.getProjectId());
if (project == null || project.getRoleLabor() == null || project.getRoleLabor().trim().isEmpty()) return null;
return objectMapper.readTree(project.getRoleLabor());
} catch (Exception e) {
log.warn("[attendee] 解析项目角色劳务失败 meetingId={}", meetingId, e);
return null;
}
}
/**
* 在项目角色劳务 JSON 数组 [{role, customName, amount}] 里按单个角色名匹配劳务金额.
* 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role. * 匹配规则与前端 ProjectRoleSelect 一致: role === '其他' 时用 customName 作 label, 否则用 role.
* *
* @param nodes 已解析的 role_labor JSON (可为 null/非数组) * @param nodes 已解析的 role_labor JSON (可为 null/非数组)
* @param laborForm 参会人填的角色名 (可为 null/空) * @param roleLabel 单个角色名 (可为 null/空)
* @return 匹配到的 amount (BigDecimal); 没匹配到或 amount 非法 → null * @return 匹配到的 amount (BigDecimal); 没匹配到或 amount 非法 → null
*/ */
private BigDecimal findRoleAmount(JsonNode nodes, String laborForm) { private BigDecimal matchRoleAmount(JsonNode nodes, String roleLabel) {
if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) { if (nodes == null || !nodes.isArray() || roleLabel == null || roleLabel.trim().isEmpty()) {
return null; return null;
} }
String target = laborForm.trim(); String target = roleLabel.trim();
for (JsonNode n : nodes) { for (JsonNode n : nodes) {
if (n == null || n.isNull()) continue; if (n == null || n.isNull()) continue;
String role = n.path("role").asText(""); String role = n.path("role").asText("");
@@ -334,6 +426,47 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return null; return null;
} }
/**
* 在项目角色劳务 JSON 里按角色名匹配劳务金额 (导入自动带出金额用, 单个角色).
* 保留原 findRoleAmount 语义: 单个 laborForm 精确匹配.
*/
private BigDecimal findRoleAmount(JsonNode nodes, String laborForm) {
return matchRoleAmount(nodes, laborForm);
}
/**
* 参会人角色 (laborForm, 可能逗号分隔多选) 对应的角色劳务金额合计.
* 与前端 MeetingDetail.roleAmountSum 一致: 按逗号拆分逐个匹配求和.
*
* @return 金额合计; nodes 为空/非数组 → null (无 role_labor 数据, 无法比较)
*/
private BigDecimal sumRoleAmount(JsonNode nodes, String laborForm) {
if (nodes == null || !nodes.isArray() || laborForm == null || laborForm.trim().isEmpty()) {
return null;
}
BigDecimal sum = BigDecimal.ZERO;
for (String item : laborForm.split(",")) {
BigDecimal a = matchRoleAmount(nodes, item.trim());
if (a != null) sum = sum.add(a);
}
return sum;
}
/**
* 校验实发金额(fee)不超所选角色劳务金额合计 (与前端 MeetingDetail.validateFee 一致).
* laborForm 为空 / fee 为空 / 项目无 role_labor 数据时跳过 (无"设置金额"可比较).
*/
private void validateFeeAgainstRole(JsonNode roleLaborNodes, String laborForm, BigDecimal fee) {
if (laborForm == null || laborForm.trim().isEmpty()) return;
if (fee == null) return;
BigDecimal sum = sumRoleAmount(roleLaborNodes, laborForm);
if (sum == null) return;
if (fee.compareTo(sum) > 0) {
throw new ServiceException("实发金额不能超过角色金额 "
+ sum.setScale(2, RoundingMode.HALF_UP).toPlainString() + "");
}
}
/** /**
* 批量导入参会人 (Excel → biz_meeting_attendee). * 批量导入参会人 (Excel → biz_meeting_attendee).
* *
@@ -467,11 +600,12 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
} }
String meetingName = m != null ? m.getMeetingName() : null; String meetingName = m != null ? m.getMeetingName() : null;
Date startTime = m != null ? m.getStartTime() : null; Date startTime = m != null ? m.getStartTime() : null;
Date endTime = m != null ? m.getEndTime() : null;
// 1. 短信 (电子签模板: name=参会人姓名, date=会议日期, link=签署链接) // 1. 短信 (邀请签署模板: name=参会人姓名, time=会议时间, attendeeId=参会人id)
String phone = a.getPhone(); String phone = a.getPhone();
if (phone != null && !phone.trim().isEmpty()) { if (phone != null && !phone.trim().isEmpty()) {
aliyunSmsSender.sendEsign(phone.trim(), a.getName(), startTime, aliyunSmsSender.esignLink(id)); aliyunSmsSender.sendEsign(phone.trim(), a.getName(), startTime, endTime, id);
} }
// 2. 站内信 (劳务协议待签署 + 签署链接, 与短信同一链接) // 2. 站内信 (劳务协议待签署 + 签署链接, 与短信同一链接)
bizNotifyService.esignPushed(a.getUserId(), id, mid, meetingName, aliyunSmsSender.esignLink(id)); bizNotifyService.esignPushed(a.getUserId(), id, mid, meetingName, aliyunSmsSender.esignLink(id));
@@ -5,12 +5,14 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.text.SimpleDateFormat;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.net.URI;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@@ -202,6 +204,45 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
return list; return list;
} }
@Override
public boolean isMaterialSetChanged(Long meetingId, List<BizMeetingMaterial> list) {
// 只关心"影响会务费"的服务类发票 (M_*): 劳务材料/凭证/现场照片等不影响会务费, 直接忽略.
// 会务费口径 (见 FeeCalcScheduler.computeMeetingFee): 有总发票 M_INVOICE 就用它, 否则各 M_* 子发票之和.
List<BizMeetingMaterial> oldList = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
Map<String, String> oldUrl = new HashMap<>();
if (oldList != null) {
for (BizMeetingMaterial o : oldList) {
if (isFeeRelevant(o.getSubType())) oldUrl.put(o.getSubType(), o.getOssUrl());
}
}
Map<String, String> newUrl = new HashMap<>();
if (list != null) {
for (BizMeetingMaterial m : list) {
if (isFeeRelevant(m.getSubType())) newUrl.put(m.getSubType(), m.getOssUrl());
}
}
boolean hasInvoice = oldUrl.containsKey("M_INVOICE") || newUrl.containsKey("M_INVOICE");
if (hasInvoice) {
// 有总发票: 会务费只看总发票, 其余子发票被覆盖 → 仅总发票变化才算变
return !Objects.equals(oldUrl.get("M_INVOICE"), newUrl.get("M_INVOICE"));
}
// 无总发票: 任一 M_* 子发票 (排除 M_INVOICE / M_SETTLEMENT 两个汇总单据) 增删或换 URL 都算变
Set<String> subs = new HashSet<>();
subs.addAll(oldUrl.keySet());
subs.addAll(newUrl.keySet());
subs.remove("M_INVOICE");
subs.remove("M_SETTLEMENT");
for (String sub : subs) {
if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true;
}
return false;
}
/** 是否影响会务费: 仅服务类发票 M_* (劳务 L_*、凭证 LV/SV、结算单等都不算) */
private static boolean isFeeRelevant(String subType) {
return subType != null && subType.startsWith("M_");
}
@Override @Override
public int updateAmount(Long materialId, BigDecimal amount) { public int updateAmount(Long materialId, BigDecimal amount) {
if (materialId == null || amount == null) return 0; if (materialId == null || amount == null) return 0;
@@ -217,7 +258,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
/** /**
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行. * 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行.
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底. * 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不 markFeeCalcPending. * 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算.
* extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空. * extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
*/ */
@Override @Override
@@ -309,9 +350,10 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{ {
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId); BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在"); if (meeting == null) throw new ServiceException("会议不存在");
String prefix = "download/huiwu/" + meetingId + "/"; String name = singleZipName(meeting, "会务");
String prefix = "download/huiwu/" + name + "/";
ossZipService.clearPrefix(prefix); ossZipService.clearPrefix(prefix);
int copied = stageServiceMaterials(meetingId, prefix, projectFolderName(meeting)); int copied = stageServiceMaterials(meetingId, prefix, name);
if (copied == 0) throw new ServiceException("该会议暂无可下载的会务材料"); if (copied == 0) throw new ServiceException("该会议暂无可下载的会务材料");
return ossZipService.zipDownload(prefix); return ossZipService.zipDownload(prefix);
} }
@@ -327,9 +369,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{ {
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId); BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在"); if (meeting == null) throw new ServiceException("会议不存在");
String prefix = "download/labor/" + meetingId + "/"; String name = singleZipName(meeting, "劳务");
String prefix = "download/labor/" + name + "/";
ossZipService.clearPrefix(prefix); ossZipService.clearPrefix(prefix);
int copied = stageLaborMaterials(meetingId, prefix, projectFolderName(meeting)); int copied = stageLaborMaterials(meetingId, prefix, name);
copied += stageSchedulePoster(meeting, prefix, name);
if (copied == 0) throw new ServiceException("该会议暂无可下载的劳务材料"); if (copied == 0) throw new ServiceException("该会议暂无可下载的劳务材料");
return ossZipService.zipDownload(prefix); return ossZipService.zipDownload(prefix);
} }
@@ -343,7 +387,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
public String buildBatchServiceZipUrl(List<Long> meetingIds) public String buildBatchServiceZipUrl(List<Long> meetingIds)
{ {
List<Long> ids = normalizeIds(meetingIds); List<Long> ids = normalizeIds(meetingIds);
String prefix = "download/huiwu/batch/" + System.currentTimeMillis() + "/"; String prefix = "download/huiwu/batch/" + batchZipName("会务") + "/";
ossZipService.clearPrefix(prefix); ossZipService.clearPrefix(prefix);
Set<String> usedFolders = new HashSet<>(); Set<String> usedFolders = new HashSet<>();
int copied = 0; int copied = 0;
@@ -362,7 +406,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
public String buildBatchLaborZipUrl(List<Long> meetingIds) public String buildBatchLaborZipUrl(List<Long> meetingIds)
{ {
List<Long> ids = normalizeIds(meetingIds); List<Long> ids = normalizeIds(meetingIds);
String prefix = "download/labor/batch/" + System.currentTimeMillis() + "/"; String prefix = "download/labor/batch/" + batchZipName("劳务") + "/";
ossZipService.clearPrefix(prefix); ossZipService.clearPrefix(prefix);
Set<String> usedFolders = new HashSet<>(); Set<String> usedFolders = new HashSet<>();
int copied = 0; int copied = 0;
@@ -370,12 +414,40 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{ {
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(id); BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(id);
if (meeting == null) continue; if (meeting == null) continue;
copied += stageLaborMaterials(id, prefix, uniqueFolder(usedFolders, batchMeetingFolderName(meeting))); String folder = uniqueFolder(usedFolders, batchMeetingFolderName(meeting));
copied += stageLaborMaterials(id, prefix, folder);
copied += stageSchedulePoster(meeting, prefix, folder);
} }
if (copied == 0) throw new ServiceException("所选会议暂无可下载的劳务材料"); if (copied == 0) throw new ServiceException("所选会议暂无可下载的劳务材料");
return ossZipService.zipDownload(prefix); return ossZipService.zipDownload(prefix);
} }
// ===================================================================
// 下载文件名 (FC 固定命名 output_1-xxx.zip, 需后端代理改名下发)
// ===================================================================
@Override
public String serviceZipFilename(Long meetingId)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
return singleZipName(meeting, "会务") + ".zip";
}
@Override
public String laborZipFilename(Long meetingId)
{
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (meeting == null) throw new ServiceException("会议不存在");
return singleZipName(meeting, "劳务") + ".zip";
}
@Override
public String batchServiceZipFilename() { return batchZipName("会务") + ".zip"; }
@Override
public String batchLaborZipFilename() { return batchZipName("劳务") + ".zip"; }
/** 收集该会议会务材料并 copy 到 staging 前缀 prefix + folderName 下, 返回 copy 的文件数 (0 = 无会务材料) */ /** 收集该会议会务材料并 copy 到 staging 前缀 prefix + folderName 下, 返回 copy 的文件数 (0 = 无会务材料) */
private int stageServiceMaterials(Long meetingId, String prefix, String folderName) private int stageServiceMaterials(Long meetingId, String prefix, String folderName)
{ {
@@ -432,16 +504,38 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
return copied; return copied;
} }
/** 会议 zip 顶层目录名: 项目名 (为空回退项目编号, 再回退会议ID), 统一消毒 */ /** 会议日程海报 (biz_meeting.schedule_url) staging 到 zip 的"日程海报"目录, 返回写入的文件数 (0 = 无日程海报) */
private static String projectFolderName(BizMeeting meeting) private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName)
{ {
String name = meeting.getProjectName(); if (meeting == null || meeting.getScheduleUrl() == null || meeting.getScheduleUrl().isEmpty()) return 0;
if (name == null || name.trim().isEmpty()) String srcKey = ossZipService.extractKey(meeting.getScheduleUrl());
if (srcKey == null || srcKey.isEmpty()) return 0;
String ext = extOf(srcKey);
String dstKey = prefix + folderName + "/日程海报/日程海报" + ext;
ossZipService.copyObject(srcKey, dstKey);
return 1;
}
/** 单会议下载命名 (zip 文件名 + 顶层目录): 项目编号_项目名称_第N期_{suffix} (缺项跳过, 全空回退会议ID), 统一消毒 */
private static String singleZipName(BizMeeting meeting, String suffix)
{
StringBuilder sb = new StringBuilder();
appendPart(sb, meeting.getProjectNo());
appendPart(sb, meeting.getProjectName());
if (meeting.getPeriodNo() != null)
{ {
name = (meeting.getProjectNo() != null && !meeting.getProjectNo().trim().isEmpty()) appendPart(sb, "" + meeting.getPeriodNo() + "");
? meeting.getProjectNo() : "会议" + meeting.getMeetingId();
} }
return safeName(name); String base = sb.toString();
if (base.isEmpty()) base = "会议" + meeting.getMeetingId();
return safeName(base) + "_" + suffix;
}
/** 批量下载命名 (zip 文件名): {suffix}_下载时间 (yyyyMMdd_HHmmss) */
private static String batchZipName(String suffix)
{
String time = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
return suffix + "_" + time;
} }
/** 批量 zip 每个会议顶层目录名: 项目编号_会议名_第N期 (缺项跳过, 全空回退会议ID) */ /** 批量 zip 每个会议顶层目录名: 项目编号_会议名_第N期 (缺项跳过, 全空回退会议ID) */
@@ -586,24 +680,33 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
if (files == null || files.isEmpty()) continue; if (files == null || files.isEmpty()) continue;
List<String> names = folderNames.get(subType); List<String> names = folderNames.get(subType);
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType); String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
BizMeetingMaterial old = existingBySubType.get(subType);
String ossUrl; // 先算好要上传的字节 + 文件名 (单文件原样 / 多文件先打 zip), 便于做"内容未变"判断
String fileName; String fileName;
byte[] toUpload;
boolean isZip = files.size() > 1;
if (files.size() == 1) { if (files.size() == 1) {
fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg"); fileName = (names != null && !names.isEmpty()) ? names.get(0) : (label + ".jpg");
ossUrl = ossUploader.upload(files.get(0), fileName, subDir); toUpload = files.get(0);
} else { } else {
// 目录内多文件 → 先打 zip 再上传 OSS
fileName = label + ".zip"; fileName = label + ".zip";
ossUrl = ossUploader.upload(zipFiles(files, names), fileName, subDir); toUpload = zipFiles(files, names);
} }
// 内容未变且已计算过 (fee_status=1) → 跳过重传重 OCR, 保留旧金额, 不触发会务费重算
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
&& contentEquals(old.getOssUrl(), toUpload)) {
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
continue;
}
String ossUrl = ossUploader.upload(toUpload, fileName, subDir);
// 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 待 OCR(0), 其余已计算(1) // 会务材料都是发票类 (M_* 不在 NON_OCR 集合): 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 待 OCR(0), 其余已计算(1)
boolean isZip = files.size() > 1;
boolean ocrNeeded = isZip || (ossUrl != null && RECOGNIZABLE.matcher(ossUrl).find()); boolean ocrNeeded = isZip || (ossUrl != null && RECOGNIZABLE.matcher(ossUrl).find());
int feeStatus = ocrNeeded ? 0 : 1; int feeStatus = ocrNeeded ? 0 : 1;
BizMeetingMaterial old = existingBySubType.get(subType);
Long materialId; Long materialId;
Long oldMaterialId; Long oldMaterialId;
if (old != null) { if (old != null) {
@@ -696,6 +799,22 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
return out.toByteArray(); return out.toByteArray();
} }
/** 下载 OSS 旧文件并与新文件字节比对是否一致. 下载失败按"不一致"处理 (走正常重传), 不阻塞打包上传. */
private boolean contentEquals(String ossUrl, byte[] newBytes) {
if (ossUrl == null || ossUrl.isEmpty() || newBytes == null) return false;
try {
java.net.URLConnection conn = URI.create(ossUrl).toURL().openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
try (InputStream in = conn.getInputStream()) {
return Arrays.equals(readAllBytes(in), newBytes);
}
} catch (Exception e) {
log.warn("[material] 下载旧材料对比失败, 按不一致处理 url={} err={}", ossUrl, e.getMessage());
return false;
}
}
/** /**
* 是否会被前端提交 OCR (与 MeetingDetail.saveMaterials 门控一致): * 是否会被前端提交 OCR (与 MeetingDetail.saveMaterials 门控一致):
* 文件可识别 (jpg/jpeg/png/pdf) 且 不在非发票 subType 集合里. * 文件可识别 (jpg/jpeg/png/pdf) 且 不在非发票 subType 集合里.
@@ -1,14 +1,19 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMapper; import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper; import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.mapper.BizMeetingSupervisorMapper; import com.ruoyi.business.mapper.BizMeetingSupervisorMapper;
@@ -23,6 +28,8 @@ import com.ruoyi.common.utils.id.IdGenerator;
@Service @Service
public class BizMeetingServiceImpl implements IBizMeetingService public class BizMeetingServiceImpl implements IBizMeetingService
{ {
private static final Logger log = LoggerFactory.getLogger(BizMeetingServiceImpl.class);
@Autowired @Autowired
private BizMeetingMapper bizMeetingMapper; private BizMeetingMapper bizMeetingMapper;
@Autowired @Autowired
@@ -48,6 +55,9 @@ public class BizMeetingServiceImpl implements IBizMeetingService
public List<BizMeeting> selectList(BizMeeting entity) public List<BizMeeting> selectList(BizMeeting entity)
{ return bizMeetingMapper.selectList(entity); } { return bizMeetingMapper.selectList(entity); }
@Override @Override
public List<Map<String, Object>> selectStageStats(BizMeeting entity)
{ return bizMeetingMapper.selectStageStats(entity); }
@Override
public int insert(BizMeeting entity) { public int insert(BizMeeting entity) {
// meetingId: 从 DB AUTO_INCREMENT 改为应用赋值 — 10 位数字会议ID = 开始日期(yyMMdd) + 4 位序列号(0001 起, 每开始日期 Redis 独立计数) // meetingId: 从 DB AUTO_INCREMENT 改为应用赋值 — 10 位数字会议ID = 开始日期(yyMMdd) + 4 位序列号(0001 起, 每开始日期 Redis 独立计数)
if (entity.getMeetingId() == null) { if (entity.getMeetingId() == null) {
@@ -145,9 +155,81 @@ public class BizMeetingServiceImpl implements IBizMeetingService
{ return bizMeetingMapper.countByProjectIdExecutionUnitPeriod(projectId, executionUnitId, periodNo); } { return bizMeetingMapper.countByProjectIdExecutionUnitPeriod(projectId, executionUnitId, periodNo); }
@Override @Override
public void markFeeCalcPending(Long meetingId) { public int countByProjectIdExecutionUnitPeriodExclude(Long projectId, Long executionUnitId, Long periodNo, Long excludeMeetingId)
if (meetingId != null) { { return bizMeetingMapper.countByProjectIdExecutionUnitPeriodExclude(projectId, executionUnitId, periodNo, excludeMeetingId); }
bizMeetingMapper.markFeeCalcPending(meetingId);
@Override
@Transactional(rollbackFor = Exception.class)
public void recomputeMeetingFee(Long meetingId) {
if (meetingId == null) return;
// ① 置计算中 -1, 让 FeeCalcScheduler (只扫 =0) 不并发重复算
bizMeetingMapper.updateFeeCalcStatus(meetingId, -1);
try {
List<BizMeetingMaterial> mats = materialMapper.selectByMeetingId(meetingId);
// ② 任一材料发票仍未 OCR 完 (fee_status=0) → 会务费金额未定, 回滚 0 走调度器兜底 (选项 A)
boolean ocrReady = true;
if (mats != null) {
for (BizMeetingMaterial m : mats) {
if (m.getFeeStatus() != null && m.getFeeStatus() == 0) {
ocrReady = false;
break;
}
}
}
if (!ocrReady) {
bizMeetingMapper.updateFeeCalcStatus(meetingId, 0);
return;
}
// ③ 汇总: labor_fee + meeting_fee → total_fee, 成功置 1 (updateFeeSummary 内含)
BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId);
if (laborFee == null) laborFee = BigDecimal.ZERO;
BigDecimal meetingFee = computeMeetingFee(mats);
BigDecimal totalFee = laborFee.add(meetingFee);
bizMeetingMapper.updateFeeSummary(meetingId, laborFee, meetingFee, totalFee);
log.info("[recomputeMeetingFee] 立即汇总完成 meetingId={} labor={} meeting={} total={}",
meetingId, laborFee, meetingFee, totalFee);
} catch (Exception e) {
// ④ 算错了 → 回滚 0, 交给 FeeCalcScheduler 下分钟兜底 (不让 -1 残留卡死)
log.warn("[recomputeMeetingFee] 立即汇总失败, 回滚 0 走调度器兜底 meetingId={} err={}", meetingId, e.getMessage(), e);
bizMeetingMapper.updateFeeCalcStatus(meetingId, 0);
} }
} }
/**
* 会务费口径: 有总发票 (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;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void recomputeLaborFee(Long meetingId) {
if (meetingId == null) return;
BigDecimal laborFee = attendeeMapper.sumFeePreTaxByMeetingId(meetingId);
if (laborFee == null) laborFee = BigDecimal.ZERO;
BizMeeting m = bizMeetingMapper.selectByPrimaryKey(meetingId);
if (m == null) return;
BigDecimal meetingFee = m.getMeetingFee() != null ? m.getMeetingFee() : BigDecimal.ZERO;
BigDecimal totalFee = laborFee.add(meetingFee);
bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee);
}
} }
@@ -38,6 +38,22 @@ public class BizMessageServiceImpl implements IBizMessageService
return bizMessageMapper.selectMyRecent(q); return bizMessageMapper.selectMyRecent(q);
} }
@Override
public List<BizMessage> selectMyPage(Long receiverUserId, int offset, int pageSize)
{
BizMessage q = new BizMessage();
q.setReceiverUserId(receiverUserId);
q.getParams().put("offset", offset);
q.getParams().put("limit", pageSize);
return bizMessageMapper.selectMyRecent(q);
}
@Override
public int countMy(Long receiverUserId)
{
return bizMessageMapper.countMy(receiverUserId);
}
@Override @Override
public int countUnread(Long receiverUserId) public int countUnread(Long receiverUserId)
{ {
@@ -34,12 +34,35 @@ public class BizOrgServiceImpl implements IBizOrgService {
@Override @Override
public int insert(BizOrg entity) { public int insert(BizOrg entity) {
// 单位名称查重: 同 orgType 下 org_name 不能重复 (trim 后精确匹配)
String orgName = entity.getOrgName() == null ? null : entity.getOrgName().trim();
if (orgName != null && !orgName.isEmpty()) {
BizOrg probe = new BizOrg();
probe.setOrgType(entity.getOrgType());
probe.setOrgName(orgName);
if (bizOrgMapper.countByOrgName(probe) > 0) {
throw new ServiceException("单位名称「" + orgName + "」已存在");
}
}
// 主键由 AUTO_INCREMENT 自增, 不需要 Snowflake // 主键由 AUTO_INCREMENT 自增, 不需要 Snowflake
return bizOrgMapper.insert(entity); return bizOrgMapper.insert(entity);
} }
@Override @Override
public int updateByPrimaryKey(BizOrg entity) { return bizOrgMapper.updateByPrimaryKey(entity); } public int updateByPrimaryKey(BizOrg entity) {
// 改名时同样查重: 同 orgType 下 org_name 不能重复 (trim 精确匹配), 排除自身
String orgName = entity.getOrgName() == null ? null : entity.getOrgName().trim();
if (orgName != null && !orgName.isEmpty()) {
BizOrg probe = new BizOrg();
probe.setOrgType(entity.getOrgType());
probe.setOrgName(orgName);
probe.setOrgId(entity.getOrgId());
if (bizOrgMapper.countByOrgName(probe) > 0) {
throw new ServiceException("单位名称「" + orgName + "」已存在");
}
}
return bizOrgMapper.updateByPrimaryKey(entity);
}
@Override @Override
public int deleteByPrimaryKeys(Long[] orgIds) { public int deleteByPrimaryKeys(Long[] orgIds) {
@@ -79,6 +79,15 @@ public class BizPersonServiceImpl implements IBizPersonService
} }
// 1. 创建 sys_user 子账号 // 1. 创建 sys_user 子账号
// 归属父账号: 优先取所属机构主账号 (biz_org.user_id), 而非 getUserId() (当前登录人).
// 否则 admin/manager 在 sponsor-people 建人时, 子账号会错挂到 admin 自己名下,
// 导致 sponsor 主账号在自己的 sponsor/people 页 (parent_user_id 过滤) 看不到这些人.
Long parentUid = mainUserId;
BizOrg ownerOrg = bizOrgMapper.selectByPrimaryKey(entity.getOrgId());
if (ownerOrg != null && ownerOrg.getUserId() != null) {
parentUid = ownerOrg.getUserId();
}
// role_type 取 person.unitType (sponsor/executor/doctor), 而非继承创建者角色: // role_type 取 person.unitType (sponsor/executor/doctor), 而非继承创建者角色:
// 否则 admin/manager 在 admin/sponsor-people 建人会把子账号错位成 admin/manager (后台管理员). // 否则 admin/manager 在 admin/sponsor-people 建人会把子账号错位成 admin/manager (后台管理员).
// 仅当 unitType 缺失时才回退到主账号 role_type 兜底. // 仅当 unitType 缺失时才回退到主账号 role_type 兜底.
@@ -94,7 +103,7 @@ public class BizPersonServiceImpl implements IBizPersonService
newUser.setEmail(entity.getEmail()); newUser.setEmail(entity.getEmail());
newUser.setPassword(SecurityUtils.encryptPassword(entity.getLoginPassword())); newUser.setPassword(SecurityUtils.encryptPassword(entity.getLoginPassword()));
newUser.setAccountType("SUB"); newUser.setAccountType("SUB");
newUser.setParentUserId(mainUserId); newUser.setParentUserId(parentUid);
newUser.setStatus("0"); newUser.setStatus("0");
newUser.setDelFlag("0"); newUser.setDelFlag("0");
if (roleType != null) { if (roleType != null) {
@@ -139,6 +148,17 @@ public class BizPersonServiceImpl implements IBizPersonService
return n; return n;
} }
/** 个人资料编辑: 按 user_id 反查 personId, 再复用 updateByPrimaryKey (更新 person + 同步 nick_name/phonenumber/email) */
@Override
public int updateProfileByUserId(BizPerson entity) {
BizPerson existed = bizPersonMapper.selectByUserId(entity.getUserId());
if (existed == null) {
throw new ServiceException("未找到人员档案");
}
entity.setPersonId(existed.getPersonId());
return updateByPrimaryKey(entity);
}
@Override @Override
public int deleteByPrimaryKey(String personId) public int deleteByPrimaryKey(String personId)
{ {
@@ -1,8 +1,10 @@
package com.ruoyi.business.service.impl; package com.ruoyi.business.service.impl;
import java.util.Date;
import java.util.List; import java.util.List;
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 com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizProjectPlan; import com.ruoyi.business.domain.BizProjectPlan;
import com.ruoyi.business.mapper.BizProjectPlanMapper; import com.ruoyi.business.mapper.BizProjectPlanMapper;
import com.ruoyi.business.notify.BizNotifyService; import com.ruoyi.business.notify.BizNotifyService;
@@ -23,7 +25,14 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
public List<BizProjectPlan> selectList(BizProjectPlan entity) public List<BizProjectPlan> selectList(BizProjectPlan entity)
{ return bizProjectPlanMapper.selectList(entity); } { return bizProjectPlanMapper.selectList(entity); }
@Override @Override
public int insert(BizProjectPlan entity) { com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "planId"); return bizProjectPlanMapper.insert(entity); } public int insert(BizProjectPlan entity)
{
com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "planId");
// 补 create_by / create_time (历史一直没写, 导致"提交时间"为空; 列表按 create_time 倒序)
if (entity.getCreateBy() == null) entity.setCreateBy(SecurityUtils.getUsername());
if (entity.getCreateTime() == null) entity.setCreateTime(new Date());
return bizProjectPlanMapper.insert(entity);
}
/** /**
* 通用 update. #4 触发点 (方案审核结果通知) 在这里插桩: * 通用 update. #4 触发点 (方案审核结果通知) 在这里插桩:
@@ -42,6 +51,10 @@ public class BizProjectPlanServiceImpl implements IBizProjectPlanService
oldStatus = existed.getStatus(); oldStatus = existed.getStatus();
} }
} }
// 提交动作 (状态 → '1' 待审核): 写入提交时间; 拒绝 '3' 重提也会刷新
if ("1".equals(entity.getStatus()) && !"1".equals(oldStatus)) {
entity.setSubmitTime(new Date());
}
int n = bizProjectPlanMapper.updateByPrimaryKey(entity); int n = bizProjectPlanMapper.updateByPrimaryKey(entity);
if (n > 0 && entity.getStatus() != null && !entity.getStatus().equals(oldStatus)) { if (n > 0 && entity.getStatus() != null && !entity.getStatus().equals(oldStatus)) {
// 重新读一次拿 submitterId/planName/auditOpinion (entity 可能只传了 planId+status+opinion) // 重新读一次拿 submitterId/planName/auditOpinion (entity 可能只传了 planId+status+opinion)
@@ -62,6 +62,13 @@ public class BizProjectServiceImpl implements IBizProjectService
if (entity.getCreateUserId() == null) { if (entity.getCreateUserId() == null) {
entity.setCreateUserId(SecurityUtils.getUserId()); entity.setCreateUserId(SecurityUtils.getUserId());
} }
// 补 create_by / create_time (历史一直没写, 导致项目列表"创建人/创建时间"两列为空)
if (entity.getCreateBy() == null) {
entity.setCreateBy(SecurityUtils.getUsername());
}
if (entity.getCreateTime() == null) {
entity.setCreateTime(new java.util.Date());
}
return bizProjectMapper.insert(entity); return bizProjectMapper.insert(entity);
} }
@Override @Override
@@ -16,6 +16,7 @@ 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.BizOrg;
import com.ruoyi.business.domain.BizProject; 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;
@@ -23,6 +24,7 @@ import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizProjectMapper; 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.IBizOrgService;
import com.ruoyi.business.service.PdfService; import com.ruoyi.business.service.PdfService;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
@@ -42,6 +44,8 @@ public class BizSignServiceImpl implements BizSignService {
private PdfService pdfService; private PdfService pdfService;
@Autowired @Autowired
private BizProjectMapper projectMapper; private BizProjectMapper projectMapper;
@Autowired
private IBizOrgService bizOrgService;
@Override @Override
public Map<String, Object> getSignInfo(Long attendeeId) { public Map<String, Object> getSignInfo(Long attendeeId) {
@@ -134,7 +138,12 @@ public class BizSignServiceImpl implements BizSignService {
result.put("attendeeId", attendeeId); result.put("attendeeId", attendeeId);
result.put("meetingName", meeting != null ? meeting.getMeetingName() : ""); result.put("meetingName", meeting != null ? meeting.getMeetingName() : "");
result.put("periodNo", meeting != null ? meeting.getPeriodNo() : null); result.put("periodNo", meeting != null ? meeting.getPeriodNo() : null);
result.put("totalPeriods", meeting != null ? meeting.getTotalPeriods() : null); result.put("totalPeriods", assignedTotalPeriods(meeting));
// 已签署状态: laborProtocol 非空即已签 (重复打开签署链接 → 前端直接展示 PDF)
boolean signed = attendee.getLaborProtocol() != null && !attendee.getLaborProtocol().trim().isEmpty();
result.put("signed", signed);
result.put("laborProtocol", attendee.getLaborProtocol());
return result; return result;
} }
@@ -159,11 +168,27 @@ public class BizSignServiceImpl implements BizSignService {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("meetingName", meeting.getMeetingName()); result.put("meetingName", meeting.getMeetingName());
result.put("periodNo", meeting.getPeriodNo()); result.put("periodNo", meeting.getPeriodNo());
result.put("totalPeriods", meeting.getTotalPeriods()); result.put("totalPeriods", assignedTotalPeriods(meeting));
result.put("attendeeId", attendeeId); result.put("attendeeId", attendeeId);
return result; return result;
} }
/** 期数分母: 分配给执行方 (本公司) 的总场次 (biz_project_assign.sessions 之和, 按 execution_unit_id 隔离).
* 会议无 execution_unit_id / 反查不到执行方 MAIN / assigned 算出 0 时, 回退项目总期数, 避免显示 1/0. */
private Long assignedTotalPeriods(BizMeeting meeting) {
if (meeting == null) return null;
if (meeting.getExecutionUnitId() == null || meeting.getProjectId() == null) {
return meeting.getTotalPeriods();
}
BizOrg org = bizOrgService.getById(meeting.getExecutionUnitId());
Long executorUserId = org == null ? null : org.getUserId();
if (executorUserId == null) {
return meeting.getTotalPeriods();
}
int assigned = projectMapper.countAssignedSessions(meeting.getProjectId(), executorUserId);
return assigned > 0 ? (long) assigned : meeting.getTotalPeriods();
}
private Map<String, String> map(String value, String label) { private Map<String, String> map(String value, String label) {
Map<String, String> m = new HashMap<>(); Map<String, String> m = new HashMap<>();
m.put("value", value); m.put("value", value);
@@ -7,6 +7,7 @@ import java.util.List;
import com.ruoyi.business.service.IBizMeetingInvoiceService; import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.IBizMeetingMaterialService; import com.ruoyi.business.service.IBizMeetingMaterialService;
import com.ruoyi.business.service.IBizMeetingService;
import 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;
@@ -58,6 +59,9 @@ public class InvoiceOcrService
@Autowired @Autowired
private IBizMeetingMaterialService materialService; private IBizMeetingMaterialService materialService;
@Autowired
private IBizMeetingService bizMeetingService;
@Autowired @Autowired
@Qualifier("ocrExecutor") @Qualifier("ocrExecutor")
private ExecutorService ocrExecutor; private ExecutorService ocrExecutor;
@@ -138,6 +142,8 @@ public class InvoiceOcrService
{ {
// OCR 处理完毕 (成功/非发票/失败), 该材料金额最终确定 → fee_status=1 // OCR 处理完毕 (成功/非发票/失败), 该材料金额最终确定 → fee_status=1
materialService.updateFeeStatus(materialId, 1); materialService.updateFeeStatus(materialId, 1);
// 立即尝试重算会务费 (若仍有其它发票待 OCR 会回滚 0 走兜底), 不再等 FeeCalcScheduler 每分钟扫
bizMeetingService.recomputeMeetingFee(meetingId);
} }
}); });
@@ -302,6 +308,8 @@ public class InvoiceOcrService
{ {
// 兜底 OCR 处理完毕, 金额最终确定 → fee_status=1 // 兜底 OCR 处理完毕, 金额最终确定 → fee_status=1
materialService.updateFeeStatus(inv.getMaterialId(), 1); materialService.updateFeeStatus(inv.getMaterialId(), 1);
// 立即尝试重算会务费 (若仍有其它发票待 OCR 会回滚 0 走兜底)
bizMeetingService.recomputeMeetingFee(inv.getMeetingId());
} }
} }
@@ -75,10 +75,11 @@ public class AliyunSmsSender {
} }
/** /**
* 发送电子签短信 (模板占位符 name=姓名, date=日期 MM月dd日, link=签署链接). * 发送邀请签署短信 (模板 SMS_512040098 占位符 name=姓名, time=会议时间, attendeeId=参会人id).
* 与验证码模板 (ruoyi.sms.template) 分离, 成功返回 true. * 与验证码模板 (ruoyi.sms.template) 分离, 成功返回 true.
* time 由 startTime/endTime 统一格式化: 当天 "8月13日18:20 - 19:20", 跨天 "8月13日18:20 - 8月16日19:20".
*/ */
public boolean sendEsign(String phone, String name, Date date, String link) { public boolean sendEsign(String phone, String name, Date startTime, Date endTime, Long attendeeId) {
try { try {
SendSmsRequest req = new SendSmsRequest(); SendSmsRequest req = new SendSmsRequest();
req.setPhoneNumbers(phone); req.setPhoneNumbers(phone);
@@ -86,28 +87,42 @@ public class AliyunSmsSender {
req.setTemplateCode(esignTemplate); req.setTemplateCode(esignTemplate);
Map<String, String> params = new LinkedHashMap<>(); Map<String, String> params = new LinkedHashMap<>();
params.put("name", name != null ? name : ""); params.put("name", name != null ? name : "");
params.put("date", date != null ? new SimpleDateFormat("MM月dd日").format(date) : ""); params.put("time", formatMeetingTime(startTime, endTime));
params.put("link", link != null ? link : ""); params.put("attendeeId", attendeeId != null ? String.valueOf(attendeeId) : "");
req.setTemplateParam(JSONUtil.toJsonStr(params)); req.setTemplateParam(JSONUtil.toJsonStr(params));
SendSmsResponse resp = getClient().getAcsResponse(req); SendSmsResponse resp = getClient().getAcsResponse(req);
if ("OK".equalsIgnoreCase(resp.getCode())) { if ("OK".equalsIgnoreCase(resp.getCode())) {
log.info("[SMS] 电子签短信发送成功 phone={}, bizId={}", phone, resp.getBizId()); log.info("[SMS] 邀请签署短信发送成功 phone={}, bizId={}", phone, resp.getBizId());
return true; return true;
} }
log.error("[SMS] 电子签短信发送失败 phone={}, code={}, msg={}, requestId={}", log.error("[SMS] 邀请签署短信发送失败 phone={}, code={}, msg={}, requestId={}",
phone, resp.getCode(), resp.getMessage(), resp.getRequestId()); phone, resp.getCode(), resp.getMessage(), resp.getRequestId());
return false; return false;
} catch (Exception e) { } catch (Exception e) {
log.error("[SMS] 电子签短信异常 phone={}", phone, e); log.error("[SMS] 邀请签署短信异常 phone={}", phone, e);
return false; return false;
} }
} }
/** 会议时间统一格式化: 当天 "M月d日HH:mm - HH:mm", 跨天 "M月d日HH:mm - M月d日HH:mm" */
private String formatMeetingTime(Date start, Date end) {
if (start == null && end == null) return "";
SimpleDateFormat full = new SimpleDateFormat("M月d日HH:mm");
SimpleDateFormat hm = new SimpleDateFormat("HH:mm");
if (start == null) return full.format(end);
if (end == null) return full.format(start);
SimpleDateFormat day = new SimpleDateFormat("yyyyMMdd");
if (day.format(start).equals(day.format(end))) {
return full.format(start) + " - " + hm.format(end);
}
return full.format(start) + " - " + full.format(end);
}
/** /**
* 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId} * 拼电子签签署链接: {esignBaseUrl}/#/doctor/sign-fill?attendeeId={attendeeId}
* 例: https://risingdoctor.com/hg/#/doctor/sign-fill?attendeeId=123 * 例: https://hegui.bahim.org.cn/#/doctor/sign-fill?attendeeId=123
* (nginx 子路径 /hg 已配在 ruoyi.sms.esignBaseUrl 里, 前端 Vue Router 是 hash 模式, * (前端 Vue Router 是 hash 模式, Java 只拼 #/doctor/sign-fill 路由 + attendeeId,
* 所以 Java 只拼 #/doctor/sign-fill 路由 + attendeeId) * 域名/子路径由 ruoyi.sms.esignBaseUrl 提供)
*/ */
public String esignLink(Long attendeeId) { public String esignLink(Long attendeeId) {
return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId; return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId;
@@ -0,0 +1,63 @@
package com.ruoyi.business.supplier;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* 供应商账号接口解密后的单条账号 (对应 refer/bindingdemo 里 decrypted rows[] 的一行).
* <p>
* 登录名 = account/email (邮箱); password 为 BCrypt 哈希 (不可逆), 同步时写入 sys_user.password2.
* 字段名与接口返回 JSON 的 camelCase 一致, 直接由 Jackson 反序列化.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class SupplierAccount
{
/** 登录账号 (= 邮箱) */
private String account;
/** 邮箱 */
private String email;
/** 密码 (BCrypt 哈希, 不可逆) */
private String password;
/** 联系人 */
private String contactName;
/** 联系电话 */
private String contactPhone;
/** 企业名称 */
private String enterpriseName;
/** 企业类型 */
private String enterpriseType;
/** 公司地址 */
private String companyAddress;
/** 税号 */
private String taxNo;
/** 删除标识 (true=已删除) */
private Boolean deleted;
/** 禁用标识 (true=已禁用) */
private Boolean disabled;
/** 供应商编码 */
private String supplierCode;
public String getAccount() { return account; }
public void setAccount(String account) { this.account = account; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getContactName() { return contactName; }
public void setContactName(String contactName) { this.contactName = contactName; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getEnterpriseName() { return enterpriseName; }
public void setEnterpriseName(String enterpriseName) { this.enterpriseName = enterpriseName; }
public String getEnterpriseType() { return enterpriseType; }
public void setEnterpriseType(String enterpriseType) { this.enterpriseType = enterpriseType; }
public String getCompanyAddress() { return companyAddress; }
public void setCompanyAddress(String companyAddress) { this.companyAddress = companyAddress; }
public String getTaxNo() { return taxNo; }
public void setTaxNo(String taxNo) { this.taxNo = taxNo; }
public Boolean getDeleted() { return deleted; }
public void setDeleted(Boolean deleted) { this.deleted = deleted; }
public Boolean getDisabled() { return disabled; }
public void setDisabled(Boolean disabled) { this.disabled = disabled; }
public String getSupplierCode() { return supplierCode; }
public void setSupplierCode(String supplierCode) { this.supplierCode = supplierCode; }
}
@@ -0,0 +1,201 @@
package com.ruoyi.business.supplier;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.mapper.BizOrgMapper;
import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.system.mapper.SysUserMapper;
import lombok.extern.slf4j.Slf4j;
/**
* 供应商账号同步: 把供应商接口拉取/解密的账号, upsert 到执行方侧三张表.
* <p>
* 去重键 = 邮箱 (account == email). 每个供应商账号对应:
* 1. sys_user — 登录账号 (user_name/email=邮箱, password2=供应商 BCrypt 哈希, role_type=executor)
* 2. biz_org — 企业档案 (org_type=executor)
* 3. biz_person — 联系人档案 (unit_type=executor, is_synced=1)
* <p>
* 状态同步: disabled → sys_user.status='1' + biz_org.status='1'; deleted → sys_user.del_flag='2'.
* 幂等: 每分钟重跑, 按邮箱 upsert, 单条失败只 log 不影响后续.
*/
@Slf4j
@Service
public class SupplierAccountSyncService
{
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private BizOrgMapper bizOrgMapper;
@Autowired
private BizPersonMapper bizPersonMapper;
public int sync(List<SupplierAccount> accounts)
{
if (accounts == null || accounts.isEmpty())
{
return 0;
}
int done = 0;
int failed = 0;
for (SupplierAccount a : accounts)
{
try
{
syncOne(a);
done++;
}
catch (Exception e)
{
failed++;
log.warn("[SupplierAccountSync] 同步失败 account={} err={}", a.getAccount(), e.getMessage());
}
}
log.info("[SupplierAccountSync] 本轮 {} 条, 成功 {} 失败 {}", accounts.size(), done, failed);
return done;
}
private void syncOne(SupplierAccount a)
{
String email = firstNonBlank(a.getEmail(), a.getAccount());
if (email == null)
{
log.warn("[SupplierAccountSync] 账号缺邮箱, 跳过 supplierCode={}", a.getSupplierCode());
return;
}
String status = Boolean.TRUE.equals(a.getDisabled()) ? "1" : "0";
String delFlag = Boolean.TRUE.equals(a.getDeleted()) ? "2" : "0";
// 供应商侧部分账号缺联系人/电话, 做非空回退 + 按列宽截断 (避免 NOT NULL/UNIQUE/超长报错)
String nickName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 30); // sys_user.nick_name
String personName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 50); // biz_person.name
String phone = truncate(firstNonBlank(a.getContactPhone(), a.getSupplierCode(), email), 20); // biz_person.phone 非空唯一
String phonenumber = truncate(a.getContactPhone(), 11); // sys_user.phonenumber 可空
String orgName = truncate(firstNonBlank(a.getEnterpriseName(), email), 200); // biz_org.org_name
String businessNature = truncate(a.getEnterpriseType(), 20);
String address = truncate(a.getCompanyAddress(), 500);
String taxNo = truncate(a.getTaxNo(), 50);
// 1. sys_user upsert (去重键=邮箱, 不过滤 del_flag, 以支持"删除→恢复")
SysUser user = sysUserMapper.selectUserByEmailIgnoreDel(email);
Long userId;
if (user == null)
{
SysUser nu = new SysUser();
nu.setUserName(email);
nu.setNickName(nickName);
nu.setEmail(email);
nu.setPhonenumber(phonenumber);
nu.setPassword2(a.getPassword());
nu.setStatus(status);
nu.setDelFlag(delFlag);
nu.setAccountType("MAIN");
nu.setRoleType("executor");
sysUserMapper.insertSyncedUser(nu);
userId = nu.getUserId();
}
else
{
userId = user.getUserId();
SysUser upd = new SysUser();
upd.setUserId(userId);
upd.setPassword2(a.getPassword());
upd.setNickName(nickName);
upd.setPhonenumber(phonenumber);
upd.setEmail(email);
upd.setStatus(status);
upd.setDelFlag(delFlag);
sysUserMapper.updateSyncedUser(upd);
}
// 2. biz_org upsert (按主账号 user_id)
BizOrg q = new BizOrg();
q.setUserId(userId);
q.setOrgType("executor");
List<BizOrg> orgs = bizOrgMapper.selectList(q);
BizOrg org = orgs.isEmpty() ? null : orgs.get(0);
if (org == null)
{
BizOrg no = new BizOrg();
no.setUserId(userId);
no.setOrgName(orgName);
no.setOrgType("executor");
no.setBusinessNature(businessNature);
no.setAddress(address);
no.setTaxNo(taxNo);
no.setContactName(a.getContactName());
no.setContactPhone(a.getContactPhone());
no.setStatus(status);
bizOrgMapper.insert(no);
org = no;
}
else
{
org.setOrgName(orgName);
org.setBusinessNature(businessNature);
org.setAddress(address);
org.setTaxNo(taxNo);
org.setContactName(a.getContactName());
org.setContactPhone(a.getContactPhone());
org.setStatus(status);
bizOrgMapper.updateByPrimaryKey(org);
}
Long orgId = org.getOrgId();
// 3. biz_person upsert (按 user_id), 打同步标记
BizPerson person = bizPersonMapper.selectByUserId(userId);
if (person == null)
{
BizPerson np = new BizPerson();
SnowflakeId.injectIfEmpty(np, "personId");
np.setName(personName);
np.setPhone(phone);
np.setOrgId(orgId);
np.setUnitType("executor");
np.setUserId(userId);
np.setIsSynced(1);
np.setCreateBy("supplier-sync");
np.setUpdateBy("supplier-sync");
bizPersonMapper.insert(np);
}
else
{
person.setName(personName);
person.setPhone(phone);
person.setOrgId(orgId);
person.setIsSynced(1);
person.setUpdateBy("supplier-sync");
bizPersonMapper.updateByPrimaryKey(person);
}
}
private static String firstNonBlank(String... vals)
{
for (String v : vals)
{
if (v != null && !v.isBlank())
{
return v;
}
}
return null;
}
private static String truncate(String s, int max)
{
if (s == null)
{
return null;
}
return s.length() > max ? s.substring(0, max) : s;
}
}
@@ -15,6 +15,7 @@
<result property="titleCertUrl" column="title_cert_url" /> <result property="titleCertUrl" column="title_cert_url" />
<result property="bankCard" column="bank_card" /> <result property="bankCard" column="bank_card" />
<result property="bankName" column="bank_name" /> <result property="bankName" column="bank_name" />
<result property="bankBranch" column="bank_branch" />
<result property="bankRegion" column="bank_region" /> <result property="bankRegion" column="bank_region" />
<result property="bankAddress" column="bank_address" /> <result property="bankAddress" column="bank_address" />
<result property="idCardAttachments" column="id_card_attachments" /> <result property="idCardAttachments" column="id_card_attachments" />
@@ -27,7 +28,7 @@
<result property="updateTime" column="update_time" /> <result property="updateTime" column="update_time" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_region, bank_address, id_card_attachments, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_branch, bank_region, bank_address, id_card_attachments, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
from biz_expert from biz_expert
</sql> </sql>
<select id="selectByUserId" resultMap="BizExpertResult" parameterType="Long"> <select id="selectByUserId" resultMap="BizExpertResult" parameterType="Long">
@@ -68,6 +69,7 @@
<if test="titleCertUrl != null">title_cert_url,</if> <if test="titleCertUrl != null">title_cert_url,</if>
<if test="bankCard != null">bank_card,</if> <if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if> <if test="bankName != null">bank_name,</if>
<if test="bankBranch != null">bank_branch,</if>
<if test="bankRegion != null">bank_region,</if> <if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if> <if test="bankAddress != null">bank_address,</if>
<if test="auditStatus != null">audit_status,</if> <if test="auditStatus != null">audit_status,</if>
@@ -94,6 +96,7 @@
<if test="titleCertUrl != null">#{titleCertUrl},</if> <if test="titleCertUrl != null">#{titleCertUrl},</if>
<if test="bankCard != null">#{bankCard},</if> <if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if> <if test="bankName != null">#{bankName},</if>
<if test="bankBranch != null">#{bankBranch},</if>
<if test="bankRegion != null">#{bankRegion},</if> <if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if> <if test="bankAddress != null">#{bankAddress},</if>
<if test="auditStatus != null">#{auditStatus},</if> <if test="auditStatus != null">#{auditStatus},</if>
@@ -118,6 +121,7 @@
<if test="idCard != null">id_card = #{idCard},</if> <if test="idCard != null">id_card = #{idCard},</if>
<if test="bankCard != null">bank_card = #{bankCard},</if> <if test="bankCard != null">bank_card = #{bankCard},</if>
<if test="bankName != null">bank_name = #{bankName},</if> <if test="bankName != null">bank_name = #{bankName},</if>
<if test="bankBranch != null">bank_branch = #{bankBranch},</if>
<if test="bankRegion != null">bank_region = #{bankRegion},</if> <if test="bankRegion != null">bank_region = #{bankRegion},</if>
<if test="bankAddress != null">bank_address = #{bankAddress},</if> <if test="bankAddress != null">bank_address = #{bankAddress},</if>
<if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if> <if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if>
@@ -144,6 +148,7 @@
<if test="idCard != null">id_card,</if> <if test="idCard != null">id_card,</if>
<if test="bankCard != null">bank_card,</if> <if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if> <if test="bankName != null">bank_name,</if>
<if test="bankBranch != null">bank_branch,</if>
<if test="bankRegion != null">bank_region,</if> <if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if> <if test="bankAddress != null">bank_address,</if>
<if test="idCardAttachments != null">id_card_attachments,</if> <if test="idCardAttachments != null">id_card_attachments,</if>
@@ -162,6 +167,7 @@
<if test="idCard != null">#{idCard},</if> <if test="idCard != null">#{idCard},</if>
<if test="bankCard != null">#{bankCard},</if> <if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if> <if test="bankName != null">#{bankName},</if>
<if test="bankBranch != null">#{bankBranch},</if>
<if test="bankRegion != null">#{bankRegion},</if> <if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if> <if test="bankAddress != null">#{bankAddress},</if>
<if test="idCardAttachments != null">#{idCardAttachments},</if> <if test="idCardAttachments != null">#{idCardAttachments},</if>
@@ -178,6 +184,7 @@
<if test="idCard != null and idCard != ''">id_card = #{idCard},</if> <if test="idCard != null and idCard != ''">id_card = #{idCard},</if>
<if test="bankCard != null and bankCard != ''">bank_card = #{bankCard},</if> <if test="bankCard != null and bankCard != ''">bank_card = #{bankCard},</if>
<if test="bankName != null and bankName != ''">bank_name = #{bankName},</if> <if test="bankName != null and bankName != ''">bank_name = #{bankName},</if>
<if test="bankBranch != null and bankBranch != ''">bank_branch = #{bankBranch},</if>
<if test="bankRegion != null and bankRegion != ''">bank_region = #{bankRegion},</if> <if test="bankRegion != null and bankRegion != ''">bank_region = #{bankRegion},</if>
<if test="bankAddress != null and bankAddress != ''">bank_address = #{bankAddress},</if> <if test="bankAddress != null and bankAddress != ''">bank_address = #{bankAddress},</if>
<if test="idCardAttachments != null and idCardAttachments != ''">id_card_attachments = #{idCardAttachments},</if> <if test="idCardAttachments != null and idCardAttachments != ''">id_card_attachments = #{idCardAttachments},</if>
@@ -201,7 +208,7 @@
(expert_id, user_id, name, phone, region, id_card, (expert_id, user_id, name, phone, region, id_card,
work_unit, department, title, work_unit, department, title,
practice_cert_url, title_cert_url, practice_cert_url, title_cert_url,
bank_card, bank_name, bank_region, bank_address, bank_card, bank_name, bank_branch, bank_region, bank_address,
id_card_attachments, id_card_attachments,
audit_status, audit_by, audit_time, status, audit_status, audit_by, audit_time, status,
create_by, create_time) create_by, create_time)
@@ -210,7 +217,7 @@
(#{e.expertId}, #{e.userId}, #{e.name}, #{e.phone}, #{e.region}, #{e.idCard}, (#{e.expertId}, #{e.userId}, #{e.name}, #{e.phone}, #{e.region}, #{e.idCard},
#{e.workUnit}, #{e.department}, #{e.title}, #{e.workUnit}, #{e.department}, #{e.title},
#{e.practiceCertUrl}, #{e.titleCertUrl}, #{e.practiceCertUrl}, #{e.titleCertUrl},
#{e.bankCard}, #{e.bankName}, #{e.bankRegion}, #{e.bankAddress}, #{e.bankCard}, #{e.bankName}, #{e.bankBranch}, #{e.bankRegion}, #{e.bankAddress},
#{e.idCardAttachments}, #{e.idCardAttachments},
#{e.auditStatus}, #{e.auditBy}, #{e.auditTime}, #{e.status}, #{e.auditStatus}, #{e.auditBy}, #{e.auditTime}, #{e.status},
#{e.createBy}, sysdate()) #{e.createBy}, sysdate())
@@ -41,6 +41,7 @@
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
<result property="isEsigned" column="is_esigned" /> <result property="isEsigned" column="is_esigned" />
<result property="isInvited" column="is_invited" /> <result property="isInvited" column="is_invited" />
<result property="hasIntent" column="has_intent" />
</resultMap> </resultMap>
<insert id="insert" parameterType="BizMeetingAttendee"> <insert id="insert" parameterType="BizMeetingAttendee">
insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time) insert into biz_meeting_attendee(id, meeting_id, user_id, create_by, create_time)
@@ -152,7 +153,12 @@
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, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, labor_protocol_masked, create_by, create_time, is_deleted, is_esigned, is_invited,
(select if(exists(
select 1 from biz_execution_intent e
where e.user_id = biz_meeting_attendee.user_id
and e.project_no collate utf8mb4_unicode_ci = (select p.project_no collate utf8mb4_unicode_ci from biz_meeting m join biz_project p on m.project_id = p.project_id where m.meeting_id = #{meetingId})
), 1, 0)) as has_intent
from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0 from biz_meeting_attendee where meeting_id = #{meetingId} and is_deleted = 0
order by id order by id
</select> </select>
@@ -11,6 +11,7 @@
<result property="meetingName" column="meeting_name" /> <result property="meetingName" column="meeting_name" />
<result property="periodNo" column="period_no" /> <result property="periodNo" column="period_no" />
<result property="totalPeriods" column="total_periods" /> <result property="totalPeriods" column="total_periods" />
<result property="assignedSessions" column="assigned_sessions" />
<result property="projectForm" column="project_form" /> <result property="projectForm" column="project_form" />
<result property="startTime" column="start_time" /> <result property="startTime" column="start_time" />
<result property="endTime" column="end_time" /> <result property="endTime" column="end_time" />
@@ -62,12 +63,14 @@
from biz_meeting 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 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.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> <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>
(select o.org_name from biz_project p join biz_org o on o.org_id = p.sponsor_org_id where p.project_id = biz_meeting.project_id limit 1) as org_name, (select o.org_name from biz_project p join biz_org o on o.org_id = p.sponsor_org_id where p.project_id = biz_meeting.project_id limit 1) as org_name,
(select p.invitation_url from biz_project p where p.project_id = biz_meeting.project_id limit 1) as project_invitation_url, (select p.invitation_url from biz_project p where p.project_id = biz_meeting.project_id limit 1) as project_invitation_url,
<if test="params.assignedExecutorUserId != null">(select coalesce(sum(a.sessions), 0) from biz_project_assign a where a.project_id = biz_meeting.project_id and a.is_deleted = 0 and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.assignedExecutorUserId} and org_type = 'executor')) as assigned_sessions,</if>
<include refid="selectFields"/> <include refid="selectFields"/>
from biz_meeting from biz_meeting
<where> <where>
@@ -78,15 +81,15 @@
<if test="periodNo != null">and period_no = #{periodNo}</if> <if test="periodNo != null">and period_no = #{periodNo}</if>
<if test="projectForm != null and projectForm != ''">and project_form = #{projectForm}</if> <if test="projectForm != null and projectForm != ''">and project_form = #{projectForm}</if>
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if> <if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="currentStageNotIn != null and currentStageNotIn != ''">
and current_stage not in
<foreach collection="currentStageNotIn.split(',')" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
<if test="startTime != null">and start_time &gt;= #{startTime}</if> <if test="startTime != null">and start_time &gt;= #{startTime}</if>
<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) -->
<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_org_id (经 user_id 反查 org_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_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') and is_deleted = 0)</if> <if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') 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> <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>
<!-- 结题且未开通的项目, sponsor 会议不可见 (与项目列表 selectSponsorList 同口径) -->
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null"> <if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
and not exists ( and not exists (
select 1 from biz_project p2 select 1 from biz_project p2
@@ -95,20 +98,39 @@
and p2.is_finished = '1' and p2.open_status = 'N' and p2.is_finished = '1' and p2.open_status = 'N'
) )
</if> </if>
<!-- executor 数据权限: MAIN 看本执行单位项目下的会议 (biz_project_assign.execution_unit_id), SUB(执行人) 只看本人创建的会议 (create_by) -->
<if test="params.executorUserId != null">and project_id in ( <if test="params.executorUserId != null">and project_id in (
select distinct a.project_id from biz_project_assign a select distinct a.project_id from biz_project_assign a
where a.is_deleted = 0 where a.is_deleted = 0
and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor') and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')
)</if> )</if>
<if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if> <if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if>
<!-- 合规人员(manager) 数据权限: 只看本人创建项目的会议 -->
<if test="params.managerCreateUserId != null">and project_id in ( <if test="params.managerCreateUserId != null">and project_id in (
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0 select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
)</if> )</if>
</where> </where>
order by meeting_id desc order by meeting_id desc
</select> </select>
<!-- 会议阶段统计 (仅 sponsor/Home KPI 用): 按 current_stage 分组计数, 只套 sponsor 数据权限 (其他角色单独修, 不在此复用多角色过滤) -->
<select id="selectStageStats" resultType="java.util.HashMap" parameterType="BizMeeting">
select current_stage as currentStage, count(*) as cnt
from biz_meeting
<where>
is_deleted = 0
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') 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>
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
and not exists (
select 1 from biz_project p2
where p2.project_id = biz_meeting.project_id
and p2.is_deleted = 0
and p2.is_finished = '1' and p2.open_status = 'N'
)
</if>
</where>
group by current_stage
</select>
<insert id="insert" parameterType="BizMeeting"> <insert id="insert" parameterType="BizMeeting">
insert into biz_meeting insert into biz_meeting
<trim prefix="(" suffix=")" suffixOverrides=","> <trim prefix="(" suffix=")" suffixOverrides=",">
@@ -250,7 +272,27 @@
and execution_unit_id = #{executionUnitId} and execution_unit_id = #{executionUnitId}
and period_no = #{periodNo} and period_no = #{periodNo}
</select> </select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING. 已软删/已冻结/已提交材料的不动. --> <!-- 修改校验用 (执行方隔离): 同上, 但排除指定 meetingId (修改自身会议不触发相同期数误报) -->
<select id="countByProjectIdExecutionUnitPeriodExclude" resultType="int">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
and execution_unit_id = #{executionUnitId}
and period_no = #{periodNo}
and meeting_id != #{excludeMeetingId}
</select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 end_time 未到 且 material 未提交 且未执行的会议 → current_stage = IN_PROGRESS (执行中). 已软删/已冻结/已执行的不动. -->
<update id="markInProgress">
update biz_meeting
set current_stage = 'IN_PROGRESS'
where is_deleted = 0
and is_executed = 0
and is_frozen = 0
and start_time is not null
and start_time &lt;= NOW()
and end_time &gt; NOW()
and labor_audit_stage = 'NOT_SUBMITTED'
and service_audit_stage = 'NOT_SUBMITTED'
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): end_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING (已执行). 已软删/已冻结/已提交材料的不动. -->
<update id="markExecuted"> <update id="markExecuted">
update biz_meeting update biz_meeting
set is_executed = 1, set is_executed = 1,
@@ -259,8 +301,8 @@
where is_deleted = 0 where is_deleted = 0
and is_executed = 0 and is_executed = 0
and is_frozen = 0 and is_frozen = 0
and start_time is not null and end_time is not null
and start_time &lt;= NOW() and end_time &lt;= NOW()
and labor_audit_stage = 'NOT_SUBMITTED' and labor_audit_stage = 'NOT_SUBMITTED'
and service_audit_stage = 'NOT_SUBMITTED' and service_audit_stage = 'NOT_SUBMITTED'
</update> </update>
@@ -282,9 +324,9 @@
<select id="selectPendingFeeCalcIds" resultType="Long"> <select id="selectPendingFeeCalcIds" resultType="Long">
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0 select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
</select> </select>
<!-- 置未汇总 (人员/材料变化触发, 幂等) --> <!-- 费用重算状态机: 直接置 fee_calc_status (-1 计算中 / 0 待算兜底); 成功(1) 由 updateFeeSummary 一并写入 -->
<update id="markFeeCalcPending" parameterType="Long"> <update id="updateFeeCalcStatus">
update biz_meeting set fee_calc_status = 0 where meeting_id = #{meetingId} update biz_meeting set fee_calc_status = #{status} where meeting_id = #{meetingId}
</update> </update>
<!-- 汇总回写 3 个费用字段 + 置已汇总 --> <!-- 汇总回写 3 个费用字段 + 置已汇总 -->
<update id="updateFeeSummary"> <update id="updateFeeSummary">
@@ -295,4 +337,11 @@
fee_calc_status = 1 fee_calc_status = 1
where meeting_id = #{meetingId} where meeting_id = #{meetingId}
</update> </update>
<!-- 同步重算劳务费 (参会人增删改后立即调用): 只回写 labor_fee + total_fee, 不动 meeting_fee / fee_calc_status -->
<update id="updateLaborFee">
update biz_meeting
set labor_fee = #{laborFee},
total_fee = #{totalFee}
where meeting_id = #{meetingId}
</update>
</mapper> </mapper>
@@ -21,9 +21,11 @@
<sql id="selectFields"> <sql id="selectFields">
select m.msg_id, m.receiver_user_id, m.msg_type, m.title, m.content, m.biz_type, m.biz_id, select m.msg_id, m.receiver_user_id, m.msg_type, m.title, m.content, m.biz_type, m.biz_id,
m.is_read, m.read_time, m.create_by, m.create_time, m.update_by, m.update_time, m.is_read, m.read_time, m.create_by, m.create_time, m.update_by, m.update_time,
u.user_name as receiver_name COALESCE(NULLIF(bp.name, ''), NULLIF(be.name, ''), u.nick_name) as receiver_name
from biz_message m from biz_message m
left join sys_user u on u.user_id = m.receiver_user_id left join sys_user u on u.user_id = m.receiver_user_id
left join biz_person bp on bp.user_id = m.receiver_user_id
left join biz_expert be on be.user_id = m.receiver_user_id
</sql> </sql>
<select id="selectByPrimaryKey" resultMap="BizMessageResult" parameterType="Long"> <select id="selectByPrimaryKey" resultMap="BizMessageResult" parameterType="Long">
@@ -49,9 +51,23 @@
m.receiver_user_id = #{receiverUserId} m.receiver_user_id = #{receiverUserId}
</where> </where>
order by m.is_read asc, m.msg_id desc order by m.is_read asc, m.msg_id desc
<if test="params.limit != null"> <!-- 分页: params.offset 有值时用 offset,limit; 否则只用 limit (嵌入式列表) -->
limit #{params.limit} <choose>
</if> <when test="params.offset != null">
limit #{params.offset}, #{params.limit}
</when>
<otherwise>
<if test="params.limit != null">
limit #{params.limit}
</if>
</otherwise>
</choose>
</select>
<!-- 某用户的全部消息总数 (分页 total 用, 与 countUnread 解耦) -->
<select id="countMy" resultType="int" parameterType="Long">
select count(*) from biz_message
where receiver_user_id = #{receiverUserId}
</select> </select>
<select id="countUnread" resultType="int" parameterType="Long"> <select id="countUnread" resultType="int" parameterType="Long">
@@ -120,7 +120,7 @@
<if test="orgId != null">and o.org_id = #{orgId}</if> <if test="orgId != null">and o.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if> <if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc order by o.org_id desc
LIMIT 5 LIMIT 10
</select> </select>
<!-- <!--
@@ -146,7 +146,7 @@
<if test="orgId != null">and o.org_id = #{orgId}</if> <if test="orgId != null">and o.org_id = #{orgId}</if>
<if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if> <if test="orgName != null and orgName != ''">and o.org_name like concat('%', #{orgName}, '%')</if>
order by o.org_id desc order by o.org_id desc
LIMIT 5 LIMIT 10
</select> </select>
<!-- <!--
@@ -198,4 +198,12 @@
(select org_id from biz_person where user_id = #{userId} limit 1) (select org_id from biz_person where user_id = #{userId} limit 1)
) )
</select> </select>
<!-- 单位名称查重 (新增/改名前): 同 orgType 下 org_name 精确匹配 (trim 后) 的条数; orgId 传非空则排除自身 (改名用) -->
<select id="countByOrgName" parameterType="BizOrg" resultType="int">
select count(*) from biz_org
where org_type = #{orgType}
and trim(org_name) = #{orgName}
<if test="orgId != null"> and org_id != #{orgId}</if>
</select>
</mapper> </mapper>
@@ -11,6 +11,7 @@
<result property="department" column="department" /> <result property="department" column="department" />
<result property="position" column="position" /> <result property="position" column="position" />
<result property="userId" column="user_id" /> <result property="userId" column="user_id" />
<result property="isSynced" column="is_synced" />
<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" />
@@ -23,14 +24,14 @@
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select person_id, name, phone, org_id, department, position, unit_type, user_id, create_by, create_time, update_by, update_time select person_id, name, phone, org_id, department, position, unit_type, is_synced, user_id, create_by, create_time, update_by, update_time
from biz_person from biz_person
</sql> </sql>
<!-- 通用列表: LEFT JOIN biz_org 取公司名/类型, LEFT JOIN sys_user 取账号状态/类型 --> <!-- 通用列表: LEFT JOIN biz_org 取公司名/类型, LEFT JOIN sys_user 取账号状态/类型 -->
<sql id="selectFieldsWithAccount"> <sql id="selectFieldsWithAccount">
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.unit_type, p.user_id, p.department, p.position, p.unit_type, p.is_synced, 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, u.user_name as user_name,
@@ -82,6 +83,7 @@
<if test="department != null">department,</if> <if test="department != null">department,</if>
<if test="position != null">position,</if> <if test="position != null">position,</if>
<if test="unitType != null">unit_type,</if> <if test="unitType != null">unit_type,</if>
<if test="isSynced != null">is_synced,</if>
<if test="userId != null">user_id,</if> <if test="userId != null">user_id,</if>
<if test="createBy != null and createBy != ''">create_by,</if> <if test="createBy != null and createBy != ''">create_by,</if>
create_time, create_time,
@@ -96,6 +98,7 @@
<if test="department != null">#{department},</if> <if test="department != null">#{department},</if>
<if test="position != null">#{position},</if> <if test="position != null">#{position},</if>
<if test="unitType != null">#{unitType},</if> <if test="unitType != null">#{unitType},</if>
<if test="isSynced != null">#{isSynced},</if>
<if test="userId != null">#{userId},</if> <if test="userId != null">#{userId},</if>
<if test="createBy != null and createBy != ''">#{createBy},</if> <if test="createBy != null and createBy != ''">#{createBy},</if>
sysdate(), sysdate(),
@@ -110,6 +113,7 @@
<if test="userId != null and userId != ''">user_id = #{userId},</if> <if test="userId != null and userId != ''">user_id = #{userId},</if>
<if test="orgId != null">org_id = #{orgId},</if> <if test="orgId != null">org_id = #{orgId},</if>
<if test="unitType != null and unitType != ''">unit_type = #{unitType},</if> <if test="unitType != null and unitType != ''">unit_type = #{unitType},</if>
<if test="isSynced != null">is_synced = #{isSynced},</if>
<if test="name != null">name = #{name},</if> <if test="name != null">name = #{name},</if>
<if test="phone != null">phone = #{phone},</if> <if test="phone != null">phone = #{phone},</if>
<if test="department != null">department = #{department},</if> <if test="department != null">department = #{department},</if>
@@ -145,15 +149,13 @@
</foreach> </foreach>
</update> </update>
<!-- sponsor 专属: 通过 biz_person.user_id 关联 sys_user, 利用 sys_user.parent_user_id 过滤主账号归属 --> <!-- sponsor 专属: SQL 硬编码 unit_type='sponsor' + 严格按 org_id 圈本机构所有用户 (含主账号自己) -->
<!-- 主账号自己 (parent_user_id=NULL) 也通过 OR u.user_id=#{sponsorOwnerUid} 一并带上 (否则主账号看不到自己) -->
<select id="selectSponsorList" resultMap="BizPersonResult" parameterType="BizPerson"> <select id="selectSponsorList" resultMap="BizPersonResult" parameterType="BizPerson">
<include refid="selectFieldsWithAccount"/> <include refid="selectFieldsWithAccount"/>
<where> <where>
u.del_flag = '0' u.del_flag = '0'
and p.unit_type = 'sponsor' and p.unit_type = 'sponsor'
and (u.parent_user_id = #{params.sponsorOwnerUid} and p.org_id = #{params.sponsorOrgId}
or u.user_id = #{params.sponsorOwnerUid})
<if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if> <if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and p.phone = #{phone}</if> <if test="phone != null and phone != ''"> and p.phone = #{phone}</if>
<if test="orgId != null"> and p.org_id = #{orgId}</if> <if test="orgId != null"> and p.org_id = #{orgId}</if>
@@ -165,15 +167,13 @@
order by p.person_id desc order by p.person_id desc
</select> </select>
<!-- executor 专属: 同 sponsor, SQL 硬编码 unit_type='executor' + parent_user_id=当前主账号 (前端绕不开) --> <!-- executor 专属: SQL 硬编码 unit_type='executor' + 严格按 org_id 圈本机构所有用户 (含主账号自己) -->
<!-- 主账号自己 (parent_user_id=NULL) 也通过 OR u.user_id=#{executorOwnerUid} 一并带上 (否则主账号看不到自己) -->
<select id="selectExecutorList" resultMap="BizPersonResult" parameterType="BizPerson"> <select id="selectExecutorList" resultMap="BizPersonResult" parameterType="BizPerson">
<include refid="selectFieldsWithAccount"/> <include refid="selectFieldsWithAccount"/>
<where> <where>
u.del_flag = '0' u.del_flag = '0'
and p.unit_type = 'executor' and p.unit_type = 'executor'
and (u.parent_user_id = #{params.executorOwnerUid} and p.org_id = #{params.executorOrgId}
or u.user_id = #{params.executorOwnerUid})
<if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if> <if test="name != null and name != ''"> and p.name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and p.phone = #{phone}</if> <if test="phone != null and phone != ''"> and p.phone = #{phone}</if>
<if test="orgId != null"> and p.org_id = #{orgId}</if> <if test="orgId != null"> and p.org_id = #{orgId}</if>
@@ -185,4 +185,21 @@
order by p.person_id desc order by p.person_id desc
</select> </select>
<!-- 按 user_id 反查人员档案 (个人资料编辑: 拿 personId 再走 updateByPrimaryKey) -->
<select id="selectByUserId" resultMap="BizPersonResult" parameterType="Long">
<include refid="selectFields"/>
where user_id = #{userId}
limit 1
</select>
<!-- 按 org_id 反查该机构主账号 user_id: biz_person.org_id 绑定 + sys_user.account_type='MAIN', 不依赖 biz_org.user_id -->
<select id="selectMainUserIdByOrgId" resultType="java.lang.Long" parameterType="Long">
select u.user_id
from biz_person p
join sys_user u on u.user_id = p.user_id
where p.org_id = #{orgId} and u.account_type = 'MAIN' and u.del_flag = '0'
order by u.user_id
limit 1
</select>
</mapper> </mapper>
@@ -35,12 +35,14 @@
<select id="selectByProjectId" resultMap="BaseResultMap"> <select id="selectByProjectId" resultMap="BaseResultMap">
SELECT a.*, SELECT a.*,
e.user_name AS executor_user_name, COALESCE(NULLIF(ep.name, ''), e.nick_name) AS executor_user_name,
s.user_name AS staff_user_name COALESCE(NULLIF(sp.name, ''), s.nick_name) AS staff_user_name
FROM biz_project_executor_assign a FROM biz_project_executor_assign a
LEFT JOIN biz_org o ON o.org_id = a.executor_org_id LEFT JOIN biz_org o ON o.org_id = a.executor_org_id
LEFT JOIN sys_user e ON e.user_id = o.user_id LEFT JOIN sys_user e ON e.user_id = o.user_id
LEFT JOIN biz_person ep ON ep.user_id = o.user_id
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
LEFT JOIN biz_person sp ON sp.user_id = a.staff_user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0 WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC ORDER BY a.create_time DESC
</select> </select>
@@ -51,7 +51,15 @@
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount,
<!-- 已执行会议数: stage 走过 NOT_STARTED/IN_PROGRESS 就算 (含 FROZEN 已结算异常态) -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
<!-- 未执行会议数: 仅 NOT_STARTED -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
(select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount, (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount, (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0) (p.total_amount - ifnull(p.manage_fee, 0)
@@ -59,8 +67,8 @@
- (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)) as available_amount, - (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)) as available_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.publish_time, p.sponsor_org_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, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.publish_time, p.sponsor_org_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, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name, o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name, COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
lu.user_name as lead_user_name, COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name, bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',') (select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa from biz_project_assign bpa
@@ -70,13 +78,21 @@
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor' left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id left join sys_user su on su.user_id = o.user_id
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 sup on sup.user_id = o.user_id
left join biz_person lup on lup.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
</sql> </sql>
<!-- sponsor 端专属查询: 与 selectFields 等价 (sponsor 评分改走 biz_project_rating 子表, 这里只查项目本体) --> <!-- sponsor 端专属查询: 与 selectFields 等价 (sponsor 评分改走 biz_project_rating 子表, 这里只查项目本体) -->
<sql id="selectFieldsForSponsor"> <sql id="selectFieldsForSponsor">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, select p.project_id, p.project_no, p.project_name, p.total_sessions,
p.total_amount, p.total_amount,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED') as todo_sessions,
(select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount, (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount, (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0) (p.total_amount - ifnull(p.manage_fee, 0)
@@ -89,8 +105,8 @@
p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url,
p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted, p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name, o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name, COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
lu.user_name as lead_user_name, COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name, bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',') (select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa from biz_project_assign bpa
@@ -100,6 +116,8 @@
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor' left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id left join sys_user su on su.user_id = o.user_id
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 sup on sup.user_id = o.user_id
left join biz_person lup on lup.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
</sql> </sql>
<select id="selectByPrimaryKey" resultMap="BizProjectResult" parameterType="Long"> <select id="selectByPrimaryKey" resultMap="BizProjectResult" parameterType="Long">
@@ -150,6 +168,7 @@
<if test="startTime != null">and p.start_time &gt;= #{startTime}</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="endTime != null">and p.end_time &lt;= #{endTime}</if>
<if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if> <if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if>
<if test="isSettled != null and isSettled != ''">and p.is_settled = #{isSettled}</if>
</where> </where>
order by p.project_id desc order by p.project_id desc
</select> </select>
@@ -164,10 +183,10 @@
biz_meeting.execution_unit_id = 本执行方 org 过滤, 与项目级 total_amount/available_amount 无关. biz_meeting.execution_unit_id = 本执行方 org 过滤, 与项目级 total_amount/available_amount 无关.
--> -->
<select id="selectExecutorList" resultMap="BizProjectResult" parameterType="BizProject"> <select id="selectExecutorList" resultMap="BizProjectResult" parameterType="BizProject">
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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, select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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, o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name, COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
lu.user_name as lead_user_name, COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name, bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',') (select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa2 from biz_project_assign bpa2
@@ -213,7 +232,17 @@
and m.is_deleted = 0 and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount, and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
<!-- 执行方级已执行: 同上 execution_unit_id 隔离, FROZEN 算已执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions,
<!-- 执行方级未执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED'
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions
from biz_project p from biz_project p
<!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 --> <!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
join biz_project_assign a on a.project_id = p.project_id join biz_project_assign a on a.project_id = p.project_id
@@ -222,6 +251,8 @@
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor' left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id left join sys_user su on su.user_id = o.user_id
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 sup on sup.user_id = o.user_id
left join biz_person lup on lup.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
@@ -230,6 +261,7 @@
<if test="startTime != null">and p.start_time &gt;= #{startTime}</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="endTime != null">and p.end_time &lt;= #{endTime}</if>
<if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if> <if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if>
<if test="isSettled != null and isSettled != ''">and p.is_settled = #{isSettled}</if>
</where> </where>
order by p.project_id desc order by p.project_id desc
</select> </select>
@@ -242,10 +274,10 @@
已支付劳务/会务/可用金额同样按 biz_meeting.execution_unit_id = 本公司 org 过滤 (执行方级). 已支付劳务/会务/可用金额同样按 biz_meeting.execution_unit_id = 本公司 org 过滤 (执行方级).
--> -->
<select id="selectExecutorStaffList" resultMap="BizProjectResult" parameterType="BizProject"> <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.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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, select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_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, o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name, COALESCE(NULLIF(sup.name, ''), su.nick_name) as sponsor_admin_user_name,
lu.user_name as lead_user_name, COALESCE(NULLIF(lup.name, ''), lu.nick_name) as lead_user_name,
bp.name as create_user_name, bp.name as create_user_name,
(select group_concat(distinct o2.org_name separator ',') (select group_concat(distinct o2.org_name separator ',')
from biz_project_assign bpa2 from biz_project_assign bpa2
@@ -291,11 +323,22 @@
and m.is_deleted = 0 and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount, and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0 (select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
<!-- 执行人视角: 仍按主账号 org_id 聚合 (执行人看公司数据不是个人), FROZEN 算已执行 -->
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as done_sessions,
(select count(*) from biz_meeting m
where m.project_id = p.project_id and m.is_deleted = 0
and m.current_stage = 'NOT_STARTED'
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as todo_sessions
from biz_project p from biz_project p
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor' left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id left join sys_user su on su.user_id = o.user_id
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 sup on sup.user_id = o.user_id
left join biz_person lup on lup.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
@@ -309,6 +352,7 @@
<if test="startTime != null">and p.start_time &gt;= #{startTime}</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="endTime != null">and p.end_time &lt;= #{endTime}</if>
<if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if> <if test="isFinished != null and isFinished != ''">and p.is_finished = #{isFinished}</if>
<if test="isSettled != null and isSettled != ''">and p.is_settled = #{isSettled}</if>
</where> </where>
order by p.project_id desc order by p.project_id desc
</select> </select>
@@ -25,6 +25,7 @@
<result property="createTime" column="create_time" /> <result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" /> <result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" /> <result property="updateTime" column="update_time" />
<result property="submitTime" column="submit_time" />
<result property="isDeleted" column="is_deleted" /> <result property="isDeleted" column="is_deleted" />
</resultMap> </resultMap>
<sql id="selectFields"> <sql id="selectFields">
@@ -32,9 +33,9 @@
s.title as plan_direction_title, s.title as plan_direction_title,
p.plan_category, p.project_form, p.design_file_url, p.subject_direction, p.status, p.is_settled, p.plan_category, p.project_form, p.design_file_url, p.subject_direction, p.status, p.is_settled,
p.project_no, p.remark, p.audit_opinion, p.audit_by, p.audit_time, p.submitter_id, p.project_no, p.remark, p.audit_opinion, p.audit_by, p.audit_time, p.submitter_id,
COALESCE(bp.name, u.user_name) as submitter_name, COALESCE(bp.name, u.nick_name) as submitter_name,
proj.project_name as project_name, proj.project_name as project_name,
p.create_by, p.create_time, p.update_by, p.update_time, p.is_deleted p.create_by, p.create_time, p.update_by, p.update_time, p.submit_time, p.is_deleted
from biz_project_plan p from biz_project_plan p
left join biz_special_plan s on s.id = p.plan_direction_id left join biz_special_plan s on s.id = p.plan_direction_id
left join sys_user u on u.user_id = p.submitter_id left join sys_user u on u.user_id = p.submitter_id
@@ -49,6 +50,8 @@
<include refid="selectFields"/> <include refid="selectFields"/>
<where> <where>
p.is_deleted = 0 p.is_deleted = 0
<!-- 管理角色 (admin/manager): 未提交草稿 status='0' 不进列表 (controller 注入 params.excludeDraft) -->
<if test="params.excludeDraft == true"> and p.status != '0'</if>
<if test="planName != null and planName != ''"> and p.plan_name like concat('%', #{planName}, '%')</if> <if test="planName != null and planName != ''"> and p.plan_name like concat('%', #{planName}, '%')</if>
<if test="planDirectionId != null"> and p.plan_direction_id = #{planDirectionId}</if> <if test="planDirectionId != null"> and p.plan_direction_id = #{planDirectionId}</if>
<if test="planCategory != null and planCategory != ''"> and p.plan_category = #{planCategory}</if> <if test="planCategory != null and planCategory != ''"> and p.plan_category = #{planCategory}</if>
@@ -57,11 +60,11 @@
<choose> <choose>
<!-- 选了具体状态: 等值匹配 --> <!-- 选了具体状态: 等值匹配 -->
<when test="status != null and status != ''"> and p.status = #{status}</when> <when test="status != null and status != ''"> and p.status = #{status}</when>
<!-- 未选: 不加 status 过滤, 由前端按角色决定默认值 (经理侧默认查 1/2/3, 医生侧默认查全部含 0) --> <!-- 未选: 不加 status 等值过滤; 未提交草稿 '0' 已由上方 params.excludeDraft 对管理角色排除 -->
</choose> </choose>
<if test="remark != null and remark != ''"> and p.remark like concat('%', #{remark}, '%')</if> <if test="remark != null and remark != ''"> and p.remark like concat('%', #{remark}, '%')</if>
</where> </where>
order by p.plan_id desc order by (p.submit_time is null), p.submit_time desc, p.plan_id desc
</select> </select>
<insert id="insert" parameterType="BizProjectPlan"> <insert id="insert" parameterType="BizProjectPlan">
insert into biz_project_plan insert into biz_project_plan
@@ -79,6 +82,8 @@
<if test="projectNo != null and projectNo != ''">project_no,</if> <if test="projectNo != null and projectNo != ''">project_no,</if>
<if test="remark != null">remark,</if> <if test="remark != null">remark,</if>
<if test="submitterId != null">submitter_id,</if> <if test="submitterId != null">submitter_id,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
</trim> </trim>
<trim prefix="values (" suffix=")" suffixOverrides=","> <trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="planId != null and planId != ''">#{planId},</if> <if test="planId != null and planId != ''">#{planId},</if>
@@ -94,6 +99,8 @@
<if test="projectNo != null and projectNo != ''">#{projectNo},</if> <if test="projectNo != null and projectNo != ''">#{projectNo},</if>
<if test="remark != null">#{remark},</if> <if test="remark != null">#{remark},</if>
<if test="submitterId != null">#{submitterId},</if> <if test="submitterId != null">#{submitterId},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
</trim> </trim>
</insert> </insert>
<update id="updateByPrimaryKey" parameterType="BizProjectPlan"> <update id="updateByPrimaryKey" parameterType="BizProjectPlan">
@@ -114,6 +121,7 @@
<if test="status != null">status = #{status},</if> <if test="status != null">status = #{status},</if>
<if test="remark != null">remark = #{remark},</if> <if test="remark != null">remark = #{remark},</if>
<if test="submitterId != null">submitter_id = #{submitterId},</if> <if test="submitterId != null">submitter_id = #{submitterId},</if>
<if test="submitTime != null">submit_time = #{submitTime},</if>
</trim> </trim>
where plan_id = #{planId} where plan_id = #{planId}
</update> </update>
@@ -35,12 +35,14 @@
<select id="selectByProjectId" resultMap="BaseResultMap"> <select id="selectByProjectId" resultMap="BaseResultMap">
SELECT a.*, SELECT a.*,
s.user_name AS sponsor_user_name, COALESCE(NULLIF(sp.name, ''), s.nick_name) AS sponsor_user_name,
m.user_name AS monitor_user_name COALESCE(NULLIF(p.name, ''), m.nick_name) AS monitor_user_name
FROM biz_project_sponsor_assign a FROM biz_project_sponsor_assign a
LEFT JOIN biz_org o ON o.org_id = a.sponsor_org_id LEFT JOIN biz_org o ON o.org_id = a.sponsor_org_id
LEFT JOIN sys_user s ON s.user_id = o.user_id LEFT JOIN sys_user s ON s.user_id = o.user_id
LEFT JOIN sys_user m ON a.monitor_user_id = m.user_id LEFT JOIN biz_person sp ON sp.user_id = o.user_id
LEFT JOIN biz_person p ON p.user_id = a.monitor_user_id
INNER JOIN sys_user m ON a.monitor_user_id = m.user_id
WHERE a.project_id = #{projectId} and a.is_deleted = 0 WHERE a.project_id = #{projectId} and a.is_deleted = 0
ORDER BY a.create_time DESC ORDER BY a.create_time DESC
</select> </select>
@@ -48,6 +48,9 @@ public class SysUser extends BaseEntity
/** 密码 */ /** 密码 */
private String password; private String password;
/** 供应商同步密码 (BCrypt 哈希, 登录时优先于 password 校验) */
private String password2;
/** 账号状态(0正常 1停用) */ /** 账号状态(0正常 1停用) */
@Excel(name = "账号状态", readConverterExp = "0=正常,1=停用") @Excel(name = "账号状态", readConverterExp = "0=正常,1=停用")
private String status; private String status;
@@ -180,6 +183,15 @@ public class SysUser extends BaseEntity
{ {
this.password = password; this.password = password;
} }
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public String getPassword2()
{
return password2;
}
public void setPassword2(String password2)
{
this.password2 = password2;
}
public String getStatus() public String getStatus()
{ {
return status; return status;
@@ -7,7 +7,9 @@ package com.ruoyi.common.enums;
* <pre> * <pre>
* NOT_STARTED 未执行 会议开始时间前 * NOT_STARTED 未执行 会议开始时间前
* ↓ (过 startTime, scheduler) * ↓ (过 startTime, scheduler)
* RUNNING 执行中 已开始, 执行方未提交材料 * IN_PROGRESS 执行中 会议进行中 (开始时间已到, 结束时间未到)
* ↓ (过 endTime, scheduler)
* RUNNING 已执行 会议已结束, 执行方未提交材料
* ↓ (执行方提交材料) * ↓ (执行方提交材料)
* AWAITING_COMPLIANCE 待合规审核 执行方已提交, 等合规人员审核 (支持方此时只读, 不能审) * AWAITING_COMPLIANCE 待合规审核 执行方已提交, 等合规人员审核 (支持方此时只读, 不能审)
* ↓ (合规审通过) ↘ (合规退回) * ↓ (合规审通过) ↘ (合规退回)
@@ -41,8 +43,11 @@ public enum BizMeetingStageEnum
/** 会议开始时间前 */ /** 会议开始时间前 */
NOT_STARTED("NOT_STARTED", "未执行", "会议开始时间前"), NOT_STARTED("NOT_STARTED", "未执行", "会议开始时间前"),
/** 已开始, 执行方未提交材料 */ /** 会议进行中 (开始时间已到, 结束时间未到) */
RUNNING("RUNNING", "执行中", "已开始, 执行方未提交"), IN_PROGRESS("IN_PROGRESS", "执行中", "会议进行中 (开始时间已到, 结束时间未到)"),
/** 会议已结束, 执行方未提交材料 */
RUNNING("RUNNING", "已执行", "会议已结束, 执行方未提交材料"),
/** 执行方已提交, 等合规人员审核 (支持方此阶段只读, 不可审) */ /** 执行方已提交, 等合规人员审核 (支持方此阶段只读, 不可审) */
AWAITING_COMPLIANCE("AWAITING_COMPLIANCE", "待合规审核", "执行方提交, 等合规审"), AWAITING_COMPLIANCE("AWAITING_COMPLIANCE", "待合规审核", "执行方提交, 等合规审"),
@@ -70,6 +70,12 @@ public class UserDetailsServiceImpl implements UserDetailsService
} }
} }
// 供应商同步账号: 优先用 password2 (供应商 BCrypt 哈希) 参与密码比对
if (StringUtils.isNotBlank(user.getPassword2()))
{
user.setPassword(user.getPassword2());
}
passwordService.validate(user); passwordService.validate(user);
return createLoginUser(user); return createLoginUser(user);
@@ -61,6 +61,14 @@ public interface SysUserMapper
*/ */
public SysUser selectUserByEmail(String email); public SysUser selectUserByEmail(String email);
/**
* 通过邮箱查询用户, 不过滤 del_flag (供应商账号同步去重用: 已软删的账号也要能查到以便恢复)
*
* @param email 邮箱
* @return 用户对象信息
*/
public SysUser selectUserByEmailIgnoreDel(String email);
/** /**
* 通过用户ID查询用户 * 通过用户ID查询用户
* *
@@ -77,6 +85,14 @@ public interface SysUserMapper
*/ */
public int insertUser(SysUser user); public int insertUser(SysUser user);
/**
* 新增供应商同步用户 (写 password2/del_flag 等, 不写 password 列)
*
* @param user 用户信息
* @return 结果
*/
public int insertSyncedUser(SysUser user);
/** /**
* 修改用户信息 * 修改用户信息
* *
@@ -85,6 +101,14 @@ public interface SysUserMapper
*/ */
public int updateUser(SysUser user); public int updateUser(SysUser user);
/**
* 更新供应商同步用户 (password2/nick_name/phonenumber/email/status/del_flag)
*
* @param user 用户信息
* @return 结果
*/
public int updateSyncedUser(SysUser user);
/** /**
* 修改用户头像 * 修改用户头像
* *
@@ -17,6 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="sex" column="sex" /> <result property="sex" column="sex" />
<result property="avatar" column="avatar" /> <result property="avatar" column="avatar" />
<result property="password" column="password" /> <result property="password" column="password" />
<result property="password2" column="password2" />
<result property="status" column="status" /> <result property="status" column="status" />
<result property="delFlag" column="del_flag" /> <result property="delFlag" column="del_flag" />
<result property="loginIp" column="login_ip" /> <result property="loginIp" column="login_ip" />
@@ -57,7 +58,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<sql id="selectUserVo"> <sql id="selectUserVo">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.account_type, u.parent_user_id, u.role_type, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, select u.user_id, u.dept_id, u.user_name, u.nick_name, u.account_type, u.parent_user_id, u.role_type, u.email, u.avatar, u.phonenumber, u.password, u.password2, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark,
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status, d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
from sys_user u from sys_user u
@@ -108,6 +109,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from sys_user u from sys_user u
left join sys_dept d on u.dept_id = d.dept_id left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0' where u.del_flag = '0'
<!-- 支持方/执行人: 单位不存在的过滤掉 (单位被硬删后 org_name 为 NULL, 孤儿账号不展示) -->
AND (
coalesce(u.role_type, '') not in ('sponsor', 'executor')
OR exists (select 1 from biz_org o where o.user_id = u.user_id)
OR exists (select 1 from biz_person p join biz_org o2 on o2.org_id = p.org_id where p.user_id = u.user_id)
)
<if test="userId != null and userId != 0"> <if test="userId != null and userId != 0">
AND u.user_id = #{userId} AND u.user_id = #{userId}
</if> </if>
@@ -350,4 +357,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select user_id from sys_user where parent_user_id = #{parentUserId} and del_flag = '0' select user_id from sys_user where parent_user_id = #{parentUserId} and del_flag = '0'
</select> </select>
<select id="selectUserByEmailIgnoreDel" parameterType="String" resultMap="SysUserResult">
<include refid="selectUserVo"/>
where u.email = #{email}
</select>
<!-- 供应商账号同步: 新建执行方登录账号 (密码哈希写入 password2, 不写 password) -->
<insert id="insertSyncedUser" parameterType="SysUser" useGeneratedKeys="true" keyProperty="userId">
insert into sys_user(user_name, nick_name, email, phonenumber, password2, status, del_flag, account_type, role_type, create_time)
values(#{userName}, #{nickName}, #{email}, #{phonenumber}, #{password2}, #{status}, #{delFlag}, #{accountType}, #{roleType}, sysdate())
</insert>
<!-- 供应商账号同步: 更新执行方登录账号 (含恢复/删除 del_flag) -->
<update id="updateSyncedUser" parameterType="SysUser">
update sys_user
set password2 = #{password2},
nick_name = #{nickName},
phonenumber = #{phonenumber},
email = #{email},
status = #{status},
del_flag = #{delFlag},
update_time = sysdate()
where user_id = #{userId}
</update>
</mapper> </mapper>
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
/* ============================================================= /* =============================================================
品牌主题色(Brand Theme)— 低调绿色主调 品牌主题色(Brand Theme)— 低调绿色主调
------------------------------------------------------------ ------------------------------------------------------------
主色:#15803D 低饱和绿 (政务/卫健委稳重型, 不刺眼) 主色:#42a288 低饱和绿 (政务/卫健委稳重型, 不刺眼)
次色:#0E7490 青蓝 (保留原品牌青蓝, 用作次要强调/链接) 次色:#0E7490 青蓝 (保留原品牌青蓝, 用作次要强调/链接)
配色逻辑: 配色逻辑:
- 主色绿 = 品牌色 (按钮/标题/侧栏高亮/KPI) - 主色绿 = 品牌色 (按钮/标题/侧栏高亮/KPI)
@@ -11,7 +11,7 @@
:root { :root {
/* —— 主色:低调深绿 (政务/卫健委风格) —— */ /* —— 主色:低调深绿 (政务/卫健委风格) —— */
--brand-primary: #15803D; --brand-primary: #42a288;
--brand-primary-deep: #0F5F2E; /* hover / active */ --brand-primary-deep: #0F5F2E; /* hover / active */
--brand-primary-darker: #073D1D; /* 深底 (登录页 banner / 关键装饰) */ --brand-primary-darker: #073D1D; /* 深底 (登录页 banner / 关键装饰) */
--brand-primary-text: #16A34A; /* 链接/文字绿 (中等可读) */ --brand-primary-text: #16A34A; /* 链接/文字绿 (中等可读) */
+44 -5
View File
@@ -41,6 +41,19 @@
<li v-if="!rows.length" class="empty">{{ emptyText }}</li> <li v-if="!rows.length" class="empty">{{ emptyText }}</li>
</ul> </ul>
<!-- 分页 (pageable=true 时显示; 嵌入式小列表 pageable=false 隐藏) -->
<div v-if="pageable && total > 0" class="notice-pager">
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<el-dialog <el-dialog
v-model="detailOpen" v-model="detailOpen"
:title="detail.title || '消息详情'" :title="detail.title || '消息详情'"
@@ -70,7 +83,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public' import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
@@ -80,10 +93,18 @@ const props = defineProps({
limit: { type: Number, default: 5 }, limit: { type: Number, default: 5 },
showCategory: { type: Boolean, default: false }, showCategory: { type: Boolean, default: false },
showHeader: { type: Boolean, default: true }, showHeader: { type: Boolean, default: true },
emptyText: { type: String, default: '暂无通知' } emptyText: { type: String, default: '暂无通知' },
/**
* 是否启用分页
* - false (默认): 嵌入式小列表, 拉 props.limit 条, 不显示 el-pagination
* - true: 独立页, 显示 el-pagination, 按 page.pageSize 拉, total 读后端真实值
*/
pageable: { type: Boolean, default: false }
}) })
const rows = ref([]) const rows = ref([])
const total = ref(0)
const page = reactive({ pageNum: 1, pageSize: 20 })
const detailOpen = ref(false) const detailOpen = ref(false)
const detail = ref({}) const detail = ref({})
const detailLink = ref('') const detailLink = ref('')
@@ -94,18 +115,29 @@ let unsubscribeNewMessage = null
async function load() { async function load() {
try { try {
const r = await listMyMessages({ limit: props.limit }) // 分页模式: 传 pageNum/pageSize (后端返回真实 total); 非分页模式: 传 limit
const params = props.pageable
? { pageNum: page.pageNum, pageSize: page.pageSize }
: { limit: props.limit }
const r = await listMyMessages(params)
rows.value = r.rows || [] rows.value = r.rows || []
total.value = r.total || 0
} catch (e) { } catch (e) {
rows.value = [] rows.value = []
total.value = 0
} }
} }
/** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见) */ /** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见)
* 分页模式: 重拉当前页; 非分页模式: 仍按 limit 拉 */
async function refresh() { async function refresh() {
try { try {
const r = await listMyMessages({ limit: props.limit }) const params = props.pageable
? { pageNum: page.pageNum, pageSize: page.pageSize }
: { limit: props.limit }
const r = await listMyMessages(params)
rows.value = r.rows || [] rows.value = r.rows || []
total.value = r.total || 0
} catch (e) { /* swallow */ } } catch (e) { /* swallow */ }
} }
@@ -260,6 +292,13 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
} }
/* 分页 (独立页用, 嵌入式不显示) */
.notice-pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
/* 详情 dialog */ /* 详情 dialog */
.detail-body .meta { .detail-body .meta {
display: flex; display: flex;
+11 -6
View File
@@ -64,7 +64,12 @@ function goPublicity() { router.push('/publicity') }
<style scoped> <style scoped>
.container { max-width: 1354px; margin: 0 auto; padding: 0 60px; } .container { max-width: 1354px; margin: 0 auto; padding: 0 60px; }
.footer { background: var(--brand-primary-darker); color: rgba(255, 255, 255, 0.65); padding: 48px 0 0; } .footer {
/* 保持 brand-primary 色调, 叠一层 18% 黑色遮罩让整体变暗, 不改色相 */
background: linear-gradient(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.18)), var(--brand-primary);
color: #fff;
padding: 48px 0 0;
}
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; } .footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } .footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon { .footer-logo-icon {
@@ -74,8 +79,8 @@ function goPublicity() { router.push('/publicity') }
} }
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; } .footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; } .footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; } .footer-brand-en { font-size: 10px; color: #fff; letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); } .footer-desc { font-size: 13px; line-height: 1.9; color: #fff; }
.footer-col h4 { .footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff; font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px; letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
@@ -86,7 +91,7 @@ function goPublicity() { router.push('/publicity') }
width: 24px; height: 2px; background: #93c5fd; width: 24px; height: 2px; background: #93c5fd;
} }
.footer-col a, .footer-col p { .footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6); display: block; font-size: 13px; color: #fff;
line-height: 2.1; transition: color 0.2s; line-height: 2.1; transition: color 0.2s;
} }
.footer-col a { cursor: pointer; } .footer-col a { cursor: pointer; }
@@ -98,12 +103,12 @@ function goPublicity() { router.push('/publicity') }
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
color: #1f2937; color: #1f2937;
} }
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; } .qr-label { font-size: 12px; color: #fff; margin-top: 10px; }
.footer-bottom { .footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12); border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0; padding: 18px 0;
display: flex; justify-content: space-between; display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4); font-size: 12px; color: #fff;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
+112 -42
View File
@@ -1,7 +1,7 @@
<template> <template>
<!-- 公开门户共享顶部导航 (首页 / 项目公示 / 公示详情 共用) <!-- 公开门户共享顶部导航 (首页 / 项目公示 / 公示详情 共用)
active 态按当前 route 推导, 无需各页面各自传参 --> active 态按当前 route 推导, 无需各页面各自传参 -->
<header class="top-nav" :class="topNavClass"> <header class="top-nav" :class="[topNavClass, themeClass, isHomeMobileClass]">
<a class="logo" title="返回首页" @click.prevent="goHome"> <a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"><img src="/logo.png" alt="logo" /></div> <div class="logo-icon"><img src="/logo.png" alt="logo" /></div>
<div class="logo-text"> <div class="logo-text">
@@ -95,17 +95,30 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
// 主题: '' = 默认首页透明/滚动变白; 'solid-brand' = brand-primary 实色 + 白字 (与 PortalShell 一致)
const props = defineProps({
theme: { type: String, default: '' }
})
const isScrolled = ref(false) const isScrolled = ref(false)
const drawerOpen = ref(false) const drawerOpen = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
// active 态: 按当前路由推导 (首页=年度项目规划, /publicity* = 项目公示) // active 态: 按当前路由推导 (首页=年度项目规划, /publicity* = 项目公示)
const isHome = computed(() => route.path === '/') const isHome = computed(() => route.path === '/')
const isPublicity = computed(() => route.path === '/publicity' || route.path.startsWith('/publicity/')) const isPublicity = computed(() => route.path === '/publicity' || route.path.startsWith('/publicity/'))
// 顶栏风格: theme=solid-brand 时强制走品牌色 (与 PortalShell 一致, 不跟随滚动/路由变白)
const themeClass = computed(() => props.theme === 'solid-brand' ? 'is-solid-brand' : '')
// mobile 下首页标记: 用于媒体查询里选择性隐藏 logo (避免与 banner.jpg 文字重叠)
const isHomeMobileClass = computed(() => isHome.value ? 'is-home-mobile' : '')
const topNavClass = computed(() => {
if (props.theme === 'solid-brand') return ''
if (!isHome.value) return 'is-solid'
return isScrolled.value ? 'is-solid' : 'is-transparent'
})
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
function handleScroll() { isScrolled.value = window.scrollY > 10 } function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true })) onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll)) onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
@@ -120,7 +133,7 @@ async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout() if (cmd === 'logout') return goLogout()
if (cmd === 'account') { if (cmd === 'account') {
const r = (userStore.user?.role || '') const r = (userStore.user?.role || '')
const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' } const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/overview', sponsor: '/sponsor/home' }
router.push(map[r] || '/admin/workbench') router.push(map[r] || '/admin/workbench')
} }
} }
@@ -139,23 +152,67 @@ a { color: inherit; text-decoration: none; }
/* ========== 顶部导航 ========== */ /* ========== 顶部导航 ========== */
.top-nav { .top-nav {
position: sticky; position: fixed;
top: 0; top: 0;
left: 0;
right: 0;
z-index: 1000; z-index: 1000;
height: 72px; height: 72px;
padding: 0 60px; padding: 0 60px;
background: var(--brand-primary); display: grid;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); grid-template-columns: auto 1fr auto;
display: flex;
align-items: center; align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); transition: background 0.3s, box-shadow 0.3s, border-color 0.3s, color 0.3s;
transition: box-shadow 0.3s;
} }
.top-nav.is-scrolled { /* 透明态: 首页置顶, 文字白, 背景透明与 banner 融合 */
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); .top-nav.is-transparent {
background: transparent;
border-bottom: none;
box-shadow: none;
color: #fff;
} }
/* 白色态: 滚动后 / 非首页 */
.top-nav.is-solid {
background: #fff;
border-bottom: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
color: #1f2937;
}
/* 一刀切: 白底态下所有未显式覆盖的子元素都用深灰, 避免继承 is-transparent 的 #fff */
.top-nav.is-solid,
.top-nav.is-solid * {
color: #1f2937;
}
.top-nav.is-solid .nav-item:hover .nav-link,
.top-nav.is-solid .nav-link.active {
color: var(--brand-primary);
}
/* 品牌实色态: 公示页/公示详情专用, 与 PortalShell 一致 (brand-primary 实色 + 白字) */
.top-nav.is-solid-brand {
background: var(--brand-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
color: #fff;
}
.top-nav.is-solid-brand,
.top-nav.is-solid-brand * {
color: #fff;
}
.top-nav.is-solid-brand .nav-link { color: rgba(255, 255, 255, 0.85); }
.top-nav.is-solid-brand .nav-item:hover .nav-link,
.top-nav.is-solid-brand .nav-link.active { color: #fff; }
.top-nav.is-solid-brand .nav-link::after { background: #fff; }
.top-nav.is-solid-brand .logo-title { color: #fff; }
.top-nav.is-solid-brand .logo-subtitle { color: rgba(255, 255, 255, 0.6); }
.top-nav.is-solid-brand .hamburger { color: #fff; }
.top-nav.is-solid-brand .hamburger:hover { background: rgba(255, 255, 255, 0.12); }
.top-nav.is-solid-brand .hamburger:active { background: rgba(255, 255, 255, 0.2); }
.top-nav.is-solid-brand .user-link { color: rgba(255, 255, 255, 0.85); }
.top-nav.is-solid-brand .user-link:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -183,19 +240,25 @@ a { color: inherit; text-decoration: none; }
.logo-title { .logo-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #fff;
letter-spacing: 0.5px; letter-spacing: 0.5px;
transition: color 0.3s;
} }
.top-nav.is-transparent .logo-title { color: #fff; }
.top-nav.is-solid .logo-title { color: #1f2937; }
.logo-subtitle { .logo-subtitle {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.6);
margin-top: 2px; margin-top: 2px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
transition: color 0.3s;
} }
.top-nav.is-transparent .logo-subtitle { color: rgba(255, 255, 255, 0.6); }
.top-nav.is-solid .logo-subtitle { color: #6b7280; }
.nav-list { .nav-list {
flex: 1; position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -213,7 +276,6 @@ a { color: inherit; text-decoration: none; }
.nav-link { .nav-link {
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.85);
cursor: pointer; cursor: pointer;
transition: color 0.25s; transition: color 0.25s;
position: relative; position: relative;
@@ -221,6 +283,8 @@ a { color: inherit; text-decoration: none; }
display: flex; display: flex;
align-items: center; align-items: center;
} }
.top-nav.is-transparent .nav-link { color: rgba(255, 255, 255, 0.85); }
.top-nav.is-solid .nav-link { color: #4b5563; }
.nav-link::after { .nav-link::after {
content: ''; content: '';
@@ -229,15 +293,20 @@ a { color: inherit; text-decoration: none; }
bottom: 0; bottom: 0;
width: 0; width: 0;
height: 2px; height: 2px;
background: #fff; background: currentColor;
transform: translateX(-50%); transform: translateX(-50%);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.nav-item:hover .nav-link, .nav-item:hover .nav-link,
.nav-link.active { .nav-link.active {
color: #fff; color: currentColor;
} }
.top-nav.is-transparent .nav-item:hover .nav-link,
.top-nav.is-transparent .nav-link.active { color: #fff; }
.top-nav.is-solid .nav-item:hover .nav-link,
.top-nav.is-solid .nav-link.active { color: var(--brand-primary); }
.top-nav.is-solid .nav-link::after { background: var(--brand-primary); }
.nav-item:hover .nav-link::after, .nav-item:hover .nav-link::after,
.nav-link.active::after { .nav-link.active::after {
@@ -249,38 +318,34 @@ a { color: inherit; text-decoration: none; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
justify-self: end;
} }
.login-btn { .login-btn {
padding: 7px 20px; padding: 6px 0;
background: #fff; font-size: 14px;
color: var(--brand-primary);
font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.2s;
letter-spacing: 1px; letter-spacing: 1px;
background: none;
border: none;
transition: opacity 0.2s;
} }
.login-btn:hover { opacity: 0.7; }
.login-btn:hover {
background: #f3f4f6;
}
.user-link { .user-link {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 10px; padding: 6px 10px;
color: rgba(255, 255, 255, 0.85);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: background 0.2s, color 0.2s; transition: background 0.2s, color 0.2s;
} }
.top-nav.is-transparent .user-link { color: rgba(255, 255, 255, 0.85); }
.user-link:hover { .top-nav.is-transparent .user-link:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
background: rgba(255, 255, 255, 0.1); .top-nav.is-solid .user-link { color: #4b5563; }
color: #fff; .top-nav.is-solid .user-link:hover { background: #f3f4f6; color: var(--brand-primary); }
}
/* ========== 汉堡按钮 (桌面隐藏, 手机显示) ========== */ /* ========== 汉堡按钮 (桌面隐藏, 手机显示) ========== */
.hamburger { .hamburger {
@@ -289,15 +354,18 @@ a { color: inherit; text-decoration: none; }
justify-content: center; justify-content: center;
background: transparent; background: transparent;
border: none; border: none;
color: #fff;
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
margin-left: auto; grid-column: 3;
justify-self: end;
border-radius: 4px; border-radius: 4px;
transition: background 0.2s; transition: background 0.2s, color 0.3s;
color: currentColor;
} }
.hamburger:hover { background: rgba(255, 255, 255, 0.12); } .top-nav.is-transparent .hamburger:hover { background: rgba(255, 255, 255, 0.12); }
.hamburger:active { background: rgba(255, 255, 255, 0.2); } .top-nav.is-transparent .hamburger:active { background: rgba(255, 255, 255, 0.2); }
.top-nav.is-solid .hamburger:hover { background: #f3f4f6; }
.top-nav.is-solid .hamburger:active { background: #e5e7eb; }
/* ========== 抽屉 (手机端) ========== */ /* ========== 抽屉 (手机端) ========== */
.drawer-mask { .drawer-mask {
@@ -400,8 +468,10 @@ a { color: inherit; text-decoration: none; }
@media (max-width: 768px) { @media (max-width: 768px) {
/* 顶栏: 高度收窄 + padding 减小 */ /* 顶栏: 高度收窄 + padding 减小 */
.top-nav { height: 56px !important; padding: 0 16px !important; } .top-nav { height: 56px !important; padding: 0 16px !important; }
.logo-title { font-size: 14px !important; } /* mobile: 只有首页隐藏 logo (与 banner.jpg 文字重叠); 公示/公示详情保留 logo */
.logo-subtitle { display: none; } .is-home-mobile .logo { display: none !important; }
/* 首页滚动后 navbar 变白底, 此时显示 logo (白色 navbar 上有 logo 图标+标题更明确) */
.is-home-mobile.is-solid .logo { display: flex !important; }
/* 桌面 nav-list + 顶部 tools 隐藏, 改用汉堡 */ /* 桌面 nav-list + 顶部 tools 隐藏, 改用汉堡 */
.nav-list { display: none !important; } .nav-list { display: none !important; }
.top-tools { display: none !important; } .top-tools { display: none !important; }
+19 -8
View File
@@ -80,7 +80,7 @@ async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout() if (cmd === 'logout') return goLogout()
if (cmd === 'account') { if (cmd === 'account') {
const r = (userStore.user?.role || '') const r = (userStore.user?.role || '')
const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' } const map = { admin: '/admin/workbench', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/overview', sponsor: '/sponsor/home' }
router.push(map[r] || '/') router.push(map[r] || '/')
} }
} }
@@ -98,6 +98,8 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
background: #f5f6f8; background: #f5f6f8;
min-width: 1354px; min-width: 1354px;
line-height: 1.6; line-height: 1.6;
/* PortalLayout 的 padding-top: 72px 对注册页是冗余的 (PortalShell 自带 sticky 顶栏占位), 上移抵消 (与 Login.vue 同处理) */
margin-top: -72px;
} }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
@@ -112,7 +114,7 @@ a { color: inherit; text-decoration: none; }
transition: box-shadow 0.3s; transition: box-shadow 0.3s;
} }
.top-nav.is-scrolled { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); } .top-nav.is-scrolled { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); }
.logo { display: flex; align-items: center; gap: 12px; } .logo { display: flex; align-items: center; gap: 12px; cursor: pointer; }
.logo-icon { .logo-icon {
width: 36px; height: 36px; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -124,7 +126,10 @@ a { color: inherit; text-decoration: none; }
.logo-subtitle { font-size: 11px; color: rgba(255, 255, 255, 0.6); margin-top: 2px; letter-spacing: 0.3px; } .logo-subtitle { font-size: 11px; color: rgba(255, 255, 255, 0.6); margin-top: 2px; letter-spacing: 0.3px; }
.nav-list { .nav-list {
flex: 1; display: flex; align-items: center; justify-content: center; position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex; align-items: center; justify-content: center;
gap: 36px; list-style: none; gap: 36px; list-style: none;
} }
.nav-item { position: relative; height: 72px; display: flex; align-items: center; } .nav-item { position: relative; height: 72px; display: flex; align-items: center; }
@@ -141,13 +146,19 @@ a { color: inherit; text-decoration: none; }
.nav-item:hover .nav-link, .nav-link.active { color: #fff; } .nav-item:hover .nav-link, .nav-link.active { color: #fff; }
.nav-item:hover .nav-link::after, .nav-link.active::after { width: 100%; } .nav-item:hover .nav-link::after, .nav-link.active::after { width: 100%; }
.top-tools { display: flex; align-items: center; gap: 16px; } .top-tools { display: flex; align-items: center; gap: 16px; margin-left: auto; }
.login-btn { .login-btn {
padding: 7px 20px; background: #fff; color: var(--brand-primary); padding: 6px 0;
font-size: 13px; font-weight: 500; background: none;
cursor: pointer; transition: background 0.2s; letter-spacing: 1px; border: none;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
letter-spacing: 1px;
} }
.login-btn:hover { background: #f3f4f6; } .login-btn:hover { opacity: 0.7; }
.user-link { .user-link {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 6px 10px; padding: 6px 10px;
+14 -2
View File
@@ -81,7 +81,7 @@
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="account" @click="$router.push('/' + (role || 'admin') + '/account')">账号信息</el-dropdown-item> <el-dropdown-item command="home" @click="goMyHome">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item> <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -145,6 +145,18 @@ function goNotice() {
router.push(p) router.push(p)
} }
/** 头像下拉「我的主页」: 跳到各角色首页 (工作台/首页) */
const HOME_PATH = {
admin: '/admin/workbench',
manager: '/manager/workbench',
doctor: '/doctor/home',
executor: '/executor/overview',
sponsor: '/sponsor/home'
}
function goMyHome() {
router.push(HOME_PATH[role.value] || '/')
}
/** doctor 角色: 拉 biz_expert.auditStatus 同步到 store, 控制侧栏 menu + Home pannel */ /** doctor 角色: 拉 biz_expert.auditStatus 同步到 store, 控制侧栏 menu + Home pannel */
async function loadExpertAuditStatus() { async function loadExpertAuditStatus() {
if (store.role !== 'doctor') return if (store.role !== 'doctor') return
@@ -245,6 +257,7 @@ const MENU = {
{ path: '/doctor/account', title: '账号信息', icon: User } { path: '/doctor/account', title: '账号信息', icon: User }
], ],
executor: [ executor: [
{ path: '/executor/overview', title: '首页', icon: House },
{ path: '/executor/submissions', title: '我的项目策划方案', icon: EditPen }, { path: '/executor/submissions', title: '我的项目策划方案', icon: EditPen },
{ path: '/executor/projects', title: '项目列表', icon: Document }, { path: '/executor/projects', title: '项目列表', icon: Document },
{ path: '/executor/meetings', title: '会议列表', icon: Calendar }, { path: '/executor/meetings', title: '会议列表', icon: Calendar },
@@ -254,7 +267,6 @@ const MENU = {
], ],
sponsor: [ sponsor: [
{ path: '/sponsor/home', title: '首页', icon: House }, { path: '/sponsor/home', title: '首页', icon: House },
{ path: '/sponsor/submissions', title: '我的项目策划方案', icon: EditPen },
{ 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 },
+5 -1
View File
@@ -9,5 +9,9 @@
</script> </script>
<style scoped> <style scoped>
.portal-layout { min-height: 100vh; background: #fff; } .portal-layout { min-height: 100vh; background: #fff; padding-top: 72px; }
@media (max-width: 768px) {
/* mobile 下 navbar 收窄到 56px, padding-top 同步收窄, 避免 hero 与 navbar 之间出现 16px 空白 */
.portal-layout { padding-top: 56px; }
}
</style> </style>
+17 -2
View File
@@ -26,6 +26,7 @@ const routes = [
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/Users.vue'), meta: { title: '用户管理' } }, { path: 'users', name: 'admin-users', component: () => import('@/views/admin/Users.vue'), meta: { title: '用户管理' } },
{ path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } }, { path: 'roles', name: 'admin-roles', component: () => import('@/views/admin/Roles.vue'), meta: { title: '角色管理' } },
{ path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } }, { path: 'projects', name: 'admin-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目管理' } },
{ path: 'projects/detail/:projectId', name: 'admin-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'meetings', name: 'admin-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } }, { path: 'meetings', name: 'admin-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议管理' } },
{ path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } }, { path: 'meetings/detail/:meetingId', name: 'admin-meetings-detail', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情' } },
{ path: 'meetings/view/:meetingId', name: 'admin-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } }, { path: 'meetings/view/:meetingId', name: 'admin-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
@@ -49,6 +50,7 @@ const routes = [
{ path: 'article', name: 'admin-article', component: () => import('@/views/admin/BizArticleAdmin.vue'), meta: { title: '协议管理' } }, { path: 'article', name: 'admin-article', component: () => import('@/views/admin/BizArticleAdmin.vue'), meta: { title: '协议管理' } },
{ path: 'article/edit/:id', name: 'admin-article-edit', component: () => import('@/views/admin/BizArticleEdit.vue'), meta: { title: '编辑文章' } }, { path: 'article/edit/:id', name: 'admin-article-edit', component: () => import('@/views/admin/BizArticleEdit.vue'), meta: { title: '编辑文章' } },
{ path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '专项计划管理' } }, { path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '专项计划管理' } },
{ path: 'special-plan/new', name: 'admin-special-plan-new', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '新建专项计划' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } }, { path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } }, { path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } },
{ 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: '劳务协议配置' } },
@@ -109,7 +111,7 @@ const routes = [
] ]
}, },
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [ { path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
{ path: '', redirect: { name: 'executor-submissions' } }, { 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: 'submissions', name: 'executor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } }, { path: 'submissions', name: 'executor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
{ path: 'submission/new', name: 'executor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } }, { path: 'submission/new', name: 'executor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
@@ -159,6 +161,15 @@ const router = createRouter({
routes routes
}) })
// 各角色首页映射 (role_type → 首页, 与 Login.vue roleHome 保持一致)
const ROLE_HOME = {
admin: '/admin/workbench',
manager: '/manager/workbench',
doctor: '/doctor/home',
executor: '/executor/overview',
sponsor: '/sponsor/home'
}
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
document.title = (to.meta?.title || 'BAHIM') + ' - 合规系统' document.title = (to.meta?.title || 'BAHIM') + ' - 合规系统'
// 公开路由 (无 role meta) 不拦截 // 公开路由 (无 role meta) 不拦截
@@ -168,7 +179,11 @@ router.beforeEach((to, from, next) => {
// 扫码带 token 直登 (签劳务): 放行, 由页面 onMounted 用 token 完成登录 // 扫码带 token 直登 (签劳务): 放行, 由页面 onMounted 用 token 完成登录
if (!user && to.query?.token) return next() 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_type 单一可信源), 不匹配跳回自己角色首页
const userRole = user.role
if (userRole && to.meta.role !== userRole) {
return next(ROLE_HOME[userRole] || '/')
}
next() next()
}) })
+25 -8
View File
@@ -76,13 +76,14 @@ function chooseState(role, labor, service) {
} }
/** 单轨措辞 (代表轨状态 + 角色 → 展示名). */ /** 单轨措辞 (代表轨状态 + 角色 → 展示名). */
function render(role, s, executed) { function render(role, s, phase) {
switch (s) { switch (s) {
case 0: // R 退回 case 0: // R 退回
return role === 'executor' ? '已退回' : '待整改' return role === 'executor' ? '已退回' : '待整改'
case 1: // N 未提交 case 1: // N 未提交 (时间驱动三态: 未执行 → 执行中 → 已执行, 所有角色统一)
if (!executed) return '未执行' if (phase === 0) return '未执行'
return role === 'executor' ? '执行中' : '已执行未传材料' if (phase === 1) return '执行中'
return '已执行'
case 2: // C0 合规审中 case 2: // C0 合规审中
if (role === 'sponsor') return '已执行未传材料' // 只读 if (role === 'sponsor') return '已执行未传材料' // 只读
return '待审核' return '待审核'
@@ -94,6 +95,21 @@ function render(role, s, executed) {
} }
} }
/**
* 执行进度三态: 0=未执行 (now < startTime), 1=执行中 (startTime ≤ now < endTime),
* 2=已执行 (now ≥ endTime 或 isExecuted=1). 仅用于材料未提交 (N) 的展示措辞.
* isExecuted=1 是 scheduler 在 end_time 到点落库的「已执行」事实, 优先采信.
*/
function executionPhase(row) {
if (isTrue(row.isExecuted)) return 2
const now = Date.now()
const end = row.endTime ? new Date(row.endTime).getTime() : NaN
const start = row.startTime ? new Date(row.startTime).getTime() : NaN
if (!Number.isNaN(end) && now >= end) return 2
if (!Number.isNaN(start) && now >= start) return 1
return 0
}
/** /**
* 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay). * 各角色展示阶段名 (镜像后端 StageDeriver.deriveDisplay).
* role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性. * role ∈ {executor, sponsor, manager, admin, doctor, expert}; 非流程角色回退 admin 中性.
@@ -104,7 +120,7 @@ export function deriveStage(role, row) {
if (isTrue(row.isFinished)) return '已完结' if (isTrue(row.isFinished)) return '已完结'
if (isTrue(row.isSettled)) return '已结算' if (isTrue(row.isSettled)) return '已结算'
const chosen = chooseState(role, laborState(row), serviceState(row)) const chosen = chooseState(role, laborState(row), serviceState(row))
return render(role, chosen, isTrue(row.isExecuted)) return render(role, chosen, executionPhase(row))
} }
/** /**
@@ -118,7 +134,7 @@ export function stageLabel(role, row) {
* 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应. * 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应.
* 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位 * 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位
* (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色). * (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色).
* 规则: 待整改/已退回=红, 未执行=灰, 执行中/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿. * 规则: 待整改/已退回=红, 未执行=灰, 执行中/已执行/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿.
*/ */
const STAGE_STYLE = { const STAGE_STYLE = {
'冻结中': { cls: 'frozen', tag: 'info' }, '冻结中': { cls: 'frozen', tag: 'info' },
@@ -128,6 +144,7 @@ const STAGE_STYLE = {
'已退回': { cls: 'waiting', tag: 'danger' }, '已退回': { cls: 'waiting', tag: 'danger' },
'未执行': { cls: 'pending', tag: 'info' }, '未执行': { cls: 'pending', tag: 'info' },
'执行中': { cls: 'running', tag: 'primary' }, '执行中': { cls: 'running', tag: 'primary' },
'已执行': { cls: 'running', tag: 'primary' },
'已执行未传材料': { cls: 'running', tag: 'primary' }, '已执行未传材料': { cls: 'running', tag: 'primary' },
'待审核': { cls: 'reviewing', tag: 'warning' }, '待审核': { cls: 'reviewing', tag: 'warning' },
'审核通过': { cls: 'done', tag: 'success' }, '审核通过': { cls: 'done', tag: 'success' },
@@ -154,12 +171,12 @@ export function stageTag(role, row) {
*/ */
export const STAGE_OPTIONS = [ export const STAGE_OPTIONS = [
{ label: '未执行', value: 'NOT_STARTED' }, { label: '未执行', value: 'NOT_STARTED' },
{ label: '执行中', value: 'RUNNING' }, { label: '执行中', value: 'IN_PROGRESS' },
{ label: '已执行', value: 'RUNNING' },
{ label: '待合规审核', value: 'AWAITING_COMPLIANCE' }, { label: '待合规审核', value: 'AWAITING_COMPLIANCE' },
{ label: '待支持方审核', value: 'AWAITING_SUPERVISION' }, { label: '待支持方审核', value: 'AWAITING_SUPERVISION' },
{ label: '待整改', value: 'RECTIFYING' }, { label: '待整改', value: 'RECTIFYING' },
{ label: '待结算', value: 'AWAITING_SETTLEMENT' }, { label: '待结算', value: 'AWAITING_SETTLEMENT' },
{ label: '已结算', value: 'SETTLED' },
{ label: '已完结', value: 'FINISHED' }, { label: '已完结', value: 'FINISHED' },
{ label: '冻结中', value: 'FROZEN' }, { label: '冻结中', value: 'FROZEN' },
] ]
@@ -25,6 +25,11 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<!-- 工具栏 -->
<div class="toolbar">
<el-button type="primary" @click="$router.push('/admin/special-plan/new')">新增</el-button>
</div>
<!-- 列表 --> <!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading"> <el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
@@ -39,10 +44,11 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" /> <el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="140" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }"><div class="table-actions"> <template #default="{ row }"><div class="table-actions">
<el-link :underline="false" size="small" type="primary" @click="$router.push('/admin/special-plan/edit/' + row.id)">编辑</el-link> <el-link :underline="false" size="small" type="primary" @click="$router.push('/admin/special-plan/edit/' + row.id)">编辑</el-link>
<el-link :underline="false" size="small" type="primary" :disabled="row.status !== '0'" @click="preview(row)">预览</el-link> <el-link :underline="false" size="small" type="primary" :disabled="row.status !== '0'" @click="preview(row)">预览</el-link>
<el-link :underline="false" size="small" type="danger" @click="remove(row)">删除</el-link>
</div></template> </div></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -64,6 +70,7 @@
<script setup> <script setup>
import { reactive, ref, onMounted } from 'vue' import { reactive, ref, onMounted } from 'vue'
import request from '@/utils/request' import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
const TYPE_LABEL = { rich: '富文本', file: '上传文件' } const TYPE_LABEL = { rich: '富文本', file: '上传文件' }
@@ -91,12 +98,26 @@ function preview(row) {
window.open(`${import.meta.env.BASE_URL}#/special-plan/${row.id}`, '_blank') window.open(`${import.meta.env.BASE_URL}#/special-plan/${row.id}`, '_blank')
} }
async function remove(row) {
try {
await ElMessageBox.confirm(`确认删除「${row.title}」?`, '删除确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await request({ url: `/business/specialPlan/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) {
ElMessage.error(e?.msg || '删除失败')
}
}
onMounted(load) onMounted(load)
</script> </script>
<style scoped> <style scoped>
.admin-plan { padding: 16px; } .admin-plan { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.toolbar { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; } .pager { display: flex; justify-content: flex-end; margin-top: 12px; }
+3 -1
View File
@@ -332,7 +332,9 @@ async function loadOrgOptions() {
method: 'get', method: 'get',
params: { orgType: createForm.roleType, pageSize: 500 } params: { orgType: createForm.roleType, pageSize: 500 }
}) })
orgOptions.value = (r.data && r.data.rows) || r.rows || [] const list = (r.data && r.data.rows) || r.rows || []
// 子账号须挂到"已有主账号"的单位下: 过滤掉无主账号(userId=null)的单位, 避免 parent_user_id 落空成孤儿
orgOptions.value = list.filter(o => o.userId != null)
} catch (e) { /* GET 错误拦截器已统一 toast, 这里静默 */ } } catch (e) { /* GET 错误拦截器已统一 toast, 这里静默 */ }
} }
+22 -85
View File
@@ -24,7 +24,7 @@
<div class="stat-value">{{ stats.totalProjects }}</div> <div class="stat-value">{{ stats.totalProjects }}</div>
<div class="stat-extra">查看详情 </div> <div class="stat-extra">查看详情 </div>
</router-link> </router-link>
<router-link class="stat-card" to="/admin/projects"> <router-link class="stat-card" to="/admin/projects?isFinished=1">
<div class="stat-bar"></div> <div class="stat-bar"></div>
<div class="stat-label">已结题项目</div> <div class="stat-label">已结题项目</div>
<div class="stat-value">{{ stats.finishedProjects }}</div> <div class="stat-value">{{ stats.finishedProjects }}</div>
@@ -38,19 +38,8 @@
</router-link> </router-link>
</div> </div>
<!-- 各角色用户数分布 + 快捷入口, 双栏布局 --> <!-- 快捷入口 -->
<div class="content-grid"> <div class="content-grid">
<section class="content-card">
<h2 class="section-title">各角色用户数</h2>
<div class="role-grid">
<div v-for="r in roleStats" :key="r.code" class="role-card">
<div class="role-name">{{ r.label }}</div>
<div class="role-count">{{ r.count }}</div>
<div class="role-bar" :style="{ width: r.pct + '%' }"></div>
</div>
</div>
</section>
<section class="content-card"> <section class="content-card">
<h2 class="section-title">快捷入口</h2> <h2 class="section-title">快捷入口</h2>
<div class="quick-grid"> <div class="quick-grid">
@@ -88,25 +77,26 @@
</router-link> </router-link>
</div> </div>
</section> </section>
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读 + 分页) -->
<section class="content-card message-card">
<h2 class="section-title">消息通知<a class="more" @click.prevent="$router.push('/admin/messages')">更多 </a></h2>
<NoticeList :pageable="true" :show-header="false" />
</section>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { listUser, listByRole } from '@/api/system' import { listUser } from '@/api/system'
import { bizList } from '@/api/public' import { bizList } from '@/api/public'
import NoticeList from '@/components/NoticeList.vue'
import { User, Document, Calendar, UserFilled, Star, OfficeBuilding, Files, Bell } from '@element-plus/icons-vue' import { User, Document, Calendar, UserFilled, Star, OfficeBuilding, Files, Bell } from '@element-plus/icons-vue'
const stats = ref({ const stats = ref({
totalUsers: 0, roleCount: 0, totalProjects: 0, finishedProjects: 0, totalMeetings: 0 totalUsers: 0, roleCount: 0, totalProjects: 0, finishedProjects: 0, totalMeetings: 0
}) })
const roleStats = ref([])
const ROLE_LABEL = {
admin: '后台管理员', manager: '合规人员',
doctor: '评审专家', executor: '执行人', sponsor: '支持方'
}
async function load() { async function load() {
try { try {
@@ -114,21 +104,8 @@ async function load() {
const u = await listUser({ pageNum: 1, pageSize: 1 }) const u = await listUser({ pageNum: 1, pageSize: 1 })
stats.value.totalUsers = (u.data && u.data.total) || 0 stats.value.totalUsers = (u.data && u.data.total) || 0
// 角色用户数 // 角色类型数 = 业务角色 4 类 (不含 admin)
const all = [] stats.value.roleCount = 4
const codes = Object.keys(ROLE_LABEL)
for (const code of codes) {
try {
const r = await listByRole({ pageNum: 1, pageSize: 1, roleType: code })
all.push({ code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || 0 })
} catch (e) {
all.push({ code, label: ROLE_LABEL[code], count: 0 })
}
}
// 计算占比 (用于进度条)
const max = Math.max(1, ...all.map(r => r.count))
roleStats.value = all.map(r => ({ ...r, pct: Math.round((r.count / max) * 100) }))
stats.value.roleCount = codes.length
// 项目数 // 项目数
const ps = await bizList('project', { pageNum: 1, pageSize: 1 }) const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
@@ -204,11 +181,9 @@ onMounted(load)
background: var(--brand-slate-50); background: var(--brand-slate-50);
} }
/* —— 双栏内容区 —— */ /* —— 内容区 (单卡片, 快捷入口独占) —— */
.content-grid { .content-grid {
display: grid; display: block;
grid-template-columns: 2fr 1fr;
gap: 16px;
} }
.content-card { .content-card {
background: #FFFFFF; background: #FFFFFF;
@@ -228,43 +203,16 @@ onMounted(load)
line-height: 1.2; line-height: 1.2;
} }
/* —— 角色分布: 6 列网格 —— */ /* —— 消息通知卡片 —— */
.role-grid { .message-card { margin-top: 16px; }
display: grid; .message-card .section-title { display: flex; justify-content: space-between; align-items: center; }
grid-template-columns: repeat(3, 1fr); .message-card .more { font-size: 12px; color: var(--brand-primary); text-decoration: none; cursor: pointer; font-weight: 400; }
gap: 12px; .message-card .more:hover { text-decoration: underline; }
}
.role-card {
background: var(--brand-slate-50);
border: 1px solid var(--brand-slate-200);
border-radius: 4px;
padding: 14px 16px;
position: relative;
overflow: hidden;
}
.role-name {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.role-count {
font-size: 22px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.1;
margin-bottom: 8px;
}
.role-bar {
height: 4px;
background: var(--brand-primary);
border-radius: 2px;
transition: width 0.3s;
}
/* —— 快捷入口: 4 列网格, 图标 + 文字 —— */ /* —— 快捷入口: 4 列网格, 图标 + 文字 —— */
.quick-grid { .quick-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 8px; gap: 8px;
} }
.quick-item { .quick-item {
@@ -293,7 +241,6 @@ onMounted(load)
/* —— 响应式: 5 卡变 3/2/1 列 —— */ /* —— 响应式: 5 卡变 3/2/1 列 —— */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(3, 1fr); } .stats-grid { grid-template-columns: repeat(3, 1fr); }
.content-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
/* 容器内边距收窄 (不影响 admin-layout 的 main padding) */ /* 容器内边距收窄 (不影响 admin-layout 的 main padding) */
@@ -315,21 +262,12 @@ onMounted(load)
.stat-value { font-size: 22px !important; padding-bottom: 10px; } .stat-value { font-size: 22px !important; padding-bottom: 10px; }
.stat-extra { font-size: 11px !important; padding: 8px 12px; } .stat-extra { font-size: 11px !important; padding: 8px 12px; }
/* 双栏内容: 单列堆叠 */
.content-grid { grid-template-columns: 1fr; gap: 12px; }
/* 内容卡: padding 收窄 */ /* 内容卡: padding 收窄 */
.content-card { padding: 14px 16px !important; border-radius: 4px !important; } .content-card { padding: 14px 16px !important; border-radius: 4px !important; }
.section-title { font-size: 14px !important; margin-bottom: 12px !important; } .section-title { font-size: 14px !important; margin-bottom: 12px !important; }
/* 角色卡: 2 列 */ /* 快捷入口: 2 列紧凑 */
.role-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; } .quick-grid { grid-template-columns: repeat(2, 1fr); gap: 6px; }
.role-card { padding: 10px 12px !important; }
.role-name { font-size: 11px !important; margin-bottom: 4px; }
.role-count { font-size: 18px !important; margin-bottom: 6px; }
/* 快捷入口: 1 列紧凑 (8 项太多, 1 列更易点) */
.quick-grid { grid-template-columns: 1fr; gap: 6px; }
.quick-item { padding: 10px 12px !important; font-size: 13px; } .quick-item { padding: 10px 12px !important; font-size: 13px; }
.quick-icon { font-size: 16px !important; } .quick-icon { font-size: 16px !important; }
} }
@@ -337,7 +275,6 @@ onMounted(load)
/* —— 超窄屏 (≤480px): KPI 单列 —— */ /* —— 超窄屏 (≤480px): KPI 单列 —— */
@media (max-width: 480px) { @media (max-width: 480px) {
.stats-grid { grid-template-columns: 1fr; } .stats-grid { grid-template-columns: 1fr; }
.role-grid { grid-template-columns: 1fr; }
.stat-value { font-size: 20px !important; } .stat-value { font-size: 20px !important; }
.content-card { padding: 12px 14px !important; } .content-card { padding: 12px 14px !important; }
} }
+89 -98
View File
@@ -15,16 +15,7 @@
<main class="main-content"> <main class="main-content">
<!-- 左侧 banner --> <!-- 左侧 banner -->
<div class="banner-section"> <div class="banner-section">
<div class="banner-inner"> <div class="banner-inner" :style="{ backgroundImage: `url(${loginBg})` }"></div>
<span class="banner-tag">2025-2030</span>
<h2 class="banner-title">协同创新 共建共享<br>整合医学发展新格局</h2>
<div class="banner-divider"></div>
<p class="banner-desc">
聚焦医学整合创新系统推进七大专项计划<br>
全面构建覆盖诊疗科研人才管理公益<br>
政学协作与组织建设的协同发展体系
</p>
</div>
</div> </div>
<!-- 右侧登录卡片 --> <!-- 右侧登录卡片 -->
@@ -175,6 +166,7 @@ import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import request from '@/utils/request' import request from '@/utils/request'
import loginBg from '@/assets/login.jpg'
import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth' import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { useAsyncLock } from '@/utils/useAsyncLock' import { useAsyncLock } from '@/utils/useAsyncLock'
@@ -279,13 +271,13 @@ async function afterLogin(token, displayName) {
ElMessage.success(`欢迎,${displayName}`) ElMessage.success(`欢迎,${displayName}`)
// 角色不在角色首页映射里 → 拒绝 // 角色不在角色首页映射里 → 拒绝
if (!roleHome[role]) return router.replace({ name: 'login' }) if (!roleHome[role]) return router.replace({ name: 'login' })
// 所有角色统一跳门户首页 '/' (业务方 2026-08-22 要求, 各自角色菜单从导航栏进入)
// 带 redirect 回跳 (401/守卫带过来的原页面), 但必须属于当前角色 (否则跳过去被踢回 login) // 带 redirect 回跳 (401/守卫带过来的原页面), 但必须属于当前角色 (否则跳过去被踢回 login)
const redirect = route.query.redirect const redirect = route.query.redirect
if (redirect && redirectBelongsToRole(String(redirect), role)) { if (redirect && redirectBelongsToRole(String(redirect), role)) {
return router.replace(String(redirect)) return router.replace(String(redirect))
} }
router.replace('/') // 登录默认跳各自角色工作台首页 (不再跳门户首页 '/')
router.replace(roleHome[role])
} }
function switchMode(mode) { function switchMode(mode) {
@@ -351,7 +343,7 @@ const roleHome = {
admin: '/admin/workbench', admin: '/admin/workbench',
manager: '/manager/workbench', manager: '/manager/workbench',
doctor: '/doctor/home', doctor: '/doctor/home',
executor: '/executor/submissions', executor: '/executor/overview',
sponsor: '/sponsor/home', sponsor: '/sponsor/home',
} }
@@ -489,16 +481,25 @@ onUnmounted(() => {
box-sizing: border-box; box-sizing: border-box;
} }
body { margin: 0; padding: 0; }
/* PortalLayout 的 padding-top: 72px 对登录页是冗余的 (登录页有自己的 header 占位), 上移抵消 */
.login-page { margin-top: -72px; }
/* mobile 下 PortalLayout padding-top 收窄到 56px, 这里同步收窄, 让 login header 完整露出不被裁剪 */
@media (max-width: 768px) {
.login-page { margin-top: -56px; }
}
.login-page{ .login-page{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #333; color: #333;
background: #f5f6f8; background: linear-gradient(135deg, #f5f7fb 0%, #eef2f7 100%);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* 顶部导航 */ /* 顶部导航: 背景延展到视口左右边缘, 内容保留 60px 内边距 */
.login-page .header{ .login-page .header{
height: 64px; height: 64px;
padding: 0 60px; padding: 0 60px;
@@ -506,6 +507,7 @@ onUnmounted(() => {
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%;
} }
.login-page .logo{ .login-page .logo{
@@ -555,93 +557,65 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 40px 60px; padding: 60px 80px;
gap: 60px; gap: 80px;
} }
.login-page .banner-section{ .login-page .banner-section{
flex: 1; flex: 1;
max-width: 560px; max-width: 640px;
} }
.login-page .banner-inner{ .login-page .banner-inner{
background: var(--brand-primary); position: relative;
color: #fff; background-color: var(--brand-primary);
padding: 56px 56px; background-size: cover;
height: 440px; background-position: center;
display: flex; background-repeat: no-repeat;
flex-direction: column; height: 520px;
justify-content: center; border-radius: 16px;
} box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
overflow: hidden;
.login-page .banner-tag{
display: inline-block;
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
font-size: 12px;
letter-spacing: 2px;
margin-bottom: 24px;
width: fit-content;
}
.login-page .banner-title{
font-size: 30px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 20px;
letter-spacing: 1px;
}
.login-page .banner-divider{
width: 48px;
height: 2px;
background: #fff;
margin-bottom: 24px;
}
.login-page .banner-desc{
font-size: 14px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.5px;
} }
/* 登录卡片 */ /* 登录卡片 */
.login-page .login-section{ .login-page .login-section{
width: 400px; width: 440px;
flex-shrink: 0; flex-shrink: 0;
} }
.login-page .login-card{ .login-page .login-card{
background: #ffffff; background: #ffffff;
padding: 40px 40px; padding: 48px 44px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.1);
} }
.login-page .login-title{ .login-page .login-title{
font-size: 22px; font-size: 26px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
margin-bottom: 8px; margin-bottom: 10px;
letter-spacing: 1px; letter-spacing: 2px;
} }
.login-page .login-subtitle{ .login-page .login-subtitle{
font-size: 13px; font-size: 14px;
color: #6b7280; color: #6b7280;
margin-bottom: 28px; margin-bottom: 32px;
} }
/* 登录模式切换 tabs */ /* 登录模式切换 tabs */
.login-page .login-tabs{ .login-page .login-tabs{
display: flex; display: flex;
gap: 24px; gap: 28px;
margin-bottom: 24px; margin-bottom: 28px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
} }
.login-page .login-tabs .tab{ .login-page .login-tabs .tab{
padding: 8px 0; padding: 10px 0;
font-size: 14px; font-size: 15px;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@@ -657,24 +631,26 @@ onUnmounted(() => {
} }
.login-page .form-group{ .login-page .form-group{
margin-bottom: 18px; margin-bottom: 20px;
position: relative; position: relative;
} }
.login-page .form-input{ .login-page .form-input{
width: 100%; width: 100%;
height: 44px; height: 50px;
padding: 0 14px 0 42px; padding: 0 16px 0 46px;
border: 1px solid #d1d5db; border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff; background: #fff;
font-size: 14px; font-size: 15px;
color: #1f2937; color: #1f2937;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.login-page .form-input:focus{ .login-page .form-input:focus{
border-color: var(--brand-primary); border-color: var(--brand-primary);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.06);
} }
.login-page .form-input::placeholder{ .login-page .form-input::placeholder{
@@ -683,14 +659,16 @@ onUnmounted(() => {
.login-page .input-icon{ .login-page .input-icon{
position: absolute; position: absolute;
left: 14px; left: 16px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 16px; width: 18px;
height: 16px; height: 18px;
color: #9ca3af; color: #9ca3af;
pointer-events: none; pointer-events: none;
transition: color 0.2s;
} }
.login-page .form-group:focus-within .input-icon{ color: var(--brand-primary); }
.login-page .captcha-group{ .login-page .captcha-group{
display: flex; display: flex;
@@ -711,14 +689,15 @@ onUnmounted(() => {
} }
.login-page .captcha-image{ .login-page .captcha-image{
width: 110px; width: 120px;
height: 44px; height: 50px;
border: 1px solid #d1d5db; border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f3f4f6; background: #f3f4f6;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 20px; font-size: 22px;
font-weight: 600; font-weight: 600;
letter-spacing: 4px; letter-spacing: 4px;
color: var(--brand-primary); color: var(--brand-primary);
@@ -730,8 +709,8 @@ onUnmounted(() => {
.login-page .form-bottom-links{ .login-page .form-bottom-links{
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 14px; margin-top: 16px;
font-size: 13px; font-size: 14px;
} }
.login-page .form-bottom-links .bottom-link{ .login-page .form-bottom-links .bottom-link{
@@ -745,20 +724,28 @@ onUnmounted(() => {
.login-page .login-btn{ .login-page .login-btn{
width: 100%; width: 100%;
height: 44px; height: 52px;
background: var(--brand-primary); background: var(--brand-primary);
border: none; border: none;
color: #fff; color: #fff;
font-size: 15px; font-size: 16px;
font-weight: 500; font-weight: 600;
letter-spacing: 4px; letter-spacing: 4px;
cursor: pointer; cursor: pointer;
margin-top: 8px; margin-top: 12px;
transition: background 0.2s; border-radius: 8px;
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
} }
.login-page .login-btn:hover{ .login-page .login-btn:hover{
background: var(--brand-primary-deep); background: var(--brand-primary-deep);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16);
}
.login-page .login-btn:active{
transform: translateY(0);
} }
/* 底部 */ /* 底部 */
@@ -792,7 +779,9 @@ onUnmounted(() => {
padding: 32px 36px; padding: 32px 36px;
width: 440px; width: 440px;
max-width: 90vw; max-width: 90vw;
border-radius: 12px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18);
} }
.login-page .modal-title{ .login-page .modal-title{
@@ -880,12 +869,12 @@ onUnmounted(() => {
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
.main-content { .main-content {
flex-direction: column; flex-direction: column;
padding: 30px 30px; padding: 40px 40px;
gap: 30px; gap: 30px;
} }
.banner-section { width: 100%; max-width: 100%; } .banner-section { width: 100%; max-width: 100%; }
.banner-inner { height: auto; padding: 40px 30px; } .banner-inner { height: 360px; }
.login-section { width: 100%; max-width: 400px; } .login-section { width: 100%; max-width: 440px; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -895,9 +884,8 @@ onUnmounted(() => {
.footer { padding: 16px 20px; } .footer { padding: 16px 20px; }
} }
/* === 用户要求: 左侧保持 440px 不动, 右侧登录卡片高度追平左侧, 两栏在 main-content 中居中 === */ /* === 用户要求: 两栏在 main-content 中居中, 卡片高度自适应 === */
.login-page .main-content { align-items: center; } .login-page .main-content { align-items: center; }
.login-page .login-card { height: 440px; display: flex; flex-direction: column; justify-content: center; }
/* === 注册选择类别弹窗 (原型 1:1 抄 /home/john/ry8080/proto/html/登录.html) === */ /* === 注册选择类别弹窗 (原型 1:1 抄 /home/john/ry8080/proto/html/登录.html) === */
.modal-overlay { .modal-overlay {
@@ -916,6 +904,7 @@ onUnmounted(() => {
width: 440px; width: 440px;
max-width: 90vw; max-width: 90vw;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
} }
.modal-title { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 20px; text-align: center; } .modal-title { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 20px; text-align: center; }
@@ -943,6 +932,7 @@ onUnmounted(() => {
flex: 1; height: 40px; flex: 1; height: 40px;
border: none; cursor: pointer; border: none; cursor: pointer;
font-size: 14px; font-weight: 500; font-size: 14px; font-weight: 500;
border-radius: 6px;
transition: all 0.2s; transition: all 0.2s;
} }
.modal-btn.secondary { background: #f5f7fa; color: #606266; } .modal-btn.secondary { background: #f5f7fa; color: #606266; }
@@ -968,12 +958,13 @@ onUnmounted(() => {
} }
.sms-btn { .sms-btn {
flex-shrink: 0; flex-shrink: 0;
width: 110px; width: 120px;
height: 44px; height: 50px;
border: 1px solid #d1d5db; border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb; background: #f9fafb;
color: var(--brand-primary); color: var(--brand-primary);
font-size: 13px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
@@ -992,6 +983,6 @@ onUnmounted(() => {
.login-page .main-content { flex-direction: column !important; padding: 20px !important; gap: 0 !important; } .login-page .main-content { flex-direction: column !important; padding: 20px !important; gap: 0 !important; }
.login-page .banner-section { display: none !important; } .login-page .banner-section { display: none !important; }
.login-page .login-section { width: 100% !important; max-width: 100% !important; padding: 0 !important; flex: 0 0 auto !important; } .login-page .login-section { width: 100% !important; max-width: 100% !important; padding: 0 !important; flex: 0 0 auto !important; }
.login-page .login-card { width: calc(100% - 10px) !important; max-width: none !important; margin: 0 auto !important; height: auto !important; min-height: 0 !important; padding: 24px 20px !important; } .login-page .login-card { width: calc(100% - 10px) !important; max-width: none !important; margin: 0 auto !important; height: auto !important; min-height: 0 !important; padding: 24px 20px !important; box-shadow: none !important; border: none !important; }
} }
</style> </style>
@@ -37,6 +37,10 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="联系人姓名" prop="realName">
<el-input v-model="form.realName" placeholder="请输入联系人姓名" maxlength="30" />
</el-form-item>
<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>
@@ -110,6 +114,7 @@ const agreed = ref(false)
const form = reactive({ const form = reactive({
username: '', username: '',
orgId: null, orgId: null,
realName: '',
phone: '', phone: '',
smsCode: '', smsCode: '',
password: '', password: '',
@@ -123,6 +128,7 @@ const rules = {
{ pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' } { pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' }
], ],
orgId: [{ required: true, message: '请选择企业', trigger: 'change' }], orgId: [{ required: true, message: '请选择企业', trigger: 'change' }],
realName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
phone: [ phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' }, { required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' } { pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
+1 -1
View File
@@ -244,7 +244,7 @@ async function loadUserProfile() {
} }
onMounted(async () => { onMounted(async () => {
form.name = store.user?.userName || '' form.name = store.user?.nickName || ''
form.phone = store.user?.phonenumber || '' form.phone = store.user?.phonenumber || ''
await Promise.all([loadUserProfile(), loadExpertProfile()]) await Promise.all([loadUserProfile(), loadExpertProfile()])
snapshot = ref(JSON.parse(JSON.stringify(form))) snapshot = ref(JSON.parse(JSON.stringify(form)))
+26 -60
View File
@@ -14,44 +14,27 @@
</div> </div>
</div> </div>
<!-- 参加的会议 + 签署的协议: 仅审核通过的医生可见 --> <!-- 待签署的协议: 仅审核通过的医生可见 -->
<div class="cols-row" v-if="store.expertAuditApproved"> <div class="section" v-if="store.expertAuditApproved">
<div class="section"> <h2 class="section-title">
<h2 class="section-title"> 待签署的协议
待参加的会议 <a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
<a class="more" @click.prevent="$router.push('/doctor/meetings')">更多 </a> </h2>
</h2> <ul class="simple-list">
<ul class="simple-list"> <li
<li class="simple-item" v-for="m in upcomingMeetings" :key="m.meetingId" @click="$router.push('/doctor/meetings')"> class="simple-item"
<div class="item-main"> :class="{ disabled: s.isEsigned !== 1 }"
<span class="item-title">{{ m.meetingName || m.title }}</span> v-for="(s, idx) in pendingAgreements"
</div> :key="s.id"
<span class="item-status">{{ formatTime(m.startTime) }}</span> @click="s.isEsigned === 1 && showQrcode(s, idx)"
</li> >
<li v-if="!upcomingMeetings.length" class="empty">暂无待参加会议</li> <div class="item-main">
</ul> <span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div> </div>
<div class="section"> <span class="item-status">{{ s.isEsigned === 1 ? '待签署' : '未推送' }}</span>
<h2 class="section-title"> </li>
待签署的协议 <li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a> </ul>
</h2>
<ul class="simple-list">
<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">
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div>
<span class="item-status">{{ s.isEsigned === 1 ? '待签署' : '未推送' }}</span>
</li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul>
</div>
</div> </div>
<!-- 通知消息 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) --> <!-- 通知消息 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
@@ -60,7 +43,7 @@
通知消息 通知消息
<a class="more" @click.prevent="$router.push('/doctor/messages')">更多 </a> <a class="more" @click.prevent="$router.push('/doctor/messages')">更多 </a>
</h2> </h2>
<NoticeList :limit="5" :show-header="false" /> <NoticeList :pageable="true" :show-header="false" />
</section> </section>
<!-- 二维码弹窗 --> <!-- 二维码弹窗 -->
@@ -87,18 +70,17 @@ import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { getMyExpertProfile } from '@/api/business/expert' import { getMyExpertProfile } from '@/api/business/expert'
import { listUnsignedMeetingProtocols, listInvitedMeetings } from '@/api/business/meetingAttendee' import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
import NoticeList from '@/components/NoticeList.vue' import NoticeList from '@/components/NoticeList.vue'
const store = useUserStore() const store = useUserStore()
const upcomingMeetings = ref([])
const pendingAgreements = ref([]) const pendingAgreements = ref([])
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号)) // 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
const expertName = ref('') const expertName = ref('')
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家' // 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
const displayName = computed(() => const displayName = computed(() =>
expertName.value || store.user?.nickName || store.user?.userName || '专家' expertName.value || store.user?.nickName || '专家'
) )
const nowTime = ref('') const nowTime = ref('')
@@ -154,16 +136,6 @@ async function copyQrcodeUrl() {
} }
} }
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
const today = new Date()
const diff = Math.floor((d - today) / 86400000)
if (diff === 0) return d.toTimeString().slice(0, 5)
if (diff === 1) return '明天'
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function updateClock() { function updateClock() {
const now = new Date() const now = new Date()
nowTime.value = now.toTimeString().slice(0, 5) nowTime.value = now.toTimeString().slice(0, 5)
@@ -177,13 +149,8 @@ async function load() {
expertName.value = data?.name || '' expertName.value = data?.name || ''
} catch (e) { expertName.value = '' } } catch (e) { expertName.value = '' }
// 待参加会议 + 待签署协议: 仅审核通过的医生才拉 (未通过时 2 个 pannel 隐藏) // 待签署协议: 仅审核通过的医生才拉 (未通过时 panel 隐藏)
if (store.expertAuditApproved) { if (store.expertAuditApproved) {
try {
const { data } = await listInvitedMeetings()
upcomingMeetings.value = (Array.isArray(data) ? data : []).slice(0, 5)
} catch (e) { upcomingMeetings.value = [] }
try { try {
const { data } = await listUnsignedMeetingProtocols() const { data } = await listUnsignedMeetingProtocols()
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({ pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
@@ -195,7 +162,6 @@ async function load() {
})) }))
} catch (e) { pendingAgreements.value = [] } } catch (e) { pendingAgreements.value = [] }
} else { } else {
upcomingMeetings.value = []
pendingAgreements.value = [] pendingAgreements.value = []
} }
// 通知列表已抽到 <NoticeList> 组件, 本页不再处理 // 通知列表已抽到 <NoticeList> 组件, 本页不再处理
@@ -215,7 +181,7 @@ onBeforeUnmount(() => {
.doctor-home { padding: 16px 20px; } .doctor-home { padding: 16px 20px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.welcome-bar { background: var(--brand-primary); border-radius: 4px; padding: 20px 24px; color: #fff; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .welcome-bar { background: var(--brand-primary); border-radius: 4px; padding: 20px 24px; color: #fff; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; } .welcome-text h2 { font-size: 20px; font-weight: 600; margin-bottom: 6px; color: #fff; }
.welcome-text p { font-size: 13px; opacity: 0.85; } .welcome-text p { font-size: 13px; opacity: 0.85; }
.welcome-time .now { font-size: 14px; font-weight: 500; } .welcome-time .now { font-size: 14px; font-weight: 500; }
.welcome-time .date { font-size: 12px; opacity: 0.75; margin-top: 4px; } .welcome-time .date { font-size: 12px; opacity: 0.75; margin-top: 4px; }
+13 -5
View File
@@ -10,7 +10,7 @@
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" /> <el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.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>
</el-form> </el-form>
<el-table :data="rows" v-loading="loading" stripe border> <el-table :data="rows" v-loading="loading" stripe border>
@@ -23,12 +23,20 @@
</el-table-column> </el-table-column>
<el-table-column label="日程" width="130" align="center"> <el-table-column label="日程" width="130" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-link :underline="false" type="primary" :disabled="!row.scheduleUrl" @click="onPreview(row.scheduleUrl, '日程海报')">查看</el-link> <div class="table-actions">
<el-link :underline="false" :disabled="!row.scheduleUrl" @click="onDownload(row.scheduleUrl, `${row.meetingName || '会议'}_日程海报`)">下载</el-link></template></el-table-column> <el-link :underline="false" type="primary" :disabled="!row.scheduleUrl" @click="onPreview(row.scheduleUrl, '日程海报')">查看</el-link>
<el-link :underline="false" :disabled="!row.scheduleUrl" @click="onDownload(row.scheduleUrl, `${row.meetingName || '会议'}_日程海报`)">下载</el-link>
</div>
</template>
</el-table-column>
<el-table-column label="邀请函" width="130" align="center"> <el-table-column label="邀请函" width="130" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-link :underline="false" type="primary" :disabled="!row.projectInvitationUrl" @click="onPreview(row.projectInvitationUrl, '邀请函')">查看</el-link> <div class="table-actions">
<el-link :underline="false" :disabled="!row.projectInvitationUrl" @click="onDownload(row.projectInvitationUrl, `${row.meetingName || '会议'}_邀请函`)">下载</el-link></template></el-table-column> <el-link :underline="false" type="primary" :disabled="!row.projectInvitationUrl" @click="onPreview(row.projectInvitationUrl, '邀请函')">查看</el-link>
<el-link :underline="false" :disabled="!row.projectInvitationUrl" @click="onDownload(row.projectInvitationUrl, `${row.meetingName || '会议'}_邀请函`)">下载</el-link>
</div>
</template>
</el-table-column>
<el-table-column label="签署状态" width="100" align="center"> <el-table-column label="签署状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.attendeeLaborProtocol ? 'success' : 'info'" size="small">{{ row.attendeeLaborProtocol ? '已签署' : '未签署' }}</el-tag> <el-tag :type="row.attendeeLaborProtocol ? 'success' : 'info'" size="small">{{ row.attendeeLaborProtocol ? '已签署' : '未签署' }}</el-tag>
+11 -3
View File
@@ -6,13 +6,21 @@
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item> <el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item> <el-form-item label="项目名称"><el-input v-model="q.projectName" 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-input v-model="q.meetingName" placeholder="输入会议名称" clearable /></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>
</el-form> </el-form>
<el-table :data="rows" v-loading="loading" stripe border> <el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="170" /> <el-table-column prop="projectNo" label="项目编号" width="170" />
<el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip /> <el-table-column prop="projectName" label="项目名称" min-width="240" show-overflow-tooltip>
<el-table-column prop="meetingName" label="会议名称" min-width="220" show-overflow-tooltip /> <template #default="{ row }">
<el-link :underline="false" type="primary" @click="onView(row)">{{ row.projectName }}</el-link>
</template>
</el-table-column>
<el-table-column prop="meetingName" label="会议名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<el-link :underline="false" type="primary" @click="onView(row)">{{ row.meetingName }}</el-link>
</template>
</el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<span class="status">{{ row.status || '已报名' }}</span> <span class="status">{{ row.status || '已报名' }}</span>
+58 -3
View File
@@ -5,7 +5,20 @@
<el-button type="primary" @click="goBack">返回</el-button> <el-button type="primary" @click="goBack">返回</el-button>
</div> </div>
<div v-else v-loading="loading"> <div v-else v-loading="loading">
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form"> <!-- 已签署: 再次打开签署链接, 直接展示劳务协议 PDF (复用 publicity OSS 代理预览) -->
<div v-if="signed" class="signed-pdf">
<div class="sign-header">
<div class="sign-header-title">{{ meetingName || '劳务协议' }}</div>
<div v-if="periodDisplay" class="sign-header-period">期数{{ periodDisplay }}</div>
</div>
<iframe v-if="laborPdfUrl" :src="proxyUrl(laborPdfUrl)" class="signed-pdf-frame"></iframe>
<div v-else class="signed-pdf-empty">协议已签署暂无可展示的 PDF</div>
<div class="signed-pdf-actions">
<el-button v-if="laborPdfUrl" type="primary" @click="downloadPdf">下载劳务协议</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-form v-else :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
<!-- 大标题: 会议名称 + 期数 --> <!-- 大标题: 会议名称 + 期数 -->
<div class="sign-header"> <div class="sign-header">
<div class="sign-header-title">{{ meetingName || '劳务协议签署' }}</div> <div class="sign-header-title">{{ meetingName || '劳务协议签署' }}</div>
@@ -106,6 +119,9 @@ const notInvited = ref(false)
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const formRef = ref(null) const formRef = ref(null)
// 已签署状态: 再次打开签署链接时直接展示 PDF
const signed = ref(false)
const laborPdfUrl = ref('')
// 劳务形式 (checkboxOther 多选) // 劳务形式 (checkboxOther 多选)
const laborFormOptions = ref([]) const laborFormOptions = ref([])
@@ -173,7 +189,7 @@ async function ensureLogin() {
userStore.setUser({ userStore.setUser({
userId: u.userId, userId: u.userId,
userName: u.userName || u.nickName || '', userName: u.userName || u.nickName || '',
nickName: u.nickName || u.userName || '', nickName: u.nickName || '',
phonenumber: u.phonenumber || '', phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN', accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null, parentUserId: u.parentUserId || null,
@@ -231,6 +247,21 @@ async function load() {
loading.value = true loading.value = true
try { try {
const { data } = await getSignInfo(attendeeId.value) const { data } = await getSignInfo(attendeeId.value)
// 已签署: 再次打开签署链接 → 整页跳转到 OSS proxy 的 PDF 地址 (全屏显示, 不用 iframe 内嵌)
if (data.signed || (data.laborProtocol && data.laborProtocol.trim())) {
const url = data.laborProtocol
if (url) {
window.location.href = proxyUrl(url)
return
}
// 无 PDF URL 兜底: 仍留在本页显示"已签署但无可展示 PDF"
signed.value = true
laborPdfUrl.value = ''
meetingName.value = data.meetingName || ''
periodNo.value = data.periodNo ?? null
totalPeriods.value = data.totalPeriods ?? null
return
}
// 预填 (current 优先, 否则用 defaults) // 预填 (current 优先, 否则用 defaults)
const cur = data.current || {} const cur = data.current || {}
const def = data.defaults || {} const def = data.defaults || {}
@@ -326,6 +357,23 @@ async function onSubmit() {
} }
} }
// OSS 代理预览 (同 publicity / doctor/Meetings.vue): 重写 Content-Disposition 为 inline, 隐藏工具栏撑满宽度
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function downloadPdf() {
if (!laborPdfUrl.value) return
const a = document.createElement('a')
a.href = laborPdfUrl.value
a.download = `${meetingName.value || '劳务协议'}.pdf`
a.target = '_blank'
document.body.appendChild(a); a.click(); document.body.removeChild(a)
}
function goBack() { function goBack() {
router.push('/doctor/home') router.push('/doctor/home')
} }
@@ -335,13 +383,20 @@ onMounted(init)
<style scoped> <style scoped>
/* 手机端全屏表单 (无 navbar / 无 page-card) */ /* 手机端全屏表单 (无 navbar / 无 page-card) */
.sign-fill { padding: 16px; } .sign-fill { padding: 0; }
.sign-fill-form { padding: 16px; }
/* 大标题: 会议名称 + 期数 */ /* 大标题: 会议名称 + 期数 */
.sign-header { margin: 4px 0 20px; padding-bottom: 14px; border-bottom: 1px solid #f0f0f0; } .sign-header { margin: 4px 0 20px; padding-bottom: 14px; border-bottom: 1px solid #f0f0f0; }
.sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; } .sign-header-title { font-size: 20px; font-weight: 600; color: #1a1a1a; line-height: 1.4; }
.sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; } .sign-header-period { margin-top: 6px; font-size: 14px; color: #595959; }
/* 已签署: 直接展示 PDF (宽度 100%, 高度不限制) */
.signed-pdf { display: flex; flex-direction: column; }
.signed-pdf-frame { width: 100%; height: 424vw; border: 0; }
.signed-pdf-empty { padding: 48px 16px; text-align: center; color: #909399; }
.signed-pdf-actions { margin-top: 16px; display: flex; gap: 12px; }
/* 未受邀提示 */ /* 未受邀提示 */
.not-invited { padding: 60px 20px; text-align: center; } .not-invited { padding: 60px 20px; text-align: center; }
.not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; } .not-invited-text { font-size: 16px; color: #606266; margin-bottom: 20px; }
+53 -63
View File
@@ -115,9 +115,12 @@ onMounted(load)
</script> </script>
<style scoped> <style scoped>
/* 整页面板 (详情页标准: max-width 1200px) */ /* 整页面板 (与 Submissions.vue 列表页风格一致: 16px/20px padding, 6px 圆角, 1px 浅灰描边) */
.doctor-submission-detail { max-width: 1200px; padding: 16px; } .doctor-submission-detail { background: #fff; max-width: 1200px; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 20px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
/* 主按钮品牌色 (与列表页保持一致) */
: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); }
/* 只读表单样式 - 模拟 el-input 视觉, 但只显示文字 */ /* 只读表单样式 - 模拟 el-input 视觉, 但只显示文字 */
.readonly-form :deep(.el-form-item) { margin-bottom: 22px; } .readonly-form :deep(.el-form-item) { margin-bottom: 22px; }
@@ -140,84 +143,71 @@ onMounted(load)
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px) — 参考 executor/meetings/new MeetingNew.vue
- 卡片 padding 收窄 不动 el-form-item__label 内部样式 (float/width/height), 只改容器布局,
- 工具栏横向滚动 让 Element Plus 自带的 label-width=120px 右对齐 + input 高度自然撑开 label
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */ ======================================== */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 卡片 padding */ /* 卡片 padding (与列表页统一) */
.page-card { padding: 12px !important; border-radius: 4px !important; } .doctor-submission-detail { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */ /* 双列 → 单列:
.toolbar { - el-row 强制 block (避免 flex 横排)
flex-wrap: nowrap !important; - el-col 强制 100% 宽
overflow-x: auto; 间距来源: 桌面样式 .readonly-form :deep(.el-form-item) { margin-bottom: 22px }
-webkit-overflow-scrolling: touch; (第 126 行), 手机端直接继承, 不再额外加 padding 避免双倍间距 */
padding-bottom: 6px; .readonly-form :deep(.el-row) {
margin-bottom: 8px !important; display: block !important;
scrollbar-width: thin;
} }
.toolbar::-webkit-scrollbar { height: 4px; } .readonly-form :deep(.el-col) {
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; } width: 100% !important;
.toolbar :deep(.action-btn), max-width: 100% !important;
.toolbar :deep(.el-button) { flex: 0 0 100% !important;
flex-shrink: 0; display: block !important;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
} }
/* filter-form: 横向 (label 左 + input 右) */ /* form-item: 横向 label + content, flex-start 让 error message 占独立行
.filter-form { 保留桌面已有的 22px margin-bottom (不要清零, 那是行间/字段间间距的关键),
改 flex-start 避免多行 label 被居中.
不动 __label 的 float/width/height/line-height — Element Plus 自带 label-width=120px
会自然右对齐 + 跟随 content 行高, 不会参差不齐 */
.readonly-form :deep(.el-form-item) {
display: flex !important; display: flex !important;
flex-direction: column !important; align-items: flex-start !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important; margin-right: 0 !important;
margin-bottom: 0 !important;
} }
.filter-form :deep(.el-form-item__label) { .readonly-form :deep(.el-form-item__content) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important; flex: 1 !important;
min-width: 0 !important; min-width: 0 !important;
} }
/* 强制所有控件全宽 */ /* 只读控件全宽, display:block 让 textarea 也按 block 拉伸 */
.filter-form :deep(.el-select), .readonly-form :deep(.el-input),
.filter-form :deep(.el-input), .readonly-form :deep(.el-textarea) {
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important; width: 100% !important;
min-width: 0 !important; min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important; display: block !important;
} }
.filter-form :deep(.el-button + .el-button) { .readonly-cell { flex: 1; min-width: 0; font-size: 14px; }
margin-top: 8px !important;
}
/* 表格字号收紧 */ /* 返回按钮 — 左右并排 (与 MeetingNew 的 form-actions 一致) */
:deep(.el-table) { font-size: 12px !important; } .detail-actions {
display: flex !important;
flex-wrap: wrap !important;
gap: 0 !important;
margin-top: 16px !important;
padding-top: 16px !important;
border-top: 1px solid #f0f0f0;
}
.detail-actions :deep(.el-button) {
flex: 1 1 0 !important;
width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.detail-actions :deep(.el-button + .el-button) {
margin-left: 8px !important;
}
} }
</style> </style>
+50 -66
View File
@@ -60,12 +60,13 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button @click="confirmCancel">取消</el-button>
</el-form-item>
</el-form> </el-form>
<!-- 操作按钮 el-form 提出来, 避免 Element Plus form-item 容器影响按钮布局 -->
<div class="detail-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button @click="confirmCancel">取消</el-button>
</div>
</div> </div>
</template> </template>
@@ -198,90 +199,73 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
/* 整页面板 (与 admin/ExpertNew.vue 一致: max-width 1200px) */ /* 整页面板 (与 Submissions.vue 列表页风格一致) */
.doctor-submission-new { max-width: 1200px; padding: 16px; } .doctor-submission-new { background: #fff; max-width: 1200px; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; } .breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
/* 主按钮品牌色 (与列表页保持一致) */
: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); }
/* 操作按钮区 (从 el-form 提出来, 桌面/移动端一致布局) */
.detail-actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid #f0f0f0; }
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px) — 参考 executor/meetings/new MeetingNew
- 卡片 padding 收窄 不动 el-form-item__label 内部样式 (float/width/height), 让 Element Plus 自带
- 工具栏横向滚动 label-width=120px 右对齐 + input 高度自然撑开 label, 不会参差不齐
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */ ======================================== */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 卡片 padding */ /* 卡片 padding (与列表页统一) */
.page-card { padding: 12px !important; border-radius: 4px !important; } .doctor-submission-new { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */ /* 双列 → 单列: el-row 强制 block, el-col 100% 宽 */
.toolbar { .doctor-submission-new :deep(.el-row) { display: block !important; }
flex-wrap: nowrap !important; .doctor-submission-new :deep(.el-col) {
overflow-x: auto; width: 100% !important;
-webkit-overflow-scrolling: touch; max-width: 100% !important;
padding-bottom: 6px; flex: 0 0 100% !important;
margin-bottom: 8px !important; display: block !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
} }
/* filter-form: 横向 (label 左 + input 右) */ /* form-item: 横向 label + content, flex-start 让 error message 占独立行
.filter-form { 不动 __label 的 float/width/height/line-height,
不动 form-item 的 margin-bottom (桌面默认 0, 手机端继承, 间距靠 el-form-item 自带 + el-row 默认布局) */
.doctor-submission-new :deep(.el-form-item) {
display: flex !important; display: flex !important;
flex-direction: column !important; align-items: flex-start !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important; margin-right: 0 !important;
margin-bottom: 0 !important;
} }
.filter-form :deep(.el-form-item__label) { .doctor-submission-new :deep(.el-form-item__content) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important; flex: 1 !important;
min-width: 0 !important; min-width: 0 !important;
} }
/* 强制所有控件全宽 */ /* 全部控件全宽, display:block 强制 select/input 也按 block 拉伸 (与列表页一致) */
.filter-form :deep(.el-select), .doctor-submission-new :deep(.el-select),
.filter-form :deep(.el-input), .doctor-submission-new :deep(.el-input),
.filter-form :deep(.el-date-editor), .doctor-submission-new :deep(.el-textarea),
.filter-form :deep(.el-button), .doctor-submission-new :deep(.el-date-editor),
.filter-form :deep(.el-cascader) { .doctor-submission-new :deep(.el-cascader) {
width: 100% !important; width: 100% !important;
min-width: 0 !important; min-width: 0 !important;
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
display: block !important; display: block !important;
} }
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */ /* 保存/取消按钮 — 独立 .detail-actions, flex 横排占满 */
:deep(.el-table) { font-size: 12px !important; } .detail-actions {
display: flex !important;
gap: 8px !important;
margin-top: 16px !important;
padding-top: 16px !important;
border-top: 1px solid #f0f0f0;
}
.detail-actions :deep(.el-button) {
flex: 1 1 0 !important;
width: 0 !important;
}
} }
</style> </style>
+20 -4
View File
@@ -22,14 +22,14 @@
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="q.status" placeholder="请选择" clearable style="width: 140px"> <el-select v-model="q.status" placeholder="请选择" clearable style="width: 140px">
<el-option label="提交" value="0" /> <el-option label="提交" value="0" />
<el-option label="待审核" value="1" /> <el-option label="待审核" value="1" />
<el-option label="通过" value="2" /> <el-option label="通过" value="2" />
<el-option label="拒绝" value="3" /> <el-option label="拒绝" value="3" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="输入备注" clearable style="width: 200px" /></el-form-item> <el-form-item label="备注"><el-input v-model="q.remark" placeholder="输入备注" clearable style="width: 200px" /></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>
</el-form> </el-form>
<div class="toolbar"> <div class="toolbar">
@@ -48,8 +48,10 @@
<el-table-column label="设计文件" width="140"> <el-table-column label="设计文件" width="140">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.designFileUrl"> <template v-if="row.designFileUrl">
<el-link :underline="false" type="primary" @click="openPreview(row.designFileUrl, '设计文件')">查看</el-link> <div class="table-actions">
<el-link type="primary" :href="row.designFileUrl" target="_blank">下载</el-link> <el-link :underline="false" type="primary" @click="openPreview(row.designFileUrl, '设计文件')">查看</el-link>
<el-link type="primary" :href="row.designFileUrl" target="_blank">下载</el-link>
</div>
</template> </template>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
@@ -59,6 +61,20 @@
<audit-status-tag :status="row.status" /> <audit-status-tag :status="row.status" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="是否结算" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isSettled==='Y' ? 'success' : 'warning'">
{{ row.isSettled==='Y' ? '已结算' : '未结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="projectNo" label="项目编号" width="140" align="center" />
<el-table-column label="创建时间" width="160" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column>
<el-table-column label="审核意见" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.auditOpinion || '-' }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip /> <el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }"><div class="table-actions"> <template #default="{ row }"><div class="table-actions">
@@ -53,7 +53,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" /> <el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" /> <el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" :width="isAdmin ? 320 : 240" fixed="right"> <el-table-column label="操作" :width="isAdmin ? 240 : 180" fixed="right">
<template #default="{ row }"><div class="table-actions"> <template #default="{ row }"><div class="table-actions">
<!-- 查看: 两角色都有 (manager 原版本有, admin 原版本用 alert, 这里统一用 dialog 更清晰) --> <!-- 查看: 两角色都有 (manager 原版本有, admin 原版本用 alert, 这里统一用 dialog 更清晰) -->
<el-link :underline="false" type="primary" @click="onView(row)">查看</el-link> <el-link :underline="false" type="primary" @click="onView(row)">查看</el-link>
@@ -65,7 +65,7 @@
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag> <el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="340" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }"><div class="table-actions"> <template #default="{ row }"><div class="table-actions">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 --> <!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-link :underline="false" type="primary" @click="goView(row)">查看</el-link> <el-link :underline="false" type="primary" @click="goView(row)">查看</el-link>
@@ -149,83 +149,33 @@ onMounted(loadDetail)
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */ ======================================== */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 卡片 padding */ /* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; } .page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */ /* form-card 内部 padding 收窄 */
.toolbar { :deep(.form-card .el-card__body) { padding: 12px !important; }
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */ /* form-item: 横向 label + content, flex-start 让 error message 占独立行
.filter-form { 不动 __label — Element Plus 自带 label-width=100px 自然对齐 */
:deep(.el-form-item) {
display: flex !important; display: flex !important;
flex-direction: column !important; align-items: flex-start !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important; margin-right: 0 !important;
margin-bottom: 0 !important;
} }
.filter-form :deep(.el-form-item__label) { :deep(.el-form-item__content) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important; flex: 1 !important;
min-width: 0 !important; min-width: 0 !important;
} }
/* 强制所有控件全宽 */ /* 底部按钮: 占满整行 (返回按钮单独占一行) */
.filter-form :deep(.el-select), .form-actions {
.filter-form :deep(.el-input), margin-top: 12px !important;
.filter-form :deep(.el-date-editor), }
.filter-form :deep(.el-button), .form-actions :deep(.el-button) {
.filter-form :deep(.el-cascader) {
width: 100% !important; width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
} }
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
} }
</style> </style>
@@ -185,83 +185,36 @@ onMounted(() => {
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */ ======================================== */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 卡片 padding */ /* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; } .page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */ /* form-card 内部 padding 收窄 */
.toolbar { :deep(.form-card .el-card__body) { padding: 12px !important; }
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */ /* form-item: 横向 label + content, flex-start 让 error message 占独立行
.filter-form { 不动 __label — Element Plus 自带 label-width=100px 自然对齐 */
:deep(.el-form-item) {
display: flex !important; display: flex !important;
flex-direction: column !important; align-items: flex-start !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important; margin-right: 0 !important;
margin-bottom: 0 !important;
} }
.filter-form :deep(.el-form-item__label) { :deep(.el-form-item__content) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important; flex: 1 !important;
min-width: 0 !important; min-width: 0 !important;
} }
/* 强制所有控件全宽 */ /* 底部按钮: 等宽并排 */
.filter-form :deep(.el-select), .form-actions {
.filter-form :deep(.el-input), flex-wrap: wrap !important;
.filter-form :deep(.el-date-editor), gap: 8px !important;
.filter-form :deep(.el-button), margin-top: 12px !important;
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
} }
.filter-form :deep(.el-button + .el-button) { .form-actions :deep(.el-button) {
margin-top: 8px !important; flex: 1 1 0 !important;
width: 0 !important;
} }
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
} }
</style> </style>
+1 -1
View File
@@ -53,7 +53,7 @@ async function onSave() {
await formRef.value.validate() await formRef.value.validate()
saving.value = true saving.value = true
try { try {
await request({ url: '/system/user/profile', method: 'put', data: { nickName: form.nickName, phonenumber: form.phonenumber, sex: profile.value.sex } }) await request({ url: '/business/person/profile', method: 'put', data: { name: form.nickName, phone: form.phonenumber } })
if (form.newPassword) { if (form.newPassword) {
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: form.oldPassword, newPassword: form.newPassword } }) await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: form.oldPassword, newPassword: form.newPassword } })
} }
+1 -1
View File
@@ -14,7 +14,7 @@
<el-form-item label="会议名称"><el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" /></el-form-item> <el-form-item label="会议名称"><el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" /></el-form-item>
<el-form-item label="专家姓名"><el-input v-model="q.expertName" clearable placeholder="输入专家姓名" style="width:160px" /></el-form-item> <el-form-item label="专家姓名"><el-input v-model="q.expertName" clearable placeholder="输入专家姓名" style="width:160px" /></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>
<el-button @click="reset">重置</el-button> <el-button @click="reset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
+25 -9
View File
@@ -9,9 +9,9 @@
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item> <el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item> <el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
<el-form-item label="会议时间"> <el-form-item label="会议时间">
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" /> <el-date-picker v-model="q.startTime" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width:170px" />
<span class="date-sep"></span> <span class="date-sep"></span>
<el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width:170px" /> <el-date-picker v-model="q.endTime" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width:170px" />
</el-form-item> </el-form-item>
<el-form-item label="项目形式"> <el-form-item label="项目形式">
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px"> <el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
@@ -28,7 +28,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="请输入备注" clearable style="width:140px" /></el-form-item> <el-form-item label="备注"><el-input v-model="q.remark" placeholder="请输入备注" clearable style="width:140px" /></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>
<el-button @click="reset">重置</el-button> <el-button @click="reset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -40,14 +40,20 @@
<el-table-column prop="projectNo" label="项目编号" width="170" fixed /> <el-table-column prop="projectNo" label="项目编号" width="170" fixed />
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" /> <el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" /> <el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip /> <el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link :underline="false" type="primary" @click="onView(row)">{{ row.meetingName }}</el-link>
</template>
</el-table-column>
<el-table-column label="会议开始时间" width="160" align="center"> <el-table-column label="会议开始时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.startTime) }}</template> <template #default="{ row }">{{ fmtTime(row.startTime) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="会议结束时间" width="160" align="center"> <el-table-column label="会议结束时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template> <template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" /> <el-table-column label="总期数" width="80" align="center">
<template #default="{ row }">{{ row.assignedSessions ?? row.totalPeriods ?? '-' }}</template>
</el-table-column>
<el-table-column label="期数" width="80" align="center"> <el-table-column label="期数" width="80" align="center">
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template> <template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
</el-table-column> </el-table-column>
@@ -57,11 +63,12 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip /> <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="200" fixed="right" align="center"> <el-table-column label="操作" width="240" fixed="right" align="center">
<template #default="{ row }"><div class="table-actions"> <template #default="{ row }"><div class="table-actions">
<el-link :underline="false" type="primary" @click="onView(row)">查看</el-link> <el-link :underline="false" type="primary" @click="onView(row)">查看</el-link>
<el-link :underline="false" type="primary" @click="onUpload(row)">编辑材料</el-link> <el-link :underline="false" type="primary" @click="onEdit(row)">编辑</el-link>
<el-link :underline="false" type="primary" @click="onEdit(row)">修改</el-link> <el-link :underline="false" type="primary" @click="onUpload(row)">上传材料</el-link>
<el-link :underline="false" type="primary" @click="onCopy(row)">复制</el-link>
</div></template> </div></template>
</el-table-column> </el-table-column>
</GrTable> </GrTable>
@@ -101,7 +108,11 @@ function fmtTime(d) {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const { data } = await bizList('meeting', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize }) const params = { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize }
// 时间范围含两端: 开始日 00:00:00 ~ 结束日 23:59:59
if (params.startTime) params.startTime = params.startTime + ' 00:00:00'
if (params.endTime) params.endTime = params.endTime + ' 23:59:59'
const { data } = await bizList('meeting', params)
rows.value = data?.rows || [] rows.value = data?.rows || []
page.total = data?.total || 0 page.total = data?.total || 0
} catch { rows.value = []; page.total = 0 } } catch { rows.value = []; page.total = 0 }
@@ -135,6 +146,11 @@ function onEdit(row) {
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } }) router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '' } })
} }
// 复制会议: 跳 executor-meetings-new (MeetingNew.vue 公共页), mode=copy → 后端 POST /business/meeting
function onCopy(row) {
router.push({ name: 'executor-meetings-new', query: { meetingId: row.meetingId, projectId: row.projectId || '', mode: 'copy' } })
}
onMounted(() => { readQueryFromRoute(); load() }) onMounted(() => { readQueryFromRoute(); load() })
</script> </script>
+21 -63
View File
@@ -294,82 +294,40 @@ onMounted(() => {
/* ======================================== /* ========================================
移动端适配 (≤768px) 移动端适配 (≤768px)
- 卡片 padding 收窄 - 卡片 padding 收窄
- 工具栏横向滚动 - el-row 双列 → 单列
- filter-form: label 与输入框横向 - form-item label 左 + content/content 横向并排 (不压控件内部样式, 留 error 提示行)
- 表格字号收紧 - 保存/取消按钮左右并排等宽
======================================== */ ======================================== */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; } .page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; } .breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
:deep(.form-card .el-card__body) { padding: 16px 12px !important; }
/* 工具栏: 横向滚动 */ /* 双列 el-row → 单列 */
.toolbar { .new-person :deep(.el-row) { display: block !important; }
flex-wrap: nowrap !important; .new-person :deep(.el-col) {
overflow-x: auto; width: 100% !important;
-webkit-overflow-scrolling: touch; max-width: 100% !important;
padding-bottom: 6px; flex: 0 0 100% !important;
margin-bottom: 8px !important; display: block !important;
scrollbar-width: thin;
} }
.toolbar::-webkit-scrollbar { height: 4px; } /* form-item 横向: label 左 + content 右, flex-start 保留 error 提示行 */
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; } .new-person :deep(.el-form-item) {
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */
.filter-form {
display: flex !important; display: flex !important;
flex-direction: column !important; align-items: flex-start !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important; margin-right: 0 !important;
margin-bottom: 0 !important; margin-bottom: 14px !important;
} }
.filter-form :deep(.el-form-item__label) { .new-person :deep(.el-form-item__content) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important; flex: 1 !important;
min-width: 0 !important; min-width: 0 !important;
} }
/* 强制所有控件全宽 */ /* 保存/取消按钮: 左右并排等宽 */
.filter-form :deep(.el-select), .form-actions { margin-top: 12px !important; }
.filter-form :deep(.el-input), .form-actions :deep(.el-button) {
.filter-form :deep(.el-date-editor), flex: 1 1 0 !important;
.filter-form :deep(.el-button), width: 0 !important;
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
} }
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
} }
</style> </style>
+11 -11
View File
@@ -29,13 +29,14 @@
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) --> <!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
<section class="section"> <section class="section">
<div class="section-title">消息通知<a class="more" @click.prevent="$router.push('/executor/messages')">更多 </a></div> <div class="section-title">消息通知<a class="more" @click.prevent="$router.push('/executor/messages')">更多 </a></div>
<NoticeList :limit="50" /> <NoticeList :pageable="true" :show-header="false" />
</section> </section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import request from '@/utils/request'
import { bizList } from '@/api/public' import { bizList } from '@/api/public'
import NoticeList from '@/components/NoticeList.vue' import NoticeList from '@/components/NoticeList.vue'
@@ -46,22 +47,21 @@ const stats = ref({
async function loadStats() { async function loadStats() {
try { try {
// 项目总数量 (跟 manager 一样调普通 list, 后续若需要按 executor 隔离再换 executorList 接口) // 项目统计走 executorList: 后端按当前账号隔离 (MAIN=本执行单位全部, SUB=自己负责的项目)
const ps = await bizList('project', { pageNum: 1, pageSize: 1 }) const projectCount = async (extra = {}) => {
stats.value.totalProjects = ps.total || ps.data?.total || 0 const { data } = await request.get('/business/project/executorList', { params: { pageNum: 1, pageSize: 1, ...extra } })
// 已结题项目 return data?.total || 0
const cp = await bizList('project', { pageNum: 1, pageSize: 1, isFinished: '1' }) }
stats.value.completedProjects = cp.total || cp.data?.total || 0 stats.value.totalProjects = await projectCount()
// 会议总数量 stats.value.settledProjects = await projectCount({ isSettled: 'Y' })
stats.value.completedProjects = await projectCount({ isFinished: '1' })
// 会议统计走 /business/meeting/list (后端已按 roleType MAIN/SUB 隔离)
const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 }) const ms = await bizList('meeting', { pageNum: 1, pageSize: 1 })
const meetingTotal = ms.total || ms.data?.total || 0 const meetingTotal = ms.total || ms.data?.total || 0
// 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交) // 已执行会议 = currentStage='RUNNING' (阶段已过开始时间, 执行方未提交)
const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' }) const em = await bizList('meeting', { pageNum: 1, pageSize: 1, currentStage: 'RUNNING' })
stats.value.executedMeetings = em.total || em.data?.total || 0 stats.value.executedMeetings = em.total || em.data?.total || 0
stats.value.pendingMeetings = Math.max(0, meetingTotal - stats.value.executedMeetings) stats.value.pendingMeetings = Math.max(0, meetingTotal - stats.value.executedMeetings)
// 已结算项目
const sp = await bizList('project', { pageNum: 1, pageSize: 1, isSettled: 'Y' })
stats.value.settledProjects = sp.total || sp.data?.total || 0
} catch (e) { } catch (e) {
console.warn('loadStats failed', e) console.warn('loadStats failed', e)
} }
+29 -10
View File
@@ -10,7 +10,7 @@
<el-form-item label="部门"><el-input v-model="q.department" placeholder="部门" clearable style="width:140px" /></el-form-item> <el-form-item label="部门"><el-input v-model="q.department" placeholder="部门" clearable style="width:140px" /></el-form-item>
<el-form-item label="职务"><el-input v-model="q.position" placeholder="职务" clearable style="width:140px" /></el-form-item> <el-form-item label="职务"><el-input v-model="q.position" placeholder="职务" clearable style="width:140px" /></el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="loadList"></el-button> <el-button type="primary" @click="loadList"></el-button>
<el-button @click="reset">重置</el-button> <el-button @click="reset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -43,17 +43,27 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="300" fixed="right"> <el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"><div class="table-actions"> <template #default="{ row }"><div class="table-actions">
<el-link :underline="false" size="small" type="primary" @click="onView(row)">查看</el-link> <el-link :underline="false" size="small" type="primary" @click="onView(row)">查看</el-link>
<el-link :underline="false" size="small" type="primary" @click="goEdit(row)">编辑</el-link> <!-- 次要操作折叠到"更多"下拉, 避免操作列过宽 -->
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) --> <el-dropdown trigger="hover" @command="(cmd) => onMoreAction(cmd, row)">
<el-link :underline="false" v-if="row.userId" size="small" type="primary" @click="onResetPwd(row)">重置密码</el-link> <el-link :underline="false" size="small" type="primary" class="op-dropdown">
<!-- 本人不显示禁用/恢复按钮 ( sponsor 一样, 避免主账号把自己禁用) --> 更多<el-icon class="op-caret"><ArrowDown /></el-icon>
<template v-if="row.userId !== store.user?.userId"> </el-link>
<el-link :underline="false" v-if="row.status === '0'" size="small" type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-link> <template #dropdown>
<el-link :underline="false" v-else size="small" type="success" @click="onToggleStatus(row, '恢复')">恢复</el-link> <el-dropdown-menu>
</template> <el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item v-if="row.userId" command="resetPwd">重置密码</el-dropdown-item>
<el-dropdown-item v-if="row.userId !== store.user?.userId && row.status === '0'" command="disable" divided>
<span style="color:#DC2626">禁用</span>
</el-dropdown-item>
<el-dropdown-item v-if="row.userId !== store.user?.userId && row.status !== '0'" command="enable" divided>
<span style="color:#16A34A">恢复</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div></template> </div></template>
</el-table-column> </el-table-column>
</GrTable> </GrTable>
@@ -115,6 +125,7 @@ import { bizUpdate, resetPersonPassword } from '@/api/public'
import { listExecutorPerson } from '@/api/business/person' import { listExecutorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap' import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
import GrTable from '@/components/GrTable.vue' import GrTable from '@/components/GrTable.vue'
import request from '@/utils/request' import request from '@/utils/request'
@@ -156,6 +167,14 @@ function goEdit(row) { router.push(`/executor/people/edit/${row.personId}`) }
function onView(row) { router.push(`/executor/people/detail/${row.personId}`) } function onView(row) { router.push(`/executor/people/detail/${row.personId}`) }
// "更多"下拉分发: 把 cmd 映射到现有 handler (操作列折叠, 避免过宽)
function onMoreAction(cmd, row) {
if (cmd === 'edit') goEdit(row)
else if (cmd === 'resetPwd') onResetPwd(row)
else if (cmd === 'disable') onToggleStatus(row, '禁用')
else if (cmd === 'enable') onToggleStatus(row, '恢复')
}
async function onResetPwd(row) { async function onResetPwd(row) {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(

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