Merge branch '20260519-3.9.2版本-葛昊天分支'

This commit is contained in:
geht
2026-06-05 10:45:42 +08:00
86 changed files with 9783 additions and 54 deletions

View File

@@ -677,7 +677,37 @@ jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockReason/MesXslRubberSmallLoc
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLog.data.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLog.api.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLogList.vue
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/components/MesXslRubberSmallLockLogModal.vue
-- author:GHT---date:20260604--for: 【QH-MES审批台账】跨 MES/钉钉 统一审批门禁台账 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_125__mes_xsl_approval_record.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/constant/ApprovalRecordConstants.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalRecordMapper.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalRecordService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalRecordServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalGateService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalGateServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/vo/ApprovalGateVo.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalGateController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
-- author:GHT---date:20260604--for: 【QH-MES审批台账】MESToDing审批配置下增加审批台账菜单与列表页 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_126__mes_xsl_approval_record_menu.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalRecordController.java
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.api.ts
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.data.ts
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList.vue
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslApprovalRecordDetailModal.vue
-- author:GHT---date:20260604--for: 【钉钉Stream回调】Stream模式接收钉钉审批结果并回写MES审批台账 -----
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java
-- author:jiangxh---date:20250602--for: 【MES】设备台账原设备编号改为自定义编号、新增001自增只读系统编号 ---
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_equipment_ledger_ledger_no.sql

View File

@@ -19,6 +19,12 @@
<artifactId>jeecg-boot-base-core</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 钉钉 Stream SDK: 官方 Stream 模式事件订阅,无需公网回调地址 GHT 20260604 -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
<!-- 复用打印模板模块打印机枚举、业务绑定、PDF 提交队列 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>

View File

