推送项目重构代码

This commit is contained in:
2026-05-31 14:19:15 +08:00
parent a28ea44cab
commit dcc66aa4a9
30 changed files with 1112 additions and 1021 deletions

View File

@@ -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));

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -76,5 +76,9 @@ public class OaRequirementsBo extends BaseEntity {
*/
private String accessory;
/**
* 状态多选筛选:逗号分隔的状态值,如 "0,1"。用于"未完成"等组合 tab。
* 与 status 同时存在时优先生效。
*/
private String statusIn;
}

View File

@@ -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());
}
}
}

View File

@@ -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) {

View File

@@ -29,6 +29,8 @@ public class OpenImClient {
/** 自定义消息 contentTypeOpenIM 约定 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");

View File

@@ -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);
//根据创建时间倒叙

View File

@@ -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();
}

View File

@@ -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());
}
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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>

View File

@@ -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

View 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
}
}
}

View 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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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: [],
// 状态分组 tabundone=未完成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();
},
// 多选框选中数据