新增MES审批流设计功能,包括审批流定义、审批实例管理及审批办理接口,支持可视化设计与业务单据联动,提升审批流程的灵活性与用户体验。

This commit is contained in:
geht
2026-05-29 15:49:10 +08:00
parent 94132ea8da
commit aefa44b8a9
48 changed files with 5603 additions and 261 deletions

View File

@@ -466,3 +466,58 @@ jeecgboot-vue3/src/views/xslmes/mesXslRubberQuickTestRecord/MesXslRubberQuickTes
jeecgboot-vue3/src/views/xslmes/mesXslRubberQuickTestRecord/MesXslRubberQuickTestRecord.data.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberQuickTestRecord/components/MesXslRubberQuickTestRecordModal.vue
jeecgboot-vue3/src/views/mes/material/MesMaterialList.vue
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】我的租户下新增审批流设计,钉钉式可视化拖拽设计(先选单据再设计流程),本期实现设计器 ---
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_111__mes_xsl_approval_flow.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalFlowMapper.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalFlowService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalFlowServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java
jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts
jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts
jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue
jeecgboot-vue3/src/views/approval/flow/ApprovalFlowModal.vue
jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts
jeecgboot-vue3/src/views/approval/flow/components/FlowNode.vue
jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue
jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue
jeecgboot-vue3/src/views/approval/flow/components/flow.less
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批运行时:全局悬浮按钮(选单据类型->选单据->发起),本期仅发起(生成审批实例+解析首节点处理人),不办理/不回写业务表 ---
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_112__mes_xsl_approval_instance.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalInstance.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalInstanceMapper.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalInstanceService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalInstanceServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts
jeecgboot-vue3/src/views/approval/flow/launch.api.ts
jeecgboot-vue3/src/components/ApprovalLaunch/index.vue
jeecgboot-vue3/src/layouts/default/index.vue
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批悬浮按钮仅在配置了审批流的功能页显示:审批流定义增加route_path(功能页路由),前端按当前路由匹配后才显示按钮 ---
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_113__mes_xsl_approval_flow_route.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java
jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts
jeecgboot-vue3/src/components/ApprovalLaunch/index.vue
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】取消手填功能页路由:publishedList按单据表名自动反查sys_permission菜单url填入routePath,设计表单去掉路由字段 ---
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批支持列表多选联动:useListPage自动同步选中行到全局上下文,悬浮按钮发起弹窗自动带入选中单据并批量发起 ---
jeecgboot-vue3/src/components/ApprovalLaunch/useApprovalSelection.ts
jeecgboot-vue3/src/hooks/system/useListPage.ts
jeecgboot-vue3/src/components/ApprovalLaunch/index.vue
jeecgboot-vue3/src/views/approval/flow/launch.api.ts
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批与IM聊天结合:IM新增系统单聊消息(绕过同部门校验),发起后把审批消息发给当前节点处理人 ---
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
-- author:GHT---date:20260529--for: 【QH-MES审批流设计】审批IM消息升级为可跳转业务卡片(biz_record):点击可定位到对应单据,无法定位功能页时退回纯文本 ---
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java

View File

@@ -0,0 +1,27 @@
package org.jeecg.modules.xslmes.approval.callback;
import org.springframework.context.ApplicationEvent;
/**
* 审批动作领域事件。
* 与 {@link IApprovalBizCallback} 等价的另一种接入方式:
* 偏好松耦合的业务可使用 {@code @EventListener} 监听本事件(同步、同事务)。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调
*/
public class ApprovalActionEvent extends ApplicationEvent {
private static final long serialVersionUID = 1L;
private final ApprovalCallbackContext context;
public ApprovalActionEvent(Object source, ApprovalCallbackContext context) {
super(source);
this.context = context;
}
public ApprovalCallbackContext getContext() {
return context;
}
}

View File

