From eae8cbb8e8a4bdd1331437aba6412d8d961eae93 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 7 Feb 2026 19:18:45 +0800 Subject: [PATCH] feat(settings): add safe user offboarding transfer workflow --- .../designhub/controller/UserController.java | 25 +- .../designhub/dto/UserOffboardingRequest.java | 35 ++ .../com/designhub/entity/TaskTransferLog.java | 50 ++ .../mapper/TaskTransferLogMapper.java | 9 + .../service/UserOffboardingService.java | 449 ++++++++++++++++++ .../vo/UserOffboardingExecuteVO.java | 28 ++ .../vo/UserOffboardingPreviewVO.java | 45 ++ frontend/src/api/user.ts | 58 +++ frontend/src/views/Settings.vue | 424 ++++++++++++++++- 9 files changed, 1119 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/designhub/dto/UserOffboardingRequest.java create mode 100644 backend/src/main/java/com/designhub/entity/TaskTransferLog.java create mode 100644 backend/src/main/java/com/designhub/mapper/TaskTransferLogMapper.java create mode 100644 backend/src/main/java/com/designhub/service/UserOffboardingService.java create mode 100644 backend/src/main/java/com/designhub/vo/UserOffboardingExecuteVO.java create mode 100644 backend/src/main/java/com/designhub/vo/UserOffboardingPreviewVO.java diff --git a/backend/src/main/java/com/designhub/controller/UserController.java b/backend/src/main/java/com/designhub/controller/UserController.java index 7670a8a..cdbd549 100644 --- a/backend/src/main/java/com/designhub/controller/UserController.java +++ b/backend/src/main/java/com/designhub/controller/UserController.java @@ -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 previewOffboarding( + @RequestBody UserOffboardingRequest request + ) { + return Result.success(userOffboardingService.preview(request)); + } + + @Operation(summary = "执行离职交接") + @PostMapping("/offboarding/execute") + public Result 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)); + } +} diff --git a/backend/src/main/java/com/designhub/dto/UserOffboardingRequest.java b/backend/src/main/java/com/designhub/dto/UserOffboardingRequest.java new file mode 100644 index 0000000..722bcd8 --- /dev/null +++ b/backend/src/main/java/com/designhub/dto/UserOffboardingRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/designhub/entity/TaskTransferLog.java b/backend/src/main/java/com/designhub/entity/TaskTransferLog.java new file mode 100644 index 0000000..69597b9 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskTransferLog.java @@ -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; +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskTransferLogMapper.java b/backend/src/main/java/com/designhub/mapper/TaskTransferLogMapper.java new file mode 100644 index 0000000..a4052ee --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskTransferLogMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/designhub/service/UserOffboardingService.java b/backend/src/main/java/com/designhub/service/UserOffboardingService.java new file mode 100644 index 0000000..014725b --- /dev/null +++ b/backend/src/main/java/com/designhub/service/UserOffboardingService.java @@ -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 FINISHED_TASK_STATUSES = Arrays.asList("completed", "cancelled", "draft"); + private static final List 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 demandTasks = scope.includeDemand ? loadDemandTasks(fromUser.getId()) : new ArrayList<>(); + List receiverTasks = scope.includeReceiver ? loadReceiverTasks(fromUser.getId()) : new ArrayList<>(); + List 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 loadDemandTasks(Long fromUserId) { + LambdaQueryWrapper 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 loadReceiverTasks(Long fromUserId) { + LambdaQueryWrapper wrapper = baseUnfinishedTaskQuery() + .eq(Task::getReceiverId, fromUserId); + return taskMapper.selectList(wrapper); + } + + private List loadReceiverTaskItems(Long fromUserId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(TaskItem::getReceiverId, fromUserId) + .and(w -> w.isNull(TaskItem::getStatus) + .or() + .notIn(TaskItem::getStatus, FINISHED_TASK_ITEM_STATUSES)); + return taskItemMapper.selectList(wrapper); + } + + private LambdaQueryWrapper baseUnfinishedTaskQuery() { + return new LambdaQueryWrapper() + .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 taskIds = context.demandTasks.stream() + .filter(task -> context.fromUser.getId().equals(task.getPublisherId())) + .map(Task::getId) + .collect(Collectors.toList()); + if (taskIds.isEmpty()) { + return 0; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .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 taskIds = context.demandTasks.stream() + .filter(task -> context.fromUser.getId().equals(task.getPrimaryPublisherId())) + .map(Task::getId) + .collect(Collectors.toList()); + if (taskIds.isEmpty()) { + return 0; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .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 taskIds = context.demandTasks.stream() + .filter(task -> context.fromUser.getId().equals(task.getDetailCompleterId())) + .map(Task::getId) + .collect(Collectors.toList()); + if (taskIds.isEmpty()) { + return 0; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .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 taskIds = context.receiverTasks.stream() + .map(Task::getId) + .collect(Collectors.toList()); + if (taskIds.isEmpty()) { + return 0; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .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 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 wrapper = new LambdaUpdateWrapper() + .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 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 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 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 buildSamples(OffboardingContext context) { + Map 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 buildTaskRoleMap(OffboardingContext context) { + Map 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 taskIds = context.receiverTaskItems.stream() + .map(TaskItem::getTaskId) + .filter(id -> id != null) + .collect(Collectors.toSet()); + if (!taskIds.isEmpty()) { + Map 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 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 demandTasks; + private List receiverTasks; + private List receiverTaskItems; + } + + @Builder + private static class TaskRoleSnapshot { + private Task task; + private LinkedHashSet roles; + } +} diff --git a/backend/src/main/java/com/designhub/vo/UserOffboardingExecuteVO.java b/backend/src/main/java/com/designhub/vo/UserOffboardingExecuteVO.java new file mode 100644 index 0000000..71cae57 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/UserOffboardingExecuteVO.java @@ -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; +} diff --git a/backend/src/main/java/com/designhub/vo/UserOffboardingPreviewVO.java b/backend/src/main/java/com/designhub/vo/UserOffboardingPreviewVO.java new file mode 100644 index 0000000..8b5dc2b --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/UserOffboardingPreviewVO.java @@ -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 samples; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AffectedTaskSample { + private Long taskId; + private String taskNo; + private String title; + private String status; + private String roles; + } +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 4065711..6637ac7 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -184,3 +184,61 @@ export const setAdmin = async (id: number, isAdmin: number): Promise => 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 => { + const response = await request.post('/users/offboarding/preview', payload); + return response.data; +}; + +export const executeUserOffboarding = async ( + payload: UserOffboardingRequest +): Promise => { + const response = await request.post('/users/offboarding/execute', payload); + return response.data; +}; diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 9ed25d3..484a1cf 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -504,6 +504,182 @@ + +
+
+
+

离职任务交接

+

先预览影响,再执行交接。支持需求侧和执行侧未完结任务安全迁移。

+
+
+ + + +
+ + + + + + + {{ item.realName || item.username }}({{ item.deptName || '未分配部门' }}{{ item.status === 1 ? '' : ' / 已禁用' }}) + + + + + + + + + {{ item.realName || item.username }}({{ item.deptName || '未分配部门' }}{{ item.status === 1 ? '' : ' / 已禁用' }}) + + + + + + + + 需求侧 + 执行侧 + 全部 + + + + + + + + + + + + + + + + + + + +
+ + + + 预览影响 + + + + + 执行交接 + + + +
+
+ +
+ + +
+
影响任务
+
{{ offboardingPreview.affectedTaskCount }}
+
+
+ +
+
发起人任务
+
{{ offboardingPreview.publisherTaskCount }}
+
+
+ +
+
一级发起
+
{{ offboardingPreview.primaryPublisherTaskCount }}
+
+
+ +
+
明细完善
+
{{ offboardingPreview.detailCompleterTaskCount }}
+
+
+ +
+
接单任务
+
{{ offboardingPreview.receiverTaskCount }}
+
+
+ +
+
子任务
+
{{ offboardingPreview.receiverTaskItemCount }}
+
+
+
+ +
+
+

受影响任务样本(最多20条)

+
+ +
+
+
+
+
@@ -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(null) const transferDeptId = ref(null) const allUsers = ref([]) +const offboardingPreviewLoading = ref(false) +const offboardingExecuting = ref(false) +const offboardingPreview = ref(null) +const offboardingPreviewDigest = ref('') +const offboardingForm = reactive({ + fromUserId: undefined, + toUserId: undefined, + scope: 'demand', + disableFromUser: true, + reason: '离职交接' +}) // S3配置相关 const s3Configs = ref([]) @@ -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 = { + 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) => { } } - -