提交所有代码

This commit is contained in:
郭庆泰
2026-08-31 19:01:00 +08:00
parent f4d8bc1463
commit a14d95288c
53 changed files with 1356 additions and 286 deletions
@@ -4,10 +4,7 @@ spring:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
master:
url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Qljl1rh_
# master 数据源 url/username/password 按环境拆分到 application-test.yml / application-prod.yml
slave:
enabled: false
url:
@@ -0,0 +1,34 @@
# 生产环境数据源 (47.95.12.80 / hegui)
# 生产端口 8082 (测试端口 8080 为 application.yml 默认值)
server:
port: 8082
spring:
datasource:
druid:
master:
url: jdbc:mysql://47.95.12.80:3306/hegui?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: hegui
password: C55zwt5L2TeB4kLB
# 生产环境外部接口地址
ruoyi:
sms:
esignBaseUrl: https://hegui.bahim.org.cn
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://hegui.bahim.org.cn/camera/
supplier-account-api:
# base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
bidding-api:
# notice-list-url: https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list
# detail-url: https://zbpublic.guojustar.com/#/detail/
notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
detail-url: https://zbpublictest.guojustar.com/#/detail/
@@ -0,0 +1,27 @@
# 测试环境数据源 (mmos.uvwcloud.com / guoju0808)
spring:
datasource:
druid:
master:
url: jdbc:mysql://mmos.uvwcloud.com:3306/guoju0808?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Qljl1rh_
# 测试环境外部接口地址
ruoyi:
sms:
esignBaseUrl: https://risingdoctor.com/hg
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://risingdoctor.com/camera/
supplier-account-api:
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
bidding-api:
notice-list-url: https://zbpublictest.guojustar.com/portal-api/bidding/public/notice/list
detail-url: https://zbpublictest.guojustar.com/#/detail/
@@ -33,7 +33,6 @@ ruoyi:
signName: 北京整合医学学会
template: SMS_291440833
esignTemplate: SMS_512040098
esignBaseUrl: https://hegui.bahim.org.cn
endpoint: dysmsapi.aliyuncs.com
regionId: cn-hangzhou
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
@@ -55,21 +54,15 @@ ruoyi:
upload:
max-mb: 20
pdf-dpi: 150
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
camera:
base-url: https://risingdoctor.com/camera/
# 供应商账号接口 (拉取/解密) 配置
# 供应商账号接口 (拉取/解密) 配置 (base-url 按环境拆分到 application-test/prod.yml)
supplier-account-api:
base-url: https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
# 每页条数
page-size: 20
# 每次拉取最近 N 分钟更新的数据
pull-minutes: 5
# 供应商账号接口 AES 密钥 (数据解密用, 测试环境密钥)
supplier-account-api-aes:
key-id: supplier-api-key
key: 'kY0+oIO/laeaaaYDd+9TCYbCQR0b/vqMeJ9sUnSVd9U='
# 供应商账号接口 AES 密钥 (supplier-account-api-aes.key 按环境拆分到 application-test/prod.yml)
# 招标公告查询接口 (notice-list-url / detail-url 按环境拆分到 application-test/prod.yml)
# 开发环境配置
server:
@@ -103,7 +96,11 @@ spring:
# 国际化资源文件路径
basename: i18n/messages
profiles:
active: druid
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test)
active: prod
group:
test: druid,test
prod: druid,prod
# 文件上传
servlet:
multipart:
@@ -72,10 +72,18 @@ public class BizMeetingAttendeeController extends BaseController {
/**
* 管理端: 某会议的全部参会人 (MeetingDetail 参会人 CRUD 用).
* 返回全字段, 前端按需展示.
* <p>sponsor (监督方) 只读且敏感信息脱敏: 姓名/手机号/银行卡号/账户名称/身份证号在后端打码,
* 明文不下发 (前端遮罩只是显示层, 不是安全边界).
*/
@GetMapping("/list/{meetingId}")
public AjaxResult listByMeetingId(@PathVariable("meetingId") Long meetingId) {
return success(attendeeService.selectByMeetingId(meetingId));
List<BizMeetingAttendee> list = attendeeService.selectByMeetingId(meetingId);
if (isSponsor()) {
for (BizMeetingAttendee a : list) {
maskForSponsor(a);
}
}
return success(list);
}
/**
@@ -324,4 +332,50 @@ public class BizMeetingAttendeeController extends BaseController {
}
}
/** 当前登录用户是否为 sponsor (监督方只读, 敏感信息需脱敏) */
private boolean isSponsor() {
try {
return "sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType());
} catch (Exception e) {
return false;
}
}
/** sponsor 脱敏: 姓名/手机号/银行卡号/账户名称/身份证号 (对齐 desensitizeHtml 口径) */
private void maskForSponsor(BizMeetingAttendee a) {
if (a == null) return;
a.setName(maskName(a.getName()));
a.setPhone(maskPhone(a.getPhone()));
a.setBankCard(maskBankCard(a.getBankCard()));
a.setAccountName(maskName(a.getAccountName()));
a.setIdCard(maskIdCard(a.getIdCard()));
}
/** 姓名脱敏: 保留首字 (姓), 其余打码 (张三 → 张*) */
private String maskName(String v) {
if (v == null) return null;
String s = v.trim();
if (s.isEmpty()) return v;
if (s.length() <= 1) return "*";
return s.charAt(0) + "*".repeat(s.length() - 1);
}
/** 手机号脱敏: 前3 + **** + 后4 */
private String maskPhone(String v) {
if (v == null || v.length() != 11) return v;
return v.substring(0, 3) + "****" + v.substring(7);
}
/** 银行卡号脱敏: 前4 + ******** + 后4 */
private String maskBankCard(String v) {
if (v == null || v.length() < 8) return v;
return v.substring(0, 4) + "********" + v.substring(v.length() - 4);
}
/** 身份证号脱敏: 前6 + ******** + 后4 */
private String maskIdCard(String v) {
if (v == null || v.length() != 18) return v;
return v.substring(0, 6) + "********" + v.substring(14);
}
}
@@ -111,6 +111,10 @@ public class BizMeetingController extends BaseController {
else if ("manager".equals(roleType)) {
bizMeeting.getParams().put("managerCreateUserId", uid);
}
// 项目负责人(leader) 数据权限: 只看"本人负责的项目"下的会议 (project_id ∈ lead_user_id = 自己)
else if ("leader".equals(roleType)) {
bizMeeting.getParams().put("leaderUserId", uid);
}
startPage();
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
return getDataTable(list);
@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.impl.InvoiceOcrService;
/**
@@ -11,7 +12,7 @@ import com.ruoyi.business.service.impl.InvoiceOcrService;
* <p>
* v3 变更:
* - 端点改为"提交即返回 SUBMITTED", OCR 在后台 ExecutorService 跑
* - 新增 isZip 字段: true → zip 路径(解压 → 重传 OSS → 写多行 invoice)
* - zip 由后端按 ossUrl 后缀判断 (解压 → 重传 OSS → 写多行 invoice), 前端无需传 isZip
* - 新增 oldMaterialId 字段: 替换场景, 后端先清旧 invoice + material.amount=0
* - 不再走旧的同步 recognizeAndSave, 已被 submitRecognition 取代
*/
@@ -23,14 +24,16 @@ public class BizMeetingInvoiceController extends BaseController
@Autowired
private InvoiceOcrService invoiceOcrService;
@Autowired
private IBizMeetingInvoiceService invoiceService;
/**
* 提交发票识别 (后台执行, 立即返回)
* <p>
* body: {
* "materialId": 123,
* "meetingId": 456,
* "ossUrl": "https://...",
* "isZip": false, // 新增
* "ossUrl": "https://...", // .zip 结尾走 zip 解压, 否则按单文件
* "oldMaterialId": null // 新增, 替换时携带原 materialId
* }
*/
@@ -41,19 +44,28 @@ public class BizMeetingInvoiceController extends BaseController
body.getMaterialId(),
body.getMeetingId(),
body.getOssUrl(),
body.isZip(),
body.getOldMaterialId());
return success(r);
}
/**
* 查询某会议下所有发票明细 (会务材料 tab 顶部「发票表」展示)
* <p>
* 返回 BizMeetingInvoice 列表, 含 materialSubType / materialFileName (join biz_meeting_material 带出),
* 供前端按所属材料类型渲染发票表.
*/
@GetMapping("/list")
public AjaxResult list(@RequestParam("meetingId") Long meetingId)
{
return success(invoiceService.selectByMeetingId(meetingId));
}
/** request body wrapper */
public static class RecognizeBody
{
private Long materialId;
private Long meetingId;
private String ossUrl;
/** v3 新增: true=zip 路径(解压遍历), false=单文件路径 */
private boolean isZip;
/** v3 新增: 替换场景携带的旧 materialId, 后端先 DELETE invoice WHERE material_id=old + material.amount=0 */
private Long oldMaterialId;
@@ -63,8 +75,6 @@ public class BizMeetingInvoiceController extends BaseController
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getOssUrl() { return ossUrl; }
public void setOssUrl(String ossUrl) { this.ossUrl = ossUrl; }
public boolean isZip() { return isZip; }
public void setZip(boolean zip) { isZip = zip; }
public Long getOldMaterialId() { return oldMaterialId; }
public void setOldMaterialId(Long oldMaterialId) { this.oldMaterialId = oldMaterialId; }
}
@@ -31,26 +31,37 @@ public class BizMeetingStageStatsController extends BaseController
private SysUserMapper sysUserMapper;
/**
* 按 current_stage 分组计数, 做 sponsor 数据权限隔离 (MAIN=本支持单位全部, SUB=自己负责的项目).
* 按 current_stage 分组计数, 做 sponsor / leader 数据权限隔离 (sponsor MAIN=本支持单位全部, SUB=自己负责的项目; leader=本人负责的项目).
* 返回 { stage: cnt, ... }, 前端据此算: 未执行=NOT_STARTED, 已执行=排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN).
* 其他角色的统计端点单独实现, 此端点非 sponsor 调用返回空 map (不泄露全量数据).
* 其他角色的统计端点单独实现, 此端点非 sponsor/leader 调用返回空 map (不泄露全量数据).
*/
@GetMapping("/stageStats")
public AjaxResult stageStats() {
Map<String, Long> result = new HashMap<>();
if (!"sponsor".equals(SecurityUtils.getLoginUser().getUser().getRoleType())) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"sponsor".equals(roleType) && !"leader".equals(roleType)) {
return success(result);
}
BizMeeting q = new BizMeeting();
Long uid = SecurityUtils.getUserId();
if ("leader".equals(roleType)) {
// 项目负责人: 统计范围 = 本人负责的项目 (lead_user_id = 自己)
q.getParams().put("leaderUserId", uid);
return success(doStats(q));
}
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);
}
return success(doStats(q));
}
/** 按 current_stage 分组计数, 结果 key 大小写随 JDBC 驱动, 做大小写不敏感匹配, 避免"统计恒 0". */
private Map<String, Long> doStats(BizMeeting q) {
Map<String, Long> result = new HashMap<>();
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()) {
@@ -66,6 +77,6 @@ public class BizMeetingStageStatsController extends BaseController
result.put(stage, cnt);
}
}
return success(result);
return result;
}
}
@@ -48,7 +48,7 @@ public class BizPersonController extends BaseController
@GetMapping("/list")
public TableDataInfo list(BizPerson bizPerson)
{
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到本主账号子账号对应人员
// 当前登录用户是 MAIN 业务账号 → 自动注入 subUserIds, 隔离到本主账号自己 + 其子账号对应人员
Long currentUserId = getUserId();
SysUser currentUser = currentUserId == null ? null : sysUserMapper.selectUserById(currentUserId);
if (currentUser != null
@@ -56,10 +56,14 @@ public class BizPersonController extends BaseController
&& currentUser.getRoleType() != null
&& BUSINESS_MAIN_ROLES.contains(currentUser.getRoleType())) {
List<Long> subUserIds = sysUserMapper.selectUserIdsByParent(currentUserId);
// 即使没子账号也强制注入, 避免漏出 sys_user.parent_user_id=NULL 的孤立 person
bizPerson.getParams().put("subUserIds", subUserIds == null || subUserIds.isEmpty()
? java.util.Collections.singletonList(-1L)
: subUserIds);
// 隔离范围 = 主账号自己 + 子账号. 主账号自己也要能看到自己的 biz_person
// (公益支持/项目执行意向回显 工作单位/部门/职务 用), 同时仍不漏出 parent_user_id=NULL 的孤立 person
List<Long> visibleUserIds = new ArrayList<>();
visibleUserIds.add(currentUserId);
if (subUserIds != null) {
visibleUserIds.addAll(subUserIds);
}
bizPerson.getParams().put("subUserIds", visibleUserIds);
}
startPage();
List<BizPerson> list = bizPersonService.selectList(bizPerson);
@@ -106,10 +106,12 @@ public class BizProjectController extends BaseController
@GetMapping("/list")
public TableDataInfo list(BizProject bizProject)
{
// 合规人员(manager): 只看本人创建的项目 (create_user_id = 当前用户); admin 不限制
// 数据权限: manager 只看本人创建(create_user_id), leader 只看本人负责(lead_user_id), admin 不限制
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
} else if ("leader".equals(roleType)) {
bizProject.getParams().put("leaderUserId", SecurityUtils.getUserId());
}
startPage();
List<BizProject> list = bizProjectService.selectList(bizProject);
@@ -192,6 +194,7 @@ public class BizProjectController extends BaseController
List<BizProject> list = bizProjectService.selectExecutorList(bizProject);
return getDataTable(list);
}
@GetMapping("/{projectId}")
public AjaxResult getInfo(@PathVariable("projectId") Long projectId)
{
@@ -3,13 +3,21 @@ package com.ruoyi.business.controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.net.URLEncoder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.core.env.Environment;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.http.HttpUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.ruoyi.business.domain.BizArticle;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizSupportLetter;
@@ -37,6 +45,10 @@ public class BizPublicController extends BaseController {
@Autowired private IBizArticleService articleService;
@Autowired private IBizSpecialPlanService specialPlanService;
@Autowired private Environment environment;
private static final Logger log = LoggerFactory.getLogger(BizPublicController.class);
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 按类型获取已发布的协议/政策 (供注册页底部链接打开)
* type: agreement | privacy
@@ -92,6 +104,55 @@ public class BizPublicController extends BaseController {
return success(projectService.getById(projectId));
}
/**
* 招标公告查询: 按项目编号查外部招标平台的 noticeId, 返回详情跳转 URL.
* <p>数据源: GET {bidding-api.notice-list-url}?pageNum=1&pageSize=200&keyword={projectNo}&projectType=10&noticeType=10
* 取响应 rows[0].noticeId, 拼接 {bidding-api.detail-url}{noticeId} 返回, 前端 window.open 跳转.
*/
@GetMapping("/biddingNotice")
public AjaxResult biddingNotice(@RequestParam String projectNo)
{
String listUrl = environment.getProperty("bidding-api.notice-list-url",
"https://zbpublic.guojustar.com/portal-api/bidding/public/notice/list");
String detailUrl = environment.getProperty("bidding-api.detail-url",
"https://zbpublic.guojustar.com/#/detail/");
if (projectNo == null || projectNo.trim().isEmpty())
{
return error("项目编号不能为空");
}
try
{
String param = "pageNum=1&pageSize=200&keyword=" + URLEncoder.encode(projectNo.trim(), "UTF-8")
+ "&projectType=10&noticeType=10";
String resp = HttpUtils.sendGet(listUrl, param);
if (resp == null || resp.isEmpty())
{
return error("招标公告查询失败");
}
JsonNode root = objectMapper.readTree(resp);
JsonNode rows = root.path("rows");
if (!rows.isArray() || rows.size() == 0)
{
return error("未查到该项目的招标公告");
}
long noticeId = rows.get(0).path("noticeId").asLong(0L);
if (noticeId == 0L)
{
return error("招标公告 noticeId 缺失");
}
// 注意: 不能用 success(url) —— BaseController.success(String) 会把 URL 当 msg、data 为 null
// 用 Map 承载, 前端取 data.detailUrl
Map<String, Object> data = new HashMap<>();
data.put("detailUrl", detailUrl + noticeId);
return success(data);
}
catch (Exception e)
{
log.error("招标公告查询失败, projectNo={}", projectNo, e);
return error("招标公告查询异常");
}
}
@GetMapping("/supportLetter/{annId}")
public AjaxResult supportLetterDetail(@PathVariable String annId) {
return success(supportLetterService.getById(annId));
@@ -58,6 +58,18 @@ public class BizMeetingInvoice {
/** 原始文件名 (zip 解压时记录, 单文件路径可空) */
private String sourceFilename;
/** 所属材料类型 sub_type (join biz_meeting_material 带出, 发票表展示用) */
private String materialSubType;
/** 所属材料原始文件名 (join biz_meeting_material 带出, 单文件发票 source_filename 为空时兜底) */
private String materialFileName;
/** 发票号码 (OCR 识别) */
private String invoiceNo;
/** 开票日期 (OCR 识别, YYYY-MM-DD) */
private String invoiceDate;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@@ -93,4 +105,16 @@ public class BizMeetingInvoice {
public String getSourceFilename() { return sourceFilename; }
public void setSourceFilename(String sourceFilename) { this.sourceFilename = sourceFilename; }
public String getMaterialSubType() { return materialSubType; }
public void setMaterialSubType(String materialSubType) { this.materialSubType = materialSubType; }
public String getMaterialFileName() { return materialFileName; }
public void setMaterialFileName(String materialFileName) { this.materialFileName = materialFileName; }
public String getInvoiceNo() { return invoiceNo; }
public void setInvoiceNo(String invoiceNo) { this.invoiceNo = invoiceNo; }
public String getInvoiceDate() { return invoiceDate; }
public void setInvoiceDate(String invoiceDate) { this.invoiceDate = invoiceDate; }
}
@@ -93,8 +93,8 @@ public class BizMeetingAttendeeImportVo {
@Excel(name = "银行详细地址", sort = 18)
private String bankAddress;
/** 身份证附件(正反面 URL CSV) */
@Excel(name = "身份证附件", sort = 19)
/** 身份证附件(正反面 URL CSV)— 仅导出,不进入导入模板(附件文件无法通过 Excel 导入) */
@Excel(name = "身份证附件", sort = 19, type = Excel.Type.EXPORT)
private String idCardAttachments;
public String getPhone() { return phone; }
@@ -51,6 +51,22 @@ public interface BizMeetingInvoiceMapper {
int updateAmountByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount);
/**
* 单文件 OCR 完成: 更新金额 + 发票号 + 开票日期 (按 material_id, 与状态更新分开调用)
*/
int updateOcrResultByMaterial(@org.apache.ibatis.annotations.Param("materialId") Long materialId,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("invoiceNo") String invoiceNo,
@org.apache.ibatis.annotations.Param("invoiceDate") String invoiceDate);
/**
* 兜底 OCR 完成: 更新状态(RECOGNIZED) + 金额 + 发票号 + 开票日期 (按主键)
*/
int updateOcrResultByPrimaryKey(@org.apache.ibatis.annotations.Param("id") Long id,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("invoiceNo") String invoiceNo,
@org.apache.ibatis.annotations.Param("invoiceDate") String invoiceDate);
/**
* 兜底 OCR: 按主键更新 (状态 + 金额 + 错误信息)
*/
@@ -58,4 +74,10 @@ public interface BizMeetingInvoiceMapper {
@org.apache.ibatis.annotations.Param("recognizeStatus") String recognizeStatus,
@org.apache.ibatis.annotations.Param("amount") java.math.BigDecimal amount,
@org.apache.ibatis.annotations.Param("errorMsg") String errorMsg);
/**
* 材料全删全插后, 未变材料的发票 material_id 需从旧 id 迁移到新 id (否则发票表 join 不到材料类型)
*/
int updateInvoiceMaterialId(@org.apache.ibatis.annotations.Param("oldMaterialId") Long oldMaterialId,
@org.apache.ibatis.annotations.Param("newMaterialId") Long newMaterialId);
}
@@ -7,6 +7,8 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
@@ -39,31 +41,17 @@ public class ZipExtractor
if (size <= 0) throw new IOException("OSS zip 下载失败: " + zipUrl);
log.info("zip 下载: url={} size={}B tmp={}", zipUrl, size, zipFile.getAbsolutePath());
List<File> out = new ArrayList<>();
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile)))
// 优先 UTF-8 解包; 若 entry 名非 UTF-8 (常见 GBK, Windows 打包) 会抛
// IllegalArgumentException (malformed input) → 回退 GBK 重试
try
{
ZipEntry e;
while ((e = zin.getNextEntry()) != null)
{
if (e.isDirectory()) continue;
String name = e.getName();
String lower = name.toLowerCase();
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
{
continue;
}
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
File outFile = new File(tmpRoot, name.replace("/", "_"));
try (FileOutputStream fos = new FileOutputStream(outFile))
{
zin.transferTo(fos);
}
out.add(outFile);
}
return doExtract(zipFile, tmpRoot, StandardCharsets.UTF_8);
}
catch (IllegalArgumentException e)
{
log.warn("zip 文件名非 UTF-8, 回退 GBK 重试: {}", e.getMessage());
return doExtract(zipFile, tmpRoot, Charset.forName("GBK"));
}
log.info("zip 解压完成: 文件数={}", out.size());
return out;
}
finally
{
@@ -72,6 +60,38 @@ public class ZipExtractor
}
}
/**
* 按指定 charset 解压 zip, 返回图片/PDF 文件列表
*/
private static List<File> doExtract(File zipFile, File tmpRoot, Charset charset) throws IOException
{
List<File> out = new ArrayList<>();
try (ZipInputStream zin = new ZipInputStream(new FileInputStream(zipFile), charset))
{
ZipEntry e;
while ((e = zin.getNextEntry()) != null)
{
if (e.isDirectory()) continue;
String name = e.getName();
String lower = name.toLowerCase();
if (!(lower.endsWith(".png") || lower.endsWith(".jpg")
|| lower.endsWith(".jpeg") || lower.endsWith(".pdf")))
{
continue;
}
// 子目录展平: a/b/c.png → tmpRoot/a_b_c.png
File outFile = new File(tmpRoot, name.replace("/", "_"));
try (FileOutputStream fos = new FileOutputStream(outFile))
{
zin.transferTo(fos);
}
out.add(outFile);
}
}
log.info("zip 解压完成: charset={} 文件数={}", charset, out.size());
return out;
}
/** 清理整个解压临时目录 */
public static void cleanup(File tmpRoot)
{
@@ -0,0 +1,84 @@
package com.ruoyi.business.pdf;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.kernel.events.Event;
import com.itextpdf.kernel.events.IEventHandler;
import com.itextpdf.kernel.events.PdfDocumentEvent;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.element.Image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 劳务协议盖章 (固定定位)
*
* <p>在指定页 (默认第 2 页) 固定坐标叠加公章: x 固定, y = 页面垂直中心偏上 yOffset.
* 使用 {@link PdfDocumentEvent#END_PAGE} 事件叠加在页面内容流之后, 公章 z 序在正文之上.
*
* <p>容错: 印章图片缺失 / 加载失败时只记日志并跳过, 不阻断 PDF 生成.
*/
public class SealStamper implements IEventHandler {
private static final Logger log = LoggerFactory.getLogger(SealStamper.class);
/** 印章图片相对路径 (运行目录, 与 simsun.ttc 同级) */
private final String sealImagePath;
/** 印章水平坐标 (iText 坐标 x 从左往右) */
private final float x;
/** 印章垂直: 页面垂直中心偏上的偏移量 (正=往上) */
private final float yOffsetFromCenter;
/** 印章尺寸 (宽高, px) */
private final float width;
private final float height;
/** 盖章页码 (从 1 起) */
private final int pageNumber;
public SealStamper(String sealImagePath, float x, float yOffsetFromCenter,
float width, float height, int pageNumber) {
this.sealImagePath = sealImagePath;
this.x = x;
this.yOffsetFromCenter = yOffsetFromCenter;
this.width = width;
this.height = height;
this.pageNumber = pageNumber;
}
@Override
public void handleEvent(Event event) {
PdfDocumentEvent documentEvent = (PdfDocumentEvent) event;
PdfDocument document = documentEvent.getDocument();
int n = document.getPageNumber(documentEvent.getPage());
// 只盖指定页
if (n != pageNumber) {
return;
}
Image image;
try {
image = new Image(ImageDataFactory.create(sealImagePath));
} catch (Exception e) {
log.warn("[seal] 印章图片加载失败, 跳过盖章: {}", sealImagePath, e);
return;
}
image.setWidth(width);
image.setHeight(height);
PdfPage page = documentEvent.getPage();
Rectangle pageSize = page.getPageSize();
// y = 页面垂直中心偏上 yOffsetFromCenter (iText 坐标 y 从下往上)
float y = pageSize.getHeight() / 2 + yOffsetFromCenter;
image.setFixedPosition(x, y);
PdfCanvas pdfCanvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), document);
Canvas canvas = new Canvas(pdfCanvas, pageSize);
try {
canvas.add(image);
} finally {
canvas.close();
}
}
}
@@ -49,7 +49,7 @@ public class SupplierAccountPullScheduler
private static final String FULL_PULL_DAYS = "supplier-account-api.full-pull-days";
private static final String DEFAULT_BASE_URL =
"https://zbsuppliertest.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
"https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts";
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -3,6 +3,7 @@ package com.ruoyi.business.service;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.events.PdfDocumentEvent;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
@@ -10,6 +11,7 @@ import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.font.FontProvider;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.pdf.SealStamper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -28,6 +30,18 @@ public class PdfService {
@Autowired
private OssUploader ossUploader;
/** 公章图片相对路径 (运行目录, 与 simsun.ttc 同级) */
private static final String SEAL_IMAGE_PATH = "zhenghe.png";
/** 公章水平坐标 (保持不动) */
private static final float SEAL_X = 80f;
/** 公章垂直: 页面正中间偏上 15 */
private static final float SEAL_Y_OFFSET = 15f;
/** 公章尺寸 (宽高, px) */
private static final float SEAL_WIDTH = 120f;
private static final float SEAL_HEIGHT = 120f;
/** 盖章页码 (第 2 页) */
private static final int SEAL_PAGE = 2;
/**
* HTML 字符串 → PDF 字节流
*/
@@ -48,6 +62,10 @@ public class PdfService {
fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H);
properties.setFontProvider(fontProvider);
// 盖章: 第 2 页固定坐标叠加公章 (END_PAGE 事件, 必须在 convertToPdf 之前注册, 否则 document 已关闭)
pdfDocument.addEventHandler(PdfDocumentEvent.END_PAGE,
new SealStamper(SEAL_IMAGE_PATH, SEAL_X, SEAL_Y_OFFSET, SEAL_WIDTH, SEAL_HEIGHT, SEAL_PAGE));
HtmlConverter.convertToPdf(fullHtml, pdfDocument, properties);
pdfDocument.close();
return baos.toByteArray();
@@ -119,7 +119,7 @@ public class StageDeriver
}
// 仅 待审核/审核通过/待整改/待结算 四类加前缀
if ("待审核".equals(label) || "审核通过".equals(label) || "待整改".equals(label) || "待结算".equals(label))
if ("待审核".equals(label) || "审核通过".equals(label) || "待整改".equals(label) || "待结算".equals(label) || "待监察".equals(label))
{
return ("labor".equals(track) ? "劳务" : "会务") + label;
}
@@ -179,11 +179,11 @@ public class StageDeriver
if (phase == 1) return "执行中";
return "已执行";
case 2: // C0 合规审中
if ("sponsor".equals(role)) return "已执行未传材料"; // 只读
return "待审核"; // executor / manager / admin
return "待审核"; // executor / manager / sponsor / admin
case 3: // C1 支持方审中
if ("manager".equals(role)) return "审核通过";
return "审核"; // executor / sponsor / admin
if ("sponsor".equals(role)) return "监察";
return "待审核"; // executor / admin
default: // 4 = A 已通过
return "待结算";
}
@@ -26,7 +26,7 @@ import com.ruoyi.system.service.ISysUserService;
@Service
public class BizAdminUserServiceImpl implements IBizAdminUserService {
private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor");
private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor", "leader");
@Autowired
private ISysUserService sysUserService;
@@ -79,6 +79,7 @@ public class BizAdminUserServiceImpl implements IBizAdminUserService {
switch (role) {
case "admin":
case "manager":
case "leader": // 项目负责人: 纯 sys_user (姓名落 sys_user.nick_name, 供 lead_user_name 反查兜底)
return insertPlain(b, role, encPwd);
case "doctor":
return insertDoctor(b, encPwd);
@@ -37,6 +37,7 @@ import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.domain.BizMeetingMaterial;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.mapper.BizMeetingInvoiceMapper;
import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.business.oss.OssZipService;
@@ -54,8 +55,8 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
"L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK"));
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF 才触发 OCR */
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf)$", Pattern.CASE_INSENSITIVE);
/** 可识别文件扩展名 (与前端 isRecognizable 对齐): 图片/PDF/zip 才触发 OCR (zip 走解压分析发票) */
private static final Pattern RECOGNIZABLE = Pattern.compile("\\.(jpe?g|png|pdf|zip)$", Pattern.CASE_INSENSITIVE);
/** 会务下载范围: 会务材料 (SERVICE) + 会务凭证 (SERVICE_VOUCHER) */
private static final Set<String> SERVICE_MATERIAL_TYPES = new HashSet<>(Arrays.asList("SERVICE", "SERVICE_VOUCHER"));
@@ -114,6 +115,8 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
@Autowired
private BizMeetingMapper bizMeetingMapper;
@Autowired
private BizMeetingInvoiceMapper bizMeetingInvoiceMapper;
@Autowired
private OssZipService ossZipService;
@Autowired
private IBizMeetingAttendeeService attendeeService;
@@ -162,11 +165,28 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
}
}
// 被移除的 subType (旧有、新无) 的发票一并删除, 否则发票表残留孤儿发票行 (material_id 指向已删材料)
Set<String> newSubTypes = new HashSet<>();
if (list != null) {
for (BizMeetingMaterial m : list) {
if (m.getSubType() != null) newSubTypes.add(m.getSubType());
}
}
if (oldList != null) {
for (BizMeetingMaterial o : oldList) {
if (o.getSubType() != null && o.getId() != null && !newSubTypes.contains(o.getSubType())) {
bizMeetingInvoiceMapper.deleteByMaterialId(o.getId());
}
}
}
bizMeetingMaterialMapper.deleteByMeetingId(meetingId);
if (list == null || list.isEmpty()) {
return list;
}
// 不接受前端传的 id, 走 AUTO_INCREMENT; meetingId 兜底由路径提供
// 未变材料的旧 id (subType -> 旧 material id), 全删全插后迁移其发票 material_id 到新 id
Map<String, Long> unchangedOldId = new HashMap<>();
for (BizMeetingMaterial m : list) {
m.setId(null);
m.setMeetingId(meetingId);
@@ -177,6 +197,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
m.setAmount(old.getAmount());
m.setFeeStatus(1);
m.setExtraOssUrl(old.getExtraOssUrl());
unchangedOldId.put(m.getSubType(), old.getId());
} else {
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算
m.setAmount(null);
@@ -199,6 +220,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
for (BizMeetingMaterial m : list) {
if (m.getSubType() != null) {
m.setId(idBySubType.get(m.getSubType()));
// 未变材料: 材料全删全插导致 id 变化, 迁移其发票 material_id 旧 id → 新 id
Long oldId = unchangedOldId.get(m.getSubType());
if (oldId != null && m.getId() != null && !m.getId().equals(oldId)) {
bizMeetingInvoiceMapper.updateInvoiceMaterialId(oldId, m.getId());
}
}
}
return list;
@@ -500,10 +526,74 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
byte[] excel = attendeeService.buildAttendeeInfoExcel(attendees);
ossZipService.putObject(prefix + folderName + "/参会人信息/参会人信息.xlsx", excel, XLSX_CONTENT_TYPE);
copied++;
// 补参会人照片/身份证照/劳务协议 (存 attendee 表, 材料表扫不到, 单独 copy 进劳务 zip)
copied += stageAttendeeFiles(attendees, prefix, folderName);
}
return copied;
}
/** 把参会人照片(on_site_photos)/身份证照(id_card_attachments)/劳务协议(labor_protocol) copy 进劳务 zip, 返回写入文件数 */
private int stageAttendeeFiles(List<BizMeetingAttendee> attendees, String prefix, String folderName)
{
int copied = 0;
for (int i = 0; i < attendees.size(); i++)
{
BizMeetingAttendee a = attendees.get(i);
// 序号 = 列表 1 起下标, 与前端表格 index 及"下载目录模板"的 {序号}_{姓名} 命名对齐
String person = (i + 1) + "_" + safeName(a.getName());
copied += copyCsvUrls(a.getOnSitePhotos(), prefix + folderName + "/参会人照片/" + person + "/", "照片");
copied += copyIdCardFiles(a.getIdCardAttachments(), prefix + folderName + "/参会人身份证/" + person + "/");
copied += copySingleUrl(a.getLaborProtocol(), prefix + folderName + "/劳务协议/", person);
}
return copied;
}
/** 逗号分隔 OSS URL → 逐个 copy 到 dstDir, 文件名 = baseName-i{ext}; 返回 copy 数 */
private int copyCsvUrls(String csvUrls, String dstDir, String baseName)
{
if (csvUrls == null || csvUrls.trim().isEmpty()) return 0;
String[] urls = csvUrls.split(",");
int copied = 0;
for (int i = 0; i < urls.length; i++)
{
String srcKey = ossZipService.extractKey(urls[i].trim());
if (srcKey == null || srcKey.isEmpty()) continue;
String fileName = safeName(baseName) + "-" + (i + 1) + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
copied++;
}
return copied;
}
/** 身份证照 (正/反两张, 顺序固定 front,back) copy 到 dstDir, 文件名 身份证-正面/身份证-反面{ext}; 返回 copy 数 */
private int copyIdCardFiles(String csvUrls, String dstDir)
{
if (csvUrls == null || csvUrls.trim().isEmpty()) return 0;
String[] urls = csvUrls.split(",");
String[] labels = { "身份证-正面", "身份证-反面" };
int copied = 0;
for (int i = 0; i < urls.length && i < labels.length; i++)
{
String srcKey = ossZipService.extractKey(urls[i].trim());
if (srcKey == null || srcKey.isEmpty()) continue;
String fileName = labels[i] + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
copied++;
}
return copied;
}
/** 单个 OSS URL → copy 到 dstDir, 文件名 = baseName{ext}; 返回 copy 数 (0/1) */
private int copySingleUrl(String url, String dstDir, String baseName)
{
if (url == null || url.trim().isEmpty()) return 0;
String srcKey = ossZipService.extractKey(url.trim());
if (srcKey == null || srcKey.isEmpty()) return 0;
String fileName = safeName(baseName) + extOf(srcKey);
ossZipService.copyObject(srcKey, dstDir + fileName);
return 1;
}
/** 把会议日程海报 (biz_meeting.schedule_url) staging 到 zip 的"日程海报"目录, 返回写入的文件数 (0 = 无日程海报) */
private int stageSchedulePoster(BizMeeting meeting, String prefix, String folderName)
{
@@ -732,7 +822,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
}
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
if (ocrNeeded) {
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, isZip, oldMaterialId);
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId);
}
updated++;
}
@@ -329,7 +329,7 @@ public class BizSignServiceImpl implements BizSignService {
throw new ServiceException("手写签名不能为空");
}
String fullPdfUrl = pdfService.htmlToPdfFile(contentHtml, "labor/" + attendee.getMeetingId());
String maskedHtml = desensitizeHtml(contentHtml);
String maskedHtml = desensitizeHtml(contentHtml, attendee);
String maskedPdfUrl = pdfService.htmlToPdfFile(maskedHtml, "labor/" + attendee.getMeetingId() + "/masked");
BizMeetingAttendee update = new BizMeetingAttendee();
update.setId(attendeeId);
@@ -346,7 +346,7 @@ public class BizSignServiceImpl implements BizSignService {
return result;
}
private String desensitizeHtml(String html) {
private String desensitizeHtml(String html, BizMeetingAttendee attendee) {
if (html == null) return null;
java.util.regex.Pattern idCardP = java.util.regex.Pattern.compile("(?<!\\d)(\\d{17}[\\dXx])(?!\\d)");
java.util.regex.Matcher m = idCardP.matcher(html);
@@ -378,6 +378,42 @@ public class BizSignServiceImpl implements BizSignService {
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
return sb.toString();
html = sb.toString();
// sponsor 脱敏 PDF: 身份证附件是正反面照片无法文本打码, 直接移除 (不携带身份证)
if (attendee != null && attendee.getIdCardAttachments() != null && !attendee.getIdCardAttachments().isEmpty()) {
for (String u : attendee.getIdCardAttachments().split(",")) {
String url = u.trim();
if (url.isEmpty()) continue;
html = html.replace(
"<div style=\"margin:8px auto;text-align:center;\"><img src='" + url + "' style=\"max-width:240px;\"/></div>",
"");
}
}
// 姓名/账户名称: 中文文本无法按数字正则定位, 用已知原值精确替换 (长串优先, 避免姓名是账户名的子串时漏脱敏)
if (attendee != null) {
java.util.List<String> texts = new java.util.ArrayList<>();
if (attendee.getName() != null && !attendee.getName().trim().isEmpty()) texts.add(attendee.getName().trim());
if (attendee.getAccountName() != null && !attendee.getAccountName().trim().isEmpty()) texts.add(attendee.getAccountName().trim());
texts.sort((a, b) -> Integer.compare(b.length(), a.length()));
for (String t : texts) {
if (t.length() < 2) continue; // 单字姓名/账户名跳过, 避免误替换正文中的同字
String masked = maskName(t);
if (!masked.equals(t)) {
html = html.replace(t, masked);
}
}
}
return html;
}
/** 姓名脱敏: 保留首字, 其余打码 (张三 → 张*) */
private String maskName(String v) {
if (v == null) return null;
String s = v.trim();
if (s.isEmpty()) return v;
if (s.length() <= 1) return "*";
return s.charAt(0) + "*".repeat(s.length() - 1);
}
}
@@ -74,13 +74,13 @@ public class InvoiceOcrService
* @param materialId biz_meeting_material.id
* @param meetingId 会议 ID
* @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包)
* @param isZip true → ZIP 路径;false → 单文件路径
* @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0;
* null → 新增场景, 不做清理
* @return 提交摘要 (立即返回, 不等 OCR 完成)
* <p>是否 zip 不再由前端传, 后端按 ossUrl 后缀 (转小写) 判断, 避免前后端不一致.
*/
public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl,
boolean isZip, Long oldMaterialId)
Long oldMaterialId)
{
RecognizeResult out = new RecognizeResult();
if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty())
@@ -89,6 +89,11 @@ public class InvoiceOcrService
out.setErrorMsg("参数缺失");
return out;
}
// 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参
boolean isZip = ossUrl.toLowerCase().endsWith(".zip");
// 提前取登录用户 ID: OCR 在后台线程池跑, 无 SecurityContext, 必须在此 (HTTP 线程) 取好
final Long creatorId = SecurityUtils.getUserId();
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
if (oldMaterialId != null)
@@ -107,7 +112,7 @@ public class InvoiceOcrService
placeholder.setOssUrl(ossUrl);
placeholder.setInvoiceType("SUB");
placeholder.setRecognizeStatus("UNRECOGNIZED");
placeholder.setCreatorId(SecurityUtils.getUserId());
placeholder.setCreatorId(creatorId);
placeholder.setCreateTime(new Date());
placeholder.setUpdateTime(new Date());
invoiceMapper.insert(placeholder);
@@ -121,7 +126,7 @@ public class InvoiceOcrService
{
if (isZip)
{
recognizeZip(materialId, meetingId, url);
recognizeZip(materialId, meetingId, url, creatorId);
}
else
{
@@ -172,7 +177,8 @@ public class InvoiceOcrService
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
invoiceMapper.updateStatusByMaterial(materialId, "RECOGNIZED", null);
invoiceMapper.updateAmountByMaterial(materialId, amount);
invoiceMapper.updateOcrResultByMaterial(materialId, amount,
ir.getFields().getInvoiceNo(), ir.getFields().getInvoiceDate());
materialService.updateAmount(materialId, amount);
log.info("单文件识别成功: materialId={} amount={} elapsed={}ms",
materialId, amount, ir.getElapsedMs());
@@ -196,7 +202,7 @@ public class InvoiceOcrService
* <p>
* material 表只动一次 (末尾的 updateAmount), 即使 0 张发票也照写; 非发票文件不进任何统计
*/
void recognizeZip(Long materialId, Long meetingId, String zipUrl) throws Exception
void recognizeZip(Long materialId, Long meetingId, String zipUrl, Long creatorId) throws Exception
{
File tmpRoot = null;
try
@@ -239,9 +245,11 @@ public class InvoiceOcrService
inv.setOssUrl(newUrl);
inv.setInvoiceType("SUB");
inv.setAmount(amount);
inv.setInvoiceNo(ir.getFields().getInvoiceNo());
inv.setInvoiceDate(ir.getFields().getInvoiceDate());
inv.setRecognizeStatus("RECOGNIZED");
inv.setSourceFilename(f.getName());
inv.setCreatorId(SecurityUtils.getUserId());
inv.setCreatorId(creatorId);
inv.setCreateTime(new Date());
inv.setUpdateTime(new Date());
invoiceMapper.insert(inv);
@@ -287,7 +295,8 @@ public class InvoiceOcrService
BigDecimal amount = ir.getFields().getAmount() != null
? BigDecimal.valueOf(ir.getFields().getAmount())
: BigDecimal.ZERO;
invoiceMapper.updateStatusAndAmountByPrimaryKey(inv.getId(), "RECOGNIZED", amount, null);
invoiceMapper.updateOcrResultByPrimaryKey(inv.getId(), amount,
ir.getFields().getInvoiceNo(), ir.getFields().getInvoiceDate());
materialService.updateAmount(inv.getMaterialId(), amount);
log.info("兜底识别成功: invoiceId={} amount={}", inv.getId(), amount);
}
@@ -45,7 +45,7 @@ public class AliyunSmsSender {
@Value("${ruoyi.sms.esignTemplate}")
private String esignTemplate;
@Value("${ruoyi.sms.esignBaseUrl:https://risingdoctor.com/hg}")
@Value("${ruoyi.sms.esignBaseUrl:https://hegui.bahim.org.cn}")
private String esignBaseUrl;
@Value("${ruoyi.sms.regionId:cn-hangzhou}")
@@ -15,11 +15,15 @@
<result property="recognizeStatus" column="recognize_status" />
<result property="errorMsg" column="error_msg" />
<result property="sourceFilename" column="source_filename" />
<result property="materialSubType" column="material_sub_type" />
<result property="materialFileName" column="material_file_name" />
<result property="invoiceNo" column="invoice_no" />
<result property="invoiceDate" column="invoice_date" />
</resultMap>
<sql id="selectFields">
select id, meeting_id, material_id, oss_url, invoice_type, amount, creator_id, create_time, update_time,
recognize_status, error_msg, source_filename
recognize_status, error_msg, source_filename, invoice_no, invoice_date
from biz_meeting_invoice
</sql>
@@ -45,9 +49,13 @@
</select>
<select id="selectByMeetingId" resultMap="BizMeetingInvoiceResult" parameterType="Long">
<include refid="selectFields"/>
where meeting_id = #{meetingId}
order by id desc
select i.id, i.meeting_id, i.material_id, i.oss_url, i.invoice_type, i.amount, i.creator_id, i.create_time, i.update_time,
i.recognize_status, i.error_msg, i.source_filename, i.invoice_no, i.invoice_date,
m.sub_type as material_sub_type, m.file_name as material_file_name
from biz_meeting_invoice i
inner join biz_meeting_material m on m.id = i.material_id
where i.meeting_id = #{meetingId}
order by i.id desc
</select>
<insert id="insert" parameterType="BizMeetingInvoice" useGeneratedKeys="true" keyProperty="id">
@@ -64,6 +72,8 @@
<if test="recognizeStatus != null and recognizeStatus != ''">recognize_status,</if>
<if test="errorMsg != null">error_msg,</if>
<if test="sourceFilename != null">source_filename,</if>
<if test="invoiceNo != null and invoiceNo != ''">invoice_no,</if>
<if test="invoiceDate != null and invoiceDate != ''">invoice_date,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="meetingId != null">#{meetingId},</if>
@@ -77,6 +87,8 @@
<if test="recognizeStatus != null and recognizeStatus != ''">#{recognizeStatus},</if>
<if test="errorMsg != null">#{errorMsg},</if>
<if test="sourceFilename != null">#{sourceFilename},</if>
<if test="invoiceNo != null and invoiceNo != ''">#{invoiceNo},</if>
<if test="invoiceDate != null and invoiceDate != ''">#{invoiceDate},</if>
</trim>
</insert>
@@ -150,4 +162,33 @@
where id = #{id}
</update>
<!-- 单文件 OCR 完成: 更新金额 + 发票号 + 开票日期 (按 material_id, 与状态更新分开调用) -->
<update id="updateOcrResultByMaterial">
update biz_meeting_invoice
set amount = #{amount},
invoice_no = #{invoiceNo},
invoice_date = #{invoiceDate},
update_time = now()
where material_id = #{materialId}
</update>
<!-- 兜底 OCR 完成: 更新状态(RECOGNIZED) + 金额 + 发票号 + 开票日期 (按主键) -->
<update id="updateOcrResultByPrimaryKey">
update biz_meeting_invoice
set recognize_status = 'RECOGNIZED',
amount = #{amount},
invoice_no = #{invoiceNo},
invoice_date = #{invoiceDate},
update_time = now()
where id = #{id}
</update>
<!-- 材料全删全插后, 未变材料的发票 material_id 迁移旧 id → 新 id -->
<update id="updateInvoiceMaterialId">
update biz_meeting_invoice
set material_id = #{newMaterialId},
update_time = now()
where material_id = #{oldMaterialId}
</update>
</mapper>
@@ -114,6 +114,9 @@
<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
)</if>
<if test="params.leaderUserId != null">and project_id in (
select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0
)</if>
</where>
order by meeting_id desc
</select>
@@ -134,6 +137,7 @@
and p2.is_finished = '1' and p2.open_status = 'N'
)
</if>
<if test="params.leaderUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0)</if>
</where>
group by current_stage
</select>
@@ -362,6 +362,7 @@
<where>
p.is_deleted = 0
<if test="params.createUserId != null">and create_user_id = #{params.createUserId}</if>
<if test="params.leaderUserId != null">and p.lead_user_id = #{params.leaderUserId}</if>
<if test="isPublished != null and isPublished != ''">and p.is_published = #{isPublished}</if>
<if test="params.projectIds != null and params.projectIds.size() > 0">
and project_id in
@@ -73,6 +73,9 @@ public class HttpUtils
log.info("sendGet - {}", urlNameString);
URL realUrl = new URL(urlNameString);
URLConnection connection = realUrl.openConnection();
// 连接/读超时: 防止外部接口不可达时无限挂起 (后台子任务会占死单线程调度器)
connection.setConnectTimeout(10000);
connection.setReadTimeout(30000);
connection.setRequestProperty("accept", "*/*");
connection.setRequestProperty("connection", "Keep-Alive");
connection.setRequestProperty("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
@@ -16,6 +16,7 @@ import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.web.filter.CorsFilter;
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
import com.ruoyi.framework.security.filter.LeaderReadOnlyFilter;
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
@@ -80,6 +81,8 @@ public class SecurityConfig
})
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
// leader 只读拦截: 在 JWT 鉴权之后 (SecurityContext 已就绪), 任何业务写操作前拦下
.addFilterAfter(new LeaderReadOnlyFilter(), JwtAuthenticationTokenFilter.class)
.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class)
.addFilterBefore(corsFilter, LogoutFilter.class)
.build();
@@ -0,0 +1,84 @@
package com.ruoyi.framework.security.filter;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.web.filter.OncePerRequestFilter;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
/**
* 「项目负责人」(leader) 角色只读拦截.
*
* <p>leader 是纯查看角色: 除了查看自己的资料/改密码 (账号信息页), 不允许任何业务写操作.
* 后端兜底 (前端隐藏按钮之外, 防 devtools 直接调接口):
* <ul>
* <li>GET 请求一律放行.</li>
* <li>非 GET (POST/PUT/DELETE) 仅放行本人资料维护: {@code /system/user/profile} 与
* {@code /system/user/profile/updatePwd} (SysProfileController 的 GET/PUT 与改密码).</li>
* <li>其余非 GET 一律 403.</li>
* </ul>
* 与 JwtAuthenticationTokenFilter 之后注册 (SecurityConfig), 依赖其已写入 SecurityContext.
*/
public class LeaderReadOnlyFilter extends OncePerRequestFilter
{
/** 放行的本人资料维护路径 (账号信息页) */
private static final String[] SELF_SERVICE_ALLOWED = {
"/system/user/profile",
"/system/user/profile/updatePwd",
};
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
if (HttpMethod.GET.matches(request.getMethod())
|| !isLeader()
|| isSelfService(request.getRequestURI()))
{
chain.doFilter(request, response);
return;
}
// leader 非 GET 且非本人资料维护 → 只读拒绝
response.setStatus(HttpStatus.FORBIDDEN);
ServletUtils.renderString(response, JSON.toJSONString(
AjaxResult.error(HttpStatus.FORBIDDEN, "项目负责人无操作权限")));
}
/** 当前登录人是否为 leader (role_type 单一可信源), 未登录/异常视为非 leader (放行交给后续鉴权) */
private boolean isLeader()
{
try
{
return "leader".equals(SecurityUtils.getLoginUser().getUser().getRoleType());
}
catch (Exception e)
{
return false;
}
}
/** 是否命中本人资料维护白名单 (账号信息页: 查看/修改资料 + 改密码) */
private boolean isSelfService(String uri)
{
if (StringUtils.isEmpty(uri))
{
return false;
}
for (String path : SELF_SERVICE_ALLOWED)
{
if (uri.equals(path) || uri.startsWith(path + "/"))
{
return true;
}
}
return false;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+2 -2
View File
@@ -66,9 +66,9 @@
import { useCamera } from '@/composables/useCamera'
const videoId = 'signin-video'
// A4 纸: 从顶边到底边的一竖条, 水平位置在 70%~85% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
// A4 纸: 从顶边到底边的一竖条, 水平位置在 60%~70% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
useCamera(videoId, { start: 0.7, end: 0.85, radius: 20 })
useCamera(videoId, { start: 0.6, end: 0.7, radius: 20 })
function goBack() {
uni.navigateBack()
+2 -2
View File
@@ -1,5 +1,5 @@
# 生产环境 API 基路径 (nginx 反向代理前缀)
VITE_APP_BASE_API = '/hg-api'
# 生产环境静态资源子路径 (nginx 部署在 /hg/)
VITE_BASE = '/hg/'
# 生产环境静态资源子路径 (nginx 部署在根路径 /)
VITE_BASE = '/'
+5
View File
@@ -0,0 +1,5 @@
# 测试环境 API 基路径 (nginx 反向代理前缀)
VITE_APP_BASE_API = '/hegui-api'
# 测试环境静态资源子路径 (nginx 部署在 /hegui/)
VITE_BASE = '/hegui/'
+3 -1
View File
@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build --mode production",
"build:prod": "vite build --mode production",
"build:test": "vite build --mode test",
"preview": "vite preview"
},
"dependencies": {
+2 -2
View File
@@ -20,8 +20,8 @@ export function listByRole(query) {
// 兼容旧名 (项目分配弹窗已不再使用, 保留避免破坏其它 import)
export const listExecutor = (query) => listByRole({ roleType: 'executor', ...query })
export const listSupporters = (query) => listByRole({ roleType: 'sponsor', ...query })
// 合规管理员 (sys_user.role_type='manager', 选项目负责人用)
export const listManagers = (query) => listByRole({ roleType: 'manager', ...query })
// 项目负责人 (sys_user.role_type='leader', 选项目负责人用)
export const listLeaders = (query) => listByRole({ roleType: 'leader', ...query })
// 支持方下拉选项 (JOIN biz_org + sys_user): 返回 [{ orgId, orgName, userName }, ...]
// 用于 manager 项目分配弹窗按公司名搜索 + 选公司
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 478 KiB

+14 -2
View File
@@ -27,14 +27,14 @@
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg></el-icon>
<div class="file-meta">
<a class="file-name" :href="modelValue" target="_blank" @click.stop>{{ fileName }}</a>
<a class="file-name" :href="openHref" target="_blank" @click.stop>{{ fileName }}</a>
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
</div>
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
<div v-if="showPreview" class="preview-box">
<el-image v-if="isImage" :src="modelValue" :preview-src-list="[modelValue]" :initial-index="0" fit="cover" class="preview-img" />
<a v-else :href="modelValue" target="_blank" class="preview-file" @click.stop>
<a v-else :href="openHref" target="_blank" class="preview-file" @click.stop>
<el-icon :size="28"><Document /></el-icon>
<span class="preview-text">{{ getFileType(modelValue) }}</span>
</a>
@@ -100,6 +100,18 @@ const isImage = computed(() => {
if (!props.modelValue) return false
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext.value)
})
// PDF: 点击不下载, 走 /common/oss/proxy 后端代理重写 Content-Disposition 为 inline, 新窗口内嵌预览
const isPdf = computed(() => ext.value.toLowerCase() === 'pdf')
// OSS 代理预览 (与 Preview.vue / PublicityDetail 同技术): bucket 默认 attachment, 直接打开会强制下载
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
}
// 文件名/预览框点击目标: PDF → 代理预览(新窗口), 其余 → 原 URL (图片 el-image 预览, 其他下载)
const openHref = computed(() => isPdf.value ? proxyUrl(props.modelValue) : props.modelValue)
// 文件类型标签 (PDF / DOC / XLS / ZIP 等)
const typeLabelMap = {
pdf: 'PDF', doc: 'DOC', docx: 'DOCX',
+11 -3
View File
@@ -138,7 +138,8 @@ const NOTICE_PATH = {
manager: '/manager/messages',
doctor: '/doctor/messages',
executor: '/executor/messages',
sponsor: '/sponsor/messages'
sponsor: '/sponsor/messages',
leader: '/leader/messages'
}
function goNotice() {
const p = NOTICE_PATH[role.value] || '/'
@@ -151,7 +152,8 @@ const HOME_PATH = {
manager: '/manager/workbench',
doctor: '/doctor/home',
executor: '/executor/overview',
sponsor: '/sponsor/home'
sponsor: '/sponsor/home',
leader: '/leader/home'
}
function goMyHome() {
router.push(HOME_PATH[role.value] || '/')
@@ -208,7 +210,7 @@ onBeforeUnmount(() => {
if (stopWatchRoute) stopWatchRoute()
})
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方' }
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方', leader: '项目负责人' }
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
const MENU = {
@@ -272,6 +274,12 @@ const MENU = {
{ path: '/sponsor/people', title: '人员管理', icon: User, requireMain: true },
{ path: '/sponsor/messages', title: '消息通知', icon: Bell },
{ path: '/sponsor/account', title: '账号信息', icon: Setting }
],
leader: [
{ path: '/leader/home', title: '首页', icon: House },
{ path: '/leader/projects', title: '项目列表', icon: Document },
{ path: '/leader/meetings', title: '会议列表', icon: Calendar },
{ path: '/leader/account', title: '账号信息', icon: Setting }
]
}
const menu = computed(() => (MENU[role.value] || []).filter(item =>
+13 -1
View File
@@ -153,6 +153,17 @@ const routes = [
{ path: 'messages', name: 'sponsor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } }
]
},
{ path: '/leader', component: AdminLayout, meta: { role: 'leader' }, children: [
{ path: '', redirect: { name: 'leader-home' } },
{ path: 'home', name: 'leader-home', component: () => import('@/views/leader/Home.vue'), meta: { title: '首页' } },
{ path: 'projects', name: 'leader-projects', component: () => import('@/views/manager/Projects.vue'), meta: { title: '项目列表' } },
{ path: 'projects/detail/:projectId', name: 'leader-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情', readonly: true } },
{ path: 'meetings', name: 'leader-meetings', component: () => import('@/views/meetings/Meetings.vue'), meta: { title: '会议列表' } },
{ path: 'meetings/view/:meetingId', name: 'leader-meetings-view', component: () => import('@/views/meetings/MeetingDetail.vue'), meta: { title: '会议详情', readonly: true } },
{ path: 'messages', name: 'leader-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
{ path: 'account', name: 'leader-account', component: () => import('@/views/leader/Account.vue'), meta: { title: '账号信息' } }
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
@@ -167,7 +178,8 @@ const ROLE_HOME = {
manager: '/manager/workbench',
doctor: '/doctor/home',
executor: '/executor/overview',
sponsor: '/sponsor/home'
sponsor: '/sponsor/home',
leader: '/leader/home'
}
router.beforeEach((to, from, next) => {
+20 -4
View File
@@ -85,10 +85,10 @@ function render(role, s, phase) {
if (phase === 1) return '执行中'
return '已执行'
case 2: // C0 合规审中
if (role === 'sponsor') return '已执行未传材料' // 只读
return '待审核'
case 3: // C1 支持方审中
if (role === 'manager') return '审核通过'
if (role === 'sponsor') return '待监察'
return '待审核'
default: // 4 = A 已通过
return '待结算'
@@ -142,7 +142,7 @@ export function deriveStage(role, row) {
else { track = 'service'; label = sl }
}
// 仅 待审核/审核通过/待整改/待结算 四类加前缀
if (label === '待审核' || label === '审核通过' || label === '待整改' || label === '待结算') {
if (label === '待审核' || label === '审核通过' || label === '待整改' || label === '待结算' || label === '待监察') {
return (track === 'labor' ? '劳务' : '会务') + label
}
return label
@@ -166,7 +166,7 @@ export function stageLabel(role, row) {
* 展示阶段名 → 颜色映射 (class + el-tag type), 与 render() 措辞一一对应.
* 颜色跟随「各角色看到的展示阶段」而非物理阶段, 避免文案与颜色错位
* (如 sponsor 看「待审核」却因物理阶段 RECTIFYING 显示红色).
* 规则: 待整改=红, 未执行=灰, 执行中/已执行/已执行未传材料=蓝, 待审核=橙, 通过/待结算/完结=绿.
* 规则: 待整改=红, 未执行=灰, 执行中/已执行=蓝, 待审核/待监察=橙, 通过/待结算/完结=绿.
*/
const STAGE_STYLE = {
'冻结中': { cls: 'frozen', tag: 'info' },
@@ -176,12 +176,14 @@ const STAGE_STYLE = {
'未执行': { cls: 'pending', tag: 'info' },
'执行中': { 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' },
'劳务待审核': { cls: 'reviewing', tag: 'warning' },
'会务待审核': { cls: 'reviewing', tag: 'warning' },
'劳务待监察': { cls: 'reviewing', tag: 'warning' },
'会务待监察': { cls: 'reviewing', tag: 'warning' },
'劳务审核通过': { cls: 'done', tag: 'success' },
'会务审核通过': { cls: 'done', tag: 'success' },
'劳务待整改': { cls: 'waiting', tag: 'danger' },
@@ -204,6 +206,20 @@ export function stageTag(role, row) {
return stageStyle(role, row).tag
}
/**
* 阶段标签悬停注释 (title): 「待结算」态区分劳务/会务.
* 结算为会议级 (两轨都 APPROVED 才进入), 但两笔费用各自独立: 已 APPROVED 的那一轨即「待结算」.
* 返回空串表示无注释, 前端据此不挂 title 属性, 避免空 tooltip.
*/
export function stageTitle(row) {
if (!row) return ''
if (isTrue(row.isSettled) || isTrue(row.isFinished) || isTrue(row.isFrozen)) return ''
const parts = []
if (laborState(row) === 4) parts.push('劳务费待结算')
if (serviceState(row) === 4) parts.push('会务费待结算')
return parts.join('、')
}
/**
* 物理状态机全量 10 值 (用于筛选下拉: label=中性名, value=physical code).
* 筛选按物理状态精确匹配, 显示层才按角色折叠, 避免多值 IN 复杂化.
+2 -2
View File
@@ -189,7 +189,7 @@ const userStore = useUserStore()
const ROLE_LABEL = {
admin: '后台管理员', manager: '合规人员',
doctor: '评审专家', executor: '执行人', sponsor: '支持方'
doctor: '评审专家', executor: '执行人', sponsor: '支持方', leader: '项目负责人'
}
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
@@ -299,7 +299,7 @@ const createForm = reactive({
accountType: 'MAIN', orgId: null, orgName: '', businessNature: '',
contactName: '', contactPhone: ''
})
const isSimpleRole = computed(() => createForm.roleType === 'admin' || createForm.roleType === 'manager')
const isSimpleRole = computed(() => createForm.roleType === 'admin' || createForm.roleType === 'manager' || createForm.roleType === 'leader')
const isUnitRole = computed(() => createForm.roleType === 'executor' || createForm.roleType === 'sponsor')
function openCreate() {
+7 -1
View File
@@ -345,6 +345,7 @@ const roleHome = {
doctor: '/doctor/home',
executor: '/executor/overview',
sponsor: '/sponsor/home',
leader: '/leader/home',
}
/** redirect 是否属于当前角色: 防止 doctor 拿到 manager 的 redirect 后跳过去被踢回 login ("现在评审专家登录后不跳转" 的根本原因) */
@@ -371,9 +372,14 @@ function confirmRegister() {
ElMessage.warning('请选择用户类别')
return
}
// 执行方(供应商)注册: 跳转到独立供应商注册系统, 不走本系统注册页
if (registerUserType.value === 'executor') {
showRoleModal.value = false
window.location.href = 'https://zbsuppliertest.guojustar.com/'
return
}
const routeMap = {
expert: '/register-expert',
executor: '/register-executor',
sponsor: '/register-sponsor'
}
const target = routeMap[registerUserType.value]
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="page-card leader-account">
<div class="breadcrumb">首页 / 账号信息</div>
<el-tabs v-model="tab">
<el-tab-pane label="修改资料" name="profile">
<el-form :model="form" label-width="100px" class="account-form">
<el-form-item label="姓名">
<el-input v-model="form.nickName" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phonenumber" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="saveProfile">保存</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="修改密码" name="password">
<el-form :model="pwd" label-width="100px" class="account-form">
<el-form-item label="旧密码">
<el-input v-model="pwd.oldPassword" type="password" show-password placeholder="请输入旧密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwd.newPassword" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input v-model="pwd.confirmPassword" type="password" show-password placeholder="请再次输入新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="changePassword">保存</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import request from '@/utils/request'
import { ElMessage } from 'element-plus'
const tab = ref('profile')
const loading = ref(false)
const form = reactive({ nickName: '', phonenumber: '', email: '' })
const pwd = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
async function load() {
try {
const { data } = await request({ url: '/system/user/profile', method: 'get' })
Object.assign(form, {
nickName: data?.nickName || '',
phonenumber: data?.phonenumber || '',
email: data?.email || ''
})
} catch (e) { console.warn(e) }
}
async function saveProfile() {
loading.value = true
try {
await request({ url: '/system/user/profile', method: 'put', data: { ...form } })
ElMessage.success('保存成功')
load()
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { loading.value = false }
}
async function changePassword() {
if (!pwd.oldPassword || !pwd.newPassword) return ElMessage.warning('请填写旧密码和新密码')
if (pwd.newPassword !== pwd.confirmPassword) return ElMessage.warning('两次输入的新密码不一致')
loading.value = true
try {
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: pwd.oldPassword, newPassword: pwd.newPassword } })
ElMessage.success('密码修改成功')
pwd.oldPassword = pwd.newPassword = pwd.confirmPassword = ''
} catch (e) {
ElMessage.error(e?.msg || '密码修改失败')
} finally { loading.value = false }
}
onMounted(load)
</script>
<style scoped>
.leader-account { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.account-form { max-width: 480px; }
@media (max-width: 768px) {
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<template>
<!--
项目负责人 (leader) 首页 只读视角
KPI: 项目总数量 / 已结算数量 / 已结题数量 / 未执行会议 / 已执行会议
数据口径与 sponsor/Home 平行: 项目走 list (后端按 leader lead_user_id 隔离), 会议走 stageStats (leader 分支)
-->
<div class="page-card leader-home">
<div class="breadcrumb">首页</div>
<!-- KPI: 5 个数字面板, 1 行展示 (跳转 + 自动应用筛选) -->
<div class="stats-grid stats-grid-5">
<router-link class="stat-card" to="/leader/projects">
<div class="stat-label">项目总数量</div>
<div class="stat-value">{{ stats.totalProjects }}</div>
<div class="stat-desc">查看全部项目</div>
</router-link>
<router-link class="stat-card" to="/leader/projects?isSettled=Y">
<div class="stat-label">已结算数量</div>
<div class="stat-value">{{ stats.settledProjects }}</div>
<div class="stat-desc">查看已结算项目</div>
</router-link>
<router-link class="stat-card" to="/leader/projects?isFinished=1">
<div class="stat-label">已结题数量</div>
<div class="stat-value">{{ stats.completedProjects }}</div>
<div class="stat-desc">查看已结题项目</div>
</router-link>
<router-link class="stat-card" to="/leader/meetings?currentStage=NOT_STARTED">
<div class="stat-label">未执行会议</div>
<div class="stat-value">{{ stats.pendingMeetings }}</div>
<div class="stat-desc">查看未执行会议</div>
</router-link>
<router-link class="stat-card" to="/leader/meetings?currentStageNotIn=NOT_STARTED,IN_PROGRESS">
<div class="stat-label">已执行会议</div>
<div class="stat-value">{{ stats.executedMeetings }}</div>
<div class="stat-desc">查看已执行会议</div>
</router-link>
</div>
<!-- 消息通知 (NoticeList 组件内部已含 SSE 实时刷新 + 详情 dialog + 全部已读) -->
<section class="section">
<h2 class="section-title">消息通知<router-link class="more" to="/leader/messages">更多 </router-link></h2>
<NoticeList :pageable="true" :show-header="false" />
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import request from '@/utils/request'
import NoticeList from '@/components/NoticeList.vue'
const stats = ref({
totalProjects: 0,
settledProjects: 0, // 项目级 isSettled=Y
completedProjects: 0, // 项目级 isFinished=1
pendingMeetings: 0, // 未执行 = NOT_STARTED
executedMeetings: 0 // 已执行 = 排除 NOT_STARTED/IN_PROGRESS 的其余 stage (含 FROZEN)
})
async function loadStats() {
try {
// 项目统计走 list: 后端 list 已按 leader 角色 lead_user_id=自己 隔离
const projectCount = async (extra = {}) => {
const { data } = await request.get('/business/project/list', { params: { pageNum: 1, pageSize: 1, ...extra } })
return data?.total || 0
}
stats.value.totalProjects = await projectCount()
stats.value.settledProjects = await projectCount({ isSettled: 'Y' })
stats.value.completedProjects = await projectCount({ isFinished: '1' })
// 会议统计走 /business/meeting/stageStats (后端已按 leader 隔离), 返回 { stage: cnt }
const ss = await request.get('/business/meeting/stageStats')
const stageMap = ss?.data || {}
let executed = 0, pending = 0
for (const [stage, cnt] of Object.entries(stageMap)) {
const n = Number(cnt) || 0
if (stage === 'NOT_STARTED') pending += n
else if (stage !== 'IN_PROGRESS') executed += n
}
stats.value.executedMeetings = executed
stats.value.pendingMeetings = pending
} catch (e) {
console.warn('loadStats failed', e)
}
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
/* 整页面板 (与 sponsor/Home 风格一致) */
.leader-home { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.stats-grid { display: grid; gap: 16px; margin-bottom: 16px; }
.stats-grid-5 { grid-template-columns: repeat(5, 1fr); }
.stat-card {
background: var(--brand-primary);
border: 1px solid var(--brand-primary);
border-radius: 8px;
padding: 24px;
cursor: pointer;
text-decoration: none;
color: #fff;
display: block;
transition: opacity .15s, transform .15s, box-shadow .2s;
box-shadow: 0 2px 8px rgba(30, 58, 138, 0.15);
}
.stat-card:hover { opacity: .92; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(30, 58, 138, 0.25); }
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.85); margin-bottom: 16px; }
.stat-value {
font-size: 32px;
font-weight: 700;
line-height: 1;
margin-bottom: 8px;
color: #fff;
}
.stat-desc { font-size: 12px; color: rgba(255, 255, 255, 0.7); }
.section {
background: #fff;
border-radius: 8px;
padding: 20px 24px;
border: 1px solid #f0f0f0;
}
.section-title {
font-size: 15px; font-weight: 600; color: #1a1a1a;
margin-bottom: 16px; padding-left: 10px;
border-left: 3px solid var(--brand-primary);
line-height: 1;
display: flex; justify-content: space-between;
}
.more { font-size: 12px; color: var(--brand-primary); text-decoration: none; cursor: pointer; }
.more:hover { text-decoration: underline; }
@media (max-width: 768px) {
.stats-grid-5 { grid-template-columns: repeat(2, 1fr) !important; }
.leader-home { padding: 12px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
.stat-card { padding: 16px 12px !important; min-height: 90px; }
.stat-label { font-size: 12px !important; margin-bottom: 8px !important; }
.stat-value { font-size: 24px !important; margin-bottom: 4px !important; }
.stat-desc { font-size: 11px !important; }
.section { padding: 14px 16px !important; }
.section-title { font-size: 14px !important; margin-bottom: 12px !important; }
}
@media (max-width: 480px) {
.stats-grid-5 { grid-template-columns: 1fr !important; }
}
</style>
+1 -13
View File
@@ -98,9 +98,7 @@
</el-radio-group>
</el-form-item>
<el-form-item v-if="auditForm.result === '2'" label="项目编号" prop="projectNo">
<el-select v-model="auditForm.projectNo" placeholder="请选择项目编号" filterable style="width:100%">
<el-option v-for="opt in projectNoOptions" :key="opt.projectNo" :label="opt.projectNo + (opt.projectName ? ' / ' + opt.projectName : '')" :value="opt.projectNo" />
</el-select>
<el-input v-model="auditForm.projectNo" placeholder="请输入项目编号" maxlength="100" />
</el-form-item>
<el-form-item label="审核意见">
<el-input v-model="auditForm.opinion" type="textarea" rows="3" placeholder="请填写审核意见 (可选)" />
@@ -157,15 +155,6 @@ async function loadPlanDirectionOptions() {
} catch { planDirectionOptions.value = [] }
}
// 项目编号下拉选项: [{projectNo, projectName}], 审核通过时选择用
const projectNoOptions = ref([])
async function loadProjectNoOptions() {
try {
const { data } = await request.get('/business/project/projectNoOptions')
projectNoOptions.value = (data && data.data) || data || []
} catch { projectNoOptions.value = [] }
}
const router = useRouter()
const q = ref({ planName: '', projectNo: '', planDirectionId: '', planCategory: '', status: '', isSettled: '', remark: '' })
const rows = ref([]); const selection = ref([])
@@ -230,7 +219,6 @@ function onAudit(row) {
auditForm.result = '2'
auditForm.projectNo = ''
auditForm.opinion = ''
loadProjectNoOptions()
auditOpen.value = true
}
async function submitAudit() {
+32 -27
View File
@@ -1,6 +1,6 @@
<template>
<div class="page-card">
<div class="breadcrumb">首页 / 项目管理</div>
<div class="breadcrumb">首页 / {{ isLeader ? '项目列表' : '项目管理' }}</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="支持单位:">
@@ -63,13 +63,13 @@
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button @click="doExport">导出</el-button>
<el-button @click="doExportRating">项目评价导出</el-button>
<el-button v-if="!isLeader" @click="doExport">导出</el-button>
<el-button v-if="!isLeader" @click="doExportRating">项目评价导出</el-button>
<el-button v-if="isManager" @click="doExportSignupExpert">已报名专家导出</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<div v-if="!isLeader" class="toolbar">
<el-button class="action-btn" @click="openNewProject">新建项目</el-button>
<el-button @click="openAssign">项目分配</el-button>
<el-button @click="openBatch('score')">批量评分</el-button>
@@ -87,7 +87,7 @@
:main-cols="['projectNo', 'projectName']"
@selection-change="onSel"
>
<el-table-column type="selection" width="44" />
<el-table-column v-if="!isLeader" type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" width="170" fixed="left" />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
@@ -139,27 +139,30 @@
</el-table-column>
<el-table-column prop="startTime" label="项目开始时间" width="170" align="center" :formatter="fmtDateTime" />
<el-table-column prop="endTime" label="项目结束时间" width="170" align="center" :formatter="fmtDateTime" />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column label="操作" :width="isLeader ? 80 : 180" fixed="right">
<template #default="{ row }"><div class="table-actions">
<div class="op-cell">
<el-link v-if="!isFinishedRow(row)" :underline="false" type="primary" @click="doCreateMeeting(row)">建会</el-link>
<el-link :underline="false" type="primary" :disabled="isFinishedRow(row)" @click="doAssign(row)">分配</el-link>
<el-dropdown trigger="hover" @command="(cmd) => onAction(cmd, row)">
<el-link :underline="false" type="primary" class="op-dropdown">
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
</el-link>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="activate">开通</el-dropdown-item>
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-link v-if="isLeader" :underline="false" type="primary" @click="viewDetail(row)">查看</el-link>
<template v-else>
<el-link v-if="!isFinishedRow(row)" :underline="false" type="primary" @click="doCreateMeeting(row)">建会</el-link>
<el-link :underline="false" type="primary" :disabled="isFinishedRow(row)" @click="doAssign(row)">分配</el-link>
<el-dropdown trigger="hover" @command="(cmd) => onAction(cmd, row)">
<el-link :underline="false" type="primary" class="op-dropdown">
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
</el-link>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="row.isFinished !== '1' && row.isFinished !== 1" command="close">结题</el-dropdown-item>
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="edit">编辑</el-dropdown-item>
<el-dropdown-item command="activate">开通</el-dropdown-item>
<el-dropdown-item command="delAnn">删除公告</el-dropdown-item>
<el-dropdown-item command="rate">执行单位评分</el-dropdown-item>
<el-dropdown-item v-if="canDelete" command="delete">删除项目</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</div></template>
</el-table-column>
@@ -394,6 +397,8 @@ function readQueryFromRoute() {
// 仅 manager 可见「已报名专家导出」 (前端 v-if, 后端 controller 再兜底 role 校验)
const isManager = computed(() => userStore.role === 'manager')
// 项目负责人(leader): 只读, 复用本列表页 (数据由后端 list 按 lead_user_id 隔离), 隐藏所有写操作
const isLeader = computed(() => userStore.role === 'leader')
// ========== 导出 (跟随当前筛选, 与 load() 同一口径) ==========
function buildExportParams() {
@@ -543,15 +548,15 @@ async function confirmSingleScore() {
}
}
function viewDetail(row) {
// admin 复用本列表页, 详情须跳到 /admin/projects/detail (否则被 beforeEach 角色守卫拦截)
const base = userStore.role === 'admin' ? '/admin' : '/manager'
// admin/leader 复用本列表页, 详情须跳到各自前缀 (否则被 beforeEach 角色守卫拦截)
const base = userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager'
router.push(`${base}/projects/detail/${row.projectId}`)
}
// 点击「总场次/已执行/未执行」列 → 跳对应角色的会议列表, 按项目 + 阶段筛选 (会议列表后端按 role 隔离数据权限)
// scope: 'all'(全部) | 'done'(已执行) | 'todo'(未执行)
function goMeetings(row, scope) {
const base = userStore.role === 'admin' ? '/admin' : '/manager'
const base = userStore.role === 'admin' ? '/admin' : userStore.role === 'leader' ? '/leader' : '/manager'
const q = { projectId: row.projectId }
if (scope === 'done') q.currentStageNotIn = 'NOT_STARTED,IN_PROGRESS'
if (scope === 'todo') q.currentStage = 'NOT_STARTED'
+16 -16
View File
@@ -23,8 +23,8 @@
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="项目负责人">
<el-select v-model="form.leadUserId" placeholder="请选择合规管理员" filterable clearable :filter-method="searchManagers" style="width:100%">
<el-option v-for="u in managerOptions" :key="u.userId"
<el-select v-model="form.leadUserId" placeholder="请选择项目负责人" filterable clearable :filter-method="searchLeaders" style="width:100%">
<el-option v-for="u in leaderOptions" :key="u.userId"
:label="`${u.nickName || u.userName}${u.nickName && u.userName ? ' (' + u.userName + ')' : ''}`" :value="u.userId" />
</el-select>
</el-form-item>
@@ -133,7 +133,7 @@
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
import { listExecutor, listManagers } from '@/api/system'
import { listExecutor, listLeaders } from '@/api/system'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import OssFileUploader from '@/components/OssFileUploader.vue'
@@ -155,13 +155,13 @@ function nowLocalDatetime() {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 项目负责人候选 (合规管理员, sys_user.role_type='manager')
const managerOptions = ref([])
// 项目负责人候选 (sys_user.role_type='leader')
const leaderOptions = ref([])
let _managerTimer = null
const form = reactive({
projectName: '', projectNo: '', projectForm: '',
// 项目负责人 (sys_user.role_type='manager' 合规管理员, 名称由后端 JOIN 查, 不缓存)
// 项目负责人 (sys_user.role_type='leader', 名称由后端 JOIN 查, 不缓存)
leadUserId: null,
// 是否招标项目 Y/N (默认 N)
isBidProject: 'N',
@@ -267,29 +267,29 @@ async function loadProject() {
}
}
// 加载项目负责人 (合规管理员, sys_user.role_type='manager')
async function loadManagers(query) {
// 加载项目负责人 (sys_user.role_type='leader')
async function loadLeaders(query) {
if (_managerTimer) clearTimeout(_managerTimer)
_managerTimer = setTimeout(async () => {
try {
const params = { pageNum: 1, pageSize: 50 }
if (query) params.userName = query
const r = await listManagers(params)
managerOptions.value = (r.data && r.data.rows) || r.rows || []
const r = await listLeaders(params)
leaderOptions.value = (r.data && r.data.rows) || r.rows || []
// 已选项不在结果里时按 userId 单独拉取并加首位, 避免 el-select 选中后 label 空白
if (form.leadUserId && !managerOptions.value.find(u => u.userId === form.leadUserId)) {
const r2 = await listManagers({ userId: form.leadUserId })
if (form.leadUserId && !leaderOptions.value.find(u => u.userId === form.leadUserId)) {
const r2 = await listLeaders({ userId: form.leadUserId })
const sel = (r2.data && r2.data.rows) || r2.rows || []
if (sel[0]) managerOptions.value = [sel[0], ...managerOptions.value]
if (sel[0]) leaderOptions.value = [sel[0], ...leaderOptions.value]
}
} catch (e) { managerOptions.value = [] }
} catch (e) { leaderOptions.value = [] }
}, 200)
}
function searchManagers(q) { loadManagers(q) }
function searchLeaders(q) { loadLeaders(q) }
onMounted(() => {
loadProject()
loadManagers('')
loadLeaders('')
})
function goBack() {
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
+155 -96
View File
@@ -79,7 +79,7 @@
让内容超出时容器内出现横向滚动条, 不影响外层 grid 布局.
-->
<div class="attendee-table-wrap">
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" show-summary :summary-method="attendeeSummary" @selection-change="onAttendeeSelectionChange" class="attendee-table" :row-class-name="attendeeRowClass">
<el-table :data="attendeeRows" v-loading="attendeeLoading" border size="small" style="width: 100%;" @selection-change="onAttendeeSelectionChange" class="attendee-table" :row-class-name="attendeeRowClass">
<el-table-column v-if="!isSponsor && !isReadonly" type="selection" width="42" />
<el-table-column type="index" label="序号" width="50" align="center" />
<el-table-column label="医生" min-width="90">
@@ -87,29 +87,17 @@
<span :style="(row.hasIntent === 1 || row.hasIntent === '1') ? {} : { color: '#f56c6c' }">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="联系电话" width="120">
<template #default="{ row }">{{ isSponsor ? maskPhone(row.phone) : row.phone }}</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" width="120" />
<el-table-column prop="workUnit" label="医院" min-width="140" show-overflow-tooltip />
<el-table-column prop="department" label="科室" min-width="90" show-overflow-tooltip />
<el-table-column prop="title" label="职称" min-width="90" show-overflow-tooltip />
<el-table-column prop="bankName" label="银行" min-width="100" show-overflow-tooltip />
<el-table-column label="银行卡号码" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ isSponsor ? maskBankCard(row.bankCard) : row.bankCard }}</template>
</el-table-column>
<el-table-column prop="bankCard" label="银行卡号码" min-width="160" show-overflow-tooltip />
<el-table-column prop="bankBranch" label="开户行" min-width="130" show-overflow-tooltip />
<el-table-column prop="accountName" label="账户名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="bankRegion" label="开户行地址" min-width="120" show-overflow-tooltip />
<el-table-column prop="bankAddress" label="银行详细地址" min-width="140" show-overflow-tooltip />
<el-table-column label="身份证号" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ isSponsor ? maskIdCard(row.idCard) : row.idCard }}</template>
</el-table-column>
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="idCard" label="身份证号" min-width="170" show-overflow-tooltip />
<el-table-column label="应发金额" width="110" align="right">
<template #default="{ row }">{{ row.feePreTax != null ? '¥ ' + Number(row.feePreTax).toFixed(2) : '-' }}</template>
</el-table-column>
@@ -124,6 +112,12 @@
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="120" show-overflow-tooltip />
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<el-link :underline="false" v-if="idCardFiles(row).length" type="primary" @click="openPreview(idCardFiles(row), '身份证')">查看</el-link>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<el-link :underline="false" v-if="protocolUrl(row)" type="primary" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-link>
@@ -136,12 +130,6 @@
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="签字" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.handsign" type="success" size="small">已签</el-tag>
<el-tag v-else type="info" size="small">未签</el-tag>
</template>
</el-table-column>
<el-table-column label="是否邀请" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.isInvited === 1 || row.isInvited === '1'" type="success" size="small">已邀请</el-tag>
@@ -178,6 +166,10 @@
</template>
</el-table>
</div>
<div class="labor-fee-summary">
<span class="fee-label">劳务费合计:</span>
<span class="fee-value">¥ {{ laborFeeTotal.toFixed(2) }}</span>
</div>
</div>
<div class="file-list" style="margin-top: 16px;">
@@ -230,9 +222,33 @@
</el-tab-pane>
<el-tab-pane label="会务材料" name="service">
<div class="file-list">
<!-- 发票表: 会议全部会务发票明细 (物料制作上方) -->
<div v-if="invoiceRows.length" class="invoice-table-wrap">
<div class="invoice-table-title">发票表</div>
<el-table :data="invoiceRows" size="small" border class="invoice-table">
<el-table-column label="材料类型" width="100">
<template #default="{ row }">{{ serviceLabel(row.materialSubType) }}</template>
</el-table-column>
<el-table-column label="文件名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ invoiceFileName(row) }}</template>
</el-table-column>
<el-table-column label="发票号" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.invoiceNo || '—' }}</template>
</el-table-column>
<el-table-column label="开票时间" width="110">
<template #default="{ row }">{{ row.invoiceDate || '—' }}</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.recognizeStatus === 'RECOGNIZED'" class="invoice-table-amount">¥ {{ Number(row.amount || 0).toFixed(2) }}</span>
<span v-else class="invoice-table-pending"></span>
</template>
</el-table-column>
</el-table>
</div>
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" accept=".pdf,.png,.jpg,.jpeg,.zip" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !serviceEditable" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
@@ -282,9 +298,9 @@
<el-button v-if="isManager && !isReadonly && laborC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'LABOR')">审核劳务</el-button>
<el-button v-if="isManager && !isReadonly && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'SERVICE')">审核会务</el-button>
<el-button v-if="isManager && !isReadonly && laborC0 && serviceC0" type="primary" @click="openAuditDialog('COMPLIANCE', 'ALL')">审核全部</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">审核劳务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">审核会务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">审核全部</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1" type="primary" @click="openAuditDialog('SUPERVISION', 'LABOR')">监察劳务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'SERVICE')">监察会务</el-button>
<el-button v-if="supervisorAuditor && !isReadonly && laborC1 && serviceC1" type="primary" @click="openAuditDialog('SUPERVISION', 'ALL')">监察全部</el-button>
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
@@ -385,65 +401,67 @@
<!-- 参会人 dialog (新增/编辑通用, 2 列布局) -->
<el-dialog v-model="attendeeDialog.show" :title="attendeeDialog.title" width="760px" append-to-body :close-on-click-modal="false" @closed="resetAttendeeForm">
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" label-width="100px" class="attendee-form-2col">
<el-form ref="attendeeFormRef" :model="attendeeDialog.form" :rules="attendeeRules" label-width="100px" class="attendee-form-2col">
<!-- Col 1: 基础档案 (8 字段) -->
<el-form-item label="联系方式" required>
<el-form-item label="联系方式" prop="phone">
<el-input v-model="attendeeDialog.form.phone" :disabled="attendeeDialog.editing" placeholder="11位手机号" maxlength="11">
<template v-if="!attendeeDialog.editing" #suffix>
<el-icon class="phone-lookup" title="按手机号查专家并回填" @click="lookupExpertByPhone"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="姓名">
<el-form-item label="姓名" prop="name">
<el-input v-model="attendeeDialog.form.name" placeholder="请输入姓名(未注册手机号将以此作为昵称建号)" />
</el-form-item>
<el-form-item label="医院">
<el-form-item label="医院" prop="workUnit">
<el-input v-model="attendeeDialog.form.workUnit" placeholder="请输入医院名称" />
</el-form-item>
<el-form-item label="身份证号">
<el-form-item label="身份证号" prop="idCard">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
</el-form-item>
<el-form-item label="身份证附件" style="grid-column: 1 / -1">
<el-form-item label="身份证附件" prop="idCardAttachments" style="grid-column: 1 / -1">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
</el-form-item>
<el-form-item label="科室">
<el-form-item label="科室" prop="department">
<doctor-dept-select v-model="attendeeDialog.form.department" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
</el-form-item>
<el-form-item label="职称">
<el-form-item label="职称" prop="title">
<doctor-title-select v-model="attendeeDialog.form.title" value-field="label" placeholder="输入关键词搜索" filterable style="width:100%" />
</el-form-item>
<el-form-item label="银行">
<el-form-item label="银行" prop="bankName">
<el-input v-model="attendeeDialog.form.bankName" placeholder="如: 中国工商银行" />
</el-form-item>
<el-form-item label="银行卡号码">
<el-form-item label="银行卡号码" prop="bankCard">
<el-input v-model="attendeeDialog.form.bankCard" placeholder="持卡人银行卡号" maxlength="25" />
</el-form-item>
<!-- Col 2: 财务 + 备注 (8 字段) -->
<el-form-item label="开户行">
<el-form-item label="开户行" prop="bankBranch">
<el-input v-model="attendeeDialog.form.bankBranch" placeholder="如: 北京东城支行" />
</el-form-item>
<el-form-item label="账户名称">
<el-form-item label="账户名称" prop="accountName">
<el-input v-model="attendeeDialog.form.accountName" placeholder="持卡人姓名" />
</el-form-item>
<el-form-item label="开户行地址">
<el-form-item label="开户行地址" prop="bankRegion">
<el-input v-model="attendeeDialog.form.bankRegion" placeholder="如: 北京市东城区" />
</el-form-item>
<el-form-item label="银行详细地址">
<el-form-item label="银行详细地址" prop="bankAddress">
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
</el-form-item>
<el-form-item label="角色" style="grid-column: 1 / -1">
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
<el-form-item label="角色" prop="laborForm" style="grid-column: 1 / -1">
<el-radio-group v-model="attendeeDialog.form.laborForm">
<el-radio v-for="o in projectRoleOptions" :key="o.value" :value="o.value">{{ o.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="应发金额">
<el-form-item label="应发金额" prop="feePreTax">
<el-input-number v-model="attendeeDialog.form.feePreTax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="个税税金">
<el-form-item label="个税税金" prop="tax">
<el-input-number v-model="attendeeDialog.form.tax" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="增值税附加">
<el-form-item label="增值税附加" prop="vatAndSurcharge">
<el-input-number v-model="attendeeDialog.form.vatAndSurcharge" :precision="2" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="实发金额">
<el-form-item label="实发金额" prop="fee">
<div :class="{ 'fee-warn': feeDeviates }" style="width:100%">
<el-input-number v-model="attendeeDialog.form.fee" :precision="2" :min="0" controls-position="right" style="width:100%" />
</div>
@@ -538,7 +556,6 @@ import ImportResultDialog from '@/components/ImportResultDialog.vue'
import Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
import { ElMessageBox } from 'element-plus'
import { Upload, ArrowDown, Search } from '@element-plus/icons-vue'
import { derivePhysicalStage } from '@/utils/meetingStage'
@@ -616,23 +633,6 @@ function fmtMoney(v) {
return (isNaN(n) ? 0 : n).toFixed(2)
}
function isOne(v) { return v === 1 || v === '1' || v === true }
// sponsor 脱敏展示 (对齐后端 desensitizeHtml 规则: 手机前3+后4 / 银行卡前4+后4 / 身份证前6+后4)
function maskPhone(v) {
const s = String(v ?? '')
if (!s) return ''
if (s.length === 11) return s.slice(0, 3) + '****' + s.slice(7)
return s.replace(/^(\d{3})\d+(\d{4})$/, '$1****$2')
}
function maskBankCard(v) {
const s = String(v ?? '')
if (!s) return ''
return s.length >= 8 ? s.slice(0, 4) + '****' + s.slice(-4) : s
}
function maskIdCard(v) {
const s = String(v ?? '')
if (!s) return ''
return s.length === 18 ? s.slice(0, 6) + '********' + s.slice(14) : s
}
/** sponsor 看脱敏 PDF, 其他角色看完整 PDF */
function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
@@ -930,16 +930,6 @@ const meetingFee = computed(() => {
return { total, useMain: false }
})
/** 参会人表格底部合计行: "医生"列显示"合计", "应发金额"列显示劳务费合计 */
function attendeeSummary({ columns }) {
const sums = columns.map(() => '')
const nameIdx = columns.findIndex(c => c.label === '医生')
const feeIdx = columns.findIndex(c => c.label === '应发金额')
if (nameIdx >= 0) sums[nameIdx] = '合计'
if (feeIdx >= 0) sums[feeIdx] = '¥ ' + laborFeeTotal.value.toFixed(2)
return sums
}
/** 已勾选的参会人 (批量邀请/推送共用) */
const selectedAttendees = ref([])
/** 推送电子签中 (批量工具栏按钮 loading) */
@@ -954,6 +944,13 @@ const inviteLoadingId = ref(null)
/** 项目级角色 (来自 biz_project.role_labor JSON 解析), 用于参会人 dialog 角色下拉 */
const projectRoles = ref([])
/** 参会人角色单选选项 (来自 projectRoles, 去掉"其他"逃生口) */
const projectRoleOptions = computed(() => {
return (projectRoles.value || [])
.filter(r => r && r.role && r.role !== '其他')
.map(r => ({ value: r.role, label: r.role }))
})
/** 参会人 dialog 状态 (新增/编辑共用) */
const attendeeDialog = ref({
show: false,
@@ -988,6 +985,28 @@ function emptyAttendeeForm() {
onSitePhotos: ''
}
}
/** 参会人表单必填规则 (除 摘要/现场照片 外全必填) */
const attendeeRules = {
phone: [{ required: true, message: '请填写联系方式', trigger: 'blur' }],
name: [{ required: true, message: '请填写姓名', trigger: 'blur' }],
workUnit: [{ required: true, message: '请填写医院', trigger: 'blur' }],
idCard: [{ required: true, message: '请填写身份证号', trigger: 'blur' }],
idCardAttachments: [{ required: true, message: '请上传身份证附件', trigger: 'change' }],
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
title: [{ required: true, message: '请选择职称', trigger: 'change' }],
bankName: [{ required: true, message: '请填写银行', trigger: 'blur' }],
bankCard: [{ required: true, message: '请填写银行卡号码', trigger: 'blur' }],
bankBranch: [{ required: true, message: '请填写开户行', trigger: 'blur' }],
accountName: [{ required: true, message: '请填写账户名称', trigger: 'blur' }],
bankRegion: [{ required: true, message: '请填写开户行地址', trigger: 'blur' }],
bankAddress: [{ required: true, message: '请填写银行详细地址', trigger: 'blur' }],
laborForm: [{ required: true, message: '请选择角色', trigger: 'change' }],
feePreTax: [{ required: true, message: '请填写应发金额', trigger: 'change' }],
tax: [{ required: true, message: '请填写个税税金', trigger: 'change' }],
vatAndSurcharge: [{ required: true, message: '请填写增值税附加', trigger: 'change' }],
fee: [{ required: true, message: '请填写实发金额', trigger: 'change' }]
}
function resetAttendeeForm() {
attendeeDialog.value = {
show: false, title: '', editing: false,
@@ -1059,7 +1078,13 @@ watch(
else if (f > 3360 && f <= 21000) tax = (0.16 * f) / 0.84
else if (f > 21000 && f <= 49500) tax = (0.24 * f - 2000) / 0.76
else if (f > 49500) tax = (0.32 * f - 7000) / 0.68
attendeeDialog.value.form.tax = Number(tax.toFixed(2))
tax = Number(tax.toFixed(2))
const vat = Number(((tax + f) * 0.0151).toFixed(2))
// 直接补全整链 (tax→vat→feePreTax), 不依赖下游 watch:
// 小金额 (fee<=800) 个税为 0, tax 赋值前后不变, 下游 watch 不触发会漏算 vat/feePreTax
attendeeDialog.value.form.tax = tax
attendeeDialog.value.form.vatAndSurcharge = vat
attendeeDialog.value.form.feePreTax = Number((f + tax + vat).toFixed(2))
}
)
@@ -1571,7 +1596,7 @@ async function load() {
row.value = (resp && (resp.data || resp)) || {}
scheduleUrl.value = row.value.scheduleUrl || ''
invitationUrl.value = row.value.invitationUrl || ''
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles()])
await Promise.all([loadMaterials(), loadStaff(), loadTrail(), loadAttendees(), loadProjectRoles(), loadInvoices()])
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
// 从列表「结算」按钮跳入 (带 ?settle=1): 自动弹出结算确认 (结算前需已上传付款凭证)
if (route.query.settle === '1' && canSettle.value) {
@@ -1643,15 +1668,14 @@ async function saveMaterials() {
if (!m || !m.id || !m.ossUrl) continue
if (!isRecognizable(m.ossUrl)) continue
if (NON_OCR_SUBTYPES.has(m.subType)) continue
const isZip = isZipUrl(m.ossUrl)
const old = preSaveState.get(m.subType)
if (!old) {
// 新增
toOcr.push({ materialId: m.id, ossUrl: m.ossUrl, isZip, oldMaterialId: null, subType: m.subType })
toOcr.push({ materialId: m.id, ossUrl: m.ossUrl, oldMaterialId: null, subType: m.subType })
} else if (old.ossUrl !== m.ossUrl) {
// 替换 (URL 变了 → 后端需先清旧 invoice + amount=0 再 OCR)
toOcr.push({
materialId: m.id, ossUrl: m.ossUrl, isZip,
materialId: m.id, ossUrl: m.ossUrl,
oldMaterialId: old.id, subType: m.subType
})
}
@@ -1662,9 +1686,13 @@ async function saveMaterials() {
submitOcrForMaterials(toOcr)
}
// 5. 1.5s 后重拉 materials, 让 amount 字段更新可见
// 5. 保存即刷新发票表: 后端 replaceByMeetingId 已同步删孤儿发票/迁移 material_id,
// 移除材料后立即可见减少 (doSave 走 refreshFee, onSubmitMaterials 走这里)
loadInvoices()
// 6. 1.5s 后重拉 materials + 发票表, 让 OCR 回写的 amount/发票号 更新可见
if (toOcr.length) {
setTimeout(() => loadMaterials(), 1500)
setTimeout(() => { loadMaterials(); loadInvoices() }, 1500)
}
return { saved, ocrCount: toOcr.length }
@@ -1770,12 +1798,7 @@ function isImageUrl(url) {
/** 判断是否可识别: 图片或 PDF */
function isRecognizable(url) {
return /\.(jpe?g|png|pdf)$/i.test(url || '')
}
/** 判断是否 zip (走 zip 路径: 解压 + 重传 OSS) */
function isZipUrl(url) {
return /\.zip(\?|$)/i.test(url || '')
return /\.(jpe?g|png|pdf|zip)$/i.test(url || '')
}
/**
@@ -1788,13 +1811,39 @@ function materialAmount(subType) {
return Number(m.amount)
}
// ===================== 发票表 (会务材料 tab 顶部展示会议全部会务发票明细) =====================
/** 会议下所有会务发票明细 (biz_meeting_invoice join material 带出 subType/文件名) */
const invoiceRows = ref([])
/** 会务材料 subType → 中文名 (ROW_CONFIG label) */
function serviceLabel(subType) {
const r = ROW_CONFIG.find(x => x.subType === subType)
return r ? r.label : (subType || '—')
}
/** 发票文件名: sourceFilename(zip) → materialFileName(单文件) → ossUrl 末段 → '—' */
function invoiceFileName(row) {
if (row.sourceFilename) return row.sourceFilename
if (row.materialFileName) return row.materialFileName
const seg = (((row.ossUrl || '').split('?')[0]).split('/').pop()) || ''
return seg || '—'
}
/** 拉取发票明细 (静默, 发票表非关键) */
async function loadInvoices() {
try {
const resp = await request.get(`/business/meeting/invoice/list`, { params: { meetingId: meetingId.value }, __silentError: true })
invoiceRows.value = Array.isArray(resp && resp.data) ? resp.data : []
} catch (e) { /* 忽略 */ }
}
/**
* 保存成功后, 对所有需要识别的 material 提交 OCR 任务 (并行 fire-and-forget).
* 后端 InvoiceOcrService.submitRecognition:
* - 单文件 (isZip=false): 后台 OCR → 是发票 → update invoice + 回写 material.amount
* 不是发票 → 删占位 invoice 行 + amount=0
* - zip (isZip=true): 下载解压 → 遍历 → 是发票 → 重传 OSS (新 URL) → 写 invoice 行
* 不是发票 → 跳过 (不入库)
* - 单文件: 后台 OCR → 是发票 → update invoice + 回写 material.amount
* 不是发票 → 删占位 invoice 行 + amount=0
* - zip (后端按 ossUrl 后缀判断): 下载解压 → 遍历 → 是发票 → 重传 OSS (新 URL) → 写 invoice 行
* 不是发票 → 跳过 (不入库)
* - 替换 (oldMaterialId != null): 先 DELETE invoice WHERE material_id=old + amount=0
*
* 前端仅做"提交", 不等结果; 识别状态由 InvoiceOcrScheduler 兜底
@@ -1806,7 +1855,6 @@ function submitOcrForMaterials(items) {
materialId: m.materialId,
meetingId: Number(meetingId.value),
ossUrl: m.ossUrl,
isZip: m.isZip, // v3 新增
oldMaterialId: m.oldMaterialId // v3 新增 (替换场景)
}, { __silentError: true }).then(r => {
const data = (r && r.data) || {}
@@ -1837,7 +1885,8 @@ async function onSubmitMaterials(types) {
}
busy.value.submitMaterial = true
try {
await saveMaterials()
// 保存全部未落库内容 (材料 + 付款凭证 + 海报/邀请函), 与普通「保存」按钮同款 doSave
await doSave()
// __silentError: 拦截器不自动 toast, 错误统一由下方 catch 处理 (避免 axios 拦截器和 catch 双 toast)
await request.post(`/business/meeting/${meetingId.value}/submit-material`, { types }, { __silentError: true })
ElMessage.success('材料已提交, 待合规审核')
@@ -1905,7 +1954,7 @@ function openAuditDialog(action, scope = 'ALL') {
auditDialog.value = {
show: true,
action,
title: isCompliance ? `合规审核 (材料 · ${scopeLabel})` : `监察审核 (材料 · ${scopeLabel})`,
title: isCompliance ? `合规审核 (材料 · ${scopeLabel})` : `监察意见 (材料 · ${scopeLabel})`,
opinion: '',
tracks,
saving: false
@@ -1995,6 +2044,7 @@ async function refreshFee() {
const m = (resp && (resp.data || resp)) || {}
row.value = { ...row.value, laborFee: m.laborFee, meetingFee: m.meetingFee, totalFee: m.totalFee, feeCalcStatus: m.feeCalcStatus }
} catch (e) { /* 拉取失败忽略, 下轮再试 */ }
loadInvoices()
if (row.value.feeCalcStatus === 0) scheduleFeePoll()
else stopFeePolling()
}
@@ -2050,6 +2100,7 @@ async function onServiceZipChange(ev) {
const n = (resp && resp.data != null) ? resp.data : 0
ElMessage.success(`已回填 ${n} 类会务材料`)
loadMaterials()
loadInvoices()
refreshFee()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '上传失败')
@@ -2106,12 +2157,18 @@ onBeforeUnmount(stopFeePolling)
.file-list { display: flex; flex-direction: column; gap: 12px; }
.file-row { display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px; align-items: center; padding: 4px 0; }
.file-label { font-size: 14px; color: #595959; text-align: right; line-height: 28px; }
.fee-summary { display: flex; align-items: center; gap: 10px; padding: 10px 0 0 110px; border-top: 1px dashed #e8e8e8; margin-top: 4px; }
.fee-summary { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 10px 0 0; border-top: 1px dashed #e8e8e8; margin-top: 4px; }
.labor-fee-summary { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 10px 0 0; border-top: 1px dashed #e8e8e8; margin-top: 8px; }
.fee-label { font-size: 14px; color: #595959; }
.fee-value { font-size: 16px; font-weight: 600; color: #f5222d; }.file-uploader { width: 100%; min-width: 0; }
.file-hint { grid-column: 2; font-size: 11px; color: #8c8c8c; line-height: 1.5; }
.agreement-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
.invoice-amount { font-size: 13px; font-weight: 600; color: #f56c6c; padding-left: 8px; white-space: nowrap; }
.invoice-amount { grid-column: 2; justify-self: end; font-size: 13px; font-weight: 600; color: #f56c6c; white-space: nowrap; }
.invoice-table-wrap { margin: 2px 0 8px; overflow-x: auto; }
.invoice-table-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }
.invoice-table { width: 100%; min-width: 640px; }
.invoice-table-amount { font-weight: 600; color: #f56c6c; }
.invoice-table-pending { color: #c0c4cc; }
.file-link { color: var(--brand-primary); text-decoration: none; font-size: 14px; }
.file-link:hover { text-decoration: underline; }
.file-link.inline-link { font-size: 13px; }
@@ -2321,6 +2378,8 @@ onBeforeUnmount(stopFeePolling)
color: var(--el-text-color-secondary) !important;
}
.file-hint { grid-column: 1 !important; padding-left: 0 !important; }
.invoice-amount { grid-column: 1 !important; }
.invoice-table-wrap { overflow-x: auto; }
/* fee-summary: 去掉 110px 左 padding */
.fee-summary {
+26 -11
View File
@@ -71,31 +71,44 @@
</el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }">
<span :class="['stage-tag', 'stage-' + stageClass(roleSegment, row)]">{{ stageLabel(roleSegment, row) }}</span>
<span :class="['stage-tag', 'stage-' + stageClass(roleSegment, row)]" :title="stageTitle(row) || undefined">{{ stageLabel(roleSegment, row) }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" :width="isRole('sponsor') ? 140 : 260" fixed="right" align="center">
<template #default="{ row }"><div class="table-actions">
<!-- 主操作: 查看 直链 (所有角色) -->
<el-link :underline="false" type="primary" @click="viewOnly(row)">查看</el-link>
<!-- 查看: manager 直链 (admin/sponsor ); manager 折叠到更多 -->
<el-link :underline="false" v-if="!isRole('manager')" type="primary" @click="viewOnly(row)">查看</el-link>
<!-- manager: 审核 直链 (合规审核, 与结算互斥) -->
<el-link :underline="false" v-if="isRole('manager') && isCompliancePending(row)" type="warning" @click="onAudit(row)">审核</el-link>
<el-link :underline="false" v-if="isRole('admin', 'manager')" type="primary" @click="viewDetail(row)">编辑材料</el-link>
<!-- 结算: 直链 (两轨均审核通过才出现; 审核互斥, 不增加宽度) -->
<!-- 编辑材料: admin 直链; manager 折叠到更多 -->
<el-link :underline="false" v-if="isRole('admin')" type="primary" @click="viewDetail(row)">编辑材料</el-link>
<!-- 结算: 直链 (admin/manager, 两轨均审核通过才出现; 审核互斥) -->
<el-link :underline="false" v-if="isRole('admin', 'manager') && canSettleRow(row)" type="warning" @click="onSettle(row)">结算</el-link>
<!-- 更多: admin/manager 视角 7 个次操作折叠 -->
<!-- manager: 会务下载 / 劳务下载 更多拿出为直链 -->
<el-link :underline="false" v-if="isRole('manager')" type="primary" @click="onDownloadService(row)">会务下载</el-link>
<el-link :underline="false" v-if="isRole('manager')" type="primary" @click="onDownloadLabor(row)">劳务下载</el-link>
<!-- 更多: admin/manager 次操作折叠 -->
<el-dropdown v-if="isRole('admin', 'manager')" trigger="hover" @command="(cmd) => onMoreAction(cmd, row)">
<el-link :underline="false" type="primary" class="op-dropdown">
更多<el-icon class="op-caret"><ArrowDown /></el-icon>
</el-link>
<template #dropdown>
<el-dropdown-menu>
<!-- manager: 查看 / 编辑材料 折叠进来 -->
<template v-if="isRole('manager')">
<el-dropdown-item command="view">查看</el-dropdown-item>
<el-dropdown-item command="editMaterial">编辑材料</el-dropdown-item>
</template>
<el-dropdown-item command="edit">修改</el-dropdown-item>
<el-dropdown-item command="copy">复制</el-dropdown-item>
<el-dropdown-item v-if="canFinishRow(row)" command="finish">完结</el-dropdown-item>
<el-dropdown-item v-if="isOneVal(row.isFrozen)" command="unfreeze">解冻</el-dropdown-item>
<el-dropdown-item command="downloadService">会务下载</el-dropdown-item>
<el-dropdown-item command="downloadLabor">劳务下载</el-dropdown-item>
<!-- admin: 会务下载 / 劳务下载 留在更多 -->
<template v-if="isRole('admin')">
<el-dropdown-item command="downloadService">会务下载</el-dropdown-item>
<el-dropdown-item command="downloadLabor">劳务下载</el-dropdown-item>
</template>
<el-dropdown-item divided command="delete">
<span style="color:#DC2626">删除</span>
</el-dropdown-item>
@@ -103,7 +116,7 @@
</template>
</el-dropdown>
<!-- sponsor 视角: 审核直链 (支持方审核, 不折叠到更多) -->
<el-link :underline="false" v-if="isRole('sponsor') && isSponsorPending(row)" type="warning" @click="onAudit(row)">审核</el-link>
<el-link :underline="false" v-if="isRole('sponsor') && isSponsorPending(row)" type="warning" @click="onAudit(row)">监察</el-link>
</div></template>
</el-table-column>
</GrTable>
@@ -152,7 +165,7 @@ import { useRouter, useRoute } from 'vue-router'
import { bizList, bizDelete } from '@/api/public'
import GrTable from '@/components/GrTable.vue'
import request from '@/utils/request'
import { stageLabel, STAGE_OPTIONS, stageClass } from '@/utils/meetingStage'
import { stageLabel, STAGE_OPTIONS, stageClass, stageTitle } from '@/utils/meetingStage'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
@@ -242,7 +255,9 @@ function viewOnly(row) {
}
// 操作列"更多"下拉分发: 把 cmd 映射到现有 handler
function onMoreAction(cmd, row) {
if (cmd === 'edit') onEdit(row)
if (cmd === 'view') viewOnly(row)
else if (cmd === 'editMaterial') viewDetail(row)
else if (cmd === 'edit') onEdit(row)
else if (cmd === 'copy') onCopy(row)
else if (cmd === 'settle') onSettle(row)
else if (cmd === 'finish') onFinish(row)
+48 -13
View File
@@ -8,12 +8,20 @@
</section>
</div>
<section class="overview-section">
<div class="overview-inner">
<div class="section-title overview-heading">年度项目概述</div>
<p class="overview-subtitle">ANNUAL PROGRAM OVERVIEW · 2026 ANNUAL PLANNING</p>
<p class="overview-paragraph">2026年北京整合医学学会年度项目规划以服务国家卫生健康事业发展大局为根本导向围绕医疗卫生事业发展中的重点领域和关键环节构建以学术建设为核心科研创新为驱动人才发展为基础机构赋能为延伸学术交流为纽带社会公益为责任政学协作为支撑组织治理为保障的年度工作框架持续促进医学领域跨学科跨机构跨区域协同推动优质学术资源创新成果和专业能力更好转化为临床实践和健康服务效能学会将以更加开放的视野汇聚行业智慧以更加专业的能力服务行业发展以更加规范的治理夯实发展根基为推动我国医疗卫生事业高质量发展和健康中国建设贡献整合医学力量</p>
</div>
</section>
<section class="specialty-section">
<div class="specialty-inner">
<div class="specialty-header">
<div class="specialty-header-text">
<div class="section-title">八大专项计划</div>
<p class="specialty-subtitle">EIGHT SPECIALTY PROGRAMS · 围绕健康中国战略协同推进</p>
<p class="specialty-subtitle">EIGHT SPECIALTY PROGRAMS</p>
</div>
<span v-if="canPropose" class="cta-btn cta-btn--inline" @click="onSubmit">项目提案</span>
</div>
@@ -437,7 +445,7 @@ a { color: inherit; text-decoration: none; }
.specialty-header {
display: flex;
align-items: flex-end;
align-items: baseline;
justify-content: space-between;
margin-bottom: 40px;
}
@@ -617,17 +625,41 @@ a { color: inherit; text-decoration: none; }
/* ========== 总述 ========== */
.overview-section {
padding: 50px 0 30px;
padding: 70px 0 50px;
background: #ffffff;
}
.overview-text {
font-size: 14px;
color: #4b5563;
line-height: 1.9;
background: #fff;
padding: 24px 28px;
border: 1px solid #e5e7eb;
margin-bottom: 30px;
.overview-inner {
max-width: 1354px;
margin: 0 auto;
padding: 0 60px;
}
.overview-heading {
margin-bottom: 0;
padding-left: 0;
font-size: 30px;
font-weight: 800;
letter-spacing: 1.5px;
width: fit-content;
}
.overview-heading::before { display: none; }
.overview-subtitle {
font-size: 12px;
color: #6b7280;
letter-spacing: 2px;
margin: 8px 0 24px;
}
.overview-paragraph {
font-size: 16px;
color: #374151;
line-height: 1.95;
letter-spacing: 0.5px;
max-width: 1200px;
margin: 0;
text-indent: 2em;
}
.plan-image {
@@ -668,8 +700,11 @@ a { color: inherit; text-decoration: none; }
.home-hero-wrap { margin-top: -56px !important; }
/* 总述区 */
.overview-section { padding: 32px 0 20px !important; }
.overview-text { padding: 18px 16px !important; font-size: 13px !important; }
.overview-section { padding: 36px 0 24px !important; }
.overview-inner { padding: 0 16px !important; }
.overview-heading { font-size: 22px !important; }
.overview-subtitle { font-size: 11px !important; margin: 6px 0 16px !important; }
.overview-paragraph { font-size: 14px !important; line-height: 1.85 !important; }
.plan-image { padding: 16px !important; }
.section-title { font-size: 17px !important; margin-bottom: 16px !important; }
+15 -3
View File
@@ -537,9 +537,21 @@ async function doSubmitIntent(type, fields) {
}
}
// 查看招标公告: 暂跳百度占位 (后续替换为真实招标公告链接)
function onViewBidAnnouncement() {
window.open('https://www.baidu.com', '_blank')
// 查看招标公告: 后端按项目编号查外部招标平台 noticeId, 返回详情跳转 URL
async function onViewBidAnnouncement() {
const projectNo = ann.value?.projectNo
if (!projectNo) return ElMessage.warning('缺少项目编号')
try {
const res = await request({ url: '/business/public/biddingNotice', method: 'get', params: { projectNo } })
const url = res.data?.data?.detailUrl || res.data?.detailUrl
if (!url || typeof url !== 'string') {
ElMessage.warning('未查到该项目的招标公告')
return
}
window.open(url, '_blank')
} catch (e) {
ElMessage.error(e?.msg || '招标公告查询失败')
}
}
function onKeydown(e) { if (e.key === 'Escape') showQr.value = false }
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => {
// 开发 API 前缀 = /dev-api (Vite dev proxy), 生产 = /hg-api (nginx 反代)
const apiPath = env.VITE_APP_BASE_API || '/dev-api'
return {
// 生产部署在 nginx /hg/ 子路径下 (.env.production 设 VITE_BASE=/hg/), 开发默认 /
// 部署基路径: prod = / (.env.production), test = /hegui/ (.env.test), 开发默认 /
base: env.VITE_BASE || '/',
plugins: [
vue(),