feat(settings): add safe user offboarding transfer workflow
This commit is contained in:
parent
3b4971175d
commit
eae8cbb8e8
@ -2,9 +2,13 @@ package com.designhub.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.designhub.common.Result;
|
||||
import com.designhub.dto.UserOffboardingRequest;
|
||||
import com.designhub.entity.User;
|
||||
import com.designhub.service.UserOffboardingService;
|
||||
import com.designhub.service.UserService;
|
||||
import com.designhub.util.JwtUtil;
|
||||
import com.designhub.vo.UserOffboardingExecuteVO;
|
||||
import com.designhub.vo.UserOffboardingPreviewVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -25,6 +29,7 @@ import java.util.Map;
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final UserOffboardingService userOffboardingService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Operation(summary = "分页查询用户列表")
|
||||
@ -172,5 +177,23 @@ public class UserController {
|
||||
log.info("设置用户管理员身份: userId={}, isAdmin={}", id, isAdmin);
|
||||
return Result.success(userService.setAdmin(id, isAdmin));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "离职交接预览")
|
||||
@PostMapping("/offboarding/preview")
|
||||
public Result<UserOffboardingPreviewVO> previewOffboarding(
|
||||
@RequestBody UserOffboardingRequest request
|
||||
) {
|
||||
return Result.success(userOffboardingService.preview(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "执行离职交接")
|
||||
@PostMapping("/offboarding/execute")
|
||||
public Result<UserOffboardingExecuteVO> executeOffboarding(
|
||||
@RequestHeader("Authorization") String authHeader,
|
||||
@RequestBody UserOffboardingRequest request
|
||||
) {
|
||||
String token = jwtUtil.extractTokenFromHeader(authHeader);
|
||||
Long operatorId = jwtUtil.getUserIdFromToken(token);
|
||||
return Result.success(userOffboardingService.execute(request, operatorId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package com.designhub.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 离职交接请求
|
||||
*/
|
||||
@Data
|
||||
public class UserOffboardingRequest {
|
||||
|
||||
/**
|
||||
* 离职用户ID
|
||||
*/
|
||||
private Long fromUserId;
|
||||
|
||||
/**
|
||||
* 接替用户ID
|
||||
*/
|
||||
private Long toUserId;
|
||||
|
||||
/**
|
||||
* 交接范围:demand / receiver / all
|
||||
*/
|
||||
private String scope;
|
||||
|
||||
/**
|
||||
* 执行后是否禁用离职账号
|
||||
*/
|
||||
private Boolean disableFromUser;
|
||||
|
||||
/**
|
||||
* 交接原因
|
||||
*/
|
||||
private String reason;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.designhub.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 任务调配日志
|
||||
*/
|
||||
@Data
|
||||
@TableName("task_transfer_log")
|
||||
public class TaskTransferLog implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private String transferType;
|
||||
|
||||
private Long fromUserId;
|
||||
|
||||
private Long toUserId;
|
||||
|
||||
private Long operatorId;
|
||||
|
||||
private String reason;
|
||||
|
||||
private LocalDateTime oldDeadline;
|
||||
|
||||
private LocalDateTime newDeadline;
|
||||
|
||||
private Integer oldPriority;
|
||||
|
||||
private Integer newPriority;
|
||||
|
||||
private Integer delayDays;
|
||||
|
||||
private Integer penaltyScore;
|
||||
|
||||
private String remark;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.designhub.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.designhub.entity.TaskTransferLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface TaskTransferLogMapper extends BaseMapper<TaskTransferLog> {
|
||||
}
|
||||
@ -0,0 +1,449 @@
|
||||
package com.designhub.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.designhub.dto.UserOffboardingRequest;
|
||||
import com.designhub.entity.Task;
|
||||
import com.designhub.entity.TaskItem;
|
||||
import com.designhub.entity.TaskTransferLog;
|
||||
import com.designhub.entity.User;
|
||||
import com.designhub.exception.BusinessException;
|
||||
import com.designhub.mapper.TaskItemMapper;
|
||||
import com.designhub.mapper.TaskMapper;
|
||||
import com.designhub.mapper.TaskTransferLogMapper;
|
||||
import com.designhub.mapper.UserMapper;
|
||||
import com.designhub.vo.UserOffboardingExecuteVO;
|
||||
import com.designhub.vo.UserOffboardingPreviewVO;
|
||||
import lombok.Builder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 离职交接服务
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserOffboardingService {
|
||||
|
||||
private static final List<String> FINISHED_TASK_STATUSES = Arrays.asList("completed", "cancelled", "draft");
|
||||
private static final List<String> FINISHED_TASK_ITEM_STATUSES = Arrays.asList("completed", "rejected", "cancelled");
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final TaskMapper taskMapper;
|
||||
private final TaskItemMapper taskItemMapper;
|
||||
private final TaskTransferLogMapper taskTransferLogMapper;
|
||||
|
||||
public UserOffboardingPreviewVO preview(UserOffboardingRequest request) {
|
||||
OffboardingContext context = buildContext(request);
|
||||
return buildPreviewVO(context);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public UserOffboardingExecuteVO execute(UserOffboardingRequest request, Long operatorId) {
|
||||
if (operatorId == null) {
|
||||
throw new BusinessException("操作人不能为空");
|
||||
}
|
||||
User operator = userMapper.selectById(operatorId);
|
||||
if (operator == null) {
|
||||
throw new BusinessException("操作人不存在");
|
||||
}
|
||||
if (operator.getIsAdmin() == null || operator.getIsAdmin() != 1) {
|
||||
throw new BusinessException("仅管理员可执行离职交接");
|
||||
}
|
||||
|
||||
OffboardingContext context = buildContext(request);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
long updatedPublisher = updatePublisherTasks(context, now);
|
||||
long updatedPrimary = updatePrimaryPublisherTasks(context, now);
|
||||
long updatedDetail = updateDetailCompleterTasks(context, now);
|
||||
long updatedReceiver = updateReceiverTasks(context, now);
|
||||
long updatedTaskItems = updateReceiverTaskItems(context, now);
|
||||
long transferLogs = writeTransferLogs(context, operatorId, now);
|
||||
|
||||
boolean sourceUserDisabled = false;
|
||||
if (Boolean.TRUE.equals(context.request.getDisableFromUser())) {
|
||||
User disabled = new User();
|
||||
disabled.setId(context.fromUser.getId());
|
||||
disabled.setStatus(0);
|
||||
sourceUserDisabled = userMapper.updateById(disabled) > 0;
|
||||
}
|
||||
|
||||
return UserOffboardingExecuteVO.builder()
|
||||
.preview(buildPreviewVO(context))
|
||||
.updatedPublisherTasks(updatedPublisher)
|
||||
.updatedPrimaryPublisherTasks(updatedPrimary)
|
||||
.updatedDetailCompleterTasks(updatedDetail)
|
||||
.updatedReceiverTasks(updatedReceiver)
|
||||
.updatedReceiverTaskItems(updatedTaskItems)
|
||||
.transferLogCount(transferLogs)
|
||||
.sourceUserDisabled(sourceUserDisabled)
|
||||
.executedAt(now)
|
||||
.build();
|
||||
}
|
||||
|
||||
private OffboardingContext buildContext(UserOffboardingRequest request) {
|
||||
UserOffboardingRequest safeRequest = normalizeAndValidate(request);
|
||||
User fromUser = userMapper.selectById(safeRequest.getFromUserId());
|
||||
User toUser = userMapper.selectById(safeRequest.getToUserId());
|
||||
|
||||
if (fromUser == null) {
|
||||
throw new BusinessException("离职用户不存在");
|
||||
}
|
||||
if (toUser == null) {
|
||||
throw new BusinessException("接替用户不存在");
|
||||
}
|
||||
if (fromUser.getId().equals(toUser.getId())) {
|
||||
throw new BusinessException("离职用户与接替用户不能相同");
|
||||
}
|
||||
if (toUser.getStatus() == null || toUser.getStatus() != 1) {
|
||||
throw new BusinessException("接替用户必须是启用状态");
|
||||
}
|
||||
|
||||
Scope scope = Scope.from(safeRequest.getScope());
|
||||
List<Task> demandTasks = scope.includeDemand ? loadDemandTasks(fromUser.getId()) : new ArrayList<>();
|
||||
List<Task> receiverTasks = scope.includeReceiver ? loadReceiverTasks(fromUser.getId()) : new ArrayList<>();
|
||||
List<TaskItem> receiverTaskItems = scope.includeReceiver ? loadReceiverTaskItems(fromUser.getId()) : new ArrayList<>();
|
||||
|
||||
return OffboardingContext.builder()
|
||||
.request(safeRequest)
|
||||
.scope(scope)
|
||||
.fromUser(fromUser)
|
||||
.toUser(toUser)
|
||||
.demandTasks(demandTasks)
|
||||
.receiverTasks(receiverTasks)
|
||||
.receiverTaskItems(receiverTaskItems)
|
||||
.build();
|
||||
}
|
||||
|
||||
private UserOffboardingRequest normalizeAndValidate(UserOffboardingRequest request) {
|
||||
if (request == null) {
|
||||
throw new BusinessException("请求参数不能为空");
|
||||
}
|
||||
if (request.getFromUserId() == null || request.getToUserId() == null) {
|
||||
throw new BusinessException("离职用户和接替用户不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(request.getScope())) {
|
||||
request.setScope("demand");
|
||||
}
|
||||
Scope.from(request.getScope());
|
||||
if (!StringUtils.hasText(request.getReason())) {
|
||||
request.setReason("离职交接");
|
||||
}
|
||||
if (request.getDisableFromUser() == null) {
|
||||
request.setDisableFromUser(Boolean.TRUE);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private List<Task> loadDemandTasks(Long fromUserId) {
|
||||
LambdaQueryWrapper<Task> wrapper = baseUnfinishedTaskQuery()
|
||||
.and(w -> w.eq(Task::getPublisherId, fromUserId)
|
||||
.or()
|
||||
.eq(Task::getPrimaryPublisherId, fromUserId)
|
||||
.or()
|
||||
.eq(Task::getDetailCompleterId, fromUserId));
|
||||
return taskMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
private List<Task> loadReceiverTasks(Long fromUserId) {
|
||||
LambdaQueryWrapper<Task> wrapper = baseUnfinishedTaskQuery()
|
||||
.eq(Task::getReceiverId, fromUserId);
|
||||
return taskMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
private List<TaskItem> loadReceiverTaskItems(Long fromUserId) {
|
||||
LambdaQueryWrapper<TaskItem> wrapper = new LambdaQueryWrapper<TaskItem>()
|
||||
.eq(TaskItem::getReceiverId, fromUserId)
|
||||
.and(w -> w.isNull(TaskItem::getStatus)
|
||||
.or()
|
||||
.notIn(TaskItem::getStatus, FINISHED_TASK_ITEM_STATUSES));
|
||||
return taskItemMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Task> baseUnfinishedTaskQuery() {
|
||||
return new LambdaQueryWrapper<Task>()
|
||||
.eq(Task::getDeleted, 0)
|
||||
.and(w -> w.isNull(Task::getStatus)
|
||||
.or()
|
||||
.notIn(Task::getStatus, FINISHED_TASK_STATUSES));
|
||||
}
|
||||
|
||||
private long updatePublisherTasks(OffboardingContext context, LocalDateTime now) {
|
||||
List<Long> taskIds = context.demandTasks.stream()
|
||||
.filter(task -> context.fromUser.getId().equals(task.getPublisherId()))
|
||||
.map(Task::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (taskIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaUpdateWrapper<Task> wrapper = new LambdaUpdateWrapper<Task>()
|
||||
.in(Task::getId, taskIds)
|
||||
.eq(Task::getPublisherId, context.fromUser.getId())
|
||||
.set(Task::getPublisherId, context.toUser.getId())
|
||||
.set(Task::getPublisherDeptId, context.toUser.getDeptId())
|
||||
.set(Task::getUpdateTime, now);
|
||||
return taskMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
private long updatePrimaryPublisherTasks(OffboardingContext context, LocalDateTime now) {
|
||||
List<Long> taskIds = context.demandTasks.stream()
|
||||
.filter(task -> context.fromUser.getId().equals(task.getPrimaryPublisherId()))
|
||||
.map(Task::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (taskIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaUpdateWrapper<Task> wrapper = new LambdaUpdateWrapper<Task>()
|
||||
.in(Task::getId, taskIds)
|
||||
.eq(Task::getPrimaryPublisherId, context.fromUser.getId())
|
||||
.set(Task::getPrimaryPublisherId, context.toUser.getId())
|
||||
.set(Task::getPrimaryPublisherDeptId, context.toUser.getDeptId())
|
||||
.set(Task::getUpdateTime, now);
|
||||
return taskMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
private long updateDetailCompleterTasks(OffboardingContext context, LocalDateTime now) {
|
||||
List<Long> taskIds = context.demandTasks.stream()
|
||||
.filter(task -> context.fromUser.getId().equals(task.getDetailCompleterId()))
|
||||
.map(Task::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (taskIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaUpdateWrapper<Task> wrapper = new LambdaUpdateWrapper<Task>()
|
||||
.in(Task::getId, taskIds)
|
||||
.eq(Task::getDetailCompleterId, context.fromUser.getId())
|
||||
.set(Task::getDetailCompleterId, context.toUser.getId())
|
||||
.set(Task::getUpdateTime, now);
|
||||
return taskMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
private long updateReceiverTasks(OffboardingContext context, LocalDateTime now) {
|
||||
List<Long> taskIds = context.receiverTasks.stream()
|
||||
.map(Task::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (taskIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaUpdateWrapper<Task> wrapper = new LambdaUpdateWrapper<Task>()
|
||||
.in(Task::getId, taskIds)
|
||||
.eq(Task::getReceiverId, context.fromUser.getId())
|
||||
.set(Task::getReceiverId, context.toUser.getId())
|
||||
.set(Task::getReceiverDeptId, context.toUser.getDeptId())
|
||||
.set(Task::getUpdateTime, now);
|
||||
return taskMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
private long updateReceiverTaskItems(OffboardingContext context, LocalDateTime now) {
|
||||
List<Long> itemIds = context.receiverTaskItems.stream()
|
||||
.map(TaskItem::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (itemIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
String receiverName = StringUtils.hasText(context.toUser.getRealName())
|
||||
? context.toUser.getRealName()
|
||||
: context.toUser.getUsername();
|
||||
LambdaUpdateWrapper<TaskItem> wrapper = new LambdaUpdateWrapper<TaskItem>()
|
||||
.in(TaskItem::getId, itemIds)
|
||||
.eq(TaskItem::getReceiverId, context.fromUser.getId())
|
||||
.set(TaskItem::getReceiverId, context.toUser.getId())
|
||||
.set(TaskItem::getReceiverDeptId, context.toUser.getDeptId())
|
||||
.set(TaskItem::getReceiverName, receiverName)
|
||||
.set(TaskItem::getUpdateTime, now);
|
||||
return taskItemMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
private long writeTransferLogs(OffboardingContext context, Long operatorId, LocalDateTime now) {
|
||||
Map<Long, TaskRoleSnapshot> taskMap = buildTaskRoleMap(context);
|
||||
if (taskMap.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long count = 0;
|
||||
for (TaskRoleSnapshot snapshot : taskMap.values()) {
|
||||
Task task = snapshot.task;
|
||||
TaskTransferLog log = new TaskTransferLog();
|
||||
log.setTaskId(task.getId());
|
||||
log.setTransferType("transfer");
|
||||
log.setFromUserId(context.fromUser.getId());
|
||||
log.setToUserId(context.toUser.getId());
|
||||
log.setOperatorId(operatorId);
|
||||
log.setOldDeadline(task.getDeadline());
|
||||
log.setNewDeadline(task.getDeadline());
|
||||
log.setOldPriority(task.getPriority());
|
||||
log.setNewPriority(task.getPriority());
|
||||
log.setReason(context.request.getReason() + "(" + String.join("、", snapshot.roles) + ")");
|
||||
log.setRemark("离职交接");
|
||||
log.setCreateTime(now);
|
||||
taskTransferLogMapper.insert(log);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private UserOffboardingPreviewVO buildPreviewVO(OffboardingContext context) {
|
||||
List<UserOffboardingPreviewVO.AffectedTaskSample> samples = buildSamples(context);
|
||||
long publisherCount = context.demandTasks.stream()
|
||||
.filter(task -> context.fromUser.getId().equals(task.getPublisherId()))
|
||||
.count();
|
||||
long primaryCount = context.demandTasks.stream()
|
||||
.filter(task -> context.fromUser.getId().equals(task.getPrimaryPublisherId()))
|
||||
.count();
|
||||
long detailCount = context.demandTasks.stream()
|
||||
.filter(task -> context.fromUser.getId().equals(task.getDetailCompleterId()))
|
||||
.count();
|
||||
long receiverCount = context.receiverTasks.size();
|
||||
|
||||
Set<Long> affectedTaskIds = buildTaskRoleMap(context).keySet();
|
||||
|
||||
return UserOffboardingPreviewVO.builder()
|
||||
.fromUserId(context.fromUser.getId())
|
||||
.fromUserName(resolveUserName(context.fromUser))
|
||||
.toUserId(context.toUser.getId())
|
||||
.toUserName(resolveUserName(context.toUser))
|
||||
.scope(context.scope.raw)
|
||||
.affectedTaskCount(affectedTaskIds.size())
|
||||
.publisherTaskCount(publisherCount)
|
||||
.primaryPublisherTaskCount(primaryCount)
|
||||
.detailCompleterTaskCount(detailCount)
|
||||
.receiverTaskCount(receiverCount)
|
||||
.receiverTaskItemCount(context.receiverTaskItems.size())
|
||||
.samples(samples)
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<UserOffboardingPreviewVO.AffectedTaskSample> buildSamples(OffboardingContext context) {
|
||||
Map<Long, TaskRoleSnapshot> taskMap = buildTaskRoleMap(context);
|
||||
return taskMap.values().stream()
|
||||
.sorted(Comparator
|
||||
.comparing((TaskRoleSnapshot item) -> item.task.getUpdateTime() != null ? item.task.getUpdateTime() : LocalDateTime.MIN)
|
||||
.reversed())
|
||||
.limit(20)
|
||||
.map(item -> UserOffboardingPreviewVO.AffectedTaskSample.builder()
|
||||
.taskId(item.task.getId())
|
||||
.taskNo(item.task.getTaskNo())
|
||||
.title(item.task.getTitle())
|
||||
.status(item.task.getStatus())
|
||||
.roles(String.join("、", item.roles))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Map<Long, TaskRoleSnapshot> buildTaskRoleMap(OffboardingContext context) {
|
||||
Map<Long, TaskRoleSnapshot> map = new HashMap<>();
|
||||
Long fromUserId = context.fromUser.getId();
|
||||
|
||||
for (Task task : context.demandTasks) {
|
||||
if (fromUserId.equals(task.getPublisherId())) {
|
||||
addRole(map, task, "发起人");
|
||||
}
|
||||
if (fromUserId.equals(task.getPrimaryPublisherId())) {
|
||||
addRole(map, task, "一级发起人");
|
||||
}
|
||||
if (fromUserId.equals(task.getDetailCompleterId())) {
|
||||
addRole(map, task, "明细完善人");
|
||||
}
|
||||
}
|
||||
for (Task task : context.receiverTasks) {
|
||||
if (fromUserId.equals(task.getReceiverId())) {
|
||||
addRole(map, task, "接单人");
|
||||
}
|
||||
}
|
||||
if (!context.receiverTaskItems.isEmpty()) {
|
||||
Set<Long> taskIds = context.receiverTaskItems.stream()
|
||||
.map(TaskItem::getTaskId)
|
||||
.filter(id -> id != null)
|
||||
.collect(Collectors.toSet());
|
||||
if (!taskIds.isEmpty()) {
|
||||
Map<Long, Task> taskById = taskMapper.selectBatchIds(taskIds).stream()
|
||||
.filter(task -> task != null && task.getId() != null)
|
||||
.collect(Collectors.toMap(Task::getId, task -> task, (left, right) -> left));
|
||||
for (Long taskId : taskIds) {
|
||||
addRole(map, taskById.get(taskId), "子任务接单人");
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void addRole(Map<Long, TaskRoleSnapshot> map, Task task, String role) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return;
|
||||
}
|
||||
TaskRoleSnapshot snapshot = map.computeIfAbsent(task.getId(), key ->
|
||||
TaskRoleSnapshot.builder()
|
||||
.task(task)
|
||||
.roles(new LinkedHashSet<>())
|
||||
.build());
|
||||
snapshot.roles.add(role);
|
||||
}
|
||||
|
||||
private String resolveUserName(User user) {
|
||||
if (user == null) {
|
||||
return "";
|
||||
}
|
||||
if (StringUtils.hasText(user.getRealName())) {
|
||||
return user.getRealName();
|
||||
}
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
private enum Scope {
|
||||
DEMAND("demand", true, false),
|
||||
RECEIVER("receiver", false, true),
|
||||
ALL("all", true, true);
|
||||
|
||||
private final String raw;
|
||||
private final boolean includeDemand;
|
||||
private final boolean includeReceiver;
|
||||
|
||||
Scope(String raw, boolean includeDemand, boolean includeReceiver) {
|
||||
this.raw = raw;
|
||||
this.includeDemand = includeDemand;
|
||||
this.includeReceiver = includeReceiver;
|
||||
}
|
||||
|
||||
public static Scope from(String raw) {
|
||||
String value = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
|
||||
for (Scope scope : values()) {
|
||||
if (scope.raw.equals(value)) {
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
throw new BusinessException("不支持的交接范围: " + raw);
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
private static class OffboardingContext {
|
||||
private UserOffboardingRequest request;
|
||||
private Scope scope;
|
||||
private User fromUser;
|
||||
private User toUser;
|
||||
private List<Task> demandTasks;
|
||||
private List<Task> receiverTasks;
|
||||
private List<TaskItem> receiverTaskItems;
|
||||
}
|
||||
|
||||
@Builder
|
||||
private static class TaskRoleSnapshot {
|
||||
private Task task;
|
||||
private LinkedHashSet<String> roles;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.designhub.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 离职交接执行结果
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserOffboardingExecuteVO {
|
||||
|
||||
private UserOffboardingPreviewVO preview;
|
||||
private long updatedPublisherTasks;
|
||||
private long updatedPrimaryPublisherTasks;
|
||||
private long updatedDetailCompleterTasks;
|
||||
private long updatedReceiverTasks;
|
||||
private long updatedReceiverTaskItems;
|
||||
private long transferLogCount;
|
||||
private boolean sourceUserDisabled;
|
||||
private LocalDateTime executedAt;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.designhub.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 离职交接预览
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserOffboardingPreviewVO {
|
||||
|
||||
private Long fromUserId;
|
||||
private String fromUserName;
|
||||
private Long toUserId;
|
||||
private String toUserName;
|
||||
private String scope;
|
||||
|
||||
private long affectedTaskCount;
|
||||
private long publisherTaskCount;
|
||||
private long primaryPublisherTaskCount;
|
||||
private long detailCompleterTaskCount;
|
||||
private long receiverTaskCount;
|
||||
private long receiverTaskItemCount;
|
||||
|
||||
private List<AffectedTaskSample> samples;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AffectedTaskSample {
|
||||
private Long taskId;
|
||||
private String taskNo;
|
||||
private String title;
|
||||
private String status;
|
||||
private String roles;
|
||||
}
|
||||
}
|
||||
@ -184,3 +184,61 @@ export const setAdmin = async (id: number, isAdmin: number): Promise<boolean> =>
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export type UserOffboardingScope = 'demand' | 'receiver' | 'all';
|
||||
|
||||
export interface UserOffboardingRequest {
|
||||
fromUserId?: number;
|
||||
toUserId?: number;
|
||||
scope: UserOffboardingScope;
|
||||
disableFromUser: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface OffboardingTaskSample {
|
||||
taskId: number;
|
||||
taskNo: string;
|
||||
title: string;
|
||||
status: string;
|
||||
roles: string;
|
||||
}
|
||||
|
||||
export interface UserOffboardingPreview {
|
||||
fromUserId: number;
|
||||
fromUserName: string;
|
||||
toUserId: number;
|
||||
toUserName: string;
|
||||
scope: UserOffboardingScope;
|
||||
affectedTaskCount: number;
|
||||
publisherTaskCount: number;
|
||||
primaryPublisherTaskCount: number;
|
||||
detailCompleterTaskCount: number;
|
||||
receiverTaskCount: number;
|
||||
receiverTaskItemCount: number;
|
||||
samples: OffboardingTaskSample[];
|
||||
}
|
||||
|
||||
export interface UserOffboardingExecuteResult {
|
||||
preview: UserOffboardingPreview;
|
||||
updatedPublisherTasks: number;
|
||||
updatedPrimaryPublisherTasks: number;
|
||||
updatedDetailCompleterTasks: number;
|
||||
updatedReceiverTasks: number;
|
||||
updatedReceiverTaskItems: number;
|
||||
transferLogCount: number;
|
||||
sourceUserDisabled: boolean;
|
||||
executedAt: string;
|
||||
}
|
||||
|
||||
export const previewUserOffboarding = async (
|
||||
payload: UserOffboardingRequest
|
||||
): Promise<UserOffboardingPreview> => {
|
||||
const response = await request.post('/users/offboarding/preview', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const executeUserOffboarding = async (
|
||||
payload: UserOffboardingRequest
|
||||
): Promise<UserOffboardingExecuteResult> => {
|
||||
const response = await request.post('/users/offboarding/execute', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@ -504,6 +504,182 @@
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="offboarding" tab="离职交接">
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 class="section-title">离职任务交接</h2>
|
||||
<p class="section-subtitle">先预览影响,再执行交接。支持需求侧和执行侧未完结任务安全迁移。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="安全建议"
|
||||
description="仅会处理未完结任务并写入任务调配日志。建议先预览确认数量,再执行交接。"
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
|
||||
<div class="offboarding-panel">
|
||||
<a-form :model="offboardingForm" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="离职成员">
|
||||
<a-select
|
||||
v-model:value="offboardingForm.fromUserId"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOffboardingUserOption"
|
||||
placeholder="请选择离职成员"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="item in offboardingUsers"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:disabled="item.id === offboardingForm.toUserId"
|
||||
>
|
||||
{{ item.realName || item.username }}({{ item.deptName || '未分配部门' }}{{ item.status === 1 ? '' : ' / 已禁用' }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="接替成员">
|
||||
<a-select
|
||||
v-model:value="offboardingForm.toUserId"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOffboardingUserOption"
|
||||
placeholder="请选择接替成员"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="item in offboardingUsers"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:disabled="item.id === offboardingForm.fromUserId || item.status !== 1"
|
||||
>
|
||||
{{ item.realName || item.username }}({{ item.deptName || '未分配部门' }}{{ item.status === 1 ? '' : ' / 已禁用' }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="交接范围">
|
||||
<a-radio-group v-model:value="offboardingForm.scope" button-style="solid">
|
||||
<a-radio-button value="demand">需求侧</a-radio-button>
|
||||
<a-radio-button value="receiver">执行侧</a-radio-button>
|
||||
<a-radio-button value="all">全部</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="交接原因">
|
||||
<a-input
|
||||
v-model:value="offboardingForm.reason"
|
||||
placeholder="例如:成员离职,任务由部门同事接替"
|
||||
maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="离职账号处理">
|
||||
<a-switch
|
||||
v-model:checked="offboardingForm.disableFromUser"
|
||||
checked-children="执行后禁用"
|
||||
un-checked-children="保持启用"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<div class="offboarding-actions">
|
||||
<a-space>
|
||||
<a-button :loading="offboardingPreviewLoading" @click="handlePreviewOffboarding">
|
||||
<template #icon><Icon name="search" size="sm" /></template>
|
||||
预览影响
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确认执行离职交接?"
|
||||
ok-text="确认执行"
|
||||
cancel-text="取消"
|
||||
@confirm="handleExecuteOffboarding"
|
||||
>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
:loading="offboardingExecuting"
|
||||
:disabled="!offboardingPreview"
|
||||
>
|
||||
<template #icon><Icon name="arrowRight" size="sm" /></template>
|
||||
执行交接
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="offboardingPreview" class="offboarding-preview">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="12" :md="4">
|
||||
<div class="mini-stat-card">
|
||||
<div class="mini-stat-label">影响任务</div>
|
||||
<div class="mini-stat-value">{{ offboardingPreview.affectedTaskCount }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="4">
|
||||
<div class="mini-stat-card">
|
||||
<div class="mini-stat-label">发起人任务</div>
|
||||
<div class="mini-stat-value">{{ offboardingPreview.publisherTaskCount }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="4">
|
||||
<div class="mini-stat-card">
|
||||
<div class="mini-stat-label">一级发起</div>
|
||||
<div class="mini-stat-value">{{ offboardingPreview.primaryPublisherTaskCount }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="4">
|
||||
<div class="mini-stat-card">
|
||||
<div class="mini-stat-label">明细完善</div>
|
||||
<div class="mini-stat-value">{{ offboardingPreview.detailCompleterTaskCount }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="4">
|
||||
<div class="mini-stat-card">
|
||||
<div class="mini-stat-label">接单任务</div>
|
||||
<div class="mini-stat-value">{{ offboardingPreview.receiverTaskCount }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :md="4">
|
||||
<div class="mini-stat-card">
|
||||
<div class="mini-stat-label">子任务</div>
|
||||
<div class="mini-stat-value">{{ offboardingPreview.receiverTaskItemCount }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="table-card" style="margin-top: 16px;">
|
||||
<div class="table-header">
|
||||
<h3 class="chart-title">受影响任务样本(最多20条)</h3>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="offboardingTaskColumns"
|
||||
:data-source="offboardingPreview.samples || []"
|
||||
row-key="taskId"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 部门管理 -->
|
||||
<a-tab-pane key="departments" tab="部门管理">
|
||||
<div class="settings-section">
|
||||
@ -2813,7 +2989,11 @@ import {
|
||||
updateUserDepartment,
|
||||
deleteUser,
|
||||
setAdmin,
|
||||
type User
|
||||
previewUserOffboarding,
|
||||
executeUserOffboarding,
|
||||
type User,
|
||||
type UserOffboardingPreview,
|
||||
type UserOffboardingRequest
|
||||
} from '@/api/user'
|
||||
import {
|
||||
getAllS3Configs,
|
||||
@ -2957,6 +3137,17 @@ const transferModalVisible = ref(false)
|
||||
const selectedUser = ref<User | null>(null)
|
||||
const transferDeptId = ref<number | null>(null)
|
||||
const allUsers = ref<User[]>([])
|
||||
const offboardingPreviewLoading = ref(false)
|
||||
const offboardingExecuting = ref(false)
|
||||
const offboardingPreview = ref<UserOffboardingPreview | null>(null)
|
||||
const offboardingPreviewDigest = ref('')
|
||||
const offboardingForm = reactive<UserOffboardingRequest>({
|
||||
fromUserId: undefined,
|
||||
toUserId: undefined,
|
||||
scope: 'demand',
|
||||
disableFromUser: true,
|
||||
reason: '离职交接'
|
||||
})
|
||||
|
||||
// S3配置相关
|
||||
const s3Configs = ref<S3Config[]>([])
|
||||
@ -3168,6 +3359,34 @@ const unassignedUsers = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const offboardingUsers = computed(() => {
|
||||
return allUsers.value
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const statusA = a.status === 1 ? 0 : 1
|
||||
const statusB = b.status === 1 ? 0 : 1
|
||||
if (statusA !== statusB) {
|
||||
return statusA - statusB
|
||||
}
|
||||
const nameA = a.realName || a.username || ''
|
||||
const nameB = b.realName || b.username || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
const offboardingTaskColumns = [
|
||||
{ title: '任务编号', dataIndex: 'taskNo', key: 'taskNo', width: 180 },
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{ title: '角色', dataIndex: 'roles', key: 'roles', width: 180 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
customRender: ({ text }: { text?: string }) => getTaskStatusLabel(text)
|
||||
}
|
||||
]
|
||||
|
||||
// 过滤出当前部门的用户(用于负责人选择)
|
||||
const departmentUsers = computed(() => {
|
||||
// 部门负责人应该可以选择任何用户,不限制于该部门的成员
|
||||
@ -3254,6 +3473,22 @@ const formatUserTime = (time: string | undefined) => {
|
||||
return time.replace('T', ' ').substring(0, 16)
|
||||
}
|
||||
|
||||
function getTaskStatusLabel(status?: string) {
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: '待接单',
|
||||
in_progress: '进行中',
|
||||
review: '待审核',
|
||||
rejected: '已驳回',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
draft: '草稿'
|
||||
}
|
||||
if (!status) {
|
||||
return '未设置'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 接单部门列表(用于需求类型的部门选择)
|
||||
const receivingDepartments = computed(() => {
|
||||
return departments.value.filter(dept => dept.deptType === 'receiving')
|
||||
@ -4012,6 +4247,109 @@ const filterUserOption = (input: string, option: any) => {
|
||||
return realName.includes(searchText) || username.includes(searchText)
|
||||
}
|
||||
|
||||
const filterOffboardingUserOption = (input: string, option: any) => {
|
||||
const user = offboardingUsers.value.find(u => u.id === option?.value)
|
||||
if (!user) return false
|
||||
const keyword = input.trim().toLowerCase()
|
||||
if (!keyword) return true
|
||||
const candidates = [
|
||||
user.realName || '',
|
||||
user.username || '',
|
||||
user.deptName || '',
|
||||
String(user.id || '')
|
||||
]
|
||||
return candidates.some(item => item.toLowerCase().includes(keyword))
|
||||
}
|
||||
|
||||
const buildOffboardingPayload = (showError = true): UserOffboardingRequest | null => {
|
||||
if (!offboardingForm.fromUserId) {
|
||||
if (showError) message.warning('请选择离职成员')
|
||||
return null
|
||||
}
|
||||
if (!offboardingForm.toUserId) {
|
||||
if (showError) message.warning('请选择接替成员')
|
||||
return null
|
||||
}
|
||||
if (offboardingForm.fromUserId === offboardingForm.toUserId) {
|
||||
if (showError) message.warning('离职成员和接替成员不能相同')
|
||||
return null
|
||||
}
|
||||
const reason = (offboardingForm.reason || '').trim()
|
||||
if (!reason) {
|
||||
if (showError) message.warning('请填写交接原因')
|
||||
return null
|
||||
}
|
||||
return {
|
||||
fromUserId: offboardingForm.fromUserId,
|
||||
toUserId: offboardingForm.toUserId,
|
||||
scope: offboardingForm.scope || 'demand',
|
||||
disableFromUser: offboardingForm.disableFromUser !== false,
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
const createOffboardingDigest = (payload: UserOffboardingRequest) => {
|
||||
return JSON.stringify({
|
||||
fromUserId: payload.fromUserId || null,
|
||||
toUserId: payload.toUserId || null,
|
||||
scope: payload.scope,
|
||||
disableFromUser: payload.disableFromUser !== false,
|
||||
reason: (payload.reason || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const handlePreviewOffboarding = async () => {
|
||||
const payload = buildOffboardingPayload()
|
||||
if (!payload) return
|
||||
|
||||
offboardingPreviewLoading.value = true
|
||||
try {
|
||||
const data = await previewUserOffboarding(payload)
|
||||
offboardingPreview.value = data
|
||||
offboardingPreviewDigest.value = createOffboardingDigest(payload)
|
||||
message.success(`预览完成,预计影响 ${data.affectedTaskCount || 0} 个任务`)
|
||||
} catch (error: any) {
|
||||
console.error('离职交接预览失败:', error)
|
||||
message.error(error?.response?.data?.message || error?.message || '预览失败,请稍后重试')
|
||||
} finally {
|
||||
offboardingPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecuteOffboarding = async () => {
|
||||
const payload = buildOffboardingPayload()
|
||||
if (!payload) return
|
||||
if (!offboardingPreview.value) {
|
||||
message.warning('请先预览影响范围后再执行')
|
||||
return
|
||||
}
|
||||
|
||||
const currentDigest = createOffboardingDigest(payload)
|
||||
if (!offboardingPreviewDigest.value || offboardingPreviewDigest.value !== currentDigest) {
|
||||
message.warning('表单已变更,请重新点击“预览影响”后执行')
|
||||
return
|
||||
}
|
||||
|
||||
offboardingExecuting.value = true
|
||||
try {
|
||||
const result = await executeUserOffboarding(payload)
|
||||
offboardingPreview.value = result.preview || offboardingPreview.value
|
||||
offboardingPreviewDigest.value = ''
|
||||
const summary = [
|
||||
`任务${result.preview?.affectedTaskCount || 0}个`,
|
||||
`子任务${result.updatedReceiverTaskItems || 0}个`,
|
||||
`日志${result.transferLogCount || 0}条`
|
||||
].join(',')
|
||||
message.success(`交接执行成功:${summary}`)
|
||||
await Promise.all([loadDepartmentUsers(), loadDepartments()])
|
||||
} catch (error: any) {
|
||||
console.error('离职交接执行失败:', error)
|
||||
message.error(error?.response?.data?.message || error?.message || '执行失败,请联系管理员')
|
||||
} finally {
|
||||
offboardingExecuting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 部门搜索处理
|
||||
const onDepartmentSearch = () => {
|
||||
// 搜索逻辑通过 computed 属性 filteredDepartments 自动处理
|
||||
@ -5911,11 +6249,30 @@ const saveProgressCheckConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
offboardingForm.fromUserId,
|
||||
offboardingForm.toUserId,
|
||||
offboardingForm.scope,
|
||||
offboardingForm.disableFromUser,
|
||||
offboardingForm.reason
|
||||
],
|
||||
() => {
|
||||
offboardingPreview.value = null
|
||||
offboardingPreviewDigest.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeTab, (value) => {
|
||||
// 切换到用户与部门 tab 时,始终刷新数据以获取最新状态
|
||||
if (value === 'users') {
|
||||
loadDepartmentUsers()
|
||||
}
|
||||
if (value === 'offboarding') {
|
||||
if (!usersLoaded.value) {
|
||||
loadDepartmentUsers()
|
||||
}
|
||||
}
|
||||
// 切换到部门管理 tab 时,刷新部门数据以获取最新成员数
|
||||
if (value === 'departments') {
|
||||
loadDepartments()
|
||||
@ -5988,6 +6345,69 @@ watch(activeTab, (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.offboarding-panel {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid $border-primary;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.offboarding-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.offboarding-preview {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mini-stat-card {
|
||||
height: 100%;
|
||||
min-height: 84px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba($brand-primary, 0.2);
|
||||
border-radius: $radius-md;
|
||||
|
||||
.mini-stat-label {
|
||||
color: $text-secondary;
|
||||
font-size: $font-size-sm;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
color: $text-primary;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid $border-primary;
|
||||
border-radius: $radius-md;
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
margin: 0;
|
||||
color: $text-primary;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-display {
|
||||
font-size: $font-size-xl;
|
||||
color: $color-info;
|
||||
@ -7321,5 +7741,3 @@ watch(activeTab, (value) => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user