发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
29 changed files with 3694 additions and 365 deletions
Showing only changes of commit 57bd7b5d06 - Show all commits

View File

@@ -1,27 +1,38 @@
package tech.easyflow.admin.controller.system; package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.approval.entity.ApprovalFlowScope;
import tech.easyflow.approval.entity.ApprovalFlowStepAssignee;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalScopeType;
import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result; import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree; import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept; import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService; import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Collection; import java.util.Collection;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* 部门表 控制层。 * 部门表 控制层。
@@ -33,18 +44,47 @@ import java.util.List;
@RequestMapping("/api/v1/sysDept") @RequestMapping("/api/v1/sysDept")
public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> { public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> {
@Resource private final SysAccountService sysAccountService;
private SysAccountService sysAccountService; private final ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
private final ApprovalFlowScopeMapper approvalFlowScopeMapper;
public SysDeptController(SysDeptService service) { /**
* 创建部门管理控制器。
*
* @param service 部门服务
* @param sysAccountService 用户服务
* @param approvalFlowStepAssigneeMapper 审批步骤对象 Mapper
* @param approvalFlowScopeMapper 审批范围 Mapper
*/
public SysDeptController(SysDeptService service,
SysAccountService sysAccountService,
ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper,
ApprovalFlowScopeMapper approvalFlowScopeMapper) {
super(service); super(service);
this.sysAccountService = sysAccountService;
this.approvalFlowStepAssigneeMapper = approvalFlowStepAssigneeMapper;
this.approvalFlowScopeMapper = approvalFlowScopeMapper;
} }
/**
* 获取部门列表默认排序规则。
*
* @return 默认排序表达式
*/
@Override @Override
protected String getDefaultOrderBy() { protected String getDefaultOrderBy() {
return "sort_no asc"; return "sort_no asc";
} }
/**
* 查询部门列表并组装为树形结构。
*
* @param entity 查询条件
* @param asTree 是否返回树形结构
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 部门树
*/
@Override @Override
@GetMapping("list") @GetMapping("list")
public Result<List<SysDept>> list(SysDept entity, Boolean asTree, String sortKey, String sortType) { public Result<List<SysDept>> list(SysDept entity, Boolean asTree, String sortKey, String sortType) {
@@ -54,9 +94,64 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
return Result.ok(Tree.tryToTree(sysMenus, "id", "parentId")); return Result.ok(Tree.tryToTree(sysMenus, "id", "parentId"));
} }
/**
* 批量修改部门状态。
*
* @param ids 部门主键集合
* @param status 目标状态
* @return 实际更新数量
*/
@PostMapping("changeStatusBatch")
@SaCheckPermission("/api/v1/sysDept/save")
@Transactional(rollbackFor = Exception.class)
public Result<Integer> changeStatusBatch(
@JsonBody(value = "ids", required = true) List<BigInteger> ids,
@JsonBody(value = "status", required = true) Integer status) {
Set<BigInteger> uniqueIds = normalizeIds(ids);
if (uniqueIds.isEmpty()) {
return Result.fail("请选择需要操作的部门", null);
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(status)
&& !EnumDataStatus.UNAVAILABLE.getCode().equals(status)) {
return Result.fail("部门状态不合法", null);
}
List<SysDept> records = service.listByIds(uniqueIds);
if (records.size() != uniqueIds.size()) {
return Result.fail("部分部门不存在或已删除,请刷新后重试", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status) && containsRootDept(records)) {
return Result.fail("根部门不能禁用", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status)
&& isUsedByApprovalFlow(uniqueIds)) {
return Result.fail("所选部门已被审批流程使用,不能禁用", null);
}
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysDept update = new SysDept();
update.setStatus(status);
update.setModified(new Date());
update.setModifiedBy(loginUser.getId());
QueryWrapper updateWrapper = QueryWrapper.create();
updateWrapper.in(SysDept::getId, uniqueIds);
int updated = service.getMapper().updateByQuery(update, updateWrapper);
if (updated <= 0) {
return Result.fail("部门状态修改失败", null);
}
return Result.ok(updated);
}
/**
* {@inheritDoc}
*/
@Override @Override
protected Result onSaveOrUpdateBefore(SysDept entity, boolean isSave) { protected Result onSaveOrUpdateBefore(SysDept entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount(); LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave && entity.getStatus() == null) {
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
}
BigInteger parentId = entity.getParentId(); BigInteger parentId = entity.getParentId();
if (parentId.equals(BigInteger.ZERO)) { if (parentId.equals(BigInteger.ZERO)) {
entity.setAncestors(parentId.toString()); entity.setAncestors(parentId.toString());
@@ -65,7 +160,7 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
entity.setAncestors(parent.getAncestors() + "," + parentId); entity.setAncestors(parent.getAncestors() + "," + parentId);
} }
if (isSave) { if (isSave) {
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId()); commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
} else { } else {
entity.setModified(new Date()); entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId()); entity.setModifiedBy(loginUser.getId());
@@ -73,20 +168,87 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
return null; return null;
} }
/**
* {@inheritDoc}
*/
@Override @Override
protected Result onRemoveBefore(Collection<Serializable> ids) { protected Result onRemoveBefore(Collection<Serializable> ids) {
List<SysDept> records = service.listByIds(ids); List<SysDept> records = service.listByIds(ids);
for (SysDept dept : records) { if (records.size() != ids.size()) {
if (Constants.ROOT_DEPT.equals(dept.getDeptCode())) { return Result.fail(1, "部分部门不存在或已删除,请刷新后重试");
}
if (containsRootDept(records)) {
return Result.fail(1, "无法删除根部门"); return Result.fail(1, "无法删除根部门");
} }
QueryWrapper childQuery = QueryWrapper.create();
childQuery.in(SysDept::getParentId, ids);
childQuery.notIn(SysDept::getId, ids);
if (service.count(childQuery) > 0) {
return Result.fail(1, "所选部门包含未选中的下级部门,不能删除");
} }
QueryWrapper w = QueryWrapper.create(); if (isUsedByApprovalFlow(ids)) {
w.in(SysAccount::getDeptId, ids); return Result.fail(1, "所选部门已被审批流程使用,请先调整审批配置");
long count = sysAccountService.count(w); }
QueryWrapper accountQuery = QueryWrapper.create();
accountQuery.in(SysAccount::getDeptId, ids);
long count = sysAccountService.count(accountQuery);
if (count > 0) { if (count > 0) {
return Result.fail(1, "部门下有员工,不能删除"); return Result.fail(1, "所选部门下有员工,不能删除");
} }
return super.onRemoveBefore(ids); return super.onRemoveBefore(ids);
} }
/**
* 去重并过滤无效部门主键。
*
* @param ids 原始部门主键集合
* @return 有效且去重后的主键集合
*/
private Set<BigInteger> normalizeIds(Collection<BigInteger> ids) {
Set<BigInteger> uniqueIds = new LinkedHashSet<>();
if (ids == null) {
return uniqueIds;
}
for (BigInteger id : ids) {
if (id != null) {
uniqueIds.add(id);
}
}
return uniqueIds;
}
/**
* 判断部门集合中是否包含根部门。
*
* @param records 部门集合
* @return 包含根部门时返回 {@code true}
*/
private boolean containsRootDept(Collection<SysDept> records) {
return records.stream()
.anyMatch(dept -> Constants.ROOT_DEPT.equals(dept.getDeptCode()));
}
/**
* 判断部门是否仍被审批步骤或审批范围引用。
*
* @param ids 部门主键集合
* @return 存在审批流程引用时返回 {@code true}
*/
private boolean isUsedByApprovalFlow(Collection<? extends Serializable> ids) {
QueryWrapper assigneeQuery = QueryWrapper.create();
assigneeQuery.eq(
ApprovalFlowStepAssignee::getAssigneeType,
ApprovalAssigneeType.DEPT.getCode());
assigneeQuery.in(ApprovalFlowStepAssignee::getTargetId, ids);
if (approvalFlowStepAssigneeMapper.selectCountByQuery(assigneeQuery) > 0) {
return true;
}
QueryWrapper scopeQuery = QueryWrapper.create();
scopeQuery.eq(ApprovalFlowScope::getScopeType, ApprovalScopeType.DEPT.getCode());
scopeQuery.in(ApprovalFlowScope::getScopeValue, ids);
return approvalFlowScopeMapper.selectCountByQuery(scopeQuery) > 0;
}
} }

View File

@@ -0,0 +1,247 @@
package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.testng.annotations.Test;
import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.mapper.SysDeptMapper;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
/**
* {@link SysDeptController} 部门状态保存测试。
*/
public class SysDeptControllerTest {
/**
* 验证批量状态修改使用单次更新并写入审计字段。
*/
@Test
public void changeStatusBatchShouldUpdateAllDepartmentsOnce() {
SysDeptService service = mock(SysDeptService.class);
SysDeptMapper mapper = mock(SysDeptMapper.class);
SysDeptController controller = createController(service);
List<BigInteger> ids = List.of(BigInteger.ONE, BigInteger.TWO);
when(service.listByIds(anyCollection()))
.thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1"), buildDept(BigInteger.TWO, "dept_2")));
when(service.getMapper()).thenReturn(mapper);
when(mapper.updateByQuery(any(SysDept.class), any(QueryWrapper.class))).thenReturn(2);
LoginAccount loginAccount = mock(LoginAccount.class);
when(loginAccount.getId()).thenReturn(BigInteger.TEN);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
Result<Integer> result = controller.changeStatusBatch(ids, EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(result.getErrorCode(), 0);
assertEquals(result.getData(), Integer.valueOf(2));
}
ArgumentCaptor<SysDept> updateCaptor = ArgumentCaptor.forClass(SysDept.class);
verify(mapper).updateByQuery(updateCaptor.capture(), any(QueryWrapper.class));
assertEquals(updateCaptor.getValue().getStatus(), EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(updateCaptor.getValue().getModifiedBy(), BigInteger.TEN);
}
/**
* 验证根部门不能通过批量接口禁用。
*/
@Test
public void changeStatusBatchShouldRejectRootDepartmentDisable() {
SysDeptService service = mock(SysDeptService.class);
SysDeptMapper mapper = mock(SysDeptMapper.class);
SysDeptController controller = createController(service);
SysDept root = buildDept(BigInteger.ONE, Constants.ROOT_DEPT);
when(service.listByIds(anyCollection())).thenReturn(List.of(root));
when(service.getMapper()).thenReturn(mapper);
Result<Integer> result = controller.changeStatusBatch(
List.of(BigInteger.ONE),
EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "根部门不能禁用");
verify(mapper, never()).updateByQuery(any(SysDept.class), any(QueryWrapper.class));
}
/**
* 验证批量状态修改拒绝非法状态。
*/
@Test
public void changeStatusBatchShouldRejectInvalidStatus() {
SysDeptService service = mock(SysDeptService.class);
SysDeptController controller = createController(service);
Result<Integer> result = controller.changeStatusBatch(List.of(BigInteger.ONE), 2);
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "部门状态不合法");
verify(service, never()).listByIds(anyCollection());
}
/**
* 验证存在未选中下级部门时禁止批量删除。
*/
@Test
public void removeBatchShouldRejectUnselectedChildDepartment() {
SysDeptService service = mock(SysDeptService.class);
SysDeptController controller = createController(service);
Collection<Serializable> ids = List.of(BigInteger.ONE);
when(service.listByIds(ids)).thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
when(service.count(any(QueryWrapper.class))).thenReturn(1L);
Result<?> result = controller.onRemoveBefore(ids);
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "所选部门包含未选中的下级部门,不能删除");
}
/**
* 验证审批步骤引用的部门不能禁用。
*/
@Test
public void changeStatusBatchShouldRejectApprovalStepReference() {
SysDeptService service = mock(SysDeptService.class);
ApprovalFlowStepAssigneeMapper assigneeMapper = mock(ApprovalFlowStepAssigneeMapper.class);
SysDeptController controller = new SysDeptController(
service,
mock(SysAccountService.class),
assigneeMapper,
mock(ApprovalFlowScopeMapper.class));
when(service.listByIds(anyCollection()))
.thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
when(assigneeMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
Result<Integer> result = controller.changeStatusBatch(
List.of(BigInteger.ONE),
EnumDataStatus.UNAVAILABLE.getCode());
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "所选部门已被审批流程使用,不能禁用");
}
/**
* 验证审批范围引用的部门不能删除。
*/
@Test
public void removeBatchShouldRejectApprovalScopeReference() {
SysDeptService service = mock(SysDeptService.class);
ApprovalFlowScopeMapper scopeMapper = mock(ApprovalFlowScopeMapper.class);
SysDeptController controller = new SysDeptController(
service,
mock(SysAccountService.class),
mock(ApprovalFlowStepAssigneeMapper.class),
scopeMapper);
Collection<Serializable> ids = List.of(BigInteger.ONE);
when(service.listByIds(ids)).thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
when(service.count(any(QueryWrapper.class))).thenReturn(0L);
when(scopeMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
Result<?> result = controller.onRemoveBefore(ids);
assertEquals(result.getErrorCode(), 1);
assertEquals(result.getMessage(), "所选部门已被审批流程使用,请先调整审批配置");
}
/**
* 验证新增部门未传状态时默认启用。
*/
@Test
public void saveShouldDefaultMissingStatusToAvailable() {
SysDept entity = buildDept(null);
invokeSaveBefore(entity);
assertEquals(entity.getStatus(), EnumDataStatus.AVAILABLE.getCode());
}
/**
* 验证新增部门显式传入未启用状态时保持原值。
*/
@Test
public void saveShouldPreserveExplicitUnavailableStatus() {
SysDept entity = buildDept(EnumDataStatus.UNAVAILABLE.getCode());
invokeSaveBefore(entity);
assertEquals(entity.getStatus(), EnumDataStatus.UNAVAILABLE.getCode());
}
/**
* 构造待保存的部门。
*
* @param status 部门状态
* @return 部门实体
*/
private SysDept buildDept(Integer status) {
SysDept entity = new SysDept();
entity.setParentId(BigInteger.ZERO);
entity.setStatus(status);
return entity;
}
/**
* 构造指定主键和编码的部门。
*
* @param id 部门主键
* @param deptCode 部门编码
* @return 部门实体
*/
private SysDept buildDept(BigInteger id, String deptCode) {
SysDept entity = new SysDept();
entity.setId(id);
entity.setDeptCode(deptCode);
return entity;
}
/**
* 调用新增前置处理。
*
* @param entity 部门实体
*/
private void invokeSaveBefore(SysDept entity) {
LoginAccount loginAccount = mock(LoginAccount.class);
SysDeptController controller = createController(mock(SysDeptService.class));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.onSaveOrUpdateBefore(entity, true);
}
}
/**
* 创建带有隔离依赖的部门控制器。
*
* @param service 部门服务
* @return 部门控制器
*/
private SysDeptController createController(SysDeptService service) {
return new SysDeptController(
service,
mock(SysAccountService.class),
mock(ApprovalFlowStepAssigneeMapper.class),
mock(ApprovalFlowScopeMapper.class));
}
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.approval.entity;
import com.mybatisflex.annotation.Table;
import tech.easyflow.approval.entity.base.ApprovalFlowStepAssigneeBase;
/**
* 审批流程步骤对象关联实体。
*/
@Table("tb_approval_flow_step_assignee")
public class ApprovalFlowStepAssignee extends ApprovalFlowStepAssigneeBase {
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.approval.entity;
import com.mybatisflex.annotation.Table;
import tech.easyflow.approval.entity.base.ApprovalTaskAssigneeBase;
/**
* 审批任务对象关联实体。
*/
@Table("tb_approval_task_assignee")
public class ApprovalTaskAssignee extends ApprovalTaskAssigneeBase {
}

View File

@@ -0,0 +1,248 @@
package tech.easyflow.approval.entity.base;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 审批流程步骤对象关联基础字段。
*/
public class ApprovalFlowStepAssigneeBase implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键")
private BigInteger id;
@Column(comment = "审批步骤ID")
private BigInteger stepId;
@Column(comment = "审批对象类型")
private String assigneeType;
@Column(comment = "审批对象ID")
private BigInteger targetId;
@Column(comment = "审批对象编码")
private String targetCode;
@Column(comment = "审批对象名称")
private String targetName;
@Column(comment = "是否包含子部门")
private Integer includeChildren;
@Column(comment = "创建时间")
private Date created;
@Column(comment = "创建者")
private BigInteger createdBy;
@Column(comment = "修改时间")
private Date modified;
@Column(comment = "修改者")
private BigInteger modifiedBy;
/**
* 获取主键。
*
* @return 主键
*/
public BigInteger getId() {
return id;
}
/**
* 设置主键。
*
* @param id 主键
*/
public void setId(BigInteger id) {
this.id = id;
}
/**
* 获取审批步骤 ID。
*
* @return 审批步骤 ID
*/
public BigInteger getStepId() {
return stepId;
}
/**
* 设置审批步骤 ID。
*
* @param stepId 审批步骤 ID
*/
public void setStepId(BigInteger stepId) {
this.stepId = stepId;
}
/**
* 获取审批对象类型。
*
* @return 审批对象类型
*/
public String getAssigneeType() {
return assigneeType;
}
/**
* 设置审批对象类型。
*
* @param assigneeType 审批对象类型
*/
public void setAssigneeType(String assigneeType) {
this.assigneeType = assigneeType;
}
/**
* 获取审批对象 ID。
*
* @return 审批对象 ID
*/
public BigInteger getTargetId() {
return targetId;
}
/**
* 设置审批对象 ID。
*
* @param targetId 审批对象 ID
*/
public void setTargetId(BigInteger targetId) {
this.targetId = targetId;
}
/**
* 获取审批对象编码。
*
* @return 审批对象编码
*/
public String getTargetCode() {
return targetCode;
}
/**
* 设置审批对象编码。
*
* @param targetCode 审批对象编码
*/
public void setTargetCode(String targetCode) {
this.targetCode = targetCode;
}
/**
* 获取审批对象名称。
*
* @return 审批对象名称
*/
public String getTargetName() {
return targetName;
}
/**
* 设置审批对象名称。
*
* @param targetName 审批对象名称
*/
public void setTargetName(String targetName) {
this.targetName = targetName;
}
/**
* 获取是否包含子部门。
*
* @return 1 表示包含0 表示不包含
*/
public Integer getIncludeChildren() {
return includeChildren;
}
/**
* 设置是否包含子部门。
*
* @param includeChildren 1 表示包含0 表示不包含
*/
public void setIncludeChildren(Integer includeChildren) {
this.includeChildren = includeChildren;
}
/**
* 获取创建时间。
*
* @return 创建时间
*/
public Date getCreated() {
return created;
}
/**
* 设置创建时间。
*
* @param created 创建时间
*/
public void setCreated(Date created) {
this.created = created;
}
/**
* 获取创建者。
*
* @return 创建者 ID
*/
public BigInteger getCreatedBy() {
return createdBy;
}
/**
* 设置创建者。
*
* @param createdBy 创建者 ID
*/
public void setCreatedBy(BigInteger createdBy) {
this.createdBy = createdBy;
}
/**
* 获取修改时间。
*
* @return 修改时间
*/
public Date getModified() {
return modified;
}
/**
* 设置修改时间。
*
* @param modified 修改时间
*/
public void setModified(Date modified) {
this.modified = modified;
}
/**
* 获取修改者。
*
* @return 修改者 ID
*/
public BigInteger getModifiedBy() {
return modifiedBy;
}
/**
* 设置修改者。
*
* @param modifiedBy 修改者 ID
*/
public void setModifiedBy(BigInteger modifiedBy) {
this.modifiedBy = modifiedBy;
}
}