@@ -75,6 +75,19 @@ public class ApprovalActionHttpExecutor {
if (node == null || inst == null) {
return;
}
String token = currentToken();
run(node, phase, inst.getBizDataId(), token);
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】钉钉后台线程无HTTP上下文支持显式传入token-----
/**
* 以显式 token 执行节点回调配置的接口(供无 HTTP 上下文的后台线程使用,如钉钉 Stream 回调)。
* 当 token 为空时降级跳过(与原有行为一致)。
*/
public void run(JSONObject node, String phase, String bizDataId, String token) {
if (node == null || oConvertUtils.isEmpty(bizDataId)) {
return;
}
JSONObject propsObj = node.getJSONObject("props");
if (propsObj == null) {
return;
@@ -87,7 +100,6 @@ public class ApprovalActionHttpExecutor {
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) {
@@ -98,14 +110,14 @@ public class ApprovalActionHttpExecutor {
continue;
}
if (oConvertUtils.isEmpty(token)) {
// 无登录态(自动流转/无人值守) -> 降级跳过
log.warn("[审批回调] 无当前处理人登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, inst.getBizDataId());
log.warn("[审批回调] 无登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, bizDataId);
continue;
}
String method = oConvertUtils.getString(action.getString("method"), "POST").toUpperCase();
invoke(method, url, action.getJSONObject("body"), inst.getBizDataId(), token);
invoke(method, url, action.getJSONObject("body"), bizDataId, token);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】钉钉后台线程无HTTP上下文支持显式传入token-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表注解自动调用业务接口-----------
/**

View File

@@ -0,0 +1,34 @@
package org.jeecg.modules.xslmes.approval.constant;
/**
* 审批台账常量
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public final class ApprovalRecordConstants {
private ApprovalRecordConstants() {
}
/** MES 审批通道 */
public static final String CHANNEL_MES = "MES";
/** 钉钉审批通道 */
public static final String CHANNEL_DINGTALK = "DINGTALK";
/** 流转中 */
public static final String STATUS_RUNNING = "0";
/** 审批通过 */
public static final String STATUS_APPROVED = "1";
/** 审批拒绝 */
public static final String STATUS_REJECTED = "2";
/** 已撤销 */
public static final String STATUS_CANCELLED = "3";
/** 发起失败 */
public static final String STATUS_LAUNCH_FAILED = "4";
}

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.Data;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 审批门禁 API
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Tag(name = "MES审批门禁")
@RestController
@RequestMapping("/xslmes/approvalGate")
@Slf4j
public class MesXslApprovalGateController {
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Operation(summary = "检查是否允许发起审批")
@GetMapping("/canLaunch")
public Result<ApprovalGateVo> canLaunch(
@RequestParam("bizTable") String bizTable,
@RequestParam("bizDataId") String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("bizTable 与 bizDataId 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
ApprovalGateVo vo = approvalGateService.checkCanLaunch(bizTable.trim(), bizDataId.trim(), tenantId);
return Result.OK(vo);
}
@Operation(summary = "批量检查是否允许发起审批")
@PostMapping("/canLaunchBatch")
public Result<List<ApprovalGateVo>> canLaunchBatch(@RequestBody CanLaunchBatchRequest req) {
if (req == null || oConvertUtils.isEmpty(req.getBizTable()) || req.getBizDataIds() == null || req.getBizDataIds().isEmpty()) {
return Result.error("bizTable 与 bizDataIds 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
List<ApprovalGateVo> list = approvalGateService.checkCanLaunchBatch(req.getBizTable().trim(), req.getBizDataIds(), tenantId);
return Result.OK(list);
}
@Operation(summary = "查询业务单据审批台账历史")
@GetMapping("/history")
public Result<List<MesXslApprovalRecord>> history(
@RequestParam("bizTable") String bizTable,
@RequestParam("bizDataId") String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("bizTable 与 bizDataId 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
return Result.OK(approvalGateService.listHistory(bizTable.trim(), bizDataId.trim(), tenantId));
}
@Data
public static class CanLaunchBatchRequest {
private String bizTable;
private List<String> bizDataIds;
}
}

View File

@@ -1,6 +1,5 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,8 +11,10 @@ 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.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -52,6 +53,11 @@ public class MesXslApprovalLaunchController {
private IMesXslApprovalHandleService approvalHandleService;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起改用流转引擎进入首节点-----
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -166,22 +172,22 @@ public class MesXslApprovalLaunchController {
return Result.error("该审批流未发布,无法发起");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
return Result.error("该单据已有审批中的流程,请勿重复发起");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
Integer tenantId = MesXslTenantUtils.resolveTenantId(flow.getTenantId());
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
try {
approvalGateService.assertCanLaunch(flow.getBizTable(), bizDataId, tenantId);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalGateService.createRunningRecord(
approvalGateService.buildMesDraft(flow.getBizTable(), flow.getBizTableName(), bizDataId, bizTitle,
flow.getId(), flow.getFlowName(), inst.getId(), loginUser, tenantId));
approvalHandleService.enterFirstNode(inst, loginUser);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
syncApprovalRecordAfterLaunch(inst.getId());
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
return Result.OK("发起成功!");
}
@@ -218,23 +224,25 @@ public class MesXslApprovalLaunchController {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重:跳过已有审批中实例的单据-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】批量发起统一门禁与台账写入-----
Integer tenantId = MesXslTenantUtils.resolveTenantId(flow.getTenantId());
ApprovalGateVo gate = approvalGateService.checkCanLaunch(flow.getBizTable(), bizDataId, tenantId);
if (gate.getAllowed() == null || !gate.getAllowed()) {
skipCount++;
continue;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重跳过已有审批中实例的单据-----
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalGateService.createRunningRecord(
approvalGateService.buildMesDraft(flow.getBizTable(), flow.getBizTableName(), bizDataId, bizTitle,
flow.getId(), flow.getFlowName(), inst.getId(), loginUser, tenantId));
approvalHandleService.enterFirstNode(inst, loginUser);
syncApprovalRecordAfterLaunch(inst.getId());
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】批量发起统一门禁与台账写入-----
count++;
}
if (count == 0) {
return Result.error(skipCount > 0 ? "所选单据均已有审批中的流程,无需重复发起" : "没有有效的单据数据");
return Result.error(skipCount > 0 ? "所选单据均不允许发起审批(可能已有流转中或已通过流程)" : "没有有效的单据数据");
}
String msg = "已发起 " + count + " 条审批!";
if (skipCount > 0) {
@@ -265,4 +273,14 @@ public class MesXslApprovalLaunchController {
// 处理人解析、节点进度初始化、卡片发送统一由 IMesXslApprovalHandleService.enterFirstNode 完成
return inst;
}
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起后同步台账终态(如无节点自动通过)-----
private void syncApprovalRecordAfterLaunch(String instanceId) {
MesXslApprovalInstance latest = approvalInstanceService.getById(instanceId);
if (latest == null || "0".equals(latest.getStatus())) {
return;
}
approvalGateService.finishByMesInstance(instanceId, latest.getStatus(), latest.getCurrentHandlersText());
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起后同步台账终态(如无节点自动通过)-----
}

View File

@@ -0,0 +1,69 @@
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.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
/**
* MES 审批台账(只读查询)
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】台账列表菜单
*/
@Tag(name = "MES审批台账")
@RestController
@RequestMapping("/xslmes/mesXslApprovalRecord")
@Slf4j
public class MesXslApprovalRecordController extends JeecgController<MesXslApprovalRecord, IMesXslApprovalRecordService> {
@Autowired
private IMesXslApprovalRecordService mesXslApprovalRecordService;
@Operation(summary = "MES审批台账-分页列表查询")
@RequiresPermissions("xslmes:mes_xsl_approval_record:list")
@GetMapping(value = "/list")
public Result<IPage<MesXslApprovalRecord>> queryPageList(
MesXslApprovalRecord model,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslApprovalRecord> queryWrapper = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
queryWrapper.orderByDesc("apply_time").orderByDesc("create_time");
Page<MesXslApprovalRecord> page = new Page<>(pageNo, pageSize);
IPage<MesXslApprovalRecord> pageList = mesXslApprovalRecordService.page(page, queryWrapper);
return Result.OK(pageList);
}
@Operation(summary = "MES审批台账-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_approval_record:list")
@GetMapping(value = "/queryById")
public Result<MesXslApprovalRecord> queryById(@RequestParam(name = "id") String id) {
MesXslApprovalRecord entity = mesXslApprovalRecordService.getById(id);
if (entity == null) {
return Result.error("未找到对应数据");
}
return Result.OK(entity);
}
@RequiresPermissions("xslmes:mes_xsl_approval_record:exportXls")
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, MesXslApprovalRecord model) {
return super.exportXls(request, model, MesXslApprovalRecord.class, "MES审批台账");
}
}

View File

@@ -0,0 +1,111 @@
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 审批台账(跨 MES/钉钉 统一门禁)
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_approval_record")
@Schema(description = "MES审批台账")
public class MesXslApprovalRecord extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据中文名")
private String bizTableName;
@Schema(description = "业务编码(菜单permission.id)")
private String bizCode;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "业务单据展示标题")
private String bizTitle;
@Schema(description = "审批通道 MES/DINGTALK")
@Dict(dicCode = "mes_xsl_approval_channel")
private String channel;
@Schema(description = "外部实例ID(MES实例ID或钉钉instanceId)")
private String externalInstanceId;
@Schema(description = "MES审批流ID")
private String flowId;
@Schema(description = "MES审批流名称")
private String flowName;
@Schema(description = "钉钉审批模板ID")
private String templateId;
@Schema(description = "钉钉审批模板名称")
private String templateName;
@Schema(description = "同一业务单据第几次发起")
private Integer launchNo;
@Schema(description = "状态 0流转中 1通过 2拒绝 3撤销 4发起失败")
@Dict(dicCode = "mes_xsl_approval_record_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 = "办结时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date finishTime;
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】与 MesXslApprovalInstance 对齐,复用 isBizAtOriginStatus 逻辑-----
@Schema(description = "业务单据状态字段名(发起时快照,驳回回写依据)")
private String statusField;
@Schema(description = "发起审批时业务状态原值(驳回回写依据)")
private String originStatus;
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】与 MesXslApprovalInstance 对齐,复用 isBizAtOriginStatus 逻辑-----
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁字段记录已处理的节点回调数-----
@Schema(description = "钉钉回调已处理节点数(幂等去重,勿用于业务逻辑)")
private Integer processedOpCount;
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁字段记录已处理的节点回调数-----
@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,13 @@
package org.jeecg.modules.xslmes.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
/**
* MES 审批台账 Mapper
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface MesXslApprovalRecordMapper extends BaseMapper<MesXslApprovalRecord> {
}

View File

@@ -0,0 +1,80 @@
package org.jeecg.modules.xslmes.approval.service;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import java.util.List;
import java.util.Map;
/**
* 审批门禁服务:统一判断能否发起审批,并维护审批台账
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface IMesXslApprovalGateService {
/**
* 检查是否允许对指定业务单据发起审批(跨 MES/钉钉通道)
*/
ApprovalGateVo checkCanLaunch(String bizTable, String bizDataId, Integer tenantId);
/**
* 批量检查是否允许发起审批
*/
List<ApprovalGateVo> checkCanLaunchBatch(String bizTable, List<String> bizDataIds, Integer tenantId);
/**
* 校验允许发起,不允许则抛出 IllegalStateException供 Controller 转 Result.error
*/
void assertCanLaunch(String bizTable, String bizDataId, Integer tenantId);
/**
* 发起审批时写入台账(状态=流转中)
*/
MesXslApprovalRecord createRunningRecord(MesXslApprovalRecord draft);
/**
* MES 审批实例办结时同步台账
*/
void finishByMesInstance(String mesInstanceId, String status, String remark);
/**
* 按外部实例 ID 办结台账(预留钉钉回调等场景)。
* 返回 true 表示本次调用实际更新了台账(状态从流转中变为终态);
* 返回 false 表示台账已是终态,本次为重复调用。
*/
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回是否实际更新供调用方判断是否跳过后续业务回调-----
boolean finishByExternalInstance(String channel, String externalInstanceId, String status, String remark);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回是否实际更新供调用方判断是否跳过后续业务回调-----
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁标记节点已处理-----
/**
* 用乐观锁尝试将台账的 processed_op_count 推进到 nodeIndex+1。
* 返回 true 表示成功(本次是第一个处理该节点的线程);
* 返回 false 表示已被其他线程/事件处理过,调用方应跳过。
*/
boolean tryMarkNodeProcessed(String recordId, int nodeIndex);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁标记节点已处理-----
/**
* 查询业务单据审批历史(按发起时间倒序)
*/
List<MesXslApprovalRecord> listHistory(String bizTable, String bizDataId, Integer tenantId);
/**
* 构建 MES 通道台账草稿
*/
MesXslApprovalRecord buildMesDraft(String bizTable, String bizTableName, String bizDataId, String bizTitle,
String flowId, String flowName, String mesInstanceId, LoginUser loginUser,
Integer tenantId);
/**
* 构建钉钉通道台账草稿
*/
MesXslApprovalRecord buildDingDraft(String bizTable, String bizTableName, String bizCode, String bizDataId,
String bizTitle, String flowId, String flowName, String templateId,
String templateName, String dingInstanceId, LoginUser loginUser,
Integer tenantId);
}

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.MesXslApprovalRecord;
/**
* MES 审批台账 Service
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface IMesXslApprovalRecordService extends IService<MesXslApprovalRecord> {
}

View File

@@ -0,0 +1,302 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 审批门禁服务实现
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Slf4j
@Service
public class MesXslApprovalGateServiceImpl implements IMesXslApprovalGateService {
@Autowired
private IMesXslApprovalRecordService recordService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public ApprovalGateVo checkCanLaunch(String bizTable, String bizDataId, Integer tenantId) {
ApprovalGateVo vo = new ApprovalGateVo()
.setBizTable(bizTable)
.setBizDataId(bizDataId);
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return vo.setAllowed(false).setReason("业务单据信息不完整,无法判断审批状态");
}
MesXslApprovalRecord latest = findLatest(bizTable, bizDataId, tenantId);
if (latest == null) {
return vo.setAllowed(true).setReason("尚未发起过审批,允许发起");
}
fillLatestInfo(vo, latest);
if (ApprovalRecordConstants.STATUS_RUNNING.equals(latest.getStatus())) {
String channelText = channelText(latest.getChannel());
return vo.setAllowed(false).setReason("该单据已有" + channelText + "流程流转中,请勿重复发起");
}
if (ApprovalRecordConstants.STATUS_APPROVED.equals(latest.getStatus())) {
return vo.setAllowed(false).setReason("该单据已审批通过,无需重复发起");
}
if (ApprovalRecordConstants.STATUS_REJECTED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次审批已被拒绝,允许重新发起");
}
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次审批已撤销,允许重新发起");
}
if (ApprovalRecordConstants.STATUS_LAUNCH_FAILED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次发起失败,允许重新发起");
}
return vo.setAllowed(true).setReason("允许发起审批");
}
@Override
public List<ApprovalGateVo> checkCanLaunchBatch(String bizTable, List<String> bizDataIds, Integer tenantId) {
List<ApprovalGateVo> list = new ArrayList<>();
if (bizDataIds == null || bizDataIds.isEmpty()) {
return list;
}
for (String bizDataId : bizDataIds) {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
list.add(checkCanLaunch(bizTable, bizDataId, tenantId));
}
return list;
}
@Override
public void assertCanLaunch(String bizTable, String bizDataId, Integer tenantId) {
ApprovalGateVo gate = checkCanLaunch(bizTable, bizDataId, tenantId);
if (gate.getAllowed() == null || !gate.getAllowed()) {
throw new IllegalStateException(oConvertUtils.getString(gate.getReason(), "当前不允许发起审批"));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public MesXslApprovalRecord createRunningRecord(MesXslApprovalRecord draft) {
if (draft == null) {
throw new IllegalArgumentException("台账数据不能为空");
}
assertCanLaunch(draft.getBizTable(), draft.getBizDataId(), draft.getTenantId());
Date now = new Date();
if (draft.getApplyTime() == null) {
draft.setApplyTime(now);
}
draft.setStatus(ApprovalRecordConstants.STATUS_RUNNING);
draft.setLaunchNo(nextLaunchNo(draft.getBizTable(), draft.getBizDataId(), draft.getTenantId()));
recordService.save(draft);
return draft;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void finishByMesInstance(String mesInstanceId, String status, String remark) {
finishByExternalInstance(ApprovalRecordConstants.CHANNEL_MES, mesInstanceId, status, remark);
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回实际影响行数0表示台账已是终态-----
@Override
@Transactional(rollbackFor = Exception.class)
public boolean finishByExternalInstance(String channel, String externalInstanceId, String status, String remark) {
if (oConvertUtils.isEmpty(externalInstanceId) || oConvertUtils.isEmpty(status)) {
return false;
}
if (ApprovalRecordConstants.STATUS_RUNNING.equals(status)) {
return false;
}
LambdaUpdateWrapper<MesXslApprovalRecord> uw = new LambdaUpdateWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, externalInstanceId)
.eq(MesXslApprovalRecord::getStatus, ApprovalRecordConstants.STATUS_RUNNING);
if (oConvertUtils.isNotEmpty(channel)) {
uw.eq(MesXslApprovalRecord::getChannel, channel);
}
uw.set(MesXslApprovalRecord::getStatus, status)
.set(MesXslApprovalRecord::getFinishTime, new Date());
if (oConvertUtils.isNotEmpty(remark)) {
uw.set(MesXslApprovalRecord::getRemark, remark);
}
// getBaseMapper().update() 返回实际影响行数0 = 台账已是终态(重复事件)
int affected = recordService.getBaseMapper().update(null, uw);
return affected > 0;
}
@Override
public boolean tryMarkNodeProcessed(String recordId, int nodeIndex) {
if (oConvertUtils.isEmpty(recordId)) return false;
// 乐观锁processed_op_count < nodeIndex+1 时才推进,保证同一节点只被处理一次
// 两个并发线程同时到达时MySQL 行锁保证只有一个 UPDATE 成功
int affected = jdbcTemplate.update(
"UPDATE mes_xsl_approval_record SET processed_op_count = ? " +
"WHERE id = ? AND processed_op_count < ? AND del_flag = 0",
nodeIndex + 1, recordId, nodeIndex + 1);
return affected > 0;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回实际影响行数0表示台账已是终态-----
@Override
public List<MesXslApprovalRecord> listHistory(String bizTable, String bizDataId, Integer tenantId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return List.of();
}
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime);
return recordService.list(qw);
}
@Override
public MesXslApprovalRecord buildMesDraft(String bizTable, String bizTableName, String bizDataId, String bizTitle,
String flowId, String flowName, String mesInstanceId, LoginUser loginUser,
Integer tenantId) {
MesXslApprovalRecord record = new MesXslApprovalRecord();
record.setBizTable(bizTable);
record.setBizTableName(bizTableName);
record.setBizDataId(bizDataId);
record.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
record.setChannel(ApprovalRecordConstants.CHANNEL_MES);
record.setExternalInstanceId(mesInstanceId);
record.setFlowId(flowId);
record.setFlowName(flowName);
record.setTenantId(tenantId);
fillApplyUser(record, loginUser);
return record;
}
@Override
public MesXslApprovalRecord buildDingDraft(String bizTable, String bizTableName, String bizCode, String bizDataId,
String bizTitle, String flowId, String flowName, String templateId,
String templateName, String dingInstanceId, LoginUser loginUser,
Integer tenantId) {
MesXslApprovalRecord record = new MesXslApprovalRecord();
record.setBizTable(bizTable);
record.setBizTableName(bizTableName);
record.setBizCode(bizCode);
record.setBizDataId(bizDataId);
record.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
record.setChannel(ApprovalRecordConstants.CHANNEL_DINGTALK);
record.setExternalInstanceId(dingInstanceId);
record.setFlowId(flowId);
record.setFlowName(flowName);
record.setTemplateId(templateId);
record.setTemplateName(templateName);
record.setTenantId(tenantId);
fillApplyUser(record, loginUser);
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】快照业务状态原值复用 isBizAtOriginStatus 逻辑-----
snapshotOriginStatus(record, bizTable, bizDataId);
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】快照业务状态原值复用 isBizAtOriginStatus 逻辑-----
return record;
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】发起时快照业务状态驳回时供共用判断-----
/**
* 探测业务表是否存在 status 字段,若存在则读取当前值快照到台账。
* 与 MesXslApprovalHandleServiceImpl.snapshotBizStatus() 逻辑对齐,
* 使驳回时的 isBizAtOriginStatus 判断可跨 MES/钉钉两通道复用。
*/
private void snapshotOriginStatus(MesXslApprovalRecord record, String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) return;
if (!bizTable.matches("^[A-Za-z0-9_]+$")) return;
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.columns " +
"WHERE table_schema=(SELECT DATABASE()) AND table_name=? AND column_name='status'",
Integer.class, bizTable);
if (cnt == null || cnt == 0) return;
java.util.List<String> vals = jdbcTemplate.queryForList(
"SELECT status FROM " + bizTable + " WHERE id=? LIMIT 1", String.class, bizDataId);
if (!vals.isEmpty()) {
record.setStatusField("status");
record.setOriginStatus(vals.get(0));
}
} catch (Exception e) {
log.warn("[ApprovalGate] 快照业务状态失败 table={} id={}: {}", bizTable, bizDataId, e.getMessage());
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】发起时快照业务状态驳回时供共用判断-----
private MesXslApprovalRecord findLatest(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime).last("LIMIT 1");
return recordService.getOne(qw, false);
}
private LambdaQueryWrapper<MesXslApprovalRecord> baseBizQuery(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = new LambdaQueryWrapper<>();
qw.eq(MesXslApprovalRecord::getBizTable, bizTable)
.eq(MesXslApprovalRecord::getBizDataId, bizDataId);
if (tenantId != null) {
qw.eq(MesXslApprovalRecord::getTenantId, tenantId);
}
return qw;
}
private int nextLaunchNo(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
long count = recordService.count(qw);
return (int) count + 1;
}
private void fillLatestInfo(ApprovalGateVo vo, MesXslApprovalRecord latest) {
vo.setLatestRecordId(latest.getId())
.setLatestStatus(latest.getStatus())
.setLatestChannel(latest.getChannel())
.setLatestChannelText(channelText(latest.getChannel()))
.setLatestStatusText(statusText(latest.getStatus()));
}
private void fillApplyUser(MesXslApprovalRecord record, LoginUser loginUser) {
if (loginUser == null) {
return;
}
record.setApplyUser(loginUser.getUsername());
record.setApplyUserName(loginUser.getRealname());
record.setSysOrgCode(loginUser.getOrgCode());
}
private String channelText(String channel) {
if (ApprovalRecordConstants.CHANNEL_DINGTALK.equals(channel)) {
return "钉钉审批";
}
if (ApprovalRecordConstants.CHANNEL_MES.equals(channel)) {
return "MES审批";
}
return oConvertUtils.getString(channel, "审批");
}
private String statusText(String status) {
if (ApprovalRecordConstants.STATUS_RUNNING.equals(status)) {
return "流转中";
}
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
return "审批通过";
}
if (ApprovalRecordConstants.STATUS_REJECTED.equals(status)) {
return "审批拒绝";
}
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
return "已撤销";
}
if (ApprovalRecordConstants.STATUS_LAUNCH_FAILED.equals(status)) {
return "发起失败";
}
return oConvertUtils.getString(status, "");
}
}

View File

@@ -18,6 +18,7 @@ import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher;
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.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -68,6 +69,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
@Autowired
private IMesXslApprovalInstanceService instanceService;
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -121,6 +127,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentNodeName("无审批节点");
inst.setCurrentHandlersText("无审批节点,自动通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, "无审批节点,自动通过");
// 无审批节点直接最终通过 -> 回调业务
callbackDispatcher.fireApproved(buildContext(inst, inst.getCurrentNodeId(), "无审批节点", applyUser, "无审批节点,自动通过"));
notifyApplicant(inst, applyUser == null ? null : applyUser.getUsername(),
@@ -209,6 +216,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, comment);
// 无后续流程,直接最终通过
callbackDispatcher.fireApproved(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, comment));
return Result.OK("审批通过");
@@ -258,6 +266,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlersText("已驳回");
inst.setNodeProgress(progress.toJSONString());
instanceService.updateById(inst);
syncApprovalRecord(inst, reason);
// 驳回 -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, reason));
@@ -324,6 +333,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("已撤销");
instanceService.updateById(inst);
syncApprovalRecord(inst, comment);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销改为恢复发起前快照,不再走业务「拒绝/驳回」接口-----
// 撤销时单据仍处于发起前最初状态,只需把状态字段恢复到发起时快照(幂等)
@@ -423,6 +433,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批人为空,流程终止");
instanceService.updateById(inst);
syncApprovalRecord(inst, "审批人为空,流程终止");
// 流程终止(等同驳回) -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, nodeId, nodeName, null, "审批人为空,流程终止"));
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】流程终止驳回统一回退(按表注解自动执行)-----
@@ -516,6 +527,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, null);
// 流程最终通过 -> 回调业务(终态)
callbackDispatcher.fireApproved(buildContext(inst, currentNodeId, inst.getCurrentNodeName(), actingUser, null));
actionHttpExecutor.run(findNodeById(root, currentNodeId), "onApprove", inst);
@@ -1571,4 +1583,13 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】全链路通知补全-----
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
private void syncApprovalRecord(MesXslApprovalInstance inst, String remark) {
if (inst == null || oConvertUtils.isEmpty(inst.getId()) || "0".equals(inst.getStatus())) {
return;
}
approvalGateService.finishByMesInstance(inst.getId(), inst.getStatus(), remark);
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
}

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalRecordMapper;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.stereotype.Service;
/**
* MES 审批台账 ServiceImpl
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Service
public class MesXslApprovalRecordServiceImpl extends ServiceImpl<MesXslApprovalRecordMapper, MesXslApprovalRecord>
implements IMesXslApprovalRecordService {
}

View File

@@ -0,0 +1,48 @@
package org.jeecg.modules.xslmes.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 审批门禁检查结果
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Data
@Accessors(chain = true)
@Schema(description = "审批门禁检查结果")
public class ApprovalGateVo implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "是否允许发起审批")
private Boolean allowed;
@Schema(description = "不允许时的原因说明")
private String reason;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "最新台账记录ID")
private String latestRecordId;
@Schema(description = "最新台账状态")
private String latestStatus;
@Schema(description = "最新台账通道 MES/DINGTALK")
private String latestChannel;
@Schema(description = "最新台账通道展示名")
private String latestChannelText;
@Schema(description = "最新台账状态展示名")
private String latestStatusText;
}

View File

@@ -44,6 +44,9 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
@Autowired
private ISysDepartService sysDepartService;
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
@Operation(summary = "MES密炼PS编制-分页列表查询")
@GetMapping(value = "/list")
public Result<IPage<MesXslMixerPsCompile>> queryPageList(

View File

@@ -0,0 +1,319 @@
package org.jeecg.modules.xslmes.dingtalk.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.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.modules.print.catalog.IPrintBizEntityFieldCatalogProvider;
import org.jeecg.modules.print.service.IPrintBizPermEntityService;
import org.jeecg.modules.print.util.PrintBizDetailPropertyScanner;
import org.jeecg.modules.print.util.PrintBizEntityFieldIntrospector;
import org.jeecg.modules.print.vo.PrintBizDetailSlotVO;
import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
import org.jeecg.modules.print.vo.PrintBizTypeVO;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 钉钉审批模板绑定:将菜单业务实体字段与审批模板表单控件可视化绑定
*
* @author GHT
* @date 2026-06-04 for【MESToDing审批配置】审批模板绑定
*/
@Tag(name = "钉钉审批模板绑定")
@RestController
@RequestMapping("/xslmes/dingTplBind")
@Slf4j
public class MesXslDingTplBindController {
@Autowired private IMesXslDingTplBindService bindService;
@Autowired private IMesXslDingProcessTplService tplService;
@Autowired private IPrintBizPermEntityService printBizPermEntityService;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired(required = false)
private IPrintBizEntityFieldCatalogProvider fieldCatalogProvider;
// ═══════════════════════ 菜单树 ═══════════════════════
/**
* 查询 sys_permission 一二级菜单树menu_type=0/1最多两层供左侧选择业务菜单。
* 不限租户(全量展示,与打印绑定白名单选树逻辑一致)。
*/
@Operation(summary = "查询一二级菜单树(供左侧树选择)")
@GetMapping("/menuTree")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MenuNodeVO>> menuTree() {
String sql = "SELECT id, parent_id, name, sort_no, menu_type, icon " +
"FROM sys_permission " +
"WHERE del_flag = 0 AND status = '1' AND menu_type IN (0, 1) " +
"ORDER BY sort_no ASC, create_time ASC";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
Map<String, MenuNodeVO> nodeMap = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
MenuNodeVO n = new MenuNodeVO();
n.setId(str(row.get("id")));
n.setName(str(row.get("name")));
n.setParentId(str(row.get("parent_id")));
n.setMenuType(row.get("menu_type") != null ? ((Number) row.get("menu_type")).intValue() : 0);
n.setIcon(str(row.get("icon")));
n.setChildren(new ArrayList<>());
nodeMap.put(n.getId(), n);
}
// 构建两层树根节点parentId 为空)及其直接子节点
List<MenuNodeVO> roots = new ArrayList<>();
Set<String> rootIds = new HashSet<>();
for (MenuNodeVO n : nodeMap.values()) {
if (StringUtils.isBlank(n.getParentId()) || !nodeMap.containsKey(n.getParentId())) {
roots.add(n);
rootIds.add(n.getId());
}
}
for (MenuNodeVO n : nodeMap.values()) {
if (rootIds.contains(n.getParentId())) {
nodeMap.get(n.getParentId()).getChildren().add(n);
}
}
// 过滤掉没有子节点的根节点(纯目录空节点无意义展示)
roots.removeIf(r -> r.getChildren().isEmpty() && r.getMenuType() == 0);
return Result.OK(roots);
}
// ═══════════════════════ 绑定总览列表 ═══════════════════════
@Operation(summary = "查询所有绑定记录(按创建时间倒序)")
@GetMapping("/list")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MesXslDingTplBind>> list() {
List<MesXslDingTplBind> rows = bindService.list(
new QueryWrapper<MesXslDingTplBind>()
.orderByDesc("create_time"));
return Result.OK(rows);
}
// ═══════════════════════ 模板列表 ═══════════════════════
@Operation(summary = "可用钉钉审批模板列表(状态启用)")
@GetMapping("/tplList")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MesXslDingProcessTpl>> tplList() {
List<MesXslDingProcessTpl> list = tplService.list(
new QueryWrapper<MesXslDingProcessTpl>()
.eq("status", "1")
.eq("del_flag", 0)
.orderByAsc("sort_no")
.orderByDesc("create_time"));
return Result.OK(list);
}
// ═══════════════════════ 业务字段 ═══════════════════════
@Operation(summary = "按业务编码获取主实体可用字段")
@GetMapping("/bizFields")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizFieldItemVO>> bizFields(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listMainFields(code));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizEntityFieldIntrospector.listFields(cls));
}
@Operation(summary = "主实体上的明细槽位(供 TableField 绑定明细集合)")
@GetMapping("/detailSlots")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizDetailSlotVO>> detailSlots(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listDetailSlots(code));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizDetailPropertyScanner.listSlots(cls));
}
@Operation(summary = "指定明细槽位的字段列表(字段路径带属性名前缀)")
@GetMapping("/detailFields")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizFieldItemVO>> detailFields(
@RequestParam(name = "bizCode") String bizCode,
@RequestParam(name = "detailProperty") String detailProperty,
@RequestParam(name = "slotKind", defaultValue = "LIST") String slotKind) {
if (StringUtils.isAnyBlank(bizCode, detailProperty)) {
return Result.error("bizCode 与 detailProperty 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listPrefixedDetailFields(code, detailProperty.trim(), slotKind.trim()));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizDetailPropertyScanner.listPrefixedDetailFields(cls, detailProperty.trim(), slotKind.trim()));
}
// ═══════════════════════ 按路由检测绑定(全局悬浮按钮使用) ═══════════════════════
/**
* 通过前端路由路径查找对应的审批模板绑定。
* 全局悬浮按钮在每次路由跳转时调用,若返回非 null 则显示"发起钉钉审批"按钮。
*/
@Operation(summary = "按前端路由路径检测是否有钉钉绑定null=无)")
@GetMapping("/bindingByRoute")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<MesXslDingTplBind> bindingByRoute(@RequestParam(name = "routePath") String routePath) {
if (StringUtils.isBlank(routePath)) {
return Result.OK(null);
}
String path = routePath.trim();
String sql = "SELECT id FROM sys_permission WHERE url = ? AND del_flag = 0 LIMIT 1";
List<String> ids;
try {
ids = jdbcTemplate.queryForList(sql, String.class, path);
} catch (Exception e) {
return Result.OK(null);
}
if (ids.isEmpty()) {
return Result.OK(null);
}
MesXslDingTplBind bind = bindService.getByBizCode(ids.get(0));
return Result.OK(bind);
}
// ═══════════════════════ 绑定 CRUD ═══════════════════════
@Operation(summary = "按业务编码查询已保存的绑定配置")
@GetMapping("/getByBizCode")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<MesXslDingTplBind> getByBizCode(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
MesXslDingTplBind row = bindService.getByBizCode(bizCode.trim());
return row != null ? Result.OK(row) : Result.OK(null);
}
@AutoLog(value = "钉钉审批模板绑定-保存")
@Operation(summary = "保存绑定配置(新增或更新)")
@PostMapping("/save")
@RequiresPermissions("xslmes:mesXslDingTplBind:save")
public Result<String> save(@RequestBody SaveBindRequest req) {
if (req == null || StringUtils.isBlank(req.getBizCode())) {
return Result.error("业务编码不能为空");
}
if (StringUtils.isBlank(req.getTemplateId())) {
return Result.error("请选择钉钉审批模板");
}
MesXslDingProcessTpl tpl = tplService.getById(req.getTemplateId());
if (tpl == null) {
return Result.error("选择的钉钉审批模板不存在");
}
MesXslDingTplBind existing = bindService.getByBizCode(req.getBizCode().trim());
if (existing != null) {
// 更新已有绑定
existing.setTemplateId(req.getTemplateId());
existing.setTemplateName(tpl.getTplName());
if (StringUtils.isNotBlank(req.getBizName())) {
existing.setBizName(req.getBizName());
}
existing.setFieldMappingJson(normalizeJson(req.getFieldMappingJson()));
bindService.updateById(existing);
return Result.OK("更新成功");
}
// 新增
MesXslDingTplBind bind = new MesXslDingTplBind();
bind.setBizCode(req.getBizCode().trim());
bind.setBizName(req.getBizName());
bind.setTemplateId(req.getTemplateId());
bind.setTemplateName(tpl.getTplName());
bind.setFieldMappingJson(normalizeJson(req.getFieldMappingJson()));
bindService.save(bind);
return Result.OK("保存成功");
}
@AutoLog(value = "钉钉审批模板绑定-删除")
@Operation(summary = "删除绑定配置")
@DeleteMapping("/delete")
@RequiresPermissions("xslmes:mesXslDingTplBind:delete")
public Result<String> delete(@RequestParam(name = "id") String id) {
bindService.removeById(id);
return Result.OK("删除成功");
}
// ═══════════════════════ 辅助方法 ═══════════════════════
private static String str(Object o) {
return o == null ? null : o.toString();
}
/** 确保 mapping JSON 为数组字符串,避免空或非法格式入库 */
private static String normalizeJson(String raw) {
if (StringUtils.isBlank(raw)) {
return "[]";
}
String trimmed = raw.trim();
if (trimmed.startsWith("[")) {
return trimmed;
}
return "[]";
}
// ═══════════════════════ 内部 VO ═══════════════════════
@Data
public static class MenuNodeVO {
private String id;
private String name;
private String parentId;
private Integer menuType;
private String icon;
private List<MenuNodeVO> children;
}
@Data
public static class SaveBindRequest {
private String bizCode;
private String bizName;
private String templateId;
private String fieldMappingJson;
}
}

View File

@@ -0,0 +1,43 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 钉钉审批表单控件
* 对应官方 SDKFormComponent
*
* 支持的 componentType
* TextField 单行文本
* TextareaField 多行文本
* NumberField 数字输入
* DDSelectField 单选
* DDMultiSelectField 多选
* DDDateField 日期
* DDDateRangeField 时间区间
* DDPhotoField 图片
* DDAttachment 附件
* DepartmentField 部门
* InnerContactField 联系人
* TextNote 说明文字
* MoneyField 金额
* PhoneField 电话
* AddressField 省市区
* StarRatingField 评分
* TableField 明细(含子控件 children
*/
@Data
@Accessors(chain = true)
public class DingFormComponent {
/** 控件类型 */
private String componentType;
/** 控件属性 */
private DingFormComponentProps props;
/** 子控件列表,仅 TableField 使用 */
private List<DingFormComponent> children;
}

View File

@@ -0,0 +1,99 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 钉钉审批表单控件属性
* 对应官方 SDKFormComponentProps
*/
@Data
@Accessors(chain = true)
public class DingFormComponentProps {
/** 控件唯一标识,建议格式:{componentType}-{bizKey} */
private String componentId;
/** 控件标题(钉钉表单中显示的字段名) */
private String label;
/** 占位提示文字 */
private String placeholder;
/** 是否必填 */
private Boolean required;
/** 日期格式DDDateField/DDDateRangeField 使用,如 "yyyy-MM-dd" */
private String format;
/** 单位NumberField/DDDateField/DDDateRangeField 使用 */
private String unit;
/** 说明内容TextNote 使用 */
private String content;
/** 说明文字超链接TextNote 使用 */
private String link;
/** 是否参与打印("0"=否TextNote 使用 */
private String print;
/** 单选/多选选项列表DDSelectField/DDMultiSelectField 使用 */
private List<SelectOption> options;
/** 业务别名DDSelectField 使用(如 "staff_type" */
private String bizAlias;
/** 金额大写显示("0"=否 "1"=是MoneyField 使用 */
private String upper;
/** 电话模式("phone"PhoneField 使用 */
private String mode;
/** 联系人选择模式("1"=多选InnerContactField 使用 */
private String choice;
/** 部门是否多选DepartmentField 使用 */
private Boolean multiple;
/** 省市区精度("city"=市级 "district"=区级AddressField 使用 */
private String addressModel;
/** 评分最大值StarRatingField 使用 */
private Integer limit;
/** 明细视图模式("table"/"list"TableField 使用 */
private String tableViewMode;
/** 明细打印方向true=纵向 false=横向TableField 使用 */
private Boolean verticalPrint;
/** 明细汇总字段TableField 使用 */
private List<StatField> statField;
/** 可关联的审批单列表RelateField 使用 */
private List<AvailableTemplate> availableTemplates;
@Data
@Accessors(chain = true)
public static class SelectOption {
private String key;
private String value;
}
@Data
@Accessors(chain = true)
public static class StatField {
private String componentId;
private String label;
}
@Data
@Accessors(chain = true)
public static class AvailableTemplate {
private String name;
private String processCode;
}
}

View File

@@ -0,0 +1,25 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 创建钉钉审批模板请求体
* POST https://api.dingtalk.com/v1.0/workflow/forms
* 对应官方 SDKFormCreateRequest
*/
@Data
@Accessors(chain = true)
public class DingFormCreateRequest {
/** 模板名称 */
private String name;
/** 模板描述 */
private String description;
/** 表单控件列表 */
private List<DingFormComponent> formComponents;
}

View File

@@ -0,0 +1,28 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 更新钉钉审批模板请求体
* PUT https://api.dingtalk.com/v1.0/workflow/forms
* 对应官方 SDKFormUpdateRequest
*/
@Data
@Accessors(chain = true)
public class DingFormUpdateRequest {
/** 要更新的模板 processCode */
private String processCode;
/** 模板名称 */
private String name;
/** 模板描述 */
private String description;
/** 表单控件列表 */
private List<DingFormComponent> formComponents;
}

View File

@@ -0,0 +1,73 @@
package org.jeecg.modules.xslmes.dingtalk.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.jeecgframework.poi.excel.annotation.Excel;
import java.io.Serializable;
/**
* 钉钉审批模板配置
*
* @author GHT
* @date 2026-06-03 for【MESToDing审批配置】钉钉审批模板配置
*/
@Data
@TableName("mes_xsl_ding_process_tpl")
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "钉钉审批模板配置")
public class MesXslDingProcessTpl extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Excel(name = "模板名称", width = 20)
@Schema(description = "模板名称")
private String tplName;
@Excel(name = "钉钉processCode", width = 35)
@Schema(description = "钉钉processCode")
private String processCode;
@Excel(name = "业务类型标识", width = 20)
@Schema(description = "业务类型标识(供审批流关联使用)")
private String bizType;
@Excel(name = "表单字段映射", width = 30)
@Schema(description = "表单字段映射JSON(钉钉字段名→MES字段名)")
private String formFields;
@Excel(name = "状态", width = 10, dicCode = "mes_ding_tpl_status")
@Dict(dicCode = "mes_ding_tpl_status")
@Schema(description = "状态:0停用 1启用")
private String status;
@Excel(name = "排序", width = 10)
@Schema(description = "排序")
private Integer sortNo;
@Excel(name = "备注", width = 30)
@Schema(description = "备注")
private String remark;
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】绑定MES审批流审批人来源-----------
@Schema(description = "绑定的MES审批流ID(用于发起审批时解析审批人)")
private String flowId;
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】绑定MES审批流审批人来源-----------
@Schema(description = "逻辑删除:0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,52 @@
package org.jeecg.modules.xslmes.dingtalk.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.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* 钉钉审批模板绑定(菜单业务实体 ↔ 钉钉审批模板表单字段)
*
* @author GHT
* @date 2026-06-04 for【MESToDing审批配置】审批模板绑定
*/
@Data
@TableName("mes_xsl_ding_tpl_bind")
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "钉钉审批模板绑定")
public class MesXslDingTplBind extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务编码(sys_permission.id与打印绑定 biz_code 含义一致)")
private String bizCode;
@Schema(description = "业务名称(菜单名)")
private String bizName;
@Schema(description = "钉钉审批模板ID(mes_xsl_ding_process_tpl.id)")
private String templateId;
@Schema(description = "钉钉审批模板名称")
private String templateName;
@Schema(description = "字段绑定JSON[{componentId,componentLabel,componentName,parentId,bizField}]")
private String fieldMappingJson;
@Schema(description = "逻辑删除:0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.dingtalk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
/**
* 钉钉审批模板配置 Mapper
*
* @author GHT
* @date 2026-06-03
*/
public interface MesXslDingProcessTplMapper extends BaseMapper<MesXslDingProcessTpl> {
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.dingtalk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
/**
* 钉钉审批模板绑定 Mapper
*
* @author GHT
* @date 2026-06-04
*/
public interface MesXslDingTplBindMapper extends BaseMapper<MesXslDingTplBind> {
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingProcessTplMapper">
</mapper>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper">
</mapper>

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.dingtalk.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
/**
* 钉钉审批模板配置 Service 接口
*
* @author GHT
* @date 2026-06-03
*/
public interface IMesXslDingProcessTplService extends IService<MesXslDingProcessTpl> {
}

View File

@@ -0,0 +1,16 @@
package org.jeecg.modules.xslmes.dingtalk.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
/**
* 钉钉审批模板绑定 Service
*
* @author GHT
* @date 2026-06-04
*/
public interface IMesXslDingTplBindService extends IService<MesXslDingTplBind> {
/** 按业务编码查询绑定记录(未删除的第一条) */
MesXslDingTplBind getByBizCode(String bizCode);
}

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.xslmes.dingtalk.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
import org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingProcessTplMapper;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
import org.springframework.stereotype.Service;
/**
* 钉钉审批模板配置 ServiceImpl
*
* @author GHT
* @date 2026-06-03
*/
@Service
public class MesXslDingProcessTplServiceImpl extends ServiceImpl<MesXslDingProcessTplMapper, MesXslDingProcessTpl>
implements IMesXslDingProcessTplService {
}

View File

@@ -0,0 +1,24 @@
package org.jeecg.modules.xslmes.dingtalk.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
import org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
import org.springframework.stereotype.Service;
/**
* 钉钉审批模板绑定 ServiceImpl
*
* @author GHT
* @date 2026-06-04
*/
@Service
public class MesXslDingTplBindServiceImpl extends ServiceImpl<MesXslDingTplBindMapper, MesXslDingTplBind>
implements IMesXslDingTplBindService {
@Override
public MesXslDingTplBind getByBizCode(String bizCode) {
return getOne(new QueryWrapper<MesXslDingTplBind>().eq("biz_code", bizCode).last("LIMIT 1"));
}
}

View File

@@ -0,0 +1,352 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.ApprovalCallbackDispatcher;
import org.jeecg.modules.xslmes.approval.callback.ApprovalActionHttpExecutor;
import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 钉钉审批事件处理器。
* <p>
* 收到 Stream 事件后,主动调用 {@link DingTalkWorkflowService#getProcessInstance} 拉取完整实例数据,
* 通过 {@code operationRecords} 精准得知"哪个节点由谁操作",从而:
* <ul>
* <li>用审批人自己的 JWT Token 调用业务接口proofread_by/audit_by 写入真实操作人);</li>
* <li>以 operationRecord 的顺序索引映射到 MES 流程节点,执行节点配置的 callbackActions</li>
* <li>同时触发 {@link org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback} Bean 回调。</li>
* </ul>
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于operationRecords精准映射节点与操作人
*/
@Slf4j
@Component
public class DingBpmsEventProcessor {
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Autowired
private IMesXslApprovalRecordService approvalRecordService;
@Autowired
private IMesXslApprovalFlowService approvalFlowService;
@Autowired
private ApprovalCallbackDispatcher callbackDispatcher;
@Autowired
private ApprovalActionHttpExecutor actionHttpExecutor;
@Autowired
private DingTalkWorkflowService workflowService;
@Autowired
private JdbcTemplate jdbcTemplate;
// ==================== bpms_instance_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
public void onInstanceChange(JSONObject data) {
if (data == null) return;
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
log.info("[DingBpms] bpms_instance_change instanceId={} type={} result={}",
processInstanceId, type, result);
if (oConvertUtils.isEmpty(processInstanceId) || "start".equals(type)) {
return;
}
// ① 映射终态
String status;
String remark;
if ("finish".equals(type)) {
if ("agree".equals(result)) {
status = ApprovalRecordConstants.STATUS_APPROVED;
remark = "钉钉审批通过";
} else if ("refuse".equals(result)) {
status = ApprovalRecordConstants.STATUS_REJECTED;
remark = "钉钉审批拒绝";
} else {
log.info("[DingBpms] 审批转交 result={},不处理 instanceId={}", result, processInstanceId);
return;
}
} else if ("terminate".equals(type)) {
status = ApprovalRecordConstants.STATUS_CANCELLED;
remark = "钉钉审批已终止";
} else {
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
// ② 更新台账乐观条件WHERE status=RUNNING返回 false 表示已是终态,本次为重复事件,直接跳过)
try {
boolean updated = approvalGateService.finishByExternalInstance(
ApprovalRecordConstants.CHANNEL_DINGTALK, processInstanceId, status, remark);
if (!updated) {
log.info("[DingBpms] instanceId={} 台账已是终态,跳过重复的终态事件", processInstanceId);
return;
}
log.info("[DingBpms] 台账已更新 instanceId={} -> status={}", processInstanceId, status);
} catch (Exception e) {
log.error("[DingBpms] 台账更新失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
return;
}
// ③ 拉取完整审批实例
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
return;
}
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
ApprovalCallbackContext ctx = buildContext(record, remark);
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
// 最终通过:执行最后一个节点的 onApprove用最后审批人的 token
if (!mesNodes.isEmpty() && !taskOps.isEmpty()) {
JSONObject lastOp = taskOps.get(taskOps.size() - 1);
String lastDtUserId = lastOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
actionHttpExecutor.run(lastNode, "onApprove", record.getBizDataId(), token);
}
callbackDispatcher.fireApproved(ctx);
} else {
// 驳回:复用与 MES 内部审批相同的 isBizAtOriginStatus 逻辑
// 台账已在发起时快照了 originStatus若单据状态仍为原值说明未被推进跳过 onReject
JSONObject refuseOp = findRefuseOp(taskOps);
if (refuseOp != null) {
int refuseIndex = taskOps.indexOf(refuseOp);
boolean bizAtOrigin = isBizAtOriginStatus(record);
if (!bizAtOrigin && refuseIndex < mesNodes.size()) {
// 单据已被前面节点推进,需要调 onReject 回退
String dtUserId = refuseOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(dtUserId);
try {
actionHttpExecutor.run(mesNodes.get(refuseIndex), "onReject",
record.getBizDataId(), token);
} catch (Exception e) {
log.error("[DingBpms] onReject HTTP 回调失败: {}", e.getMessage());
}
} else {
log.info("[DingBpms] 单据仍处于发起前原始状态,跳过 onReject 回调 instanceId={}",
processInstanceId);
}
}
callbackDispatcher.fireRejected(ctx);
}
log.info("[DingBpms] 终态回调完成 bizTable={} bizDataId={} status={}",
record.getBizTable(), record.getBizDataId(), status);
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
// ==================== bpms_task_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
/**
* 节点任务变更:每个审批人操作时触发。
* <p>
* 节点通过时:
* <ol>
* <li>拉取审批实例详情,从 operationRecords 得到"已完成任务列表"</li>
* <li>最后一条完成操作的索引 = 本次节点在 MES 流程中的位置;</li>
* <li>用该操作人的 Token 执行对应 MES 节点的 onNodeApprove 回调接口;</li>
* <li>触发 IApprovalBizCallback.onNodeApproved。</li>
* </ol>
*/
public void onTaskChange(JSONObject data) {
if (data == null) return;
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
String actionerDtUserId = data.getString("actionerUserId");
log.info("[DingBpms] bpms_task_change instanceId={} type={} result={} actionerUserId={}",
processInstanceId, type, result, actionerDtUserId);
// 只处理节点"完成-通过"
if (!"finish".equals(type) || !"agree".equals(result)) {
// 拒绝终态由 bpms_instance_change 统一处理,此处不重复触发
return;
}
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
return;
}
// 拉取实例详情
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
if (instance == null) {
log.warn("[DingBpms] 获取审批实例详情失败 instanceId={},跳过节点回调", processInstanceId);
return;
}
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
if (taskOps.isEmpty() || mesNodes.isEmpty()) {
return;
}
// 刚完成的是最后一条操作index = taskOps.size()-1
int nodeIndex = taskOps.size() - 1;
if (nodeIndex >= mesNodes.size()) {
log.debug("[DingBpms] 节点索引 {} 超出 MES 节点数 {},跳过", nodeIndex, mesNodes.size());
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// tryMarkNodeProcessedUPDATE ... SET processed_op_count=nodeIndex+1 WHERE processed_op_count<nodeIndex+1
// MySQL 行锁保证原子性:并发两个事件只有一个成功,另一个返回 false
boolean claimed = approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex);
if (!claimed) {
log.info("[DingBpms] 节点{} 回调已执行,跳过重复事件 instanceId={}", nodeIndex + 1, processInstanceId);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// 操作人:优先用 operationRecords 里的 userId兜底用事件里的 actionerUserId
JSONObject lastOp = taskOps.get(nodeIndex);
String dtUserId = oConvertUtils.isNotEmpty(lastOp.getString("userId"))
? lastOp.getString("userId") : actionerDtUserId;
String actioner = oConvertUtils.isNotEmpty(lastOp.getString("showName"))
? lastOp.getString("showName") : "审批人";
String token = workflowService.generateTokenByDtUserId(dtUserId);
JSONObject node = mesNodes.get(nodeIndex);
// 执行该节点的 onNodeApprove HTTP 回调
actionHttpExecutor.run(node, "onNodeApprove", record.getBizDataId(), token);
// 触发 IApprovalBizCallback.onNodeApproved
try {
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + "")
.setOperatorName(actioner);
callbackDispatcher.fireNodeApproved(ctx);
} catch (Exception e) {
log.error("[DingBpms] 节点 Bean 回调失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
}
log.info("[DingBpms] 节点{}/{} 回调完成 actioner={} bizTable={} bizDataId={}",
nodeIndex + 1, mesNodes.size(), actioner, record.getBizTable(), record.getBizDataId());
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
// ==================== 内部辅助 ====================
private MesXslApprovalRecord findRecord(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) return null;
List<MesXslApprovalRecord> list = approvalRecordService.list(
new LambdaQueryWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, processInstanceId)
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
.orderByDesc(MesXslApprovalRecord::getCreateTime)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
private List<JSONObject> loadApproverNodes(String flowId) {
List<JSONObject> result = new ArrayList<>();
if (oConvertUtils.isEmpty(flowId)) return result;
try {
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) return result;
collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result);
} catch (Exception e) {
log.warn("[DingBpms] 加载流程节点失败 flowId={}: {}", flowId, e.getMessage());
}
return result;
}
private void collectApproverNodes(JSONObject node, List<JSONObject> out) {
if (node == null) return;
if ("approver".equals(node.getString("type"))) {
out.add(node);
}
JSONArray branches = node.getJSONArray("conditionNodes");
if (branches != null && !branches.isEmpty()) {
Object first = branches.get(0);
if (first instanceof JSONObject) {
collectApproverNodes(((JSONObject) first).getJSONObject("childNode"), out);
}
}
collectApproverNodes(node.getJSONObject("childNode"), out);
}
/** 找第一条 result=REFUSE 的操作记录 */
private JSONObject findRefuseOp(List<JSONObject> taskOps) {
for (JSONObject op : taskOps) {
String r = op.getString("result");
if ("REFUSE".equalsIgnoreCase(r) || "refuse".equalsIgnoreCase(r)) {
return op;
}
}
return null;
}
/**
* 判断业务单据是否仍处于发起审批前的原始状态。
* 与 MesXslApprovalHandleServiceImpl.isBizAtOriginStatus(MesXslApprovalInstance) 逻辑完全一致,
* 此处基于 MesXslApprovalRecord钉钉通道实现使驳回逻辑在两通道间真正复用。
*/
private boolean isBizAtOriginStatus(MesXslApprovalRecord record) {
String statusField = record.getStatusField();
String originStatus = record.getOriginStatus();
if (oConvertUtils.isEmpty(statusField) || originStatus == null) {
// 无快照信息(旧数据兼容):退化为 false走 onReject 调用
return false;
}
if (!record.getBizTable().matches("^[A-Za-z0-9_]+$")
|| !statusField.matches("^[A-Za-z0-9_]+$")) {
return false;
}
try {
List<String> vals = jdbcTemplate.queryForList(
"SELECT " + statusField + " FROM " + record.getBizTable()
+ " WHERE id=? LIMIT 1", String.class, record.getBizDataId());
return !vals.isEmpty() && java.util.Objects.equals(originStatus, vals.get(0));
} catch (Exception e) {
log.warn("[DingBpms] 读取业务状态失败 table={}: {}", record.getBizTable(), e.getMessage());
return false;
}
}
private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) {
return new ApprovalCallbackContext()
.setInstanceId(record.getId())
.setFlowId(record.getFlowId())
.setFlowName(record.getFlowName())
.setBizTable(record.getBizTable())
.setBizTableName(record.getBizTableName())
.setBizDataId(record.getBizDataId())
.setBizTitle(record.getBizTitle())
.setApplyUser(record.getApplyUser())
.setComment(comment)
.setOperatorUsername("dingtalk")
.setOperatorName("钉钉审批");
}
}

View File

@@ -0,0 +1,151 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.dingtalk.open.app.api.GenericEventListener;
import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder;
import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent;
import com.dingtalk.open.app.api.security.AuthClientCredential;
import com.dingtalk.open.app.stream.protocol.event.EventAckStatus;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
/**
* 钉钉 Stream 模式事件接收客户端(基于官方 SDK com.dingtalk.open:dingtalk-stream
* <p>
* 无需注册公网回调地址:应用主动建立长连接,钉钉通过该通道推送事件(如审批结果),
* 官方 SDK 内部自动维护重连与心跳。
* <p>
* 启动时机:{@link SmartLifecycle}phase=MAX-100确保 Spring 上下文完全就绪后再建连。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于官方SDK的Stream客户端
*/
@Slf4j
@Component
public class DingTalkStreamClient implements SmartLifecycle {
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@Autowired
private DingBpmsEventProcessor bpmsEventProcessor;
private volatile boolean running = false;
// ==================== SmartLifecycle ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
@Override
public int getPhase() {
// 最后启动,确保 DB / Spring Bean 全部就绪
return Integer.MAX_VALUE - 100;
}
@Override
public void start() {
running = true;
// 在后台线程初始化,避免阻塞 Spring 上下文刷新
Thread t = new Thread(this::initSdkClient, "ding-stream");
t.setDaemon(true);
t.start();
}
@Override
public void stop() {
running = false;
// SDK 内部使用 daemon 线程JVM 退出时自动终止
log.info("[DingStream] 钉钉 Stream 客户端已停止");
}
@Override
public boolean isRunning() {
return running;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
// ==================== SDK 初始化(官方写法)====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
private void initSdkClient() {
try {
String[] creds = dingtalkService.getDingAppCredentials();
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
log.warn("[DingStream] 钉钉 AppKey/AppSecret 未配置Stream 连接未启动。"
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。");
return;
}
log.info("[DingStream] 正在建立钉钉 Stream 连接AppKey={}", creds[0]);
// 官方写法build().start() 链式调用SDK 内部管理长连接与重连
OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(creds[0], creds[1]))
.registerAllEventListener(new GenericEventListener() {
@Override
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
return handleEvent(event);
}
})
.build()
.start();
log.info("[DingStream] 钉钉 Stream 客户端已启动,等待审批事件推送");
} catch (Exception e) {
log.error("[DingStream] SDK 启动失败,请检查钉钉配置: {}", e.getMessage(), e);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
// ==================== 事件处理 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
private EventAckStatus handleEvent(GenericOpenDingTalkEvent event) {
try {
String eventType = event.getEventType();
log.debug("[DingStream] 收到事件 eventId={} eventType={} bornTime={}",
event.getEventId(), eventType, event.getEventBornTime());
if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) {
return EventAckStatus.SUCCESS;
}
// getData() 返回 fastjson2 JSONObject
com.alibaba.fastjson2.JSONObject data = toJsonObject(event.getData());
if (data == null) {
log.warn("[DingStream] 事件 data 为空eventType={}", eventType);
return EventAckStatus.SUCCESS;
}
if ("bpms_instance_change".equals(eventType)) {
bpmsEventProcessor.onInstanceChange(data);
} else {
bpmsEventProcessor.onTaskChange(data);
}
return EventAckStatus.SUCCESS;
} catch (Exception e) {
log.error("[DingStream] 事件处理异常 eventType={}: {}", event.getEventType(), e.getMessage(), e);
// LATER通知钉钉稍后重推避免丢失事件
return EventAckStatus.LATER;
}
}
private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) {
if (raw == null) return null;
if (raw instanceof com.alibaba.fastjson2.JSONObject) {
return (com.alibaba.fastjson2.JSONObject) raw;
}
try {
return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw));
} catch (Exception e) {
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
}

View File

@@ -0,0 +1,204 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
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.system.util.JwtUtil;
import org.jeecg.common.util.RedisUtil;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysUser;
import org.jeecg.modules.system.service.ISysUserService;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* 钉钉审批工作流 API 封装。
* 提供「获取审批实例详情」和「审批人身份 Token 生成」两个核心能力,
* 供 {@link DingBpmsEventProcessor} 在收到事件后主动查询完整实例数据。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】主动拉取审批实例详情精准映射节点与操作人
*/
@Slf4j
@Service
public class DingTalkWorkflowService {
private static final String PROCESS_INSTANCE_URL =
"https://api.dingtalk.com/v1.0/workflow/processInstances";
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@Autowired
private ISysUserService sysUserService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RedisUtil redisUtil;
// ==================== 审批实例详情 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
/**
* 调用 GET /v1.0/workflow/processInstances 获取审批实例完整数据。
*
* @param processInstanceId 钉钉审批实例 ID
* @return result 节点(含 status/result/operationRecords/tasks/formComponentValues 等),失败返回 null
*/
public JSONObject getProcessInstance(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) {
return null;
}
// 后台线程无 TenantContext必须用绕过租户校验的专用方法
String accessToken = dingtalkService.getAccessTokenForBackground();
if (oConvertUtils.isEmpty(accessToken)) {
log.warn("[DingWorkflow] AccessToken 获取失败,无法查询审批实例 {}", processInstanceId);
return null;
}
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(PROCESS_INSTANCE_URL + "?processInstanceId=" + processInstanceId))
.header("x-acs-dingtalk-access-token", accessToken)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
String body = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
JSONObject resp = JSONObject.parseObject(body);
if (resp.containsKey("code")) {
log.warn("[DingWorkflow] 查询审批实例失败 instanceId={} code={} msg={}",
processInstanceId, resp.getString("code"), resp.getString("message"));
return null;
}
return resp.getJSONObject("result");
} catch (Exception e) {
log.error("[DingWorkflow] 调用钉钉审批实例详情接口异常 instanceId={}: {}",
processInstanceId, e.getMessage(), e);
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
// ==================== operationRecords 解析 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
/**
* 从审批实例的 operationRecords 中提取所有"正常任务执行"记录type=EXECUTE_TASK_NORMAL
* 按时间顺序排列,每条记录对应一个审批节点的操作(通过/拒绝)。
* <p>
* 返回的列表索引与 MES 流程节点列表索引一一对应第0条 = 第1个节点第1条 = 第2个节点以此类推。
*/
public List<JSONObject> getTaskOperations(JSONObject instanceResult) {
List<JSONObject> ops = new ArrayList<>();
if (instanceResult == null) {
return ops;
}
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) {
return ops;
}
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec == null) continue;
String type = rec.getString("type");
// 只取正常节点执行(过滤掉发起、转交、评论等操作)
if ("EXECUTE_TASK_NORMAL".equals(type) || "EXECUTE_TASK_AGENT".equals(type)) {
ops.add(rec);
}
}
return ops;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
// ==================== 操作人身份 Token ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】按钉钉userId生成操作人JWT Token-----
/**
* 将钉钉 userId 映射到 MES 系统用户,并生成该用户的 JWT Token。
* <p>
* 查询链sys_third_account(third_user_id=dtUserId) → sys_user_id → sys_user.username/password
* <p>
* 这样回调业务接口时,接口内部通过 {@code SecurityUtils.getSubject().getPrincipal()}
* 拿到的就是真实审批人,而非 admin保证 proofread_by/audit_by/approve_by 字段写入正确。
*
* @param dtUserId 钉钉 userId来自 operationRecord.userId
* @return JWT token找不到绑定或发生异常时返回 null调用方降级用 admin token
*/
public String generateTokenByDtUserId(String dtUserId) {
if (oConvertUtils.isEmpty(dtUserId)) {
return null;
}
try {
// ① 钉钉userId → MES sys_user_id
List<String> userIds = jdbcTemplate.queryForList(
"SELECT sys_user_id FROM sys_third_account " +
"WHERE third_type='dingtalk' AND third_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, dtUserId);
if (userIds.isEmpty() || oConvertUtils.isEmpty(userIds.get(0))) {
log.debug("[DingWorkflow] 钉钉用户 {} 未绑定 MES 账号,降级使用 admin token", dtUserId);
return generateAdminToken();
}
// ② sys_user_id → username + password
SysUser user = sysUserService.getById(userIds.get(0));
if (user == null || oConvertUtils.isEmpty(user.getPassword())) {
return generateAdminToken();
}
return signAndCache(user.getUsername(), user.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成用户 token 失败 dtUserId={}: {}", dtUserId, e.getMessage());
return generateAdminToken();
}
}
/** 生成 admin 系统 token用于钉钉用户未绑定 MES 账号时的兜底 */
public String generateAdminToken() {
try {
SysUser admin = sysUserService.getUserByName("admin");
if (admin == null || oConvertUtils.isEmpty(admin.getPassword())) {
log.warn("[DingWorkflow] admin 用户不存在,无法生成系统 token");
return null;
}
return signAndCache(admin.getUsername(), admin.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成 admin token 失败: {}", e.getMessage());
return null;
}
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】生成token后写入Redis通过Shiro校验-----
/**
* 生成 JWT Token 并写入 Rediskey=PREFIX_USER_TOKEN+tokenvalue=token
* <p>
* JeecgBoot Shiro 在 {@code jwtTokenRefresh()} 中验证 token 时,
* 必须从 Redis {@code "prefix_user_token:" + token} 取到缓存值,
* 否则报 "Token失效请重新登录"。新生成的 token 需要手动写入 Redis 才能通过校验。
* <p>
* TTL 设置为 JwtUtil.EXPIRE_TIME / 1000与正常登录保持一致。
*/
private String signAndCache(String username, String password) {
String token = JwtUtil.sign(username, password);
if (oConvertUtils.isNotEmpty(token)) {
// 写入 Redis让 Shiro jwtTokenRefresh 能查到
long ttlSeconds = JwtUtil.EXPIRE_TIME / 1000;
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token, ttlSeconds);
}
return token;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】生成token后写入Redis通过Shiro校验-----
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】按钉钉userId生成操作人JWT Token-----
}

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -66,6 +66,12 @@ public class SysThirdAppConfig {
@Schema(description = "是否启用(0-否,1-是)")
private Integer status;
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】Stream事件推送主配置标识-----
/**Stream事件推送主配置(0-否,1-是)*/
@Schema(description = "Stream事件推送主配置(0-否,1-是),同一 thirdType 中只有一条记录为1")
private Integer streamEnabled;
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】Stream事件推送主配置标识-----
/**创建日期*/
@Excel(name = "创建日期", width = 20, format = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")

View File

@@ -1196,6 +1196,70 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService {
return configMapper.getThirdConfigByThirdType(tenantId,MessageTypeEnum.DD.getType());
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】获取钉钉应用凭证Stream模式专用-----
/**
* 获取钉钉应用凭证 [clientId, clientSecret],供 Stream 长连接使用。
* <p>
* 优先级:
* ① stream_enabled=1 的记录(管理员在「钉钉集成」页面显式指定的 Stream 主配置)
* ② 兜底AppKey 长度 > 10 的第一条有效记录(迁移期或未配置时保持可用)
*
* @return [appKey, appSecret];未配置时返回 null
*/
public String[] getDingAppCredentials() {
java.util.List<SysThirdAppConfig> all = configMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysThirdAppConfig>()
.eq(SysThirdAppConfig::getThirdType, THIRD_TYPE)
.isNotNull(SysThirdAppConfig::getClientId)
.isNotNull(SysThirdAppConfig::getClientSecret)
// stream_enabled=1 的排最前面
.orderByDesc(SysThirdAppConfig::getStreamEnabled)
.orderByDesc(SysThirdAppConfig::getTenantId));
for (SysThirdAppConfig c : all) {
String appKey = c.getClientId();
String appSecret = c.getClientSecret();
if (oConvertUtils.isNotEmpty(appKey) && appKey.length() > 10
&& oConvertUtils.isNotEmpty(appSecret) && appSecret.length() > 10) {
return new String[]{appKey, appSecret};
}
}
return null;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】获取钉钉应用凭证Stream模式专用-----
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】后台线程专用AccessToken绕过租户检查-----
/**
* 后台线程专用:获取钉钉 AccessToken不依赖 TenantContext。
* <p>
* 原 {@link #getAccessToken()} 内部调 {@code tenantIzExist(0)}
* 后台线程无租户上下文时 tenantId 默认 0若 tenant=0 不存在直接抛异常。
* 本方法复用 {@link #getDingAppCredentials()} 的安全查询,绕过租户校验。
*
* @return AccessToken 字符串;未配置或失败时返回 null
*/
public String getAccessTokenForBackground() {
String[] creds = getDingAppCredentials();
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
log.warn("[DingBg] 未找到有效钉钉配置,无法获取 AccessToken");
return null;
}
try {
// JdtBaseAPI 已在文件顶部 import com.jeecg.dingtalk.api.base.JdtBaseAPI
// AccessToken 已在文件顶部 import com.jeecg.dingtalk.api.core.vo.AccessToken
AccessToken token = JdtBaseAPI.getAccessToken(creds[0], creds[1]);
if (token != null && oConvertUtils.isNotEmpty(token.getAccessToken())) {
return token.getAccessToken();
}
log.warn("[DingBg] getAccessToken 返回空");
return null;
} catch (Exception e) {
log.warn("[DingBg] 获取 AccessToken 失败: {}", e.getMessage());
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】后台线程专用AccessToken绕过租户检查-----
/**
* 获取钉钉accessToken
* @param config

View File

@@ -126,6 +126,8 @@ spring:
web-stat-filter:
enabled: true
dynamic:
# 非主数据源懒加载,避免花生壳/SQL Server 未就绪时拖慢或阻断启动
lazy: true
druid:
# 连接池的配置信息
# 初始化大小,最小,最大
@@ -165,9 +167,18 @@ spring:
#update-begin---author:geh ---date:2026-06-02 for【MES上辅机】新增 SQL Server 中间表数据源MES_ShareDB-----------
sqlserver_mcs:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://1lo04860wn636.vicp.fun:31601;DatabaseName=MES_ShareDB;encrypt=false;trustServerCertificate=true;SelectMethod=cursor;
# loginTimeout/connectTimeout花生壳映射不稳定时延长 TCP/登录等待(单位:秒 / 毫秒)
url: jdbc:sqlserver://1lo04860wn636.vicp.fun:31601;DatabaseName=MES_ShareDB;encrypt=false;trustServerCertificate=true;SelectMethod=cursor;loginTimeout=120;connectTimeout=120000;
username: sa
password: 123456
druid:
initial-size: 0
min-idle: 0
max-wait: 120000
connect-timeout: 120000
connection-error-retry-attempts: 10
time-between-connect-error-millis: 3000
break-after-acquire-failure: false
#update-end---author:geh ---date:2026-06-02 for【MES上辅机】新增 SQL Server 中间表数据源MES_ShareDB-----------
# # shardingjdbc数据源
# sharding-db:

View File

@@ -0,0 +1,5 @@
-- 20260604钉钉回调幂等去重台账新增 processed_op_count 字段
-- 用于 bpms_task_change 节点回调的 DB 乐观锁去重记录已处理的节点回调数
-- 默认 0每处理一个节点后 +1并发事件通过 WHERE processed_op_count < ? 条件竞争
ALTER TABLE mes_xsl_approval_record
ADD COLUMN processed_op_count INT NOT NULL DEFAULT 0 COMMENT '钉钉回调已处理节点数幂等去重';

View File

@@ -0,0 +1,74 @@
-- ============================================================
-- MESToDing审批配置 - 钉钉审批模板配置
-- author: GHT date: 2026-06-03
-- ============================================================
-- ========== 建表 DDL ==========
CREATE TABLE IF NOT EXISTS `mes_xsl_ding_process_tpl` (
`id` varchar(32) NOT NULL COMMENT '主键',
`tpl_name` varchar(100) NOT NULL COMMENT '模板名称',
`process_code` varchar(100) NOT NULL COMMENT '钉钉processCode',
`biz_type` varchar(50) DEFAULT NULL COMMENT '业务类型标识(供审批流关联使用)',
`form_fields` text DEFAULT NULL COMMENT '表单字段映射JSON(钉钉字段名MES字段名)',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '状态:0停用 1启用',
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序',
`remark` varchar(500) 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 '更新时间',
`del_flag` int NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`tenant_id` int NOT NULL DEFAULT 0 COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES-钉钉审批模板配置';
-- ========== 字典 mes_ding_tpl_status ==========
INSERT INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `update_by`, `update_time`, `type`, `tenant_id`)
VALUES (REPLACE(UUID(),'-',''), '钉钉审批模板状态', 'mes_ding_tpl_status', '钉钉审批模板启用/停用状态', 0, 'admin', NOW(), NULL, NULL, 0, 0);
INSERT INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`, `update_by`, `update_time`)
SELECT REPLACE(UUID(),'-',''), id, '启用', '1', NULL, 1, 1, 'admin', NOW(), NULL, NULL FROM `sys_dict` WHERE `dict_code` = 'mes_ding_tpl_status' LIMIT 1;
INSERT INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`, `update_by`, `update_time`)
SELECT REPLACE(UUID(),'-',''), id, '停用', '0', NULL, 2, 1, 'admin', NOW(), NULL, NULL FROM `sys_dict` WHERE `dict_code` = 'mes_ding_tpl_status' LIMIT 1;
-- ========== 菜单权限 ==========
-- 注意该页面对应的前台目录为 views/xslmes/dingtalk/mesXslDingProcessTpl 文件夹下
-- 父菜单MESToDing审批配置目录级is_leaf=0
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420801', NULL, 'MESToDing审批配置', '/mestoding', 'layouts/RouteView', NULL, NULL, 0, NULL, '1', 99.00, 0, 'ant-design:dingtalk-outlined', 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0);
-- 子菜单钉钉审批模板配置is_leaf=0有按钮子级
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420802', '178046026420801', '钉钉审批模板配置', '/xslmes/mesXslDingProcessTplList', 'xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList', NULL, NULL, 0, NULL, '1', 1.00, 0, NULL, 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0);
-- 按钮权限parent_id = 178046026420802is_leaf=1
INSERT 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 ('178046026420803', '178046026420802', '添加钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:add', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420804', '178046026420802', '编辑钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:edit', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420805', '178046026420802', '删除钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:delete', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420806', '178046026420802', '批量删除钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:deleteBatch', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420807', '178046026420802', '导出excel_钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:exportXls', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420808', '178046026420802', '导入excel_钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:importExcel', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
-- ========== admin 角色授权role_id = f6817f48af4fb3af11b9e8bf182f618b==========
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420801', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420802', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420803', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420804', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420805', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420806', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420807', NULL, '2026-06-03 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420808', NULL, '2026-06-03 00:00:00', '127.0.0.1');

View File

@@ -0,0 +1,6 @@
-- ============================================================
-- 钉钉审批模板配置 - 新增绑定MES审批流字段
-- author: GHT date: 2026-06-04
-- ============================================================
ALTER TABLE `mes_xsl_ding_process_tpl`
ADD COLUMN `flow_id` varchar(32) DEFAULT NULL COMMENT '绑定的MES审批流ID(用于发起审批时解析审批人)';

View File

@@ -0,0 +1,43 @@
-- ============================================================
-- MESToDing审批配置 - 审批模板绑定
-- author: GHT date: 2026-06-04
-- ============================================================
CREATE TABLE IF NOT EXISTS `mes_xsl_ding_tpl_bind` (
`id` varchar(32) NOT NULL COMMENT '主键',
`biz_code` varchar(64) NOT NULL COMMENT '业务编码(sys_permission.id)',
`biz_name` varchar(200) DEFAULT NULL COMMENT '业务名称(菜单名)',
`template_id` varchar(32) NOT NULL COMMENT '钉钉审批模板ID',
`template_name` varchar(200) DEFAULT NULL COMMENT '钉钉审批模板名称',
`field_mapping_json` longtext DEFAULT NULL COMMENT '字段绑定JSON:[{componentId,componentLabel,componentName,parentId,bizField}]',
`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 '更新时间',
`del_flag` int NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`tenant_id` int NOT NULL DEFAULT 0 COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ding_tpl_bind_biz_code` (`biz_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES-钉钉审批模板绑定';
-- ========== 菜单权限 ==========
-- 子菜单审批模板绑定挂在 MESToDing审批配置 父菜单 178046026420801
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420810', '178046026420801', '审批模板绑定', '/xslmes/dingTplBindList', 'xslmes/dingtalk/dingTplBind/index', NULL, NULL, 0, NULL, '1', 2.00, 0, 'ant-design:link-outlined', 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0);
-- 按钮权限
INSERT 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 ('178046026420811', '178046026420810', '查询审批模板绑定', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mesXslDingTplBind:list', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420812', '178046026420810', '保存审批模板绑定', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mesXslDingTplBind:save', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420813', '178046026420810', '删除审批模板绑定', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mesXslDingTplBind:delete', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
-- ========== admin 角色授权 ==========
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420810', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420811', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420812', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420813', NULL, '2026-06-04 00:00:00', '127.0.0.1');

View File

@@ -0,0 +1,56 @@
-- QH-MES审批台账 MES/钉钉 统一审批门禁台账
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `mes_xsl_approval_record` (
`id` varchar(32) NOT NULL COMMENT '主键',
`biz_table` varchar(100) NOT NULL COMMENT '业务单据表名',
`biz_table_name` varchar(200) DEFAULT NULL COMMENT '业务单据中文名',
`biz_code` varchar(64) DEFAULT NULL COMMENT '业务编码(菜单permission.id钉钉绑定用)',
`biz_data_id` varchar(64) NOT NULL COMMENT '业务单据记录ID',
`biz_title` varchar(300) DEFAULT NULL COMMENT '业务单据展示标题',
`channel` varchar(20) NOT NULL COMMENT '审批通道 MES/DINGTALK',
`external_instance_id` varchar(128) DEFAULT NULL COMMENT '外部实例ID(MES实例ID或钉钉instanceId)',
`flow_id` varchar(32) DEFAULT NULL COMMENT 'MES审批流ID',
`flow_name` varchar(100) DEFAULT NULL COMMENT 'MES审批流名称',
`template_id` varchar(32) DEFAULT NULL COMMENT '钉钉审批模板ID',
`template_name` varchar(200) DEFAULT NULL COMMENT '钉钉审批模板名称',
`launch_no` int DEFAULT '1' COMMENT '同一业务单据第几次发起',
`status` varchar(2) DEFAULT '0' COMMENT '状态 0流转中 1通过 2拒绝 3撤销 4发起失败',
`apply_user` varchar(50) DEFAULT NULL COMMENT '发起人username',
`apply_user_name` varchar(100) DEFAULT NULL COMMENT '发起人姓名',
`apply_time` datetime DEFAULT NULL COMMENT '发起时间',
`finish_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_rec_biz` (`tenant_id`, `biz_table`, `biz_data_id`, `apply_time`),
KEY `idx_appr_rec_ext` (`channel`, `external_instance_id`),
KEY `idx_appr_rec_status` (`tenant_id`, `biz_table`, `biz_data_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批台账(跨通道门禁)';
-- 审批通道字典
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000350', '审批通道', 'mes_xsl_approval_channel', '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
('1995000000000000351', '1995000000000000350', 'MES审批', 'MES', 'MES审批', 1, 1, 'admin', NOW()),
('1995000000000000352', '1995000000000000350', '钉钉审批', 'DINGTALK', '钉钉审批', 2, 1, 'admin', NOW());
-- 台账状态字典(在实例状态基础上增加发起失败)
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000353', '审批台账状态', 'mes_xsl_approval_record_status', '审批台账状态', 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
('1995000000000000354', '1995000000000000353', '流转中', '0', '流转中', 1, 1, 'admin', NOW()),
('1995000000000000355', '1995000000000000353', '审批通过', '1', '审批通过', 2, 1, 'admin', NOW()),
('1995000000000000356', '1995000000000000353', '审批拒绝', '2', '审批拒绝', 3, 1, 'admin', NOW()),
('1995000000000000357', '1995000000000000353', '已撤销', '3', '已撤销', 4, 1, 'admin', NOW()),
('1995000000000000358', '1995000000000000353', '发起失败', '4', '发起失败', 5, 1, 'admin', NOW());

View File

@@ -0,0 +1,18 @@
-- ============================================================
-- MESToDing审批配置 - 审批台账菜单
-- author: GHT date: 2026-06-04
-- ============================================================
-- 子菜单审批台账挂在 MESToDing审批配置 父菜单 178046026420801
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420820', '178046026420801', '审批台账', '/xslmes/mesXslApprovalRecordList', 'xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList', NULL, NULL, 0, NULL, '1', 3.00, 0, 'ant-design:audit-outlined', 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0);
INSERT 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 ('178046026420821', '178046026420820', '查询审批台账', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_approval_record:list', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT 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 ('178046026420822', '178046026420820', '导出审批台账', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_approval_record:exportXls', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420820', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420821', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420822', NULL, '2026-06-04 00:00:00', '127.0.0.1');

View File

@@ -0,0 +1,4 @@
-- GHT 20260604 钉钉Stream回调sys_third_app_config 新增 stream_enabled 字段
-- 用于在第三方应用配置页面指定哪条钉钉配置作为 Stream 事件推送的主连接
ALTER TABLE sys_third_app_config
ADD COLUMN stream_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Stream事件推送主配置(0-,1-)';

View File

@@ -0,0 +1,5 @@
-- GHT 20260604 钉钉Stream回调mes_xsl_approval_record 新增 origin_status/status_field 字段
-- mes_xsl_approval_instance 对齐用于驳回时共用 isBizAtOriginStatus 逻辑
ALTER TABLE mes_xsl_approval_record
ADD COLUMN status_field VARCHAR(64) NULL COMMENT '业务状态字段名发起时快照',
ADD COLUMN origin_status VARCHAR(64) NULL COMMENT '发起审批时业务状态原值驳回回写依据';

View File

@@ -0,0 +1,4 @@
-- process_code 允许草稿阶段为空创建钉钉模板前默认空串
-- author: GHT date: 2026-06-04
ALTER TABLE `mes_xsl_ding_process_tpl`
MODIFY COLUMN `process_code` varchar(100) NOT NULL DEFAULT '' COMMENT '钉钉processCode未推送钉钉前为空';

View File

@@ -0,0 +1,807 @@
<template>
<a-modal
v-model:open="visible"
:title="`发起钉钉审批 · ${tplData?.templateName || ''}`"
:width="multiRow ? 1120 : 940"
:confirm-loading="submitting"
ok-text="发起审批"
cancel-text="取消"
destroy-on-close
:body-style="{ padding: 0 }"
@ok="handleSubmit"
@cancel="visible = false"
>
<div class="dal-body">
<!-- 左侧审批流时间轴 -->
<div class="dal-timeline-panel">
<div class="dal-panel-title">审批流程</div>
<div v-if="!selectedFlowId" class="dal-timeline-empty">
<div class="dal-timeline-empty-icon">🔗</div>
<div>请在右侧审批流配置<br>页签中选择审批流</div>
</div>
<a-spin v-else-if="previewLoading" style="display:flex;justify-content:center;padding:32px 0" />
<div v-else class="dal-timeline">
<div class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--start"></div>
<div class="dal-ts-line" v-if="approverPreview.length > 0"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name">发起人</div>
<div class="dal-ts-sub">所有人可发起</div>
</div>
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId || ni" class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot"
:class="[node.nodeType==='cc'?'dal-ts-dot--cc':'dal-ts-dot--approver', !node.allResolved?'dal-ts-dot--warn':'']">
</div>
<div class="dal-ts-line" v-if="ni < approverPreview.length - 1"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-tags">
<span class="dal-ts-badge" :class="node.nodeType==='cc'?'dal-ts-badge--cc':'dal-ts-badge--approver'">
{{ node.nodeType==='cc'?'抄送':'审批' }}
</span>
<span v-if="node.nodeType!=='cc'" class="dal-ts-mode">{{ modeLabel(node.multiMode) }}</span>
</div>
<div class="dal-ts-name">{{ node.nodeName }}</div>
<div class="dal-ts-users">
<template v-for="(u, ui) in node.users" :key="u.username">
<span :class="u.resolved?'dal-ts-user--ok':'dal-ts-user--err'">{{ u.realname }}</span>
<span v-if="ui < node.users.length-1" style="color:#ccc;margin:0 2px">·</span>
</template>
</div>
<div v-if="!node.allResolved" class="dal-ts-unresolved"> 有未解析成员请补充手机号</div>
</div>
</div>
<div class="dal-ts-step" v-if="approverPreview.length > 0">
<div class="dal-ts-left"><div class="dal-ts-dot dal-ts-dot--end"></div></div>
<div class="dal-ts-content"><div class="dal-ts-name" style="color:#888">结束</div></div>
</div>
</div>
</div>
<div class="dal-panel-divider"></div>
<!-- 中间主内容表单字段 + 审批流配置 -->
<div class="dal-content-panel">
<a-tabs v-model:activeKey="activeTab" size="small" class="dal-tabs">
<!-- 表单展示只读 -->
<a-tab-pane key="form" tab="表单字段">
<div class="dal-form-scroll">
<a-spin :spinning="loading" tip="加载表单字段中...">
<a-alert v-if="loadError" type="error" :message="loadError" show-icon style="margin-bottom:12px" />
<template v-else-if="!loading">
<a-alert
type="info"
show-icon
style="margin-bottom:12px"
message="字段已根据绑定配置从业务单据自动填充,不可手动修改。如需调整请先更新审批模板绑定中的字段映射。"
/>
<div v-if="dingFields.length === 0" class="dal-form-empty">该模板暂无表单字段</div>
<template v-for="field in dingFields" :key="field.label">
<div v-if="field.componentName === 'TextNote'" class="dal-form-note">{{ field.label }}</div>
<!-- 明细表只读展示 -->
<template v-else-if="field.componentName === 'TableField'">
<div class="dal-form-item">
<div class="dal-field-label" :class="{'dal-field-label--required': field.required}">{{ field.label }}</div>
<div class="dal-table-wrap">
<table class="dal-table">
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th v-for="child in field.children||[]" :key="child.label">{{ child.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIdx) in getTableRows(field.label)" :key="rowIdx">
<td style="text-align:center;color:#aaa">{{ rowIdx+1 }}</td>
<td v-for="child in field.children||[]" :key="child.label">
<span class="dal-readonly-cell">{{ row[child.label] ?? '—' }}</span>
</td>
</tr>
<tr v-if="getTableRows(field.label).length === 0">
<td :colspan="(field.children?.length||0)+1" style="text-align:center;color:#bbb;padding:10px 0">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- 普通字段只读 -->
<div v-else class="dal-form-item">
<div class="dal-field-label" :class="{'dal-field-label--required': field.required}">{{ field.label }}</div>
<a-input
:value="displayValue(field)"
disabled
class="dal-readonly-input"
/>
</div>
</template>
</template>
</a-spin>
</div>
</a-tab-pane>
<!-- 审批流配置只读查看 -->
<a-tab-pane key="flow">
<template #tab>
审批流配置
<a-badge v-if="hasUnresolved" color="red" style="margin-left:4px" />
</template>
<div class="dal-form-scroll">
<a-alert
type="info"
show-icon
style="margin-bottom:14px"
>
<template #message>
审批流程仅供查看不可在此修改如需调整请前往
<a class="flow-readonly-link" @click="goToTplConfig">钉钉审批模板配置</a>
中更改绑定的审批流
</template>
</a-alert>
<div class="flow-select-row">
<a-select
v-model:value="selectedFlowId"
style="flex:1;min-width:0"
placeholder="(未绑定审批流)"
:loading="flowLoading"
:options="flowSelectOptions"
disabled
>
<template #option="{ label, status, remark }">
<div class="flow-opt-item">
<span class="flow-opt-name">{{ label }}</span>
<span style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<span v-if="remark" class="flow-opt-remark">{{ remark }}</span>
<a-tag :color="status==='1'?'green':status==='2'?'default':'orange'"
style="margin:0;font-size:11px;line-height:16px;padding:0 5px">
{{ status==='1'?'已发布':status==='2'?'已停用':'草稿' }}
</a-tag>
</span>
</div>
</template>
</a-select>
</div>
<template v-if="selectedFlowId">
<a-divider style="margin:14px 0 10px" />
<div class="preview-title">
审批节点 · 人员解析
<a-spin :spinning="previewLoading" size="small" style="margin-left:8px" />
</div>
<div v-if="!previewLoading && approverPreview.length === 0" style="color:#bbb;font-size:12px;margin-top:6px">
该审批流暂无审批人节点
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId||ni" class="preview-node"
:class="{'preview-node--cc': node.nodeType==='cc'}">
<div class="preview-node-hd">
<a-tag :color="node.nodeType==='cc'?'blue':'orange'" style="margin:0 6px 0 0;font-size:11px">
{{ node.nodeType==='cc'?'抄送':'审批' }}
</a-tag>
<span class="preview-node-name">{{ node.nodeName }}</span>
<span class="preview-node-mode">{{ node.nodeType==='cc'?'位置自动判断':modeLabel(node.multiMode) }}</span>
</div>
<div v-for="u in node.users" :key="u.username" class="preview-user">
<a-tag v-if="u.resolved" color="success" style="margin:0">{{ u.realname }}{{ u.username }}</a-tag>
<a-tag v-else-if="u.unsupported" color="default" style="margin:0">{{ u.realname }}不支持自动解析</a-tag>
<a-tag v-else color="error" style="margin:0">{{ u.realname }}{{ u.username }}未找到钉钉账号</a-tag>
</div>
<div v-if="!node.allResolved" class="preview-supplement">
<a-input
v-model:value="supplementPhones[node.nodeId||String(ni)]"
:placeholder="node.nodeType==='cc'?'补充抄送人手机号,多个用逗号分隔':'补充审批人手机号,多个用逗号分隔'"
allow-clear size="small"
/>
<div class="dal-field-hint" style="margin-top:3px">手机号需在企业钉钉注册与自动解析的成员合并</div>
</div>
</div>
<a-alert v-if="hasUnresolved" type="warning" show-icon style="margin-top:10px;font-size:12px"
message="部分节点有未解析成员,请补充手机号后再发起审批" />
</template>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 右侧已选数据列表多条时显示 -->
<template v-if="multiRow">
<div class="dal-panel-divider"></div>
<div class="dal-rows-panel">
<div class="dal-panel-title">
已选数据
<span class="dal-rows-badge">{{ allRows.length }}</span>
</div>
<div class="dal-rows-list">
<div
v-for="(row, idx) in allRows"
:key="idx"
class="dal-row-item"
:class="{ 'dal-row-item--active': idx === currentRowIndex }"
@click="switchRow(idx)"
>
<div class="dal-row-index">{{ idx + 1 }}</div>
<div class="dal-row-label">{{ getRowLabel(row, idx) }}</div>
</div>
</div>
</div>
</template>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import {
getTemplateDetail,
queryById as queryTplById,
launchApproval,
getApprovalFlowList,
previewFlowApprovers,
} from '/@/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api';
import { checkCanLaunch } from '/@/views/approval/gate/approvalGate.api';
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const router = useRouter();
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const loadError = ref('');
const activeTab = ref('form');
interface BindInfo { id: string; templateId: string; templateName: string; fieldMappingJson?: string; bizCode?: string }
const tplData = ref<BindInfo | null>(null);
// 多条数据支持
const allRows = ref<any[]>([]);
const currentRowIndex = ref(0);
const multiRow = computed(() => allRows.value.length > 0);
const dingFields = ref<any[]>([]);
const formValues = reactive<Record<string, any>>({});
const tableValues = reactive<Record<string, Record<string, string>[]>>({});
const flowLoading = ref(false);
const flowList = ref<any[]>([]);
const selectedFlowId = ref('');
const previewLoading = ref(false);
const approverPreview = ref<any[]>([]);
const supplementPhones = reactive<Record<string, string>>({});
const hasUnresolved = computed(() => approverPreview.value.some(n => !n.allResolved));
const flowSelectOptions = computed(() =>
flowList.value.map(f => ({
value: f.id, label: f.flowName || f.name,
status: f.status, remark: f.remark || '',
}))
);
function modeLabel(mode: string) {
if (mode === 'none') return '单人';
if (mode === 'or') return '或签';
if (mode === 'sequence') return '依次';
return '会签';
}
// ══ 打开弹窗,支持单条或多条 ══
async function open(bindInfo: BindInfo, rowsOrRow: any[] | any) {
tplData.value = bindInfo;
allRows.value = Array.isArray(rowsOrRow) ? rowsOrRow : [rowsOrRow];
currentRowIndex.value = 0;
resetFieldValues();
loadError.value = '';
activeTab.value = 'form';
selectedFlowId.value = '';
approverPreview.value = [];
visible.value = true;
loading.value = true;
try {
const [detail, flows, tplRecord] = await Promise.all([
getTemplateDetail(bindInfo.templateId),
getApprovalFlowList({ pageSize: 200 }),
queryTplById(bindInfo.templateId),
]);
dingFields.value = detail?.dingFields || [];
if (detail?.schemaError) loadError.value = detail.schemaError;
flowList.value = flows?.records || flows || [];
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [];
}
applyPrefillForRow(currentRowIndex.value);
const presetFlowId = tplRecord?.flowId;
if (presetFlowId) {
selectedFlowId.value = presetFlowId;
loadPreview(presetFlowId);
}
} catch (e: any) {
loadError.value = e?.message || '加载模板字段失败';
} finally {
loading.value = false;
}
}
function goToTplConfig() {
visible.value = false;
router.push('/xslmes/mesXslDingProcessTplList');
}
defineExpose({ open });
// ══ 切换数据行 ══
function switchRow(idx: number) {
if (idx === currentRowIndex.value) return;
currentRowIndex.value = idx;
resetFieldValues();
applyPrefillForRow(idx);
activeTab.value = 'form';
}
function resetFieldValues() {
Object.keys(formValues).forEach(k => delete formValues[k]);
Object.keys(tableValues).forEach(k => delete tableValues[k]);
Object.keys(supplementPhones).forEach(k => delete supplementPhones[k]);
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [];
}
}
function applyPrefillForRow(idx: number) {
const rowData = allRows.value[idx];
if (rowData && tplData.value?.fieldMappingJson) {
applyPrefill(dingFields.value, tplData.value.fieldMappingJson, rowData);
}
}
// ══ 行标签(用于右侧列表显示) ══
const LABEL_SKIP = new Set([
'id', 'createBy', 'createTime', 'updateBy', 'updateTime',
'delFlag', 'sysOrgCode', 'tenantId', 'version',
]);
function getRowLabel(row: any, index: number): string {
for (const [key, val] of Object.entries(row ?? {})) {
if (LABEL_SKIP.has(key)) continue;
if (val && typeof val === 'string' && val.length <= 40) return val;
if (val !== null && val !== undefined && typeof val === 'number') return String(val);
}
return `条目 ${index + 1}`;
}
// ══ 预填充 ══
interface MappingItem {
componentId: string;
componentLabel: string;
componentName: string;
parentId?: string;
bizField?: string;
}
function applyPrefill(fields: any[], mappingJson: string, rowData: any) {
let mapping: MappingItem[] = [];
try { mapping = JSON.parse(mappingJson); } catch { return; }
const byId = new Map(mapping.map(m => [m.componentId, m]));
for (const field of fields) {
if (field.componentName === 'TextNote') continue;
const cid = field.id || field.label;
const m = byId.get(cid);
if (field.componentName === 'TableField') {
const slotName = m?.bizField;
if (!slotName) continue;
const arr: any[] = getNestedValue(rowData, slotName);
if (!Array.isArray(arr) || !arr.length) continue;
const childMappings = mapping.filter(x => x.parentId === cid && x.bizField);
tableValues[field.label] = arr.map(element => {
const row: Record<string, string> = {};
for (const child of childMappings) {
const parts = (child.bizField || '').split('.');
const colKey = parts.slice(1).join('.');
const val = colKey ? getNestedValue(element, colKey) : undefined;
row[child.componentLabel] = val !== undefined && val !== null ? String(val) : '';
}
for (const child of (field.children || [])) {
if (!(child.label in row)) row[child.label] = '';
}
return row;
});
} else {
if (!m?.bizField) continue;
const val = getNestedValue(rowData, m.bizField);
if (val === undefined || val === null) continue;
formValues[field.label] = formatForDisplay(val, field.componentName);
}
}
}
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined;
return path.split('.').reduce((acc: any, k: string) => acc?.[k], obj);
}
function formatForDisplay(v: any, componentName: string): any {
if (v === null || v === undefined) return '';
if (['NumberField', 'MoneyField'].includes(componentName)) {
return typeof v === 'number' ? v : Number(v) || 0;
}
if (componentName === 'DDDateField') {
const s = String(v);
return s.includes('T') ? s.split('T')[0] : s.split(' ')[0];
}
if (Array.isArray(v)) return v.join(',');
return String(v);
}
function getTableRows(label: string): Record<string, string>[] {
return tableValues[label] || [];
}
function displayValue(field: any): string {
const v = formValues[field.label];
if (v === undefined || v === null || v === '') return '';
if (field.componentName === 'DDDateRangeField' && Array.isArray(v)) return v.join(' ~ ');
if (field.componentName === 'DDMultiSelectField' && Array.isArray(v)) return v.join(', ');
return String(v);
}
// ══ 审批流预览 ══
async function loadPreview(flowId: string) {
previewLoading.value = true;
try {
const res = await previewFlowApprovers(flowId);
approverPreview.value = Array.isArray(res) ? res : [];
} catch { approverPreview.value = []; }
finally { previewLoading.value = false; }
}
// ══ 提交 ══
async function handleSubmit() {
if (!selectedFlowId.value) {
activeTab.value = 'flow';
createMessage.warning('请在「审批流配置」页签中选择一个审批流');
return Promise.reject();
}
const unresolvedNodes = approverPreview.value.filter(n => !n.allResolved);
for (const node of unresolvedNodes) {
const key = node.nodeId || String(approverPreview.value.indexOf(node));
if (!supplementPhones[key]?.trim()) {
activeTab.value = 'flow';
createMessage.warning(`${node.nodeType==='cc'?'抄送节点':'审批节点'}${node.nodeName}」有未解析成员,请补充手机号`);
return Promise.reject();
}
}
const fvList: { name: string; value: string }[] = [];
for (const field of dingFields.value) {
if (field.componentName === 'TextNote') continue;
const label = field.label;
if (field.componentName === 'TableField') {
const validRows = getTableRows(label).filter(r => Object.values(r).some(v => v !== ''));
if (validRows.length === 0) continue;
fvList.push({
name: label,
value: JSON.stringify(validRows.map(row =>
Object.entries(row).map(([k, v]) => ({ name: k, value: String(v ?? '') }))
)),
});
continue;
}
let val = formValues[label];
if (field.componentName === 'DDDateRangeField' && Array.isArray(val)) {
val = val.join('~');
} else if (field.componentName === 'DDMultiSelectField' && Array.isArray(val)) {
val = val.length > 0 ? JSON.stringify(val) : null;
} else if (['InnerContactField', 'RelateField', 'DDPhotoField'].includes(field.componentName)) {
const raw = val ? String(val).trim() : '';
val = raw ? JSON.stringify(raw.split(',').map((s: string) => s.trim()).filter(Boolean)) : null;
} else {
val = val !== undefined && val !== null ? String(val) : null;
}
if (val === null || val === '') { if (!field.required) continue; val = ''; }
fvList.push({ name: label, value: val as string });
}
const approverOverrides = Object.entries(supplementPhones)
.filter(([, phones]) => phones?.trim())
.map(([nodeId, phones]) => ({ nodeId, phones: phones.trim() }));
const row = allRows.value[currentRowIndex.value];
const flow = flowList.value.find((f) => f.id === selectedFlowId.value);
const bizTable = flow?.bizTable;
const bizDataId = row?.id != null ? String(row.id) : '';
const bizTitle = getRowLabel(row, currentRowIndex.value);
//update-begin---author:GHT ---date:2026-06-04 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
if (bizTable && bizDataId) {
const gate = await checkCanLaunch({ bizTable, bizDataId });
if (!gate?.allowed) {
createMessage.warning(gate?.reason || '当前不允许发起审批');
return Promise.reject();
}
}
//update-end---author:GHT ---date:2026-06-04 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
submitting.value = true;
try {
const result = await launchApproval({
id: tplData.value!.templateId,
formValues: fvList,
flowId: selectedFlowId.value,
approverOverrides,
bizTable,
bizDataId,
bizTitle,
bizCode: tplData.value?.bizCode,
});
createMessage.success(typeof result === 'string' ? result : '审批发起成功!审批人将在钉钉「待我审批」中收到任务');
visible.value = false;
emit('success', result);
} catch (e: any) {
createMessage.error(e?.message || '发起失败');
return Promise.reject(e);
} finally {
submitting.value = false;
}
}
</script>
<style lang="less" scoped>
.dal-body {
display: flex;
min-height: 480px;
max-height: 70vh;
}
.dal-timeline-panel {
width: 210px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
padding: 16px 14px;
overflow-y: auto;
}
.dal-panel-title {
font-size: 12px;
font-weight: 600;
color: #888;
letter-spacing: .5px;
text-transform: uppercase;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.dal-timeline-empty {
text-align: center; color: #bbb; font-size: 12px; padding-top: 32px; line-height: 1.8;
.dal-timeline-empty-icon { font-size: 24px; margin-bottom: 8px; }
}
.dal-ts-step { display: flex; align-items: flex-start; gap: 8px; }
.dal-ts-left { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 12px; padding-top: 2px; }
.dal-ts-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
position: relative; z-index: 1; border: 2px solid currentColor; background: #fff;
&--start { color: #1677ff; background: #1677ff; border-color: #1677ff; }
&--end { color: #d9d9d9; background: #d9d9d9; border-color: #d9d9d9; width: 8px; height: 8px; }
&--approver { color: #fa8c16; }
&--cc { color: #1677ff; }
&--warn { color: #ff4d4f !important; }
}
.dal-ts-line { width: 2px; flex: 1; min-height: 22px; background: linear-gradient(to bottom, #e0e0e0 50%, transparent 100%); margin: 3px 0 0; }
.dal-ts-content { flex: 1; padding-bottom: 18px; min-width: 0; }
.dal-ts-sub { font-size: 11px; color: #aaa; }
.dal-ts-tags { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; }
.dal-ts-badge {
font-size: 10px; padding: 0 5px; border-radius: 3px; line-height: 16px; font-weight: 500;
&--approver { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
&--cc { background: #e6f4ff; color: #0958d9; border: 1px solid #91caff; }
}
.dal-ts-mode { font-size: 10px; color: #aaa; background: #f5f5f5; padding: 0 4px; border-radius: 2px; line-height: 14px; }
.dal-ts-name { font-size: 12px; font-weight: 500; color: #333; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dal-ts-users { font-size: 11px; color: #888; margin-top: 2px; line-height: 1.5; }
.dal-ts-user--ok { color: #52c41a; }
.dal-ts-user--err { color: #ff4d4f; }
.dal-ts-unresolved { font-size: 10px; color: #ff7a00; margin-top: 2px; }
.dal-panel-divider { width: 1px; background: #f0f0f0; flex-shrink: 0; }
.dal-content-panel {
flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden;
}
.dal-tabs {
height: 100%; display: flex; flex-direction: column;
:deep(.ant-tabs-nav) { padding: 0 16px; margin-bottom: 0; }
:deep(.ant-tabs-content-holder) { flex: 1; overflow: hidden; }
:deep(.ant-tabs-content) { height: 100%; }
:deep(.ant-tabs-tabpane) { height: 100%; }
}
.dal-form-scroll { height: 100%; overflow-y: auto; padding: 14px 18px; }
.dal-form-item { margin-bottom: 14px; }
.dal-form-empty { color: #bbb; text-align: center; padding: 32px 0; font-size: 13px; }
.dal-field-label {
font-size: 13px; color: #555; margin-bottom: 5px; font-weight: 500;
&--required::before { content: '* '; color: #ff4d4f; }
}
.dal-field-hint { font-size: 11px; color: #aaa; margin-top: 3px; }
.dal-form-note {
background: #f8f8f8; border-left: 3px solid #ddd; padding: 6px 10px;
font-size: 12px; color: #777; margin-bottom: 12px; border-radius: 0 4px 4px 0;
}
.dal-readonly-input {
:deep(.ant-input[disabled]) {
color: rgba(0,0,0,.75) !important;
background: #f8f8f8 !important;
cursor: default;
}
}
.dal-readonly-cell {
display: block;
padding: 2px 6px;
color: rgba(0,0,0,.75);
font-size: 13px;
line-height: 1.5;
}
.dal-table-wrap { border: 1px solid #ebebeb; border-radius: 6px; overflow: hidden; }
.dal-table {
width: 100%; border-collapse: collapse; font-size: 13px;
th { background: #fafafa; padding: 7px 10px; border-bottom: 1px solid #ebebeb; font-weight: 500; color: #666; font-size: 12px; }
td { padding: 4px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
}
// 审批流配置
.flow-select-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.flow-readonly-link {
color: inherit;
text-decoration: none;
border-bottom: 1px dashed currentColor;
cursor: pointer;
transition: color .15s;
&:hover { color: #1677ff; }
}
.flow-opt-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.flow-opt-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.flow-opt-remark { font-size: 11px; color: #aaa; }
.preview-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 8px; display: flex; align-items: center; }
.preview-node {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px;
&--cc { background: #f0f7ff; border-color: #bae0ff; }
}
.preview-node-hd { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.preview-node-name { font-size: 12px; font-weight: 500; flex: 1; }
.preview-node-mode { font-size: 11px; color: #aaa; }
.preview-user { margin-bottom: 4px; }
.preview-supplement { margin-top: 8px; }
// ══ 右侧:已选数据列表 ══
.dal-rows-panel {
width: 170px;
flex-shrink: 0;
background: #fafafa;
padding: 16px 10px;
overflow-y: auto;
}
.dal-rows-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #ff6900;
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
min-width: 16px;
height: 16px;
padding: 0 4px;
line-height: 1;
}
.dal-rows-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.dal-row-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: all .15s;
background: #fff;
border-color: #f0f0f0;
&:hover {
border-color: #ff6900;
background: #fff8f3;
}
&--active {
border-color: #ff6900 !important;
background: #fff3eb !important;
.dal-row-index {
background: #ff6900;
color: #fff;
}
}
}
.dal-row-index {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
background: #e8e8e8;
color: #666;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
transition: all .15s;
margin-top: 1px;
}
.dal-row-label {
flex: 1;
min-width: 0;
font-size: 12px;
color: #333;
line-height: 1.5;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,257 @@
<!--
全局发起钉钉审批按钮
路由变化时检查当前页面是否配置了钉钉审批模板绑定有则显示
默认停靠在列表页查询条件区域最右侧可拖拽移动位置按路由 localStorage 记忆
@author GHT
@date 2026-06-04 forMESToDing审批配置审批模板绑定-发起钉钉审批
-->
<template>
<Teleport to="body">
<div
v-if="binding"
ref="floatRef"
class="dtl-float"
:class="{ 'dtl-float--dragging': isDragging }"
:style="floatStyle"
>
<div
class="dtl-float-btn"
:class="{ 'dtl-float-btn--active': hasRows }"
:title="btnTitle"
@pointerdown="onBtnPointerDown"
@click="onBtnClick"
>
<Icon icon="ant-design:dingtalk-outlined" :size="18" class="dtl-float-icon" />
<span class="dtl-float-text">钉钉审批</span>
<span v-if="hasRows" class="dtl-float-badge">{{ selectedRows.length }}</span>
</div>
</div>
</Teleport>
<DingBindLaunchModal ref="modalRef" />
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { useApprovalSelection } from '/@/components/ApprovalLaunch/useApprovalSelection';
import { getBindingByRoute } from '/@/views/xslmes/dingtalk/dingTplBind/dingTplBind.api';
import DingBindLaunchModal from './DingBindLaunchModal.vue';
import { useDraggablePosition } from './useDraggablePosition';
defineOptions({ name: 'DingTplLaunchFloat' });
const { createMessage } = useMessage();
const router = useRouter();
const { getRowsByPath } = useApprovalSelection();
const binding = ref<any>(null);
const modalRef = ref<InstanceType<typeof DingBindLaunchModal> | null>(null);
const floatRef = ref<HTMLElement | null>(null);
const selectedRows = computed(() => getRowsByPath(router.currentRoute.value.path));
const hasRows = computed(() => selectedRows.value.length > 0);
const btnTitle = computed(() =>
hasRows.value
? `已选 ${selectedRows.value.length} 条,点击发起钉钉审批(可拖拽移动)`
: '请先在列表中勾选数据(可拖拽移动)',
);
const { pos, isDragging, initPosition, applyDefaultPosition, onPointerDown, wasDragged, clampPosition } =
useDraggablePosition(computed(() => router.currentRoute.value.path));
const floatStyle = computed(() => ({
left: `${pos.left}px`,
top: `${pos.top}px`,
}));
let resizeObserver: ResizeObserver | null = null;
let layoutTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleLayout(preferDefault = false) {
if (layoutTimer) clearTimeout(layoutTimer);
layoutTimer = setTimeout(() => {
layoutTimer = null;
const path = router.currentRoute.value.path;
if (!binding.value || !path) return;
initPosition(path, preferDefault);
nextTick(() => {
if (floatRef.value) {
const rect = floatRef.value.getBoundingClientRect();
clampPosition();
}
});
}, 80);
}
function bindFormResize() {
unbindFormResize();
const formEl = document.querySelector('.jeecg-basic-table-form-container');
if (!formEl || typeof ResizeObserver === 'undefined') return;
resizeObserver = new ResizeObserver(() => {
const path = router.currentRoute.value.path;
const saved = localStorage.getItem('mes_ding_tpl_launch_pos');
const hasSaved = saved && JSON.parse(saved || '{}')[path];
if (!hasSaved) applyDefaultPosition();
});
resizeObserver.observe(formEl);
}
function unbindFormResize() {
resizeObserver?.disconnect();
resizeObserver = null;
}
watch(
() => router.currentRoute.value.path,
async (path) => {
binding.value = null;
unbindFormResize();
if (!path || path === '/' || path.startsWith('/login')) return;
try {
const result = await getBindingByRoute(path);
binding.value = result || null;
if (binding.value) {
await nextTick();
scheduleLayout(false);
bindFormResize();
}
} catch {
binding.value = null;
}
},
{ immediate: true },
);
onMounted(() => {
window.addEventListener('resize', onWindowResize);
});
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize);
unbindFormResize();
if (layoutTimer) clearTimeout(layoutTimer);
});
function onWindowResize() {
if (!binding.value) return;
clampPosition();
const path = router.currentRoute.value.path;
try {
const all = JSON.parse(localStorage.getItem('mes_ding_tpl_launch_pos') || '{}');
if (path && all[path]) {
all[path] = { left: pos.left, top: pos.top };
localStorage.setItem('mes_ding_tpl_launch_pos', JSON.stringify(all));
}
} catch {
/* ignore */
}
}
function onBtnPointerDown(e: PointerEvent) {
onPointerDown(e, floatRef.value);
}
function onBtnClick() {
if (wasDragged()) return;
handleClick();
}
function handleClick() {
if (!binding.value) return;
const rows = selectedRows.value;
if (!rows.length) {
createMessage.warning('请先在列表中勾选要发起审批的数据');
return;
}
modalRef.value?.open(
{
id: binding.value.id,
templateId: binding.value.templateId,
templateName: binding.value.templateName || '',
fieldMappingJson: binding.value.fieldMappingJson,
bizCode: binding.value.bizCode,
},
rows,
);
}
</script>
<style scoped>
.dtl-float {
position: fixed;
z-index: 1001;
user-select: none;
touch-action: none;
}
.dtl-float--dragging {
z-index: 1002;
}
.dtl-float-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(255, 105, 0, 0.12);
border: 1.5px solid #ff6900;
border-radius: 24px;
color: #cc5500;
font-size: 13px;
font-weight: 600;
cursor: grab;
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s,
opacity 0.2s;
box-shadow: 0 2px 8px rgba(255, 105, 0, 0.15);
opacity: 0.85;
}
.dtl-float--dragging .dtl-float-btn {
cursor: grabbing;
opacity: 1;
box-shadow: 0 6px 18px rgba(255, 105, 0, 0.35);
}
.dtl-float-btn:hover,
.dtl-float-btn--active {
opacity: 1;
background: #ff6900;
color: #fff;
box-shadow: 0 4px 14px rgba(255, 105, 0, 0.4);
}
.dtl-float-btn:hover .dtl-float-icon,
.dtl-float-btn--active .dtl-float-icon {
color: #fff;
}
.dtl-float-icon {
color: #ff6900;
flex-shrink: 0;
}
.dtl-float-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
color: #ff6900;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
min-width: 18px;
height: 18px;
padding: 0 4px;
}
.dtl-float-btn--active .dtl-float-badge {
color: #ff6900;
}
</style>

View File

@@ -0,0 +1,157 @@
import { reactive, ref, type Ref } from 'vue';
const STORAGE_KEY = 'mes_ding_tpl_launch_pos';
export interface FloatPosition {
left: number;
top: number;
}
function readAllPositions(): Record<string, FloatPosition> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {};
} catch {
return {};
}
}
/** 查询条件区域BasicTable 搜索表单)右侧的默认坐标 */
export function calcDefaultPosition(btnWidth: number, btnHeight: number): FloatPosition {
const formEl =
document.querySelector<HTMLElement>('.jeecg-basic-table-form-container .ant-form') ||
document.querySelector<HTMLElement>('.jeecg-basic-table-form-container');
if (!formEl) {
return {
left: Math.max(8, window.innerWidth - btnWidth - 24),
top: 100,
};
}
const rect = formEl.getBoundingClientRect();
return {
left: Math.max(8, rect.right - btnWidth - 12),
top: Math.max(8, rect.top + (rect.height - btnHeight) / 2),
};
}
/**
* 可拖拽悬浮按钮位置:默认对齐查询区右侧,拖拽后按路由持久化。
*/
export function useDraggablePosition(routePath: Ref<string>) {
const pos = reactive<FloatPosition>({ left: 0, top: 0 });
const isDragging = ref(false);
let moved = false;
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let btnWidth = 120;
let btnHeight = 36;
function setButtonSize(width: number, height: number) {
btnWidth = width;
btnHeight = height;
}
function loadPosition(path: string): boolean {
const saved = readAllPositions()[path];
if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') {
pos.left = saved.left;
pos.top = saved.top;
return true;
}
return false;
}
function savePosition(path: string) {
const all = readAllPositions();
all[path] = { left: pos.left, top: pos.top };
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
function applyDefaultPosition() {
const p = calcDefaultPosition(btnWidth, btnHeight);
pos.left = p.left;
pos.top = p.top;
}
function initPosition(path: string, preferDefault = false) {
if (!path) return;
if (!preferDefault && loadPosition(path)) return;
applyDefaultPosition();
}
function clampPosition() {
const maxLeft = window.innerWidth - btnWidth - 8;
const maxTop = window.innerHeight - btnHeight - 8;
pos.left = Math.min(Math.max(8, pos.left), maxLeft);
pos.top = Math.min(Math.max(8, pos.top), maxTop);
}
function onPointerMove(e: PointerEvent) {
if (pointerId !== e.pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
moved = true;
isDragging.value = true;
}
if (!moved) return;
pos.left = startLeft + dx;
pos.top = startTop + dy;
clampPosition();
}
function endDrag(path: string) {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
if (moved && path) {
savePosition(path);
}
pointerId = null;
setTimeout(() => {
isDragging.value = false;
moved = false;
}, 0);
}
function onPointerUp(e: PointerEvent) {
if (pointerId !== e.pointerId) return;
endDrag(routePath.value);
}
function onPointerDown(e: PointerEvent, el: HTMLElement | null) {
if (e.button !== 0) return;
if (el) {
const rect = el.getBoundingClientRect();
setButtonSize(rect.width, rect.height);
}
moved = false;
isDragging.value = false;
pointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
startLeft = pos.left;
startTop = pos.top;
(e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
}
function wasDragged() {
return moved;
}
return {
pos,
isDragging,
setButtonSize,
initPosition,
applyDefaultPosition,
onPointerDown,
wasDragged,
clampPosition,
};
}

View File

@@ -13,6 +13,9 @@
<!-- 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-06-04 forMESToDing审批配置全局钉钉审批模板绑定发起按钮----- -->
<DingTplLaunchFloat />
<!-- update-end---author:GHT ---date:2026-06-04 forMESToDing审批配置全局钉钉审批模板绑定发起按钮----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
<ApprovalDesignFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
@@ -48,6 +51,9 @@
//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审批流设计】全局审批流程设计悬浮按钮-----
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】全局钉钉审批模板绑定发起按钮-----
DingTplLaunchFloat: createAsyncComponent(() => import('/@/components/DingTplLaunch/index.vue')),
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】全局钉钉审批模板绑定发起按钮-----
LayoutHeader,
LayoutContent,
LayoutSideBar,

View File

@@ -53,7 +53,10 @@
</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" />
<JSelectUser v-model:value="form.props.userText" :disabled="readonly" @change="onUserTextChange" />
<div v-if="form.props.multiMode === 'none'" style="font-size:12px;color:#888;margin-top:3px">
单人审批只能指定一位已自动保留第一位
</div>
</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="请选择角色" />
@@ -65,12 +68,16 @@
<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-form-item label="审批方式">
<a-radio-group v-model:value="form.props.multiMode" :disabled="readonly" @change="onMultiModeChange">
<a-radio value="none">单人审批</a-radio>
<a-radio value="and">会签全部同意</a-radio>
<a-radio value="or">或签一人同意</a-radio>
<a-radio value="sequence">依次审批</a-radio>
</a-radio-group>
<div v-if="form.props.multiMode === 'none'" style="font-size:12px;color:#ff7a00;margin-top:4px">
单人审批仅允许指定一位审批人对应钉钉 actionType = NONE
</div>
</a-form-item>
<a-form-item label="审批人为空时">
<a-radio-group v-model:value="form.props.emptyStrategy" :disabled="readonly">
@@ -193,7 +200,7 @@
</template>
<script lang="ts" setup>
import { computed, ref, inject, watch } from 'vue';
import { computed, ref, inject, watch, nextTick } from 'vue';
import type { Ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { defHttp } from '/@/utils/http/axios';
@@ -317,12 +324,49 @@
list.push({ name: raw.name, method: raw.method || 'POST', url: raw.url });
}
/** 切换审批方式时,若切为单人则自动裁剪 userText 至第一个 */
function onMultiModeChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
trimToSingleUser();
}
}
/** userText 变化时,若当前是单人模式则裁剪 */
function onUserTextChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
nextTick(trimToSingleUser);
}
}
function trimToSingleUser() {
if (!form.value) return;
const ut: string = form.value.props.userText || '';
const parts = ut.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length > 1) {
form.value.props.userText = parts[0];
}
}
function onClose() {
open.value = false;
}
function onConfirm() {
if (node.value && form.value) {
// 单人审批最终兜底校验
if (
node.value.type === 'approver' &&
form.value.props.multiMode === 'none' &&
form.value.props.approverType === 'user'
) {
const names = (form.value.props.userText || '').split(',').filter(Boolean);
if (names.length > 1) {
form.value.props.userText = names[0];
createMessage.warning('单人审批已自动保留第一位审批人');
}
}
node.value.name = form.value.name;
node.value.props = cloneDeep(form.value.props);
emit('confirm', node.value);

View File

@@ -55,7 +55,7 @@ export function createApproverNode(): FlowNode {
userText: '',
roleList: [],
leaderLevel: 1,
// 多人审批方式 and会签(需全部同意) / or或签(一人同意) / sequence依次审批
// 多人审批方式 and会签 / or或签 / sequence依次 / none单人审批(仅一人)
multiMode: 'and',
// 审批人为空时 pass自动通过 / admin转交管理员 / stop终止
emptyStrategy: 'admin',
@@ -169,13 +169,19 @@ export function nodeSummary(node: FlowNode): string {
}
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}` : '请设置审批角色';
const mode = node.props.multiMode;
const modeTag = mode === 'none' ? '单人' : mode === 'or' ? '或签' : mode === 'sequence' ? '依次' : '会签';
if (t === 'self') return `发起人自己${modeTag}`;
if (t === 'leader') return `${node.props.leaderLevel || 1}级主管(${modeTag}`;
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.props.userText) return '请设置审批人';
const names = node.props.userText.split(',').filter(Boolean);
return mode === 'none'
? `单人审批:${names[0] || '请选择'}`
: `${modeTag}${names.length}人):${names.slice(0, 2).join('、')}${names.length > 2 ? '...' : ''}`;
}
if (node.type === 'cc') {
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段抄送人摘要-----

View File

@@ -0,0 +1,31 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
canLaunch = '/xslmes/approvalGate/canLaunch',
canLaunchBatch = '/xslmes/approvalGate/canLaunchBatch',
history = '/xslmes/approvalGate/history',
}
export interface ApprovalGateVo {
allowed?: boolean;
reason?: string;
bizTable?: string;
bizDataId?: string;
latestRecordId?: string;
latestStatus?: string;
latestChannel?: string;
latestChannelText?: string;
latestStatusText?: string;
}
/** 检查是否允许发起审批 */
export const checkCanLaunch = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get<ApprovalGateVo>({ url: Api.canLaunch, params });
/** 批量检查是否允许发起审批 */
export const checkCanLaunchBatch = (params: { bizTable: string; bizDataIds: string[] }) =>
defHttp.post<ApprovalGateVo[]>({ url: Api.canLaunchBatch, params });
/** 查询业务单据审批台账历史 */
export const getApprovalHistory = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get({ url: Api.history, params });

View File

@@ -64,6 +64,19 @@ export const thirdAppFormSchema: FormSchema[] = [
unCheckedValue: 0
},
defaultValue: 1
},{
label: 'Stream事件推送',
field: 'streamEnabled',
component: 'Switch',
ifShow: ({ values }) => values.thirdType === 'dingtalk',
componentProps: {
checkedChildren: '已启用',
checkedValue: 1,
unCheckedChildren: '未启用',
unCheckedValue: 0,
},
helpMessage: '开启后,此应用的 AppKey/AppSecret 将用于接收钉钉 Stream 事件推送(审批结果等)。同一企业只需开启一个',
defaultValue: 0,
},{
label: '租户id',
field: 'tenantId',

View File

@@ -45,6 +45,14 @@
<a-input-password v-model:value="appConfigData.clientSecret" readonly />
</div>
</div>
<div class="flex-flow">
<div class="base-title">Stream推送</div>
<div class="base-message" style="display:flex;align-items:center;height:50px;">
<a-tag :color="appConfigData.streamEnabled === 1 ? 'green' : 'default'">
{{ appConfigData.streamEnabled === 1 ? '已设为Stream主配置' : '未启用' }}
</a-tag>
</div>
</div>
<div style="margin-top: 20px; width: 100%; text-align: right">
<a-button @click="dingEditClick">编辑</a-button>
<a-button v-if="appConfigData.id" @click="cancelBindClick" danger style="margin-left: 10px">取消绑定</a-button>

View File

@@ -0,0 +1,13 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslApprovalRecord/list',
queryById = '/xslmes/mesXslApprovalRecord/queryById',
exportXls = '/xslmes/mesXslApprovalRecord/exportXls',
}
export const getExportUrl = Api.exportXls;
export const list = (params) => defHttp.get({ url: Api.list, params });
export const queryById = (params: { id: string }) => defHttp.get({ url: Api.queryById, params });

View File

@@ -0,0 +1,78 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务单据', align: 'center', dataIndex: 'bizTableName', width: 120, ellipsis: true },
{ title: '业务标题', align: 'center', dataIndex: 'bizTitle', width: 180, ellipsis: true },
{ title: '审批通道', align: 'center', dataIndex: 'channel_dictText', width: 100 },
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 100 },
{ title: '发起次数', align: 'center', dataIndex: 'launchNo', width: 80 },
{ title: '发起人', align: 'center', dataIndex: 'applyUserName', width: 100 },
{
title: '发起时间',
align: 'center',
dataIndex: 'applyTime',
width: 165,
customRender: ({ text }) => (!text ? '' : String(text).length > 19 ? String(text).substring(0, 19) : text),
},
{
title: '办结时间',
align: 'center',
dataIndex: 'finishTime',
width: 165,
customRender: ({ text }) => (!text ? '' : String(text).length > 19 ? String(text).substring(0, 19) : text),
},
{ title: 'MES审批流', align: 'center', dataIndex: 'flowName', width: 140, ellipsis: true },
{ title: '钉钉模板', align: 'center', dataIndex: 'templateName', width: 140, ellipsis: true },
{ title: '外部实例ID', align: 'center', dataIndex: 'externalInstanceId', width: 160, ellipsis: true },
{ title: '备注', align: 'center', dataIndex: 'remark', width: 160, ellipsis: true },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务单据', field: 'bizTableName', component: 'Input', colProps: { span: 6 } },
{ label: '业务标题', field: 'bizTitle', component: 'Input', colProps: { span: 6 } },
{
label: '审批通道',
field: 'channel',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_channel', placeholder: '请选择' },
colProps: { span: 6 },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_record_status', placeholder: '请选择' },
colProps: { span: 6 },
},
{ label: '发起人', field: 'applyUserName', component: 'Input', colProps: { span: 6 } },
];
export const detailFormSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{ label: '业务单据', field: 'bizTableName', component: 'Input', componentProps: { readonly: true } },
{ label: '业务表名', field: 'bizTable', component: 'Input', componentProps: { readonly: true } },
{ label: '业务数据ID', field: 'bizDataId', component: 'Input', componentProps: { readonly: true } },
{ label: '业务标题', field: 'bizTitle', component: 'Input', componentProps: { readonly: true } },
{ label: '业务编码', field: 'bizCode', component: 'Input', componentProps: { readonly: true } },
{
label: '审批通道',
field: 'channel',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_channel', disabled: true },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_record_status', disabled: true },
},
{ label: '发起次数', field: 'launchNo', component: 'InputNumber', componentProps: { disabled: true, style: { width: '100%' } } },
{ label: '发起人账号', field: 'applyUser', component: 'Input', componentProps: { readonly: true } },
{ label: '发起人姓名', field: 'applyUserName', component: 'Input', componentProps: { readonly: true } },
{ label: '发起时间', field: 'applyTime', component: 'Input', componentProps: { readonly: true } },
{ label: '办结时间', field: 'finishTime', component: 'Input', componentProps: { readonly: true } },
{ label: 'MES审批流', field: 'flowName', component: 'Input', componentProps: { readonly: true } },
{ label: '钉钉模板', field: 'templateName', component: 'Input', componentProps: { readonly: true } },
{ label: '外部实例ID', field: 'externalInstanceId', component: 'Input', componentProps: { readonly: true } },
{ label: '备注', field: 'remark', component: 'InputTextArea', componentProps: { rows: 3, readonly: true } },
];

View File

@@ -0,0 +1,68 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_approval_record:exportXls'"
preIcon="ant-design:export-outlined"
@click="onExportXls"
>
导出
</a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
label: '详情',
onClick: handleDetail.bind(null, record),
auth: 'xslmes:mes_xsl_approval_record:list',
},
]"
/>
</template>
</BasicTable>
<MesXslApprovalRecordDetailModal @register="registerModal" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslApprovalRecord" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslApprovalRecordDetailModal from './components/MesXslApprovalRecordDetailModal.vue';
import { columns, searchFormSchema } from './MesXslApprovalRecord.data';
import { list, getExportUrl } from './MesXslApprovalRecord.api';
const [registerModal, { openModal }] = useModal();
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '审批台账',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
labelWidth: 100,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
actionColumn: {
width: 100,
fixed: 'right',
},
},
exportConfig: {
name: 'MES审批台账',
url: getExportUrl,
},
});
const [registerTable] = tableContext;
function handleDetail(record: Recordable) {
openModal(true, { record });
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="审批台账详情" width="720px" :showOkBtn="false" cancelText="关闭">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form';
import { detailFormSchema } from '../MesXslApprovalRecord.data';
import { queryById } from '../MesXslApprovalRecord.api';
const [registerForm, { setFieldsValue, resetFields }] = useForm({
labelWidth: 110,
schemas: detailFormSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const recordId = ref('');
const [registerModal, { setModalProps }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
recordId.value = data?.record?.id || '';
if (!unref(recordId)) {
return;
}
const res = await queryById({ id: unref(recordId) });
await setFieldsValue({ ...res });
});
</script>

View File

@@ -0,0 +1,38 @@
import { defHttp } from '/@/utils/http/axios';
const BASE = '/xslmes/dingTplBind';
export const getMenuTree = () => defHttp.get({ url: `${BASE}/menuTree` });
export const getTplList = () => defHttp.get({ url: `${BASE}/tplList` });
export const getBizFields = (bizCode: string) =>
defHttp.get({ url: `${BASE}/bizFields`, params: { bizCode } });
export const getDetailSlots = (bizCode: string) =>
defHttp.get({ url: `${BASE}/detailSlots`, params: { bizCode } });
export const getDetailFields = (bizCode: string, detailProperty: string, slotKind = 'LIST') =>
defHttp.get({ url: `${BASE}/detailFields`, params: { bizCode, detailProperty, slotKind } });
export const getBindList = () => defHttp.get({ url: `${BASE}/list` });
export const getBindingByRoute = (routePath: string) =>
defHttp.get({ url: `${BASE}/bindingByRoute`, params: { routePath } }, { errorMessageMode: 'none' });
export const getByBizCode = (bizCode: string) =>
defHttp.get({ url: `${BASE}/getByBizCode`, params: { bizCode } });
export const saveBind = (data: {
bizCode: string;
bizName?: string;
templateId: string;
fieldMappingJson: string;
}) => defHttp.post({ url: `${BASE}/save`, data });
export const deleteBind = (id: string) =>
defHttp.delete({ url: `${BASE}/delete`, params: { id } });
/** 复用现有接口:拉取钉钉模板表单字段(含 dingFields */
export const getTemplateDetail = (id: string) =>
defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/getTemplateDetail', params: { id } });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslDingProcessTpl/list',
save = '/xslmes/mesXslDingProcessTpl/add',
edit = '/xslmes/mesXslDingProcessTpl/edit',
deleteOne = '/xslmes/mesXslDingProcessTpl/delete',
deleteBatch = '/xslmes/mesXslDingProcessTpl/deleteBatch',
importExcel = '/xslmes/mesXslDingProcessTpl/importExcel',
exportXls = '/xslmes/mesXslDingProcessTpl/exportXls',
syncFromDingtalk = '/xslmes/mesXslDingProcessTpl/syncFromDingtalk',
batchImport = '/xslmes/mesXslDingProcessTpl/batchImport',
getTemplateDetail = '/xslmes/mesXslDingProcessTpl/getTemplateDetail',
saveFieldMapping = '/xslmes/mesXslDingProcessTpl/saveFieldMapping',
addNewTemplate = '/xslmes/mesXslDingProcessTpl/addNewTemplate',
createDingTemplate = '/xslmes/mesXslDingProcessTpl/createDingTemplate',
updateDingTemplate = '/xslmes/mesXslDingProcessTpl/updateDingTemplate',
launchApproval = '/xslmes/mesXslDingProcessTpl/launchApproval',
bindFlow = '/xslmes/mesXslDingProcessTpl/bindFlow',
approvalFlowList = '/xslmes/approvalFlow/list',
previewFlowApprovers = '/xslmes/mesXslDingProcessTpl/previewFlowApprovers',
}
export const getExportUrl = Api.exportXls;
export const getImportUrl = Api.importExcel;
export const list = (params) => defHttp.get({ url: Api.list, params });
export const deleteOne = (params, handleSuccess) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess());
export const batchDelete = (params, handleSuccess) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否删除选中数据',
okText: '确认',
cancelText: '取消',
onOk: () =>
defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => handleSuccess()),
});
};
export const saveOrUpdate = (params, isUpdate) =>
defHttp.post({ url: isUpdate ? Api.edit : Api.save, params }, { successMessageMode: 'none' });
/** 新增审批模板草稿(返回含 id 的完整记录) */
export const addNewTemplate = (params) =>
defHttp.post({ url: Api.addNewTemplate, params }, { successMessageMode: 'none' });
export const syncFromDingtalk = () => defHttp.get({ url: Api.syncFromDingtalk }, { successMessageMode: 'none' });
export const batchImport = (params) => defHttp.post({ url: Api.batchImport, params }, { successMessageMode: 'none' });
export const getTemplateDetail = (id: string) =>
defHttp.get({ url: Api.getTemplateDetail, params: { id } }, { successMessageMode: 'none' });
export const queryById = (id: string) =>
defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/queryById', params: { id } }, { successMessageMode: 'none' });
export const saveFieldMapping = (params) =>
defHttp.post({ url: Api.saveFieldMapping, params }, { successMessageMode: 'none' });
export const createDingTemplate = (params) =>
defHttp.post({ url: Api.createDingTemplate, params }, { successMessageMode: 'none' });
export const updateDingTemplate = (params) =>
defHttp.post({ url: Api.updateDingTemplate, params }, { successMessageMode: 'none' });
export const launchApproval = (params) =>
defHttp.post({ url: Api.launchApproval, params }, { successMessageMode: 'none' });
export const bindApprovalFlow = (params: { id: string; flowId: string }) =>
defHttp.post({ url: Api.bindFlow, params }, { successMessageMode: 'none' });
export const getApprovalFlowList = (params?) =>
defHttp.get({ url: Api.approvalFlowList, params }, { successMessageMode: 'none' });
export const previewFlowApprovers = (flowId: string) =>
defHttp.get({ url: Api.previewFlowApprovers, params: { flowId } }, { successMessageMode: 'none' });

View File

@@ -0,0 +1,103 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '模板名称', align: 'center', dataIndex: 'tplName', width: 180 },
{ title: '钉钉processCode', align: 'center', dataIndex: 'processCode', width: 280 },
{ title: '业务类型标识', align: 'center', dataIndex: 'bizType', width: 140 },
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 90 },
{ title: '排序', align: 'center', dataIndex: 'sortNo', width: 80 },
{ title: '备注', align: 'center', dataIndex: 'remark', width: 200 },
{ title: '创建时间', align: 'center', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{
label: '模板名称',
field: 'tplName',
component: 'JInput',
colProps: { span: 6 },
},
{
label: '钉钉processCode',
field: 'processCode',
component: 'JInput',
colProps: { span: 6 },
},
{
label: '业务类型标识',
field: 'bizType',
component: 'JInput',
colProps: { span: 6 },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_ding_tpl_status' },
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{
label: '模板名称',
field: 'tplName',
component: 'Input',
componentProps: { placeholder: '请输入模板名称' },
dynamicRules: () => [{ required: true, message: '请输入模板名称!' }],
},
{
label: 'processCode',
field: 'processCode',
component: 'Input',
componentProps: {
placeholder: '钉钉返回的 processCode「新增审批模板」流程中可留空设计器创建钉钉模板后自动回填',
},
},
{
label: '业务类型标识',
field: 'bizType',
component: 'Input',
componentProps: { placeholder: '供审批流关联使用,如 mixer_ps、formula_spec' },
},
{
label: '表单字段映射',
field: 'formFields',
component: 'InputTextArea',
componentProps: {
placeholder: '{"PS编码":"psCode","类型":"type"} —— 钉钉模板字段名→MES字段名',
rows: 4,
},
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: {
dictCode: 'mes_ding_tpl_status',
placeholder: '请选择状态',
getPopupContainer: () => document.body,
},
},
{
label: '排序',
field: 'sortNo',
component: 'InputNumber',
componentProps: { placeholder: '请输入排序值', style: 'width:100%' },
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { placeholder: '请输入备注', rows: 2 },
},
];
export const superQuerySchema = {
tplName: { title: '模板名称', order: 0, view: 'text', type: 'string' },
processCode: { title: 'processCode', order: 1, view: 'text', type: 'string' },
bizType: { title: '业务类型标识', order: 2, view: 'text', type: 'string' },
status: { title: '状态', order: 3, view: 'list', type: 'string', dictCode: 'mes_ding_tpl_status' },
sortNo: { title: '排序', order: 4, view: 'number', type: 'number' },
};

View File

@@ -0,0 +1,385 @@
<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<!--update-begin---author:GHT ---date:2026-06-04 forMESToDing审批配置新增审批模板创建草稿+打开设计器-->
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_ding_process_tpl:add'"
preIcon="ant-design:dingtalk-outlined"
@click="handleAddNewTemplate"
>
新增审批模板
</a-button>
<!--update-end---author:GHT ---date:2026-06-04 forMESToDing审批配置新增审批模板创建草稿+打开设计器-->
<a-button v-auth="'xslmes:mes_xsl_ding_process_tpl:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
快速录入
</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls">
导出
</a-button>
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">
导入
</j-upload-button>
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置从钉钉同步按钮-->
<a-button preIcon="ant-design:sync-outlined" :loading="syncLoading" @click="handleSyncFromDingtalk">
从钉钉同步
</a-button>
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置从钉钉同步按钮-->
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete">
<Icon icon="ant-design:delete-outlined" />
删除
</a-menu-item>
</a-menu>
</template>
<a-button v-auth="'xslmes:mes_xsl_ding_process_tpl:deleteBatch'">
批量操作
<Icon icon="mdi:chevron-down" />
</a-button>
</a-dropdown>
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
</template>
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template>
</BasicTable>
<MesXslDingProcessTplModal @register="registerModal" @success="handleSuccess" />
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉字段详情弹窗只读-->
<a-modal
v-model:open="schemaVisible"
title="钉钉模板字段详情"
width="720px"
:footer="null"
destroy-on-close
@cancel="schemaVisible = false"
>
<a-spin :spinning="schemaLoading">
<template v-if="schemaData">
<a-descriptions :column="2" bordered size="small" style="margin-bottom:14px">
<a-descriptions-item label="模板名称">{{ schemaData.tplName }}</a-descriptions-item>
<a-descriptions-item label="业务类型">{{ schemaData.bizType || '—' }}</a-descriptions-item>
<a-descriptions-item label="processCode" :span="2">
<a-typography-text v-if="schemaData.processCode" code copyable>{{ schemaData.processCode }}</a-typography-text>
<a-tag v-else color="orange">未创建</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-alert v-if="schemaData.schemaError" type="warning" :message="schemaData.schemaError" show-icon style="margin-bottom:12px" />
<template v-if="schemaData.dingFields?.length">
<div style="font-weight:600;margin-bottom:8px">
钉钉表单字段
<a-tag color="blue" style="margin-left:6px;font-weight:400">{{ schemaData.dingFields.length }} </a-tag>
</div>
<a-table
:dataSource="schemaData.dingFields"
:columns="dingFieldColumns"
:pagination="false"
size="small"
:rowKey="(_, i) => i"
:scroll="{ y: 300 }"
/>
</template>
<div v-else-if="!schemaData.schemaError" style="color:#999;text-align:center;padding:20px">
未从钉钉获取到字段模板可能无 processCode 或字段为空
</div>
<a-collapse style="margin-top:14px" :bordered="false">
<a-collapse-panel key="json" header="原始 JSON 数据" style="background:#fafafa">
<pre style="font-size:12px;margin:0;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all">{{ JSON.stringify(schemaData, null, 2) }}</pre>
</a-collapse-panel>
</a-collapse>
</template>
</a-spin>
</a-modal>
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉字段详情弹窗只读-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置表单设计器-->
<DingTplDesigner ref="designerRef" @success="handleSuccess" />
<DingTplCreateModal ref="createModalRef" @success="onNewTemplateCreated" />
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置表单设计器-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置手动填表发起钉钉审批-->
<DingApprovalLaunchModal ref="launchModalRef" @success="handleLaunchSuccess" />
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置手动填表发起钉钉审批-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉同步结果弹窗-->
<a-modal
v-model:open="syncVisible"
title="从钉钉同步审批模板"
width="780px"
:confirmLoading="importLoading"
okText="导入选中"
cancelText="取消"
@ok="handleBatchImport"
@cancel="syncVisible = false"
>
<a-spin :spinning="syncLoading">
<a-alert v-if="syncList.length === 0 && !syncLoading" message="未获取到钉钉审批模板,请确认钉钉配置及账号绑定" type="warning" show-icon style="margin-bottom:12px" />
<a-table
v-if="syncList.length > 0"
:dataSource="syncList"
:columns="syncColumns"
:rowSelection="{ type: 'checkbox', selectedRowKeys: syncSelectedKeys, onChange: onSyncSelectChange }"
:rowKey="(r) => r.processCode"
:pagination="false"
size="small"
:scroll="{ y: 380 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'imported'">
<a-tag v-if="record.linkDraft" color="orange">待回填本地</a-tag>
<a-tag v-else :color="record.imported ? 'green' : 'default'">{{ record.imported ? '已导入' : '未导入' }}</a-tag>
</template>
</template>
</a-table>
</a-spin>
</a-modal>
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉同步结果弹窗-->
</div>
</template>
<script lang="ts" name="xslmes-mesXslDingProcessTpl" setup>
import { ref, reactive } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import Icon from '/@/components/Icon';
import MesXslDingProcessTplModal from './components/MesXslDingProcessTplModal.vue';
import DingTplDesigner from './components/DingTplDesigner.vue';
import DingTplCreateModal from './components/DingTplCreateModal.vue';
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
import DingApprovalLaunchModal from './components/DingApprovalLaunchModal.vue';
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
import { columns, searchFormSchema, superQuerySchema } from './MesXslDingProcessTpl.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, syncFromDingtalk, batchImport, getTemplateDetail } from './MesXslDingProcessTpl.api';
const { createMessage } = useMessage();
const queryParam = reactive<any>({});
const [registerModal, { openModal }] = useModal();
const { tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '钉钉审批模板配置',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
actionColumn: {
title: '操作',
dataIndex: 'action',
width: 220,
fixed: 'right',
slots: { customRender: 'action' },
},
beforeFetch: (params) => Object.assign(params, queryParam),
},
exportConfig: { name: '钉钉审批模板配置', url: getExportUrl, params: queryParam },
importConfig: { url: getImportUrl, success: handleSuccess },
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
function handleSuperQuery(params) {
Object.keys(params).forEach((k) => (queryParam[k] = params[k]));
reload();
}
function handleAdd() {
openModal(true, { isUpdate: false, showFooter: true });
}
const createModalRef = ref();
function handleAddNewTemplate() {
createModalRef.value?.open();
}
function onNewTemplateCreated({ record, openDesigner }: { record: Recordable; openDesigner: boolean }) {
reload();
if (openDesigner && record?.id) {
designerRef.value?.open(record);
}
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: true });
}
function handleDetail(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: false });
}
async function handleDelete(record) {
await deleteOne({ id: record.id }, handleSuccess);
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
function getTableAction(record) {
return [
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_ding_process_tpl:edit' },
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】操作列新增发起审批按钮
{
label: '发起审批',
icon: 'ant-design:send-outlined',
color: 'success',
disabled: !record.processCode,
tooltip: record.processCode ? '手动填表后发起钉钉审批' : '请先配置 processCode',
onClick: handleLaunchApproval.bind(null, record),
},
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】操作列新增发起审批按钮
];
}
function getDropDownAction(record) {
const actions: any[] = [
{ label: '详情', onClick: handleDetail.bind(null, record) },
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】新增设计模板入口
{ label: '设计模板', onClick: handleDesignTemplate.bind(null, record), icon: 'ant-design:layout-outlined' },
];
if (!record.processCode) {
actions.push({
label: '创建钉钉模板',
icon: 'ant-design:dingtalk-outlined',
onClick: handleDesignTemplate.bind(null, record),
auth: 'xslmes:mes_xsl_ding_process_tpl:edit',
});
}
actions.push(
{ label: '查看钉钉字段', onClick: handleShowDingSchema.bind(null, record), icon: 'ant-design:dingtalk-outlined' },
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】新增设计模板入口
{
label: '删除',
popConfirm: { title: '是否确认删除', confirm: handleDelete.bind(null, record), placement: 'topLeft' },
auth: 'xslmes:mes_xsl_ding_process_tpl:delete',
},
);
return actions;
}
// ===== 手动填表发起钉钉审批 =====
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
const launchModalRef = ref();
function handleLaunchApproval(record: Recordable) {
if (!record.processCode) {
createMessage.warning('该模板尚未配置 processCode请先完成模板配置');
return;
}
launchModalRef.value?.open(record);
}
function handleLaunchSuccess() {
// 发起成功后可按需刷新列表(本期无需刷新,审批实例不在此列表)
}
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
// ===== 表单设计器 =====
const designerRef = ref();
function handleDesignTemplate(record: Recordable) {
designerRef.value?.open(record);
}
// ===== 钉钉字段详情(只读 schema 查看器)=====
const schemaVisible = ref(false);
const schemaLoading = ref(false);
const schemaData = ref<any>(null);
const dingFieldColumns = [
{ title: '控件标题(钉钉字段名)', dataIndex: 'label' },
{ title: '控件类型', dataIndex: 'componentName', width: 160 },
{ title: '必填', dataIndex: 'required', width: 70 },
];
async function handleShowDingSchema(record: Recordable) {
schemaVisible.value = true;
schemaLoading.value = true;
schemaData.value = null;
try {
schemaData.value = await getTemplateDetail(record.id);
} catch (e: any) {
createMessage.error(e?.message || '获取模板字段失败');
schemaVisible.value = false;
} finally {
schemaLoading.value = false;
}
}
// ===== 从钉钉同步 =====
const syncVisible = ref(false);
const syncLoading = ref(false);
const importLoading = ref(false);
const syncList = ref<any[]>([]);
const syncSelectedKeys = ref<string[]>([]);
const syncColumns = [
{ title: '模板名称', dataIndex: 'name', width: 200 },
{ title: 'processCode', dataIndex: 'processCode', width: 300 },
{ title: '描述', dataIndex: 'description', ellipsis: true },
{ title: '状态', dataIndex: 'imported', width: 90 },
];
async function handleSyncFromDingtalk() {
syncVisible.value = true;
syncLoading.value = true;
syncList.value = [];
syncSelectedKeys.value = [];
try {
const data = await syncFromDingtalk();
syncList.value = data || [];
syncSelectedKeys.value = (syncList.value as any[])
.filter((r) => !r.imported || r.linkDraft)
.map((r) => r.processCode);
} catch (e: any) {
createMessage.error(e?.message || '从钉钉同步失败');
syncVisible.value = false;
} finally {
syncLoading.value = false;
}
}
function onSyncSelectChange(keys: string[]) {
syncSelectedKeys.value = keys;
}
async function handleBatchImport() {
if (syncSelectedKeys.value.length === 0) {
createMessage.warning('请勾选要导入的模板');
return;
}
const selected = syncList.value.filter((r) => syncSelectedKeys.value.includes(r.processCode));
importLoading.value = true;
try {
const msg = await batchImport(selected);
createMessage.success(typeof msg === 'string' ? msg : '导入成功');
syncVisible.value = false;
reload();
} catch (e: any) {
createMessage.error(e?.message || '批量导入失败');
} finally {
importLoading.value = false;
}
}
</script>
<style lang="less" scoped>
:deep(.ant-picker-range) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,776 @@
<template>
<a-modal
v-model:open="visible"
:title="`发起审批 · ${tplData?.tplName || ''}`"
width="940px"
:confirm-loading="submitting"
ok-text="发起审批"
cancel-text="取消"
destroy-on-close
:body-style="{ padding: 0 }"
@ok="handleSubmit"
@cancel="handleClose"
>
<div class="dal-body">
<!-- 左侧审批流时间轴 -->
<div class="dal-timeline-panel">
<div class="dal-panel-title">审批流程</div>
<div v-if="!selectedFlowId" class="dal-timeline-empty">
<div class="dal-timeline-empty-icon">🔗</div>
<div>请先在审批流配置<br>页签中选择审批流</div>
</div>
<a-spin v-else-if="previewLoading" style="display:flex;justify-content:center;padding:32px 0" />
<div v-else class="dal-timeline">
<!-- 发起人节点固定头 -->
<div class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--start"></div>
<div class="dal-ts-line" v-if="approverPreview.length > 0"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name">发起人</div>
<div class="dal-ts-sub">所有人可发起</div>
</div>
</div>
<!-- 动态节点 -->
<div
v-for="(node, ni) in approverPreview"
:key="node.nodeId || ni"
class="dal-ts-step"
>
<div class="dal-ts-left">
<div
class="dal-ts-dot"
:class="[
node.nodeType === 'cc' ? 'dal-ts-dot--cc' : 'dal-ts-dot--approver',
!node.allResolved ? 'dal-ts-dot--warn' : ''
]"
></div>
<div class="dal-ts-line" v-if="ni < approverPreview.length - 1"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-tags">
<span class="dal-ts-badge" :class="node.nodeType === 'cc' ? 'dal-ts-badge--cc' : 'dal-ts-badge--approver'">
{{ node.nodeType === 'cc' ? '抄送' : '审批' }}
</span>
<span v-if="node.nodeType !== 'cc'" class="dal-ts-mode">{{ modeLabel(node.multiMode) }}</span>
</div>
<div class="dal-ts-name">{{ node.nodeName }}</div>
<div class="dal-ts-users">
<template v-for="(u, ui) in node.users" :key="u.username">
<span :class="u.resolved ? 'dal-ts-user--ok' : 'dal-ts-user--err'">
{{ u.realname }}
</span>
<span v-if="ui < node.users.length - 1" style="color:#ccc;margin:0 2px">·</span>
</template>
</div>
<div v-if="!node.allResolved" class="dal-ts-unresolved">
有未解析成员请补充手机号
</div>
</div>
</div>
<!-- 结束节点 -->
<div class="dal-ts-step" v-if="approverPreview.length > 0">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--end"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name" style="color:#888">结束</div>
</div>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="dal-panel-divider"></div>
<!-- 右侧主内容 -->
<div class="dal-content-panel">
<a-tabs v-model:activeKey="activeTab" size="small" class="dal-tabs">
<!-- 表单填写 -->
<a-tab-pane key="form" tab="表单填写">
<div class="dal-form-scroll">
<a-spin :spinning="loading" tip="加载表单字段中...">
<a-alert v-if="loadError" type="error" :message="loadError" show-icon style="margin-bottom:12px" />
<template v-else-if="!loading">
<div v-if="dingFields.length === 0" class="dal-form-empty">
该模板暂无表单字段可直接发起仅通知审批人
</div>
<template v-for="field in dingFields" :key="field.label">
<div v-if="field.componentName === 'TextNote'" class="dal-form-note">{{ field.label }}</div>
<template v-else-if="field.componentName === 'TableField'">
<div class="dal-form-item">
<div class="dal-field-label" :class="{ 'dal-field-label--required': field.required }">{{ field.label }}</div>
<div class="dal-table-wrap">
<table class="dal-table">
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th v-for="child in field.children||[]" :key="child.label">{{ child.label }}</th>
<th style="width:88px;text-align:center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIdx) in getTableRows(field.label)" :key="rowIdx">
<td style="text-align:center;color:#aaa">{{ rowIdx + 1 }}</td>
<td v-for="child in field.children||[]" :key="child.label">
<a-input v-model:value="row[child.label]" placeholder="请输入" size="small" :bordered="false" />
</td>
<td style="text-align:center">
<a-space :size="4">
<a style="color:#ff4d4f;font-size:12px" @click="deleteTableRow(field.label, rowIdx)">删除</a>
<a style="font-size:12px" @click="copyTableRow(field.label, rowIdx)">复制</a>
</a-space>
</td>
</tr>
<tr v-if="getTableRows(field.label).length === 0">
<td :colspan="(field.children?.length||0)+2" style="text-align:center;color:#bbb;padding:10px 0">暂无数据</td>
</tr>
</tbody>
</table>
<div class="dal-table-add" @click="addTableRow(field.label, field.children||[])">+ 添加</div>
</div>
</div>
</template>
<div v-else class="dal-form-item">
<div class="dal-field-label" :class="{ 'dal-field-label--required': field.required }">{{ field.label }}</div>
<a-range-picker v-if="field.componentName==='DDDateRangeField'" v-model:value="formValues[field.label]" style="width:100%" value-format="YYYY-MM-DD HH:mm" show-time :placeholder="['开始时间','结束时间']" />
<a-date-picker v-else-if="field.componentName==='DDDateField'" v-model:value="formValues[field.label]" style="width:100%" value-format="YYYY-MM-DD" placeholder="请选择日期" />
<a-input-number v-else-if="['NumberField','MoneyField'].includes(field.componentName)" v-model:value="formValues[field.label]" style="width:100%" placeholder="请输入" />
<a-textarea v-else-if="field.componentName==='TextareaField'" v-model:value="formValues[field.label]" :rows="3" placeholder="请输入" />
<a-select v-else-if="field.componentName==='DDSelectField'" v-model:value="formValues[field.label]" style="width:100%" placeholder="请选择" allow-clear>
<a-select-option v-for="opt in field.options||[]" :key="opt.key" :value="opt.value">{{ opt.value }}</a-select-option>
</a-select>
<a-select v-else-if="field.componentName==='DDMultiSelectField'" v-model:value="formValues[field.label]" style="width:100%" mode="multiple" placeholder="请选择" allow-clear>
<a-select-option v-for="opt in field.options||[]" :key="opt.key" :value="opt.value">{{ opt.value }}</a-select-option>
</a-select>
<template v-else-if="field.componentName==='InnerContactField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写钉钉 userId多人用英文逗号分隔" allow-clear />
<div class="dal-field-hint">多人逗号分隔系统自动转 JSON 数组格式</div>
</template>
<template v-else-if="field.componentName==='RelateField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写关联审批实例 ID多个用英文逗号分隔" allow-clear />
<div class="dal-field-hint">多个逗号分隔系统自动转 JSON 数组格式</div>
</template>
<template v-else-if="field.componentName==='DDPhotoField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写图片 URL多张用英文逗号分隔" allow-clear />
<div class="dal-field-hint">多张逗号分隔系统自动转 JSON 数组格式</div>
</template>
<template v-else-if="field.componentName==='DepartmentField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写钉钉部门 ID多个用英文逗号分隔" allow-clear />
<div class="dal-field-hint">部门 ID 逗号分隔直接传入</div>
</template>
<template v-else-if="field.componentName==='DDAttachment'">
<a-input v-model:value="formValues[field.label]" placeholder='[{"spaceId":"...","fileId":"...","fileName":"...","fileSize":"...","fileType":"..."}]' allow-clear />
<div class="dal-field-hint">需先上传到钉钉云盘获取 fileId直接填写 JSON 数组字符串</div>
</template>
<a-input v-else v-model:value="formValues[field.label]" placeholder="请输入" allow-clear />
</div>
</template>
</template>
</a-spin>
</div>
</a-tab-pane>
<!-- 审批流配置 -->
<a-tab-pane key="flow">
<template #tab>
审批流配置
<a-badge v-if="hasUnresolved" color="red" style="margin-left:4px" />
</template>
<div class="dal-form-scroll">
<div class="flow-tab-header">
<span class="flow-tab-hint">选择审批流发起时按流程节点指定钉钉审批人</span>
<a-button size="small" type="primary" ghost @click="handleNewFlow">+ 新建审批流</a-button>
</div>
<!-- 下拉选择审批流 -->
<div class="flow-select-row">
<a-select
v-model:value="selectedFlowId"
style="flex:1;min-width:0"
placeholder="请选择审批流"
:loading="flowLoading"
:options="flowSelectOptions"
show-search
:filter-option="filterFlowOption"
allow-clear
@change="handleFlowSelected"
>
<template #option="{ label, status, remark }">
<div class="flow-opt-item">
<span class="flow-opt-name">{{ label }}</span>
<span style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<span v-if="remark" class="flow-opt-remark">{{ remark }}</span>
<a-tag
:color="status==='1'?'green':status==='2'?'default':'orange'"
style="margin:0;font-size:11px;line-height:16px;padding:0 5px"
>{{ status==='1'?'已发布':status==='2'?'已停用':'草稿' }}</a-tag>
</span>
</div>
</template>
</a-select>
<a-button
v-if="selectedFlowId"
size="small"
type="link"
style="flex-shrink:0;padding-left:8px"
@click="handleDesignSelectedFlow"
>设计</a-button>
</div>
<!-- 审批人解析预览 -->
<template v-if="selectedFlowId">
<a-divider style="margin:14px 0 10px" />
<div class="preview-title">
审批节点 · 人员解析
<a-spin :spinning="previewLoading" size="small" style="margin-left:8px" />
</div>
<div v-if="!previewLoading && approverPreview.length === 0" style="color:#bbb;font-size:12px;margin-top:6px">
该审批流暂无审批人节点
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId||ni" class="preview-node" :class="{'preview-node--cc': node.nodeType==='cc'}">
<div class="preview-node-hd">
<a-tag :color="node.nodeType==='cc'?'blue':'orange'" style="margin:0 6px 0 0;font-size:11px">
{{ node.nodeType === 'cc' ? '抄送' : '审批' }}
</a-tag>
<span class="preview-node-name">{{ node.nodeName }}</span>
<span class="preview-node-mode">
{{ node.nodeType === 'cc' ? '位置自动判断' : modeLabel(node.multiMode) }}
</span>
</div>
<div v-for="u in node.users" :key="u.username" class="preview-user">
<a-tag v-if="u.resolved" color="success" style="margin:0">{{ u.realname }}{{ u.username }}</a-tag>
<a-tag v-else-if="u.unsupported" color="default" style="margin:0">{{ u.realname }}不支持自动解析</a-tag>
<a-tag v-else color="error" style="margin:0">{{ u.realname }}{{ u.username }}未找到钉钉账号</a-tag>
</div>
<div v-if="!node.allResolved" class="preview-supplement">
<a-input
v-model:value="supplementPhones[node.nodeId||String(ni)]"
:placeholder="node.nodeType==='cc' ? '补充抄送人手机号,多个用逗号分隔' : '补充审批人手机号,多个用逗号分隔'"
allow-clear
size="small"
/>
<div class="dal-field-hint" style="margin-top:3px">
手机号需在企业钉钉注册与自动解析的成员合并
</div>
</div>
</div>
<a-alert v-if="hasUnresolved" type="warning" show-icon style="margin-top:10px;font-size:12px"
message="部分节点有未解析成员,请补充手机号后再发起审批" />
</template>
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
<ApprovalFlowModal @register="registerFlowModal" @success="handleFlowCreated" />
<FlowDesign @register="registerFlowDesign" @success="loadFlowList" />
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { useModal } from '/@/components/Modal';
import { getTemplateDetail, launchApproval, getApprovalFlowList, previewFlowApprovers, bindApprovalFlow } from '../MesXslDingProcessTpl.api';
import ApprovalFlowModal from '/@/views/approval/flow/ApprovalFlowModal.vue';
import FlowDesign from '/@/views/approval/flow/components/FlowDesign.vue';
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const loadError = ref('');
const activeTab = ref('form');
const tplData = ref<any>(null);
const dingFields = ref<any[]>([]);
const formValues = reactive<Record<string, any>>({});
const tableValues = reactive<Record<string, Record<string, string>[]>>({});
const flowLoading = ref(false);
const flowList = ref<any[]>([]);
const selectedFlowId = ref('');
const previewLoading = ref(false);
const approverPreview = ref<any[]>([]);
const supplementPhones = reactive<Record<string, string>>({});
const hasUnresolved = computed(() => approverPreview.value.some((n) => !n.allResolved));
const flowSelectOptions = computed(() =>
flowList.value.map((f) => ({
value: f.id,
label: f.flowName,
status: f.status,
remark: f.remark || '',
})),
);
function filterFlowOption(input: string, option: any) {
return (option?.label ?? '').toLowerCase().includes(input.toLowerCase());
}
const [registerFlowModal, { openModal: openFlowModal }] = useModal();
const [registerFlowDesign, { openModal: openFlowDesign }] = useModal();
function modeLabel(mode: string) {
if (mode === 'none') return '单人';
if (mode === 'or') return '或签';
if (mode === 'sequence') return '依次';
return '会签';
}
function resetForm() {
Object.keys(formValues).forEach((k) => delete formValues[k]);
Object.keys(tableValues).forEach((k) => delete tableValues[k]);
Object.keys(supplementPhones).forEach((k) => delete supplementPhones[k]);
loadError.value = '';
activeTab.value = 'form';
selectedFlowId.value = '';
approverPreview.value = [];
}
async function open(record: any) {
resetForm();
tplData.value = record;
dingFields.value = [];
visible.value = true;
loading.value = true;
if (record.flowId) selectedFlowId.value = record.flowId;
try {
const detail = await getTemplateDetail(record.id);
tplData.value = detail;
dingFields.value = detail.dingFields || [];
if (detail.schemaError) loadError.value = detail.schemaError;
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [buildEmptyRow(f.children || [])];
}
} catch (e: any) {
loadError.value = e?.message || '加载模板字段失败';
} finally {
loading.value = false;
}
await loadFlowList();
if (selectedFlowId.value) loadPreview(selectedFlowId.value);
}
function handleClose() { visible.value = false; }
async function loadFlowList() {
flowLoading.value = true;
try {
const res = await getApprovalFlowList({ pageSize: 200 });
flowList.value = res?.records || res || [];
} catch { flowList.value = []; }
finally { flowLoading.value = false; }
}
function handleFlowSelected() {
// 清空补充手机号和预览(无论选中还是清空)
Object.keys(supplementPhones).forEach((k) => delete supplementPhones[k]);
approverPreview.value = [];
// 立刻持久化绑定关系选中或清空均保存flowId 为空表示解绑)
if (tplData.value?.id) {
bindApprovalFlow({ id: tplData.value.id, flowId: selectedFlowId.value || '' }).catch(() => {
// 静默失败,不影响主流程;发起时后端会再次兜底保存
});
}
if (selectedFlowId.value) loadPreview(selectedFlowId.value);
}
async function loadPreview(flowId: string) {
previewLoading.value = true;
try {
const res = await previewFlowApprovers(flowId);
approverPreview.value = Array.isArray(res) ? res : [];
} catch { approverPreview.value = []; }
finally { previewLoading.value = false; }
}
function handleNewFlow() { openFlowModal(true, { isUpdate: false }); }
async function handleFlowCreated() {
await loadFlowList();
if (flowList.value.length > 0) {
const last = flowList.value[flowList.value.length - 1];
selectedFlowId.value = last.id;
loadPreview(last.id);
}
}
function handleDesignFlow(flow: any) { openFlowDesign(true, { record: flow, readonly: false }); }
function handleDesignSelectedFlow() {
const flow = flowList.value.find((f) => f.id === selectedFlowId.value);
if (flow) handleDesignFlow(flow);
}
// ─── 表格操作 ───
function buildEmptyRow(children: any[]): Record<string, string> {
const row: Record<string, string> = {};
for (const c of children) row[c.label] = '';
return row;
}
function getTableRows(label: string) {
if (!tableValues[label]) tableValues[label] = [];
return tableValues[label];
}
function addTableRow(label: string, children: any[]) { getTableRows(label).push(buildEmptyRow(children)); }
function deleteTableRow(label: string, idx: number) { getTableRows(label).splice(idx, 1); }
function copyTableRow(label: string, idx: number) {
const rows = getTableRows(label);
rows.splice(idx + 1, 0, { ...rows[idx] });
}
// ─── 提交 ───
async function handleSubmit() {
if (!selectedFlowId.value) {
activeTab.value = 'flow';
createMessage.warning('请在「审批流配置」页签中选择一个审批流');
return;
}
const unresolvedNodes = approverPreview.value.filter((n) => !n.allResolved);
for (const node of unresolvedNodes) {
const nodeKey = node.nodeId || String(approverPreview.value.indexOf(node));
if (!supplementPhones[nodeKey]?.trim()) {
activeTab.value = 'flow';
createMessage.warning(`${node.nodeType === 'cc' ? '抄送节点' : '审批节点'}${node.nodeName}」有未解析成员,请补充手机号`);
return;
}
}
for (const field of dingFields.value) {
if (!field.required || field.componentName === 'TextNote') continue;
if (field.componentName === 'TableField') {
if (getTableRows(field.label).length === 0) { activeTab.value = 'form'; createMessage.warning(`${field.label}」至少需要填写一行`); return; }
continue;
}
const val = formValues[field.label];
if (val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)) {
activeTab.value = 'form'; createMessage.warning(`${field.label}」为必填项`); return;
}
}
const fvList: { name: string; value: string }[] = [];
for (const field of dingFields.value) {
if (field.componentName === 'TextNote') continue;
const label = field.label;
if (field.componentName === 'TableField') {
const validRows = getTableRows(label).filter((r) => Object.values(r).some((v) => v !== ''));
if (validRows.length === 0) continue;
fvList.push({ name: label, value: JSON.stringify(validRows.map((row) => Object.entries(row).map(([k, v]) => ({ name: k, value: String(v ?? '') })))) });
continue;
}
let val = formValues[label];
if (field.componentName === 'DDDateRangeField' && Array.isArray(val)) {
val = val.join('~');
} else if (field.componentName === 'DDMultiSelectField' && Array.isArray(val)) {
val = val.length > 0 ? JSON.stringify(val) : null;
} else if (['InnerContactField', 'RelateField', 'DDPhotoField'].includes(field.componentName)) {
const raw = val !== undefined && val !== null ? String(val).trim() : '';
val = raw ? JSON.stringify(raw.split(',').map((s) => s.trim()).filter(Boolean)) : null;
} else {
val = val !== undefined && val !== null ? String(val) : null;
}
if (val === null || val === '') { if (!field.required) continue; val = ''; }
fvList.push({ name: label, value: val as string });
}
const approverOverrides = Object.entries(supplementPhones)
.filter(([, phones]) => phones?.trim())
.map(([nodeId, phones]) => ({ nodeId, phones: phones.trim() }));
submitting.value = true;
try {
const result = await launchApproval({ id: tplData.value?.id, formValues: fvList, flowId: selectedFlowId.value, approverOverrides });
createMessage.success(typeof result === 'string' ? result : '审批发起成功!审批人将在钉钉「待我审批」中收到任务');
visible.value = false;
emit('success', result);
} catch (e: any) {
createMessage.error(e?.message || '发起失败');
} finally {
submitting.value = false;
}
}
defineExpose({ open });
</script>
<style lang="less" scoped>
// ─── 整体布局 ───
.dal-body {
display: flex;
min-height: 480px;
max-height: 70vh;
}
// ─── 左侧时间轴面板 ───
.dal-timeline-panel {
width: 210px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
padding: 16px 14px;
overflow-y: auto;
}
.dal-panel-title {
font-size: 12px;
font-weight: 600;
color: #888;
letter-spacing: .5px;
text-transform: uppercase;
margin-bottom: 14px;
}
.dal-timeline-empty {
text-align: center;
color: #bbb;
font-size: 12px;
padding-top: 32px;
line-height: 1.8;
.dal-timeline-empty-icon { font-size: 24px; margin-bottom: 8px; }
}
// ─── 时间轴 ───
.dal-timeline { }
.dal-ts-step {
display: flex;
align-items: flex-start;
gap: 8px;
}
.dal-ts-left {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 12px;
padding-top: 2px;
}
.dal-ts-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
position: relative;
z-index: 1;
border: 2px solid currentColor;
background: #fff;
}
.dal-ts-dot--start { color: #1677ff; background: #1677ff; border-color: #1677ff; }
.dal-ts-dot--end { color: #d9d9d9; background: #d9d9d9; border-color: #d9d9d9; width: 8px; height: 8px; }
.dal-ts-dot--approver { color: #fa8c16; }
.dal-ts-dot--cc { color: #1677ff; }
.dal-ts-dot--warn { color: #ff4d4f !important; }
.dal-ts-line {
width: 2px;
flex: 1;
min-height: 22px;
background: linear-gradient(to bottom, #e0e0e0 50%, transparent 100%);
margin: 3px 0 0;
}
.dal-ts-content {
flex: 1;
padding-bottom: 18px;
min-width: 0;
}
.dal-ts-tags {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 2px;
}
.dal-ts-badge {
font-size: 10px;
padding: 0 5px;
border-radius: 3px;
line-height: 16px;
font-weight: 500;
&--approver { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
&--cc { background: #e6f4ff; color: #0958d9; border: 1px solid #91caff; }
}
.dal-ts-mode {
font-size: 10px;
color: #aaa;
background: #f5f5f5;
padding: 0 4px;
border-radius: 2px;
line-height: 14px;
}
.dal-ts-name {
font-size: 12px;
font-weight: 500;
color: #333;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dal-ts-users {
font-size: 11px;
color: #888;
margin-top: 2px;
line-height: 1.5;
}
.dal-ts-user--ok { color: #52c41a; }
.dal-ts-user--err { color: #ff4d4f; }
.dal-ts-unresolved {
font-size: 10px;
color: #ff7a00;
margin-top: 2px;
}
// ─── 分隔线 ───
.dal-panel-divider {
width: 1px;
background: #f0f0f0;
flex-shrink: 0;
}
// ─── 右侧内容面板 ───
.dal-content-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dal-tabs {
height: 100%;
display: flex;
flex-direction: column;
:deep(.ant-tabs-nav) { padding: 0 16px; margin-bottom: 0; }
:deep(.ant-tabs-content-holder) { flex: 1; overflow: hidden; }
:deep(.ant-tabs-content) { height: 100%; }
:deep(.ant-tabs-tabpane) { height: 100%; }
}
.dal-form-scroll {
height: 100%;
overflow-y: auto;
padding: 14px 18px;
}
// ─── 表单元素 ───
.dal-form-item {
margin-bottom: 14px;
}
.dal-form-empty {
color: #bbb;
text-align: center;
padding: 32px 0;
font-size: 13px;
}
.dal-field-label {
font-size: 13px;
color: #555;
margin-bottom: 5px;
font-weight: 500;
&--required::before { content: '* '; color: #ff4d4f; }
}
.dal-field-hint { font-size: 11px; color: #aaa; margin-top: 3px; }
.dal-form-note {
background: #f8f8f8;
border-left: 3px solid #ddd;
padding: 6px 10px;
font-size: 12px;
color: #777;
margin-bottom: 12px;
border-radius: 0 4px 4px 0;
}
// ─── 表格 ───
.dal-table-wrap { border: 1px solid #ebebeb; border-radius: 6px; overflow: hidden; }
.dal-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th { background: #fafafa; padding: 7px 10px; border-bottom: 1px solid #ebebeb; font-weight: 500; color: #666; font-size: 12px; }
td { padding: 3px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
:deep(.ant-input) { padding: 2px 6px; }
}
.dal-table-add {
display: flex; align-items: center; justify-content: center; padding: 7px 0;
color: #1677ff; font-size: 13px; cursor: pointer;
border-top: 1px dashed #ddd; background: #fafcff;
&:hover { background: #e6f0ff; }
}
// ─── 审批流配置页签 ───
.flow-tab-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.flow-tab-hint { font-size: 12px; color: #999; }
.flow-select-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 2px;
}
// 下拉选项内容
.flow-opt-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.flow-opt-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.flow-opt-remark {
font-size: 11px;
color: #aaa;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 8px; display: flex; align-items: center; }
.preview-node { border: 1px solid #f0f0f0; border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; &--cc { border-color: #bae0ff; background: #f6fbff; } }
.preview-node-hd { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; }
.preview-node-name { font-size: 13px; font-weight: 500; color: #333; }
.preview-node-mode { font-size: 11px; color: #aaa; background: #f5f5f5; padding: 1px 6px; border-radius: 3px; }
.preview-user { display: inline-block; margin: 0 5px 5px 0; }
.preview-supplement { margin-top: 8px; }
</style>

View File

@@ -0,0 +1,110 @@
<!--
新增审批模板创建本地草稿并可选打开表单设计器在设计器中推送到钉钉
@author GHT
@date 2026-06-04 forMESToDing审批配置新增审批模板入口
-->
<template>
<a-modal
v-model:open="visible"
title="新增审批模板"
width="520px"
:confirmLoading="loading"
okText="创建并设计表单"
cancelText="取消"
destroy-on-close
@ok="handleSubmit"
@cancel="visible = false"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="将先在 MES 创建模板配置随后在表单设计器中添加字段并点击「创建钉钉模板」推送到钉钉processCode 由钉钉返回)。"
/>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="模板名称" name="tplName">
<a-input v-model:value="formState.tplName" placeholder="如密炼PS编制审批" allow-clear />
</a-form-item>
<a-form-item label="业务类型标识" name="bizType">
<a-input v-model:value="formState.bizType" placeholder="供审批流关联,如 mixer_ps" allow-clear />
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formState.remark" :rows="2" placeholder="可选" />
</a-form-item>
<a-form-item name="openDesigner">
<a-checkbox v-model:checked="formState.openDesigner">创建成功后打开表单设计器</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import type { FormInstance } from 'ant-design-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { addNewTemplate } from '../MesXslDingProcessTpl.api';
const emit = defineEmits<{
(e: 'success', payload: { record: Recordable; openDesigner: boolean }): void;
}>();
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const formRef = ref<FormInstance>();
const formState = reactive({
tplName: '',
bizType: '',
remark: '',
openDesigner: true,
});
const rules = {
tplName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
};
function resetForm() {
formState.tplName = '';
formState.bizType = '';
formState.remark = '';
formState.openDesigner = true;
formRef.value?.clearValidate();
}
function open() {
resetForm();
visible.value = true;
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
loading.value = true;
try {
const record: any = await addNewTemplate({
tplName: formState.tplName.trim(),
bizType: formState.bizType?.trim() || undefined,
remark: formState.remark?.trim() || undefined,
status: '1',
sortNo: 0,
});
if (!record?.id) {
createMessage.error('创建失败:未返回模板 ID');
return;
}
createMessage.success('审批模板已创建');
visible.value = false;
emit('success', { record, openDesigner: formState.openDesigner });
} catch (e: any) {
createMessage.error(e?.message || '创建失败');
} finally {
loading.value = false;
}
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,61 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="800" @ok="handleSubmit">
<BasicForm @register="registerForm" name="MesXslDingProcessTplForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from '../MesXslDingProcessTpl.data';
import { saveOrUpdate } from '../MesXslDingProcessTpl.api';
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(true);
const isDetail = ref(false);
const [registerForm, { setProps, resetFields, setFieldsValue, validate, scrollToField }] = useForm({
labelWidth: 120,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false, showCancelBtn: !!data?.showFooter, showOkBtn: !!data?.showFooter });
isUpdate.value = !!data?.isUpdate;
isDetail.value = !!data?.showFooter;
if (unref(isUpdate)) {
await setFieldsValue({ ...data.record });
}
setProps({ disabled: !data?.showFooter });
});
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(isDetail) ? '详情' : '编辑'));
async function handleSubmit() {
try {
const values = await validate();
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, isUpdate.value);
closeModal();
emit('success');
} catch ({ errorFields }: any) {
if (errorFields) {
const firstField = errorFields[0];
if (firstField) scrollToField(firstField.name, { behavior: 'smooth', block: 'center' });
}
return Promise.reject(errorFields);
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
:deep(.ant-input-number) {
width: 100%;
}
</style>