推送项目重构代码
This commit is contained in:
@@ -68,11 +68,6 @@ public class HrmAppropriationReqController extends BaseController {
|
||||
return R.ok(service.queryList(bo));
|
||||
}
|
||||
|
||||
@GetMapping("/ocr-health")
|
||||
public R<Boolean> ocrHealth() {
|
||||
return R.ok(invoiceOcrService.isAlive());
|
||||
}
|
||||
|
||||
@PostMapping("/ocr-by-oss")
|
||||
public R<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
|
||||
return R.ok(invoiceOcrService.recognizeByOssId(ossId));
|
||||
|
||||
@@ -68,11 +68,6 @@ public class HrmReimburseReqController extends BaseController {
|
||||
return R.ok(service.queryList(bo));
|
||||
}
|
||||
|
||||
@GetMapping("/ocr-health")
|
||||
public R<Boolean> ocrHealth() {
|
||||
return R.ok(invoiceOcrService.isAlive());
|
||||
}
|
||||
|
||||
@PostMapping("/ocr-by-oss")
|
||||
public R<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
|
||||
return R.ok(invoiceOcrService.recognizeByOssId(ossId));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ByteArrayResource> filePart = new HttpEntity<>(fileResource, fileHeaders);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", filePart);
|
||||
|
||||
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
|
||||
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
ResponseEntity<String> 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<HrmInvoiceOcrResultVo.Item> items = parseLineItems(text);
|
||||
|
||||
String totalAmountStr = getFieldValue(data, "amount_with_tax");
|
||||
if (StringUtils.isBlank(totalAmountStr)) {
|
||||
totalAmountStr = getFieldValue(data, "total_amount");
|
||||
}
|
||||
result.setTotalAmount(parseBigDecimal(totalAmountStr));
|
||||
|
||||
// 解析明细行
|
||||
List<HrmInvoiceOcrResultVo.Item> 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.setItemName(StringUtils.defaultIfBlank(result.getSellerName(), "发票款项"));
|
||||
item.setAmount(result.getTotalAmount());
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
result.setItems(items);
|
||||
} catch (Exception e) {
|
||||
log.warn("[OCR] 解析OCR响应失败: {}", e.getMessage());
|
||||
}
|
||||
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<HrmInvoiceOcrResultVo.Item> parseLineItems(String text) {
|
||||
List<HrmInvoiceOcrResultVo.Item> 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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -76,5 +76,9 @@ public class OaRequirementsBo extends BaseEntity {
|
||||
*/
|
||||
private String accessory;
|
||||
|
||||
|
||||
/**
|
||||
* 状态多选筛选:逗号分隔的状态值,如 "0,1"。用于"未完成"等组合 tab。
|
||||
* 与 status 同时存在时优先生效。
|
||||
*/
|
||||
private String statusIn;
|
||||
}
|
||||
|
||||
@@ -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<String, String> WEB_ROUTE = new HashMap<>();
|
||||
/** 业务类型 → 中文标签 */
|
||||
private static final Map<String, String> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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) {
|
||||
|
||||
@@ -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<String, Object> data = new HashMap<>();
|
||||
data.put("title", title);
|
||||
data.put("description", description);
|
||||
if (payload != null) { data.putAll(payload); }
|
||||
|
||||
Map<String, Object> customElem = new HashMap<>(3);
|
||||
customElem.put("data", JSON.toJSONString(data));
|
||||
customElem.put("description", description);
|
||||
customElem.put("extension", "");
|
||||
// 业务元数据放在 ex 里(客户端可解析以路由)
|
||||
Map<String, Object> 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<String, Object> content = new HashMap<>();
|
||||
content.put("customElem", customElem);
|
||||
content.put("content", visibleText);
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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");
|
||||
|
||||
@@ -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());
|
||||
// statusIn 优先于 status,用于"未完成(0,1)"等组合 tab
|
||||
if (StringUtils.isNotBlank(bo.getStatusIn())) {
|
||||
List<Integer> 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);
|
||||
//根据创建时间倒叙
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<OaReportSummary> reportedToday = summaryMapper.selectList(
|
||||
Wrappers.<OaReportSummary>lambdaQuery()
|
||||
.ge(OaReportSummary::getReportDate, start)
|
||||
.lt(OaReportSummary::getReportDate, end)
|
||||
.eq(OaReportSummary::getDelFlag, 0L));
|
||||
Set<String> reportedReporters = new HashSet<>();
|
||||
for (OaReportSummary s : reportedToday) {
|
||||
if (s.getReporter() != null) reportedReporters.add(s.getReporter().trim());
|
||||
}
|
||||
|
||||
// 全部在职用户
|
||||
List<SysUser> users = userMapper.selectList(Wrappers.<SysUser>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());
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -34,18 +34,32 @@
|
||||
<div class="msg-list" ref="msgListRef">
|
||||
<div v-for="m in messages" :key="m.clientMsgID"
|
||||
class="msg-row" :class="{ mine: m.sendID === myUserId }">
|
||||
<div class="msg-bubble">
|
||||
<div class="msg-bubble" :class="{ 'bubble-image': isImage(m) }">
|
||||
<div class="msg-meta">{{ m.senderNickname || m.sendID }} · {{ formatTime(m.sendTime) }}</div>
|
||||
<div class="msg-text">{{ renderText(m) }}</div>
|
||||
<img v-if="isImage(m)" :src="imageUrl(m)" class="msg-image"
|
||||
@click="previewImage(imageUrl(m))" :alt="m.clientMsgID" />
|
||||
<div v-else class="msg-text">{{ renderText(m) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!messages.length" class="empty">无消息</div>
|
||||
</div>
|
||||
<div class="msg-input">
|
||||
<input ref="imgInput" type="file" accept="image/*" style="display:none"
|
||||
@change="onPickImage" />
|
||||
<el-tooltip content="发送图片" placement="top">
|
||||
<el-button size="mini" icon="el-icon-picture-outline" circle
|
||||
@click="$refs.imgInput.click()" />
|
||||
</el-tooltip>
|
||||
<el-input v-model="draft" size="mini" placeholder="输入消息,回车发送"
|
||||
@keyup.enter.native="send" />
|
||||
<el-button type="primary" size="mini" :disabled="!draft.trim()" @click="send">发送</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览大图 -->
|
||||
<el-dialog :visible.sync="previewVisible" :modal-append-to-body="true"
|
||||
append-to-body width="auto" custom-class="image-preview-dialog">
|
||||
<img :src="previewSrc" style="max-width: 80vw; max-height: 80vh;" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
<div v-else class="empty pick-conv">从左侧选择一个会话</div>
|
||||
</div>
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,25 +28,36 @@ class ImClient {
|
||||
this._wireEvents()
|
||||
}
|
||||
|
||||
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({
|
||||
const doLogin = async () => {
|
||||
return this.sdk.login({
|
||||
userID: cred.imUserId,
|
||||
token: cred.imToken,
|
||||
platformID: Platform.Web,
|
||||
apiAddr: cred.apiUrl,
|
||||
wsAddr: cred.wsUrl
|
||||
})
|
||||
console.log('[IM] login result', result)
|
||||
}
|
||||
|
||||
this.loginPromise = (async () => {
|
||||
try {
|
||||
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
|
||||
|
||||
67
ruoyi-ui/src/views/hrm/minix/approverNameMixin.js
Normal file
67
ruoyi-ui/src/views/hrm/minix/approverNameMixin.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
100
ruoyi-ui/src/views/hrm/requests/_form-compact.scss
Normal file
100
ruoyi-ui/src/views/hrm/requests/_form-compact.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 申请表单通用紧凑风格覆盖
|
||||
* 通过在每个 <style scoped> 末尾 @import 引入,靠后写入实现覆盖
|
||||
*/
|
||||
|
||||
.request-page {
|
||||
padding: 10px 14px 24px !important;
|
||||
background: #fff !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
border: 1px solid #ebeef5 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #fff !important;
|
||||
|
||||
::v-deep .el-card__header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
::v-deep .el-card__body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #303133 !important;
|
||||
}
|
||||
|
||||
/* 砍掉顶部的"发起 XX / 请完善信息..."大标题块 */
|
||||
.form-summary {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 12px 0 6px !important;
|
||||
padding-left: 6px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #303133 !important;
|
||||
border-left: 2px solid #409eff !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin-top: 2px !important;
|
||||
font-size: 11px !important;
|
||||
color: #909399 !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
.approve-mode,
|
||||
.approve-panel,
|
||||
.flow-preview {
|
||||
padding: 8px 10px !important;
|
||||
border: 1px solid #ebeef5 !important;
|
||||
border-radius: 4px !important;
|
||||
background: #fafafa !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.flow-sub {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
padding: 3px 8px !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.flow-step .txt {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 14px !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
/* el-form-item 紧凑化 */
|
||||
::v-deep .el-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
::v-deep .el-form-item__label {
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
::v-deep .el-form-item__content {
|
||||
line-height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,101 +1,110 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<aside>
|
||||
<!-- 发起申请模块 -->
|
||||
<section class="apply-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">发起申请</h3>
|
||||
<p class="section-desc">选择申请类型,快速发起新的申请流程</p>
|
||||
<div class="apply-page">
|
||||
<!-- 顶部:紧凑的申请入口 -->
|
||||
<div class="apply-bar">
|
||||
<span class="apply-bar__label">发起申请:</span>
|
||||
<el-button
|
||||
v-for="item in applyTypes"
|
||||
:key="item.key"
|
||||
size="mini"
|
||||
type="primary"
|
||||
plain
|
||||
icon="el-icon-plus"
|
||||
@click="goCreate(item.key)">{{ item.title }}</el-button>
|
||||
</div>
|
||||
<div class="apply-cards">
|
||||
<el-card v-for="item in applyTypes" :key="item.key" class="apply-card" shadow="hover"
|
||||
@click.native="goCreate(item.key)">
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-title">{{ item.title }}</div>
|
||||
<div class="card-desc">{{ item.desc }}</div>
|
||||
<!-- 我的申请:紧凑工具栏 + 表格 -->
|
||||
<div class="history-block">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="block-title">我的申请</span>
|
||||
<span class="block-meta">共 {{ historyTotal }} 条</span>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<el-button type="primary" size="small" icon="el-icon-plus">发起</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 申请历史模块 -->
|
||||
<section class="history-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">我的申请</h3>
|
||||
<div class="section-actions">
|
||||
<el-select v-model="historyQuery.type" size="small" placeholder="申请类型" clearable
|
||||
style="width: 120px; margin-right: 8px" @change="loadHistory">
|
||||
<el-option label="全部" value="" />
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="historyQuery.type" size="mini" placeholder="申请类型" clearable
|
||||
style="width: 110px;" @change="loadHistory">
|
||||
<el-option label="全部类型" value="" />
|
||||
<el-option label="请假" value="leave" />
|
||||
<el-option label="出差" value="travel" />
|
||||
<el-option label="用印" value="seal" />
|
||||
<el-option label="报销" value="reimburse" />
|
||||
<el-option label="拨款" value="appropriation" />
|
||||
</el-select>
|
||||
<el-select v-model="historyQuery.status" size="small" placeholder="状态" clearable
|
||||
style="width: 120px; margin-right: 8px" @change="loadHistory">
|
||||
<el-option label="全部" value="" />
|
||||
<el-select v-model="historyQuery.status" size="mini" placeholder="状态" clearable
|
||||
style="width: 100px;" @change="loadHistory">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="审批中" value="running" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已撤销" value="revoked" />
|
||||
</el-select>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="loadHistory">刷新</el-button>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadHistory">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="history-card" shadow="never">
|
||||
<el-table :data="historyList" v-loading="historyLoading" stripe @row-dblclick="handleRowClick">
|
||||
<el-table-column label="申请类型" min-width="100">
|
||||
<el-table
|
||||
:data="historyList"
|
||||
v-loading="historyLoading"
|
||||
size="mini"
|
||||
stripe
|
||||
border
|
||||
@row-dblclick="handleRowClick">
|
||||
<el-table-column label="编号" prop="instId" width="80" />
|
||||
<el-table-column label="类型" width="70">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="getTypeTagType(scope.row.bizType)">{{ getTypeText(scope.row.bizType) }}</el-tag>
|
||||
<el-tag :type="getTypeTagType(scope.row.bizType)" size="mini">{{ getTypeText(scope.row.bizType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型/目的" min-width="140">
|
||||
<el-table-column label="关键信息" min-width="220" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.remark" placeholder="标注备注方便自己查看"
|
||||
<span>{{ summarizeBiz(scope.row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="160">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.remark" size="mini" placeholder="给自己加个备注"
|
||||
@change="handleRemarkChange(scope.row)"></el-input>
|
||||
<!-- {{ getTypeDetail(scope.row) }} -->
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="开始时间" prop="startTime" min-width="160">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.startTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" prop="endTime" min-width="160">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.endTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长" min-width="100">
|
||||
<template slot-scope="scope">{{ formatDuration(scope.row) }}</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column label="状态" prop="status" min-width="110">
|
||||
<el-table-column label="当前节点" min-width="120" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">{{ statusText(scope.row.status) }}</el-tag>
|
||||
<span>{{ scope.row.currentNodeName || (scope.row.status === 'approved' ? '已结束' : '-') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" prop="createTime" min-width="160">
|
||||
<el-table-column label="状态" width="86">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusText(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" prop="createTime" width="140">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="120" fixed="right">
|
||||
<el-table-column label="耗时" width="80">
|
||||
<template slot-scope="scope">{{ formatElapsed(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button size="mini" type="text" @click="handleRevoke(scope.row)"
|
||||
v-if="scope.row.status === 'running'">撤销</el-button>
|
||||
<el-button size="mini" type="text" @click="handleDelete(scope.row)"
|
||||
v-if="scope.row.status === 'rejected'">删除</el-button>
|
||||
v-if="scope.row.status === 'rejected' || scope.row.status === 'draft'">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination :current-page="historyQuery.pageNum" :page-sizes="[10, 20, 50, 100]"
|
||||
:page-size="historyQuery.pageSize" :total="historyTotal" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange" @current-change="handlePageChange" />
|
||||
<el-pagination
|
||||
:current-page="historyQuery.pageNum"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:page-size="historyQuery.pageSize"
|
||||
:total="historyTotal"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,45 +115,16 @@ import applyTypeMinix from '@/views/hrm/minix/applyTypeMinix.js';
|
||||
|
||||
export default {
|
||||
name: 'HrmApply',
|
||||
mixins: [applyTypeMinix],
|
||||
data () {
|
||||
return {
|
||||
currentEmp: null,
|
||||
applyTypes: [
|
||||
{
|
||||
key: 'leave',
|
||||
title: '请假申请',
|
||||
desc: '申请各类假期,包括年假、病假、事假等',
|
||||
icon: 'el-icon-calendar',
|
||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
},
|
||||
{
|
||||
key: 'travel',
|
||||
title: '出差申请',
|
||||
desc: '申请出差,包括目的地、时间、费用等信息',
|
||||
icon: 'el-icon-location',
|
||||
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'
|
||||
},
|
||||
{
|
||||
key: 'seal',
|
||||
title: '用印申请',
|
||||
desc: '申请使用印章,上传文件并指定盖章位置',
|
||||
icon: 'el-icon-stamp',
|
||||
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'
|
||||
},
|
||||
{
|
||||
key: 'reimburse',
|
||||
title: '报销申请',
|
||||
desc: '申请费用报销,包括单据、理由、金额等信息',
|
||||
icon: 'el-icon-money',
|
||||
color: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
|
||||
},
|
||||
{
|
||||
key: 'appropriation',
|
||||
title: '拨款申请',
|
||||
desc: '申请拨款,包括金额、目的、时间等信息',
|
||||
icon: 'el-icon-money',
|
||||
color: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
|
||||
}
|
||||
{ key: 'leave', title: '请假' },
|
||||
{ key: 'travel', title: '出差' },
|
||||
{ key: 'seal', title: '用印' },
|
||||
{ key: 'reimburse', title: '报销' },
|
||||
{ key: 'appropriation', title: '拨款' }
|
||||
],
|
||||
historyList: [],
|
||||
historyLoading: false,
|
||||
@@ -157,87 +137,79 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [applyTypeMinix],
|
||||
created () {
|
||||
this.loadCurrentEmployee()
|
||||
},
|
||||
methods: {
|
||||
formatEmpLabel (emp) {
|
||||
if (!emp) return '未指定'
|
||||
const name = emp.empName || emp.nickName || emp.userName || ''
|
||||
const no = emp.empNo ? ` · ${emp.empNo}` : ''
|
||||
const dept = emp.deptName ? ` · ${emp.deptName}` : ''
|
||||
return `${name || '员工'}${no}${dept}`.trim()
|
||||
},
|
||||
handleRevoke (row) {
|
||||
this.$modal.confirm('确认撤销申请?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
revokeFlowTask(row.instId).then(response => {
|
||||
this.$message({
|
||||
message: '撤销成功',
|
||||
type: 'success'
|
||||
})
|
||||
this.$modal.confirm('确认撤销该申请?', '提示', { type: 'warning' }).then(() => {
|
||||
revokeFlowTask(row.instId).then(() => {
|
||||
this.$message.success('撤销成功')
|
||||
this.loadHistory()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleDelete (row) {
|
||||
this.$modal.confirm('确认删除申请?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
delFlowInstance(row.instId).then(response => {
|
||||
this.$message({
|
||||
message: '删除成功',
|
||||
type: 'success'
|
||||
})
|
||||
this.$modal.confirm('确认删除该申请?', '提示', { type: 'warning' }).then(() => {
|
||||
delFlowInstance(row.instId).then(() => {
|
||||
this.$message.success('删除成功')
|
||||
this.loadHistory()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleRemarkChange (row) {
|
||||
updateFlowInstance(row).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.$message({
|
||||
message: '更新成功',
|
||||
type: 'success'
|
||||
})
|
||||
} else {
|
||||
this.$message({
|
||||
message: `更新失败: ${response.msg || '未知错误'}`,
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
if (response.code === 200) this.$message.success('备注已更新')
|
||||
else this.$message.error(`更新失败: ${response.msg || '未知错误'}`)
|
||||
})
|
||||
},
|
||||
formatEmpDisplay (userId) {
|
||||
// The API returns startUserId, which is a user ID, not an empId.
|
||||
// We can display the current user's name if the ID matches.
|
||||
if (this.currentEmp && String(this.currentEmp.userId) === String(userId)) {
|
||||
return this.formatEmpLabel(this.currentEmp)
|
||||
/** 按 bizType 把申请的关键信息浓缩成一行 */
|
||||
summarizeBiz (row) {
|
||||
const b = row.bizData || {}
|
||||
switch (row.bizType) {
|
||||
case 'leave': {
|
||||
const dur = this.formatDuration(b)
|
||||
return `${b.leaveType || '请假'}${dur !== '-' ? ' · ' + dur : ''}${b.reason ? ' · ' + b.reason : ''}`
|
||||
}
|
||||
case 'travel':
|
||||
return `${b.travelType || '出差'} · ${b.destination || '未填目的地'}${b.startTime ? ' · ' + this.formatDate(b.startTime) : ''}`
|
||||
case 'seal':
|
||||
return `${b.sealType || '用印'}${b.fileName ? ' · ' + b.fileName : ''}${b.useReason ? ' · ' + b.useReason : ''}`
|
||||
case 'reimburse':
|
||||
return `${b.reimburseType || '报销'} · ¥${b.totalAmount != null ? b.totalAmount : 0}${b.reason ? ' · ' + b.reason : ''}`
|
||||
case 'appropriation':
|
||||
return `${b.appropriationType || '拨款'} · ¥${b.amount != null ? b.amount : 0}${b.reason ? ' · ' + b.reason : ''}`
|
||||
default:
|
||||
return row.procDefName || '-'
|
||||
}
|
||||
return userId ? `用户ID:${userId}` : '未指定'
|
||||
},
|
||||
formatDuration (row) {
|
||||
if (row.hours) return `${row.hours}h`
|
||||
if (row.startTime && row.endTime) {
|
||||
const ms = new Date(row.endTime).getTime() - new Date(row.startTime).getTime()
|
||||
formatDuration (biz) {
|
||||
if (!biz) return '-'
|
||||
if (biz.hours) return `${biz.hours}h`
|
||||
if (biz.startTime && biz.endTime) {
|
||||
const ms = new Date(biz.endTime).getTime() - new Date(biz.startTime).getTime()
|
||||
if (ms > 0) return `${(ms / 3600000).toFixed(1)}h`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
statusText (status) {
|
||||
const map = { running: '审批中', draft: '草稿', approved: '已通过', rejected: '已驳回', finished: '已完成', revoked: '已撤销' }
|
||||
return map[status] || status || '-'
|
||||
formatElapsed (row) {
|
||||
if (!row.createTime) return '-'
|
||||
const end = row.finishTime || row.endTime || Date.now()
|
||||
const ms = new Date(end).getTime() - new Date(row.createTime).getTime()
|
||||
if (ms <= 0) return '-'
|
||||
const h = ms / 3600000
|
||||
if (h < 1) return `${Math.round(ms / 60000)}分钟`
|
||||
if (h < 24) return `${h.toFixed(1)}h`
|
||||
return `${(h / 24).toFixed(1)}天`
|
||||
},
|
||||
statusType (status) {
|
||||
if (!status) return 'info'
|
||||
statusText (s) {
|
||||
const map = { running: '审批中', draft: '草稿', approved: '已通过', rejected: '已驳回', finished: '已完成', revoked: '已撤销' }
|
||||
return map[s] || s || '-'
|
||||
},
|
||||
statusType (s) {
|
||||
if (!s) return 'info'
|
||||
const map = { running: 'warning', draft: 'info', approved: 'success', rejected: 'danger', finished: 'success', revoked: 'danger' }
|
||||
return map[status] || 'info'
|
||||
return map[s] || 'info'
|
||||
},
|
||||
formatDate (val) {
|
||||
if (!val) return ''
|
||||
@@ -245,32 +217,15 @@ export default {
|
||||
const p = n => (n < 10 ? `0${n}` : n)
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
},
|
||||
getTypeDetail (row) {
|
||||
// This detail might need to be fetched or be part of the process variables
|
||||
// For now, we display the process name as a fallback.
|
||||
return row.procDefName || '-'
|
||||
},
|
||||
handleRowClick (row) {
|
||||
this.goDetail(row)
|
||||
},
|
||||
handleRowClick (row) { this.goDetail(row) },
|
||||
async loadCurrentEmployee () {
|
||||
try {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.warning('无法获取当前用户信息,请重新登录')
|
||||
this.loadHistory() // Still try to load history if user is not found
|
||||
return
|
||||
}
|
||||
|
||||
if (!userId) { this.loadHistory(); return }
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
this.currentEmp = res.data
|
||||
} else {
|
||||
this.$message.warning('未找到当前用户对应的员工信息')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载员工信息失败', err)
|
||||
this.$message.error('加载员工信息失败')
|
||||
if (res.code === 200 && res.data) this.currentEmp = res.data
|
||||
} catch (e) {
|
||||
console.error('加载员工信息失败', e)
|
||||
} finally {
|
||||
this.loadHistory()
|
||||
}
|
||||
@@ -281,8 +236,8 @@ export default {
|
||||
const params = {
|
||||
pageNum: this.historyQuery.pageNum,
|
||||
pageSize: this.historyQuery.pageSize,
|
||||
bizType: this.historyQuery.type || undefined, // 业务类型:leave/travel/seal/reimburse
|
||||
status: this.historyQuery.status || undefined // 状态:draft/running/approved/rejected
|
||||
bizType: this.historyQuery.type || undefined,
|
||||
status: this.historyQuery.status || undefined
|
||||
}
|
||||
const res = await listMyFlowInstance(params)
|
||||
if (res.code === 200) {
|
||||
@@ -293,8 +248,8 @@ export default {
|
||||
this.historyTotal = 0
|
||||
this.$message.error(res.msg || '加载申请历史失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载申请历史失败:', err)
|
||||
} catch (e) {
|
||||
console.error('加载申请历史失败', e)
|
||||
this.$message.error('加载申请历史失败')
|
||||
this.historyList = []
|
||||
this.historyTotal = 0
|
||||
@@ -316,108 +271,68 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hrm-page {
|
||||
padding: 20px;
|
||||
// display: flex;
|
||||
height: calc(100vh - 84px);
|
||||
.apply-page {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
min-height: calc(100vh - 84px);
|
||||
box-sizing: border-box;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
.apply-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-block {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.apply-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.apply-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.apply-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
gap: 8px;
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.card-action {
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-section {
|
||||
.history-card {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
.block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.block-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 16px;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.apply-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.apply-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small" class="metal-form">
|
||||
<el-alert v-if="ocrAvailable === false" type="error" :closable="false" show-icon style="margin-bottom:14px">
|
||||
<span slot="title">发票识别服务已停止,请联系信息化部门。在服务恢复前,您暂时无法上传发票附件。</span>
|
||||
</el-alert>
|
||||
|
||||
<div class="form-summary">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">发起拨款申请</div>
|
||||
@@ -57,27 +53,27 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 拨款单据附件(OCR触发区) -->
|
||||
<!-- 拨款单据附件(仅 PDF 电子发票) -->
|
||||
<el-form-item label="拨款单据附件" prop="accessoryApplyIds">
|
||||
<file-upload v-model="form.accessoryApplyIds" :limit="50" :file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple
|
||||
:disabled="ocrAvailable === false"
|
||||
:file-type="['pdf']" multiple
|
||||
@delete="onFileDelete" />
|
||||
<div class="hint-text">
|
||||
{{ ocrAvailable === false ? '识别服务不可用,暂时无法上传' : '上传发票、收据、付款截图等(支持 PDF/图片,上传后自动识别明细)' }}
|
||||
仅支持 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票),上传后自动解析金额与明细。<br/>
|
||||
扫描件 / 图片票 / 纸质票请先在开票平台下载 PDF 原件再上传。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- OCR 识别中提示 -->
|
||||
<div v-if="anyOcrLoading" class="ocr-thinking">
|
||||
<i class="el-icon-loading"></i>
|
||||
<span>模型思考中,正在识别发票内容…</span>
|
||||
<span>正在解析发票 PDF…</span>
|
||||
</div>
|
||||
|
||||
<!-- 发票明细条目表 -->
|
||||
<div class="block-title">
|
||||
拨款明细
|
||||
<span class="block-title-hint">(上传发票后自动识别;识别失败或无发票时可手动添加)</span>
|
||||
<span class="block-title-hint">(上传 PDF 后自动解析;解析失败或无发票时可手动添加)</span>
|
||||
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addManualItem" style="margin-left:12px;vertical-align:middle">手动添加条目</el-button>
|
||||
</div>
|
||||
<div class="invoice-table" v-if="invoiceItems.length">
|
||||
@@ -108,7 +104,7 @@
|
||||
</el-tooltip>
|
||||
<el-upload v-else :action="uploadFileUrl" :headers="uploadHeaders" :show-file-list="false"
|
||||
:data="{ isPublic: 1 }" :on-success="(res) => onRowFileSuccess(res, idx)"
|
||||
accept=".pdf,.jpg,.jpeg,.png" class="row-upload">
|
||||
accept=".pdf" class="row-upload">
|
||||
<el-button size="mini" type="text" icon="el-icon-paperclip"></el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
@@ -266,15 +262,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addAppropriationReq, checkAppropriationOcrHealth, listFlowNode, listFlowTemplate, ocrAppropriationInvoice } from '@/api/hrm';
|
||||
import { addAppropriationReq, listFlowNode, listFlowTemplate, ocrAppropriationInvoice } from '@/api/hrm';
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee';
|
||||
import { ccFlowTask } from '@/api/hrm/flow';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue';
|
||||
import UserSelect from '@/components/UserSelect/single.vue';
|
||||
import approverNameMixin from '@/views/hrm/minix/approverNameMixin.js';
|
||||
|
||||
export default {
|
||||
mixins: [approverNameMixin],
|
||||
name: 'HrmAppropriationRequest',
|
||||
components: { FileUpload, UserSelect, UserMultiSelect },
|
||||
data () {
|
||||
@@ -290,9 +288,6 @@ export default {
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
appropriationTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
|
||||
// 发票明细条目
|
||||
// OCR服务状态:null=检测中,true=正常,false=不可用
|
||||
ocrAvailable: null,
|
||||
uploadFileUrl: process.env.VUE_APP_BASE_API + '/system/oss/upload',
|
||||
uploadHeaders: { Authorization: 'Bearer ' + getToken() },
|
||||
// 发票明细条目
|
||||
@@ -334,7 +329,7 @@ export default {
|
||||
created () {
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
this.checkOcrHealth()
|
||||
this.loadUserNameMap()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText () {
|
||||
@@ -372,15 +367,6 @@ export default {
|
||||
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
|
||||
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
|
||||
|
||||
async checkOcrHealth () {
|
||||
try {
|
||||
const res = await checkAppropriationOcrHealth()
|
||||
this.ocrAvailable = res.code === 200 && res.data === true
|
||||
} catch (e) {
|
||||
this.ocrAvailable = false
|
||||
}
|
||||
},
|
||||
|
||||
async triggerOcr (ossId) {
|
||||
if (this.ocrDoneSet.has(ossId)) return
|
||||
this.ocrDoneSet.add(ossId)
|
||||
@@ -422,8 +408,12 @@ export default {
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OCR] 识别失败', e)
|
||||
this.$message.warning('发票识别失败,请手动填写事由和金额')
|
||||
console.error('[Invoice] 解析失败', e)
|
||||
const msg = (e && e.msg) || (e && e.message) || ''
|
||||
this.$message.warning(
|
||||
'发票解析失败,请确认上传的是开票平台下载的正规 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票)。' +
|
||||
(msg ? ' 错误信息:' + msg : '')
|
||||
)
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
} finally {
|
||||
this.$set(this.ocrLoadingMap, ossId, false)
|
||||
@@ -495,19 +485,6 @@ export default {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
nodePreviewText (n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
normalizeOssIds (val) {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
@@ -822,4 +799,6 @@ export default {
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
|
||||
.selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
|
||||
@import "./_form-compact.scss";
|
||||
</style>
|
||||
|
||||
@@ -1,87 +1,62 @@
|
||||
<template>
|
||||
<div class="hrm-page">
|
||||
<section class="summary-bar">
|
||||
<div class="summary-left">
|
||||
<div class="page-title">审批中心</div>
|
||||
<div class="page-desc">集中查看与处理待办审批</div>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="block-title">待审批</span>
|
||||
<span class="block-meta">待办 <b>{{ todoCount }}</b> · 今日已处理 <b>{{ todayCount }}</b></span>
|
||||
</div>
|
||||
<div class="summary-right">
|
||||
<div class="metric">
|
||||
<div class="metric-value">{{ todoCount }}</div>
|
||||
<div class="metric-label">待审批</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">{{ todayCount }}</div>
|
||||
<div class="metric-label">今日处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="approval-main">
|
||||
<el-card class="approval-card" shadow="never">
|
||||
<div slot="header" class="card-header">
|
||||
<span class="header-title">待审批任务</span>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="query.bizType" size="small" placeholder="申请类型" clearable
|
||||
style="width: 120px; margin-right: 8px" @change="loadTodoList">
|
||||
<el-option label="全部" value="" />
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="query.bizType" size="mini" placeholder="申请类型" clearable
|
||||
style="width: 110px;" @change="loadTodoList">
|
||||
<el-option label="全部类型" value="" />
|
||||
<el-option label="请假" value="leave" />
|
||||
<el-option label="出差" value="travel" />
|
||||
<el-option label="用印" value="seal" />
|
||||
<el-option label="报销" value="reimburse" />
|
||||
<el-option label="拨款" value="appropriation" />
|
||||
</el-select>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="loadTodoList">刷新</el-button>
|
||||
<el-button size="small" icon="el-icon-view" @click="viewAll">查看全部</el-button>
|
||||
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadTodoList">刷新</el-button>
|
||||
<el-button size="mini" icon="el-icon-view" @click="viewAll">查看全部</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="todoList" v-loading="loading" stripe @row-dblclick="handleRowClick">
|
||||
<el-table-column label="申请类型" min-width="100">
|
||||
<el-table :data="todoList" v-loading="loading" size="mini" stripe border @row-dblclick="handleRowClick">
|
||||
<el-table-column label="编号" prop="instId" width="80" />
|
||||
<el-table-column label="类型" width="70">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="getTypeTagType(scope.row.bizType)">
|
||||
{{ getTypeText(scope.row.bizType) }}
|
||||
</el-tag>
|
||||
<el-tag :type="getTypeTagType(scope.row.bizType)" size="mini">{{ getTypeText(scope.row.bizType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请人" min-width="140">
|
||||
<template slot-scope="scope">
|
||||
{{ formatApplicant(scope.row) }}
|
||||
</template>
|
||||
<el-table-column label="申请人" min-width="140" show-overflow-tooltip>
|
||||
<template slot-scope="scope">{{ formatApplicant(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请信息" min-width="200">
|
||||
<template slot-scope="scope">
|
||||
{{ formatRequestInfo(scope.row) }}
|
||||
</template>
|
||||
<el-table-column label="关键信息" min-width="220" show-overflow-tooltip>
|
||||
<template slot-scope="scope">{{ formatRequestInfo(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前节点" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
{{ formatNodeName(scope.row) }}
|
||||
</template>
|
||||
<el-table-column label="当前节点" min-width="110" show-overflow-tooltip>
|
||||
<template slot-scope="scope">{{ formatNodeName(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="创建时间" prop="createTime" min-width="160">
|
||||
<el-table-column label="提交时间" prop="createTime" width="140">
|
||||
<template slot-scope="scope">{{ formatDate(scope.row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="100">
|
||||
<el-table-column label="等待" width="76">
|
||||
<template slot-scope="scope">{{ formatWait(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" width="80">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" size="small">
|
||||
{{ statusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusText(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="200" fixed="right">
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" @click="goDetail(scope.row)">查看详情</el-button>
|
||||
<el-button size="mini" type="text" @click="goDetail(scope.row)">详情</el-button>
|
||||
<el-button size="mini" type="text" @click="handleApprove(scope.row)">通过</el-button>
|
||||
<el-button size="mini" type="text" @click="handleReject(scope.row)">驳回</el-button>
|
||||
<el-button size="mini" type="text" style="color:#f56c6c" @click="handleReject(scope.row)">驳回</el-button>
|
||||
<el-button size="mini" type="text" @click="handleTransfer(scope.row)">转发</el-button>
|
||||
<el-button size="mini" type="text" @click="handleCc(scope.row)">抄送</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<!-- 审批操作对话框 -->
|
||||
<el-dialog :title="actionDialog.title" :visible.sync="actionDialog.visible" width="500px" append-to-body>
|
||||
@@ -230,6 +205,15 @@ export default {
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
formatWait (task) {
|
||||
if (!task.createTime) return '-'
|
||||
const ms = Date.now() - new Date(task.createTime).getTime()
|
||||
if (ms <= 0) return '-'
|
||||
const h = ms / 3600000
|
||||
if (h < 1) return `${Math.round(ms / 60000)}分`
|
||||
if (h < 24) return `${h.toFixed(1)}h`
|
||||
return `${(h / 24).toFixed(1)}天`
|
||||
},
|
||||
loadEmployees () {
|
||||
listEmployee({ pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
this.employees = res.rows || res.data || []
|
||||
@@ -357,86 +341,39 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hrm-page {
|
||||
padding: 16px 20px 32px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.summary-left .page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.summary-left .page-desc {
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.summary-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 92px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.approval-main {
|
||||
.approval-card {
|
||||
border: 1px solid #d7d9df;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
min-height: calc(100vh - 84px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.block-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
b { color: #303133; font-weight: 600; padding: 0 2px; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -229,8 +229,10 @@ import { ccFlowTask } from '@/api/hrm/flow';
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee';
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue';
|
||||
import UserSelect from '@/components/UserSelect/single.vue';
|
||||
import approverNameMixin from '@/views/hrm/minix/approverNameMixin.js';
|
||||
|
||||
export default {
|
||||
mixins: [approverNameMixin],
|
||||
name: 'HrmLeaveRequest',
|
||||
components: { UserSelect, UserMultiSelect },
|
||||
data () {
|
||||
@@ -281,6 +283,7 @@ export default {
|
||||
created () {
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
this.loadUserNameMap()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText () {
|
||||
@@ -340,19 +343,6 @@ export default {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
nodePreviewText (n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
formatEmpLabel (emp) {
|
||||
const name = emp.empName || emp.nickName || emp.userName || ''
|
||||
const no = emp.empNo ? ` · ${emp.empNo}` : ''
|
||||
@@ -630,4 +620,6 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./_form-compact.scss";
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" size="small" class="metal-form">
|
||||
<el-alert v-if="ocrAvailable === false" type="error" :closable="false" show-icon style="margin-bottom:14px">
|
||||
<span slot="title">发票识别服务已停止,请联系信息化部门。在服务恢复前,您暂时无法上传发票附件。</span>
|
||||
</el-alert>
|
||||
|
||||
<div class="form-summary">
|
||||
<div class="summary-left">
|
||||
<div class="summary-title">发起日常报销</div>
|
||||
@@ -57,27 +53,27 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 报销单据附件(OCR触发区) -->
|
||||
<!-- 报销单据附件(仅 PDF 电子发票) -->
|
||||
<el-form-item label="报销单据附件" prop="accessoryApplyIds">
|
||||
<file-upload v-model="form.accessoryApplyIds" :limit="200" :file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple
|
||||
:disabled="ocrAvailable === false"
|
||||
:file-type="['pdf']" multiple
|
||||
@delete="onFileDelete" />
|
||||
<div class="hint-text">
|
||||
{{ ocrAvailable === false ? '识别服务不可用,暂时无法上传' : '上传发票、收据、付款截图等(支持 PDF/图片,上传后自动识别明细)' }}
|
||||
仅支持 PDF 电子发票(含数电票/电子普通发票/电子专用发票),上传后自动解析金额与明细。<br/>
|
||||
扫描件 / 图片票 / 纸质票请先在开票平台下载 PDF 原件再上传。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- OCR 识别中提示 -->
|
||||
<!-- 发票解析中提示 -->
|
||||
<div v-if="anyOcrLoading" class="ocr-thinking">
|
||||
<i class="el-icon-loading"></i>
|
||||
<span>模型思考中,正在识别发票内容…</span>
|
||||
<span>正在解析发票 PDF…</span>
|
||||
</div>
|
||||
|
||||
<!-- 发票明细条目表 -->
|
||||
<div class="block-title">
|
||||
发票明细
|
||||
<span class="block-title-hint">(上传发票后自动识别;识别失败或无发票时可手动添加)</span>
|
||||
<span class="block-title-hint">(上传 PDF 后自动解析;解析失败或无发票时可手动添加)</span>
|
||||
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addManualItem" style="margin-left:12px;vertical-align:middle">手动添加条目</el-button>
|
||||
</div>
|
||||
<div class="invoice-table" v-if="invoiceItems.length">
|
||||
@@ -108,7 +104,7 @@
|
||||
</el-tooltip>
|
||||
<el-upload v-else :action="uploadFileUrl" :headers="uploadHeaders" :show-file-list="false"
|
||||
:data="{ isPublic: 1 }" :on-success="(res) => onRowFileSuccess(res, idx)"
|
||||
accept=".pdf,.jpg,.jpeg,.png" class="row-upload">
|
||||
accept=".pdf" class="row-upload">
|
||||
<el-button size="mini" type="text" icon="el-icon-paperclip"></el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
@@ -243,15 +239,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addReimburseReq, checkReimburseOcrHealth, listFlowNode, listFlowTemplate, ocrReimburseInvoice } from '@/api/hrm';
|
||||
import { addReimburseReq, listFlowNode, listFlowTemplate, ocrReimburseInvoice } from '@/api/hrm';
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee';
|
||||
import { ccFlowTask } from '@/api/hrm/flow';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue';
|
||||
import UserSelect from '@/components/UserSelect/single.vue';
|
||||
import approverNameMixin from '@/views/hrm/minix/approverNameMixin.js';
|
||||
|
||||
export default {
|
||||
mixins: [approverNameMixin],
|
||||
name: 'HrmReimburseRequest',
|
||||
components: { FileUpload, UserSelect, UserMultiSelect },
|
||||
data () {
|
||||
@@ -267,8 +265,6 @@ export default {
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
reimburseTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
|
||||
// OCR服务状态:null=检测中,true=正常,false=不可用
|
||||
ocrAvailable: null,
|
||||
uploadFileUrl: process.env.VUE_APP_BASE_API + '/system/oss/upload',
|
||||
uploadHeaders: { Authorization: 'Bearer ' + getToken() },
|
||||
// 发票明细条目
|
||||
@@ -308,7 +304,7 @@ export default {
|
||||
created () {
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
this.checkOcrHealth()
|
||||
this.loadUserNameMap()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText () {
|
||||
@@ -346,15 +342,6 @@ export default {
|
||||
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
|
||||
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
|
||||
|
||||
async checkOcrHealth () {
|
||||
try {
|
||||
const res = await checkReimburseOcrHealth()
|
||||
this.ocrAvailable = res.code === 200 && res.data === true
|
||||
} catch (e) {
|
||||
this.ocrAvailable = false
|
||||
}
|
||||
},
|
||||
|
||||
async triggerOcr (ossId) {
|
||||
if (this.ocrDoneSet.has(ossId)) return
|
||||
this.ocrDoneSet.add(ossId)
|
||||
@@ -396,8 +383,12 @@ export default {
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OCR] 识别失败', e)
|
||||
this.$message.warning('发票识别失败,请手动填写事由和金额')
|
||||
console.error('[Invoice] 解析失败', e)
|
||||
const msg = (e && e.msg) || (e && e.message) || ''
|
||||
this.$message.warning(
|
||||
'发票解析失败,请确认上传的是开票平台下载的正规 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票)。' +
|
||||
(msg ? ' 错误信息:' + msg : '')
|
||||
)
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
} finally {
|
||||
this.$set(this.ocrLoadingMap, ossId, false)
|
||||
@@ -469,19 +460,6 @@ export default {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
nodePreviewText (n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
normalizeOssIds (val) {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
@@ -820,4 +798,6 @@ export default {
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
|
||||
.selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
|
||||
@import "./_form-compact.scss";
|
||||
</style>
|
||||
|
||||
@@ -105,60 +105,12 @@
|
||||
</el-form-item>
|
||||
<UserMultiSelect ref="userMultiSelect" @onSelected="onCcUsersSelected" :init="ccUserIds" />
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<!-- 用印申请固定审批人:陆永强(不需要选择) -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
<el-radio-button label="template">使用模板流程</el-radio-button>
|
||||
<el-radio-button label="manual">手动选择审批人(一次审批)</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="approve-panel">
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div class="approve-row">
|
||||
<div class="k">流程模板</div>
|
||||
<div class="v">
|
||||
<el-select v-model="tplId" size="small" clearable filterable placeholder="请选择流程模板"
|
||||
style="width: 360px" @change="onTplChange">
|
||||
<el-option v-for="t in availableTpls" :key="t.tplId"
|
||||
:label="`${t.tplName}${t.version ? ' (v' + t.version + ')' : ''}`" :value="t.tplId" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:选择模板后,将按模板节点自动流转(含抄送节点)。</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="approve-row">
|
||||
<div class="k">审批人</div>
|
||||
<div class="v" style="max-width: 520px">
|
||||
<el-button size="mini" type="primary" plain @click="openUserSelect">选择审批人</el-button>
|
||||
<span style="margin-left: 10px; font-weight: 600; color: #2b2f36">
|
||||
{{ assigneeUserName || '未选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-text">提示:手动选择审批人将创建一次性审批流程,审批通过后流程立即结束。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交流程提示:按"真实节点配置"预览 -->
|
||||
<div class="flow-preview" v-loading="flowLoading">
|
||||
<div class="flow-preview">
|
||||
<div class="flow-title">流程预览</div>
|
||||
<div class="flow-sub">
|
||||
<template v-if="approverMode === 'template'">
|
||||
<span v-if="flowTpl">当前模板:{{ flowTpl.tplName }}(v{{ flowTpl.version || 1 }})</span>
|
||||
<span v-else>请选择流程模板</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>一次性审批(手动指定审批人)</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-sub">用印申请固定由 <b>陆永强</b>(信息化部)审批</div>
|
||||
<div class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">填写申请</div>
|
||||
@@ -166,31 +118,7 @@
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">提交</div>
|
||||
</div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot" :class="{ success: idx === flowNodes.length - 1 }"></div>
|
||||
<div class="txt">{{ nodePreviewText(n, idx) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flow-fallback">
|
||||
<div class="hint-text">提示:请选择一个模板后将展示对应节点预览。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">填写申请</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div>
|
||||
<div class="txt">提交审批(陆永强)</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
@@ -204,8 +132,6 @@
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">提交申请</el-button>
|
||||
</div>
|
||||
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -214,27 +140,18 @@
|
||||
<script>
|
||||
import { addSealReq } from '@/api/hrm'
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee'
|
||||
import { ccFlowTask, listFlowNode, listFlowTemplate } from '@/api/hrm/flow'
|
||||
import { ccFlowTask } from '@/api/hrm/flow'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue'
|
||||
import UserSelect from '@/components/UserSelect/single.vue'
|
||||
|
||||
export default {
|
||||
name: 'HrmSealRequest',
|
||||
dicts: ['hrm_seal_type'],
|
||||
components: { FileUpload, UserSelect, UserMultiSelect },
|
||||
components: { FileUpload, UserMultiSelect },
|
||||
data () {
|
||||
return {
|
||||
currentEmp: null,
|
||||
submitting: false,
|
||||
flowLoading: false,
|
||||
flowTpl: null,
|
||||
flowNodes: [],
|
||||
approverMode: 'template',
|
||||
availableTpls: [],
|
||||
tplId: null,
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
|
||||
form: {
|
||||
empId: '',
|
||||
@@ -266,7 +183,6 @@ export default {
|
||||
},
|
||||
created () {
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText () {
|
||||
@@ -284,87 +200,12 @@ export default {
|
||||
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
|
||||
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
|
||||
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
|
||||
async loadTemplates () {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'seal', enabled: 1 })
|
||||
this.availableTpls = res.rows || res.data || []
|
||||
if (!this.tplId && this.availableTpls.length) {
|
||||
this.tplId = this.availableTpls[0].tplId
|
||||
}
|
||||
await this.refreshFlowPreview()
|
||||
} catch (err) {
|
||||
this.availableTpls = []
|
||||
}
|
||||
},
|
||||
async refreshFlowPreview () {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
|
||||
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
|
||||
} finally {
|
||||
this.flowLoading = false
|
||||
}
|
||||
},
|
||||
async onTplChange (val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
onApproverModeChange (val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect () {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
normalizeUserId (val) {
|
||||
if (val === undefined || val === null || val === '') return null
|
||||
// 兼容:"sys_user:1" / "user:1" / "1" / 1
|
||||
const s = String(val)
|
||||
const parts = s.split(':')
|
||||
const last = parts[parts.length - 1]
|
||||
const num = last
|
||||
return Number.isNaN(num) ? null : num
|
||||
},
|
||||
|
||||
onUserSelected (row) {
|
||||
if (row) {
|
||||
// 兼容 userId 可能为 "sys_user:1" 的情况
|
||||
this.assigneeUserId = this.normalizeUserId(row.userId)
|
||||
this.assigneeUserName = row.nickName || row.userName || String(row.userId)
|
||||
this.refreshFlowPreview()
|
||||
}
|
||||
},
|
||||
formatEmpLabel (emp) {
|
||||
const name = emp.empName || emp.nickName || emp.userName || ''
|
||||
const no = emp.empNo ? ` · ${emp.empNo}` : ''
|
||||
const dept = emp.deptName ? ` · ${emp.deptName}` : ''
|
||||
return `${name || '员工'}${no}${dept}`.trim()
|
||||
},
|
||||
nodePreviewText (n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
async loadCurrentEmployee () {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
@@ -386,12 +227,7 @@ export default {
|
||||
async submit () {
|
||||
try {
|
||||
await this.$refs.formRef.validate()
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
return this.$message.warning('请选择一个流程模板')
|
||||
}
|
||||
if (this.approverMode === 'manual' && !this.assigneeUserId) {
|
||||
return this.$message.warning('请选择审批人')
|
||||
}
|
||||
// 用印申请审批人写死成陆永强,不需要前端选择审批人
|
||||
|
||||
this.submitting = true
|
||||
|
||||
@@ -409,15 +245,10 @@ export default {
|
||||
urgentLevel: this.form.urgentLevel,
|
||||
remark,
|
||||
status: 'pending',
|
||||
projectId: this.form.projectId,
|
||||
// tplId: this.tplId,
|
||||
manualAssigneeUserId: this.normalizeUserId(this.assigneeUserId),
|
||||
}
|
||||
if (this.approverMode === 'template') {
|
||||
payload.tplId = this.tplId
|
||||
projectId: this.form.projectId
|
||||
// 审批人由后端写死成陆永强,前端不再传 tplId / manualAssigneeUserId
|
||||
}
|
||||
const { data: instance } = await addSealReq(payload)
|
||||
console.log(instance, this.ccForm)
|
||||
if (this.ccForm.selectedUsers.length && instance?.instId) {
|
||||
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
|
||||
const fromUserId = this.$store?.state?.user?.id
|
||||
@@ -530,28 +361,6 @@ export default {
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
border-radius: 10px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.approve-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.approve-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approve-row .k {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.flow-preview {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
@@ -624,4 +433,6 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./_form-compact.scss";
|
||||
</style>
|
||||
|
||||
@@ -224,8 +224,10 @@ import FileUpload from '@/components/FileUpload'
|
||||
import UserMultiSelect from '@/components/UserSelect/multi.vue'
|
||||
import UserSelect from '@/components/UserSelect/single.vue'
|
||||
import AmapCitySelect from '@/components/AmapCitySelect/index.vue'
|
||||
import approverNameMixin from '@/views/hrm/minix/approverNameMixin.js';
|
||||
|
||||
export default {
|
||||
mixins: [approverNameMixin],
|
||||
name: 'HrmTravelRequest',
|
||||
components: {
|
||||
UserSelect,
|
||||
@@ -290,6 +292,7 @@ export default {
|
||||
created () {
|
||||
this.loadCurrentEmployee()
|
||||
this.loadTemplates()
|
||||
this.loadUserNameMap()
|
||||
},
|
||||
computed: {
|
||||
currentApplicantText () {
|
||||
@@ -356,19 +359,6 @@ export default {
|
||||
this.refreshFlowPreview()
|
||||
}
|
||||
},
|
||||
nodePreviewText (n, idx) {
|
||||
const typeMap = { approve: '审批', cc: '抄送' }
|
||||
const ruleMap = { fixed_user: '指定人员', role: '指定角色', position: '指定岗位', leader: '直属上级' }
|
||||
const nodeType = typeMap[n.nodeType] || '节点'
|
||||
const rule = ruleMap[n.approverRule] || '规则'
|
||||
let detail = ''
|
||||
try {
|
||||
const arr = Array.isArray(n.approverValue) ? n.approverValue : JSON.parse(n.approverValue || '[]')
|
||||
if (arr.length) detail = `:${arr.join('、')}`
|
||||
} catch (e) { detail = n.approverValue ? `:${n.approverValue}` : '' }
|
||||
const text = `${nodeType}(${rule}${detail})`
|
||||
return idx === this.flowNodes.length - 1 ? `${text} → 结束` : text
|
||||
},
|
||||
normalizeOssIds (val) {
|
||||
if (!val) return ''
|
||||
if (typeof val === 'string') return val
|
||||
@@ -646,4 +636,6 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./_form-compact.scss";
|
||||
</style>
|
||||
|
||||
@@ -85,13 +85,13 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="手机" align="center" prop="mobile" />
|
||||
<el-table-column label="电话" align="center" prop="telephone" />
|
||||
<el-table-column label="QQ" align="center" prop="qq" />
|
||||
<el-table-column label="微信" align="center" prop="wechat" />
|
||||
<el-table-column label="邮箱" align="center" prop="email" />
|
||||
<el-table-column label="详细地址" align="center" prop="detailAddress" />
|
||||
<el-table-column label="备注" align="center" prop="remark" />
|
||||
<el-table-column label="手机" align="center" prop="mobile" width="120" />
|
||||
<el-table-column label="电话" align="center" prop="telephone" width="120" />
|
||||
<el-table-column label="QQ" align="center" prop="qq" width="100" />
|
||||
<el-table-column label="微信" align="center" prop="wechat" width="100" />
|
||||
<el-table-column label="邮箱" align="center" prop="email" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="详细地址" align="center" prop="detailAddress" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="备注" align="center" prop="remark" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="操作" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改
|
||||
@@ -223,7 +223,7 @@ export default {
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
name: undefined,
|
||||
followUpStatus: undefined,
|
||||
contactLastTime: undefined,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-tabs v-model="statusTab" @tab-click="onStatusTab" class="compact-tabs">
|
||||
<el-tab-pane name="undone">
|
||||
<span slot="label">未完成 ({{ stat.undone }})</span>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="all">
|
||||
<span slot="label">全部 ({{ stat.all }})</span>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="done">
|
||||
<span slot="label">已完成 ({{ stat.done }})</span>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-form :model="queryParams" ref="queryForm" size="mini" :inline="true" v-show="showSearch"
|
||||
label-width="68px" class="compact-search">
|
||||
<el-form-item label="需求标题" prop="title">
|
||||
@@ -257,6 +269,10 @@ export default {
|
||||
open: false,
|
||||
// 负责人选项
|
||||
ownerOptions: [],
|
||||
// 状态分组 tab:undone=未完成(0,1) / all=全部 / done=已完成(2)
|
||||
statusTab: 'undone',
|
||||
// 各 tab 计数
|
||||
stat: { undone: 0, all: 0, done: 0 },
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
@@ -268,6 +284,7 @@ export default {
|
||||
description: undefined,
|
||||
deadline: undefined,
|
||||
status: undefined,
|
||||
statusIn: undefined,
|
||||
accessory: undefined,
|
||||
},
|
||||
// 表单参数
|
||||
@@ -301,8 +318,10 @@ export default {
|
||||
};
|
||||
},
|
||||
created () {
|
||||
this.applyStatusTab();
|
||||
this.getList();
|
||||
this.getUsers();
|
||||
this.refreshStat();
|
||||
},
|
||||
methods: {
|
||||
// 跳到入库明细页面,并预填该采购需求
|
||||
@@ -382,6 +401,35 @@ export default {
|
||||
this.ownerOptions = response.rows;
|
||||
});
|
||||
},
|
||||
/** 根据当前 tab 写 queryParams.statusIn / status */
|
||||
applyStatusTab () {
|
||||
if (this.statusTab === 'undone') {
|
||||
this.queryParams.statusIn = '0,1'
|
||||
this.queryParams.status = undefined
|
||||
} else if (this.statusTab === 'done') {
|
||||
this.queryParams.statusIn = undefined
|
||||
this.queryParams.status = 2
|
||||
} else {
|
||||
this.queryParams.statusIn = undefined
|
||||
this.queryParams.status = undefined
|
||||
}
|
||||
},
|
||||
onStatusTab () {
|
||||
this.applyStatusTab()
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
/** 刷新三个 tab 的计数 */
|
||||
refreshStat () {
|
||||
const base = { ...this.queryParams, pageNum: 1, pageSize: 1, status: undefined, statusIn: undefined }
|
||||
Promise.all([
|
||||
listRequirements({ ...base, statusIn: '0,1' }),
|
||||
listRequirements({ ...base }),
|
||||
listRequirements({ ...base, status: 2 })
|
||||
]).then(([u, a, d]) => {
|
||||
this.stat = { undone: u.total || 0, all: a.total || 0, done: d.total || 0 }
|
||||
}).catch(() => {})
|
||||
},
|
||||
/** 查询OA 需求列表 */
|
||||
getList () {
|
||||
this.loading = true;
|
||||
@@ -389,6 +437,7 @@ export default {
|
||||
this.requirementsList = response.rows;
|
||||
this.total = response.total;
|
||||
this.loading = false;
|
||||
this.refreshStat();
|
||||
});
|
||||
},
|
||||
// 取消按钮
|
||||
@@ -425,6 +474,8 @@ export default {
|
||||
/** 重置按钮操作 */
|
||||
resetQuery () {
|
||||
this.resetForm("queryForm");
|
||||
// 重置后保留当前 tab 对应的 status 过滤
|
||||
this.applyStatusTab();
|
||||
this.handleQuery();
|
||||
},
|
||||
// 多选框选中数据
|
||||
|
||||
Reference in New Issue
Block a user