View File

@@ -0,0 +1,248 @@
package tech.easyflow.approval.entity.base;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 审批任务对象关联基础字段。
*/
public class ApprovalTaskAssigneeBase implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键")
private BigInteger id;
@Column(comment = "审批任务ID")
private BigInteger taskId;
@Column(comment = "审批对象类型")
private String assigneeType;
@Column(comment = "审批对象ID")
private BigInteger targetId;
@Column(comment = "审批对象编码")
private String targetCode;
@Column(comment = "审批对象名称")
private String targetName;
@Column(comment = "是否包含子部门")
private Integer includeChildren;
@Column(comment = "创建时间")
private Date created;
@Column(comment = "创建者")
private BigInteger createdBy;
@Column(comment = "修改时间")
private Date modified;
@Column(comment = "修改者")
private BigInteger modifiedBy;
/**
* 获取主键。
*
* @return 主键
*/
public BigInteger getId() {
return id;
}
/**
* 设置主键。
*
* @param id 主键
*/
public void setId(BigInteger id) {
this.id = id;
}
/**
* 获取审批任务 ID。
*
* @return 审批任务 ID
*/
public BigInteger getTaskId() {
return taskId;
}
/**
* 设置审批任务 ID。
*
* @param taskId 审批任务 ID
*/
public void setTaskId(BigInteger taskId) {
this.taskId = taskId;
}
/**
* 获取审批对象类型。
*
* @return 审批对象类型
*/
public String getAssigneeType() {
return assigneeType;
}
/**
* 设置审批对象类型。
*
* @param assigneeType 审批对象类型
*/
public void setAssigneeType(String assigneeType) {
this.assigneeType = assigneeType;
}
/**
* 获取审批对象 ID。
*
* @return 审批对象 ID
*/
public BigInteger getTargetId() {
return targetId;
}
/**
* 设置审批对象 ID。
*
* @param targetId 审批对象 ID
*/
public void setTargetId(BigInteger targetId) {
this.targetId = targetId;
}
/**
* 获取审批对象编码。
*
* @return 审批对象编码
*/
public String getTargetCode() {
return targetCode;
}
/**
* 设置审批对象编码。
*
* @param targetCode 审批对象编码
*/
public void setTargetCode(String targetCode) {
this.targetCode = targetCode;
}
/**
* 获取审批对象名称。
*
* @return 审批对象名称
*/
public String getTargetName() {
return targetName;
}
/**
* 设置审批对象名称。
*
* @param targetName 审批对象名称
*/
public void setTargetName(String targetName) {
this.targetName = targetName;
}
/**
* 获取是否包含子部门。
*
* @return 1 表示包含0 表示不包含
*/
public Integer getIncludeChildren() {
return includeChildren;
}
/**
* 设置是否包含子部门。
*
* @param includeChildren 1 表示包含0 表示不包含
*/
public void setIncludeChildren(Integer includeChildren) {
this.includeChildren = includeChildren;
}
/**
* 获取创建时间。
*
* @return 创建时间
*/
public Date getCreated() {
return created;
}
/**
* 设置创建时间。
*
* @param created 创建时间
*/
public void setCreated(Date created) {
this.created = created;
}
/**
* 获取创建者。
*
* @return 创建者 ID
*/
public BigInteger getCreatedBy() {
return createdBy;
}
/**
* 设置创建者。
*
* @param createdBy 创建者 ID
*/
public void setCreatedBy(BigInteger createdBy) {
this.createdBy = createdBy;
}
/**
* 获取修改时间。
*
* @return 修改时间
*/
public Date getModified() {
return modified;
}
/**
* 设置修改时间。
*
* @param modified 修改时间
*/
public void setModified(Date modified) {
this.modified = modified;
}
/**
* 获取修改者。
*
* @return 修改者 ID
*/
public BigInteger getModifiedBy() {
return modifiedBy;
}
/**
* 设置修改者。
*
* @param modifiedBy 修改者 ID
*/
public void setModifiedBy(BigInteger modifiedBy) {
this.modifiedBy = modifiedBy;
}
}

View File

@@ -0,0 +1,89 @@
package tech.easyflow.approval.entity.vo;
import java.math.BigInteger;
/**
* 审批对象目标。
*/
public class ApprovalAssigneeTargetVo {
private BigInteger targetId;
private String targetCode;
private String targetName;
private Integer includeChildren;
/**
* 获取审批对象 ID。
*
* @return 审批对象 ID
*/
public BigInteger getTargetId() {
return targetId;
}
/**
* 设置审批对象 ID。
*
* @param targetId 审批对象 ID
*/
public void setTargetId(BigInteger targetId) {
this.targetId = targetId;
}
/**
* 获取审批对象编码。
*
* @return 审批对象编码
*/
public String getTargetCode() {
return targetCode;
}
/**
* 设置审批对象编码。
*
* @param targetCode 审批对象编码
*/
public void setTargetCode(String targetCode) {
this.targetCode = targetCode;
}
/**
* 获取审批对象名称。
*
* @return 审批对象名称
*/
public String getTargetName() {
return targetName;
}
/**
* 设置审批对象名称。
*
* @param targetName 审批对象名称
*/
public void setTargetName(String targetName) {
this.targetName = targetName;
}
/**
* 获取是否包含子部门。
*
* @return 1 表示包含0 表示不包含
*/
public Integer getIncludeChildren() {
return includeChildren;
}
/**
* 设置是否包含子部门。
*
* @param includeChildren 1 表示包含0 表示不包含
*/
public void setIncludeChildren(Integer includeChildren) {
this.includeChildren = includeChildren;
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.approval.entity.vo; package tech.easyflow.approval.entity.vo;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.List;
/** /**
* 审批流程步骤项。 * 审批流程步骤项。
@@ -21,6 +22,8 @@ public class ApprovalFlowStepVo {
private String assigneeTargetName; private String assigneeTargetName;
private List<ApprovalAssigneeTargetVo> assigneeTargets;
public BigInteger getId() { public BigInteger getId() {
return id; return id;
} }
@@ -76,4 +79,22 @@ public class ApprovalFlowStepVo {
public void setAssigneeTargetName(String assigneeTargetName) { public void setAssigneeTargetName(String assigneeTargetName) {
this.assigneeTargetName = assigneeTargetName; this.assigneeTargetName = assigneeTargetName;
} }
/**
* 获取审批对象列表。
*
* @return 审批对象列表
*/
public List<ApprovalAssigneeTargetVo> getAssigneeTargets() {
return assigneeTargets;
}
/**
* 设置审批对象列表。
*
* @param assigneeTargets 审批对象列表
*/
public void setAssigneeTargets(List<ApprovalAssigneeTargetVo> assigneeTargets) {
this.assigneeTargets = assigneeTargets;
}
} }

View File

@@ -2,6 +2,7 @@ package tech.easyflow.approval.entity.vo;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Date; import java.util.Date;
import java.util.List;
/** /**
* 审批任务视图。 * 审批任务视图。
@@ -31,6 +32,8 @@ public class ApprovalTaskVo {
private String assigneeTargetName; private String assigneeTargetName;
private List<ApprovalAssigneeTargetVo> assigneeTargets;
private BigInteger actedBy; private BigInteger actedBy;
private String actedByName; private String actedByName;
@@ -129,6 +132,24 @@ public class ApprovalTaskVo {
this.assigneeTargetName = assigneeTargetName; this.assigneeTargetName = assigneeTargetName;
} }
/**
* 获取审批对象列表。
*
* @return 审批对象列表
*/
public List<ApprovalAssigneeTargetVo> getAssigneeTargets() {
return assigneeTargets;
}
/**
* 设置审批对象列表。
*
* @param assigneeTargets 审批对象列表
*/
public void setAssigneeTargets(List<ApprovalAssigneeTargetVo> assigneeTargets) {
this.assigneeTargets = assigneeTargets;
}
public BigInteger getActedBy() { public BigInteger getActedBy() {
return actedBy; return actedBy;
} }

View File