@@ -0,0 +1,169 @@
package org.jeecg.modules.xslmes.approval.callback;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
/**
* 审批节点「回调接口」HTTP 执行器。
*
* 审批到对应时机时读取节点配置中录制好的业务接口url+method
* 以「当前审批处理人」的登录态(透传当前请求的 X-Access-Token内部调用该接口
* 自动带上单据ID覆盖 id 参数),从而真实执行业务页面按钮背后的逻辑。
*
* 限制:自动流转 / 无人值守节点(无登录态请求上下文)时降级跳过并记录日志。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】节点回调接口内部调用执行
*/
@Slf4j
@Component
public class ApprovalActionHttpExecutor {
@Value("${server.port:8080}")
private int serverPort;
@Value("${server.servlet.context-path:}")
private String contextPath;
private final RestTemplate restTemplate = new RestTemplate();
/**
* 执行某节点在指定时机配置的所有回调接口。
*
* @param node 流程节点 JSON含 props.callbackActions
* @param phase 时机onNodeApprove / onApprove / onReject
* @param inst 审批实例
*/
public void run(JSONObject node, String phase, MesXslApprovalInstance inst) {
if (node == null || inst == null) {
return;
}
JSONObject propsObj = node.getJSONObject("props");
if (propsObj == null) {
return;
}
JSONObject callbackActions = propsObj.getJSONObject("callbackActions");
if (callbackActions == null) {
return;
}
JSONArray actions = callbackActions.getJSONArray(phase);
if (actions == null || actions.isEmpty()) {
return;
}
String token = currentToken();
for (int i = 0; i < actions.size(); i++) {
JSONObject action = actions.getJSONObject(i);
if (action == null) {
continue;
}
String url = action.getString("url");
if (oConvertUtils.isEmpty(url)) {
continue;
}
if (oConvertUtils.isEmpty(token)) {
// 无登录态(自动流转/无人值守) -> 降级跳过
log.warn("[审批回调] 无当前处理人登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, inst.getBizDataId());
continue;
}
String method = oConvertUtils.getString(action.getString("method"), "POST").toUpperCase();
invoke(method, url, action.getJSONObject("body"), inst.getBizDataId(), token);
}
}
private void invoke(String method, String url, JSONObject recordedBody, String bizDataId, String token) {
String fullUrl = buildFullUrl(url);
HttpMethod httpMethod = HttpMethod.valueOf(method);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.add(CommonConstant.X_ACCESS_TOKEN, token);
headers.add(HttpHeaders.AUTHORIZATION, token);
Object bodyToSend = null;
if ("GET".equals(method) || "DELETE".equals(method)) {
// 查询/删除单据ID拼到 url
String sep = fullUrl.contains("?") ? "&" : "?";
fullUrl = fullUrl + sep + "id=" + bizDataId + "&dataId=" + bizDataId;
} else {
// 写操作:合并录制的 body并用单据ID覆盖 id
JSONObject body = recordedBody == null ? new JSONObject() : new JSONObject(recordedBody);
body.put("id", bizDataId);
bodyToSend = body;
}
try {
HttpEntity<Object> entity = new HttpEntity<>(bodyToSend, headers);
ResponseEntity<String> resp = restTemplate.exchange(fullUrl, httpMethod, entity, String.class);
String respBody = resp.getBody();
if (!resp.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("回调接口返回非2xx" + resp.getStatusCode());
}
// Jeecg 统一返回 {success:false,...} 视为业务失败
if (oConvertUtils.isNotEmpty(respBody)) {
try {
JSONObject r = JSONObject.parseObject(respBody);
if (r != null && r.containsKey("success") && Boolean.FALSE.equals(r.getBoolean("success"))) {
throw new RuntimeException("回调接口业务失败:" + oConvertUtils.getString(r.getString("message"), "未知错误"));
}
} catch (RuntimeException re) {
throw re;
} catch (Exception ignore) {
// 非JSON响应不强校验
}
}
log.info("[审批回调] 已调用业务接口成功 {} {} bizId={}", method, fullUrl, bizDataId);
} catch (RuntimeException e) {
log.error("[审批回调] 调用业务接口失败 {} {} bizId={}", method, fullUrl, bizDataId, e);
// 抛出以回滚整个审批动作,保证审批与业务一致
throw e;
}
}
/** 构建内部调用绝对地址http://127.0.0.1:port + context-path + url */
private String buildFullUrl(String url) {
String ctx = oConvertUtils.getString(contextPath, "");
if (oConvertUtils.isNotEmpty(ctx) && !ctx.startsWith("/")) {
ctx = "/" + ctx;
}
String path = url.startsWith("/") ? url : "/" + url;
// 录制到的路径已含 context-path 时不重复拼接
if (oConvertUtils.isNotEmpty(ctx) && (path.equals(ctx) || path.startsWith(ctx + "/"))) {
ctx = "";
}
return "http://127.0.0.1:" + serverPort + ctx + path;
}
/** 取当前请求的登录 token处理人身份 */
private String currentToken() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return null;
}
HttpServletRequest request = attrs.getRequest();
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
if (oConvertUtils.isEmpty(token)) {
token = request.getHeader(HttpHeaders.AUTHORIZATION);
}
return token;
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,80 @@
package org.jeecg.modules.xslmes.approval.callback;
import lombok.Data;
import lombok.experimental.Accessors;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import java.io.Serializable;
/**
* 审批回调上下文。
* 由审批引擎在「节点通过 / 最终通过 / 驳回」时构建并传给业务回调,
* 业务模块据此调用自身已有的审核/回写接口,实现审批与业务功能的统一联动。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调
*/
@Data
@Accessors(chain = true)
public class ApprovalCallbackContext implements Serializable {
private static final long serialVersionUID = 1L;
/** 回调动作类型 */
public enum Action {
/** 单个审批节点通过(中间态,每个节点都会触发一次) */
NODE_APPROVED,
/** 整个流程最终通过 */
APPROVED,
/** 被驳回(任一节点驳回即终止) */
REJECTED
}
/** 回调动作 */
private Action action;
/** 审批实例ID */
private String instanceId;
/** 审批流定义ID */
private String flowId;
/** 审批流名称 */
private String flowName;
/** 业务单据表名 */
private String bizTable;
/** 业务单据中文名 */
private String bizTableName;
/** 业务单据记录ID业务表主键 */
private String bizDataId;
/** 业务单据展示标题 */
private String bizTitle;
/** 当前/刚处理的节点ID */
private String nodeId;
/** 当前/刚处理的节点名称 */
private String nodeName;
/** 操作人 username系统自动处理时为 null/system */
private String operatorUsername;
/** 操作人姓名 */
private String operatorName;
/** 审批意见 / 驳回理由 */
private String comment;
/** 发起人 username */
private String applyUser;
/** 是否为流程最终结束APPROVED/REJECTED 时为 true */
private boolean finalResult;
/** 完整审批实例(供业务读取租户、发起信息等) */
private transient MesXslApprovalInstance instance;
}

View File

@@ -0,0 +1,115 @@
package org.jeecg.modules.xslmes.approval.callback;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 审批业务回调分发器。
* 统一在审批引擎流转关键点调用:
* <ol>
* <li>按业务表名路由到对应的 {@link IApprovalBizCallback} 实现(强类型,业务直接调用自身接口);</li>
* <li>同时发布 {@link ApprovalActionEvent} 供 {@code @EventListener} 松耦合监听。</li>
* </ol>
* 回调与审批状态变更同事务执行,回调异常将向上抛出以回滚整个审批动作。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调
*/
@Slf4j
@Component
public class ApprovalCallbackDispatcher {
/** 监听所有业务表的通配符 */
private static final String ANY_TABLE = "*";
private final ObjectProvider<List<IApprovalBizCallback>> callbacksProvider;
private final ApplicationEventPublisher eventPublisher;
public ApprovalCallbackDispatcher(ObjectProvider<List<IApprovalBizCallback>> callbacksProvider,
ApplicationEventPublisher eventPublisher) {
this.callbacksProvider = callbacksProvider;
this.eventPublisher = eventPublisher;
}
/** 节点通过(中间态) */
public void fireNodeApproved(ApprovalCallbackContext ctx) {
ctx.setAction(ApprovalCallbackContext.Action.NODE_APPROVED);
ctx.setFinalResult(false);
dispatch(ctx);
}
/** 流程最终通过 */
public void fireApproved(ApprovalCallbackContext ctx) {
ctx.setAction(ApprovalCallbackContext.Action.APPROVED);
ctx.setFinalResult(true);
dispatch(ctx);
}
/** 驳回 */
public void fireRejected(ApprovalCallbackContext ctx) {
ctx.setAction(ApprovalCallbackContext.Action.REJECTED);
ctx.setFinalResult(true);
dispatch(ctx);
}
private void dispatch(ApprovalCallbackContext ctx) {
if (ctx == null || oConvertUtils.isEmpty(ctx.getBizTable())) {
return;
}
// 1) 强类型回调:按表路由 + 通配
for (IApprovalBizCallback cb : matchedCallbacks(ctx.getBizTable())) {
invoke(cb, ctx);
}
// 2) 领域事件:松耦合监听(同步、同事务)
try {
eventPublisher.publishEvent(new ApprovalActionEvent(this, ctx));
} catch (RuntimeException e) {
log.error("审批领域事件处理失败 table={}, bizId={}, action={}", ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e);
throw e;
}
}
private List<IApprovalBizCallback> matchedCallbacks(String bizTable) {
List<IApprovalBizCallback> all = callbacksProvider.getIfAvailable();
List<IApprovalBizCallback> matched = new ArrayList<>();
if (all == null) {
return matched;
}
for (IApprovalBizCallback cb : all) {
String support = cb.supportTable();
if (ANY_TABLE.equals(support) || (support != null && support.equalsIgnoreCase(bizTable))) {
matched.add(cb);
}
}
return matched;
}
private void invoke(IApprovalBizCallback cb, ApprovalCallbackContext ctx) {
try {
switch (ctx.getAction()) {
case NODE_APPROVED:
cb.onNodeApproved(ctx);
break;
case APPROVED:
cb.onApproved(ctx);
break;
case REJECTED:
cb.onRejected(ctx);
break;
default:
break;
}
} catch (RuntimeException e) {
log.error("审批业务回调执行失败 callback={}, table={}, bizId={}, action={}",
cb.getClass().getSimpleName(), ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e);
// 抛出以回滚整个审批动作,保证审批与业务数据一致
throw e;
}
}
}

View File

@@ -0,0 +1,64 @@
package org.jeecg.modules.xslmes.approval.callback;
/**
* 业务审批回调扩展点SPI
*
* 各业务模块按需实现本接口并声明 {@link #supportTable()}(绑定的业务表名),
* 审批引擎会在审批流转的关键节点自动回调对应实现,业务模块在回调里
* 调用自己「已有的」审核/回写接口(如更新审核状态、扣减库存、生成下游单据等),
* 从而把审批流程与业务单据的功能统一串联起来。
*
* <p>事务说明:回调与审批状态变更处于同一事务内,
* 若回调抛出异常,整个审批动作回滚(审批失败),保证审批与业务数据一致。</p>
*
* <p>使用方式:实现类标注为 Spring Bean@Component / @Service即可被自动收集。</p>
*
* <pre>{@code
* @Component
* public class RubberStdApprovalCallback implements IApprovalBizCallback {
* @Override public String supportTable() { return "mes_xsl_rubber_quick_test_std"; }
* @Override public void onApproved(ApprovalCallbackContext ctx) {
* // 调用业务自身已有接口完成回写
* stdService.lambdaUpdate()
* .eq(MesXslRubberQuickTestStd::getId, ctx.getBizDataId())
* .set(MesXslRubberQuickTestStd::getAuditStatus, "1").update();
* }
* }
* }</pre>
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调
*/
public interface IApprovalBizCallback {
/**
* 绑定的业务表名(与审批流定义 bizTable 一致)。
* 返回 "*" 表示监听所有业务表。
*/
String supportTable();
/**
* 单个审批节点通过(中间态)。每经过一个审批节点通过都会触发一次。
* 适合更新中间状态(如「审核中」「已校对」等)。
* 默认空实现,业务按需重写。
*/
default void onNodeApproved(ApprovalCallbackContext ctx) {
// 默认不处理
}
/**
* 整个审批流程最终通过。适合执行终态业务(如置为「已批准」、生效、扣库存等)。
* 默认空实现,业务按需重写。
*/
default void onApproved(ApprovalCallbackContext ctx) {
// 默认不处理
}
/**
* 审批被驳回(流程终止)。适合回退业务状态(如置回「草稿」、释放占用等)。
* 默认空实现,业务按需重写。
*/
default void onRejected(ApprovalCallbackContext ctx) {
// 默认不处理
}
}

View File

@@ -0,0 +1,59 @@
package org.jeecg.modules.xslmes.approval.callback.impl;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback;
import org.jeecg.modules.xslmes.common.XslMesBizConstants;
import org.jeecg.modules.xslmes.entity.MesXslRubberQuickTestStd;
import org.jeecg.modules.xslmes.service.IMesXslRubberQuickTestStdService;
import org.springframework.stereotype.Component;
/**
* 胶料快检实验标准 审批回调示例。
* 演示如何把审批流转结果联动到业务单据已有功能:
* 审批最终通过 -> 审核状态置「已批准」;驳回 -> 回退「草稿」。
*
* 业务在回调里直接调用自身的 service此处用 lambdaUpdate 更新审核状态字段),
* 与原有「批准/反审核」逻辑保持统一。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调-示例
*/
@Slf4j
@Component
public class RubberQuickTestStdApprovalCallback implements IApprovalBizCallback {
private final IMesXslRubberQuickTestStdService stdService;
public RubberQuickTestStdApprovalCallback(IMesXslRubberQuickTestStdService stdService) {
this.stdService = stdService;
}
@Override
public String supportTable() {
return "mes_xsl_rubber_quick_test_std";
}
@Override
public void onApproved(ApprovalCallbackContext ctx) {
updateAuditStatus(ctx.getBizDataId(), XslMesBizConstants.RUBBER_QUICK_TEST_STD_AUDIT_APPROVED);
log.info("[审批联动] 实验标准 {} 审批通过,审核状态置为已批准", ctx.getBizDataId());
}
@Override
public void onRejected(ApprovalCallbackContext ctx) {
updateAuditStatus(ctx.getBizDataId(), XslMesBizConstants.RUBBER_QUICK_TEST_STD_AUDIT_DRAFT);
log.info("[审批联动] 实验标准 {} 被驳回,审核状态回退为草稿", ctx.getBizDataId());
}
private void updateAuditStatus(String bizDataId, String auditStatus) {
if (oConvertUtils.isEmpty(bizDataId)) {
return;
}
stdService.lambdaUpdate()
.eq(MesXslRubberQuickTestStd::getId, bizDataId)
.set(MesXslRubberQuickTestStd::getAuditStatus, auditStatus)
.update();
}
}

View File

@@ -0,0 +1,433 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.api.ISysBaseAPI;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.vo.DictModel;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* MES 审批流设计
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
@Tag(name = "MES审批流设计")
@RestController
@RequestMapping("/xslmes/approvalFlow")
@Slf4j
public class MesXslApprovalFlowController extends JeecgController<MesXslApprovalFlow, IMesXslApprovalFlowService> {
@Autowired
private IMesXslApprovalFlowService mesXslApprovalFlowService;
@Autowired
private ISysBaseAPI sysBaseAPI;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页字段解析-----
@Autowired
private JdbcTemplate jdbcTemplate;
/** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$");
/**
* 审批阶段关键字配置有序key=阶段标识name=阶段中文nodeType=对应节点类型keywords=列注释匹配关键字。
* 解析顺序即默认流程顺序:校对 -> 审核 -> 审批 -> 分发 -> 抄送。
*/
private static final String[][] STAGE_DEFS = new String[][]{
{"proofread", "校对", "approver", "校对"},
{"review", "审核", "approver", "审核|审查"},
{"approve", "审批", "approver", "审批|批准|核准"},
{"distribute", "分发", "approver", "分发|发放"},
{"cc", "抄送", "cc", "抄送"},
};
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页字段解析-----
/**
* 根据所选单据表名翻译字典,回填单据中文名
*/
private void fillBizTableName(MesXslApprovalFlow flow) {
if (oConvertUtils.isEmpty(flow.getBizTable())) {
return;
}
List<DictModel> items = sysBaseAPI.getDictItems("mes_xsl_approval_biz_doc");
if (items != null) {
for (DictModel item : items) {
if (flow.getBizTable().equals(item.getValue())) {
flow.setBizTableName(item.getText());
break;
}
}
}
}
@Operation(summary = "审批流设计-分页列表查询")
@RequiresPermissions("approval:flow:list")
@GetMapping(value = "/list")
public Result<IPage<MesXslApprovalFlow>> queryPageList(
MesXslApprovalFlow mesXslApprovalFlow,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslApprovalFlow> queryWrapper = QueryGenerator.initQueryWrapper(mesXslApprovalFlow, req.getParameterMap());
// 列表查询不返回大字段 flowConfig避免传输冗余
queryWrapper.select(MesXslApprovalFlow.class, info -> !"flow_config".equals(info.getColumn()));
queryWrapper.orderByDesc("create_time");
Page<MesXslApprovalFlow> page = new Page<>(pageNo, pageSize);
IPage<MesXslApprovalFlow> pageList = mesXslApprovalFlowService.page(page, queryWrapper);
return Result.OK(pageList);
}
@AutoLog(value = "审批流设计-添加")
@Operation(summary = "审批流设计-添加")
@RequiresPermissions("approval:flow:add")
@PostMapping(value = "/add")
public Result<String> add(@RequestBody MesXslApprovalFlow mesXslApprovalFlow) {
if (oConvertUtils.isEmpty(mesXslApprovalFlow.getFlowName())) {
return Result.error("审批流名称不能为空");
}
if (oConvertUtils.isEmpty(mesXslApprovalFlow.getBizTable())) {
return Result.error("请先选择绑定单据");
}
if (oConvertUtils.isEmpty(mesXslApprovalFlow.getStatus())) {
mesXslApprovalFlow.setStatus("0");
}
fillBizTableName(mesXslApprovalFlow);
mesXslApprovalFlowService.save(mesXslApprovalFlow);
return Result.OK("添加成功!");
}
@AutoLog(value = "审批流设计-编辑")
@Operation(summary = "审批流设计-编辑")
@RequiresPermissions("approval:flow:edit")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
public Result<String> edit(@RequestBody MesXslApprovalFlow mesXslApprovalFlow) {
if (oConvertUtils.isEmpty(mesXslApprovalFlow.getId())) {
return Result.error("主键不能为空");
}
fillBizTableName(mesXslApprovalFlow);
mesXslApprovalFlowService.updateById(mesXslApprovalFlow);
return Result.OK("编辑成功!");
}
@AutoLog(value = "审批流设计-保存流程设计")
@Operation(summary = "审批流设计-保存流程设计")
@RequiresPermissions("approval:flow:design")
@PostMapping(value = "/saveDesign")
public Result<String> saveDesign(@RequestBody MesXslApprovalFlow mesXslApprovalFlow) {
if (oConvertUtils.isEmpty(mesXslApprovalFlow.getId())) {
return Result.error("主键不能为空");
}
MesXslApprovalFlow update = new MesXslApprovalFlow();
update.setId(mesXslApprovalFlow.getId());
update.setFlowConfig(mesXslApprovalFlow.getFlowConfig());
// 设计保存时若仍为草稿则置为已发布
if (oConvertUtils.isNotEmpty(mesXslApprovalFlow.getStatus())) {
update.setStatus(mesXslApprovalFlow.getStatus());
}
mesXslApprovalFlowService.updateById(update);
return Result.OK("流程设计已保存!");
}
@AutoLog(value = "审批流设计-发布/停用")
@Operation(summary = "审批流设计-发布/停用")
@RequiresPermissions("approval:flow:design")
@PostMapping(value = "/updateStatus")
public Result<String> updateStatus(
@RequestParam(name = "id") String id,
@RequestParam(name = "status") String status) {
if (!"0".equals(status) && !"1".equals(status) && !"2".equals(status)) {
return Result.error("状态参数非法");
}
boolean ok = mesXslApprovalFlowService.lambdaUpdate()
.eq(MesXslApprovalFlow::getId, id)
.set(MesXslApprovalFlow::getStatus, status)
.update();
return ok ? Result.OK("操作成功") : Result.error("操作失败");
}
@AutoLog(value = "审批流设计-删除")
@Operation(summary = "审批流设计-删除")
@RequiresPermissions("approval:flow:delete")
@DeleteMapping(value = "/delete")
public Result<String> delete(@RequestParam(name = "id") String id) {
mesXslApprovalFlowService.removeById(id);
return Result.OK("删除成功!");
}
@AutoLog(value = "审批流设计-批量删除")
@Operation(summary = "审批流设计-批量删除")
@RequiresPermissions("approval:flow:delete")
@DeleteMapping(value = "/deleteBatch")
public Result<String> deleteBatch(@RequestParam(name = "ids") String ids) {
mesXslApprovalFlowService.removeByIds(Arrays.asList(ids.split(",")));
return Result.OK("批量删除成功!");
}
@Operation(summary = "审批流设计-通过id查询")
@GetMapping(value = "/queryById")
public Result<MesXslApprovalFlow> queryById(@RequestParam(name = "id") String id) {
MesXslApprovalFlow entity = mesXslApprovalFlowService.getById(id);
if (entity == null) {
return Result.error("未找到对应数据");
}
return Result.OK(entity);
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-当前页字段解析-----
/**
* 设计上下文:供全局"审批流程设计"悬浮按钮调用。
* 1) 根据当前功能页路由反查绑定的业务表;
* 2) 解析该表的字段,识别"校对/审核/审批/分发/抄送"等阶段字段(不存在不报错,存在即解析);
* 3) 取/建该业务表的草稿审批流,返回流程记录(含id)供直接进入设计器。
*
* @param routePath 当前功能页前端路由(如 /xslmes/mesXslFormulaSpec/MesXslFormulaSpecList)
*/
@Operation(summary = "审批流设计-当前页设计上下文")
@RequiresPermissions("approval:flow:design")
@GetMapping(value = "/designContext")
public Result<Map<String, Object>> designContext(@RequestParam(name = "routePath") String routePath) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("routePath", routePath);
data.put("bizTable", null);
data.put("bizTableName", null);
data.put("stages", new ArrayList<>());
data.put("flow", null);
// 1) 路由 -> 业务表名(无法反查时不报错,返回空上下文,前端提示)
String table = resolveTableByRoutePath(routePath);
if (oConvertUtils.isEmpty(table) || !tableExists(table)) {
return Result.OK(data);
}
data.put("bizTable", table);
// 2) 业务表中文名:优先字典,其次表注释
String bizTableName = resolveBizTableName(table);
data.put("bizTableName", bizTableName);
// 3) 解析阶段字段
data.put("stages", parseStageFields(table));
// 4) 取/建草稿审批流
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
MesXslApprovalFlow flow = findFlowByTable(table, tenantId);
if (flow == null) {
flow = new MesXslApprovalFlow();
flow.setFlowName((oConvertUtils.isNotEmpty(bizTableName) ? bizTableName : table) + "审批流");
flow.setBizTable(table);
flow.setBizTableName(bizTableName);
flow.setRoutePath(routePath);
flow.setStatus("0");
if (tenantId != null) {
flow.setTenantId(tenantId);
}
mesXslApprovalFlowService.save(flow);
} else if (oConvertUtils.isEmpty(flow.getRoutePath())) {
// 历史数据未记录路由,回填一次便于后续发起按钮匹配
mesXslApprovalFlowService.lambdaUpdate()
.eq(MesXslApprovalFlow::getId, flow.getId())
.set(MesXslApprovalFlow::getRoutePath, routePath)
.update();
flow.setRoutePath(routePath);
}
data.put("flow", flow);
return Result.OK(data);
}
/**
* 根据前端路由反查业务表名。
* 约定jeecg 代码生成的列表组件名为 表名驼峰 + Listsys_permission.component 形如
* xslmes/mesXslFormulaSpec/MesXslFormulaSpecListurl 即路由。
* 反查url=routePath -> component -> 末段组件名去掉 List -> 驼峰转下划线得到表名。
*/
private String resolveTableByRoutePath(String routePath) {
if (oConvertUtils.isEmpty(routePath)) {
return null;
}
String path = routePath.trim().replaceAll("/+$", "");
String sql = "SELECT component FROM sys_permission WHERE menu_type IN (0,1) "
+ "AND (del_flag = 0 OR del_flag IS NULL) AND url = ? "
+ "ORDER BY menu_type DESC LIMIT 1";
String component;
try {
List<String> list = jdbcTemplate.queryForList(sql, String.class, path);
if (list.isEmpty()) {
return null;
}
component = list.get(0);
} catch (Exception e) {
log.warn("反查菜单组件失败 routePath={}", routePath, e);
return null;
}
if (oConvertUtils.isEmpty(component)) {
return null;
}
// 取组件路径末段xslmes/mesXslFormulaSpec/MesXslFormulaSpecList -> MesXslFormulaSpecList
String comp = component.contains("/") ? component.substring(component.lastIndexOf('/') + 1) : component;
if (comp.endsWith("List")) {
comp = comp.substring(0, comp.length() - "List".length());
}
return camelToUnderline(comp);
}
/** 驼峰转下划线小写MesXslFormulaSpec -> mes_xsl_formula_spec */
private String camelToUnderline(String camel) {
if (oConvertUtils.isEmpty(camel)) {
return null;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < camel.length(); i++) {
char c = camel.charAt(i);
if (Character.isUpperCase(c)) {
if (i > 0) {
sb.append('_');
}
sb.append(Character.toLowerCase(c));
} else {
sb.append(c);
}
}
return sb.toString();
}
/** 校验表是否存在于当前库 */
private boolean tableExists(String table) {
if (!IDENTIFIER.matcher(table).matches()) {
return false;
}
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = (SELECT DATABASE()) AND table_name = ?",
Integer.class, table);
return cnt != null && cnt > 0;
} catch (Exception e) {
log.warn("校验表存在失败 table={}", table, e);
return false;
}
}
/** 业务表中文名:优先字典 mes_xsl_approval_biz_doc其次表注释 */
private String resolveBizTableName(String table) {
List<DictModel> items = sysBaseAPI.getDictItems("mes_xsl_approval_biz_doc");
if (items != null) {
for (DictModel item : items) {
if (table.equals(item.getValue())) {
return item.getText();
}
}
}
try {
List<String> comments = jdbcTemplate.queryForList(
"SELECT table_comment FROM information_schema.tables WHERE table_schema = (SELECT DATABASE()) AND table_name = ?",
String.class, table);
if (!comments.isEmpty() && oConvertUtils.isNotEmpty(comments.get(0))) {
return comments.get(0);
}
} catch (Exception e) {
log.warn("查询表注释失败 table={}", table, e);
}
return null;
}
/**
* 解析表字段,识别审批阶段字段。每个阶段最多取一个字段(优先列注释含"人/员"的人员字段)。
* 返回有序列表:[{stageKey, stageName, nodeType, field, fieldComment}]
*/
private List<Map<String, Object>> parseStageFields(String table) {
List<Map<String, Object>> stages = new ArrayList<>();
List<Map<String, Object>> columns;
try {
columns = jdbcTemplate.queryForList(
"SELECT column_name AS name, column_comment AS comment FROM information_schema.columns "
+ "WHERE table_schema = (SELECT DATABASE()) AND table_name = ? ORDER BY ordinal_position",
table);
} catch (Exception e) {
log.warn("查询表字段失败 table={}", table, e);
return stages;
}
for (String[] def : STAGE_DEFS) {
String stageKey = def[0];
String stageName = def[1];
String nodeType = def[2];
String[] keywords = def[3].split("\\|");
Map<String, Object> hit = matchStageColumn(columns, keywords);
if (hit != null) {
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("stageKey", stageKey);
stage.put("stageName", stageName);
stage.put("nodeType", nodeType);
stage.put("field", hit.get("name"));
stage.put("fieldComment", hit.get("comment"));
stages.add(stage);
}
}
return stages;
}
/** 在列集合中按关键字匹配阶段字段,优先返回注释含"人/员"的人员字段 */
private Map<String, Object> matchStageColumn(List<Map<String, Object>> columns, String[] keywords) {
Map<String, Object> firstMatch = null;
for (Map<String, Object> col : columns) {
String comment = col.get("comment") == null ? "" : String.valueOf(col.get("comment"));
if (oConvertUtils.isEmpty(comment)) {
continue;
}
boolean matched = false;
for (String kw : keywords) {
if (comment.contains(kw)) {
matched = true;
break;
}
}
if (!matched) {
continue;
}
if (firstMatch == null) {
firstMatch = col;
}
// 人员字段优先(如"校对人""审核员"
if (comment.contains("") || comment.contains("")) {
return col;
}
}
return firstMatch;
}
/** 按业务表+租户查找审批流(取最近一条) */
private MesXslApprovalFlow findFlowByTable(String table, Integer tenantId) {
QueryWrapper<MesXslApprovalFlow> qw = new QueryWrapper<>();
qw.eq("biz_table", table);
if (tenantId != null) {
qw.eq("tenant_id", tenantId);
}
qw.orderByDesc("create_time");
qw.last("LIMIT 1");
return mesXslApprovalFlowService.getOne(qw, false);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-当前页字段解析-----
}

View File

@@ -0,0 +1,74 @@
package org.jeecg.modules.xslmes.approval.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* MES 审批办理(运行时-流转)。
* 供 IM 审批卡片按钮调用:查看详情 / 审批通过 / 驳回。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批办理/流转
*/
@Tag(name = "MES审批办理")
@RestController
@RequestMapping("/xslmes/approvalHandle")
@Slf4j
public class MesXslApprovalHandleController {
@Autowired
private IMesXslApprovalHandleService approvalHandleService;
@Operation(summary = "审批办理-单据详情")
@GetMapping("/detail")
public Result<Map<String, Object>> detail(@RequestParam("instanceId") String instanceId) {
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
Map<String, Object> data = approvalHandleService.detail(instanceId, user);
if (data == null || data.isEmpty()) {
return Result.error("审批实例不存在");
}
return Result.OK(data);
}
@Operation(summary = "审批办理-实时状态(卡片置灰判断)")
@GetMapping("/status")
public Result<Map<String, Object>> status(@RequestParam("instanceId") String instanceId) {
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
Map<String, Object> data = approvalHandleService.statusInfo(instanceId, user);
return Result.OK(data);
}
@Operation(summary = "审批办理-通过")
@PostMapping("/approve")
public Result<String> approve(@RequestBody Map<String, Object> body) {
String instanceId = (String) body.get("instanceId");
String comment = body.get("comment") == null ? null : String.valueOf(body.get("comment"));
if (oConvertUtils.isEmpty(instanceId)) {
return Result.error("缺少审批实例ID");
}
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
return approvalHandleService.approve(instanceId, comment, user);
}
@Operation(summary = "审批办理-驳回")
@PostMapping("/reject")
public Result<String> reject(@RequestBody Map<String, Object> body) {
String instanceId = (String) body.get("instanceId");
String reason = body.get("reason") == null ? null : String.valueOf(body.get("reason"));
if (oConvertUtils.isEmpty(instanceId)) {
return Result.error("缺少审批实例ID");
}
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
return approvalHandleService.reject(instanceId, reason, user);
}
}

View File

