Merge branch '20260519-3.9.2版本-葛昊天分支'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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审批流设计】驳回统一回退:按表注解自动调用业务接口-----------
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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审批台账】发起后同步台账终态(如无节点自动通过)-----
|
||||
}
|
||||
|
||||
@@ -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审批台账");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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审批办结同步台账-----
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批表单控件
|
||||
* 对应官方 SDK:FormComponent
|
||||
*
|
||||
* 支持的 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;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批表单控件属性
|
||||
* 对应官方 SDK:FormComponentProps
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* 对应官方 SDK:FormCreateRequest
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class DingFormCreateRequest {
|
||||
|
||||
/** 模板名称 */
|
||||
private String name;
|
||||
|
||||
/** 模板描述 */
|
||||
private String description;
|
||||
|
||||
/** 表单控件列表 */
|
||||
private List<DingFormComponent> formComponents;
|
||||
}
|
||||
@@ -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
|
||||
* 对应官方 SDK:FormUpdateRequest
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class DingFormUpdateRequest {
|
||||
|
||||
/** 要更新的模板 processCode */
|
||||
private String processCode;
|
||||
|
||||
/** 模板名称 */
|
||||
private String name;
|
||||
|
||||
/** 模板描述 */
|
||||
private String description;
|
||||
|
||||
/** 表单控件列表 */
|
||||
private List<DingFormComponent> formComponents;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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=RUNNING,0行更新即终态已处理-----
|
||||
// ② 更新台账(乐观条件: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=RUNNING,0行更新即终态已处理-----
|
||||
|
||||
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,并发安全且重启不丢-----
|
||||
// tryMarkNodeProcessed:UPDATE ... 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("钉钉审批");
|
||||
}
|
||||
}
|
||||
@@ -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回调】处理事件并回写审批台账-----
|
||||
}
|
||||
@@ -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 并写入 Redis(key=PREFIX_USER_TOKEN+token,value=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-----
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 '钉钉回调已处理节点数(幂等去重)';
|
||||
@@ -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 = 178046026420802,is_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');
|
||||
@@ -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(用于发起审批时解析审批人)';
|
||||
@@ -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');
|
||||
@@ -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());
|
||||
@@ -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');
|
||||
@@ -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-是)';
|
||||
@@ -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 '发起审批时业务状态原值(驳回回写依据)';
|
||||
@@ -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,未推送钉钉前为空';
|
||||
@@ -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>
|
||||
257
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
Normal file
257
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<!--
|
||||
全局「发起钉钉审批」按钮
|
||||
路由变化时检查当前页面是否配置了钉钉审批模板绑定,有则显示。
|
||||
默认停靠在列表页「查询条件」区域最右侧,可拖拽移动(位置按路由 localStorage 记忆)。
|
||||
|
||||
@author GHT
|
||||
@date 2026-06-04 for:【MESToDing审批配置】审批模板绑定-发起钉钉审批
|
||||
-->
|
||||
<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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
<!-- update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局发起审批悬浮按钮----- -->
|
||||
<ApprovalLaunchFloat />
|
||||
<!-- update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局发起审批悬浮按钮----- -->
|
||||
<!-- update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】全局钉钉审批模板绑定发起按钮----- -->
|
||||
<DingTplLaunchFloat />
|
||||
<!-- update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】全局钉钉审批模板绑定发起按钮----- -->
|
||||
<!-- update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮----- -->
|
||||
<ApprovalDesignFloat />
|
||||
<!-- update-end---author:GHT ---date:2026-05-29 for:【QH-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,
|
||||
|
||||
@@ -53,7 +53,10 @@
|
||||
</template>
|
||||
<!-- update-end---author:GHT ---date:2026-05-29 for:【QH-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);
|
||||
|
||||
@@ -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审批流设计】取单据字段抄送人摘要-----
|
||||
|
||||
31
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
Normal file
31
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
Normal 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 });
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 } });
|
||||
1132
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue
Normal file
1132
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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' });
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div>
|
||||
<BasicTable @register="registerTable" :rowSelection="rowSelection">
|
||||
<template #tableTitle>
|
||||
<!--update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】新增审批模板(创建草稿+打开设计器)-->
|
||||
<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 for:【MESToDing审批配置】新增审批模板(创建草稿+打开设计器)-->
|
||||
<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 for:【MESToDing审批配置】从钉钉同步按钮-->
|
||||
<a-button preIcon="ant-design:sync-outlined" :loading="syncLoading" @click="handleSyncFromDingtalk">
|
||||
从钉钉同步
|
||||
</a-button>
|
||||
<!--update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】从钉钉同步按钮-->
|
||||
<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 for:【MESToDing审批配置】钉钉字段详情弹窗(只读)-->
|
||||
<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 for:【MESToDing审批配置】钉钉字段详情弹窗(只读)-->
|
||||
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】表单设计器-->
|
||||
<DingTplDesigner ref="designerRef" @success="handleSuccess" />
|
||||
<DingTplCreateModal ref="createModalRef" @success="onNewTemplateCreated" />
|
||||
<!--update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】表单设计器-->
|
||||
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批-->
|
||||
<DingApprovalLaunchModal ref="launchModalRef" @success="handleLaunchSuccess" />
|
||||
<!--update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批-->
|
||||
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】钉钉同步结果弹窗-->
|
||||
<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 for:【MESToDing审批配置】钉钉同步结果弹窗-->
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
新增审批模板:创建本地草稿并可选打开表单设计器,在设计器中推送到钉钉
|
||||
@author GHT
|
||||
@date 2026-06-04 for:【MESToDing审批配置】新增审批模板入口
|
||||
-->
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Reference in New Issue
Block a user