@@ -9,7 +9,8 @@ import tech.easyflow.common.web.exceptions.BusinessException;
public enum ApprovalAssigneeType { public enum ApprovalAssigneeType {
ROLE("ROLE"), ROLE("ROLE"),
USER("USER"); USER("USER"),
DEPT("DEPT");
private final String code; private final String code;

View File

@@ -0,0 +1,10 @@
package tech.easyflow.approval.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.approval.entity.ApprovalFlowStepAssignee;
/**
* 审批流程步骤对象关联 Mapper。
*/
public interface ApprovalFlowStepAssigneeMapper extends BaseMapper<ApprovalFlowStepAssignee> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.approval.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.approval.entity.ApprovalTaskAssignee;
/**
* 审批任务对象关联 Mapper。
*/
public interface ApprovalTaskAssigneeMapper extends BaseMapper<ApprovalTaskAssignee> {
}

View File

@@ -1,12 +1,16 @@
package tech.easyflow.approval.service; package tech.easyflow.approval.service;
import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.paginate.Page;
import tech.easyflow.approval.entity.ApprovalFlowStep;
import tech.easyflow.approval.entity.ApprovalTask; import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo; import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo; import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
@@ -22,6 +26,53 @@ public interface ApprovalAssigneeService {
*/ */
ApprovalFlowStepVo normalizeStepAssignee(ApprovalFlowStepVo step); ApprovalFlowStepVo normalizeStepAssignee(ApprovalFlowStepVo step);
/**
* 批量加载流程步骤的审批对象,关联记录缺失时兼容旧单值字段。
*
* @param steps 流程步骤
* @return 步骤 ID 到审批对象列表的映射
*/
Map<BigInteger, List<ApprovalAssigneeTargetVo>> loadStepAssigneeTargets(List<ApprovalFlowStep> steps);
/**
* 批量加载审批任务的审批对象,关联记录缺失时兼容旧单值字段。
*
* @param tasks 审批任务
* @return 任务 ID 到审批对象列表的映射
*/
Map<BigInteger, List<ApprovalAssigneeTargetVo>> loadTaskAssigneeTargets(List<ApprovalTask> tasks);
/**
* 保存流程步骤审批对象。
*
* @param stepId 流程步骤 ID
* @param assigneeType 审批对象类型
* @param targets 审批对象列表
* @param operatorId 操作人 ID
* @param now 操作时间
*/
void saveStepAssigneeTargets(BigInteger stepId, String assigneeType, List<ApprovalAssigneeTargetVo> targets,
BigInteger operatorId, Date now);
/**
* 删除指定流程步骤的审批对象。
*
* @param stepIds 流程步骤 ID
*/
void deleteStepAssigneeTargets(List<BigInteger> stepIds);
/**
* 保存任务冻结的审批对象。
*
* @param taskId 审批任务 ID
* @param assigneeType 审批对象类型
* @param targets 审批对象列表
* @param operatorId 操作人 ID
* @param now 操作时间
*/
void saveTaskAssigneeTargets(BigInteger taskId, String assigneeType, List<ApprovalAssigneeTargetVo> targets,
BigInteger operatorId, Date now);
/** /**
* 查询可用角色选项。 * 查询可用角色选项。
* *

View File

@@ -5,25 +5,39 @@ import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import tech.easyflow.approval.entity.ApprovalFlowStep;
import tech.easyflow.approval.entity.ApprovalFlowStepAssignee;
import tech.easyflow.approval.entity.ApprovalTask; import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.ApprovalTaskAssignee;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo; import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo; import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
import tech.easyflow.approval.enums.ApprovalAssigneeType; import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalTaskStatus; import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.approval.mapper.ApprovalTaskAssigneeMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper; import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalAssigneeService; import tech.easyflow.approval.service.ApprovalAssigneeService;
import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.system.service.SysRoleService; import tech.easyflow.system.service.SysRoleService;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -38,9 +52,18 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
@Resource @Resource
private SysAccountService sysAccountService; private SysAccountService sysAccountService;
@Resource
private SysDeptService sysDeptService;
@Resource @Resource
private ApprovalTaskMapper approvalTaskMapper; private ApprovalTaskMapper approvalTaskMapper;
@Resource
private ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
@Resource
private ApprovalTaskAssigneeMapper approvalTaskAssigneeMapper;
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@@ -50,18 +73,168 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
throw new BusinessException("审批步骤不能为空"); throw new BusinessException("审批步骤不能为空");
} }
ApprovalAssigneeType assigneeType = ApprovalAssigneeType.from(step.getAssigneeType()); ApprovalAssigneeType assigneeType = ApprovalAssigneeType.from(step.getAssigneeType());
BigInteger targetId = step.getAssigneeTargetId(); List<ApprovalAssigneeTargetVo> requestedTargets = step.getAssigneeTargets();
if (targetId == null) { if (CollectionUtil.isEmpty(requestedTargets) && step.getAssigneeTargetId() != null) {
requestedTargets = List.of(legacyTarget(
step.getAssigneeTargetId(),
step.getAssigneeTargetCode(),
step.getAssigneeTargetName()));
}
if (CollectionUtil.isEmpty(requestedTargets)) {
throw new BusinessException("审批对象不能为空"); throw new BusinessException("审批对象不能为空");
} }
ApprovalAssigneeOptionVo option = resolveAssigneeOption(assigneeType, targetId);
Map<BigInteger, ApprovalAssigneeTargetVo> normalizedTargets = new LinkedHashMap<>();
for (ApprovalAssigneeTargetVo target : requestedTargets) {
if (target == null || target.getTargetId() == null) {
throw new BusinessException("审批对象不能为空");
}
ApprovalAssigneeOptionVo option = resolveAssigneeOption(assigneeType, target.getTargetId());
ApprovalAssigneeTargetVo normalizedTarget = new ApprovalAssigneeTargetVo();
normalizedTarget.setTargetId(option.getId());
normalizedTarget.setTargetCode(option.getCode());
normalizedTarget.setTargetName(option.getName());
normalizedTarget.setIncludeChildren(ApprovalAssigneeType.DEPT == assigneeType
&& Integer.valueOf(1).equals(target.getIncludeChildren()) ? 1 : 0);
normalizedTargets.putIfAbsent(normalizedTarget.getTargetId(), normalizedTarget);
}
step.setAssigneeType(assigneeType.getCode()); step.setAssigneeType(assigneeType.getCode());
step.setAssigneeTargetId(option.getId()); step.setAssigneeTargets(new ArrayList<>(normalizedTargets.values()));
step.setAssigneeTargetCode(option.getCode()); applyPrimaryTarget(step);
step.setAssigneeTargetName(option.getName());
return step; return step;
} }
/**
* {@inheritDoc}
*/
@Override
public Map<BigInteger, List<ApprovalAssigneeTargetVo>> loadStepAssigneeTargets(List<ApprovalFlowStep> steps) {
if (CollectionUtil.isEmpty(steps)) {
return Map.of();
}
List<BigInteger> stepIds = steps.stream()
.map(ApprovalFlowStep::getId)
.filter(Objects::nonNull)
.collect(Collectors.toList());
List<ApprovalFlowStepAssignee> assignees = stepIds.isEmpty() ? List.of() : safeList(
approvalFlowStepAssigneeMapper.selectListByQuery(QueryWrapper.create()
.in(ApprovalFlowStepAssignee::getStepId, stepIds)
.orderBy("created asc, id asc")));
Map<BigInteger, List<ApprovalFlowStepAssignee>> relationMap = assignees.stream()
.collect(Collectors.groupingBy(
ApprovalFlowStepAssignee::getStepId,
LinkedHashMap::new,
Collectors.toList()));
Map<BigInteger, List<ApprovalAssigneeTargetVo>> result = new LinkedHashMap<>();
for (ApprovalFlowStep step : steps) {
List<ApprovalAssigneeTargetVo> targets = relationMap.getOrDefault(step.getId(), List.of()).stream()
.filter(item -> Objects.equals(step.getAssigneeType(), item.getAssigneeType()))
.map(this::toTarget)
.collect(Collectors.toList());
if (targets.isEmpty()) {
targets = legacyTargets(step.getAssigneeTargetId(), step.getAssigneeTargetCode(),
step.getAssigneeTargetName());
}
result.put(step.getId(), targets);
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public Map<BigInteger, List<ApprovalAssigneeTargetVo>> loadTaskAssigneeTargets(List<ApprovalTask> tasks) {
if (CollectionUtil.isEmpty(tasks)) {
return Map.of();
}
List<BigInteger> taskIds = tasks.stream()
.map(ApprovalTask::getId)
.filter(Objects::nonNull)
.collect(Collectors.toList());
List<ApprovalTaskAssignee> assignees = taskIds.isEmpty() ? List.of() : safeList(
approvalTaskAssigneeMapper.selectListByQuery(QueryWrapper.create()
.in(ApprovalTaskAssignee::getTaskId, taskIds)
.orderBy("created asc, id asc")));
Map<BigInteger, List<ApprovalTaskAssignee>> relationMap = assignees.stream()
.collect(Collectors.groupingBy(
ApprovalTaskAssignee::getTaskId,
LinkedHashMap::new,
Collectors.toList()));
Map<BigInteger, List<ApprovalAssigneeTargetVo>> result = new LinkedHashMap<>();
for (ApprovalTask task : tasks) {
List<ApprovalAssigneeTargetVo> targets = relationMap.getOrDefault(task.getId(), List.of()).stream()
.filter(item -> Objects.equals(task.getAssigneeType(), item.getAssigneeType()))
.map(this::toTarget)
.collect(Collectors.toList());
if (targets.isEmpty()) {
targets = legacyTargets(task.getAssigneeTargetId(), task.getAssigneeTargetCode(),
task.getAssigneeTargetName());
}
result.put(task.getId(), targets);
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public void saveStepAssigneeTargets(BigInteger stepId, String assigneeType,
List<ApprovalAssigneeTargetVo> targets,
BigInteger operatorId, Date now) {
for (ApprovalAssigneeTargetVo target : requireTargets(targets)) {
ApprovalFlowStepAssignee relation = new ApprovalFlowStepAssignee();
relation.setStepId(stepId);
relation.setAssigneeType(assigneeType);
relation.setTargetId(target.getTargetId());
relation.setTargetCode(target.getTargetCode());
relation.setTargetName(target.getTargetName());
relation.setIncludeChildren(normalizeIncludeChildren(assigneeType, target.getIncludeChildren()));
relation.setCreated(now);
relation.setCreatedBy(operatorId);
relation.setModified(now);
relation.setModifiedBy(operatorId);
approvalFlowStepAssigneeMapper.insert(relation);
}
}
/**
* {@inheritDoc}
*/
@Override
public void deleteStepAssigneeTargets(List<BigInteger> stepIds) {
if (CollectionUtil.isEmpty(stepIds)) {
return;
}
approvalFlowStepAssigneeMapper.deleteByQuery(
QueryWrapper.create().in(ApprovalFlowStepAssignee::getStepId, stepIds));
}
/**
* {@inheritDoc}
*/
@Override
public void saveTaskAssigneeTargets(BigInteger taskId, String assigneeType,
List<ApprovalAssigneeTargetVo> targets,
BigInteger operatorId, Date now) {
for (ApprovalAssigneeTargetVo target : requireTargets(targets)) {
ApprovalTaskAssignee relation = new ApprovalTaskAssignee();
relation.setTaskId(taskId);
relation.setAssigneeType(assigneeType);
relation.setTargetId(target.getTargetId());
relation.setTargetCode(target.getTargetCode());
relation.setTargetName(target.getTargetName());
relation.setIncludeChildren(normalizeIncludeChildren(assigneeType, target.getIncludeChildren()));
relation.setCreated(now);
relation.setCreatedBy(operatorId);
relation.setModified(now);
relation.setModifiedBy(operatorId);
approvalTaskAssigneeMapper.insert(relation);
}
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@@ -118,46 +291,210 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
return false; return false;
} }
ApprovalAssigneeType assigneeType = ApprovalAssigneeType.from(task.getAssigneeType()); ApprovalAssigneeType assigneeType = ApprovalAssigneeType.from(task.getAssigneeType());
List<ApprovalAssigneeTargetVo> targets = loadTaskAssigneeTargets(List.of(task))
.getOrDefault(task.getId(), List.of());
if (ApprovalAssigneeType.USER == assigneeType) { if (ApprovalAssigneeType.USER == assigneeType) {
return operatorId.equals(task.getAssigneeTargetId()); return targets.stream().anyMatch(target -> operatorId.equals(target.getTargetId()));
} }
return roleIds != null && roleIds.contains(task.getAssigneeTargetId()); if (ApprovalAssigneeType.ROLE == assigneeType) {
return roleIds != null && targets.stream().anyMatch(target -> roleIds.contains(target.getTargetId()));
}
SysAccount operator = sysAccountService.getById(operatorId);
return operator != null && matchesDepartment(targets, operator.getDeptId());
} }
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @Override
public Set<BigInteger> listPendingInstanceIds(BigInteger operatorId, Set<BigInteger> roleIds, List<BigInteger> instanceIds) { public Set<BigInteger> listPendingInstanceIds(BigInteger operatorId, Set<BigInteger> roleIds,
List<BigInteger> instanceIds) {
if (operatorId == null) { if (operatorId == null) {
return Set.of(); return Set.of();
} }
Set<BigInteger> result = new LinkedHashSet<>(); Set<BigInteger> matchedTaskIds = new LinkedHashSet<>();
QueryWrapper userQuery = QueryWrapper.create() matchedTaskIds.addAll(loadMatchingRelationTaskIds(
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode()); ApprovalAssigneeType.USER, Set.of(operatorId), null, instanceIds));
if (CollectionUtil.isNotEmpty(instanceIds)) {
userQuery.in(ApprovalTask::getInstanceId, instanceIds);
}
userQuery.eq(ApprovalTask::getAssigneeType, ApprovalAssigneeType.USER.getCode())
.eq(ApprovalTask::getAssigneeTargetId, operatorId);
result.addAll(approvalTaskMapper.selectListByQuery(userQuery).stream()
.map(ApprovalTask::getInstanceId)
.collect(Collectors.toCollection(LinkedHashSet::new)));
if (CollectionUtil.isNotEmpty(roleIds)) { if (CollectionUtil.isNotEmpty(roleIds)) {
QueryWrapper roleQuery = QueryWrapper.create() matchedTaskIds.addAll(loadMatchingRelationTaskIds(
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode()) ApprovalAssigneeType.ROLE, roleIds, null, instanceIds));
.eq(ApprovalTask::getAssigneeType, ApprovalAssigneeType.ROLE.getCode())
.in(ApprovalTask::getAssigneeTargetId, roleIds);
if (CollectionUtil.isNotEmpty(instanceIds)) {
roleQuery.in(ApprovalTask::getInstanceId, instanceIds);
} }
result.addAll(approvalTaskMapper.selectListByQuery(roleQuery).stream()
SysAccount operator = sysAccountService.getById(operatorId);
Set<BigInteger> departmentIds = resolveDepartmentIds(operator == null ? null : operator.getDeptId());
if (CollectionUtil.isNotEmpty(departmentIds)) {
matchedTaskIds.addAll(loadMatchingRelationTaskIds(
ApprovalAssigneeType.DEPT, departmentIds, operator.getDeptId(), instanceIds));
}
Set<BigInteger> result = loadPendingTasks(matchedTaskIds, instanceIds).stream()
.map(ApprovalTask::getInstanceId) .map(ApprovalTask::getInstanceId)
.collect(Collectors.toCollection(LinkedHashSet::new))); .collect(Collectors.toCollection(LinkedHashSet::new));
// 旧单值任务仅在没有关联记录时参与兜底,避免旧字段覆盖新关联表的主数据语义。
List<ApprovalTask> legacyCandidates = loadLegacyPendingCandidates(
operatorId, roleIds, operator == null ? null : operator.getDeptId(), instanceIds);
if (legacyCandidates.isEmpty()) {
return result;
} }
Set<BigInteger> candidateIds = legacyCandidates.stream()
.map(ApprovalTask::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Set<BigInteger> relatedTaskIds = candidateIds.isEmpty() ? Set.of() : safeList(
approvalTaskAssigneeMapper.selectListByQuery(
QueryWrapper.create().in(ApprovalTaskAssignee::getTaskId, candidateIds))).stream()
.map(ApprovalTaskAssignee::getTaskId)
.collect(Collectors.toSet());
legacyCandidates.stream()
.filter(task -> !relatedTaskIds.contains(task.getId()))
.map(ApprovalTask::getInstanceId)
.forEach(result::add);
return result; return result;
} }
/**
* 查询能命中当前主体的任务关联 ID。
*
* @param type 审批对象类型
* @param targetIds 候选对象 ID
* @param currentDeptId 当前部门 ID部门审批时用于区分直属命中
* @param instanceIds 可选实例过滤
* @return 任务 ID 集合
*/
private Set<BigInteger> loadMatchingRelationTaskIds(ApprovalAssigneeType type, Set<BigInteger> targetIds,
BigInteger currentDeptId,
List<BigInteger> instanceIds) {
if (CollectionUtil.isEmpty(targetIds)) {
return Set.of();
}
QueryWrapper pendingTaskQuery = QueryWrapper.create()
.select(ApprovalTask::getId)
.from(ApprovalTask.class)
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode());
if (CollectionUtil.isNotEmpty(instanceIds)) {
pendingTaskQuery.in(ApprovalTask::getInstanceId, instanceIds);
}
return safeList(approvalTaskAssigneeMapper.selectListByQuery(QueryWrapper.create()
.eq(ApprovalTaskAssignee::getAssigneeType, type.getCode())
.in(ApprovalTaskAssignee::getTargetId, targetIds)
.in(ApprovalTaskAssignee::getTaskId, pendingTaskQuery)))
.stream()
.filter(item -> type != ApprovalAssigneeType.DEPT
|| Objects.equals(currentDeptId, item.getTargetId())
|| Integer.valueOf(1).equals(item.getIncludeChildren()))
.map(ApprovalTaskAssignee::getTaskId)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* 按任务 ID 批量读取待审批任务。
*
* @param taskIds 任务 ID
* @param instanceIds 可选实例过滤
* @return 待审批任务
*/
private List<ApprovalTask> loadPendingTasks(Set<BigInteger> taskIds, List<BigInteger> instanceIds) {
if (CollectionUtil.isEmpty(taskIds)) {
return List.of();
}
QueryWrapper query = QueryWrapper.create()
.in(ApprovalTask::getId, taskIds)
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode());
if (CollectionUtil.isNotEmpty(instanceIds)) {
query.in(ApprovalTask::getInstanceId, instanceIds);
}
return safeList(approvalTaskMapper.selectListByQuery(query));
}
/**
* 查询旧单值字段能命中的待审批任务候选。
*
* @param operatorId 操作人 ID
* @param roleIds 角色 ID
* @param deptId 当前部门 ID
* @param instanceIds 可选实例过滤
* @return 候选任务
*/
private List<ApprovalTask> loadLegacyPendingCandidates(BigInteger operatorId, Set<BigInteger> roleIds,
BigInteger deptId, List<BigInteger> instanceIds) {
List<ApprovalTask> result = new ArrayList<>();
result.addAll(loadLegacyPendingCandidates(
ApprovalAssigneeType.USER, Set.of(operatorId), instanceIds));
if (CollectionUtil.isNotEmpty(roleIds)) {
result.addAll(loadLegacyPendingCandidates(
ApprovalAssigneeType.ROLE, roleIds, instanceIds));
}
if (deptId != null) {
result.addAll(loadLegacyPendingCandidates(
ApprovalAssigneeType.DEPT, Set.of(deptId), instanceIds));
}
return result.stream()
.collect(Collectors.toMap(
ApprovalTask::getId,
Function.identity(),
(left, right) -> left,
LinkedHashMap::new))
.values()
.stream()
.collect(Collectors.toList());
}
/**
* 查询一种旧单值审批对象命中的待审批任务。
*
* @param type 审批对象类型
* @param targetIds 对象 ID
* @param instanceIds 可选实例过滤
* @return 候选任务
*/
private List<ApprovalTask> loadLegacyPendingCandidates(ApprovalAssigneeType type, Set<BigInteger> targetIds,
List<BigInteger> instanceIds) {
QueryWrapper query = QueryWrapper.create()
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode())
.eq(ApprovalTask::getAssigneeType, type.getCode())
.in(ApprovalTask::getAssigneeTargetId, targetIds);
if (CollectionUtil.isNotEmpty(instanceIds)) {
query.in(ApprovalTask::getInstanceId, instanceIds);
}
return safeList(approvalTaskMapper.selectListByQuery(query));
}
/**
* 判断当前部门是否命中任一部门审批对象。
*
* @param targets 部门审批对象
* @param currentDeptId 当前部门 ID
* @return 是否命中
*/
private boolean matchesDepartment(List<ApprovalAssigneeTargetVo> targets, BigInteger currentDeptId) {
Set<BigInteger> departmentIds = resolveDepartmentIds(currentDeptId);
if (departmentIds.isEmpty()) {
return false;
}
return targets.stream().anyMatch(target ->
Objects.equals(currentDeptId, target.getTargetId())
|| Integer.valueOf(1).equals(target.getIncludeChildren())
&& departmentIds.contains(target.getTargetId()));
}
/**
* 获取当前部门及其祖先部门。
*
* @param currentDeptId 当前部门 ID
* @return 部门 ID 集合
*/
private Set<BigInteger> resolveDepartmentIds(BigInteger currentDeptId) {
return currentDeptId == null ? Set.of() : sysDeptService.getSelfAndAncestorDeptIds(currentDeptId);
}
/**
* 解析并校验单个审批对象。
*
* @param assigneeType 审批对象类型
* @param targetId 审批对象 ID
* @return 规范化选项
*/
private ApprovalAssigneeOptionVo resolveAssigneeOption(ApprovalAssigneeType assigneeType, BigInteger targetId) { private ApprovalAssigneeOptionVo resolveAssigneeOption(ApprovalAssigneeType assigneeType, BigInteger targetId) {
if (ApprovalAssigneeType.ROLE == assigneeType) { if (ApprovalAssigneeType.ROLE == assigneeType) {
SysRole role = sysRoleService.getById(targetId); SysRole role = sysRoleService.getById(targetId);
@@ -166,6 +503,13 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
} }
return toRoleOption(role); return toRoleOption(role);
} }
if (ApprovalAssigneeType.DEPT == assigneeType) {
SysDept dept = sysDeptService.getById(targetId);
if (dept == null || !EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus())) {
throw new BusinessException("审批部门不存在或未启用");
}
return toDeptOption(dept);
}
SysAccount account = sysAccountService.getById(targetId); SysAccount account = sysAccountService.getById(targetId);
if (account == null || !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())) { if (account == null || !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())) {
throw new BusinessException("审批用户不存在或未启用"); throw new BusinessException("审批用户不存在或未启用");
@@ -173,6 +517,12 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
return toAccountOption(account); return toAccountOption(account);
} }
/**
* 将角色转换为审批选项。
*
* @param role 角色
* @return 审批选项
*/
private ApprovalAssigneeOptionVo toRoleOption(SysRole role) { private ApprovalAssigneeOptionVo toRoleOption(SysRole role) {
ApprovalAssigneeOptionVo option = new ApprovalAssigneeOptionVo(); ApprovalAssigneeOptionVo option = new ApprovalAssigneeOptionVo();
option.setId(role.getId()); option.setId(role.getId());
@@ -181,6 +531,12 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
return option; return option;
} }
/**
* 将账号转换为审批选项。
*
* @param account 账号
* @return 审批选项
*/
private ApprovalAssigneeOptionVo toAccountOption(SysAccount account) { private ApprovalAssigneeOptionVo toAccountOption(SysAccount account) {
ApprovalAssigneeOptionVo option = new ApprovalAssigneeOptionVo(); ApprovalAssigneeOptionVo option = new ApprovalAssigneeOptionVo();
option.setId(account.getId()); option.setId(account.getId());
@@ -189,6 +545,26 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
return option; return option;
} }
/**
* 将部门转换为审批选项。
*
* @param dept 部门
* @return 审批选项
*/
private ApprovalAssigneeOptionVo toDeptOption(SysDept dept) {
ApprovalAssigneeOptionVo option = new ApprovalAssigneeOptionVo();
option.setId(dept.getId());
option.setCode(dept.getDeptCode());
option.setName(dept.getDeptName());
return option;
}
/**
* 解析账号展示名称。
*
* @param account 账号
* @return 展示名称
*/
private String resolveAccountDisplayName(SysAccount account) { private String resolveAccountDisplayName(SysAccount account) {
if (StringUtils.hasText(account.getNickname())) { if (StringUtils.hasText(account.getNickname())) {
return account.getNickname().trim(); return account.getNickname().trim();
@@ -198,4 +574,122 @@ public class ApprovalAssigneeServiceImpl implements ApprovalAssigneeService {
} }
return String.valueOf(account.getId()); return String.valueOf(account.getId());
} }
/**
* 将步骤的第一个审批对象同步到旧单值字段。
*
* @param step 步骤
*/
private void applyPrimaryTarget(ApprovalFlowStepVo step) {
ApprovalAssigneeTargetVo primary = step.getAssigneeTargets().get(0);
step.setAssigneeTargetId(primary.getTargetId());
step.setAssigneeTargetCode(primary.getTargetCode());
step.setAssigneeTargetName(primary.getTargetName());
}
/**
* 构造旧单值审批对象。
*
* @param targetId 对象 ID
* @param targetCode 对象编码
* @param targetName 对象名称
* @return 审批对象
*/
private ApprovalAssigneeTargetVo legacyTarget(BigInteger targetId, String targetCode, String targetName) {
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(targetId);
target.setTargetCode(targetCode);
target.setTargetName(targetName);
target.setIncludeChildren(0);
return target;
}
/**
* 构造旧单值审批对象列表。
*
* @param targetId 对象 ID
* @param targetCode 对象编码
* @param targetName 对象名称
* @return 审批对象列表
*/
private List<ApprovalAssigneeTargetVo> legacyTargets(BigInteger targetId, String targetCode, String targetName) {
return targetId == null ? List.of() : List.of(legacyTarget(targetId, targetCode, targetName));
}
/**
* 将步骤关联实体转换为审批对象。
*
* @param relation 步骤关联
* @return 审批对象
*/
private ApprovalAssigneeTargetVo toTarget(ApprovalFlowStepAssignee relation) {
return target(relation.getTargetId(), relation.getTargetCode(), relation.getTargetName(),
relation.getIncludeChildren());
}
/**
* 将任务关联实体转换为审批对象。
*
* @param relation 任务关联
* @return 审批对象
*/
private ApprovalAssigneeTargetVo toTarget(ApprovalTaskAssignee relation) {
return target(relation.getTargetId(), relation.getTargetCode(), relation.getTargetName(),
relation.getIncludeChildren());
}
/**
* 构造审批对象。
*
* @param targetId 对象 ID
* @param targetCode 对象编码
* @param targetName 对象名称
* @param includeChildren 是否包含子部门
* @return 审批对象
*/
private ApprovalAssigneeTargetVo target(BigInteger targetId, String targetCode, String targetName,
Integer includeChildren) {
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(targetId);
target.setTargetCode(targetCode);
target.setTargetName(targetName);
target.setIncludeChildren(Integer.valueOf(1).equals(includeChildren) ? 1 : 0);
return target;
}
/**
* 校验审批对象列表。
*
* @param targets 审批对象列表
* @return 非空审批对象列表
*/
private List<ApprovalAssigneeTargetVo> requireTargets(List<ApprovalAssigneeTargetVo> targets) {
if (CollectionUtil.isEmpty(targets)) {
throw new BusinessException("审批对象不能为空");
}
return targets;
}
/**
* 规范化包含子部门标记。
*
* @param assigneeType 审批对象类型
* @param includeChildren 原标记
* @return 规范化标记
*/
private int normalizeIncludeChildren(String assigneeType, Integer includeChildren) {
return ApprovalAssigneeType.DEPT.getCode().equals(assigneeType)
&& Integer.valueOf(1).equals(includeChildren) ? 1 : 0;
}
/**
* 将可能为空的 Mapper 查询结果转换为空列表。
*
* @param values 查询结果
* @param <T> 元素类型
* @return 非空列表
*/
private <T> List<T> safeList(List<T> values) {
return values == null ? List.of() : values;
}
} }

View File

