feat(settings): add safe user offboarding transfer workflow

This commit is contained in:
Your Name 2026-02-07 19:18:45 +08:00
parent 3b4971175d
commit eae8cbb8e8
9 changed files with 1119 additions and 4 deletions

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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>