@@ -0,0 +1,242 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* MES 审批发起(运行时-本期仅发起)
* 供全局"发起审批"悬浮按钮调用:选单据类型 -> 选单据记录 -> 发起。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批运行时
*/
@Tag(name = "MES审批发起")
@RestController
@RequestMapping("/xslmes/approvalLaunch")
@Slf4j
public class MesXslApprovalLaunchController {
/** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$");
@Autowired
private IMesXslApprovalFlowService approvalFlowService;
@Autowired
private IMesXslApprovalInstanceService approvalInstanceService;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起改用流转引擎进入首节点-----
@Autowired
private IMesXslApprovalHandleService approvalHandleService;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起改用流转引擎进入首节点-----
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 已发布审批流列表(按租户隔离),即"可发起的单据类型"。
* 同时按"功能模块(单据表)"自动反查其菜单路由填入 routePath供前端控制悬浮按钮仅在该功能页显示无需手工配置。
*/
@Operation(summary = "发起审批-已发布审批流列表")
@GetMapping("/publishedList")
public Result<List<MesXslApprovalFlow>> publishedList() {
QueryWrapper<MesXslApprovalFlow> qw = new QueryWrapper<>();
qw.eq("status", "1");
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
if (tenantId != null) {
qw.eq("tenant_id", tenantId);
}
qw.orderByDesc("create_time");
List<MesXslApprovalFlow> list = approvalFlowService.list(qw);
// 未手工指定 route_path 时,按单据表名自动反查菜单路由
for (MesXslApprovalFlow flow : list) {
if (oConvertUtils.isEmpty(flow.getRoutePath())) {
flow.setRoutePath(resolveRoutePathByTable(flow.getBizTable()));
}
}
return Result.OK(list);
}
/**
* 根据单据表名反查对应功能菜单的前端路由。
* 约定jeecg 代码生成的列表组件名为 表名驼峰 + List如 mes_xsl_formula_spec -> MesXslFormulaSpecList
* 对应 sys_permission.component 形如 xslmes/mesXslFormulaSpec/MesXslFormulaSpecList取其 url 即路由。
*/
private String resolveRoutePathByTable(String table) {
if (oConvertUtils.isEmpty(table) || !IDENTIFIER.matcher(table).matches()) {
return null;
}
StringBuilder comp = new StringBuilder();
for (String p : table.split("_")) {
if (oConvertUtils.isEmpty(p)) {
continue;
}
comp.append(Character.toUpperCase(p.charAt(0))).append(p.substring(1));
}
comp.append("List");
String sql = "SELECT url FROM sys_permission WHERE menu_type IN (0,1) "
+ "AND (del_flag = 0 OR del_flag IS NULL) "
+ "AND (component LIKE ? OR component LIKE ?) "
+ "ORDER BY menu_type DESC LIMIT 1";
try {
List<String> urls = jdbcTemplate.queryForList(sql, String.class, "%/" + comp, "%" + comp);
return urls.isEmpty() ? null : urls.get(0);
} catch (Exception e) {
log.warn("反查菜单路由失败 table={}", table, e);
return null;
}
}
/**
* 根据审批流绑定的单据表查询业务单据记录id + 标题),供发起时选择
*/
@Operation(summary = "发起审批-业务单据记录列表")
@GetMapping("/bizRecords")
public Result<List<Map<String, Object>>> bizRecords(
@RequestParam("flowId") String flowId,
@RequestParam(value = "keyword", required = false) String keyword) {
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null) {
return Result.error("审批流不存在");
}
String table = flow.getBizTable();
String titleField = flow.getTitleField();
if (oConvertUtils.isEmpty(table) || !IDENTIFIER.matcher(table).matches()) {
return Result.error("单据表名非法");
}
boolean hasTitle = oConvertUtils.isNotEmpty(titleField) && IDENTIFIER.matcher(titleField).matches();
StringBuilder sql = new StringBuilder("SELECT id, ");
sql.append(hasTitle ? titleField : "id").append(" AS title FROM ").append(table);
List<Object> args = new ArrayList<>();
if (hasTitle && oConvertUtils.isNotEmpty(keyword)) {
sql.append(" WHERE ").append(titleField).append(" LIKE CONCAT('%', ?, '%')");
args.add(keyword);
}
// 主键一定存在,按 id 倒序近似最新;限制条数防全表
sql.append(" ORDER BY id DESC LIMIT 100");
try {
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString(), args.toArray());
return Result.OK(list);
} catch (Exception e) {
log.error("查询业务单据失败 table={}, field={}", table, titleField, e);
return Result.error("查询业务单据失败:" + e.getMessage());
}
}
/**
* 发起审批:根据审批流定义创建审批实例,解析首个审批节点处理人
*/
@Operation(summary = "发起审批-发起")
@PostMapping("/launch")
public Result<String> launch(@RequestBody Map<String, Object> body) {
String flowId = (String) body.get("flowId");
String bizDataId = (String) body.get("bizDataId");
String bizTitle = (String) body.get("bizTitle");
if (oConvertUtils.isEmpty(flowId) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("请选择审批流和单据");
}
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null) {
return Result.error("审批流不存在");
}
if (!"1".equals(flow.getStatus())) {
return Result.error("该审批流未发布,无法发起");
}
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalHandleService.enterFirstNode(inst, loginUser);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
return Result.OK("发起成功!");
}
/**
* 批量发起审批:用于列表多选后一次性发起
*/
@Operation(summary = "发起审批-批量发起")
@PostMapping("/launchBatch")
public Result<String> launchBatch(@RequestBody Map<String, Object> body) {
String flowId = (String) body.get("flowId");
Object itemsObj = body.get("items");
if (oConvertUtils.isEmpty(flowId) || !(itemsObj instanceof List) || ((List<?>) itemsObj).isEmpty()) {
return Result.error("请选择审批流和单据");
}
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null) {
return Result.error("审批流不存在");
}
if (!"1".equals(flow.getStatus())) {
return Result.error("该审批流未发布,无法发起");
}
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】批量发起逐条进入首节点(支持按单据字段解析处理人/会签)-----
int count = 0;
for (Object o : (List<?>) itemsObj) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String bizDataId = item.get("bizDataId") == null ? null : String.valueOf(item.get("bizDataId"));
String bizTitle = item.get("bizTitle") == null ? null : String.valueOf(item.get("bizTitle"));
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalHandleService.enterFirstNode(inst, loginUser);
count++;
}
if (count == 0) {
return Result.error("没有有效的单据数据");
}
return Result.OK("已发起 " + count + " 条审批!");
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】批量发起逐条进入首节点(支持按单据字段解析处理人/会签)-----
}
/**
* 构建一条审批实例(基础字段;处理人解析/卡片由流转引擎完成)
*/
private MesXslApprovalInstance buildInstance(MesXslApprovalFlow flow, String bizDataId, String bizTitle, LoginUser loginUser) {
MesXslApprovalInstance inst = new MesXslApprovalInstance();
inst.setFlowId(flow.getId());
inst.setFlowName(flow.getFlowName());
inst.setBizTable(flow.getBizTable());
inst.setBizTableName(flow.getBizTableName());
inst.setBizDataId(bizDataId);
inst.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
inst.setStatus("0");
if (loginUser != null) {
inst.setApplyUser(loginUser.getUsername());
inst.setApplyUserName(loginUser.getRealname());
}
inst.setApplyTime(new Date());
inst.setTenantId(MesXslTenantUtils.resolveTenantId(flow.getTenantId()));
// 处理人解析、节点进度初始化、卡片发送统一由 IMesXslApprovalHandleService.enterFirstNode 完成
return inst;
}
}

View File

@@ -0,0 +1,68 @@
package org.jeecg.modules.xslmes.approval.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* MES 审批流定义
* 钉钉式可视化审批流设计,绑定 MES 业务单据。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_approval_flow")
@Schema(description = "MES审批流定义")
public class MesXslApprovalFlow extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "审批流名称")
private String flowName;
@Schema(description = "绑定单据表名")
@Dict(dicCode = "mes_xsl_approval_biz_doc")
private String bizTable;
@Schema(description = "绑定单据中文名(冗余展示)")
private String bizTableName;
@Schema(description = "单据标题字段名(发起选单据时展示)")
private String titleField;
@Schema(description = "对应功能页面前端路由(发起审批悬浮按钮仅在该页显示)")
private String routePath;
@Schema(description = "流程设计JSON(钉钉式节点树)")
private String flowConfig;
@Schema(description = "状态0草稿 1已发布 2已停用")
@Dict(dicCode = "mes_xsl_approval_flow_status")
private String status;
@Schema(description = "排序")
private Double sortNo;
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,95 @@
package org.jeecg.modules.xslmes.approval.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* MES 审批实例(本期仅记录发起,不含办理)
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批运行时
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_approval_instance")
@Schema(description = "MES审批实例")
public class MesXslApprovalInstance extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "审批流定义ID")
private String flowId;
@Schema(description = "审批流名称")
private String flowName;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据中文名")
private String bizTableName;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "业务单据展示标题")
private String bizTitle;
@Schema(description = "当前节点ID")
private String currentNodeId;
@Schema(description = "当前节点名称")
private String currentNodeName;
@Schema(description = "当前处理人(username逗号分隔)")
private String currentHandlers;
@Schema(description = "当前处理人展示文本")
private String currentHandlersText;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批办理/流转(会签/或签/依次)进度与历史-----
@Schema(description = "当前节点处理进度JSON(nodeId/mode/tasks)")
private String nodeProgress;
@Schema(description = "审批历史JSON数组")
private String history;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批办理/流转(会签/或签/依次)进度与历史-----
@Schema(description = "状态0审批中 1已通过 2已驳回 3已撤销")
@Dict(dicCode = "mes_xsl_approval_instance_status")
private String status;
@Schema(description = "发起人username")
private String applyUser;
@Schema(description = "发起人姓名")
private String applyUserName;
@Schema(description = "发起时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applyTime;
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,15 @@
package org.jeecg.modules.xslmes.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
/**
* MES 审批流定义 Mapper
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
@Mapper
public interface MesXslApprovalFlowMapper extends BaseMapper<MesXslApprovalFlow> {
}

View File

@@ -0,0 +1,15 @@
package org.jeecg.modules.xslmes.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
/**
* MES 审批实例 Mapper
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批运行时
*/
@Mapper
public interface MesXslApprovalInstanceMapper extends BaseMapper<MesXslApprovalInstance> {
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.approval.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
/**
* MES 审批流定义 Service
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
public interface IMesXslApprovalFlowService extends IService<MesXslApprovalFlow> {
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.xslmes.approval.service;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import java.util.Map;
/**
* MES 审批办理/流转引擎。
* 负责:发起后进入首节点、审批通过/驳回的流转推进(支持会签/或签/依次)、查看单据全字段详情。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批办理/流转
*/
public interface IMesXslApprovalHandleService {
/**
* 发起后进入首个审批节点:解析处理人、初始化节点进度、发送审批卡片。
* 实例需已先行 save带 id
*/
void enterFirstNode(MesXslApprovalInstance inst, LoginUser applyUser);
/**
* 审批通过:标记当前处理人任务完成,按节点 multiMode 判断是否流转到下一节点。
*/
Result<String> approve(String instanceId, String comment, LoginUser user);
/**
* 驳回:任一处理人驳回即终止流程,通知发起人。
*/
Result<String> reject(String instanceId, String reason, LoginUser user);
/**
* 查看单据全部字段 + 审批进度/历史,供 IM 卡片"查看详情"弹窗使用。
*/
Map<String, Object> detail(String instanceId, LoginUser user);
/**
* 轻量状态查询:供 IM 卡片实时判断是否仍可办理(旧节点卡片置灰)。
* 返回 status/statusText/currentNodeId/currentNodeName/canApprove。
*/
Map<String, Object> statusInfo(String instanceId, LoginUser user);
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.approval.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
/**
* MES 审批实例 Service
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批运行时
*/
public interface IMesXslApprovalInstanceService extends IService<MesXslApprovalInstance> {
}

View File

@@ -0,0 +1,17 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalFlowMapper;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.springframework.stereotype.Service;
/**
* MES 审批流定义 ServiceImpl
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
@Service
public class MesXslApprovalFlowServiceImpl extends ServiceImpl<MesXslApprovalFlowMapper, MesXslApprovalFlow> implements IMesXslApprovalFlowService {
}

View File

@@ -0,0 +1,17 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalInstanceMapper;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.springframework.stereotype.Service;
/**
* MES 审批实例 ServiceImpl
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批运行时
*/
@Service
public class MesXslApprovalInstanceServiceImpl extends ServiceImpl<MesXslApprovalInstanceMapper, MesXslApprovalInstance> implements IMesXslApprovalInstanceService {
}

View File

@@ -48,6 +48,21 @@ public interface ISysImChatService {
SysImMessageVO sendMessage(String userId, Integer tenantId, SysImSendMessageDTO dto);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】系统单聊消息(绕过同部门校验,供审批等系统通知场景)-----
/**
* 系统消息:以 fromUser 身份给 toUser 发送单聊消息。
* 与 sendMessage 区别:自动获取/创建单聊会话,且不做"同部门/同租户聊天"校验,专供审批通知等系统场景。
*
* @param fromUserId 发送人用户ID一般为业务发起人
* @param toUserId 接收人用户ID
* @param tenantId 租户ID
* @param content 消息内容
* @param msgType 消息类型 text/biz_record 等,空则按 text
* @return 消息VO发送失败返回 null
*/
SysImMessageVO sendSystemSingleMessage(String fromUserId, String toUserId, Integer tenantId, String content, String msgType);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】系统单聊消息(绕过同部门校验,供审批等系统通知场景)-----
void markRead(String userId, String conversationId);

View File

@@ -473,6 +473,57 @@ public class SysImChatServiceImpl implements ISysImChatService {
}
//update-end---author:cursor ---date:20260528 for【IM聊天-OA】发送消息-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】系统单聊消息(绕过同部门校验)-----
@Override
@Transactional(rollbackFor = Exception.class)
public SysImMessageVO sendSystemSingleMessage(String fromUserId, String toUserId, Integer tenantId, String content, String msgType) {
if (oConvertUtils.isEmpty(fromUserId) || oConvertUtils.isEmpty(toUserId) || oConvertUtils.isEmpty(content)) {
return null;
}
// 不给自己发送
if (fromUserId.equals(toUserId)) {
return null;
}
Integer tenant = tenantId == null ? 0 : tenantId;
Date now = new Date();
// 获取或创建单聊会话(系统通知场景,不做同部门校验)
String pairKey = buildPairKey(fromUserId, toUserId);
SysImConversation conversation = conversationMapper.selectOne(new LambdaQueryWrapper<SysImConversation>()
.eq(SysImConversation::getTenantId, tenant)
.eq(SysImConversation::getUserPairKey, pairKey));
if (conversation == null) {
conversation = new SysImConversation();
conversation.setConvType(CONV_TYPE_SINGLE);
conversation.setUserPairKey(pairKey);
conversation.setTenantId(tenant);
conversation.setCreateBy(fromUserId);
conversation.setCreateTime(now);
conversation.setUpdateTime(now);
conversationMapper.insert(conversation);
createMember(conversation.getId(), fromUserId, now);
createMember(conversation.getId(), toUserId, now);
}
// 写入消息
SysImMessage message = new SysImMessage();
message.setConversationId(conversation.getId());
message.setSenderId(fromUserId);
message.setContent(content.trim());
message.setMsgType(oConvertUtils.isEmpty(msgType) ? MSG_TYPE_TEXT : msgType);
message.setTenantId(tenant);
message.setCreateTime(now);
messageMapper.insert(message);
// 更新会话摘要与未读、推送
conversation.setLastContent(truncate(resolveLastContent(message.getMsgType(), message.getContent()), 200));
conversation.setLastTime(now);
conversation.setUpdateTime(now);
conversationMapper.updateById(conversation);
memberMapper.incrementUnreadExceptSender(conversation.getId(), fromUserId);
SysImMessageVO messageVo = toMessageVo(message, fromUserId);
pushChatMessage(conversation.getId(), fromUserId, messageVo, conversation.getConvType());
return messageVo;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】系统单聊消息(绕过同部门校验)-----
//update-begin---author:cursor ---date:20260528 for【IM聊天-OA】标记已读-----------
@Override
@Transactional(rollbackFor = Exception.class)

View File

@@ -0,0 +1,97 @@
-- QH-MES审批流设计审批流定义表 + 可审批单据字典 + 我的租户菜单与授权
SET NAMES utf8mb4;
-- 1) 审批流定义表
CREATE TABLE IF NOT EXISTS `mes_xsl_approval_flow` (
`id` varchar(32) NOT NULL COMMENT '主键',
`flow_name` varchar(100) NOT NULL COMMENT '审批流名称',
`biz_table` varchar(100) NOT NULL COMMENT '绑定单据表名',
`biz_table_name` varchar(200) DEFAULT NULL COMMENT '绑定单据中文名(冗余展示)',
`flow_config` longtext COMMENT '流程设计JSON(钉钉式节点树)',
`status` varchar(1) DEFAULT '0' COMMENT '状态 0草稿 1已发布 2已停用',
`sort_no` double DEFAULT '0' COMMENT '排序',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`del_flag` int DEFAULT '0' COMMENT '逻辑删除 0正常 1已删除',
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_appr_flow_tenant_biz` (`tenant_id`, `biz_table`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批流定义表';
-- 2) 可审批单据字典(单据来源:MES现有业务表) item_value=表名 item_text=单据中文名
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000310', '可审批单据', 'mes_xsl_approval_biz_doc', 'MES审批流可绑定的业务单据', 0, 'admin', NOW(), 0, 0);
INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`)
VALUES
('1995000000000000311', '1995000000000000310', '配合示方', 'mes_xsl_formula_spec', '配合示方主表', 1, 1, 'admin', NOW()),
('1995000000000000312', '1995000000000000310', '混炼示方', 'mes_xsl_mixing_spec', '混炼示方主表', 2, 1, 'admin', NOW()),
('1995000000000000313', '1995000000000000310', '密炼PS编制', 'mes_xsl_mixer_ps_compile', '密炼PS编制', 3, 1, 'admin', NOW()),
('1995000000000000314', '1995000000000000310', '胶料快检标准', 'mes_xsl_rubber_quick_test_std', '胶料快检实验标准', 4, 1, 'admin', NOW()),
('1995000000000000315', '1995000000000000310', '原料入场记录', 'mes_xsl_raw_material_entry', '原料入场记录', 5, 1, 'admin', NOW());
-- 2.1) 审批流状态字典
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000320', '审批流状态', 'mes_xsl_approval_flow_status', 'MES审批流定义状态', 0, 'admin', NOW(), 0, 0);
INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`)
VALUES
('1995000000000000321', '1995000000000000320', '草稿', '0', '草稿', 1, 1, 'admin', NOW()),
('1995000000000000322', '1995000000000000320', '已发布', '1', '已发布', 2, 1, 'admin', NOW()),
('1995000000000000323', '1995000000000000320', '已停用', '2', '已停用', 3, 1, 'admin', NOW());
-- 3) 我的租户 -> 审批流设计 菜单(parent_id=我的租户 1674708136602542082)
INSERT IGNORE INTO `sys_permission` (
`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`,
`menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`,
`hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`,
`del_flag`, `rule_flag`, `status`, `internal_or_external`
) VALUES (
'1995000000000000301', '1674708136602542082', '审批流设计', '/approval/ApprovalFlowList',
'approval/flow/ApprovalFlowList', 1, 'ApprovalFlowList', NULL,
1, NULL, '0', 4.00, 0, 'ant-design:partition-outlined', 0, 1,
0, 0, '租户审批流可视化设计', 'admin', NOW(), 'admin', NOW(),
0, 0, '1', 0
);
-- 按钮权限
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
VALUES ('1995000000000000302', '1995000000000000301', '查询', 2, 'approval:flow:list', '1', 1.00, 0, 1, 0, '1', 0, 'admin', NOW());
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
VALUES ('1995000000000000303', '1995000000000000301', '新增', 2, 'approval:flow:add', '1', 2.00, 0, 1, 0, '1', 0, 'admin', NOW());
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
VALUES ('1995000000000000304', '1995000000000000301', '编辑', 2, 'approval:flow:edit', '1', 3.00, 0, 1, 0, '1', 0, 'admin', NOW());
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
VALUES ('1995000000000000305', '1995000000000000301', '删除', 2, 'approval:flow:delete', '1', 4.00, 0, 1, 0, '1', 0, 'admin', NOW());
INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`)
VALUES ('1995000000000000306', '1995000000000000301', '设计/发布', 2, 'approval:flow:design', '1', 5.00, 0, 1, 0, '1', 0, 'admin', NOW());
-- 4) 授权给超级管理员 admin
INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`)
SELECT REPLACE(UUID(), '-', ''), r.id, p.id, NULL, NOW(), '127.0.0.1'
FROM `sys_role` r
CROSS JOIN `sys_permission` p
WHERE r.`role_code` = 'admin'
AND p.`id` IN ('1995000000000000301','1995000000000000302','1995000000000000303','1995000000000000304','1995000000000000305','1995000000000000306')
AND NOT EXISTS (
SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.`id` AND rp.`permission_id` = p.`id`
);
-- 5) 授权给租户管理员 zuhuadmin(挂在"我的租户"下必须授权,否则租户管理员看不到)
INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`)
SELECT REPLACE(UUID(), '-', ''), r.id, p.id, NULL, NOW(), '127.0.0.1'
FROM `sys_role` r
CROSS JOIN `sys_permission` p
WHERE r.`role_code` = 'zuhuadmin'
AND p.`id` IN ('1995000000000000301','1995000000000000302','1995000000000000303','1995000000000000304','1995000000000000305','1995000000000000306')
AND NOT EXISTS (
SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.`id` AND rp.`permission_id` = p.`id`
);