@@ -12,6 +12,7 @@ import tech.easyflow.approval.entity.ApprovalFlow;
import tech.easyflow.approval.entity.ApprovalFlowScope; import tech.easyflow.approval.entity.ApprovalFlowScope;
import tech.easyflow.approval.entity.ApprovalFlowStep; import tech.easyflow.approval.entity.ApprovalFlowStep;
import tech.easyflow.approval.entity.ApprovalInstance; import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo; import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo; import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowScopeVo; import tech.easyflow.approval.entity.vo.ApprovalFlowScopeVo;
@@ -293,6 +294,7 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
normalized.setStepName(item.getStepName().trim()); normalized.setStepName(item.getStepName().trim());
normalized.setAssigneeType(item.getAssigneeType()); normalized.setAssigneeType(item.getAssigneeType());
normalized.setAssigneeTargetId(item.getAssigneeTargetId()); normalized.setAssigneeTargetId(item.getAssigneeTargetId());
normalized.setAssigneeTargets(item.getAssigneeTargets());
approvalAssigneeService.normalizeStepAssignee(normalized); approvalAssigneeService.normalizeStepAssignee(normalized);
result.add(normalized); result.add(normalized);
} }
@@ -304,6 +306,12 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
private void clearChildren(BigInteger flowId) { private void clearChildren(BigInteger flowId) {
approvalFlowScopeMapper.deleteByQuery(QueryWrapper.create().eq(ApprovalFlowScope::getFlowId, flowId)); approvalFlowScopeMapper.deleteByQuery(QueryWrapper.create().eq(ApprovalFlowScope::getFlowId, flowId));
List<BigInteger> stepIds = approvalFlowStepMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, flowId))
.stream()
.map(ApprovalFlowStep::getId)
.collect(Collectors.toList());
approvalAssigneeService.deleteStepAssigneeTargets(stepIds);
approvalFlowStepMapper.deleteByQuery(QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, flowId)); approvalFlowStepMapper.deleteByQuery(QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, flowId));
} }
@@ -334,6 +342,8 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
step.setModified(now); step.setModified(now);
step.setModifiedBy(operatorId); step.setModifiedBy(operatorId);
approvalFlowStepMapper.insert(step); approvalFlowStepMapper.insert(step);
approvalAssigneeService.saveStepAssigneeTargets(
step.getId(), stepVo.getAssigneeType(), stepVo.getAssigneeTargets(), operatorId, now);
} }
} }
@@ -368,8 +378,11 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
} }
private List<ApprovalFlowStepVo> loadStepVos(BigInteger flowId) { private List<ApprovalFlowStepVo> loadStepVos(BigInteger flowId) {
return approvalFlowStepMapper.selectListByQuery(QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, flowId)) List<ApprovalFlowStep> steps = approvalFlowStepMapper.selectListByQuery(
.stream() QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, flowId));
Map<BigInteger, List<ApprovalAssigneeTargetVo>> targetMap =
approvalAssigneeService.loadStepAssigneeTargets(steps);
return steps.stream()
.sorted(Comparator.comparing(ApprovalFlowStep::getStepNo)) .sorted(Comparator.comparing(ApprovalFlowStep::getStepNo))
.map(item -> { .map(item -> {
ApprovalFlowStepVo stepVo = new ApprovalFlowStepVo(); ApprovalFlowStepVo stepVo = new ApprovalFlowStepVo();
@@ -380,6 +393,7 @@ public class ApprovalFlowServiceImpl extends ServiceImpl<ApprovalFlowMapper, App
stepVo.setAssigneeTargetId(item.getAssigneeTargetId()); stepVo.setAssigneeTargetId(item.getAssigneeTargetId());
stepVo.setAssigneeTargetCode(item.getAssigneeTargetCode()); stepVo.setAssigneeTargetCode(item.getAssigneeTargetCode());
stepVo.setAssigneeTargetName(item.getAssigneeTargetName()); stepVo.setAssigneeTargetName(item.getAssigneeTargetName());
stepVo.setAssigneeTargets(targetMap.getOrDefault(item.getId(), List.of()));
return stepVo; return stepVo;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -12,6 +12,7 @@ import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalFlowStep; import tech.easyflow.approval.entity.ApprovalFlowStep;
import tech.easyflow.approval.entity.ApprovalLog; import tech.easyflow.approval.entity.ApprovalLog;
import tech.easyflow.approval.entity.ApprovalTask; import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo; import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo; import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest; import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
@@ -271,6 +272,14 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
map.put("assigneeTargetId", item.getAssigneeTargetId()); map.put("assigneeTargetId", item.getAssigneeTargetId());
map.put("assigneeTargetCode", item.getAssigneeTargetCode()); map.put("assigneeTargetCode", item.getAssigneeTargetCode());
map.put("assigneeTargetName", item.getAssigneeTargetName()); map.put("assigneeTargetName", item.getAssigneeTargetName());
map.put("assigneeTargets", resolveStepTargets(item).stream().map(target -> {
Map<String, Object> targetMap = new LinkedHashMap<>();
targetMap.put("targetId", target.getTargetId());
targetMap.put("targetCode", target.getTargetCode());
targetMap.put("targetName", target.getTargetName());
targetMap.put("includeChildren", target.getIncludeChildren());
return targetMap;
}).collect(Collectors.toList()));
return map; return map;
}).collect(Collectors.toList())); }).collect(Collectors.toList()));
return snapshot; return snapshot;
@@ -315,6 +324,10 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
if (assigneeTargetName != null) { if (assigneeTargetName != null) {
stepVo.setAssigneeTargetName(String.valueOf(assigneeTargetName)); stepVo.setAssigneeTargetName(String.valueOf(assigneeTargetName));
} }
stepVo.setAssigneeTargets(parseAssigneeTargets(stepMap.get("assigneeTargets")));
if (stepVo.getAssigneeTargets().isEmpty() && stepVo.getAssigneeTargetId() != null) {
stepVo.setAssigneeTargets(List.of(legacyTarget(stepVo)));
}
result.add(stepVo); result.add(stepVo);
} }
mergeStepAssigneeFromFlow(instance.getFlowId(), result); mergeStepAssigneeFromFlow(instance.getFlowId(), result);
@@ -326,22 +339,30 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
} }
private void createTask(BigInteger instanceId, ApprovalFlowStepVo step, BigInteger operatorId, Date now) { private void createTask(BigInteger instanceId, ApprovalFlowStepVo step, BigInteger operatorId, Date now) {
List<ApprovalAssigneeTargetVo> targets = resolveStepTargets(step);
if (CollectionUtil.isEmpty(targets)) {
throw new BusinessException("审批步骤缺少审批对象");
}
step.setAssigneeTargets(targets);
ApprovalAssigneeTargetVo primaryTarget = targets.get(0);
ApprovalTask task = new ApprovalTask(); ApprovalTask task = new ApprovalTask();
task.setInstanceId(instanceId); task.setInstanceId(instanceId);
task.setStepNo(step.getStepNo()); task.setStepNo(step.getStepNo());
task.setStatus(ApprovalTaskStatus.PENDING.getCode()); task.setStatus(ApprovalTaskStatus.PENDING.getCode());
task.setAssigneeRoleCode(ApprovalAssigneeType.ROLE.getCode().equals(step.getAssigneeType()) task.setAssigneeRoleCode(ApprovalAssigneeType.ROLE.getCode().equals(step.getAssigneeType())
? step.getAssigneeTargetCode() ? primaryTarget.getTargetCode()
: null); : null);
task.setAssigneeType(step.getAssigneeType()); task.setAssigneeType(step.getAssigneeType());
task.setAssigneeTargetId(step.getAssigneeTargetId()); task.setAssigneeTargetId(primaryTarget.getTargetId());
task.setAssigneeTargetCode(step.getAssigneeTargetCode()); task.setAssigneeTargetCode(primaryTarget.getTargetCode());
task.setAssigneeTargetName(step.getAssigneeTargetName()); task.setAssigneeTargetName(primaryTarget.getTargetName());
task.setCreated(now); task.setCreated(now);
task.setCreatedBy(operatorId); task.setCreatedBy(operatorId);
task.setModified(now); task.setModified(now);
task.setModifiedBy(operatorId); task.setModifiedBy(operatorId);
approvalTaskMapper.insert(task); approvalTaskMapper.insert(task);
approvalAssigneeService.saveTaskAssigneeTargets(
task.getId(), step.getAssigneeType(), targets, operatorId, now);
} }
private void finishTask(ApprovalTask task, String status, String comment, BigInteger operatorId, Date now) { private void finishTask(ApprovalTask task, String status, String comment, BigInteger operatorId, Date now) {
@@ -480,8 +501,12 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
(left, right) -> left, (left, right) -> left,
LinkedHashMap::new LinkedHashMap::new
)); ));
Map<BigInteger, List<ApprovalAssigneeTargetVo>> targetMap =
approvalAssigneeService.loadStepAssigneeTargets(storedSteps);
for (ApprovalFlowStepVo step : steps) { for (ApprovalFlowStepVo step : steps) {
if (step.getAssigneeTargetId() != null && step.getAssigneeType() != null) { if (CollectionUtil.isNotEmpty(step.getAssigneeTargets())
&& step.getAssigneeTargetId() != null
&& step.getAssigneeType() != null) {
continue; continue;
} }
ApprovalFlowStep storedStep = storedMap.get(step.getStepNo()); ApprovalFlowStep storedStep = storedMap.get(step.getStepNo());
@@ -492,6 +517,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
step.setAssigneeTargetId(storedStep.getAssigneeTargetId()); step.setAssigneeTargetId(storedStep.getAssigneeTargetId());
step.setAssigneeTargetCode(storedStep.getAssigneeTargetCode()); step.setAssigneeTargetCode(storedStep.getAssigneeTargetCode());
step.setAssigneeTargetName(storedStep.getAssigneeTargetName()); step.setAssigneeTargetName(storedStep.getAssigneeTargetName());
step.setAssigneeTargets(targetMap.getOrDefault(storedStep.getId(), List.of()));
} }
} }
@@ -509,6 +535,85 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
payload.put("assigneeTargetId", step.getAssigneeTargetId()); payload.put("assigneeTargetId", step.getAssigneeTargetId());
payload.put("assigneeTargetCode", step.getAssigneeTargetCode()); payload.put("assigneeTargetCode", step.getAssigneeTargetCode());
payload.put("assigneeTargetName", step.getAssigneeTargetName()); payload.put("assigneeTargetName", step.getAssigneeTargetName());
payload.put("assigneeTargets", resolveStepTargets(step));
return payload; return payload;
} }
/**
* 从冻结快照中解析审批对象列表。
*
* @param value 快照字段
* @return 审批对象列表
*/
private List<ApprovalAssigneeTargetVo> parseAssigneeTargets(Object value) {
if (!(value instanceof List<?> values)) {
return List.of();
}
List<ApprovalAssigneeTargetVo> result = new ArrayList<>();
for (Object item : values) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
BigInteger targetId = parseTargetId(map.get("targetId"));
if (targetId == null) {
continue;
}
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(targetId);
target.setTargetCode(map.get("targetCode") == null ? null : String.valueOf(map.get("targetCode")));
target.setTargetName(map.get("targetName") == null ? null : String.valueOf(map.get("targetName")));
Object includeChildren = map.get("includeChildren");
target.setIncludeChildren(includeChildren instanceof Number number && number.intValue() == 1
|| Boolean.TRUE.equals(includeChildren) ? 1 : 0);
result.add(target);
}
return result;
}
/**
* 解析快照中的对象 ID。
*
* @param value 快照值
* @return 对象 ID
*/
private BigInteger parseTargetId(Object value) {
if (value instanceof BigInteger bigInteger) {
return bigInteger;
}
if (value instanceof Number number) {
return new BigInteger(number.toString());
}
if (value instanceof String string && !string.isBlank()) {
return new BigInteger(string);
}
return null;
}
/**
* 将旧单值步骤转换为单元素审批对象。
*
* @param step 旧步骤
* @return 审批对象
*/
private ApprovalAssigneeTargetVo legacyTarget(ApprovalFlowStepVo step) {
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(step.getAssigneeTargetId());
target.setTargetCode(step.getAssigneeTargetCode());
target.setTargetName(step.getAssigneeTargetName());
target.setIncludeChildren(0);
return target;
}
/**
* 获取步骤审批对象,兼容旧单值步骤。
*
* @param step 审批步骤
* @return 审批对象列表
*/
private List<ApprovalAssigneeTargetVo> resolveStepTargets(ApprovalFlowStepVo step) {
if (CollectionUtil.isNotEmpty(step.getAssigneeTargets())) {
return step.getAssigneeTargets();
}
return step.getAssigneeTargetId() == null ? List.of() : List.of(legacyTarget(step));
}
} }

View File

@@ -12,6 +12,7 @@ import tech.easyflow.approval.entity.ApprovalFlowStep;
import tech.easyflow.approval.entity.ApprovalInstance; import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalLog; import tech.easyflow.approval.entity.ApprovalLog;
import tech.easyflow.approval.entity.ApprovalTask; import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo; import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalInstancePageVo; import tech.easyflow.approval.entity.vo.ApprovalInstancePageVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo; import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
@@ -174,6 +175,8 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
List<ApprovalLog> logs = approvalLogMapper.selectListByQuery( List<ApprovalLog> logs = approvalLogMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId)); QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId));
Map<Integer, ApprovalFlowStepVo> frozenStepMap = resolveFrozenStepMap(instance); Map<Integer, ApprovalFlowStepVo> frozenStepMap = resolveFrozenStepMap(instance);
Map<BigInteger, List<ApprovalAssigneeTargetVo>> taskTargetMap =
approvalAssigneeService.loadTaskAssigneeTargets(tasks);
Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs, account.getTenantId()); Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs, account.getTenantId());
detail.setApplicantName(resolveAccountName(accountMap.get(instance.getApplicantId()))); detail.setApplicantName(resolveAccountName(accountMap.get(instance.getApplicantId())));
detail.setApplicantAccount(resolveAccountLoginName(accountMap.get(instance.getApplicantId()))); detail.setApplicantAccount(resolveAccountLoginName(accountMap.get(instance.getApplicantId())));
@@ -192,6 +195,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
taskVo.setAssigneeTargetId(item.getAssigneeTargetId()); taskVo.setAssigneeTargetId(item.getAssigneeTargetId());
taskVo.setAssigneeTargetCode(item.getAssigneeTargetCode()); taskVo.setAssigneeTargetCode(item.getAssigneeTargetCode());
taskVo.setAssigneeTargetName(item.getAssigneeTargetName()); taskVo.setAssigneeTargetName(item.getAssigneeTargetName());
taskVo.setAssigneeTargets(taskTargetMap.getOrDefault(item.getId(), List.of()));
taskVo.setActedBy(item.getActedBy()); taskVo.setActedBy(item.getActedBy());
taskVo.setActedByName(resolveAccountName(accountMap.get(item.getActedBy()))); taskVo.setActedByName(resolveAccountName(accountMap.get(item.getActedBy())));
taskVo.setActedAt(item.getActedAt()); taskVo.setActedAt(item.getActedAt());
@@ -466,14 +470,21 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
if (assigneeTargetName != null) { if (assigneeTargetName != null) {
stepVo.setAssigneeTargetName(String.valueOf(assigneeTargetName)); stepVo.setAssigneeTargetName(String.valueOf(assigneeTargetName));
} }
stepVo.setAssigneeTargets(parseAssigneeTargets(map.get("assigneeTargets")));
if (stepVo.getAssigneeTargets().isEmpty() && stepVo.getAssigneeTargetId() != null) {
stepVo.setAssigneeTargets(List.of(legacyTarget(stepVo)));
}
result.put(stepVo.getStepNo(), stepVo); result.put(stepVo.getStepNo(), stepVo);
} }
} }
if (!result.isEmpty() && result.values().stream().allMatch(item -> item.getAssigneeType() != null && item.getAssigneeTargetId() != null)) { if (!result.isEmpty() && result.values().stream().allMatch(item ->
item.getAssigneeType() != null && CollectionUtil.isNotEmpty(item.getAssigneeTargets()))) {
return result; return result;
} }
List<ApprovalFlowStep> storedSteps = approvalFlowStepMapper.selectListByQuery( List<ApprovalFlowStep> storedSteps = approvalFlowStepMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, instance.getFlowId())); QueryWrapper.create().eq(ApprovalFlowStep::getFlowId, instance.getFlowId()));
Map<BigInteger, List<ApprovalAssigneeTargetVo>> targetMap =
approvalAssigneeService.loadStepAssigneeTargets(storedSteps);
for (ApprovalFlowStep step : storedSteps) { for (ApprovalFlowStep step : storedSteps) {
ApprovalFlowStepVo stepVo = result.computeIfAbsent(step.getStepNo(), key -> { ApprovalFlowStepVo stepVo = result.computeIfAbsent(step.getStepNo(), key -> {
ApprovalFlowStepVo value = new ApprovalFlowStepVo(); ApprovalFlowStepVo value = new ApprovalFlowStepVo();
@@ -495,7 +506,75 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
if (!StringUtils.hasText(stepVo.getAssigneeTargetName())) { if (!StringUtils.hasText(stepVo.getAssigneeTargetName())) {
stepVo.setAssigneeTargetName(step.getAssigneeTargetName()); stepVo.setAssigneeTargetName(step.getAssigneeTargetName());
} }
if (CollectionUtil.isEmpty(stepVo.getAssigneeTargets())) {
stepVo.setAssigneeTargets(targetMap.getOrDefault(step.getId(), List.of()));
}
} }
return result; return result;
} }
/**
* 从冻结快照中解析审批对象列表。
*
* @param value 快照字段
* @return 审批对象列表
*/
private List<ApprovalAssigneeTargetVo> parseAssigneeTargets(Object value) {
if (!(value instanceof List<?> values)) {
return List.of();
}
List<ApprovalAssigneeTargetVo> result = new ArrayList<>();
for (Object item : values) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
BigInteger targetId = parseTargetId(map.get("targetId"));
if (targetId == null) {
continue;
}
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(targetId);
target.setTargetCode(map.get("targetCode") == null ? null : String.valueOf(map.get("targetCode")));
target.setTargetName(map.get("targetName") == null ? null : String.valueOf(map.get("targetName")));
Object includeChildren = map.get("includeChildren");
target.setIncludeChildren(includeChildren instanceof Number number && number.intValue() == 1
|| Boolean.TRUE.equals(includeChildren) ? 1 : 0);
result.add(target);
}
return result;
}
/**
* 解析快照中的对象 ID。
*
* @param value 快照值
* @return 对象 ID
*/
private BigInteger parseTargetId(Object value) {
if (value instanceof BigInteger bigInteger) {
return bigInteger;
}
if (value instanceof Number number) {
return new BigInteger(number.toString());
}
if (value instanceof String string && StringUtils.hasText(string)) {
return new BigInteger(string);
}
return null;
}
/**
* 将旧单值步骤转换为单元素审批对象。
*
* @param step 旧步骤
* @return 审批对象
*/
private ApprovalAssigneeTargetVo legacyTarget(ApprovalFlowStepVo step) {
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(step.getAssigneeTargetId());
target.setTargetCode(step.getAssigneeTargetCode());
target.setTargetName(step.getAssigneeTargetName());
target.setIncludeChildren(0);
return target;
}
} }

View File

