diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java index 098c778..c022c4e 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java @@ -68,11 +68,6 @@ public class HrmAppropriationReqController extends BaseController { return R.ok(service.queryList(bo)); } - @GetMapping("/ocr-health") - public R ocrHealth() { - return R.ok(invoiceOcrService.isAlive()); - } - @PostMapping("/ocr-by-oss") public R ocrByOss(@RequestParam @NotNull Long ossId) { return R.ok(invoiceOcrService.recognizeByOssId(ossId)); diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java index 4fd45da..d7b5a36 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java @@ -68,11 +68,6 @@ public class HrmReimburseReqController extends BaseController { return R.ok(service.queryList(bo)); } - @GetMapping("/ocr-health") - public R ocrHealth() { - return R.ok(invoiceOcrService.isAlive()); - } - @PostMapping("/ocr-by-oss") public R ocrByOss(@RequestParam @NotNull Long ossId) { return R.ok(invoiceOcrService.recognizeByOssId(ossId)); diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/event/ApprovalRequestedEvent.java b/fad-hrm/src/main/java/com/ruoyi/hrm/event/ApprovalRequestedEvent.java new file mode 100644 index 0000000..f0ec76e --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/event/ApprovalRequestedEvent.java @@ -0,0 +1,23 @@ +package com.ruoyi.hrm.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 申请提交后产生的事件:通知监听方(如 IM 推送)给当前待办的审批人发提醒。 + * 由 fad-hrm 发布,ruoyi-oa 监听后调 ImSendService 推送。 + */ +@Getter +@AllArgsConstructor +public class ApprovalRequestedEvent { + /** 业务类型:seal / leave / travel / reimburse / appropriation */ + private final String bizType; + /** 业务主键 */ + private final Long bizId; + /** 流程实例ID */ + private final Long instId; + /** 待审批人 OA userId */ + private final Long assigneeUserId; + /** 申请发起人 OA userId */ + private final Long startUserId; +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java index bf25469..3d22198 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java @@ -3,17 +3,10 @@ package com.ruoyi.hrm.service; import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo; /** - * 发票OCR识别服务(调用Python OCR微服务) + * 发票识别服务:本地解析电子发票 PDF。 */ public interface IHrmInvoiceOcrService { - /** - * 通过ossId识别发票 - */ + /** 根据 ossId 解析发票 PDF */ HrmInvoiceOcrResultVo recognizeByOssId(Long ossId); - - /** - * 检查OCR服务是否存活 - */ - boolean isAlive(); } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmFlowInstanceServiceImpl.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmFlowInstanceServiceImpl.java index b0e443d..3bf8f8b 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmFlowInstanceServiceImpl.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmFlowInstanceServiceImpl.java @@ -17,9 +17,11 @@ import com.ruoyi.hrm.domain.bo.HrmFlowInstanceBo; import com.ruoyi.hrm.domain.vo.HrmFlowInstanceVo; import com.ruoyi.hrm.domain.vo.HrmFlowTaskVo; import com.ruoyi.hrm.domain.vo.HrmTravelReqVo; +import com.ruoyi.hrm.event.ApprovalRequestedEvent; import com.ruoyi.hrm.mapper.*; import com.ruoyi.hrm.service.IHrmFlowInstanceService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +42,7 @@ public class HrmFlowInstanceServiceImpl implements IHrmFlowInstanceService { private final UserService userService; private final HrmFlowCcMapper ccMapper; private final HrmTravelReqMapper travelReqMapper; + private final ApplicationEventPublisher eventPublisher; @Override public HrmFlowInstanceVo queryById(Long instId) { @@ -84,6 +87,11 @@ public class HrmFlowInstanceServiceImpl implements IHrmFlowInstanceService { task.setBizId(bo.getBizId()); taskMapper.insert(task); + // 发布事件 → ruoyi-oa 监听后推 IM 通知 + 快捷跳转 + eventPublisher.publishEvent(new ApprovalRequestedEvent( + bo.getBizType(), bo.getBizId(), inst.getInstId(), + bo.getManualAssigneeUserId(), bo.getStartUserId())); + return inst.getInstId(); } @@ -131,6 +139,11 @@ public class HrmFlowInstanceServiceImpl implements IHrmFlowInstanceService { task.setBizId(bo.getBizId()); taskMapper.insert(task); + // 发布事件 → ruoyi-oa 监听后推 IM 通知 + 快捷跳转 + eventPublisher.publishEvent(new ApprovalRequestedEvent( + bo.getBizType(), bo.getBizId(), inst.getInstId(), + assignees.get(0), bo.getStartUserId())); + return inst.getInstId(); } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java index 7d6c57c..fd1eba2 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java @@ -1,49 +1,61 @@ package com.ruoyi.hrm.service.impl; import cn.hutool.core.io.IoUtil; -import com.alibaba.fastjson2.JSON; -import com.alibaba.fastjson2.JSONArray; -import com.alibaba.fastjson2.JSONObject; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo; import com.ruoyi.hrm.service.IHrmInvoiceOcrService; import com.ruoyi.oss.factory.OssFactory; -import com.ruoyi.system.mapper.SysOssMapper; import com.ruoyi.system.domain.vo.SysOssVo; +import com.ruoyi.system.mapper.SysOssMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.*; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; +import java.io.ByteArrayInputStream; import java.io.InputStream; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** - * 发票OCR识别服务实现(调用Python OCR微服务) + * 发票识别服务实现:直接解析电子发票 PDF 文本,无外部模型依赖。 + * 仅支持 PDF(电子普通发票 / 电子专用发票 / 全电数电票)。 */ @Slf4j @RequiredArgsConstructor @Service public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService { - - - @Value("${fad.ocr.url}") - String ocrUrl; - - @Value("${fad.ocr.api-key}") - String apiKey; - private final SysOssMapper sysOssMapper; + /** "价税合计(小写)¥123.45" 这种小写金额 */ + private static final Pattern P_TOTAL = Pattern.compile( + "(?:价税合计|小写)[^0-9¥¥]{0,30}[¥¥]?\\s*([0-9,]+\\.[0-9]{2})"); + + /** 开票日期:2024年01月01日 或 2024-01-01 */ + private static final Pattern P_DATE = Pattern.compile( + "开票日期[:: ]*([0-9]{4}[年\\-/][0-9]{1,2}[月\\-/][0-9]{1,2}日?)"); + + /** 发票类型抬头 */ + private static final Pattern P_TYPE = Pattern.compile( + "(电子(?:普通)?发票|增值税电子(?:普通|专用)发票|电子发票([^)]+)|数电(?:普通)?发票|普通发票|专用发票)"); + + /** 销售方名称:兼顾 "销售方名称:xxx"、"销 售 方 名称:xxx"、新版"销售方信息名称:xxx" */ + private static final Pattern P_SELLER = Pattern.compile( + "销\\s*售\\s*方[^名]*名\\s*称[:: ]*([^\\n\\r]+?)(?=\\s{2,}|纳税人|统一社会|地址|开户|$)"); + + /** 明细行金额(行末两列:金额 税率% 税额 或 金额 税率% 价税合计) */ + private static final Pattern P_LINE_AMOUNT = Pattern.compile( + "([\\u4e00-\\u9fa5A-Za-z0-9()()\\-·.\\*\\s]{2,40}?)\\s+" + // 名称 + "([0-9,]+\\.[0-9]{2})\\s+" + // 金额(不含税) + "(\\d{1,2}%|免税|不征税|\\*)\\s+" + // 税率 + "([0-9,]+\\.[0-9]{2})"); // 税额 + @Override public HrmInvoiceOcrResultVo recognizeByOssId(Long ossId) { SysOssVo oss = sysOssMapper.selectVoById(ossId); @@ -51,6 +63,11 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService { throw new ServiceException("附件不存在: " + ossId); } + String suffix = StringUtils.defaultIfBlank(oss.getFileSuffix(), "").toLowerCase().replace(".", ""); + if (!"pdf".equals(suffix)) { + throw new ServiceException("仅支持 PDF 电子发票,当前文件类型: " + suffix); + } + byte[] fileBytes; try (InputStream in = OssFactory.instance().getObjectContent(oss.getUrl())) { fileBytes = IoUtil.readBytes(in); @@ -58,170 +75,101 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService { throw new ServiceException("读取附件失败: " + e.getMessage()); } - String fileName = StringUtils.defaultIfBlank(oss.getOriginalName(), oss.getFileName()); - return callOcrService(fileBytes, fileName, oss.getFileSuffix()); + return parsePdf(fileBytes); } - private HrmInvoiceOcrResultVo callOcrService(byte[] fileBytes, String fileName, String fileSuffix) { - - if (StringUtils.isBlank(ocrUrl)) { - throw new ServiceException("OCR服务地址未配置,请检查 fad.ocr.url"); - } - - // 推断 content-type - String suffix = StringUtils.defaultIfBlank(fileSuffix, "").toLowerCase().replace(".", ""); - String contentType; - switch (suffix) { - case "pdf": - contentType = "application/pdf"; - break; - case "png": - contentType = "image/png"; - break; - case "jpg": - case "jpeg": - contentType = "image/jpeg"; - break; - default: - contentType = "application/octet-stream"; - } - - // 构建 multipart 请求 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - if (StringUtils.isNotBlank(apiKey)) { - headers.set("X-API-Key", apiKey); - } - - final String finalContentType = contentType; - final String finalFileName = StringUtils.defaultIfBlank(fileName, "invoice" + "." + suffix); - ByteArrayResource fileResource = new ByteArrayResource(fileBytes) { - @Override - public String getFilename() { - return finalFileName; - } - }; - - HttpHeaders fileHeaders = new HttpHeaders(); - fileHeaders.setContentType(MediaType.parseMediaType(finalContentType)); - HttpEntity filePart = new HttpEntity<>(fileResource, fileHeaders); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", filePart); - - HttpEntity> requestEntity = new HttpEntity<>(body, headers); - - RestTemplate restTemplate = new RestTemplate(); - ResponseEntity response; - try { - response = restTemplate.postForEntity(ocrUrl + "/v1/invoice/ocr", requestEntity, String.class); + /** 直接从 PDF 中抽文本并按发票常见版面解析字段 */ + private HrmInvoiceOcrResultVo parsePdf(byte[] bytes) { + String text; + try (PDDocument doc = PDDocument.load(new ByteArrayInputStream(bytes))) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + stripper.setLineSeparator("\n"); + text = stripper.getText(doc); } catch (Exception e) { - log.error("[OCR] 调用OCR服务失败 url={} error={}", ocrUrl, e.getMessage()); - throw new ServiceException("OCR服务调用失败: " + e.getMessage()); + log.error("[Invoice] PDF 解析失败", e); + throw new ServiceException("PDF 解析失败: " + e.getMessage() + + "。若为扫描件,请提供电子发票原始 PDF。"); + } + if (StringUtils.isBlank(text)) { + throw new ServiceException("无法从 PDF 提取文本,可能为扫描件,请上传电子发票原始 PDF。"); } - if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { - throw new ServiceException("OCR服务返回异常: " + response.getStatusCode()); - } - - return parseOcrResponse(response.getBody()); - } - - private HrmInvoiceOcrResultVo parseOcrResponse(String responseBody) { HrmInvoiceOcrResultVo result = new HrmInvoiceOcrResultVo(); - try { - JSONObject root = JSON.parseObject(responseBody); - JSONObject data = root.getJSONObject("data"); - if (data == null) { - return result; - } + result.setInvoiceType(firstGroup(P_TYPE, text)); + result.setInvoiceDate(firstGroup(P_DATE, text)); + result.setSellerName(cleanSeller(firstGroup(P_SELLER, text))); + result.setTotalAmount(parseBigDecimal(firstGroup(P_TOTAL, text))); - result.setInvoiceType(getFieldValue(data, "invoice_type")); - result.setSellerName(getFieldValue(data, "seller_name")); - result.setInvoiceDate(getFieldValue(data, "invoice_date")); + List items = parseLineItems(text); - String totalAmountStr = getFieldValue(data, "amount_with_tax"); - if (StringUtils.isBlank(totalAmountStr)) { - totalAmountStr = getFieldValue(data, "total_amount"); - } - result.setTotalAmount(parseBigDecimal(totalAmountStr)); - - // 解析明细行 - List items = new ArrayList<>(); - JSONArray lineItems = data.getJSONArray("line_items"); - if (lineItems != null) { - for (int i = 0; i < lineItems.size(); i++) { - JSONObject li = lineItems.getJSONObject(i); - if (li == null) continue; - String name = getStringOrFieldValue(li, "project_name"); - if (StringUtils.isBlank(name)) continue; - - HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item(); - item.setItemName(name); - // 行项目金额取价税合计:税前金额 + 税额 - BigDecimal preAmt = parseBigDecimal(getStringOrFieldValue(li, "amount")); - BigDecimal taxAmt = parseBigDecimal(getStringOrFieldValue(li, "tax_amount")); - BigDecimal withTax = null; - if (preAmt != null || taxAmt != null) { - withTax = (preAmt != null ? preAmt : BigDecimal.ZERO) - .add(taxAmt != null ? taxAmt : BigDecimal.ZERO); - } - item.setAmount(withTax != null ? withTax : preAmt); - item.setTaxRate(getStringOrFieldValue(li, "tax_rate")); - items.add(item); - } - } - - // 如果没有明细行但有总金额,生成一个汇总条目 - if (items.isEmpty() && result.getTotalAmount() != null) { - HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item(); - String seller = StringUtils.defaultIfBlank(result.getSellerName(), "发票款项"); - item.setItemName(seller); - item.setAmount(result.getTotalAmount()); - items.add(item); - } - - result.setItems(items); - } catch (Exception e) { - log.warn("[OCR] 解析OCR响应失败: {}", e.getMessage()); + // 兜底:解析不到明细但有总价 → 生成一条汇总 + if (items.isEmpty() && result.getTotalAmount() != null) { + HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item(); + item.setItemName(StringUtils.defaultIfBlank(result.getSellerName(), "发票款项")); + item.setAmount(result.getTotalAmount()); + items.add(item); } + result.setItems(items); return result; } - /** 从 {value: "...", confidence: 0.9} 结构取值,或直接取字符串 */ - private String getFieldValue(JSONObject obj, String key) { - Object val = obj.get(key); - if (val == null) return null; - if (val instanceof JSONObject) { - return ((JSONObject) val).getString("value"); + /** 抽明细行:在"货物名称 … 合计"之间逐行匹配 */ + private List parseLineItems(String text) { + List items = new ArrayList<>(); + // 定位明细表区间,找不到也没关系,直接全文匹配也能跑 + int begin = indexOfAny(text, "项目名称", "货物或应税劳务", "货物名称"); + int end = indexOfAny(text, "合\\s*计", "价税合计", "(大写)", "(大写)"); + String area = (begin >= 0 && end > begin) ? text.substring(begin, end) : text; + + for (String line : area.split("\\n")) { + line = line.trim(); + if (line.length() < 6) continue; + Matcher m = P_LINE_AMOUNT.matcher(line); + if (!m.find()) continue; + HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item(); + String name = m.group(1).trim().replaceAll("^\\*[^*]+\\*", ""); // 去掉 *类别* 前缀 + BigDecimal preTax = parseBigDecimal(m.group(2)); + String rate = m.group(3); + BigDecimal tax = parseBigDecimal(m.group(4)); + BigDecimal withTax = (preTax != null ? preTax : BigDecimal.ZERO) + .add(tax != null ? tax : BigDecimal.ZERO); + item.setItemName(name); + item.setTaxRate(rate); + item.setAmount(withTax); + items.add(item); } - return val.toString(); + return items; } - @Override - public boolean isAlive() { - - if (StringUtils.isBlank(ocrUrl)) return false; - try { - RestTemplate restTemplate = new RestTemplate(); - ResponseEntity resp = restTemplate.getForEntity(ocrUrl + "/health", String.class); - return resp.getStatusCode().is2xxSuccessful(); - } catch (Exception e) { - log.warn("[OCR] 健康检查失败: {}", e.getMessage()); - return false; - } + private static String firstGroup(Pattern p, String s) { + if (s == null) return null; + Matcher m = p.matcher(s); + return m.find() ? m.group(1).trim() : null; } - private String getStringOrFieldValue(JSONObject obj, String key) { - Object val = obj.get(key); - if (val == null) return null; - if (val instanceof JSONObject) { - return ((JSONObject) val).getString("value"); + private static int indexOfAny(String text, String... patterns) { + int min = -1; + for (String p : patterns) { + Matcher m = Pattern.compile(p).matcher(text); + if (m.find()) { + int idx = m.start(); + if (min < 0 || idx < min) min = idx; + } } - return val.toString(); + return min; } - private BigDecimal parseBigDecimal(String raw) { + private static String cleanSeller(String s) { + if (s == null) return null; + // 去掉抬头里常见的"名称:"残留 + 末尾空白和半角空格序列 + s = s.replaceAll("^[::\\s]+", "").trim(); + // 截断到第一个非中文/字母/数字/常见公司符号块 + String[] tail = s.split("\\s{2,}"); + return tail.length > 0 ? tail[0].trim() : s; + } + + private static BigDecimal parseBigDecimal(String raw) { if (StringUtils.isBlank(raw)) return null; try { return new BigDecimal(raw.replace(",", "").replace("¥", "").replace("¥", "").trim()); @@ -229,4 +177,5 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService { return null; } } + } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmSealReqServiceImpl.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmSealReqServiceImpl.java index 27708d7..6051f92 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmSealReqServiceImpl.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmSealReqServiceImpl.java @@ -121,6 +121,9 @@ public class HrmSealReqServiceImpl implements IHrmSealReqService { return baseMapper.selectVoWithProjectList(bo); } + /** 用印申请固定审批人:陆永强(信息化部,userId=1858417253738815490) */ + private static final Long SEAL_FIXED_APPROVER_USER_ID = 1858417253738815490L; + @Override @Transactional(rollbackFor = Exception.class) public HrmSealReqVo insertByBo(HrmSealReqBo bo) { @@ -129,22 +132,18 @@ public class HrmSealReqServiceImpl implements IHrmSealReqService { validEntityBeforeSave(add); boolean ok = baseMapper.insert(add) > 0; - // 只要传入了 tplId 或 manualAssigneeUserId,就代表需要启动流程 - Long tplId = bo.getTplId() != null ? bo.getTplId() : bo.getFlowTplId(); - boolean shouldStartFlow = tplId != null || bo.getManualAssigneeUserId() != null; - HrmSealReqVo bean = BeanUtil.toBean(add, HrmSealReqVo.class); - if (ok && shouldStartFlow) { + if (ok) { + // 用印申请审批人写死成陆永强,忽略前端传的 tplId / manualAssigneeUserId HrmFlowStartBo start = new HrmFlowStartBo(); - start.setTplId(tplId); - start.setManualAssigneeUserId(bo.getManualAssigneeUserId()); + start.setTplId(null); + start.setManualAssigneeUserId(SEAL_FIXED_APPROVER_USER_ID); start.setBizType("seal"); start.setBizId(add.getBizId()); start.setStartUserId(LoginHelper.getUserId()); start.setContentJson(bo.getContentJson()); Long instId = flowInstanceService.startInstance(start); - // 更新状态为流转中 updateStatus(add.getBizId(), "running"); bean.setInstId(instId); } diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 73f1909..bd87293 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -327,9 +327,4 @@ fad: # 新增的前端 Web 端使用的 Key 和安全密钥 webKey: 34bf20d1db5b183558b9bb85d6eed783 securityKey: 6f9171724396deb5f8c42ef256b3cbc5 - ocr: - # 发票OCR服务地址(ai-ocr Python服务) - url: http://127.0.0.1:8810 - # OCR服务 API Key - api-key: change-me-debug-key diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaRequirementsBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaRequirementsBo.java index f821ac0..a3f67b8 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaRequirementsBo.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaRequirementsBo.java @@ -76,5 +76,9 @@ public class OaRequirementsBo extends BaseEntity { */ private String accessory; - + /** + * 状态多选筛选:逗号分隔的状态值,如 "0,1"。用于"未完成"等组合 tab。 + * 与 status 同时存在时优先生效。 + */ + private String statusIn; } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/HrmApprovalListener.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/HrmApprovalListener.java new file mode 100644 index 0000000..9ae6f83 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/HrmApprovalListener.java @@ -0,0 +1,79 @@ +package com.ruoyi.oa.im; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.hrm.event.ApprovalRequestedEvent; +import com.ruoyi.system.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.HashMap; +import java.util.Map; + +/** + * 申请提交事件监听:调 ImSendService 给当前审批人推 IM 通知, + * 同时根据 bizType 计算 web 跳转路径与 app 跳转路径。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HrmApprovalListener { + + private final ImSendService imSendService; + private final SysUserMapper userMapper; + + /** 业务类型 → web 详情页路径 */ + private static final Map WEB_ROUTE = new HashMap<>(); + /** 业务类型 → 中文标签 */ + private static final Map BIZ_LABEL = new HashMap<>(); + static { + WEB_ROUTE.put("seal", "/hrm/HrmSealDetail"); + WEB_ROUTE.put("leave", "/hrm/HrmLeaveDetail"); + WEB_ROUTE.put("travel", "/hrm/HrmTravelDetail"); + WEB_ROUTE.put("reimburse", "/hrm/HrmReimburseDetail"); + WEB_ROUTE.put("appropriation", "/hrm/HrmAppropriationDetail"); + + BIZ_LABEL.put("seal", "用印申请"); + BIZ_LABEL.put("leave", "请假申请"); + BIZ_LABEL.put("travel", "出差申请"); + BIZ_LABEL.put("reimburse", "报销申请"); + BIZ_LABEL.put("appropriation", "拨款申请"); + } + + /** 事务提交后再推 IM,避免业务回滚后还发出去 */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onApprovalRequested(ApprovalRequestedEvent ev) { + try { + if (ev.getAssigneeUserId() == null) { + log.debug("[Approval] no assignee, skip push. bizType={} bizId={}", ev.getBizType(), ev.getBizId()); + return; + } + String label = BIZ_LABEL.getOrDefault(ev.getBizType(), "审批"); + String starterName = "申请人"; + if (ev.getStartUserId() != null) { + SysUser u = userMapper.selectById(ev.getStartUserId()); + if (u != null) { + starterName = u.getNickName() != null ? u.getNickName() + : (u.getUserName() != null ? u.getUserName() : starterName); + } + } + + String title = "新的" + label; + String desc = String.format("[%s] 发起了%s,等待您的审批", starterName, label); + + String webBase = WEB_ROUTE.getOrDefault(ev.getBizType(), "/hrm/approval"); + String webRoute = webBase + "?bizId=" + ev.getBizId(); + String mobileRoute = "/pages/workbench/hrm/detail/detail?bizType=" + + ev.getBizType() + "&bizId=" + ev.getBizId(); + + imSendService.sendToOaUser( + ev.getAssigneeUserId(), title, desc, + ev.getBizType(), ev.getBizId(), + webRoute, mobileRoute); + } catch (Exception e) { + log.warn("[Approval] push IM failed: {}", e.getMessage()); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java index f93ee69..d242e1e 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/ImSendService.java @@ -66,6 +66,17 @@ public class ImSendService { @Async public void sendToOaUser(Long oaUserId, String title, String description, String bizType, Object bizId, String route) { + sendToOaUser(oaUserId, title, description, bizType, bizId, route, null); + } + + /** + * 同上,但允许给 Web 和 App 分别指定跳转路径。 + * @param webRoute Web 跳转路径(同 route 字段,前端 SDK 读 route) + * @param mobileRoute 手机端跳转路径(uniapp 页面路径),手机端读 mobileRoute + */ + @Async + public void sendToOaUser(Long oaUserId, String title, String description, + String bizType, Object bizId, String webRoute, String mobileRoute) { if (oaUserId == null) { return; } try { ImBind bind = bindMapper.selectById(oaUserId); @@ -78,7 +89,8 @@ public class ImSendService { Map payload = new HashMap<>(); payload.put("bizType", bizType); payload.put("bizId", bizId); - if (route != null) { payload.put("route", route); } + if (webRoute != null) { payload.put("route", webRoute); } + if (mobileRoute != null) { payload.put("mobileRoute", mobileRoute); } openImClient.sendCustomToUser(bind.getImUserId(), title, description, payload); } catch (Exception e) { diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java index 95fafba..dddbae0 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/im/OpenImClient.java @@ -29,6 +29,8 @@ public class OpenImClient { /** 自定义消息 contentType,OpenIM 约定 110 以上为自定义 */ public static final int CUSTOM_CONTENT_TYPE = 110; + /** 文本消息 contentType */ + public static final int TEXT_CONTENT_TYPE = 101; /** 会话类型:单聊 */ public static final int SESSION_SINGLE = 1; @@ -86,41 +88,37 @@ public class OpenImClient { log.debug("[OpenIM] disabled, skip send to {}", recvImUserId); return false; } - Map data = new HashMap<>(); - data.put("title", title); - data.put("description", description); - if (payload != null) { data.putAll(payload); } - - Map customElem = new HashMap<>(3); - customElem.put("data", JSON.toJSONString(data)); - customElem.put("description", description); - customElem.put("extension", ""); + // 业务元数据放在 ex 里(客户端可解析以路由) + Map ex = new HashMap<>(); + ex.put("title", title); + ex.put("description", description); + if (payload != null) { ex.putAll(payload); } + // 用 TextElem 让聊天软件直接展示 —— content.text 是聊天可见内容 + String visibleText = "【" + title + "】\n" + description; Map content = new HashMap<>(); - content.put("customElem", customElem); + content.put("content", visibleText); Map offlinePush = new HashMap<>(); offlinePush.put("title", title); offlinePush.put("desc", description); - offlinePush.put("ex", ""); + offlinePush.put("ex", JSON.toJSONString(ex)); offlinePush.put("iOSPushSound", "default"); offlinePush.put("iOSBadgeCount", true); - // SendMsg 字段需要嵌套在 sendMessage 对象里(OpenIM v3.8 约定) - Map sendMessage = new HashMap<>(); - sendMessage.put("sendID", props.getNotificationSender()); - sendMessage.put("recvID", recvImUserId); - sendMessage.put("senderNickname", "系统通知"); - sendMessage.put("senderPlatformID", 1); - sendMessage.put("content", content); - sendMessage.put("contentType", CUSTOM_CONTENT_TYPE); - sendMessage.put("sessionType", SESSION_SINGLE); - sendMessage.put("isOnlineOnly", false); - sendMessage.put("notOfflinePush", false); - sendMessage.put("offlinePushInfo", offlinePush); - + // OpenIM v3.8 send_msg:所有字段平铺在请求顶层 Map body = new HashMap<>(); - body.put("sendMessage", sendMessage); + body.put("sendID", props.getNotificationSender()); + body.put("recvID", recvImUserId); + body.put("senderNickname", "OA助手"); + body.put("senderPlatformID", 1); + body.put("content", content); + body.put("contentType", TEXT_CONTENT_TYPE); + body.put("sessionType", SESSION_SINGLE); + body.put("isOnlineOnly", false); + body.put("notOfflinePush", false); + body.put("offlinePushInfo", offlinePush); + body.put("ex", JSON.toJSONString(ex)); JSONObject resp = postJson(props.getApiUrl() + "/msg/send_msg", body, getAdminToken()); Integer errCode = resp.getInteger("errCode"); diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java index 98baf76..8c6a631 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaRequirementsServiceImpl.java @@ -18,6 +18,7 @@ import com.ruoyi.oa.domain.OaRequirements; import com.ruoyi.oa.mapper.OaRequirementsMapper; import com.ruoyi.oa.service.IOaRequirementsService; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Collection; @@ -71,7 +72,16 @@ public class OaRequirementsServiceImpl implements IOaRequirementsService { qw.eq(bo.getProjectId() != null, "r.project_id", bo.getProjectId()); qw.like(StringUtils.isNotBlank(bo.getDescription()), "r.description", bo.getDescription()); qw.eq(bo.getDeadline() != null, "r.deadline", bo.getDeadline()); - qw.eq(bo.getStatus() != null, "r.status", bo.getStatus()); + // statusIn 优先于 status,用于"未完成(0,1)"等组合 tab + if (StringUtils.isNotBlank(bo.getStatusIn())) { + List ins = new ArrayList<>(); + for (String s : bo.getStatusIn().split(",")) { + try { ins.add(Integer.parseInt(s.trim())); } catch (Exception ignored) {} + } + if (!ins.isEmpty()) qw.in("r.status", ins); + } else { + qw.eq(bo.getStatus() != null, "r.status", bo.getStatus()); + } qw.eq(StringUtils.isNotBlank(bo.getAccessory()), "r.accessory", bo.getAccessory()); qw.eq("r.del_flag", 0); //根据创建时间倒叙 diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/suggestion/UserSuggestionController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/suggestion/UserSuggestionController.java index bdb84db..f1e8a27 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/suggestion/UserSuggestionController.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/suggestion/UserSuggestionController.java @@ -73,11 +73,20 @@ public class UserSuggestionController extends BaseController { String title = "新的修改意见"; String desc = String.format("[%s] %s", name == null ? "用户" : name, body.getTitle() == null ? "未命名" : body.getTitle()); + int sent = 0; for (SysUser u : itUsers) { - if (u.getUserId() == null || u.getUserId().equals(uid)) continue; + if (u.getUserId() == null) continue; imSendService.sendToOaUser(u.getUserId(), title, desc, "suggestion", body.getFeedbackId(), "/system/feedback?id=" + body.getFeedbackId()); + sent++; + } + // 兜底:如果信息化部一个人都没有,发一份给自己确认链路 + if (sent == 0) { + imSendService.sendToOaUser(uid, title + "(测试)", + desc + " · 信息化部暂无 IM 绑定,先回发给提出者", + "suggestion", body.getFeedbackId(), + "/system/feedback?id=" + body.getFeedbackId()); } return R.ok(); } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/task/DailyReportRemindScheduler.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/DailyReportRemindScheduler.java new file mode 100644 index 0000000..b1c2a17 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/DailyReportRemindScheduler.java @@ -0,0 +1,94 @@ +package com.ruoyi.oa.task; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.oa.domain.OaReportSummary; +import com.ruoyi.oa.im.ImSendService; +import com.ruoyi.oa.mapper.OaReportSummaryMapper; +import com.ruoyi.system.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.Calendar; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 每日 18:00 扫描没报工的员工,通过 IM 推送提醒。 + * + * @author wangyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DailyReportRemindScheduler { + + /** Web 端跳转路径("我的报工") */ + private static final String WEB_ROUTE = "/hint/my"; + /** 手机端跳转路径(uniapp 报工页) */ + private static final String MOBILE_ROUTE = "/pages/workbench/reportWork/reportWork"; + + private final OaReportSummaryMapper summaryMapper; + private final SysUserMapper userMapper; + private final ImSendService imSendService; + + /** 工作日 18:00 触发 */ + @Scheduled(cron = "0 0 18 * * MON-FRI") + public void notifyMissingReporters() { + LocalDate today = LocalDate.now(); + if (today.getDayOfWeek() == DayOfWeek.SATURDAY || today.getDayOfWeek() == DayOfWeek.SUNDAY) { + return; + } + + // 今天报过工的人(按 reporter 名字去重) + Calendar c = Calendar.getInstance(); + c.set(Calendar.HOUR_OF_DAY, 0); c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); c.set(Calendar.MILLISECOND, 0); + Date start = c.getTime(); + c.add(Calendar.DAY_OF_MONTH, 1); + Date end = c.getTime(); + + List reportedToday = summaryMapper.selectList( + Wrappers.lambdaQuery() + .ge(OaReportSummary::getReportDate, start) + .lt(OaReportSummary::getReportDate, end) + .eq(OaReportSummary::getDelFlag, 0L)); + Set reportedReporters = new HashSet<>(); + for (OaReportSummary s : reportedToday) { + if (s.getReporter() != null) reportedReporters.add(s.getReporter().trim()); + } + + // 全部在职用户 + List users = userMapper.selectList(Wrappers.lambdaQuery() + .eq(SysUser::getDelFlag, "0") + .eq(SysUser::getStatus, "0")); + + SimpleDateFormat sdf = new SimpleDateFormat("MM-dd"); + String todayStr = sdf.format(new Date()); + int pushed = 0; + for (SysUser u : users) { + if (u.getUserId() == null) continue; + String key = u.getNickName() != null ? u.getNickName() : u.getUserName(); + if (key != null && reportedReporters.contains(key.trim())) continue; + + String title = "报工提醒"; + String desc = String.format("【%s】您今天(%s)还未提交日报,请尽快填写。点击立即报工。", + u.getNickName() == null ? u.getUserName() : u.getNickName(), todayStr); + + imSendService.sendToOaUser(u.getUserId(), title, desc, + "report", System.currentTimeMillis(), + WEB_ROUTE, MOBILE_ROUTE); + pushed++; + } + log.info("[DailyReportRemind] 已推送 {} 人未报工提醒(总员工 {},今日已报工 {})", + pushed, users.size(), reportedReporters.size()); + } +} diff --git a/ruoyi-ui/src/api/hrm/appropriation.js b/ruoyi-ui/src/api/hrm/appropriation.js index 111b859..52f66af 100644 --- a/ruoyi-ui/src/api/hrm/appropriation.js +++ b/ruoyi-ui/src/api/hrm/appropriation.js @@ -58,12 +58,8 @@ export function getAppropriationStats (query) { }) } -export function checkAppropriationOcrHealth () { - return request({ url: '/hrm/appropriation/ocr-health', method: 'get' }) -} - /** - * 通过ossId触发发票OCR识别(返回识别条目,不保存) + * 通过ossId触发发票解析(返回解析条目,不保存) */ export function ocrAppropriationInvoice (ossId) { return request({ diff --git a/ruoyi-ui/src/api/hrm/reimburse.js b/ruoyi-ui/src/api/hrm/reimburse.js index 4056016..534c431 100644 --- a/ruoyi-ui/src/api/hrm/reimburse.js +++ b/ruoyi-ui/src/api/hrm/reimburse.js @@ -47,12 +47,8 @@ export function allReimburseReq(query) { }) } -export function checkReimburseOcrHealth() { - return request({ url: '/hrm/reimburse/ocr-health', method: 'get' }) -} - /** - * 通过ossId触发发票OCR识别(返回识别条目,不保存) + * 通过ossId触发发票解析(返回解析条目,不保存) */ export function ocrReimburseInvoice(ossId) { return request({ diff --git a/ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue b/ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue index cbeb714..c306261 100644 --- a/ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue +++ b/ruoyi-ui/src/components/HomeModules/modules/ImChatPanel.vue @@ -34,18 +34,32 @@
-
+
{{ m.senderNickname || m.sendID }} · {{ formatTime(m.sendTime) }}
-
{{ renderText(m) }}
+ +
{{ renderText(m) }}
无消息
+ + + + 发送
+ + + + +
从左侧选择一个会话
@@ -68,18 +82,27 @@ export default { current: null, messages: [], draft: '', - myUserId: '' + myUserId: '', + previewVisible: false, + previewSrc: '' } }, created () { this.init() imBus.$on('new-message', this.onNewMessage) imBus.$on('conv-changed', this.refreshConversations) + imBus.$on('sync-done', this.refreshConversations) imBus.$on('disconnected', () => { this.errorMsg = 'IM 断线,尝试重连…'; this.canRetry = true }) + // 兜底定时刷新(防止 SDK 事件未触发) + this.pollTimer = setInterval(() => { + if (this.myUserId) this.refreshConversations() + }, 15000) }, beforeDestroy () { imBus.$off('new-message', this.onNewMessage) imBus.$off('conv-changed', this.refreshConversations) + imBus.$off('sync-done', this.refreshConversations) + if (this.pollTimer) clearInterval(this.pollTimer) }, methods: { async init () { @@ -132,6 +155,42 @@ export default { this.$message.error('发送失败:' + (e.message || e)) } }, + async onPickImage (event) { + const file = event.target.files && event.target.files[0] + event.target.value = '' + if (!file || !this.current) return + if (!/^image\//.test(file.type)) { + this.$message.warning('请选择图片文件') + return + } + try { + this.$message.info('正在上传图片…') + await im.sendImage(this.current, file) + const newMessages = await im.getMessages(this.current.conversationID, 30) + this.messages = newMessages + this.$nextTick(() => this.scrollBottom()) + } catch (e) { + this.$message.error('发送图片失败:' + (e && (e.errMsg || e.message) || e)) + } + }, + isImage (m) { + if (!m) return false + if (m.contentType === 102) return true + return !!(m.pictureElem && (m.pictureElem.sourcePicture || m.pictureElem.bigPicture)) + }, + imageUrl (m) { + const p = m && m.pictureElem + if (!p) return '' + const big = p.bigPicture || {} + const src = p.sourcePicture || {} + const snap = p.snapshotPicture || {} + return big.url || src.url || snap.url || '' + }, + previewImage (url) { + if (!url) return + this.previewSrc = url + this.previewVisible = true + }, onNewMessage (msg) { // 当前会话的消息追加 if (this.current && msg && this.current.conversationID === msg.conversationID) { @@ -265,6 +324,24 @@ export default { .msg-input { display: flex; gap: 4px; padding: 6px 4px; border-top: 1px solid #ebeef5; + align-items: center; } .msg-input .el-input { flex: 1; } +.bubble-image { padding: 4px !important; background: transparent !important; } +.msg-image { + display: block; + max-width: 220px; + max-height: 220px; + border-radius: 4px; + cursor: zoom-in; + background: #fff; + border: 1px solid #ebeef5; + object-fit: contain; +} +::v-deep .image-preview-dialog { + background: transparent; + box-shadow: none; + .el-dialog__body { padding: 0; text-align: center; } + .el-dialog__header { padding: 0; height: 32px; } +} diff --git a/ruoyi-ui/src/utils/imClient.js b/ruoyi-ui/src/utils/imClient.js index 4c390bb..65715be 100644 --- a/ruoyi-ui/src/utils/imClient.js +++ b/ruoyi-ui/src/utils/imClient.js @@ -28,25 +28,36 @@ class ImClient { this._wireEvents() } + const doLogin = async () => { + return this.sdk.login({ + userID: cred.imUserId, + token: cred.imToken, + platformID: Platform.Web, + apiAddr: cred.apiUrl, + wsAddr: cred.wsUrl + }) + } + this.loginPromise = (async () => { try { - console.log('[IM] login start', { userID: cred.imUserId, apiAddr: cred.apiUrl, wsAddr: cred.wsUrl, platformID: Platform.Web }) - const result = await this.sdk.login({ - userID: cred.imUserId, - token: cred.imToken, - platformID: Platform.Web, - apiAddr: cred.apiUrl, - wsAddr: cred.wsUrl - }) - console.log('[IM] login result', result) + try { + await doLogin() + } catch (e) { + // 10102 = 同平台已登录 → 自动踢掉旧 session 重试 + const code = e && (e.errCode || e.code) + if (code === 10102 || (e && /repeatedly/i.test(e.errMsg || e.message || ''))) { + try { await this.sdk.logout() } catch (_) {} + await new Promise(r => setTimeout(r, 1200)) + await doLogin() + } else { + throw e + } + } this.loggedIn = true this.userID = cred.imUserId imBus.$emit('logged-in', cred.imUserId) return true } catch (e) { - console.error('[IM] login failed FULL:', e) - console.error('[IM] error keys:', Object.keys(e || {})) - console.error('[IM] error JSON:', JSON.stringify(e)) this.lastError = e imBus.$emit('login-failed', e) return false @@ -66,18 +77,15 @@ class ImClient { } _wireEvents () { - this.sdk.on(CbEvents.OnRecvNewMessage, ({ data }) => { - imBus.$emit('new-message', data) - }) - this.sdk.on(CbEvents.OnConversationChanged, ({ data }) => { - imBus.$emit('conv-changed', data) - }) - this.sdk.on(CbEvents.OnNewConversation, ({ data }) => { - imBus.$emit('conv-changed', data) - }) - this.sdk.on(CbEvents.OnTotalUnreadMessageCountChanged, ({ data }) => { - imBus.$emit('total-unread', data) + this.sdk.on(CbEvents.OnRecvNewMessage, ({ data }) => imBus.$emit('new-message', data)) + this.sdk.on(CbEvents.OnRecvNewMessages, ({ data }) => { + const list = Array.isArray(data) ? data : [data] + list.forEach(m => imBus.$emit('new-message', m)) }) + this.sdk.on(CbEvents.OnConversationChanged, ({ data }) => imBus.$emit('conv-changed', data)) + this.sdk.on(CbEvents.OnNewConversation, ({ data }) => imBus.$emit('conv-changed', data)) + this.sdk.on(CbEvents.OnTotalUnreadMessageCountChanged, ({ data }) => imBus.$emit('total-unread', data)) + this.sdk.on(CbEvents.OnSyncServerFinish, () => imBus.$emit('sync-done')) this.sdk.on(CbEvents.OnConnectFailed, () => imBus.$emit('disconnected')) this.sdk.on(CbEvents.OnKickedOffline, () => imBus.$emit('kicked')) } @@ -112,6 +120,28 @@ class ImClient { }) } + // 发送图片消息 + async sendImage (conv, file) { + if (!this.loggedIn || !file) return null + const uuid = Date.now() + '_' + Math.random().toString(36).slice(2) + const pic = { + uuid, type: file.type || 'image/png', size: file.size, + width: 0, height: 0, url: '' + } + const { data: msg } = await this.sdk.createImageMessageByFile({ + file, + sourcePath: file.name, + sourcePicture: { ...pic }, + bigPicture: { ...pic }, + snapshotPicture: { ...pic } + }) + return this.sdk.sendMessage({ + recvID: conv.userID || '', + groupID: conv.groupID || '', + message: msg + }) + } + // 标记会话已读 async markRead (conversationID) { if (!this.loggedIn) return diff --git a/ruoyi-ui/src/views/hrm/minix/approverNameMixin.js b/ruoyi-ui/src/views/hrm/minix/approverNameMixin.js new file mode 100644 index 0000000..d019509 --- /dev/null +++ b/ruoyi-ui/src/views/hrm/minix/approverNameMixin.js @@ -0,0 +1,67 @@ +import { listUser } from '@/api/system/user' + +/** + * 流程预览中把 approverValue 里的 userId 翻译成 nickName。 + * 提供: + * - data.userNameMap : { userId(String): nickName } + * - methods.loadUserNameMap() : 拉一次用户列表填充 + * - methods.formatApproverValue(rule, raw) : 把 approverValue 渲染成可读文本 + * - methods.nodePreviewText(node, idx, total) : 整个节点行的文案 + */ +export default { + data () { + return { + userNameMap: {} + } + }, + methods: { + async loadUserNameMap () { + try { + const res = await listUser({ pageNum: 1, pageSize: 2000 }) + const rows = res.rows || res.data || [] + const map = {} + rows.forEach(u => { + const id = u.userId != null ? String(u.userId) : null + if (!id) return + map[id] = u.nickName || u.userName || ('用户' + id) + }) + this.userNameMap = map + } catch (e) { + // 拉不到就算了,原样显示 userId + this.userNameMap = {} + } + }, + + /** 把 approverValue(多为 JSON 数组或逗号串)按规则翻译成可读文本 */ + formatApproverValue (rule, raw) { + if (raw == null || raw === '') return '' + let arr = [] + if (Array.isArray(raw)) { + arr = raw + } else { + try { arr = JSON.parse(raw) } catch (e) { arr = String(raw).split(',') } + } + if (!Array.isArray(arr)) arr = [arr] + arr = arr.map(x => (x == null ? '' : String(x).trim())).filter(Boolean) + if (!arr.length) return '' + + // 仅对"指定人员"做 userId → nickName 翻译;其他规则按字面值展示 + if (rule === 'fixed_user') { + return arr.map(id => this.userNameMap[id] || ('用户' + id)).join('、') + } + return arr.join('、') + }, + + /** 节点文案:审批/抄送(指定人员:张三、李四) */ + nodePreviewText (n, idx, total) { + const typeMap = { approve: '审批', cc: '抄送' } + const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' } + const nodeType = typeMap[n.nodeType] || '节点' + const rule = ruleMap[n.approverRule] || '规则' + const detail = this.formatApproverValue(n.approverRule, n.approverValue) + const text = `${nodeType}(${rule}${detail ? ':' + detail : ''})` + const len = total != null ? total : (this.flowNodes ? this.flowNodes.length : 0) + return idx === len - 1 ? `${text} → 结束` : text + } + } +} diff --git a/ruoyi-ui/src/views/hrm/requests/_form-compact.scss b/ruoyi-ui/src/views/hrm/requests/_form-compact.scss new file mode 100644 index 0000000..4d7bb28 --- /dev/null +++ b/ruoyi-ui/src/views/hrm/requests/_form-compact.scss @@ -0,0 +1,100 @@ +/** + * 申请表单通用紧凑风格覆盖 + * 通过在每个 diff --git a/ruoyi-ui/src/views/hrm/requests/appropriation.vue b/ruoyi-ui/src/views/hrm/requests/appropriation.vue index 3021fe3..802fdaa 100644 --- a/ruoyi-ui/src/views/hrm/requests/appropriation.vue +++ b/ruoyi-ui/src/views/hrm/requests/appropriation.vue @@ -9,10 +9,6 @@ - - 发票识别服务已停止,请联系信息化部门。在服务恢复前,您暂时无法上传发票附件。 - -
发起拨款申请
@@ -57,27 +53,27 @@ - +
- {{ ocrAvailable === false ? '识别服务不可用,暂时无法上传' : '上传发票、收据、付款截图等(支持 PDF/图片,上传后自动识别明细)' }} + 仅支持 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票),上传后自动解析金额与明细。
+ 扫描件 / 图片票 / 纸质票请先在开票平台下载 PDF 原件再上传。
- 模型思考中,正在识别发票内容… + 正在解析发票 PDF…
拨款明细 - (上传发票后自动识别;识别失败或无发票时可手动添加) + (上传 PDF 后自动解析;解析失败或无发票时可手动添加) 手动添加条目
@@ -108,7 +104,7 @@ + accept=".pdf" class="row-upload">
@@ -266,15 +262,17 @@