View File

@@ -0,0 +1,47 @@
-- QH-MES审批流设计审批实例表 + 审批流定义增加单据标题字段
SET NAMES utf8mb4;
-- 1) 审批流定义表增加"单据标题字段名"发起时用于展示具体单据
ALTER TABLE `mes_xsl_approval_flow`
ADD COLUMN `title_field` varchar(100) DEFAULT NULL COMMENT '单据标题字段名(发起选单据时展示)' AFTER `biz_table_name`;
-- 2) 审批实例表(本期仅发起记录实例与当前处理人)
CREATE TABLE IF NOT EXISTS `mes_xsl_approval_instance` (
`id` varchar(32) NOT NULL COMMENT '主键',
`flow_id` varchar(32) NOT NULL COMMENT '审批流定义ID',
`flow_name` varchar(100) DEFAULT NULL COMMENT '审批流名称',
`biz_table` varchar(100) DEFAULT NULL COMMENT '业务单据表名',
`biz_table_name` varchar(200) DEFAULT NULL COMMENT '业务单据中文名',
`biz_data_id` varchar(64) DEFAULT NULL COMMENT '业务单据记录ID',
`biz_title` varchar(300) DEFAULT NULL COMMENT '业务单据展示标题',
`current_node_id` varchar(64) DEFAULT NULL COMMENT '当前节点ID',
`current_node_name` varchar(100) DEFAULT NULL COMMENT '当前节点名称',
`current_handlers` varchar(1000) DEFAULT NULL COMMENT '当前处理人(username逗号分隔)',
`current_handlers_text` varchar(1000) DEFAULT NULL COMMENT '当前处理人展示文本',
`status` varchar(2) DEFAULT '0' COMMENT '状态 0审批中 1已通过 2已驳回 3已撤销',
`apply_user` varchar(50) DEFAULT NULL COMMENT '发起人username',
`apply_user_name` varchar(100) DEFAULT NULL COMMENT '发起人姓名',
`apply_time` datetime DEFAULT NULL COMMENT '发起时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`del_flag` int DEFAULT '0' COMMENT '逻辑删除 0正常 1已删除',
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_appr_inst_tenant` (`tenant_id`, `flow_id`),
KEY `idx_appr_inst_apply` (`apply_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批实例表';
-- 3) 审批实例状态字典
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000330', '审批实例状态', 'mes_xsl_approval_instance_status', 'MES审批实例状态', 0, 'admin', NOW(), 0, 0);
INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`)
VALUES
('1995000000000000331', '1995000000000000330', '审批中', '0', '审批中', 1, 1, 'admin', NOW()),
('1995000000000000332', '1995000000000000330', '已通过', '1', '已通过', 2, 1, 'admin', NOW()),
('1995000000000000333', '1995000000000000330', '已驳回', '2', '已驳回', 3, 1, 'admin', NOW()),
('1995000000000000334', '1995000000000000330', '已撤销', '3', '已撤销', 4, 1, 'admin', NOW());

View File

@@ -0,0 +1,5 @@
-- QH-MES审批流设计审批流定义增加"功能页面路由"用于控制发起审批悬浮按钮仅在对应功能页显示
SET NAMES utf8mb4;
ALTER TABLE `mes_xsl_approval_flow`
ADD COLUMN `route_path` varchar(255) DEFAULT NULL COMMENT '对应功能页面前端路由(发起审批悬浮按钮仅在该页显示)' AFTER `title_field`;

View File

@@ -0,0 +1,6 @@
-- QH-MES审批流设计审批办理/流转实例表增加当前节点处理进度与历史(支持会签/或签/依次)
SET NAMES utf8mb4;
ALTER TABLE `mes_xsl_approval_instance`
ADD COLUMN `node_progress` longtext COMMENT '当前节点处理进度JSON(nodeId/mode/tasks)' AFTER `current_handlers_text`,
ADD COLUMN `history` longtext COMMENT '审批历史JSON数组' AFTER `node_progress`;

View File

@@ -0,0 +1,113 @@
<!--
全局审批流程设计悬浮按钮
拥有 approval:flow:design 权限的用户在任意功能页点击即可
1后端按当前页路由反查绑定的业务表
2解析该表字段识别校对/审核/审批/分发/抄送等阶段字段不存在不报错
3进入可视化设计器可点选识别到的阶段字段按顺序生成审批流程并保存发布
@author GHT
@date 2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮
-->
<template>
<div v-if="show" class="approval-design-float" :style="floatStyle">
<div class="approval-design-btn" :class="{ 'is-loading': loading }" title="审批流程设计" @click="openDesigner">
<Icon :icon="loading ? 'ant-design:loading-outlined' : 'ant-design:partition-outlined'" :size="20" :spin="loading" />
<span class="approval-design-text">流程设计</span>
</div>
<!-- 流程设计器全屏 -->
<FlowDesign @register="registerDesign" @success="onSuccess" />
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { usePermission } from '/@/hooks/web/usePermission';
import { useModal } from '/@/components/Modal';
import { getApprovalDesignContext } from '/@/views/approval/flow/approvalFlow.api';
import FlowDesign from '/@/views/approval/flow/components/FlowDesign.vue';
defineOptions({ name: 'ApprovalDesignFloat' });
const { createMessage } = useMessage();
const { currentRoute } = useRouter();
const { hasPermission } = usePermission();
const [registerDesign, { openModal: openDesign }] = useModal();
const loading = ref(false);
// 悬浮位置(位于「发起审批」按钮上方)
const floatStyle = reactive({ right: '24px', bottom: '190px' });
// 仅拥有设计权限的用户可见
const show = computed(() => hasPermission('approval:flow:design'));
function normalizePath(p?: string) {
return (p || '').trim().replace(/\/+$/, '');
}
async function openDesigner() {
if (loading.value) return;
const path = normalizePath(currentRoute.value?.path);
if (!path) return;
try {
loading.value = true;
const ctx: any = await getApprovalDesignContext(path);
if (!ctx || !ctx.bizTable || !ctx.flow) {
createMessage.info('当前页面未能识别到可绑定的业务单据,无法设计审批流程');
return;
}
openDesign(true, {
record: ctx.flow,
readonly: false,
paletteStages: ctx.stages || [],
});
} catch (e: any) {
createMessage.error(e?.message || '获取设计上下文失败');
} finally {
loading.value = false;
}
}
function onSuccess() {
createMessage.success('审批流程已保存');
}
</script>
<style lang="less" scoped>
.approval-design-float {
position: fixed;
z-index: 999;
.approval-design-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #15bca3, #0e9e88);
color: #fff;
cursor: pointer;
box-shadow: 0 4px 14px rgba(21, 188, 163, 0.45);
transition: transform 0.18s, box-shadow 0.18s;
user-select: none;
.approval-design-text {
font-size: 11px;
line-height: 1;
transform: scale(0.92);
}
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(21, 188, 163, 0.6);
}
&.is-loading {
cursor: progress;
}
}
}
</style>

View File

@@ -0,0 +1,283 @@
<!--
全局发起审批悬浮按钮
仅在设计并发布了审批流且能匹配到对应功能页路由的页面显示
支持两种发起方式
1列表多选联动在列表勾选数据后点击弹窗自动带入选中单据并可批量发起
2手动选择未勾选时在弹窗内搜索选择单条单据发起
@author GHT
@date 2026-05-29 forQH-MES审批流设计发起审批运行时
-->
<template>
<div v-if="show" class="approval-float" :style="floatStyle">
<div class="approval-float-btn" title="发起审批" @click="openModal">
<Icon icon="ant-design:audit-outlined" :size="20" />
<span class="approval-float-text">发起审批</span>
</div>
<a-modal v-model:open="visible" title="发起审批" :width="540" :confirmLoading="loading" okText="发起审批" @ok="handleLaunch">
<a-form layout="vertical" style="margin-top: 8px">
<a-form-item label="单据类型(审批流)" required>
<a-select v-model:value="flowId" placeholder="请选择审批流" :options="flowOptions" @change="onFlowChange" allowClear />
</a-form-item>
<!-- 批量模式直接展示列表勾选的单据 -->
<a-form-item v-if="isBatch" :label="`已选单据(共 ${batchItems.length} 条)`" required>
<div class="approval-float-batch">
<div v-for="it in batchItems" :key="it.bizDataId" class="approval-float-batch-item">
<Icon icon="ant-design:file-text-outlined" :size="14" />
<span class="approval-float-batch-title">{{ it.bizTitle }}</span>
</div>
</div>
</a-form-item>
<!-- 手动模式搜索选择单条单据 -->
<a-form-item v-else label="选择单据" required>
<a-select
v-model:value="bizDataId"
show-search
placeholder="请选择需要发起审批的单据"
:filter-option="false"
:options="recordOptions"
:disabled="!flowId"
@search="onSearch"
@change="onRecordChange"
>
<template #notFoundContent>
<a-spin v-if="recordLoading" size="small" />
<span v-else>无单据数据</span>
</template>
</a-select>
<div v-if="flowId && !recordOptions.length && !recordLoading" class="approval-float-tip">
该单据暂无数据或审批流未配置单据标题字段
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { debounce } from 'lodash-es';
import { useMessage } from '/@/hooks/web/useMessage';
import { getPublishedFlows, getBizRecords, launchApproval, launchApprovalBatch } from '/@/views/approval/flow/launch.api';
import { useApprovalSelection } from './useApprovalSelection';
defineOptions({ name: 'ApprovalLaunchFloat' });
const { createMessage } = useMessage();
const { currentRoute } = useRouter();
const approvalSelection = useApprovalSelection();
const visible = ref(false);
const loading = ref(false);
const recordLoading = ref(false);
const flowId = ref<string>();
const bizDataId = ref<string>();
const bizTitle = ref<string>('');
const flowList = ref<any[]>([]);
const recordList = ref<any[]>([]);
// 打开弹窗时快照的列表勾选行(批量模式数据源)
const batchRows = ref<any[]>([]);
// 悬浮位置(右下角)
const floatStyle = reactive({ right: '24px', bottom: '120px' });
function normalizePath(p?: string) {
return (p || '').trim().replace(/\/+$/, '');
}
// 当前路由匹配到的已发布审批流
const matchedFlows = computed(() => {
const cur = normalizePath(currentRoute.value?.path);
if (!cur) return [];
return flowList.value.filter((f) => f.routePath && normalizePath(f.routePath) === cur);
});
const show = computed(() => matchedFlows.value.length > 0);
const flowOptions = computed(() =>
matchedFlows.value.map((f) => ({
label: `${f.flowName}${f.bizTableName || f.bizTable}`,
value: f.id,
}))
);
// 当前选中的审批流对象
const currentFlow = computed(() => matchedFlows.value.find((f) => f.id === flowId.value));
// 是否批量模式(列表有勾选)
const isBatch = computed(() => batchRows.value.length > 0);
// 批量模式下的单据项(用审批流的标题字段取展示标题)
const batchItems = computed(() => {
const titleField = currentFlow.value?.titleField;
return batchRows.value
.filter((r) => r && r.id != null)
.map((r) => ({
bizDataId: String(r.id),
bizTitle: titleField && r[titleField] != null ? String(r[titleField]) : String(r.id),
}));
});
const recordOptions = computed(() =>
recordList.value.map((r) => ({
label: r.title ?? r.id,
value: r.id,
}))
);
onMounted(loadFlows);
async function loadFlows() {
try {
flowList.value = (await getPublishedFlows()) || [];
} catch {
flowList.value = [];
}
}
function resetSelection() {
flowId.value = undefined;
bizDataId.value = undefined;
bizTitle.value = '';
recordList.value = [];
batchRows.value = [];
}
async function openModal() {
visible.value = true;
resetSelection();
// 读取当前列表页的勾选行
batchRows.value = approvalSelection.getRowsByPath(currentRoute.value?.path || '');
// 当前页只匹配到一个审批流时自动选中
if (matchedFlows.value.length === 1) {
flowId.value = matchedFlows.value[0].id;
}
// 手动模式且已确定审批流时预加载单据列表
if (!isBatch.value && flowId.value) {
await loadRecords();
}
}
async function loadRecords(keyword?: string) {
if (!flowId.value) return;
try {
recordLoading.value = true;
recordList.value = (await getBizRecords({ flowId: flowId.value, keyword })) || [];
} finally {
recordLoading.value = false;
}
}
function onFlowChange() {
bizDataId.value = undefined;
bizTitle.value = '';
recordList.value = [];
if (!isBatch.value && flowId.value) loadRecords();
}
const onSearch = debounce((val: string) => {
loadRecords(val);
}, 350);
function onRecordChange() {
const hit = recordList.value.find((r) => r.id === bizDataId.value);
bizTitle.value = hit ? hit.title ?? hit.id : '';
}
async function handleLaunch() {
if (!flowId.value) {
createMessage.warning('请选择审批流');
return;
}
try {
loading.value = true;
if (isBatch.value) {
await launchApprovalBatch({ flowId: flowId.value, items: batchItems.value });
createMessage.success(`已发起 ${batchItems.value.length} 条审批!`);
} else {
if (!bizDataId.value) {
createMessage.warning('请选择需要发起审批的单据');
return;
}
await launchApproval({ flowId: flowId.value, bizDataId: bizDataId.value, bizTitle: bizTitle.value });
createMessage.success('发起成功!');
}
visible.value = false;
} finally {
loading.value = false;
}
}
</script>
<style lang="less" scoped>
.approval-float {
position: fixed;
z-index: 999;
.approval-float-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #3296fa, #1668dc);
color: #fff;
cursor: pointer;
box-shadow: 0 4px 14px rgba(50, 150, 250, 0.45);
transition: transform 0.18s, box-shadow 0.18s;
user-select: none;
.approval-float-text {
font-size: 11px;
line-height: 1;
transform: scale(0.92);
}
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(50, 150, 250, 0.6);
}
}
}
.approval-float-batch {
max-height: 220px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 6px 8px;
.approval-float-batch-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 2px;
font-size: 13px;
border-bottom: 1px dashed #f0f0f0;
&:last-child {
border-bottom: none;
}
.approval-float-batch-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.approval-float-tip {
margin-top: 6px;
font-size: 12px;
color: #faad14;
}
</style>

View File

@@ -0,0 +1,33 @@
import { ref } from 'vue';
/**
* 审批发起-列表选中上下文(全局单例)
* 由 useListPage 自动把当前列表页的选中行同步进来,
* 全局「发起审批」悬浮按钮发起时直接读取,实现"列表多选 -> 发起弹窗"联动。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批支持列表多选联动
*/
// 当前选中的行记录
const rows = ref<any[]>([]);
// 选中来源页面路由,用于校验与当前页是否一致,避免跨页串数据
const sourcePath = ref<string>('');
export function useApprovalSelection() {
function setSelection(list: any[], path: string) {
rows.value = Array.isArray(list) ? [...list] : [];
sourcePath.value = path || '';
}
function clear() {
rows.value = [];
sourcePath.value = '';
}
/** 获取与指定路由匹配的选中行(不匹配返回空) */
function getRowsByPath(path: string): any[] {
return sourcePath.value === path ? rows.value : [];
}
return { rows, sourcePath, setSelection, clear, getRowsByPath };
}

View File

@@ -11,6 +11,9 @@ import { useDesign } from '/@/hooks/web/useDesign';
import { filterObj } from '/@/utils/common/compUtils';
import { isFunction } from '@/utils/is';
import { registerImPageListProvider } from '/@/views/system/im/imPageListRegistry';
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】列表选中行同步到审批发起上下文-----
import { useApprovalSelection } from '/@/components/ApprovalLaunch/useApprovalSelection';
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】列表选中行同步到审批发起上下文-----
import { buildImPageListSnapshot } from '/@/views/system/im/imPageListUtil';
import { IM_RECORD_QUERY_KEY } from '/@/views/system/im/imBizRecordMessage';
import {
@@ -71,7 +74,7 @@ export function useListPage(options: ListPageOptions) {
const tableContext = useListTable(options.tableProps);
const route = useRoute();
const [, tableMethods, { selectedRowKeys }] = tableContext;
const [, tableMethods, { selectedRowKeys, selectedRows }] = tableContext;
const { getForm, reload, setLoading, getColumns } = tableMethods;
const imHighlightRecordId = ref('');
let clearHighlightTimer: ReturnType<typeof setTimeout> | null = null;
@@ -84,6 +87,16 @@ export function useListPage(options: ListPageOptions) {
}
});
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】列表选中行同步到审批发起上下文供全局"发起审批"悬浮按钮读取-----
const approvalSelection = useApprovalSelection();
watch(
selectedRows,
(rows) => approvalSelection.setSelection((rows as any[]) || [], route.path),
{ deep: true },
);
onUnmounted(() => approvalSelection.clear());
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】列表选中行同步到审批发起上下文供全局"发起审批"悬浮按钮读取-----
//update-begin---author:xsl ---date:20260528 for【IM聊天-OA】列表页注册 IM 明细快照提供器-----------
onUnmounted(
registerImPageListProvider(() => {

View File

@@ -10,6 +10,12 @@
<LayoutFooter />
</Layout>
</Layout>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局发起审批悬浮按钮----- -->
<ApprovalLaunchFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局发起审批悬浮按钮----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
<ApprovalDesignFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
</Layout>
</template>
@@ -36,6 +42,12 @@
components: {
LayoutFeatures: createAsyncComponent(() => import('/@/layouts/default/feature/index.vue')),
LayoutFooter: createAsyncComponent(() => import('/@/layouts/default/footer/index.vue')),
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局发起审批悬浮按钮-----
ApprovalLaunchFloat: createAsyncComponent(() => import('/@/components/ApprovalLaunch/index.vue')),
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局发起审批悬浮按钮-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-----
ApprovalDesignFloat: createAsyncComponent(() => import('/@/components/ApprovalDesign/index.vue')),
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-----
LayoutHeader,
LayoutContent,
LayoutSideBar,

View File

@@ -0,0 +1,94 @@
import { ref } from 'vue';
/**
* 审批流「回调接口」录制器。
*
* 审批流设计器是覆盖在业务页面之上的(同一前端应用、同一个 defHttp 实例),
* 录制时全局拦截器会把每次业务请求的 url/method/参数 上报到这里,
* 捕获用户点击目标按钮所触发的真实请求,自动回填到节点的回调接口配置。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】可视化录制业务按钮接口
*/
export interface FlowCapturedApi {
/** 请求路径servlet 内路径,如 /xslmes/xxx/approve */
url: string;
/** 请求方法 GET/POST/PUT/DELETE */
method: string;
/** 请求体POST/PUT 时) */
data?: any;
/** 查询参数 */
params?: any;
}
/** 录制中状态(供设计器隐藏遮罩、显示录制条) */
export const flowApiRecording = ref(false);
let resolver: ((c: FlowCapturedApi | null) => void) | null = null;
/** 开始录制,返回 Promise捕获到请求或取消时 resolve取消为 null */
export function startFlowApiRecord(): Promise<FlowCapturedApi | null> {
// 若已有未完成的录制,先取消
cancelFlowApiRecord();
flowApiRecording.value = true;
return new Promise((resolve) => {
resolver = resolve;
});
}
/** 取消录制 */
export function cancelFlowApiRecord() {
if (resolver) {
const r = resolver;
resolver = null;
flowApiRecording.value = false;
r(null);
} else {
flowApiRecording.value = false;
}
}
/**
* 由全局请求拦截器调用,上报每次请求。
* 仅在录制中生效,默认只捕获「非 GET」业务请求按钮动作通常是写操作
* 并跳过审批流自身接口,避免误捕获。
*/
export function notifyFlowApiRecorder(config: any) {
if (!flowApiRecording.value || !resolver) {
return;
}
const method = String(config?.method || 'get').toUpperCase();
if (method === 'GET') {
return;
}
const url = config?.url;
if (!url || typeof url !== 'string') {
return;
}
// 跳过审批流/审批办理自身接口
if (url.includes('/approvalFlow') || url.includes('/approvalHandle') || url.includes('/sys/dict')) {
return;
}
const captured: FlowCapturedApi = {
url,
method,
data: safeClone(config?.data),
params: safeClone(config?.params),
};
const r = resolver;
resolver = null;
flowApiRecording.value = false;
r(captured);
}
function safeClone(v: any) {
if (v == null || typeof v !== 'object') {
return v;
}
try {
return JSON.parse(JSON.stringify(v));
} catch {
return undefined;
}
}

View File

@@ -19,6 +19,9 @@ import { useI18n } from '/@/hooks/web/useI18n';
import { joinTimestamp, formatRequestDate } from './helper';
import { useUserStoreWithOut } from '/@/store/modules/user';
import { cloneDeep } from "lodash-es";
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】回调接口录制-----
import { notifyFlowApiRecorder } from '/@/utils/flowApiRecorder';
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】回调接口录制-----
const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix;
const { createMessage, createErrorModal } = useMessage();
@@ -90,6 +93,9 @@ const transform: AxiosTransform = {
// 请求之前处理config
beforeRequestHook: (config, options) => {
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】录制时捕获原始servlet路径(未加前缀/上下文)-----
notifyFlowApiRecorder({ url: config.url, method: config.method, data: config.data, params: config.params });
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】录制时捕获原始servlet路径(未加前缀/上下文)-----
const { apiUrl, joinPrefix, joinParamsToUrl, formatDate, joinTime = true, urlPrefix } = options;
// http开头的请求url不加前缀

View File

@@ -0,0 +1,128 @@
<!--
审批流设计 列表页
@author GHT
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<div class="p-2">
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="primary" preIcon="ant-design:plus-outlined" @click="handleAdd" v-auth="'approval:flow:add'">新增审批流</a-button>
<a-button
v-if="selectedRowKeys.length > 0"
danger
preIcon="ant-design:delete-outlined"
@click="handleBatchDelete"
v-auth="'approval:flow:delete'"
style="margin-left: 8px"
>批量删除</a-button
>
</template>
<template #action="{ record }">
<TableAction :actions="getActions(record)" />
</template>
</BasicTable>
<!-- 基本信息弹窗 -->
<ApprovalFlowModal @register="registerModal" @success="reload" />
<!-- 流程设计器 -->
<FlowDesign @register="registerDesign" @success="reload" />
</div>
</template>
<script lang="ts" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema } from './approvalFlow.data';
import { getApprovalFlowList, deleteApprovalFlow, batchDeleteApprovalFlow, updateApprovalFlowStatus } from './approvalFlow.api';
import ApprovalFlowModal from './ApprovalFlowModal.vue';
import FlowDesign from './components/FlowDesign.vue';
defineOptions({ name: 'ApprovalFlowList' });
const { createMessage } = useMessage();
const [registerModal, { openModal }] = useModal();
const [registerDesign, { openModal: openDesign }] = useModal();
const { tableContext } = useListPage({
tableProps: {
title: '审批流列表',
api: getApprovalFlowList,
columns,
formConfig: {
schemas: searchFormSchema,
labelWidth: 90,
},
actionColumn: {
width: 240,
fixed: 'right',
},
showIndexColumn: true,
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
function handleAdd() {
openModal(true, { isUpdate: false });
}
function handleEdit(record) {
openModal(true, { isUpdate: true, record });
}
// 打开可视化设计器
function handleDesign(record, readonly = false) {
openDesign(true, { record, readonly });
}
function handleDelete(record) {
deleteApprovalFlow({ id: record.id }, reload);
}
function handleBatchDelete() {
batchDeleteApprovalFlow({ ids: selectedRowKeys.value.join(',') }, reload);
}
// 发布 / 停用
async function handleToggleStatus(record) {
const target = record.status === '1' ? '2' : '1';
await updateApprovalFlowStatus({ id: record.id, status: target });
createMessage.success(target === '1' ? '已发布' : '已停用');
reload();
}
function getActions(record) {
return [
{
label: '设计',
auth: 'approval:flow:design',
onClick: handleDesign.bind(null, record, false),
},
{
label: '编辑',
auth: 'approval:flow:edit',
onClick: handleEdit.bind(null, record),
},
{
label: record.status === '1' ? '停用' : '发布',
auth: 'approval:flow:design',
popConfirm: {
title: `确认${record.status === '1' ? '停用' : '发布'}该审批流?`,
confirm: handleToggleStatus.bind(null, record),
},
},
{
label: '删除',
color: 'error',
auth: 'approval:flow:delete',
popConfirm: {
title: '确认删除该审批流?',
confirm: handleDelete.bind(null, record),
},
},
];
}
</script>

View File

@@ -0,0 +1,51 @@
<!--
审批流 基本信息 新增/编辑弹窗先选单据
@author GHT
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<BasicModal v-bind="$attrs" @register="registerModal" :title="title" :width="560" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from './approvalFlow.data';
import { saveOrUpdateApprovalFlow, getApprovalFlowById } from './approvalFlow.api';
const emit = defineEmits(['success', 'register']);
const isUpdate = ref(true);
const title = computed(() => (unref(isUpdate) ? '编辑审批流' : '新增审批流'));
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
schemas: formSchema,
showActionButtonGroup: false,
labelWidth: 100,
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate) && data?.record?.id) {
const record = await getApprovalFlowById({ id: data.record.id });
await setFieldsValue({ ...record });
}
});
async function handleSubmit() {
try {
const values = await validate();
setModalProps({ confirmLoading: true });
await saveOrUpdateApprovalFlow(values, unref(isUpdate));
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>

View File

@@ -0,0 +1,75 @@
import { defHttp } from '/@/utils/http/axios';
/**
* 审批流设计 API
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
enum Api {
list = '/xslmes/approvalFlow/list',
save = '/xslmes/approvalFlow/add',
edit = '/xslmes/approvalFlow/edit',
get = '/xslmes/approvalFlow/queryById',
saveDesign = '/xslmes/approvalFlow/saveDesign',
updateStatus = '/xslmes/approvalFlow/updateStatus',
delete = '/xslmes/approvalFlow/delete',
deleteBatch = '/xslmes/approvalFlow/deleteBatch',
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文-----
designContext = '/xslmes/approvalFlow/designContext',
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文-----
}
/**
* 分页列表查询
*/
export const getApprovalFlowList = (params) => defHttp.get({ url: Api.list, params });
/**
* 新增/编辑基本信息
*/
export const saveOrUpdateApprovalFlow = (params, isUpdate) => {
const url = isUpdate ? Api.edit : Api.save;
return defHttp.post({ url, params });
};
/**
* 通过 id 查询(含流程设计 JSON
*/
export const getApprovalFlowById = (params) => defHttp.get({ url: Api.get, params });
/**
* 保存流程设计(节点树 JSON
*/
export const saveApprovalFlowDesign = (params) => defHttp.post({ url: Api.saveDesign, params });
/**
* 发布 / 停用
*/
export const updateApprovalFlowStatus = (params) => defHttp.post({ url: Api.updateStatus, params }, { joinParamsToUrl: true });
/**
* 删除
*/
export const deleteApprovalFlow = (params, handleSuccess) => {
return defHttp.delete({ url: Api.delete, data: params }, { joinParamsToUrl: true }).then(() => {
handleSuccess();
});
};
/**
* 批量删除
*/
export const batchDeleteApprovalFlow = (params, handleSuccess) => {
return defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => {
handleSuccess();
});
};
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)-----
/**
* 获取当前功能页的审批流设计上下文:
* 返回 { routePath, bizTable, bizTableName, stages[], flow }
* stages 为识别到的阶段字段(校对/审核/审批/分发/抄送flow 为可直接进入设计器的流程记录。
*/
export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url: Api.designContext, params: { routePath } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)-----

View File

@@ -0,0 +1,112 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
/**
* 审批流设计 列表列 / 表单 schema
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
export const columns: BasicColumn[] = [
{
title: '审批流名称',
dataIndex: 'flowName',
align: 'left',
},
{
title: '绑定单据',
dataIndex: 'bizTableName',
width: 200,
},
{
title: '状态',
dataIndex: 'status_dictText',
width: 100,
},
{
title: '备注',
dataIndex: 'remark',
align: 'left',
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 180,
},
];
export const searchFormSchema: FormSchema[] = [
{
field: 'flowName',
label: '审批流名称',
component: 'Input',
colProps: { span: 6 },
},
{
field: 'bizTable',
label: '绑定单据',
component: 'JDictSelectTag',
componentProps: {
dictCode: 'mes_xsl_approval_biz_doc',
},
colProps: { span: 6 },
},
{
field: 'status',
label: '状态',
component: 'JDictSelectTag',
componentProps: {
dictCode: 'mes_xsl_approval_flow_status',
},
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{
label: '主键',
field: 'id',
component: 'Input',
show: false,
},
{
field: 'flowName',
label: '审批流名称',
component: 'Input',
required: true,
componentProps: {
placeholder: '请输入审批流名称',
maxlength: 100,
},
},
{
field: 'bizTable',
label: '绑定单据',
component: 'JDictSelectTag',
required: true,
componentProps: {
dictCode: 'mes_xsl_approval_biz_doc',
placeholder: '请先选择需要审批的单据',
},
// 编辑时禁止修改绑定单据,避免已设计的节点条件与单据字段错配
dynamicDisabled: ({ values }) => !!values.id,
},
{
field: 'titleField',
label: '单据标题字段',
component: 'Input',
helpMessage: '发起审批时用于展示具体单据的字段名(如 spec_name、code不填则只显示单据ID',
componentProps: {
placeholder: '选填,业务表中用于展示的字段名,如 spec_name',
maxlength: 100,
},
},
{
field: 'remark',
label: '备注',
component: 'InputTextArea',
componentProps: {
placeholder: '请输入备注',
maxlength: 500,
rows: 3,
},
},
];

View File

@@ -0,0 +1,25 @@
import { defHttp } from '/@/utils/http/axios';
/**
* 审批办理/流转 API供 IM 审批卡片按钮调用)
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批办理/流转
*/
enum Api {
detail = '/xslmes/approvalHandle/detail',
status = '/xslmes/approvalHandle/status',
approve = '/xslmes/approvalHandle/approve',
reject = '/xslmes/approvalHandle/reject',
}
/** 查看单据全部字段 + 审批进度/历史 */
export const getApprovalDetail = (instanceId: string) => defHttp.get({ url: Api.detail, params: { instanceId } });
/** 轻量实时状态:用于卡片判断是否仍可办理(旧节点卡片置灰) */
export const getApprovalStatus = (instanceId: string) => defHttp.get({ url: Api.status, params: { instanceId } });
/** 审批通过(按节点 multiMode 流转到下一处理人/节点) */
export const approveApproval = (params: { instanceId: string; comment?: string }) => defHttp.post({ url: Api.approve, data: params });
/** 驳回(需填写理由) */
export const rejectApproval = (params: { instanceId: string; reason: string }) => defHttp.post({ url: Api.reject, data: params });

View File

@@ -0,0 +1,189 @@
<!--
钉钉式审批流 可视化设计器全屏
@author GHT
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<BasicModal v-bind="$attrs" @register="registerModal" :title="modalTitle" defaultFullscreen :canFullscreen="false" :showOkBtn="!readonly" :okText="'保存并发布'" @ok="handleSave" :wrapClassName="flowApiRecording ? 'flow-design-recording-hide' : ''">
<div class="fd-design">
<div class="fd-toolbar">
<span class="fd-tb-item">绑定单据<b>{{ record.bizTableName || record.bizTable }}</b></span>
<span class="fd-tb-item">审批流<b>{{ record.flowName }}</b></span>
<span class="fd-tb-tip">点击节点可配置点击节点间的+可插入审批人 / 抄送人 / 条件分支</span>
</div>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计当前页识别到的阶段字段作为候选,点选追加为流程节点----- -->
<div class="fd-body">
<div class="fd-palette" v-if="!readonly && paletteStages.length">
<div class="fd-palette-title">当前页识别到的审批阶段</div>
<div class="fd-palette-tip">点击下方阶段按顺序追加到流程末尾处理人将取自单据对应字段</div>
<div class="fd-palette-list">
<div v-for="s in paletteStages" :key="s.stageKey" class="fd-palette-item" :class="'fd-palette-' + s.nodeType" @click="appendStageNode(s)">
<div class="fd-palette-item-name">{{ s.stageName }}</div>
<div class="fd-palette-item-field">{{ s.fieldComment || s.field }}</div>
<Icon icon="ant-design:plus-circle-outlined" class="fd-palette-item-add" />
</div>
</div>
</div>
<div class="fd-canvas">
<div class="fd-flow" v-if="root">
<FlowNode :node="root" />
<div class="fd-end">
<div class="fd-end-dot"></div>
<span>流程结束</span>
</div>
</div>
</div>
</div>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计当前页识别到的阶段字段作为候选,点选追加为流程节点----- -->
</div>
<NodeConfigDrawer ref="drawerRef" :readonly="readonly" />
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, provide, reactive, ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import FlowNode from './FlowNode.vue';
import NodeConfigDrawer from './NodeConfigDrawer.vue';
import {
createStartNode,
createApproverNode,
createCcNode,
createConditionNode,
createStageNode,
insertAfter,
removeNode,
addBranch,
} from './flowTypes';
import type { FlowNode as FlowNodeType, NodeType, StageField } from './flowTypes';
import { saveApprovalFlowDesign, getApprovalFlowById } from '../approvalFlow.api';
import { flowApiRecording } from '/@/utils/flowApiRecorder';
defineOptions({ name: 'ApprovalFlowDesign' });
const emit = defineEmits(['success', 'register']);
const { createMessage } = useMessage();
const root = ref<FlowNodeType | null>(null);
const readonly = ref(false);
const record = reactive<any>({ id: '', flowName: '', bizTable: '', bizTableName: '' });
const drawerRef = ref();
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
const paletteStages = ref<StageField[]>([]);
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
const modalTitle = computed(() => (readonly.value ? '查看审批流' : '设计审批流'));
// 设计器上下文,提供给递归 FlowNode 使用
const flowCtx = reactive({
readonly: false,
onSelect: (node: FlowNodeType) => {
drawerRef.value?.openDrawer(node);
},
onInsert: (prevId: string, type: NodeType) => {
if (!root.value) return;
const factory: Record<string, () => FlowNodeType> = {
approver: createApproverNode,
cc: createCcNode,
condition: createConditionNode,
};
const node = factory[type]?.();
if (node) insertAfter(root.value, prevId, node);
},
onDelete: (id: string) => {
if (root.value) removeNode(root.value, id);
},
addBranch: (conditionId: string) => {
if (root.value) addBranch(root.value, conditionId);
},
});
// 通过 provide 注入,供递归 FlowNode 调用(避免逐层透传)
provide('flowCtx', flowCtx);
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
readonly.value = !!data?.readonly;
flowCtx.readonly = readonly.value;
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
paletteStages.value = Array.isArray(data?.paletteStages) ? data.paletteStages : [];
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
const id = data?.record?.id || '';
Object.assign(record, {
id,
flowName: data?.record?.flowName || '',
bizTable: data?.record?.bizTable || '',
bizTableName: data?.record?.bizTableName || '',
});
// 列表接口未返回大字段 flow_config需按 id 重新查询完整记录
let cfg = data?.record?.flowConfig;
if (id) {
try {
setModalProps({ loading: true });
const full = await getApprovalFlowById({ id });
if (full) {
cfg = full.flowConfig;
Object.assign(record, {
flowName: full.flowName || record.flowName,
bizTable: full.bizTable || record.bizTable,
bizTableName: full.bizTableName || record.bizTableName,
});
}
} finally {
setModalProps({ loading: false });
}
}
// 解析已有流程设计,无则初始化一个发起人节点
root.value = parseConfig(cfg);
});
function parseConfig(cfg?: string): FlowNodeType {
if (cfg) {
try {
const obj = JSON.parse(cfg);
if (obj && obj.type) return obj;
} catch (e) {
console.warn('[审批流设计] flowConfig 解析失败,重置为默认', e);
}
}
return createStartNode();
}
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】点选候选阶段,追加为流程末尾节点----- -->
/** 将候选阶段追加到主流程链末尾(沿 childNode 找到尾节点后插入) */
function appendStageNode(stage: StageField) {
if (!root.value || readonly.value) return;
let tail: FlowNodeType = root.value;
while (tail.childNode) {
tail = tail.childNode;
}
const node = createStageNode(stage);
insertAfter(root.value, tail.id, node);
createMessage.success(`已添加「${stage.stageName}」节点`);
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】点选候选阶段,追加为流程末尾节点----- -->
async function handleSave() {
if (!record.id) {
createMessage.error('缺少审批流ID无法保存');
return;
}
try {
setModalProps({ confirmLoading: true });
await saveApprovalFlowDesign({
id: record.id,
flowConfig: JSON.stringify(root.value),
status: '1',
});
createMessage.success('流程设计已保存并发布');
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less">
@import './flow.less';
</style>

View File

@@ -0,0 +1,99 @@
<!--
钉钉式审批流 递归节点组件
@author GHT
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<div class="fd-node">
<!-- 条件分支节点渲染多列分支 -->
<div v-if="node.type === 'condition'" class="fd-branches">
<div class="fd-add-branch-wrap">
<a-button size="small" type="primary" ghost class="fd-add-branch" @click="ctx.addBranch(node.id)">+ 条件</a-button>
</div>
<div class="fd-branch-cols">
<div class="fd-branch-col" v-for="(b, idx) in node.conditionNodes" :key="b.id">
<!-- 首末列外侧连线遮罩形成分支汇聚效果 -->
<div v-if="idx === 0" class="fd-cover fd-cover-tl"></div>
<div v-if="idx === 0" class="fd-cover fd-cover-bl"></div>
<div v-if="idx === lastBranchIndex" class="fd-cover fd-cover-tr"></div>
<div v-if="idx === lastBranchIndex" class="fd-cover fd-cover-br"></div>
<div class="fd-branch-inner">
<FlowNode :node="b" />
</div>
</div>
</div>
</div>
<!-- 普通卡片start / approver / cc / branch -->
<div v-else class="fd-card-wrap">
<div class="fd-card" :class="['fd-' + node.type, { 'fd-error': isPlaceholder }]" @click="ctx.onSelect(node)">
<div class="fd-card-header">
<span class="fd-title">{{ node.name }}</span>
<span v-if="node.type === 'branch' && !node.props.isDefault" class="fd-priority">优先级 {{ node.props.priorityLevel }}</span>
<Icon v-if="canDelete && !ctx.readonly" icon="ant-design:close-outlined" class="fd-del" @click.stop="ctx.onDelete(node.id)" />
</div>
<div class="fd-card-body" :class="{ 'fd-placeholder': isPlaceholder }">{{ summary }}</div>
</div>
</div>
<!-- 加号在当前节点之后插入新节点 -->
<div v-if="!ctx.readonly" class="fd-add">
<a-popover trigger="click" placement="rightTop" v-model:open="addOpen" :overlayClassName="'fd-add-pop'">
<template #content>
<div class="fd-add-menu">
<div class="fd-add-item fd-add-approver" @click="add('approver')">
<Icon icon="ant-design:audit-outlined" /> <span>审批人</span>
</div>
<div class="fd-add-item fd-add-cc" @click="add('cc')">
<Icon icon="ant-design:mail-outlined" /> <span>抄送人</span>
</div>
<div class="fd-add-item fd-add-condition" @click="add('condition')">
<Icon icon="ant-design:share-alt-outlined" /> <span>条件分支</span>
</div>
</div>
</template>
<button class="fd-add-btn" title="添加节点"><Icon icon="ant-design:plus-outlined" /></button>
</a-popover>
</div>
<!-- 后续链递归 -->
<FlowNode v-if="node.childNode" :node="node.childNode" />
</div>
</template>
<script lang="ts" setup>
import { computed, inject, ref } from 'vue';
import { nodeSummary } from './flowTypes';
import type { FlowNode as FlowNodeType, NodeType } from './flowTypes';
defineOptions({ name: 'FlowNode' });
const props = defineProps<{ node: FlowNodeType }>();
// 注入设计器上下文
const ctx = inject<any>('flowCtx', {
readonly: false,
onSelect: () => {},
onInsert: () => {},
onDelete: () => {},
addBranch: () => {},
});
const addOpen = ref(false);
const summary = computed(() => nodeSummary(props.node));
// 条件分支最后一列索引(用于首末列连线遮罩)
const lastBranchIndex = computed(() => (props.node.conditionNodes?.length || 0) - 1);
// 摘要为"请设置..."视为未配置(红色提示)
const isPlaceholder = computed(() => summary.value.startsWith('请'));
// 发起人节点不可删除
const canDelete = computed(() => props.node.type !== 'start');
function add(type: NodeType) {
ctx.onInsert(props.node.id, type);
addOpen.value = false;
}
</script>

View File

@@ -0,0 +1,359 @@
<!--
审批流节点配置抽屉
@author GHT
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<a-drawer :title="title" :width="480" :open="open && !flowApiRecording" @close="onClose" :maskClosable="!readonly">
<template v-if="form">
<a-form layout="vertical">
<a-form-item label="节点名称">
<a-input v-model:value="form.name" :disabled="readonly || node?.type === 'start'" placeholder="请输入节点名称" />
</a-form-item>
<!-- 发起人 -->
<template v-if="node?.type === 'start'">
<a-form-item label="可发起人员">
<a-radio-group v-model:value="form.props.initiatorType" :disabled="readonly">
<a-radio value="all">所有人</a-radio>
<a-radio value="user">指定成员</a-radio>
<a-radio value="role">指定角色</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="form.props.initiatorType === 'user'" label="指定成员">
<JSelectUser v-model:value="form.props.userText" :disabled="readonly" />
</a-form-item>
<a-form-item v-if="form.props.initiatorType === 'role'" label="指定角色">
<ApiSelect mode="multiple" v-model:value="form.props.roleList" :api="roleApi" :params="{ pageSize: 1000 }" resultField="records" labelField="roleName" valueField="id" :disabled="readonly" placeholder="请选择角色" />
</a-form-item>
</template>
<!-- 审批人 -->
<template v-else-if="node?.type === 'approver'">
<a-form-item label="审批人类型">
<a-radio-group v-model:value="form.props.approverType" :disabled="readonly">
<a-radio value="user">指定成员</a-radio>
<a-radio value="role">指定角色</a-radio>
<a-radio value="leader">主管</a-radio>
<a-radio value="self">发起人自己</a-radio>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计取单据字段审批人----- -->
<a-radio value="field">取单据字段</a-radio>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计取单据字段审批人----- -->
</a-radio-group>
</a-form-item>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计取单据字段审批人配置----- -->
<template v-if="form.props.approverType === 'field'">
<a-alert type="info" show-icon style="margin-bottom: 12px" message="发起审批时,处理人将取自单据该字段的值(通常为人员账号)。" />
<a-form-item label="字段中文名">
<a-input v-model:value="form.props.fieldLabel" :disabled="readonly" placeholder="如:校对人" />
</a-form-item>
<a-form-item label="字段名">
<a-input v-model:value="form.props.fieldName" :disabled="readonly" placeholder="如proofreader" />
</a-form-item>
</template>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计取单据字段审批人配置----- -->
<a-form-item v-if="form.props.approverType === 'user'" label="指定成员">
<JSelectUser v-model:value="form.props.userText" :disabled="readonly" />
</a-form-item>
<a-form-item v-if="form.props.approverType === 'role'" label="指定角色">
<ApiSelect mode="multiple" v-model:value="form.props.roleList" :api="roleApi" :params="{ pageSize: 1000 }" resultField="records" labelField="roleName" valueField="id" :disabled="readonly" placeholder="请选择角色" />
</a-form-item>
<a-form-item v-if="form.props.approverType === 'leader'" label="主管层级">
<a-select v-model:value="form.props.leaderLevel" :disabled="readonly" style="width: 160px">
<a-select-option :value="1">直接主管</a-select-option>
<a-select-option :value="2">第2级主管</a-select-option>
<a-select-option :value="3">第3级主管</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="多人审批方式">
<a-radio-group v-model:value="form.props.multiMode" :disabled="readonly">
<a-radio value="and">会签需全部同意</a-radio>
<a-radio value="or">或签一人同意</a-radio>
<a-radio value="sequence">依次审批</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审批人为空时">
<a-radio-group v-model:value="form.props.emptyStrategy" :disabled="readonly">
<a-radio value="admin">转交管理员</a-radio>
<a-radio value="pass">自动通过</a-radio>
<a-radio value="stop">终止流程</a-radio>
</a-radio-group>
</a-form-item>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(录制业务按钮接口)----- -->
<a-divider style="margin: 16px 0 12px">回调接口审批联动业务</a-divider>
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
message="审批到对应时机时系统会以「当前处理人」身份调用所选业务接口自动带上单据ID。点击「录制」后设计器会临时隐藏请到页面上点击目标按钮如“批准”系统自动识别其接口并回填。"
/>
<div v-for="phase in callbackPhases" :key="phase.key" class="fd-cb-block">
<div class="fd-cb-title">{{ phase.label }}</div>
<div v-for="(a, i) in form.props.callbackActions[phase.key]" :key="i" class="fd-cb-row">
<a-input v-model:value="a.name" placeholder="动作名" :disabled="readonly" style="width: 88px" />
<a-select v-model:value="a.method" :disabled="readonly" style="width: 82px" :options="methodOptions" />
<a-input v-model:value="a.url" placeholder="接口路径 /xxx" :disabled="readonly" style="flex: 1; min-width: 120px" />
<Icon v-if="!readonly" icon="ant-design:minus-circle-outlined" class="fd-cb-del" @click="removeAction(phase.key, i)" />
</div>
<a-space v-if="!readonly" style="margin-top: 4px">
<a-button size="small" @click="recordInto(phase.key)">
<Icon icon="ant-design:aim-outlined" />
<span>录制接口</span>
</a-button>
<a-button size="small" @click="addAction(phase.key)">手动添加</a-button>
</a-space>
</div>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(录制业务按钮接口)----- -->
</template>
<!-- 抄送人 -->
<template v-else-if="node?.type === 'cc'">
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计抄送人来源支持取单据字段----- -->
<a-form-item label="抄送人来源">
<a-radio-group v-model:value="form.props.ccType" :disabled="readonly">
<a-radio value="user">指定成员/角色</a-radio>
<a-radio value="field">取单据字段</a-radio>
</a-radio-group>
</a-form-item>
<template v-if="form.props.ccType === 'field'">
<a-alert type="info" show-icon style="margin-bottom: 12px" message="发起审批时,抄送人将取自单据该字段的值(通常为人员账号)。" />
<a-form-item label="字段中文名">
<a-input v-model:value="form.props.fieldLabel" :disabled="readonly" placeholder="如:分发人" />
</a-form-item>
<a-form-item label="字段名">
<a-input v-model:value="form.props.fieldName" :disabled="readonly" placeholder="如distributor" />
</a-form-item>
</template>
<template v-else>
<a-form-item label="抄送成员">
<JSelectUser v-model:value="form.props.userText" :disabled="readonly" />
</a-form-item>
<a-form-item label="抄送角色">
<ApiSelect mode="multiple" v-model:value="form.props.roleList" :api="roleApi" :params="{ pageSize: 1000 }" resultField="records" labelField="roleName" valueField="id" :disabled="readonly" placeholder="请选择角色" />
</a-form-item>
</template>
<a-form-item>
<a-checkbox v-model:checked="form.props.allowEditCc" :disabled="readonly">允许审批人自行添加抄送人</a-checkbox>
</a-form-item>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计抄送人来源支持取单据字段----- -->
</template>
<!-- 条件分支 -->
<template v-else-if="node?.type === 'branch'">
<template v-if="form.props.isDefault">
<a-alert type="info" show-icon message="“其它情况”分支:当以上条件均不满足时进入此分支,无需配置条件。" />
</template>
<template v-else>
<a-form-item label="条件关系">
<a-radio-group v-model:value="form.props.logic" :disabled="readonly">
<a-radio value="and">同时满足</a-radio>
<a-radio value="or">满足其一</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="条件设置">
<div v-for="(c, i) in form.props.conditions" :key="i" class="fd-cond-row">
<a-input v-model:value="c.label" placeholder="字段中文名" :disabled="readonly" style="width: 110px" />
<a-input v-model:value="c.field" placeholder="字段名" :disabled="readonly" style="width: 110px" />
<a-select v-model:value="c.operator" :disabled="readonly" style="width: 100px" :options="operatorOptions" />
<a-input v-if="!['empty', 'notEmpty'].includes(c.operator)" v-model:value="c.value" placeholder="值" :disabled="readonly" style="width: 90px" />
<Icon v-if="!readonly" icon="ant-design:minus-circle-outlined" class="fd-cond-del" @click="removeCond(i)" />
</div>
<a-button v-if="!readonly" type="dashed" block @click="addCond" style="margin-top: 8px">+ 添加条件</a-button>
</a-form-item>
</template>
</template>
</a-form>
</template>
<template #footer v-if="!readonly">
<a-space>
<a-button @click="onClose">取消</a-button>
<a-button type="primary" @click="onConfirm">确定</a-button>
</a-space>
</template>
</a-drawer>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计录制中悬浮提示条----- -->
<Teleport to="body">
<div v-if="flowApiRecording" class="flow-record-banner">
<Icon icon="ant-design:aim-outlined" class="flow-record-banner-icon" />
<span class="flow-record-banner-text">录制中请点击页面上要绑定的业务按钮批准/反审核系统会自动识别其接口</span>
<a-button size="small" danger @click="cancelRecord">取消录制</a-button>
</div>
</Teleport>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计录制中悬浮提示条----- -->
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { defHttp } from '/@/utils/http/axios';
import { ApiSelect } from '/@/components/Form';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import { OPERATOR_OPTIONS } from './flowTypes';
import type { FlowNode } from './flowTypes';
import { useMessage } from '/@/hooks/web/useMessage';
import { flowApiRecording, startFlowApiRecord, cancelFlowApiRecord } from '/@/utils/flowApiRecorder';
const props = defineProps<{ readonly?: boolean }>();
const emit = defineEmits(['confirm']);
const { createMessage } = useMessage();
const open = ref(false);
const node = ref<FlowNode | null>(null);
const form = ref<any>(null);
const operatorOptions = OPERATOR_OPTIONS;
const readonly = computed(() => !!props.readonly);
// 回调接口配置:三个触发时机
const callbackPhases = [
{ key: 'onNodeApprove', label: '本节点通过时执行' },
{ key: 'onApprove', label: '流程最终通过时执行' },
{ key: 'onReject', label: '驳回时执行' },
];
const methodOptions = [
{ label: 'POST', value: 'POST' },
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
];
const title = computed(() => {
const map: Record<string, string> = { start: '发起人设置', approver: '审批人设置', cc: '抄送人设置', branch: '条件设置' };
return map[node.value?.type || ''] || '节点设置';
});
const roleApi = (params) => defHttp.get({ url: '/sys/role/list', params });
function openDrawer(n: FlowNode) {
node.value = n;
// 编辑副本,确定时回写,避免取消后脏数据
form.value = { name: n.name, props: cloneDeep(n.props) };
// 审批人节点确保回调接口配置结构存在
if (n.type === 'approver') {
const cb = form.value.props.callbackActions || {};
form.value.props.callbackActions = {
onNodeApprove: Array.isArray(cb.onNodeApprove) ? cb.onNodeApprove : [],
onApprove: Array.isArray(cb.onApprove) ? cb.onApprove : [],
onReject: Array.isArray(cb.onReject) ? cb.onReject : [],
};
}
open.value = true;
}
function addAction(phaseKey: string) {
form.value.props.callbackActions[phaseKey].push({ name: '', method: 'POST', url: '' });
}
function removeAction(phaseKey: string, i: number) {
form.value.props.callbackActions[phaseKey].splice(i, 1);
}
/** 录制:临时隐藏设计器,捕获用户点击业务按钮的请求并回填 */
async function recordInto(phaseKey: string) {
const cap = await startFlowApiRecord();
if (cap) {
form.value.props.callbackActions[phaseKey].push({ name: '', method: cap.method, url: cap.url, body: cap.data });
createMessage.success(`已录制接口:${cap.method} ${cap.url}`);
}
}
function cancelRecord() {
cancelFlowApiRecord();
}
function onClose() {
open.value = false;
}
function onConfirm() {
if (node.value && form.value) {
node.value.name = form.value.name;
node.value.props = cloneDeep(form.value.props);
emit('confirm', node.value);
}
open.value = false;
}
function addCond() {
form.value.props.conditions.push({ label: '', field: '', operator: 'eq', value: '' });
}
function removeCond(i: number) {
form.value.props.conditions.splice(i, 1);
}
defineExpose({ openDrawer });
</script>
<style lang="less" scoped>
.fd-cond-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.fd-cond-del {
color: #ff4d4f;
cursor: pointer;
}
/* 回调接口配置 */
.fd-cb-block {
margin-bottom: 14px;
padding: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
}
.fd-cb-title {
font-size: 13px;
font-weight: 500;
color: #595959;
margin-bottom: 8px;
}
.fd-cb-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.fd-cb-del {
color: #ff4d4f;
cursor: pointer;
flex-shrink: 0;
}
</style>
<!-- 录制提示条 Teleport body需非 scoped 全局样式 -->
<style lang="less">
.flow-record-banner {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 99999;
display: flex;
align-items: center;
gap: 12px;
max-width: 92vw;
padding: 10px 16px;
border-radius: 8px;
background: #fffbe6;
border: 1px solid #ffe58f;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
color: #614700;
font-size: 13px;
.flow-record-banner-icon {
color: #fa8c16;
font-size: 18px;
}
.flow-record-banner-text {
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,428 @@
/**
* 钉钉式审批流设计器 样式
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
@line-color: #cacaca;
/* 录制回调接口时,隐藏整个设计器弹窗(含遮罩),让用户能点击业务页面上的真实按钮 */
.flow-design-recording-hide {
display: none !important;
}
.fd-design {
display: flex;
flex-direction: column;
height: 100%;
}
.fd-toolbar {
flex-shrink: 0;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.fd-tb-item {
margin-right: 24px;
font-size: 14px;
color: #333;
}
.fd-tb-tip {
color: #999;
font-size: 12px;
}
}
/* update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】候选阶段侧边栏布局----- */
.fd-body {
flex: 1;
display: flex;
min-height: 0;
}
.fd-palette {
flex-shrink: 0;
width: 220px;
overflow-y: auto;
background: #fff;
border-right: 1px solid #f0f0f0;
padding: 14px 12px;
.fd-palette-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
}
.fd-palette-tip {
font-size: 12px;
color: #999;
line-height: 1.5;
margin-bottom: 12px;
}
.fd-palette-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.fd-palette-item {
position: relative;
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 8px 30px 8px 10px;
cursor: pointer;
transition: all 0.15s;
border-left: 3px solid #ff943e;
&.fd-palette-cc {
border-left-color: #3296fa;
}
.fd-palette-item-name {
font-size: 13px;
color: #333;
font-weight: 500;
}
.fd-palette-item-field {
font-size: 12px;
color: #999;
margin-top: 2px;
word-break: break-all;
}
.fd-palette-item-add {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #3296fa;
font-size: 16px;
}
&:hover {
border-color: #3296fa;
background: #f0f7ff;
box-shadow: 0 2px 8px rgba(50, 150, 250, 0.15);
}
}
}
/* update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】候选阶段侧边栏布局----- */
.fd-canvas {
flex: 1;
overflow: auto;
background: #f0f2f5;
padding: 40px 20px 80px;
}
.fd-flow {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 100%;
}
/* ---------- 节点列 ---------- */
.fd-node {
display: flex;
flex-direction: column;
align-items: center;
flex-grow: 1;
position: relative;
}
.fd-card-wrap {
position: relative;
}
.fd-card {
position: relative;
width: 220px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
.fd-del {
display: inline-flex;
}
}
&.fd-error {
box-shadow: 0 0 0 1px #ff4d4f;
}
}
.fd-card-header {
display: flex;
align-items: center;
padding: 8px 12px;
font-size: 13px;
color: #fff;
border-radius: 8px 8px 0 0;
.fd-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fd-priority {
font-size: 12px;
opacity: 0.85;
margin-right: 4px;
}
.fd-del {
display: none;
cursor: pointer;
font-size: 12px;
opacity: 0.85;
&:hover {
opacity: 1;
}
}
}
.fd-card-body {
padding: 12px;
font-size: 13px;
color: #555;
min-height: 22px;
line-height: 1.5;
word-break: break-all;
&.fd-placeholder {
color: #f56c6c;
}
}
/* 各类型节点头部配色 */
.fd-start .fd-card-header {
background: #576a95;
}
.fd-approver .fd-card-header {
background: #ff943e;
}
.fd-cc .fd-card-header {
background: #3296fa;
}
.fd-branch {
.fd-card {
width: 220px;
}
.fd-card-header {
background: #fff;
color: #15bca3;
border-bottom: 1px solid #f0f0f0;
.fd-priority {
color: #999;
}
.fd-del {
color: #999;
}
}
}
/* ---------- 节点之间的连线 + 加号 ---------- */
.fd-add {
position: relative;
width: 2px;
min-height: 70px;
background: @line-color;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.fd-add-btn {
position: relative;
z-index: 1;
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: #3296fa;
color: #fff;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(50, 150, 250, 0.35);
transition: transform 0.15s;
&:hover {
transform: scale(1.12);
}
}
/* 加号弹出菜单 */
.fd-add-menu {
display: flex;
gap: 12px;
padding: 4px;
}
.fd-add-item {
width: 76px;
height: 70px;
border: 1px solid #f0f0f0;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: all 0.15s;
.anticon {
font-size: 20px;
}
&:hover {
border-color: #3296fa;
color: #3296fa;
background: #f0f7ff;
}
&.fd-add-approver .anticon {
color: #ff943e;
}
&.fd-add-cc .anticon {
color: #3296fa;
}
&.fd-add-condition .anticon {
color: #15bca3;
}
}
/* ---------- 条件分支 ---------- */
.fd-branches {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
/* +条件 按钮(带进入竖线) */
.fd-add-branch-wrap {
position: relative;
width: 100%;
height: 46px;
display: flex;
justify-content: center;
align-items: center;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 2px;
height: 100%;
background: @line-color;
}
.fd-add-branch {
position: relative;
z-index: 1;
border-radius: 14px;
}
}
.fd-branch-cols {
display: flex;
position: relative;
}
.fd-branch-col {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
background: #f0f2f5;
border-top: 2px solid @line-color;
border-bottom: 2px solid @line-color;
padding: 0 24px;
/* 列内顶部进入竖线 + 底部汇出竖线 */
.fd-branch-inner {
position: relative;
padding-top: 30px;
padding-bottom: 0;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 2px;
height: 30px;
background: @line-color;
}
}
}
/* 首末列外侧白块,遮挡多余横线,形成包裹效果 */
.fd-cover {
position: absolute;
width: 50%;
height: 4px;
background: #f0f2f5;
z-index: 2;
}
.fd-cover-tl {
top: -2px;
left: -1px;
}
.fd-cover-bl {
bottom: -2px;
left: -1px;
}
.fd-cover-tr {
top: -2px;
right: -1px;
}
.fd-cover-br {
bottom: -2px;
right: -1px;
}
/* ---------- 结束节点 ---------- */
.fd-end {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0;
.fd-end-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #dedede;
margin-bottom: 6px;
}
span {
font-size: 13px;
color: #999;
}
}

View File

@@ -0,0 +1,267 @@
/**
* 钉钉式审批流 节点数据模型 + 工厂函数
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】新增审批流可视化设计
*/
// 节点类型start发起人 approver审批人 cc抄送人 condition条件路由 branch条件分支
export type NodeType = 'start' | 'approver' | 'cc' | 'condition' | 'branch';
export interface FlowNode {
id: string;
type: NodeType;
name: string;
// 节点配置(按 type 不同含义不同)
props: Record<string, any>;
// 链式下一个节点
childNode?: FlowNode | null;
// 仅 condition 节点:分支数组(每个元素 type=branch
conditionNodes?: FlowNode[];
}
let _seed = 0;
/** 生成唯一节点 id */
export function nid(prefix = 'node'): string {
_seed += 1;
return `${prefix}_${Date.now().toString(36)}_${_seed}`;
}
/** 发起人节点(根节点,固定存在) */
export function createStartNode(): FlowNode {
return {
id: nid('start'),
type: 'start',
name: '发起人',
props: {
// initiatorType: all全员 / user指定成员 / role指定角色
initiatorType: 'all',
userText: '',
roleList: [],
},
childNode: null,
};
}
/** 审批人节点 */
export function createApproverNode(): FlowNode {
return {
id: nid('approver'),
type: 'approver',
name: '审批人',
props: {
// approverType: user指定成员 / role指定角色 / leader主管 / self发起人自己
approverType: 'user',
userText: '',
roleList: [],
leaderLevel: 1,
// 多人审批方式 and会签(需全部同意) / or或签(一人同意) / sequence依次审批
multiMode: 'and',
// 审批人为空时 pass自动通过 / admin转交管理员 / stop终止
emptyStrategy: 'admin',
},
childNode: null,
};
}
/** 抄送人节点 */
export function createCcNode(): FlowNode {
return {
id: nid('cc'),
type: 'cc',
name: '抄送人',
props: {
// ccType: user指定成员/角色 / field取单据字段中的人员
ccType: 'user',
userText: '',
roleList: [],
allowEditCc: false,
},
childNode: null,
};
}
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点-----
/** 解析出的页面阶段字段 */
export interface StageField {
stageKey: string;
stageName: string;
nodeType: 'approver' | 'cc';
field: string;
fieldComment?: string;
}
/**
* 由"当前页字段"生成阶段节点:
* 审批阶段(校对/审核/审批/分发) -> 审批人节点,处理人=取单据该字段中的人员;
* 抄送阶段 -> 抄送节点,抄送人=取单据该字段中的人员。
*/
export function createStageNode(stage: StageField): FlowNode {
const fieldLabel = stage.fieldComment || stage.field;
if (stage.nodeType === 'cc') {
const node = createCcNode();
node.name = stage.stageName;
node.props.ccType = 'field';
node.props.fieldName = stage.field;
node.props.fieldLabel = fieldLabel;
return node;
}
const node = createApproverNode();
node.name = stage.stageName;
node.props.approverType = 'field';
node.props.fieldName = stage.field;
node.props.fieldLabel = fieldLabel;
return node;
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点-----
/** 条件分支节点(默认两条分支:条件 + 其它情况) */
export function createConditionNode(): FlowNode {
return {
id: nid('condition'),
type: 'condition',
name: '条件分支',
props: {},
conditionNodes: [createBranchNode(1), createBranchNode(2, true)],
childNode: null,
};
}
/** 单个条件分支 */
export function createBranchNode(priority: number, isDefault = false): FlowNode {
return {
id: nid('branch'),
type: 'branch',
name: isDefault ? '其它情况' : `条件${priority}`,
props: {
priorityLevel: priority,
isDefault,
// 条件列表:{ field 字段名, label 字段中文, operator 运算符, value 值 }
conditions: [] as any[],
// 多条件关系 and / or
logic: 'and',
},
childNode: null,
};
}
/** 运算符选项 */
export const OPERATOR_OPTIONS = [
{ label: '等于', value: 'eq' },
{ label: '不等于', value: 'ne' },
{ label: '大于', value: 'gt' },
{ label: '大于等于', value: 'gte' },
{ label: '小于', value: 'lt' },
{ label: '小于等于', value: 'lte' },
{ label: '包含', value: 'contains' },
{ label: '为空', value: 'empty' },
{ label: '不为空', value: 'notEmpty' },
];
/** 节点卡片内容摘要文本 */
export function nodeSummary(node: FlowNode): string {
if (node.type === 'start') {
const t = node.props.initiatorType;
if (t === 'all') return '所有人可发起';
if (t === 'user') return node.props.userText ? `指定成员:${node.props.userText}` : '请设置发起人';
if (t === 'role') return node.props.roleList?.length ? `指定角色(${node.props.roleList.length}` : '请设置发起角色';
return '请设置发起人';
}
if (node.type === 'approver') {
const t = node.props.approverType;
if (t === 'self') return '发起人自己';
if (t === 'leader') return `${node.props.leaderLevel || 1}级主管`;
if (t === 'role') return node.props.roleList?.length ? `角色审批(${node.props.roleList.length}` : '请设置审批角色';
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段审批人摘要-----
if (t === 'field') return `取单据字段:${node.props.fieldLabel || node.props.fieldName || '未指定字段'}`;
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段审批人摘要-----
return node.props.userText ? `指定成员:${node.props.userText}` : '请设置审批人';
}
if (node.type === 'cc') {
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段抄送人摘要-----
if (node.props.ccType === 'field') return `抄送单据字段:${node.props.fieldLabel || node.props.fieldName || '未指定字段'}`;
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段抄送人摘要-----
return node.props.userText ? `抄送:${node.props.userText}` : '请设置抄送人';
}
if (node.type === 'branch') {
if (node.props.isDefault) return '未满足其它条件时进入此分支';
const list = node.props.conditions || [];
if (!list.length) return '请设置条件';
return list.map((c: any) => `${c.label || c.field} ${operatorText(c.operator)} ${c.value ?? ''}`).join(node.props.logic === 'or' ? ' 或 ' : ' 且 ');
}
return '';
}
function operatorText(op: string): string {
return OPERATOR_OPTIONS.find((o) => o.value === op)?.label || op;
}
/** 深度遍历每个节点(含分支) */
export function eachNode(node: FlowNode | null | undefined, cb: (n: FlowNode) => void) {
if (!node) return;
cb(node);
if (node.childNode) eachNode(node.childNode, cb);
if (node.conditionNodes) node.conditionNodes.forEach((b) => eachNode(b, cb));
}
/** 在 prevId 节点之后插入新节点 */
export function insertAfter(root: FlowNode, prevId: string, newNode: FlowNode) {
eachNode(root, (n) => {
if (n.id === prevId && newNode.id !== prevId) {
newNode.childNode = n.childNode || null;
n.childNode = newNode;
}
});
}
/** 删除节点(普通链节点 / 条件分支,发起人不可删) */
export function removeNode(root: FlowNode, id: string): boolean {
// 1) 链式节点:找到以其为 childNode 的父节点
let parent: FlowNode | null = null;
eachNode(root, (n) => {
if (n.childNode && n.childNode.id === id) parent = n;
});
if (parent) {
(parent as FlowNode).childNode = (parent as FlowNode).childNode?.childNode || null;
return true;
}
// 2) 条件分支:找到包含该分支的 condition
let cond: FlowNode | null = null;
eachNode(root, (n) => {
if (n.conditionNodes && n.conditionNodes.some((b) => b.id === id)) cond = n;
});
if (cond) {
const c = cond as FlowNode;
const arr = c.conditionNodes as FlowNode[];
if (arr.length <= 2) {
// 只剩两条分支时删除一条 => 整个条件节点收起,保留其后续链
let condParent: FlowNode | null = null;
eachNode(root, (n) => {
if (n.childNode && n.childNode.id === c.id) condParent = n;
});
if (condParent) (condParent as FlowNode).childNode = c.childNode || null;
} else {
const i = arr.findIndex((b) => b.id === id);
if (i >= 0) arr.splice(i, 1);
}
return true;
}
return false;
}
/** 给条件节点新增一条分支 */
export function addBranch(root: FlowNode, conditionId: string) {
eachNode(root, (n) => {
if (n.id === conditionId && n.conditionNodes) {
const next = n.conditionNodes.length + 1;
// 新分支插入到"其它情况"默认分支之前
const defaultIdx = n.conditionNodes.findIndex((b) => b.props.isDefault);
const branch = createBranchNode(next);
if (defaultIdx >= 0) {
n.conditionNodes.splice(defaultIdx, 0, branch);
} else {
n.conditionNodes.push(branch);
}
}
});
}

View File

@@ -0,0 +1,34 @@
import { defHttp } from '/@/utils/http/axios';
/**
* 发起审批 API全局悬浮按钮使用
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】发起审批运行时
*/
enum Api {
publishedList = '/xslmes/approvalLaunch/publishedList',
bizRecords = '/xslmes/approvalLaunch/bizRecords',
launch = '/xslmes/approvalLaunch/launch',
launchBatch = '/xslmes/approvalLaunch/launchBatch',
}
/**
* 已发布审批流列表(可发起的单据类型)
*/
export const getPublishedFlows = () => defHttp.get({ url: Api.publishedList });
/**
* 根据审批流查询其绑定单据的记录列表
*/
export const getBizRecords = (params: { flowId: string; keyword?: string }) => defHttp.get({ url: Api.bizRecords, params });
/**
* 发起审批(单条)
*/
export const launchApproval = (params: { flowId: string; bizDataId: string; bizTitle?: string }) => defHttp.post({ url: Api.launch, params });
/**
* 批量发起审批(列表多选)
*/
export const launchApprovalBatch = (params: { flowId: string; items: { bizDataId: string; bizTitle?: string }[] }) =>
defHttp.post({ url: Api.launchBatch, params });

View File

@@ -0,0 +1,154 @@
<!--
IM 审批卡片查看详情弹窗展示单据全部字段 + 审批进度/历史
@author GHT
@date 2026-05-29 forQH-MES审批流设计审批办理-查看详情
-->
<template>
<a-modal v-model:open="open" title="审批单据详情" :width="640" :footer="null" :destroyOnClose="true">
<a-spin :spinning="loading">
<div class="im-appr-detail">
<div class="im-appr-detail-head">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="审批流">{{ info.flowName }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor">{{ info.statusText }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="发起人">{{ info.applyUserName }}</a-descriptions-item>
<a-descriptions-item label="当前节点">{{ info.currentNodeName || '-' }}</a-descriptions-item>
<a-descriptions-item label="当前处理人" :span="2">{{ info.currentHandlersText || '-' }}</a-descriptions-item>
</a-descriptions>
</div>
<div class="im-appr-detail-section-title">单据数据</div>
<table class="im-appr-detail-table">
<tbody>
<tr v-for="f in fields" :key="f.label">
<th>{{ f.label }}</th>
<td>{{ f.value }}</td>
</tr>
<tr v-if="!fields.length">
<td class="im-appr-detail-empty">暂无单据数据</td>
</tr>
</tbody>
</table>
<template v-if="history.length">
<div class="im-appr-detail-section-title">审批历史</div>
<a-timeline class="im-appr-detail-timeline">
<a-timeline-item v-for="(h, i) in history" :key="i" :color="h.actionText === '驳回' ? 'red' : 'green'">
<div class="im-appr-his-line">
<b>{{ h.nodeName }}</b>
<span>{{ h.name }} {{ h.actionText }}</span>
<span class="im-appr-his-time">{{ h.time }}</span>
</div>
<div v-if="h.comment" class="im-appr-his-comment">意见{{ h.comment }}</div>
</a-timeline-item>
</a-timeline>
</template>
</div>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { getApprovalDetail } from '/@/views/approval/flow/approvalHandle.api';
defineOptions({ name: 'ImApprovalDetailModal' });
const { createMessage } = useMessage();
const open = ref(false);
const loading = ref(false);
const info = ref<any>({});
const fields = ref<{ label: string; value: string }[]>([]);
const history = ref<any[]>([]);
const statusColor = computed(() => {
const s = info.value?.status;
if (s === '1') return 'green';
if (s === '2') return 'red';
if (s === '3') return 'default';
return 'blue';
});
async function openModal(instanceId: string) {
open.value = true;
info.value = {};
fields.value = [];
history.value = [];
if (!instanceId) return;
try {
loading.value = true;
const data: any = await getApprovalDetail(instanceId);
info.value = data || {};
fields.value = data?.fields || [];
history.value = data?.history || [];
} catch (e: any) {
createMessage.error(e?.message || '获取详情失败');
} finally {
loading.value = false;
}
}
defineExpose({ openModal });
</script>
<style lang="less" scoped>
.im-appr-detail-section-title {
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
color: #333;
border-left: 3px solid #1677ff;
padding-left: 8px;
}
.im-appr-detail-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid #f0f0f0;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid #f0f0f0;
text-align: left;
vertical-align: top;
word-break: break-word;
}
th {
width: 32%;
background: #fafafa;
color: #595959;
font-weight: 500;
}
.im-appr-detail-empty {
color: #999;
text-align: center;
}
}
.im-appr-his-line {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
.im-appr-his-time {
color: #999;
font-size: 12px;
margin-left: auto;
}
}
.im-appr-his-comment {
margin-top: 2px;
font-size: 12px;
color: #888;
}
</style>

View File

@@ -1,442 +1,426 @@
<template>
<div class="im-biz-record-message">
<div v-if="showNoPermission" class="im-biz-record-no-permission">暂无当前消息权限</div>
<template v-else>
<!-- 单条详情表 -->
<template v-if="isSingleItem">
<div class="im-biz-record-item">
<div class="im-biz-record-table-wrap">
<table class="im-biz-record-table im-biz-record-table--detail">
<tbody>
<tr v-for="field in resolveItemFields(singleItem)" :key="field.label">
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
</tbody>
</table>
</div>
<template v-if="isSingleItem">
<div class="im-biz-record-item">
<div class="im-biz-record-table-wrap">
<table class="im-biz-record-table im-biz-record-table--detail">
<tbody>
<tr v-for="field in resolveItemFields(singleItem)" :key="field.label">
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
</tbody>
</table>
<!-- 审批卡片底部操作按钮 -->
<div v-if="isApprovalCard" class="im-biz-record-actions">
<a-button size="small" @click="handleDetail(singleItem)">
<Icon icon="ant-design:file-search-outlined" />
<span>查看详情</span>
</a-button>
<template v-if="liveActionable">
<a-button size="small" type="primary" :loading="approving" @click="handleApprove(singleItem)">
<Icon icon="ant-design:check-outlined" />
<span>{{ singleItem.actionLabel || '审批' }}</span>
</a-button>
<a-button size="small" danger @click="openReject(singleItem)">
<Icon icon="ant-design:close-outlined" />
<span>拒绝</span>
</a-button>
</template>
<!-- 不可办理(已处理/已流转/非当前处理人)置灰提示 -->
<span v-else-if="!props.mine && disabledText" class="im-biz-record-disabled">{{ disabledText }}</span>
<a v-if="canLocate" class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:unordered-list-outlined" />
<span>跳转至列表</span>
</a>
</div>
<!-- 普通分享卡片定位链接 -->
<a v-else class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>查看并定位到此数据</span>
</a>
</div>
</template>
<a class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>查看并定位到此数据</span>
</a>
</div>
<!-- 多条列表表第一列为定位链接 -->
<template v-else>
<div class="im-biz-record-table-wrap im-biz-record-table-wrap--list">
<table class="im-biz-record-table im-biz-record-table--list">
<thead>
<tr>
<th class="im-biz-record-link-col">链接</th>
<th v-for="columnLabel in listColumnLabels" :key="columnLabel">{{ columnLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in payload.items" :key="item.recordId || index">
<td class="im-biz-record-link-col">
<a class="im-biz-record-link" @click.prevent="handleLinkClick(item.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>定位</span>
</a>
</td>
<td v-for="columnLabel in listColumnLabels" :key="columnLabel">
{{ getFieldValue(item, columnLabel) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-if="showPeerNoPermissionTip" class="im-biz-record-peer-tip">对方无此功能权限</div>
</template>
<!-- 查看详情弹窗 -->
<ImApprovalDetailModal ref="detailModalRef" />
<!-- 多条列表表第一列为定位链接 -->
<template v-else>
<div class="im-biz-record-table-wrap im-biz-record-table-wrap--list">
<table class="im-biz-record-table im-biz-record-table--list">
<thead>
<tr>
<th class="im-biz-record-link-col">链接</th>
<th v-for="columnLabel in listColumnLabels" :key="columnLabel">{{ columnLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in payload.items" :key="item.recordId || index">
<td class="im-biz-record-link-col">
<a class="im-biz-record-link" @click.prevent="handleLinkClick(item.linkPath)">
<Icon icon="ant-design:link-outlined" />
<span>定位</span>
</a>
</td>
<td v-for="columnLabel in listColumnLabels" :key="columnLabel">
{{ getFieldValue(item, columnLabel) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-if="showPeerNoPermissionTip" class="im-biz-record-peer-tip">对方无此功能权限</div>
</template>
<!-- 驳回理由弹窗 -->
<a-modal v-model:open="rejectOpen" title="驳回审批" :confirmLoading="rejecting" okText="确认驳回" @ok="confirmReject">
<a-form layout="vertical">
<a-form-item label="驳回理由" required>
<a-textarea v-model:value="rejectReason" :rows="3" placeholder="请填写驳回理由" :maxlength="500" show-count />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, ref, onMounted, watch } from 'vue';
import type { ImBizRecordItem, ImBizRecordPayload } from './imBizRecordMessage';
import {
getImBizRecordFieldValueByLabel,
resolveImBizRecordItemFields,
resolveImBizRecordListColumnLabels,
} from './imBizRecordMessage';
import { getImBizRecordFieldValueByLabel, resolveImBizRecordItemFields, resolveImBizRecordListColumnLabels } from './imBizRecordMessage';
import { navigateImBizRecordLink } from './imRecordLocate';
import { hasImBizRecordPagePermission } from './imBizRecordPermission';
import { useMessage } from '/@/hooks/web/useMessage';
import { approveApproval, rejectApproval, getApprovalStatus } from '/@/views/approval/flow/approvalHandle.api';
import ImApprovalDetailModal from './ImApprovalDetailModal.vue';
defineOptions({ name: 'ImBizRecordMessageContent' });
const props = defineProps<{
payload: ImBizRecordPayload;
mine?: boolean;
receiverHasBizPagePermission?: boolean;
}>();
// 办理成功后通知父级(ImChat)刷新当前会话,使下一节点卡片/结果通知即时出现
const emit = defineEmits(['handled']);
const { createMessage } = useMessage();
const detailModalRef = ref();
const approving = ref(false);
const rejecting = ref(false);
const rejectOpen = ref(false);
const rejectReason = ref('');
const rejectItem = ref<ImBizRecordItem | null>(null);
// 本地办理结果approved / rejected卡片消息为静态办理后本地标记
const actionDone = ref<'' | 'approved' | 'rejected'>('');
// 审批实例实时状态(用于旧节点卡片置灰)
interface LiveStatus {
exists: boolean;
status?: string;
statusText?: string;
currentNodeId?: string;
currentHandlersText?: string;
canApprove?: boolean;
}
const liveStatus = ref<LiveStatus | null>(null);
const liveLoaded = ref(false);
const isSingleItem = computed(() => props.payload.items.length === 1);
const singleItem = computed(() => props.payload.items[0]);
const listColumnLabels = computed(() => resolveImBizRecordListColumnLabels(props.payload.items));
// 审批卡片单条且带审批实例ID
const isApprovalCard = computed(() => isSingleItem.value && !!singleItem.value?.instanceId);
// 是否仍可办理:本地未办理 且 实例审批中 且 卡片节点==当前节点 且 本人为当前处理人
const liveActionable = computed(() => {
if (!isApprovalCard.value || actionDone.value || props.mine) {
return false;
}
const s = liveStatus.value;
if (!s || !s.exists || s.status !== '0' || !s.canApprove) {
return false;
}
// 携带 nodeId 时严格比对当前节点,区分同一实例的新旧卡片
const cardNodeId = singleItem.value?.nodeId;
if (cardNodeId) {
return s.currentNodeId === cardNodeId;
}
return true;
});
// 不可办理时的置灰提示文案
const disabledText = computed(() => {
if (actionDone.value) {
return actionDone.value === 'rejected' ? '已驳回' : '已处理';
}
const s = liveStatus.value;
if (!s) {
return liveLoaded.value ? '加载失败' : '';
}
if (!s.exists) {
return '审批已失效';
}
if (s.status === '1') return '已通过';
if (s.status === '2') return '已驳回';
if (s.status === '3') return '已撤销';
// 审批中但本卡片不可办理
const cardNodeId = singleItem.value?.nodeId;
if (cardNodeId && s.currentNodeId !== cardNodeId) {
return '已流转,无需处理';
}
return '等待他人处理';
});
async function loadLiveStatus() {
const id = singleItem.value?.instanceId;
if (!isApprovalCard.value || props.mine || !id) {
return;
}
try {
const res: any = await getApprovalStatus(id);
liveStatus.value = (res || { exists: false }) as LiveStatus;
} catch {
liveStatus.value = null;
} finally {
liveLoaded.value = true;
}
}
onMounted(loadLiveStatus);
watch(() => singleItem.value?.instanceId, loadLiveStatus);
const hasPagePermission = computed(() => hasImBizRecordPagePermission(props.payload.pagePath));
// 审批卡片即使无列表页权限也应可办理/查看详情,仅"跳转至列表"受页面权限约束
const showNoPermission = computed(() => !props.mine && !hasPagePermission.value && !isApprovalCard.value);
const canLocate = computed(() => props.mine || hasPagePermission.value);
const showNoPermission = computed(() => !props.mine && !hasPagePermission.value);
const showPeerNoPermissionTip = computed(
() => !!props.mine && props.receiverHasBizPagePermission === false,
);
const showPeerNoPermissionTip = computed(() => !!props.mine && props.receiverHasBizPagePermission === false);
function resolveItemFields(item: ImBizRecordItem) {
return resolveImBizRecordItemFields(item);
}
function getFieldValue(item: ImBizRecordItem, label: string) {
return getImBizRecordFieldValueByLabel(item, label);
}
async function handleLinkClick(linkPath: string) {
if (!linkPath || showNoPermission.value) {
return;
}
await navigateImBizRecordLink(linkPath);
}
function handleDetail(item: ImBizRecordItem) {
if (!item.instanceId) return;
detailModalRef.value?.openModal(item.instanceId);
}
async function handleApprove(item: ImBizRecordItem) {
if (!item.instanceId || approving.value) return;
try {
approving.value = true;
const res: any = await approveApproval({ instanceId: item.instanceId });
createMessage.success(typeof res === 'string' ? res : '已审批');
actionDone.value = 'approved';
// 立即刷新本卡片状态(置灰),再通知父级刷新会话
await loadLiveStatus();
emit('handled');
} finally {
approving.value = false;
}
}
function openReject(item: ImBizRecordItem) {
rejectItem.value = item;
rejectReason.value = '';
rejectOpen.value = true;
}
async function confirmReject() {
const item = rejectItem.value;
if (!item?.instanceId) return;
if (!rejectReason.value.trim()) {
createMessage.warning('请填写驳回理由');
return;
}
try {
rejecting.value = true;
await rejectApproval({ instanceId: item.instanceId, reason: rejectReason.value.trim() });
createMessage.success('已驳回');
actionDone.value = 'rejected';
rejectOpen.value = false;
await loadLiveStatus();
emit('handled');
} finally {
rejecting.value = false;
}
}
</script>
<style lang="less" scoped>
.im-biz-record-message {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 280px;
max-width: 420px;
}
.im-biz-record-no-permission {
padding: 12px 10px;
font-size: 13px;
line-height: 1.5;
color: #8c8c8c;
text-align: center;
}
.im-biz-record-peer-tip {
display: inline-flex;
align-items: center;
align-self: flex-start;
margin-top: 4px;
padding: 2px 8px;
border-radius: 10px;
background: #fff7e6;
border: 1px solid #ffd591;
font-size: 12px;
line-height: 1.5;
color: #d46b08;
}
.im-biz-record-item {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 审批办理按钮栏 */
.im-biz-record-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-top: 4px;
border-top: 1px dashed #f0f0f0;
.im-biz-record-done {
font-size: 12px;
color: #52c41a;
font-weight: 500;
}
/* 不可办理置灰提示 */
.im-biz-record-disabled {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 10px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
font-size: 12px;
color: #bfbfbf;
}
}
.im-biz-record-table-wrap {
overflow: hidden;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fff;
&--list {
overflow-x: auto;
}
}
.im-biz-record-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.5;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
word-break: break-word;
}
tr:last-child {
th,
td {
border-bottom: none;
}
}
&--detail {
table-layout: fixed;
th {
width: 38%;
background: #fafafa;
color: #595959;
font-weight: 500;
text-align: left;
}
td {
color: #262626;
background: #fff;
}
}
&--list {
min-width: 100%;
table-layout: auto;
thead th {
background: #fafafa;
color: #595959;
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td {
color: #262626;
background: #fff;
}
}
}
.im-biz-record-link-col {
width: 72px;
min-width: 72px;
white-space: nowrap;
}
.im-biz-record-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #1677ff;
text-decoration: underline;
cursor: pointer;
&:hover {
color: #0958d9;
}
}
</style>

View File

@@ -175,6 +175,7 @@
:payload="getBizRecordPayload(msg.content)!"
:mine="msg.mine"
:receiver-has-biz-page-permission="msg.receiverHasBizPagePermission"
@handled="handleApprovalHandled"
/>
<div v-else class="message-content">{{ msg.content }}</div>
<div class="message-time">{{ formatTime(msg.createTime) }}</div>
@@ -1376,6 +1377,19 @@
//update-end---author:xsl ---date:20260528 for【IM聊天】收到新消息时靠近底部则自动滚底否则显示新消息提示-----------
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批办理后即时刷新当前会话,连续节点同一处理人无需关闭重开-----
// 审批卡片办理(通过/驳回)成功后:下一节点卡片或结果通知已由后端同步发出并入库,
// 此处强制刷新当前会话消息,使新卡片立即出现在当前聊天窗口(不依赖 WS 推送时序)。
async function handleApprovalHandled() {
if (!activeConversationId.value) {
return;
}
await loadMessages(true, { forceRefresh: true });
await nextTick();
scrollToBottom();
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批办理后即时刷新当前会话,连续节点同一处理人无需关闭重开-----
function handleImChatSocketUi(data: Record<string, any>) {
if (data.cmd !== 'chat') {
return;

View File

@@ -16,6 +16,16 @@ export interface ImBizRecordItem {
/** v2表格字段 */
fields?: ImBizRecordField[];
linkPath: string;
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批卡片办理扩展字段-----
/** 审批实例ID存在则为审批卡片展示办理按钮 */
instanceId?: string;
/** 当前节点办理按钮文案(如 审批/审核/批准),存在且 canApprove 时展示 */
actionLabel?: string;
/** 接收人是否可办理(当前活动处理人 且 审批中) */
canApprove?: boolean;
/** 该卡片对应的节点ID用于实时判断是否仍为当前节点旧节点卡片置灰 */
nodeId?: string;
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批卡片办理扩展字段-----
}
export interface ImBizRecordPayload {