@@ -0,0 +1,248 @@
package tech.easyflow.approval.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.ApprovalTaskAssignee;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.approval.mapper.ApprovalTaskAssigneeMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.system.service.SysRoleService;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* {@link ApprovalAssigneeServiceImpl} 多审批对象语义测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalAssigneeServiceImplTest {
@Mock
private SysRoleService sysRoleService;
@Mock
private SysAccountService sysAccountService;
@Mock
private SysDeptService sysDeptService;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
@Mock
private ApprovalTaskAssigneeMapper approvalTaskAssigneeMapper;
@InjectMocks
private ApprovalAssigneeServiceImpl service;
/**
* 验证同一步骤支持多个角色,并按 ID 去重、由服务端补齐名称。
*/
@Test
public void normalizeStepAssigneeShouldResolveAndDeduplicateMultipleRoles() {
SysRole reviewer = role(11, "reviewer", "审核员");
SysRole owner = role(12, "owner", "负责人");
when(sysRoleService.getById(reviewer.getId())).thenReturn(reviewer);
when(sysRoleService.getById(owner.getId())).thenReturn(owner);
ApprovalFlowStepVo step = new ApprovalFlowStepVo();
step.setAssigneeType(ApprovalAssigneeType.ROLE.getCode());
step.setAssigneeTargets(List.of(
target(reviewer.getId(), 0),
target(owner.getId(), 0),
target(reviewer.getId(), 0)));
ApprovalFlowStepVo normalized = service.normalizeStepAssignee(step);
assertEquals(2, normalized.getAssigneeTargets().size());
assertEquals(reviewer.getId(), normalized.getAssigneeTargetId());
assertEquals("审核员", normalized.getAssigneeTargetName());
assertEquals("负责人", normalized.getAssigneeTargets().get(1).getTargetName());
}
/**
* 验证部门审批可通过“包含子部门”命中下级部门成员。
*/
@Test
public void canHandleTaskShouldMatchDescendantDepartment() {
BigInteger operatorId = BigInteger.valueOf(7);
BigInteger parentDeptId = BigInteger.valueOf(21);
BigInteger currentDeptId = BigInteger.valueOf(22);
ApprovalTask task = new ApprovalTask();
task.setId(BigInteger.valueOf(101));
task.setAssigneeType(ApprovalAssigneeType.DEPT.getCode());
ApprovalTaskAssignee relation = new ApprovalTaskAssignee();
relation.setTaskId(task.getId());
relation.setAssigneeType(ApprovalAssigneeType.DEPT.getCode());
relation.setTargetId(parentDeptId);
relation.setIncludeChildren(1);
when(approvalTaskAssigneeMapper.selectListByQuery(any(QueryWrapper.class)))
.thenReturn(List.of(relation));
SysAccount operator = new SysAccount();
operator.setId(operatorId);
operator.setDeptId(currentDeptId);
when(sysAccountService.getById(operatorId)).thenReturn(operator);
when(sysDeptService.getSelfAndAncestorDeptIds(currentDeptId))
.thenReturn(Set.of(parentDeptId, currentDeptId));
assertTrue(service.canHandleTask(task, operatorId, Set.of()));
}
/**
* 验证未启用“包含子部门”时,下级部门成员不能处理父部门任务。
*/
@Test
public void canHandleTaskShouldRejectDescendantDepartmentWhenFlagDisabled() {
BigInteger operatorId = BigInteger.valueOf(7);
BigInteger parentDeptId = BigInteger.valueOf(21);
BigInteger currentDeptId = BigInteger.valueOf(22);
ApprovalTask task = new ApprovalTask();
task.setId(BigInteger.valueOf(101));
task.setAssigneeType(ApprovalAssigneeType.DEPT.getCode());
ApprovalTaskAssignee relation = new ApprovalTaskAssignee();
relation.setTaskId(task.getId());
relation.setAssigneeType(ApprovalAssigneeType.DEPT.getCode());
relation.setTargetId(parentDeptId);
relation.setIncludeChildren(0);
when(approvalTaskAssigneeMapper.selectListByQuery(any(QueryWrapper.class)))
.thenReturn(List.of(relation));
SysAccount operator = new SysAccount();
operator.setId(operatorId);
operator.setDeptId(currentDeptId);
when(sysAccountService.getById(operatorId)).thenReturn(operator);
when(sysDeptService.getSelfAndAncestorDeptIds(currentDeptId))
.thenReturn(Set.of(parentDeptId, currentDeptId));
assertFalse(service.canHandleTask(task, operatorId, Set.of()));
}
/**
* 验证包含子部门的部门任务会出现在下级部门成员的待审批列表。
*/
@Test
public void listPendingInstanceIdsShouldMatchDescendantDepartment() {
BigInteger operatorId = BigInteger.valueOf(7);
BigInteger parentDeptId = BigInteger.valueOf(21);
BigInteger currentDeptId = BigInteger.valueOf(22);
BigInteger taskId = BigInteger.valueOf(101);
BigInteger instanceId = BigInteger.valueOf(201);
SysAccount operator = new SysAccount();
operator.setId(operatorId);
operator.setDeptId(currentDeptId);
when(sysAccountService.getById(operatorId)).thenReturn(operator);
when(sysDeptService.getSelfAndAncestorDeptIds(currentDeptId))
.thenReturn(Set.of(parentDeptId, currentDeptId));
ApprovalTaskAssignee relation = new ApprovalTaskAssignee();
relation.setTaskId(taskId);
relation.setAssigneeType(ApprovalAssigneeType.DEPT.getCode());
relation.setTargetId(parentDeptId);
relation.setIncludeChildren(1);
when(approvalTaskAssigneeMapper.selectListByQuery(any(QueryWrapper.class)))
.thenReturn(List.of(), List.of(relation));
ApprovalTask task = new ApprovalTask();
task.setId(taskId);
task.setInstanceId(instanceId);
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class)))
.thenReturn(List.of(task), List.of(), List.of());
assertEquals(
Set.of(instanceId),
service.listPendingInstanceIds(operatorId, Set.of(), null));
}
/**
* 验证多个角色审批对象采用 OR 语义,命中任一角色即可处理。
*/
@Test
public void canHandleTaskShouldMatchAnyRoleTarget() {
BigInteger operatorId = BigInteger.valueOf(7);
ApprovalTask task = new ApprovalTask();
task.setId(BigInteger.valueOf(101));
task.setAssigneeType(ApprovalAssigneeType.ROLE.getCode());
ApprovalTaskAssignee first = taskAssignee(task.getId(), 31);
ApprovalTaskAssignee second = taskAssignee(task.getId(), 32);
when(approvalTaskAssigneeMapper.selectListByQuery(any(QueryWrapper.class)))
.thenReturn(List.of(first, second));
assertTrue(service.canHandleTask(task, operatorId, Set.of(BigInteger.valueOf(32))));
}
/**
* 构造有效角色。
*
* @param id 角色 ID
* @param code 角色编码
* @param name 角色名称
* @return 角色
*/
private SysRole role(long id, String code, String name) {
SysRole role = new SysRole();
role.setId(BigInteger.valueOf(id));
role.setRoleKey(code);
role.setRoleName(name);
role.setStatus(EnumDataStatus.AVAILABLE.getCode());
return role;
}
/**
* 构造审批对象。
*
* @param targetId 对象 ID
* @param includeChildren 是否包含子部门
* @return 审批对象
*/
private ApprovalAssigneeTargetVo target(BigInteger targetId, int includeChildren) {
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(targetId);
target.setIncludeChildren(includeChildren);
return target;
}
/**
* 构造角色任务关联。
*
* @param taskId 任务 ID
* @param targetId 角色 ID
* @return 任务关联
*/
private ApprovalTaskAssignee taskAssignee(BigInteger taskId, long targetId) {
ApprovalTaskAssignee relation = new ApprovalTaskAssignee();
relation.setTaskId(taskId);
relation.setAssigneeType(ApprovalAssigneeType.ROLE.getCode());
relation.setTargetId(BigInteger.valueOf(targetId));
relation.setIncludeChildren(0);
return relation;
}
}

View File

@@ -0,0 +1,258 @@
package tech.easyflow.approval.service.impl;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalLog;
import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeTargetVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalFlowStepMapper;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalAssigneeService;
import tech.easyflow.approval.service.ApprovalMatchService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ApprovalInstanceServiceImpl} 多审批对象冻结测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalInstanceMultiAssigneeTest {
@Mock
private ApprovalMatchService approvalMatchService;
@Mock
private ApprovalInstanceMapper approvalInstanceMapper;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private ApprovalLogMapper approvalLogMapper;
@Mock
private ApprovalFlowStepMapper approvalFlowStepMapper;
@Mock
private ApprovalAssigneeService approvalAssigneeService;
@Mock
private SysAccountService sysAccountService;
@InjectMocks
private ApprovalInstanceServiceImpl service;
/**
* 验证提审时会把全部审批对象写入实例快照和任务关联。
*/
@Test
@SuppressWarnings("unchecked")
public void submitApprovalShouldFreezeAllAssigneeTargets() {
BigInteger applicantId = BigInteger.valueOf(7);
BigInteger tenantId = BigInteger.valueOf(42);
BigInteger instanceId = BigInteger.valueOf(101);
BigInteger taskId = BigInteger.valueOf(201);
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
request.setApplicantId(applicantId);
request.setResourceId(BigInteger.valueOf(501));
request.setSummary("多用户审批");
ApprovalFlowStepVo step = new ApprovalFlowStepVo();
step.setStepNo(1);
step.setStepName("审核");
step.setAssigneeType(ApprovalAssigneeType.USER.getCode());
step.setAssigneeTargets(List.of(
target(11, "u11", "用户甲"),
target(12, "u12", "用户乙")));
ApprovalFlowDetailVo flow = new ApprovalFlowDetailVo();
flow.setId(BigInteger.valueOf(301));
flow.setVersion(3);
flow.setResourceType("WORKFLOW");
flow.setActionType("PUBLISH");
flow.setSteps(List.of(step));
when(approvalMatchService.matchFlow(request)).thenReturn(flow);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setId(applicantId);
loginAccount.setTenantId(tenantId);
SysAccount applicant = new SysAccount();
applicant.setId(applicantId);
applicant.setTenantId(tenantId);
when(sysAccountService.getById(applicantId)).thenReturn(applicant);
doAnswer(invocation -> {
((ApprovalInstance) invocation.getArgument(0)).setId(instanceId);
return 1;
}).when(approvalInstanceMapper).insert(any(ApprovalInstance.class));
doAnswer(invocation -> {
((ApprovalTask) invocation.getArgument(0)).setId(taskId);
return 1;
}).when(approvalTaskMapper).insert(any(ApprovalTask.class));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
assertEquals(instanceId, service.submitApproval(request));
}
ArgumentCaptor<ApprovalInstance> instanceCaptor = ArgumentCaptor.forClass(ApprovalInstance.class);
verify(approvalInstanceMapper).insert(instanceCaptor.capture());
List<Map<String, Object>> frozenSteps =
(List<Map<String, Object>>) instanceCaptor.getValue().getSnapshotJson().get("steps");
List<Map<String, Object>> frozenTargets =
(List<Map<String, Object>>) frozenSteps.get(0).get("assigneeTargets");
assertEquals(2, frozenTargets.size());
assertEquals(BigInteger.valueOf(12), frozenTargets.get(1).get("targetId"));
verify(approvalAssigneeService).saveTaskAssigneeTargets(
eq(taskId),
eq(ApprovalAssigneeType.USER.getCode()),
argThat(targets -> targets.size() == 2),
eq(applicantId),
any(Date.class));
verify(approvalLogMapper, org.mockito.Mockito.times(2)).insert(any(ApprovalLog.class));
}
/**
* 验证进入下一步骤时继续使用实例快照中冻结的全部审批对象。
*/
@Test
public void approveShouldCreateNextTaskFromFrozenAssigneeTargets() {
BigInteger operatorId = BigInteger.valueOf(7);
BigInteger tenantId = BigInteger.valueOf(42);
BigInteger instanceId = BigInteger.valueOf(101);
BigInteger currentTaskId = BigInteger.valueOf(201);
BigInteger nextTaskId = BigInteger.valueOf(202);
BigInteger firstDeptId = BigInteger.valueOf(31);
BigInteger secondDeptId = BigInteger.valueOf(32);
ApprovalInstance instance = new ApprovalInstance();
instance.setId(instanceId);
instance.setTenantId(tenantId);
instance.setFlowId(BigInteger.valueOf(301));
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
instance.setCurrentStepNo(1);
instance.setSnapshotJson(Map.of(
"steps", List.of(
frozenStep(1, ApprovalAssigneeType.USER.getCode(),
List.of(frozenTarget(11, 0))),
frozenStep(2, ApprovalAssigneeType.DEPT.getCode(), List.of(
frozenTarget(firstDeptId.longValue(), 0),
frozenTarget(secondDeptId.longValue(), 1))))));
ApprovalTask currentTask = new ApprovalTask();
currentTask.setId(currentTaskId);
currentTask.setInstanceId(instanceId);
currentTask.setStepNo(1);
currentTask.setStatus(ApprovalTaskStatus.PENDING.getCode());
currentTask.setAssigneeType(ApprovalAssigneeType.USER.getCode());
SysAccount operator = new SysAccount();
operator.setId(operatorId);
operator.setTenantId(tenantId);
when(sysAccountService.getById(operatorId)).thenReturn(operator);
when(approvalInstanceMapper.selectOneByQuery(any())).thenReturn(instance);
when(approvalTaskMapper.selectOneByQuery(any())).thenReturn(currentTask);
when(approvalAssigneeService.getAvailableRoleIds(operatorId)).thenReturn(Set.of());
when(approvalAssigneeService.canHandleTask(currentTask, operatorId, Set.of())).thenReturn(true);
when(approvalFlowStepMapper.selectListByQuery(any())).thenReturn(List.of());
doAnswer(invocation -> {
((ApprovalTask) invocation.getArgument(0)).setId(nextTaskId);
return 1;
}).when(approvalTaskMapper).insert(any(ApprovalTask.class));
service.approve(instanceId, "同意", operatorId);
verify(approvalAssigneeService).saveTaskAssigneeTargets(
eq(nextTaskId),
eq(ApprovalAssigneeType.DEPT.getCode()),
argThat(targets -> targets.size() == 2
&& firstDeptId.equals(targets.get(0).getTargetId())
&& secondDeptId.equals(targets.get(1).getTargetId())
&& Integer.valueOf(1).equals(targets.get(1).getIncludeChildren())),
eq(operatorId),
any(Date.class));
assertEquals(ApprovalInstanceStatus.PROCESSING.getCode(), instance.getStatus());
assertEquals(Integer.valueOf(2), instance.getCurrentStepNo());
}
/**
* 构造冻结步骤。
*
* @param stepNo 步骤序号
* @param assigneeType 审批对象类型
* @param targets 审批对象
* @return 冻结步骤
*/
private Map<String, Object> frozenStep(int stepNo, String assigneeType, List<Map<String, Object>> targets) {
Map<String, Object> primary = targets.get(0);
return Map.of(
"stepNo", stepNo,
"stepName", "" + stepNo + "",
"assigneeType", assigneeType,
"assigneeTargetId", primary.get("targetId"),
"assigneeTargetCode", "target-" + primary.get("targetId"),
"assigneeTargetName", "对象" + primary.get("targetId"),
"assigneeTargets", targets);
}
/**
* 构造冻结审批对象。
*
* @param id 对象 ID
* @param includeChildren 是否包含子部门
* @return 冻结审批对象
*/
private Map<String, Object> frozenTarget(long id, int includeChildren) {
return Map.of(
"targetId", BigInteger.valueOf(id),
"targetCode", "target-" + id,
"targetName", "对象" + id,
"includeChildren", includeChildren);
}
/**
* 构造审批对象。
*
* @param id 用户 ID
* @param code 用户编码
* @param name 用户名称
* @return 审批对象
*/
private ApprovalAssigneeTargetVo target(long id, String code, String name) {
ApprovalAssigneeTargetVo target = new ApprovalAssigneeTargetVo();
target.setTargetId(BigInteger.valueOf(id));
target.setTargetCode(code);
target.setTargetName(name);
target.setIncludeChildren(0);
return target;
}
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.approval.service.impl;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
/**
* V37 审批多对象迁移契约测试。
*/
public class ApprovalMultiAssigneeMigrationContractTest {
/**
* 验证迁移先阻断在途审批,再创建关联表并完整回填配置和历史任务。
*
* @throws Exception 迁移文件不可读时抛出
*/
@Test
public void migrationShouldGuardAndBackfillBothAssigneeRelations() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("WHERE `status` IN ('PENDING', 'PROCESSING')"));
assertTrue(sql.contains("WHERE `status` = 'PENDING'"));
assertTrue(sql.contains("SIGNAL SQLSTATE '45000'"));
assertTrue(sql.indexOf("CALL `sp_guard_approval_multi_assignee`()")
< sql.indexOf("CREATE TABLE IF NOT EXISTS `tb_approval_flow_step_assignee`"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `tb_approval_task_assignee`"));
assertTrue(sql.contains("UNIQUE KEY `uk_approval_step_assignee`"));
assertTrue(sql.contains("UNIQUE KEY `uk_approval_task_assignee`"));
assertTrue(sql.contains("FROM `tb_approval_flow_step` step"));
assertTrue(sql.contains("FROM `tb_approval_task` task"));
assertTrue(sql.contains("approval multi-assignee backfill verification failed"));
}
/**
* 读取工作区中的 V37 MySQL 迁移。
*
* @return 迁移 SQL
* @throws Exception 迁移文件不存在或不可读时抛出
*/
private String migrationSql() throws Exception {
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
while (root != null) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/V37__mysql_approval_multi_assignee.sql");
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
root = root.getParent();
}
throw new IllegalStateException("找不到 V37 审批多对象迁移");
}
}

View File

@@ -0,0 +1,127 @@
SET NAMES utf8mb4;
-- 本次迁移依赖“无在途审批”的上线窗口,先阻断再执行表结构变更和历史回填。
DROP PROCEDURE IF EXISTS `sp_guard_approval_multi_assignee`;
DELIMITER $$
CREATE PROCEDURE `sp_guard_approval_multi_assignee`()
BEGIN
IF EXISTS (
SELECT 1
FROM `tb_approval_instance`
WHERE `status` IN ('PENDING', 'PROCESSING')
LIMIT 1
) OR EXISTS (
SELECT 1
FROM `tb_approval_task`
WHERE `status` = 'PENDING'
LIMIT 1
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'approval multi-assignee migration requires no active instances or pending tasks';
END IF;
END$$
DELIMITER ;
CALL `sp_guard_approval_multi_assignee`();
DROP PROCEDURE IF EXISTS `sp_guard_approval_multi_assignee`;
CREATE TABLE IF NOT EXISTS `tb_approval_flow_step_assignee` (
`id` bigint NOT NULL COMMENT '主键',
`step_id` bigint NOT NULL COMMENT '审批步骤ID',
`assignee_type` varchar(16) NOT NULL COMMENT '审批对象类型',
`target_id` bigint NOT NULL COMMENT '审批对象ID',
`target_code` varchar(128) DEFAULT NULL COMMENT '审批对象编码',
`target_name` varchar(128) DEFAULT NULL COMMENT '审批对象名称',
`include_children` tinyint NOT NULL DEFAULT 0 COMMENT '是否包含子部门',
`created` datetime DEFAULT NULL COMMENT '创建时间',
`created_by` bigint DEFAULT NULL COMMENT '创建者',
`modified` datetime DEFAULT NULL COMMENT '修改时间',
`modified_by` bigint DEFAULT NULL COMMENT '修改者',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_approval_step_assignee` (`step_id`, `assignee_type`, `target_id`),
KEY `idx_approval_step_assignee_step` (`step_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程步骤对象关联';
CREATE TABLE IF NOT EXISTS `tb_approval_task_assignee` (
`id` bigint NOT NULL COMMENT '主键',
`task_id` bigint NOT NULL COMMENT '审批任务ID',
`assignee_type` varchar(16) NOT NULL COMMENT '审批对象类型',
`target_id` bigint NOT NULL COMMENT '审批对象ID',
`target_code` varchar(128) DEFAULT NULL COMMENT '审批对象编码',
`target_name` varchar(128) DEFAULT NULL COMMENT '审批对象名称',
`include_children` tinyint NOT NULL DEFAULT 0 COMMENT '是否包含子部门',
`created` datetime DEFAULT NULL COMMENT '创建时间',
`created_by` bigint DEFAULT NULL COMMENT '创建者',
`modified` datetime DEFAULT NULL COMMENT '修改时间',
`modified_by` bigint DEFAULT NULL COMMENT '修改者',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_approval_task_assignee` (`task_id`, `assignee_type`, `target_id`),
KEY `idx_approval_task_assignee_target` (`assignee_type`, `target_id`, `task_id`),
KEY `idx_approval_task_assignee_task` (`task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批任务对象关联';
-- 现有流程配置都是单对象,按“一条旧记录对应一条关联记录”完整回填。
INSERT INTO `tb_approval_flow_step_assignee` (
`id`, `step_id`, `assignee_type`, `target_id`, `target_code`, `target_name`,
`include_children`, `created`, `created_by`, `modified`, `modified_by`
)
SELECT
step.`id`, step.`id`, step.`assignee_type`, step.`assignee_target_id`,
step.`assignee_target_code`, step.`assignee_target_name`,
0, step.`created`, step.`created_by`, step.`modified`, step.`modified_by`
FROM `tb_approval_flow_step` step
WHERE step.`assignee_type` IS NOT NULL
AND step.`assignee_target_id` IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM `tb_approval_flow_step_assignee` relation
WHERE relation.`step_id` = step.`id`
);
-- 已完成任务同步回填,保证历史详情继续展示原审批对象。
INSERT INTO `tb_approval_task_assignee` (
`id`, `task_id`, `assignee_type`, `target_id`, `target_code`, `target_name`,
`include_children`, `created`, `created_by`, `modified`, `modified_by`
)
SELECT
task.`id`, task.`id`, task.`assignee_type`, task.`assignee_target_id`,
task.`assignee_target_code`, task.`assignee_target_name`,
0, task.`created`, task.`created_by`, task.`modified`, task.`modified_by`
FROM `tb_approval_task` task
WHERE task.`assignee_type` IS NOT NULL
AND task.`assignee_target_id` IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM `tb_approval_task_assignee` relation
WHERE relation.`task_id` = task.`id`
);
DROP PROCEDURE IF EXISTS `sp_verify_approval_multi_assignee`;
DELIMITER $$
CREATE PROCEDURE `sp_verify_approval_multi_assignee`()
BEGIN
IF EXISTS (
SELECT 1
FROM `tb_approval_flow_step` step
LEFT JOIN `tb_approval_flow_step_assignee` relation ON relation.`step_id` = step.`id`
WHERE step.`assignee_type` IS NOT NULL
AND step.`assignee_target_id` IS NOT NULL
AND relation.`id` IS NULL
LIMIT 1
) OR EXISTS (
SELECT 1
FROM `tb_approval_task` task
LEFT JOIN `tb_approval_task_assignee` relation ON relation.`task_id` = task.`id`
WHERE task.`assignee_type` IS NOT NULL
AND task.`assignee_target_id` IS NOT NULL
AND relation.`id` IS NULL
LIMIT 1
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'approval multi-assignee backfill verification failed';
END IF;
END$$
DELIMITER ;
CALL `sp_verify_approval_multi_assignee`();
DROP PROCEDURE IF EXISTS `sp_verify_approval_multi_assignee`;

View File

@@ -24,7 +24,8 @@
"revoke": "Revoke", "revoke": "Revoke",
"submit": "Submit Approval", "submit": "Submit Approval",
"addScope": "Add Scope", "addScope": "Add Scope",
"addStep": "Add Step" "addStep": "Add Step",
"removeTarget": "Remove"
}, },
"scope": { "scope": {
"category": "Category", "category": "Category",
@@ -32,7 +33,8 @@
}, },
"assignee": { "assignee": {
"role": "Role", "role": "Role",
"user": "User" "user": "User",
"dept": "Department"
}, },
"status": { "status": {
"enabled": "Enabled", "enabled": "Enabled",
@@ -53,7 +55,8 @@
}, },
"helper": { "helper": {
"scope": "Limit the flow by resource category or applicant department. Leave empty to apply globally.", "scope": "Limit the flow by resource category or applicant department. Leave empty to apply globally.",
"scopeEmpty": "No scope configured. This flow applies globally." "scopeEmpty": "No scope configured. This flow applies globally.",
"assigneeAny": "Any selected assignee can process the same step."
}, },
"fields": { "fields": {
"flowName": "Flow Name", "flowName": "Flow Name",
@@ -77,7 +80,9 @@
"applicantId": "Applicant ID", "applicantId": "Applicant ID",
"submittedAt": "Submitted At", "submittedAt": "Submitted At",
"finishedAt": "Finished At", "finishedAt": "Finished At",
"assigneeType": "Assignee Type",
"assigneeTarget": "Assignee", "assigneeTarget": "Assignee",
"selectedDeptCount": "Selected departments: {value}",
"actedBy": "Acted By", "actedBy": "Acted By",
"actedAt": "Acted At", "actedAt": "Acted At",
"comment": "Comment", "comment": "Comment",
@@ -112,6 +117,7 @@
"message": { "message": {
"needStep": "At least one step is required", "needStep": "At least one step is required",
"needStepAssignee": "Each approval step requires an assignee", "needStepAssignee": "Each approval step requires an assignee",
"confirmAssigneeTypeChange": "Changing the assignee type clears the current selections. Continue?",
"saveSuccess": "Flow saved", "saveSuccess": "Flow saved",
"statusUpdated": "Flow status updated", "statusUpdated": "Flow status updated",
"deleteSuccess": "Flow deleted", "deleteSuccess": "Flow deleted",

View File

@@ -13,5 +13,18 @@
"modified": "Modified", "modified": "Modified",
"modifiedBy": "ModifiedBy", "modifiedBy": "ModifiedBy",
"remark": "Remark", "remark": "Remark",
"isDeleted": "IsDeleted" "isDeleted": "IsDeleted",
"enable": "Enable",
"disable": "Disable",
"batchEnable": "Enable Selected",
"batchDisable": "Disable Selected",
"batchDelete": "Delete Selected",
"selectedCount": "{count} selected",
"selectRequired": "Select at least one department",
"statusConfirm": "{action} department “{name}”?",
"batchStatusConfirm": "{action} the {count} selected departments?",
"deleteConfirm": "Delete department “{name}”? This action cannot be undone.",
"batchDeleteConfirm": "Delete the {count} selected departments? This action cannot be undone.",
"operationSuccess": "Operation completed",
"operationFailed": "Operation failed. Please try again."
} }

View File

@@ -24,7 +24,8 @@
"revoke": "撤回", "revoke": "撤回",
"submit": "提交审批", "submit": "提交审批",
"addScope": "新增范围", "addScope": "新增范围",
"addStep": "新增步骤" "addStep": "新增步骤",
"removeTarget": "移除"
}, },
"scope": { "scope": {
"category": "分类", "category": "分类",
@@ -32,7 +33,8 @@
}, },
"assignee": { "assignee": {
"role": "角色", "role": "角色",
"user": "用户" "user": "用户",
"dept": "部门"
}, },
"status": { "status": {
"enabled": "启用", "enabled": "启用",
@@ -53,7 +55,8 @@
}, },
"helper": { "helper": {
"scope": "按资源分类或申请人部门限定流程命中范围;留空时默认对全部资源生效。", "scope": "按资源分类或申请人部门限定流程命中范围;留空时默认对全部资源生效。",
"scopeEmpty": "未配置范围,当前流程默认全局生效。" "scopeEmpty": "未配置范围,当前流程默认全局生效。",
"assigneeAny": "同一步骤中,任一选中的审批对象均可处理。"
}, },
"fields": { "fields": {
"flowName": "流程名称", "flowName": "流程名称",
@@ -77,7 +80,9 @@
"applicantId": "申请人ID", "applicantId": "申请人ID",
"submittedAt": "提交时间", "submittedAt": "提交时间",
"finishedAt": "完成时间", "finishedAt": "完成时间",
"assigneeType": "对象类型",
"assigneeTarget": "审批对象", "assigneeTarget": "审批对象",
"selectedDeptCount": "已选 {value} 个部门",
"actedBy": "处理人", "actedBy": "处理人",
"actedAt": "处理时间", "actedAt": "处理时间",
"comment": "处理意见", "comment": "处理意见",
@@ -112,6 +117,7 @@
"message": { "message": {
"needStep": "至少需要一个审批步骤", "needStep": "至少需要一个审批步骤",
"needStepAssignee": "每个审批步骤都需要配置审批对象", "needStepAssignee": "每个审批步骤都需要配置审批对象",
"confirmAssigneeTypeChange": "切换审批方式会清空当前已选对象,确认继续吗?",
"saveSuccess": "审批流程已保存", "saveSuccess": "审批流程已保存",
"statusUpdated": "流程状态已更新", "statusUpdated": "流程状态已更新",
"deleteSuccess": "流程已删除", "deleteSuccess": "流程已删除",

View File

@@ -13,5 +13,18 @@
"modified": "修改时间", "modified": "修改时间",
"modifiedBy": "修改者", "modifiedBy": "修改者",
"remark": "备注", "remark": "备注",
"isDeleted": "删除标识" "isDeleted": "删除标识",
"enable": "启用",
"disable": "禁用",
"batchEnable": "批量启用",
"batchDisable": "批量禁用",
"batchDelete": "批量删除",
"selectedCount": "已选 {count} 项",
"selectRequired": "请先选择要操作的部门",
"statusConfirm": "确认{action}部门“{name}”吗?",
"batchStatusConfirm": "确认{action}已选中的 {count} 个部门吗?",
"deleteConfirm": "确认删除部门“{name}”吗?删除后不可恢复。",
"batchDeleteConfirm": "确认删除已选中的 {count} 个部门吗?删除后不可恢复。",
"operationSuccess": "操作成功",
"operationFailed": "操作失败,请稍后重试"
} }

View File

@@ -206,13 +206,44 @@ function formatApplicationReason(value?: null | string) {
} }
function formatAssigneeDisplay(row: Record<string, any>) { function formatAssigneeDisplay(row: Record<string, any>) {
if (!row?.assigneeType || !row?.assigneeTargetName) { if (!row?.assigneeType) {
return '-'; return '-';
} }
if (row.assigneeType === 'ROLE') { const labelMap: Record<string, string> = {
return `${$t('approval.assignee.role')}${row.assigneeTargetName}`; DEPT: $t('approval.assignee.dept'),
ROLE: $t('approval.assignee.role'),
USER: $t('approval.assignee.user'),
};
let targets: Array<Record<string, any>> = [];
if (Array.isArray(row.assigneeTargets) && row.assigneeTargets.length > 0) {
targets = row.assigneeTargets;
} else if (row.assigneeTargetName) {
targets = [
{
includeChildren: 0,
targetName: row.assigneeTargetName,
},
];
} }
return `${$t('approval.assignee.user')}${row.assigneeTargetName}`; const targetNames = targets
.map((target: Record<string, any>) => {
const name = String(target?.targetName || '').trim();
if (!name) {
return '';
}
if (
row.assigneeType === 'DEPT' &&
(target.includeChildren === 1 || target.includeChildren === true)
) {
return `${name}${$t('approval.fields.includeChildren')}`;
}
return name;
})
.filter(Boolean);
if (targetNames.length === 0) {
return '-';
}
return `${labelMap[row.assigneeType] || row.assigneeType}${targetNames.join('、')}`;
} }
function getEventTypeLabel(eventType?: string) { function getEventTypeLabel(eventType?: string) {
@@ -384,7 +415,7 @@ function formatEventInfo(row: Record<string, any>) {
</ElTableColumn> </ElTableColumn>
<ElTableColumn <ElTableColumn
:label="$t('approval.fields.assigneeTarget')" :label="$t('approval.fields.assigneeTarget')"
width="220" min-width="260"
> >
<template #default="{ row }"> <template #default="{ row }">
{{ formatAssigneeDisplay(row) }} {{ formatAssigneeDisplay(row) }}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormInstance } from 'element-plus'; import type { FormInstance } from 'element-plus';
import { nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui'; import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -13,6 +13,7 @@ import {
ElInput, ElInput,
ElInputNumber, ElInputNumber,
ElMessage, ElMessage,
ElMessageBox,
ElOption, ElOption,
ElSelect, ElSelect,
ElSwitch, ElSwitch,
@@ -32,7 +33,7 @@ defineExpose({
type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW'; type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH'; type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
type AssigneeType = 'ROLE' | 'USER'; type AssigneeType = 'DEPT' | 'ROLE' | 'USER';
type ScopeType = 'CATEGORY' | 'DEPT'; type ScopeType = 'CATEGORY' | 'DEPT';
type FlowStatus = 'DISABLED' | 'ENABLED'; type FlowStatus = 'DISABLED' | 'ENABLED';
@@ -49,14 +50,21 @@ interface ScopeItem {
} }
interface StepItem { interface StepItem {
assigneeTargetCode?: string; assigneeTargetIds: Array<number | string>;
assigneeTargetId?: number | string; assigneeTargets: AssigneeTarget[];
assigneeTargetName?: string;
assigneeType: AssigneeType; assigneeType: AssigneeType;
includeChildrenByTarget: Record<string, boolean>;
id?: number | string; id?: number | string;
stepName: string; stepName: string;
} }
interface AssigneeTarget {
includeChildren: boolean;
targetCode?: string;
targetId: number | string;
targetName?: string;
}
interface FlowFormModel { interface FlowFormModel {
actionType: ActionType; actionType: ActionType;
id?: number | string; id?: number | string;
@@ -94,14 +102,16 @@ const SCOPE_TYPE_OPTIONS = [
const ASSIGNEE_TYPE_OPTIONS: Array<{ label: string; value: AssigneeType }> = [ const ASSIGNEE_TYPE_OPTIONS: Array<{ label: string; value: AssigneeType }> = [
{ label: $t('approval.assignee.role'), value: 'ROLE' }, { label: $t('approval.assignee.role'), value: 'ROLE' },
{ label: $t('approval.assignee.user'), value: 'USER' }, { label: $t('approval.assignee.user'), value: 'USER' },
{ label: $t('approval.assignee.dept'), value: 'DEPT' },
]; ];
const ENABLED_DATA_STATUS = 1;
const ASSIGNEE_VISIBLE_TAG_COUNT = 2;
const saveForm = ref<FormInstance>(); const saveForm = ref<FormInstance>();
const dialogVisible = ref(false); const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const btnLoading = ref(false); const btnLoading = ref(false);
const categoryLoaded = ref(false); const categoryLoaded = ref(false);
const deptLoaded = ref(false);
const roleLoaded = ref(false); const roleLoaded = ref(false);
const accountLoading = ref(false); const accountLoading = ref(false);
const deptTreeOptions = ref<any[]>([]); const deptTreeOptions = ref<any[]>([]);
@@ -112,6 +122,19 @@ const categoryOptions = ref<Record<Exclude<ResourceType, ''>, SelectOption[]>>({
KNOWLEDGE: [], KNOWLEDGE: [],
WORKFLOW: [], WORKFLOW: [],
}); });
const deptOptionMap = computed(() => {
const result = new Map<string, string>();
const visit = (items: any[]) => {
for (const item of items) {
result.set(String(item.id), item.deptName || String(item.id));
if (Array.isArray(item.children)) {
visit(item.children);
}
}
};
visit(deptTreeOptions.value);
return result;
});
const formModel = ref<FlowFormModel>(buildDefaultForm()); const formModel = ref<FlowFormModel>(buildDefaultForm());
@@ -158,7 +181,17 @@ function buildDefaultForm(): FlowFormModel {
resourceType: 'BOT', resourceType: 'BOT',
scopes: [], scopes: [],
status: 'ENABLED', status: 'ENABLED',
steps: [{ assigneeType: 'ROLE', stepName: '' }], steps: [buildDefaultStep()],
};
}
function buildDefaultStep(): StepItem {
return {
assigneeTargetIds: [],
assigneeTargets: [],
assigneeType: 'ROLE',
includeChildrenByTarget: {},
stepName: '',
}; };
} }
@@ -200,17 +233,20 @@ async function openDialog(row: any = {}) {
scopeValue: item.scopeValue, scopeValue: item.scopeValue,
})), })),
status: res.data?.status || 'ENABLED', status: res.data?.status || 'ENABLED',
steps: (res.data?.steps || []).map((item: any) => ({ steps: (res.data?.steps || []).map((item: any) => {
assigneeTargetCode: item.assigneeTargetCode, const targets = normalizeAssigneeTargets(item);
assigneeTargetId: item.assigneeTargetId, return {
assigneeTargetName: item.assigneeTargetName, assigneeTargetIds: targets.map((target) => target.targetId),
assigneeTargets: targets,
assigneeType: item.assigneeType || 'ROLE', assigneeType: item.assigneeType || 'ROLE',
id: item.id, id: item.id,
includeChildrenByTarget: buildIncludeChildrenByTarget(targets),
stepName: item.stepName, stepName: item.stepName,
})), };
}),
}; };
if (formModel.value.steps.length === 0) { if (formModel.value.steps.length === 0) {
formModel.value.steps = [{ assigneeType: 'ROLE', stepName: '' }]; formModel.value.steps = [buildDefaultStep()];
} }
for (const step of formModel.value.steps) { for (const step of formModel.value.steps) {
registerAccountOption(step); registerAccountOption(step);
@@ -249,18 +285,15 @@ async function ensureCategoryOptions() {
} }
async function ensureDeptOptions() { async function ensureDeptOptions() {
if (deptLoaded.value) {
return;
}
const res = await api.get('/api/v1/sysDept/list', { const res = await api.get('/api/v1/sysDept/list', {
params: { params: {
asTree: true, asTree: true,
sortKey: 'sortNo', sortKey: 'sortNo',
sortType: 'asc', sortType: 'asc',
status: ENABLED_DATA_STATUS,
}, },
}); });
deptTreeOptions.value = Array.isArray(res.data) ? res.data : []; deptTreeOptions.value = Array.isArray(res.data) ? res.data : [];
deptLoaded.value = true;
} }
async function ensureRoleOptions() { async function ensureRoleOptions() {
@@ -310,6 +343,41 @@ function normalizeSelectOptions(data: any[] = []) {
})); }));
} }
function normalizeAssigneeTargets(item: any): AssigneeTarget[] {
const targets = Array.isArray(item?.assigneeTargets)
? item.assigneeTargets
: [];
if (targets.length > 0) {
return targets
.filter((target: any) => target?.targetId)
.map((target: any) => ({
includeChildren: target.includeChildren === 1,
targetCode: target.targetCode,
targetId: target.targetId,
targetName: target.targetName,
}));
}
if (!item?.assigneeTargetId) {
return [];
}
return [
{
includeChildren: false,
targetCode: item.assigneeTargetCode,
targetId: item.assigneeTargetId,
targetName: item.assigneeTargetName,
},
];
}
function buildIncludeChildrenByTarget(targets: AssigneeTarget[]) {
const result: Record<string, boolean> = {};
for (const target of targets) {
result[String(target.targetId)] = target.includeChildren;
}
return result;
}
function addScope() { function addScope() {
formModel.value.scopes.push({ formModel.value.scopes.push({
includeChildren: false, includeChildren: false,
@@ -323,7 +391,7 @@ function removeScope(index: number) {
} }
function addStep() { function addStep() {
formModel.value.steps.push({ assigneeType: 'ROLE', stepName: '' }); formModel.value.steps.push(buildDefaultStep());
} }
function removeStep(index: number) { function removeStep(index: number) {
@@ -341,51 +409,110 @@ function getCategoryScopeOptions() {
return categoryOptions.value[resourceType]; return categoryOptions.value[resourceType];
} }
function handleAssigneeTypeChange(step: StepItem) { async function handleAssigneeTypeChange(
step.assigneeTargetId = undefined; step: StepItem,
step.assigneeTargetCode = ''; assigneeType: AssigneeType,
step.assigneeTargetName = ''; ) {
if (step.assigneeType === 'USER') { if (step.assigneeType === assigneeType) {
return;
}
if (step.assigneeTargetIds.length > 0) {
try {
await ElMessageBox.confirm(
$t('approval.message.confirmAssigneeTypeChange'),
$t('approval.fields.assigneeTarget'),
{
cancelButtonText: $t('button.cancel'),
confirmButtonText: $t('button.confirm'),
type: 'warning',
},
);
} catch (error) {
if (error === 'cancel' || error === 'close') {
return;
}
throw error;
}
}
step.assigneeType = assigneeType;
step.assigneeTargetIds = [];
step.assigneeTargets = [];
step.includeChildrenByTarget = {};
if (assigneeType === 'USER') {
void searchAccountOptions(''); void searchAccountOptions('');
} }
} }
function handleAssigneeTargetChange(step: StepItem) { function handleAssigneeTargetChange(step: StepItem) {
const options = const selectedIds = new Set(step.assigneeTargetIds.map(String));
step.assigneeType === 'ROLE' ? roleOptions.value : accountOptions.value; step.assigneeTargets = step.assigneeTargets.filter((target) =>
const selected = options.find( selectedIds.has(String(target.targetId)),
(item) => String(item.id) === String(step.assigneeTargetId),
); );
step.assigneeTargetName = selected?.label || ''; for (const targetId of Object.keys(step.includeChildrenByTarget)) {
if (!selectedIds.has(targetId)) {
delete step.includeChildrenByTarget[targetId];
}
}
for (const targetId of step.assigneeTargetIds) {
const targetKey = String(targetId);
if (!(targetKey in step.includeChildrenByTarget)) {
step.includeChildrenByTarget[targetKey] = false;
}
}
}
function removeAssigneeTarget(step: StepItem, targetId: number | string) {
const targetKey = String(targetId);
step.assigneeTargetIds = step.assigneeTargetIds.filter(
(item) => String(item) !== targetKey,
);
handleAssigneeTargetChange(step);
}
function getDeptTargetName(targetId: number | string) {
return deptOptionMap.value.get(String(targetId)) || String(targetId);
} }
function registerAccountOption(step?: StepItem) { function registerAccountOption(step?: StepItem) {
if (!step?.assigneeTargetId || !step.assigneeTargetName) { if (!step || step.assigneeType !== 'USER') {
return; return;
} }
for (const target of step.assigneeTargets) {
if ( if (
!target.targetName ||
accountOptions.value.some( accountOptions.value.some(
(item) => String(item.id) === String(step.assigneeTargetId), (item) => String(item.id) === String(target.targetId),
) )
) { ) {
return; continue;
} }
accountOptions.value.push({ accountOptions.value.push({
id: step.assigneeTargetId, id: target.targetId,
label: step.assigneeTargetName, label: target.targetName,
}); });
}
} }
async function save() { async function save() {
saveForm.value?.validate(async (valid) => { if (btnLoading.value) {
if (!valid) {
return; return;
} }
btnLoading.value = true; btnLoading.value = true;
try { try {
const valid = await saveForm.value?.validate().catch(() => false);
if (!valid) {
return;
}
const steps = formModel.value.steps const steps = formModel.value.steps
.map((step, index) => ({ .map((step, index) => ({
assigneeTargetId: step.assigneeTargetId, assigneeTargets: step.assigneeTargetIds.map((targetId) => ({
includeChildren:
step.assigneeType === 'DEPT' &&
step.includeChildrenByTarget[String(targetId)]
? 1
: 0,
targetId,
})),
assigneeType: step.assigneeType, assigneeType: step.assigneeType,
id: step.id, id: step.id,
stepName: step.stepName?.trim(), stepName: step.stepName?.trim(),
@@ -394,12 +521,14 @@ async function save() {
.filter((step) => step.stepName); .filter((step) => step.stepName);
if (steps.length === 0) { if (steps.length === 0) {
ElMessage.warning($t('approval.message.needStep')); ElMessage.warning($t('approval.message.needStep'));
btnLoading.value = false;
return; return;
} }
if (steps.some((step) => !step.assigneeType || !step.assigneeTargetId)) { if (
steps.some(
(step) => !step.assigneeType || step.assigneeTargets.length === 0,
)
) {
ElMessage.warning($t('approval.message.needStepAssignee')); ElMessage.warning($t('approval.message.needStepAssignee'));
btnLoading.value = false;
return; return;
} }
@@ -429,7 +558,6 @@ async function save() {
: '/api/v1/approvalFlow/update'; : '/api/v1/approvalFlow/update';
const res = await api.post(url, payload); const res = await api.post(url, payload);
if (res.errorCode !== 0) { if (res.errorCode !== 0) {
btnLoading.value = false;
return; return;
} }
ElMessage.success($t('approval.message.saveSuccess')); ElMessage.success($t('approval.message.saveSuccess'));
@@ -438,7 +566,6 @@ async function save() {
} finally { } finally {
btnLoading.value = false; btnLoading.value = false;
} }
});
} }
function closeDialog() { function closeDialog() {
@@ -600,17 +727,36 @@ function closeDialog() {
<div <div
v-for="(step, index) in formModel.steps" v-for="(step, index) in formModel.steps"
:key="`step-${index}`" :key="`step-${index}`"
class="grid grid-cols-1 gap-3 rounded-2xl border border-[hsl(var(--divider-faint)/0.66)] bg-[hsl(var(--surface-panel)/0.72)] p-4 md:grid-cols-[72px,minmax(0,1.1fr),148px,minmax(0,1fr),88px]" class="approval-flow-modal__step-card"
>
<div
class="flex items-center text-sm font-medium text-[hsl(var(--text-muted))]"
> >
<div class="approval-flow-modal__step-header">
<span class="approval-flow-modal__step-index">
{{ $t('approval.fields.stepNo', { value: index + 1 }) }} {{ $t('approval.fields.stepNo', { value: index + 1 }) }}
</span>
<ElButton
text
type="danger"
:disabled="formModel.steps.length <= 1"
@click="removeStep(index)"
>
{{ $t('button.delete') }}
</ElButton>
</div> </div>
<div class="approval-flow-modal__step-grid">
<div class="approval-flow-modal__step-field">
<span class="approval-flow-modal__step-field-label">
{{ $t('approval.fields.stepName') }}
</span>
<ElInput <ElInput
v-model.trim="step.stepName" v-model.trim="step.stepName"
:placeholder="$t('approval.placeholder.stepName')" :placeholder="$t('approval.placeholder.stepName')"
/> />
</div>
<div class="approval-flow-modal__step-field">
<span class="approval-flow-modal__step-field-label">
{{ $t('approval.fields.assigneeType') }}
</span>
<div class="approval-flow-modal__assignee-type"> <div class="approval-flow-modal__assignee-type">
<button <button
v-for="item in ASSIGNEE_TYPE_OPTIONS" v-for="item in ASSIGNEE_TYPE_OPTIONS"
@@ -621,20 +767,44 @@ function closeDialog() {
'is-active': step.assigneeType === item.value, 'is-active': step.assigneeType === item.value,
}" }"
:aria-pressed="step.assigneeType === item.value" :aria-pressed="step.assigneeType === item.value"
@click=" @click="handleAssigneeTypeChange(step, item.value)"
step.assigneeType !== item.value &&
((step.assigneeType = item.value),
handleAssigneeTypeChange(step))
"
> >
{{ item.label }} {{ item.label }}
</button> </button>
</div> </div>
</div>
<div
class="approval-flow-modal__step-field approval-flow-modal__target-field"
>
<div class="approval-flow-modal__target-heading">
<span class="approval-flow-modal__step-field-label">
{{ $t('approval.fields.assigneeTarget') }}
</span>
<span
v-if="
step.assigneeType === 'DEPT' &&
step.assigneeTargetIds.length > 0
"
class="approval-flow-modal__target-count"
>
{{
$t('approval.fields.selectedDeptCount', {
value: step.assigneeTargetIds.length,
})
}}
</span>
</div>
<ElSelect <ElSelect
v-if="step.assigneeType === 'ROLE'" v-if="step.assigneeType === 'ROLE'"
v-model="step.assigneeTargetId" v-model="step.assigneeTargetIds"
filterable filterable
clearable clearable
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="ASSIGNEE_VISIBLE_TAG_COUNT"
:placeholder="$t('approval.placeholder.assigneeTarget')" :placeholder="$t('approval.placeholder.assigneeTarget')"
@change="handleAssigneeTargetChange(step)" @change="handleAssigneeTargetChange(step)"
> >
@@ -645,18 +815,25 @@ function closeDialog() {
:value="item.id" :value="item.id"
/> />
</ElSelect> </ElSelect>
<ElSelect <ElSelect
v-else v-else-if="step.assigneeType === 'USER'"
v-model="step.assigneeTargetId" v-model="step.assigneeTargetIds"
filterable filterable
clearable clearable
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="ASSIGNEE_VISIBLE_TAG_COUNT"
remote remote
reserve-keyword reserve-keyword
:loading="accountLoading" :loading="accountLoading"
:placeholder="$t('approval.placeholder.assigneeTarget')" :placeholder="$t('approval.placeholder.assigneeTarget')"
:remote-method="searchAccountOptions" :remote-method="searchAccountOptions"
@change="handleAssigneeTargetChange(step)" @change="handleAssigneeTargetChange(step)"
@visible-change="(visible) => visible && searchAccountOptions('')" @visible-change="
(visible) => visible && searchAccountOptions('')
"
> >
<ElOption <ElOption
v-for="item in accountOptions" v-for="item in accountOptions"
@@ -665,19 +842,70 @@ function closeDialog() {
:value="item.id" :value="item.id"
/> />
</ElSelect> </ElSelect>
<ElButton
text <div v-else class="approval-flow-modal__dept-target">
type="danger" <ElTreeSelect
:disabled="formModel.steps.length <= 1" v-model="step.assigneeTargetIds"
@click="removeStep(index)" clearable
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="ASSIGNEE_VISIBLE_TAG_COUNT"
check-strictly
:data="deptTreeOptions"
:props="{
label: 'deptName',
children: 'children',
value: 'id',
}"
:placeholder="$t('approval.placeholder.assigneeTarget')"
@change="handleAssigneeTargetChange(step)"
/>
<div
v-if="step.assigneeTargetIds.length > 0"
class="approval-flow-modal__dept-target-list"
> >
{{ $t('button.delete') }} <div
v-for="targetId in step.assigneeTargetIds"
:key="String(targetId)"
class="approval-flow-modal__dept-target-option"
>
<span
class="approval-flow-modal__dept-target-name"
:title="getDeptTargetName(targetId)"
>
{{ getDeptTargetName(targetId) }}
</span>
<div class="approval-flow-modal__dept-target-actions">
<label>
{{ $t('approval.fields.includeChildren') }}
<ElSwitch
v-model="
step.includeChildrenByTarget[String(targetId)]
"
/>
</label>
<ElButton
link
type="danger"
@click="removeAssigneeTarget(step, targetId)"
>
{{ $t('approval.action.removeTarget') }}
</ElButton> </ElButton>
</div> </div>
</div>
</div>
</div>
</div>
</div>
</div>
<ElButton text type="primary" class="w-fit" @click="addStep"> <ElButton text type="primary" class="w-fit" @click="addStep">
{{ $t('approval.action.addStep') }} {{ $t('approval.action.addStep') }}
</ElButton> </ElButton>
<p class="approval-flow-modal__assignee-helper">
{{ $t('approval.helper.assigneeAny') }}
</p>
</div> </div>
</ElForm> </ElForm>
</EasyFlowFormModal> </EasyFlowFormModal>
@@ -692,16 +920,16 @@ function closeDialog() {
.approval-flow-modal__section-meta { .approval-flow-modal__section-meta {
display: flex; display: flex;
gap: 16px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px;
} }
.approval-flow-modal__section-meta p { .approval-flow-modal__section-meta p {
margin: 0; margin: 0;
color: hsl(var(--text-muted));
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
color: hsl(var(--text-muted));
} }
.approval-flow-modal__scope-list { .approval-flow-modal__scope-list {
@@ -712,8 +940,8 @@ function closeDialog() {
.approval-flow-modal__scope-empty { .approval-flow-modal__scope-empty {
padding: 4px 0 2px; padding: 4px 0 2px;
color: hsl(var(--text-muted));
font-size: 13px; font-size: 13px;
color: hsl(var(--text-muted));
} }
.approval-flow-modal__scope-row { .approval-flow-modal__scope-row {
@@ -724,7 +952,7 @@ function closeDialog() {
} }
.approval-flow-modal__scope-row + .approval-flow-modal__scope-row { .approval-flow-modal__scope-row + .approval-flow-modal__scope-row {
border-top: 1px solid hsl(var(--divider-faint) / 0.72); border-top: 1px solid hsl(var(--divider-faint) / 72%);
} }
.approval-flow-modal__scope-control { .approval-flow-modal__scope-control {
@@ -733,40 +961,90 @@ function closeDialog() {
.approval-flow-modal__scope-switch { .approval-flow-modal__scope-switch {
display: flex; display: flex;
align-items: center;
gap: 10px; gap: 10px;
color: hsl(var(--text-muted)); align-items: center;
font-size: 13px; font-size: 13px;
color: hsl(var(--text-muted));
}
.approval-flow-modal__step-card {
padding: var(--space-4);
background: hsl(var(--surface-panel) / 78%);
border: 1px solid hsl(var(--divider-faint) / 72%);
border-radius: var(--radius-panel);
}
.approval-flow-modal__step-header {
display: flex;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
min-height: 32px;
margin-bottom: var(--space-3);
}
.approval-flow-modal__step-index {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 var(--space-3);
font-size: 13px;
font-weight: 600;
color: hsl(var(--primary));
background: hsl(var(--primary) / 9%);
border-radius: var(--radius-pill);
}
.approval-flow-modal__step-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
.approval-flow-modal__step-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.approval-flow-modal__step-field-label {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: hsl(var(--text-strong));
} }
.approval-flow-modal__assignee-type { .approval-flow-modal__assignee-type {
display: inline-flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
width: fit-content; align-items: center;
width: 100%;
min-height: 40px; min-height: 40px;
padding: 4px; padding: 4px;
border: 1px solid hsl(var(--divider-faint) / 0.72); background: hsl(var(--surface-contrast-soft) / 82%);
border-radius: 14px; border: 1px solid hsl(var(--divider-faint) / 72%);
background: hsl(var(--surface-contrast-soft) / 0.82); border-radius: var(--radius-toolbar);
} }
.approval-flow-modal__assignee-type-item { .approval-flow-modal__assignee-type-item {
min-width: 64px; flex: 1;
min-width: 0;
height: 32px; height: 32px;
padding: 0 14px; padding: 0 var(--space-3);
border: 0;
border-radius: 10px;
background: transparent;
color: hsl(var(--text-muted));
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: 32px; line-height: 32px;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
transition: transition:
background-color 0.18s ease, background-color 0.18s ease,
color 0.18s ease, color 0.18s ease,
box-shadow 0.18s ease; box-shadow 0.18s ease;
cursor: pointer;
} }
.approval-flow-modal__assignee-type-item:hover { .approval-flow-modal__assignee-type-item:hover {
@@ -774,14 +1052,86 @@ function closeDialog() {
} }
.approval-flow-modal__assignee-type-item:focus-visible { .approval-flow-modal__assignee-type-item:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28); outline: 2px solid hsl(var(--primary) / 28%);
outline-offset: 1px; outline-offset: 1px;
} }
.approval-flow-modal__assignee-type-item.is-active { .approval-flow-modal__assignee-type-item.is-active {
color: hsl(var(--primary)); color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12); background: hsl(var(--primary) / 12%);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.14); box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
}
.approval-flow-modal__target-heading {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
}
.approval-flow-modal__target-count {
font-size: 12px;
line-height: 1.4;
color: hsl(var(--text-muted));
}
.approval-flow-modal__dept-target {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.approval-flow-modal__dept-target-list {
max-height: 176px;
padding: 0 var(--space-2);
overflow-y: auto;
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--divider-faint) / 72%);
border-radius: var(--radius-toolbar);
}
.approval-flow-modal__dept-target-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-3);
align-items: center;
min-height: 48px;
padding: var(--space-2) 0;
font-size: 13px;
color: hsl(var(--text-muted));
}
.approval-flow-modal__dept-target-option
+ .approval-flow-modal__dept-target-option {
border-top: 1px solid hsl(var(--divider-faint) / 72%);
}
.approval-flow-modal__dept-target-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--foreground));
white-space: nowrap;
}
.approval-flow-modal__dept-target-actions {
display: inline-flex;
flex: none;
gap: var(--space-3);
align-items: center;
}
.approval-flow-modal__dept-target-actions label {
display: inline-flex;
gap: var(--space-2);
align-items: center;
white-space: nowrap;
}
.approval-flow-modal__assignee-helper {
margin: -4px 0 0;
font-size: 13px;
color: hsl(var(--text-muted));
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -789,5 +1139,35 @@ function closeDialog() {
grid-template-columns: 148px minmax(0, 1fr) 128px 72px; grid-template-columns: 148px minmax(0, 1fr) 128px 72px;
align-items: center; align-items: center;
} }
.approval-flow-modal__step-grid {
grid-template-columns: minmax(0, 1fr) minmax(216px, 0.8fr);
}
.approval-flow-modal__target-field {
grid-column: 1 / -1;
}
}
@media (min-width: 1200px) {
.approval-flow-modal__step-grid {
grid-template-columns:
minmax(180px, 0.85fr) minmax(216px, 0.72fr)
minmax(280px, 1.35fr);
}
.approval-flow-modal__target-field {
grid-column: auto;
}
}
@media (max-width: 560px) {
.approval-flow-modal__dept-target-option {
grid-template-columns: minmax(0, 1fr);
}
.approval-flow-modal__dept-target-actions {
justify-content: space-between;
}
} }
</style> </style>

View File

@@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormInstance } from 'element-plus'; import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue'; import { Plus } from '@element-plus/icons-vue';
import { import {
ElButton, ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm, ElForm,
ElFormItem, ElFormItem,
ElIcon, ElIcon,
@@ -20,89 +17,243 @@ import {
} from 'element-plus'; } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { useDictStore } from '#/store'; import { useDictStore } from '#/store';
import SysDeptModal from './SysDeptModal.vue'; import SysDeptModal from './SysDeptModal.vue';
onMounted(() => { onMounted(() => {
getTree(); void getTree();
initDict(); initDict();
}); });
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const tableRef = ref<InstanceType<typeof ElTable>>();
const treeData = ref([]); const treeData = ref([]);
const selectedRows = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const batchActionType = ref<'' | 'delete' | 'disable' | 'enable'>('');
const rowDeleteLoadingId = ref('');
const rowStatusLoadingId = ref('');
const saveDialog = ref(); const saveDialog = ref();
const formInline = ref({ const formInline = ref({
deptName: '', deptName: '',
status: '',
}); });
const dictStore = useDictStore(); const dictStore = useDictStore();
const selectedCount = computed(() => selectedRows.value.length);
const batchActionLoading = computed(() => batchActionType.value !== '');
const rowActionLoading = computed(
() => rowDeleteLoadingId.value !== '' || rowStatusLoadingId.value !== '',
);
function initDict() { function initDict() {
dictStore.fetchDictionary('dataStatus'); dictStore.fetchDictionary('dataStatus');
} }
function search(formEl: FormInstance | undefined) { function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => { formEl?.validate((valid) => {
if (valid) { if (valid) {
getTree(); void getTree();
} }
}); });
} }
function reset(formEl: FormInstance | undefined) { function reset(formEl: FormInstance | undefined) {
formEl?.resetFields(); formEl?.resetFields();
getTree(); void getTree();
} }
function showDialog(row: any) { function showDialog(row: any) {
saveDialog.value.openDialog({ ...row }); saveDialog.value.openDialog({ ...row });
} }
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), { function isRootDept(row: any) {
return row?.deptCode === 'root_dept';
}
function isSelectable(row: any) {
return !isRootDept(row);
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows;
}
function clearSelection() {
selectedRows.value = [];
tableRef.value?.clearSelection();
}
function getSelectedIds() {
return selectedRows.value
.map((row) => row?.id)
.filter((id) => id !== null && id !== undefined);
}
function getErrorMessage(error: any) {
return error?.message || $t('sysDept.operationFailed');
}
async function confirmAction(message: string) {
try {
await ElMessageBox.confirm(message, $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'), confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'), cancelButtonText: $t('message.cancel'),
type: 'warning', type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/sysDept/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
}); });
} else { return true;
done(); } catch {
return false;
} }
},
}).catch(() => {});
} }
function getTree() {
async function changeStatus(row: any) {
const status = row.status === 1 ? 0 : 1;
const action = status === 1 ? $t('sysDept.enable') : $t('sysDept.disable');
const confirmed = await confirmAction(
$t('sysDept.statusConfirm', {
action,
name: row.deptName,
}),
);
if (!confirmed) {
return;
}
rowStatusLoadingId.value = String(row.id);
try {
const res = await api.post('/api/v1/sysDept/changeStatusBatch', {
ids: [row.id],
status,
});
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysDept.operationFailed'));
return;
}
ElMessage.success($t('sysDept.operationSuccess'));
await getTree();
} catch (error: any) {
ElMessage.error(getErrorMessage(error));
} finally {
rowStatusLoadingId.value = '';
}
}
async function changeSelectedStatus(status: 0 | 1) {
const ids = getSelectedIds();
if (ids.length === 0) {
ElMessage.warning($t('sysDept.selectRequired'));
return;
}
const action = status === 1 ? $t('sysDept.enable') : $t('sysDept.disable');
const confirmed = await confirmAction(
$t('sysDept.batchStatusConfirm', {
action,
count: ids.length,
}),
);
if (!confirmed) {
return;
}
batchActionType.value = status === 1 ? 'enable' : 'disable';
try {
const res = await api.post('/api/v1/sysDept/changeStatusBatch', {
ids,
status,
});
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysDept.operationFailed'));
return;
}
ElMessage.success($t('sysDept.operationSuccess'));
await getTree();
} catch (error: any) {
ElMessage.error(getErrorMessage(error));
} finally {
batchActionType.value = '';
}
}
async function submitDelete(
url: string,
payload: Record<string, any>,
confirmMessage: string,
isBatch: boolean,
) {
const confirmed = await confirmAction(confirmMessage);
if (!confirmed) {
return;
}
if (isBatch) {
batchActionType.value = 'delete';
} else {
rowDeleteLoadingId.value = String(payload.id);
}
try {
const res = await api.post(url, payload);
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysDept.operationFailed'));
return;
}
ElMessage.success($t('sysDept.operationSuccess'));
await getTree();
} catch (error: any) {
ElMessage.error(getErrorMessage(error));
} finally {
if (isBatch) {
batchActionType.value = '';
} else {
rowDeleteLoadingId.value = '';
}
}
}
function remove(row: any) {
void submitDelete(
'/api/v1/sysDept/remove',
{ id: row.id },
$t('sysDept.deleteConfirm', { name: row.deptName }),
false,
);
}
function removeSelected() {
const ids = getSelectedIds();
if (ids.length === 0) {
ElMessage.warning($t('sysDept.selectRequired'));
return;
}
void submitDelete(
'/api/v1/sysDept/removeBatch',
{ ids },
$t('sysDept.batchDeleteConfirm', { count: ids.length }),
true,
);
}
async function getTree() {
loading.value = true; loading.value = true;
api try {
.get('/api/v1/sysDept/list', { const res = await api.get('/api/v1/sysDept/list', {
params: { params: {
asTree: true, asTree: true,
...formInline.value, ...formInline.value,
}, },
})
.then((res) => {
loading.value = false;
treeData.value = res.data;
}); });
treeData.value = res.data || [];
clearSelection();
} finally {
loading.value = false;
}
} }
</script> </script>
<template> <template>
<div class="flex h-full flex-col gap-1.5 p-6"> <div class="flex h-full flex-col gap-1.5 p-6">
<SysDeptModal ref="saveDialog" @reload="reset" /> <SysDeptModal ref="saveDialog" @reload="reset" />
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-start justify-between gap-3">
<ElForm ref="formRef" :inline="true" :model="formInline"> <ElForm ref="formRef" :inline="true" :model="formInline" class="min-w-0">
<ElFormItem prop="deptName" class="!mr-3"> <ElFormItem prop="deptName" class="!mr-3">
<ElInput <ElInput
class="search-input" class="search-input"
@@ -110,6 +261,14 @@ function getTree() {
:placeholder="$t('sysDept.deptName')" :placeholder="$t('sysDept.deptName')"
/> />
</ElFormItem> </ElFormItem>
<ElFormItem prop="status" class="!mr-3">
<DictSelect
class="status-select"
v-model="formInline.status"
dict-code="dataStatus"
:placeholder="$t('sysDept.status')"
/>
</ElFormItem>
<ElFormItem> <ElFormItem>
<ElButton @click="search(formRef)" type="primary"> <ElButton @click="search(formRef)" type="primary">
{{ $t('button.query') }} {{ $t('button.query') }}
@@ -119,9 +278,42 @@ function getTree() {
</ElButton> </ElButton>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<div class="handle-div"> <div class="department-actions">
<div v-if="selectedCount > 0" class="department-batch-actions">
<ElButton <ElButton
v-access:code="'/api/v1/sysDept/save'" v-access:code="'/api/v1/sysDept/save'"
class="department-batch-action is-enable"
:disabled="batchActionLoading || rowActionLoading"
:loading="batchActionType === 'enable'"
text
@click="changeSelectedStatus(1)"
>
{{ $t('sysDept.batchEnable') }}
</ElButton>
<ElButton
v-access:code="'/api/v1/sysDept/save'"
class="department-batch-action is-disable"
:disabled="batchActionLoading || rowActionLoading"
:loading="batchActionType === 'disable'"
text
@click="changeSelectedStatus(0)"
>
{{ $t('sysDept.batchDisable') }}
</ElButton>
<ElButton
v-access:code="'/api/v1/sysDept/remove'"
class="department-batch-action is-danger"
:disabled="batchActionLoading || rowActionLoading"
:loading="batchActionType === 'delete'"
text
@click="removeSelected"
>
{{ $t('sysDept.batchDelete') }}
</ElButton>
</div>
<ElButton
v-access:code="'/api/v1/sysDept/save'"
class="department-add-action"
@click="showDialog({})" @click="showDialog({})"
type="primary" type="primary"
> >
@@ -134,7 +326,16 @@ function getTree() {
</div> </div>
<div class="bg-background border-border flex-1 rounded-lg border p-5"> <div class="bg-background border-border flex-1 rounded-lg border p-5">
<ElTable :data="treeData" row-key="id" v-loading="loading" border> <ElTable
ref="tableRef"
:data="treeData"
:tree-props="{ children: 'children', checkStrictly: true }"
row-key="id"
v-loading="loading"
border
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" :selectable="isSelectable" />
<ElTableColumn prop="deptName" :label="$t('sysDept.deptName')"> <ElTableColumn prop="deptName" :label="$t('sysDept.deptName')">
<template #default="{ row }"> <template #default="{ row }">
{{ row.deptName }} {{ row.deptName }}
@@ -150,6 +351,11 @@ function getTree() {
{{ row.sortNo }} {{ row.sortNo }}
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn prop="status" :label="$t('sysDept.status')" width="120">
<template #default="{ row }">
{{ dictStore.getDictLabel('dataStatus', row.status) }}
</template>
</ElTableColumn>
<ElTableColumn prop="created" :label="$t('sysDept.created')"> <ElTableColumn prop="created" :label="$t('sysDept.created')">
<template #default="{ row }"> <template #default="{ row }">
{{ row.created }} {{ row.created }}
@@ -160,26 +366,61 @@ function getTree() {
{{ row.remark }} {{ row.remark }}
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn :label="$t('common.handle')" width="90" align="right"> <ElTableColumn
:label="$t('common.handle')"
width="224"
align="right"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="flex items-center gap-3"> <div class="department-row-actions">
<ElButton link type="primary" @click="showDialog(row)"> <ElButton
v-access:code="'/api/v1/sysDept/save'"
:disabled="
batchActionLoading ||
rowDeleteLoadingId !== '' ||
rowStatusLoadingId !== '' ||
(isRootDept(row) && row.status === 1)
"
:loading="rowStatusLoadingId === String(row.id)"
link
type="primary"
@click="changeStatus(row)"
>
{{
row.status === 1
? $t('sysDept.disable')
: $t('sysDept.enable')
}}
</ElButton>
<ElButton
v-access:code="'/api/v1/sysDept/save'"
:disabled="
batchActionLoading ||
rowDeleteLoadingId !== '' ||
rowStatusLoadingId !== ''
"
link
type="primary"
@click="showDialog(row)"
>
{{ $t('button.edit') }} {{ $t('button.edit') }}
</ElButton> </ElButton>
<ElButton
<ElDropdown> v-access:code="'/api/v1/sysDept/remove'"
<ElButton link :icon="MoreFilled" /> :disabled="
batchActionLoading ||
<template #dropdown> rowStatusLoadingId !== '' ||
<ElDropdownMenu> rowDeleteLoadingId !== '' ||
<ElDropdownItem @click="remove(row)"> isRootDept(row)
<ElButton link :icon="Delete" type="danger"> "
:loading="rowDeleteLoadingId === String(row.id)"
link
type="danger"
@click="remove(row)"
>
{{ $t('button.delete') }} {{ $t('button.delete') }}
</ElButton> </ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div> </div>
</template> </template>
</ElTableColumn> </ElTableColumn>
@@ -193,4 +434,121 @@ function getTree() {
width: 300px; width: 300px;
border-radius: 4px; border-radius: 4px;
} }
.status-select {
width: 160px;
}
.department-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
min-height: 32px;
}
.department-batch-actions {
box-sizing: border-box;
display: inline-flex;
gap: 2px;
align-items: center;
height: 32px;
padding: 0 3px;
background: hsl(var(--toolbar-bg));
border: 1px solid hsl(var(--toolbar-border));
border-radius: var(--radius-toolbar);
}
.department-actions :deep(.department-add-action) {
height: 32px;
}
.department-batch-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.department-batch-actions :deep(.department-batch-action) {
height: 30px;
min-height: 30px;
padding: 0 12px;
font-weight: 500;
background: transparent;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard),
box-shadow var(--motion-duration-base) var(--motion-ease-standard);
}
.department-batch-actions :deep(.department-batch-action.is-enable) {
color: hsl(var(--primary));
}
.department-batch-actions :deep(.department-batch-action.is-disable) {
color: hsl(var(--text-strong));
}
.department-batch-actions :deep(.department-batch-action.is-danger) {
color: hsl(var(--destructive));
}
.department-batch-actions
:deep(.department-batch-action.is-enable:not(.is-disabled):hover),
.department-batch-actions
:deep(.department-batch-action.is-enable:not(.is-disabled):focus-visible) {
color: hsl(var(--primary));
background: hsl(var(--primary) / 8%);
}
.department-batch-actions
:deep(.department-batch-action.is-disable:not(.is-disabled):hover),
.department-batch-actions
:deep(.department-batch-action.is-disable:not(.is-disabled):focus-visible) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft));
}
.department-batch-actions
:deep(.department-batch-action.is-danger:not(.is-disabled):hover),
.department-batch-actions
:deep(.department-batch-action.is-danger:not(.is-disabled):focus-visible) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 8%);
}
.department-batch-actions
:deep(.department-batch-action:not(.is-disabled):focus-visible) {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--primary) / 20%);
}
.department-row-actions {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
white-space: nowrap;
}
@media (max-width: 960px) {
.department-actions {
width: 100%;
}
}
@media (max-width: 640px) {
.department-actions {
justify-content: stretch;
}
.department-batch-actions {
flex: 1;
}
.department-batch-actions :deep(.department-batch-action) {
flex: 1;
padding: 0 8px;
}
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormInstance } from 'element-plus'; import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue'; import { ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui'; import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -12,24 +12,15 @@ import DictSelect from '#/components/dict/DictSelect.vue';
import { $t } from '#/locales'; import { $t } from '#/locales';
const emit = defineEmits(['reload']); const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({ defineExpose({
openDialog, openDialog,
}); });
const saveForm = ref<FormInstance>(); const saveForm = ref<FormInstance>();
// variables // variables
const ENABLED_STATUS = 1;
const dialogVisible = ref(false); const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const entity = ref<any>({ const entity = ref<any>(buildDefaultEntity());
parentId: '',
ancestors: '',
deptName: '',
deptCode: '',
sortNo: '',
status: '',
remark: '',
});
const btnLoading = ref(false); const btnLoading = ref(false);
const rules = ref({ const rules = ref({
parentId: [ parentId: [
@@ -43,40 +34,52 @@ const rules = ref({
], ],
}); });
// functions // functions
function openDialog(row: any) { function buildDefaultEntity() {
if (row.id) { return {
isAdd.value = false; ancestors: '',
} deptCode: '',
entity.value = row; deptName: '',
parentId: '',
remark: '',
sortNo: '',
status: ENABLED_STATUS,
};
}
function openDialog(row: any = {}) {
isAdd.value = !row.id;
entity.value = {
...buildDefaultEntity(),
...row,
};
dialogVisible.value = true; dialogVisible.value = true;
} }
function save() { async function save() {
saveForm.value?.validate((valid) => { if (btnLoading.value) {
if (valid) { return;
}
btnLoading.value = true; btnLoading.value = true;
api try {
.post( const valid = await saveForm.value?.validate().catch(() => false);
if (!valid) {
return;
}
const res = await api.post(
isAdd.value ? 'api/v1/sysDept/save' : 'api/v1/sysDept/update', isAdd.value ? 'api/v1/sysDept/save' : 'api/v1/sysDept/update',
entity.value, entity.value,
) );
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) { if (res.errorCode === 0) {
ElMessage.success(res.message); ElMessage.success(res.message);
emit('reload'); emit('reload');
closeDialog(); closeDialog();
} }
}) } finally {
.catch(() => {
btnLoading.value = false; btnLoading.value = false;
});
} }
});
} }
function closeDialog() { function closeDialog() {
saveForm.value?.resetFields(); saveForm.value?.resetFields();
isAdd.value = true; isAdd.value = true;
entity.value = {}; entity.value = buildDefaultEntity();
dialogVisible.value = false; dialogVisible.value = false;
} }
</script> </script>
@@ -120,6 +123,13 @@ function closeDialog() {
<ElFormItem prop="sortNo" :label="$t('sysDept.sortNo')"> <ElFormItem prop="sortNo" :label="$t('sysDept.sortNo')">
<ElInput v-model.trim="entity.sortNo" /> <ElInput v-model.trim="entity.sortNo" />
</ElFormItem> </ElFormItem>
<ElFormItem prop="status" :label="$t('sysDept.status')">
<DictSelect
v-model="entity.status"
:clearable="false"
dict-code="dataStatus"
/>
</ElFormItem>
<ElFormItem prop="remark" :label="$t('sysDept.remark')"> <ElFormItem prop="remark" :label="$t('sysDept.remark')">
<ElInput v-model.trim="entity.remark" /> <ElInput v-model.trim="entity.remark" />
</ElFormItem> </ElFormItem>