feat: 实现任务排队解锁机制
- 新增任务排队解锁功能,确保先发布的任务优先被处理 - 添加系统设置中的任务排队配置(启用开关、每日解锁数、补充解锁数) - 新增 TaskUnlockStatus 实体和 Mapper - 新增 TaskUnlockService 处理排队解锁逻辑 - 新增 TaskStatusNotificationService 发送钉钉通知 - 更新 TaskItemService 和 TaskItemController 支持排队状态 - 前端新增 TaskStatusBadge 组件显示排队/抢单状态 - 更新 TaskCard 和 TaskHall 支持排队状态展示 - 添加数据库迁移脚本 V15
This commit is contained in:
parent
5a1a5fba84
commit
7bb3c953ef
@ -24,6 +24,7 @@ public class AdminTaskController {
|
||||
|
||||
private final AdminTaskService adminTaskService;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final com.designhub.service.TaskUnlockService taskUnlockService;
|
||||
|
||||
@Operation(summary = "分配任务")
|
||||
@PostMapping("/{taskId}/assign")
|
||||
@ -164,5 +165,41 @@ public class AdminTaskController {
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
|
||||
// ==================== 任务排队解锁相关接口 ====================
|
||||
|
||||
@Operation(summary = "获取任务解锁状态统计")
|
||||
@GetMapping("/unlock/stats")
|
||||
public Result<Map<String, Object>> getUnlockStats() {
|
||||
Map<String, Object> stats = taskUnlockService.getUnlockStats();
|
||||
return Result.success(stats);
|
||||
}
|
||||
|
||||
@Operation(summary = "手动触发任务解锁")
|
||||
@PostMapping("/unlock/trigger")
|
||||
public Result<Map<String, Object>> triggerUnlock(
|
||||
@RequestParam(required = false) Long deptId,
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean forceUnlock,
|
||||
@RequestHeader("Authorization") String authHeader
|
||||
) {
|
||||
String token = jwtUtil.extractTokenFromHeader(authHeader);
|
||||
Long adminId = jwtUtil.getUserIdFromToken(token);
|
||||
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
if (deptId != null) {
|
||||
// 指定部门解锁
|
||||
int count = taskUnlockService.unlockTasksForDept(deptId,
|
||||
forceUnlock ? 100 : taskUnlockService.getDailyUnlockMin());
|
||||
result.put("deptId", deptId);
|
||||
result.put("unlockedCount", count);
|
||||
} else {
|
||||
// 触发全部部门的每日解锁
|
||||
taskUnlockService.dailyUnlockTask();
|
||||
result.put("message", "已触发每日解锁任务");
|
||||
}
|
||||
|
||||
return Result.success(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,6 +46,7 @@ public class TaskItemController {
|
||||
|
||||
/**
|
||||
* 获取可接单的子任务列表
|
||||
* 支持排序和排队状态显示
|
||||
*/
|
||||
@Operation(summary = "获取可接单的子任务列表")
|
||||
@GetMapping("/available")
|
||||
@ -54,9 +55,11 @@ public class TaskItemController {
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize,
|
||||
@Parameter(description = "任务类型ID") @RequestParam(required = false) Long taskTypeId,
|
||||
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "筛选可接单部门ID") @RequestParam(required = false) Long filterDeptId) {
|
||||
@Parameter(description = "筛选可接单部门ID") @RequestParam(required = false) Long filterDeptId,
|
||||
@Parameter(description = "排序方式: oldest(最早优先)/latest(最新优先,默认)") @RequestParam(defaultValue = "latest") String sortBy,
|
||||
@Parameter(description = "是否包含排队中的任务") @RequestParam(defaultValue = "true") Boolean includeQueuing) {
|
||||
|
||||
IPage<TaskItem> items = taskItemService.getAvailableItems(page, pageSize, taskTypeId, keyword, filterDeptId);
|
||||
IPage<TaskItem> items = taskItemService.getAvailableItems(page, pageSize, taskTypeId, keyword, filterDeptId, sortBy, includeQueuing);
|
||||
return Result.success(items);
|
||||
}
|
||||
|
||||
|
||||
@ -279,8 +279,49 @@ public class TaskItem implements Serializable {
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String remark;
|
||||
|
||||
// Getter和Setter
|
||||
public String getRemark() { return remark; }
|
||||
public void setRemark(String remark) { this.remark = remark; }
|
||||
|
||||
// ========== 排队解锁相关字段(非数据库字段)==========
|
||||
|
||||
/**
|
||||
* 解锁状态:queuing(排队中) / unlocked(可抢单)
|
||||
* 仅对招募类任务有效,指定任务不显示此字段
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String unlockStatus;
|
||||
|
||||
/**
|
||||
* 排队位置(仅排队中有值)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private Integer queuePosition;
|
||||
|
||||
/**
|
||||
* 当前部门排队总数
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private Integer totalQueueSize;
|
||||
|
||||
/**
|
||||
* 预估解锁日期(格式:MM月DD日)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String estimatedUnlockDate;
|
||||
|
||||
/**
|
||||
* 排队进度百分比 (0-100)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private Integer queueProgress;
|
||||
|
||||
/**
|
||||
* 是否可以抢单
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private Boolean canGrab;
|
||||
|
||||
/**
|
||||
* 主任务发布类型(非数据库字段,用于判断是否为指定任务)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String releaseType;
|
||||
}
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
package com.designhub.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 任务排队解锁状态实体
|
||||
* 用于控制任务的可见性,实现任务排队解锁机制
|
||||
*/
|
||||
@Data
|
||||
@TableName("task_unlock_status")
|
||||
public class TaskUnlockStatus {
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 子任务ID
|
||||
*/
|
||||
private Long taskItemId;
|
||||
|
||||
/**
|
||||
* 接单部门ID
|
||||
*/
|
||||
private Long deptId;
|
||||
|
||||
/**
|
||||
* 任务发布日期(用于排队排序)
|
||||
*/
|
||||
private LocalDate publishDate;
|
||||
|
||||
/**
|
||||
* 是否已解锁: 0-未解锁(排队中), 1-已解锁(可抢单)
|
||||
*/
|
||||
private Integer isUnlocked;
|
||||
|
||||
/**
|
||||
* 解锁时间
|
||||
*/
|
||||
private LocalDateTime unlockTime;
|
||||
|
||||
/**
|
||||
* 排队位置(动态计算,缓存用)
|
||||
*/
|
||||
private Integer queuePosition;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package com.designhub.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.designhub.entity.TaskUnlockStatus;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 任务解锁状态 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface TaskUnlockStatusMapper extends BaseMapper<TaskUnlockStatus> {
|
||||
|
||||
/**
|
||||
* 获取指定部门已解锁的任务ID列表
|
||||
*/
|
||||
@Select("SELECT task_item_id FROM task_unlock_status WHERE dept_id = #{deptId} AND is_unlocked = 1")
|
||||
List<Long> selectUnlockedTaskItemIds(@Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 获取指定部门未解锁的任务ID列表(按发布日期排序)
|
||||
*/
|
||||
@Select("SELECT task_item_id FROM task_unlock_status " +
|
||||
"WHERE dept_id = #{deptId} AND is_unlocked = 0 " +
|
||||
"ORDER BY publish_date ASC, id ASC")
|
||||
List<Long> selectQueuedTaskItemIds(@Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 获取指定部门未解锁任务总数
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM task_unlock_status WHERE dept_id = #{deptId} AND is_unlocked = 0")
|
||||
int countQueuedTasks(@Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 获取指定部门已解锁但未被接单的任务数
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM task_unlock_status tus " +
|
||||
"INNER JOIN task_item ti ON tus.task_item_id = ti.id " +
|
||||
"WHERE tus.dept_id = #{deptId} AND tus.is_unlocked = 1 AND ti.status = 'pending'")
|
||||
int countUnlockedPendingTasks(@Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 批量解锁任务
|
||||
*/
|
||||
@Update("<script>" +
|
||||
"UPDATE task_unlock_status SET is_unlocked = 1, unlock_time = NOW(), update_time = NOW() " +
|
||||
"WHERE task_item_id IN " +
|
||||
"<foreach collection='taskItemIds' item='id' open='(' separator=',' close=')'>" +
|
||||
"#{id}" +
|
||||
"</foreach>" +
|
||||
" AND dept_id = #{deptId}" +
|
||||
"</script>")
|
||||
int batchUnlock(@Param("deptId") Long deptId, @Param("taskItemIds") List<Long> taskItemIds);
|
||||
|
||||
/**
|
||||
* 获取指定部门按发布日期分组的未解锁任务统计
|
||||
*/
|
||||
@Select("SELECT publish_date, COUNT(*) as count FROM task_unlock_status " +
|
||||
"WHERE dept_id = #{deptId} AND is_unlocked = 0 " +
|
||||
"GROUP BY publish_date ORDER BY publish_date ASC")
|
||||
@Results({
|
||||
@Result(column = "publish_date", property = "publishDate"),
|
||||
@Result(column = "count", property = "count")
|
||||
})
|
||||
List<DateCountVO> selectQueuedGroupByDate(@Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 获取需要解锁的任务ID列表(按日期顺序,限制数量)
|
||||
*/
|
||||
@Select("SELECT task_item_id FROM task_unlock_status " +
|
||||
"WHERE dept_id = #{deptId} AND is_unlocked = 0 " +
|
||||
"ORDER BY publish_date ASC, id ASC " +
|
||||
"LIMIT #{limit}")
|
||||
List<Long> selectTasksToUnlock(@Param("deptId") Long deptId, @Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 计算任务在队列中的位置
|
||||
*/
|
||||
@Select("SELECT COUNT(*) + 1 FROM task_unlock_status " +
|
||||
"WHERE dept_id = #{deptId} AND is_unlocked = 0 " +
|
||||
"AND (publish_date < (SELECT publish_date FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId}) " +
|
||||
" OR (publish_date = (SELECT publish_date FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId}) " +
|
||||
" AND id < (SELECT id FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId})))")
|
||||
int calculateQueuePosition(@Param("taskItemId") Long taskItemId, @Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 检查任务是否已解锁
|
||||
*/
|
||||
@Select("SELECT is_unlocked FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId}")
|
||||
Integer isUnlocked(@Param("taskItemId") Long taskItemId, @Param("deptId") Long deptId);
|
||||
|
||||
/**
|
||||
* 删除任务的解锁状态(任务取消/删除时调用)
|
||||
*/
|
||||
@Delete("DELETE FROM task_unlock_status WHERE task_item_id = #{taskItemId}")
|
||||
int deleteByTaskItemId(@Param("taskItemId") Long taskItemId);
|
||||
|
||||
/**
|
||||
* 日期计数VO(内部类)
|
||||
*/
|
||||
class DateCountVO {
|
||||
private LocalDate publishDate;
|
||||
private Integer count;
|
||||
|
||||
public LocalDate getPublishDate() {
|
||||
return publishDate;
|
||||
}
|
||||
|
||||
public void setPublishDate(LocalDate publishDate) {
|
||||
this.publishDate = publishDate;
|
||||
}
|
||||
|
||||
public Integer getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public void setCount(Integer count) {
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -45,12 +45,16 @@ public class TaskItemService {
|
||||
private final com.designhub.mapper.UserMapper userMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final com.designhub.util.WorkHoursCalculator workHoursCalculator;
|
||||
|
||||
// 排队解锁服务(延迟注入避免循环依赖)
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private TaskUnlockService taskUnlockService;
|
||||
|
||||
/**
|
||||
* 获取可接单的子任务列表
|
||||
*/
|
||||
public IPage<TaskItem> getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword) {
|
||||
return getAvailableItems(page, pageSize, taskTypeId, keyword, null);
|
||||
return getAvailableItems(page, pageSize, taskTypeId, keyword, null, "latest", true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -58,61 +62,143 @@ public class TaskItemService {
|
||||
* @param filterDeptId 筛选可接单部门ID(可选)
|
||||
*/
|
||||
public IPage<TaskItem> getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword, Long filterDeptId) {
|
||||
return getAvailableItems(page, pageSize, taskTypeId, keyword, filterDeptId, "latest", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可接单的子任务列表(完整版,支持排序和排队状态)
|
||||
* @param filterDeptId 筛选可接单部门ID(可选)
|
||||
* @param sortBy 排序方式:oldest(最早优先) / latest(最新优先,默认)
|
||||
* @param includeQueuing 是否包含排队中的任务
|
||||
*/
|
||||
public IPage<TaskItem> getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword,
|
||||
Long filterDeptId, String sortBy, Boolean includeQueuing) {
|
||||
Page<TaskItem> pageParam = new Page<>(page, pageSize);
|
||||
IPage<TaskItem> result;
|
||||
|
||||
// 如果没有指定筛选部门,直接查询
|
||||
if (filterDeptId == null) {
|
||||
return taskItemMapper.selectAvailableItems(pageParam, taskTypeId, keyword);
|
||||
}
|
||||
|
||||
// 🔥 获取筛选部门及其所有子部门的ID列表
|
||||
Set<Long> filterDeptAndChildIds = getDeptAndChildIds(filterDeptId);
|
||||
log.info("筛选部门ID: {}, 包含子部门: {}", filterDeptId, filterDeptAndChildIds);
|
||||
|
||||
// 获取该部门可接单的任务类型ID列表
|
||||
List<com.designhub.entity.DemandType> allDemandTypes = demandTypeMapper.selectList(null);
|
||||
Map<Long, com.designhub.entity.DemandType> demandTypeMap = allDemandTypes.stream()
|
||||
.collect(java.util.stream.Collectors.toMap(com.designhub.entity.DemandType::getId, dt -> dt));
|
||||
|
||||
List<Long> filterDeptAllowedTypeIds = allDemandTypes.stream()
|
||||
.filter(dt -> {
|
||||
String allowedDepts = dt.getAllowedDeptIds();
|
||||
if ((allowedDepts == null || allowedDepts.isEmpty()) && dt.getParentId() != null) {
|
||||
com.designhub.entity.DemandType parentType = demandTypeMap.get(dt.getParentId());
|
||||
if (parentType != null) {
|
||||
allowedDepts = parentType.getAllowedDeptIds();
|
||||
}
|
||||
}
|
||||
// 只筛选明确指定了该部门(或其子部门)的任务类型
|
||||
if (allowedDepts == null || allowedDepts.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String[] deptIds = allowedDepts.split(",");
|
||||
for (String deptIdStr : deptIds) {
|
||||
try {
|
||||
Long deptId = Long.parseLong(deptIdStr.trim());
|
||||
// 🔥 检查是否匹配筛选部门或其子部门
|
||||
if (filterDeptAndChildIds.contains(deptId)) {
|
||||
return true;
|
||||
result = taskItemMapper.selectAvailableItems(pageParam, taskTypeId, keyword);
|
||||
} else {
|
||||
// 🔥 获取筛选部门及其所有子部门的ID列表
|
||||
Set<Long> filterDeptAndChildIds = getDeptAndChildIds(filterDeptId);
|
||||
log.debug("筛选部门ID: {}, 包含子部门: {}", filterDeptId, filterDeptAndChildIds);
|
||||
|
||||
// 获取该部门可接单的任务类型ID列表
|
||||
List<com.designhub.entity.DemandType> allDemandTypes = demandTypeMapper.selectList(null);
|
||||
Map<Long, com.designhub.entity.DemandType> demandTypeMap = allDemandTypes.stream()
|
||||
.collect(java.util.stream.Collectors.toMap(com.designhub.entity.DemandType::getId, dt -> dt));
|
||||
|
||||
List<Long> filterDeptAllowedTypeIds = allDemandTypes.stream()
|
||||
.filter(dt -> {
|
||||
String allowedDepts = dt.getAllowedDeptIds();
|
||||
if ((allowedDepts == null || allowedDepts.isEmpty()) && dt.getParentId() != null) {
|
||||
com.designhub.entity.DemandType parentType = demandTypeMap.get(dt.getParentId());
|
||||
if (parentType != null) {
|
||||
allowedDepts = parentType.getAllowedDeptIds();
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// 忽略无效的部门ID
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(com.designhub.entity.DemandType::getId)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
log.info("匹配的任务类型ID: {}", filterDeptAllowedTypeIds);
|
||||
|
||||
// 如果没有找到匹配的任务类型,返回空结果
|
||||
if (filterDeptAllowedTypeIds.isEmpty()) {
|
||||
return new Page<>(page, pageSize);
|
||||
// 只筛选明确指定了该部门(或其子部门)的任务类型
|
||||
if (allowedDepts == null || allowedDepts.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String[] deptIds = allowedDepts.split(",");
|
||||
for (String deptIdStr : deptIds) {
|
||||
try {
|
||||
Long deptId = Long.parseLong(deptIdStr.trim());
|
||||
// 🔥 检查是否匹配筛选部门或其子部门
|
||||
if (filterDeptAndChildIds.contains(deptId)) {
|
||||
return true;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// 忽略无效的部门ID
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(com.designhub.entity.DemandType::getId)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
log.debug("匹配的任务类型ID: {}", filterDeptAllowedTypeIds);
|
||||
|
||||
// 如果没有找到匹配的任务类型,返回空结果
|
||||
if (filterDeptAllowedTypeIds.isEmpty()) {
|
||||
return new Page<>(page, pageSize);
|
||||
}
|
||||
|
||||
// 查询并筛选
|
||||
result = taskItemMapper.selectAvailableItemsByTaskTypes(pageParam, taskTypeId, keyword, filterDeptAllowedTypeIds);
|
||||
}
|
||||
|
||||
// 查询并筛选
|
||||
return taskItemMapper.selectAvailableItemsByTaskTypes(pageParam, taskTypeId, keyword, filterDeptAllowedTypeIds);
|
||||
// 🔥 填充排队状态信息
|
||||
if (result.getRecords() != null && !result.getRecords().isEmpty()) {
|
||||
fillUnlockStatusForItems(result.getRecords(), filterDeptId);
|
||||
|
||||
// 🔥 按排序方式和解锁状态排序
|
||||
List<TaskItem> sortedRecords = new java.util.ArrayList<>(result.getRecords());
|
||||
sortedRecords.sort((a, b) -> {
|
||||
// 首先按解锁状态排序:已解锁的排前面
|
||||
boolean aUnlocked = "unlocked".equals(a.getUnlockStatus()) || a.getUnlockStatus() == null;
|
||||
boolean bUnlocked = "unlocked".equals(b.getUnlockStatus()) || b.getUnlockStatus() == null;
|
||||
if (aUnlocked != bUnlocked) {
|
||||
return aUnlocked ? -1 : 1;
|
||||
}
|
||||
|
||||
// 然后按时间排序
|
||||
LocalDateTime timeA = a.getCreateTime();
|
||||
LocalDateTime timeB = b.getCreateTime();
|
||||
if (timeA == null && timeB == null) return 0;
|
||||
if (timeA == null) return 1;
|
||||
if (timeB == null) return -1;
|
||||
|
||||
if ("oldest".equals(sortBy)) {
|
||||
return timeA.compareTo(timeB); // 最早优先
|
||||
} else {
|
||||
return timeB.compareTo(timeA); // 最新优先(默认)
|
||||
}
|
||||
});
|
||||
|
||||
// 更新结果
|
||||
((Page<TaskItem>) result).setRecords(sortedRecords);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为任务列表填充排队解锁状态
|
||||
*/
|
||||
private void fillUnlockStatusForItems(List<TaskItem> items, Long deptId) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (TaskItem item : items) {
|
||||
// 获取主任务的发布类型
|
||||
String releaseType = item.getReleaseType();
|
||||
if (releaseType == null && item.getTaskId() != null) {
|
||||
Task mainTask = taskMapper.selectById(item.getTaskId());
|
||||
if (mainTask != null) {
|
||||
releaseType = mainTask.getReleaseType();
|
||||
item.setReleaseType(releaseType);
|
||||
}
|
||||
}
|
||||
|
||||
// 确定目标部门
|
||||
Long targetDeptId = deptId;
|
||||
if (targetDeptId == null) {
|
||||
targetDeptId = item.getReceiverDeptId();
|
||||
}
|
||||
|
||||
// 填充解锁状态
|
||||
if (targetDeptId != null) {
|
||||
taskUnlockService.fillUnlockStatus(item, targetDeptId, releaseType);
|
||||
} else {
|
||||
// 无法确定部门,默认可抢
|
||||
item.setUnlockStatus("unlocked");
|
||||
item.setCanGrab(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -358,6 +444,18 @@ public class TaskItemService {
|
||||
throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "该子任务已被锁定,请等待前序任务完成");
|
||||
}
|
||||
|
||||
// 🔥 检查排队解锁状态:非指定任务需要检查是否已解锁
|
||||
Task mainTaskForUnlockCheck = taskMapper.selectById(item.getTaskId());
|
||||
String releaseType = mainTaskForUnlockCheck != null ? mainTaskForUnlockCheck.getReleaseType() : null;
|
||||
|
||||
if (!"assigned".equals(releaseType)) {
|
||||
// 非指定任务,检查是否可抢单
|
||||
Long targetDeptId = item.getReceiverDeptId() != null ? item.getReceiverDeptId() : user.getDeptId();
|
||||
if (!taskUnlockService.canGrab(itemId, targetDeptId, releaseType)) {
|
||||
throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "该任务正在排队中,暂不可接单");
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 检查是否是指定任务:如果子任务已预先指定了接收人
|
||||
boolean isAssignedItem = false;
|
||||
if (item.getReceiverId() != null) {
|
||||
@ -446,6 +544,17 @@ public class TaskItemService {
|
||||
log.info("已通知一级发起人 {} 子任务被接单", mainTask.getPrimaryPublisherId());
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 任务被抢单后,检查并补充解锁下一批任务
|
||||
Long grabberDeptId = user.getDeptId();
|
||||
if (grabberDeptId != null) {
|
||||
try {
|
||||
taskUnlockService.onTaskGrabbed(grabberDeptId);
|
||||
} catch (Exception e) {
|
||||
log.warn("抢单后补充解锁任务失败: {}", e.getMessage());
|
||||
// 不影响抢单流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,267 @@
|
||||
package com.designhub.service;
|
||||
|
||||
import com.designhub.entity.*;
|
||||
import com.designhub.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 任务状态变更通知服务
|
||||
* 负责在任务状态变化时通知需求人
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TaskStatusNotificationService {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
/**
|
||||
* 通知需求人:任务已解锁(排队中 → 抢单中)
|
||||
*/
|
||||
@Async
|
||||
public void notifyTaskUnlocked(TaskItem item, Task mainTask) {
|
||||
if (mainTask == null) {
|
||||
log.warn("通知任务解锁失败:主任务为空");
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Long> demandUserIds = getDemandUserIds(mainTask);
|
||||
if (demandUserIds.isEmpty()) {
|
||||
log.warn("通知任务解锁失败:没有找到需求人");
|
||||
return;
|
||||
}
|
||||
|
||||
String content = String.format(
|
||||
"您发布的任务《%s》已完成排队,现已开放设计师抢单。\n" +
|
||||
"任务编号:%s\n" +
|
||||
"发布时间:%s\n" +
|
||||
"设计师正在抢单中,请耐心等待...",
|
||||
mainTask.getTitle(),
|
||||
item.getItemNo(),
|
||||
formatTime(mainTask.getCreateTime())
|
||||
);
|
||||
|
||||
for (Long userId : demandUserIds) {
|
||||
try {
|
||||
Notification notification = new Notification();
|
||||
notification.setReceiverId(userId);
|
||||
notification.setNotificationType("task_status");
|
||||
notification.setTitle("📢 任务已开放抢单");
|
||||
notification.setContent(content);
|
||||
notification.setRelatedId(item.getId());
|
||||
notification.setRelatedType("task_item");
|
||||
notification.setPriority(1); // 普通优先级
|
||||
notification.setPushDingtalk(1); // 推送钉钉
|
||||
notificationService.sendNotification(notification);
|
||||
|
||||
log.info("已通知需求人 {} 任务 {} 已解锁开放抢单", userId, item.getItemNo());
|
||||
} catch (Exception e) {
|
||||
log.error("通知需求人 {} 任务解锁失败: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知需求人:任务已被接单(抢单中 → 进行中)
|
||||
*/
|
||||
@Async
|
||||
public void notifyTaskGrabbed(TaskItem item, Task mainTask, User designer) {
|
||||
if (mainTask == null || designer == null) {
|
||||
log.warn("通知任务接单失败:主任务或设计师信息为空");
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Long> demandUserIds = getDemandUserIds(mainTask);
|
||||
if (demandUserIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String content = String.format(
|
||||
"您发布的任务《%s》已被设计师接单。\n" +
|
||||
"任务编号:%s\n" +
|
||||
"接单设计师:%s\n" +
|
||||
"预计完成:%s\n" +
|
||||
"如有问题可联系设计师沟通。",
|
||||
mainTask.getTitle(),
|
||||
item.getItemNo(),
|
||||
designer.getRealName(),
|
||||
item.getDeadline() != null ? formatTime(item.getDeadline()) : "待定"
|
||||
);
|
||||
|
||||
for (Long userId : demandUserIds) {
|
||||
// 不通知自己(如果设计师就是需求人)
|
||||
if (userId.equals(designer.getId())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Notification notification = new Notification();
|
||||
notification.setReceiverId(userId);
|
||||
notification.setNotificationType("task_status");
|
||||
notification.setTitle("🎉 任务已被接单");
|
||||
notification.setContent(content);
|
||||
notification.setRelatedId(item.getId());
|
||||
notification.setRelatedType("task_item");
|
||||
notification.setPriority(1);
|
||||
notification.setPushDingtalk(1);
|
||||
notificationService.sendNotification(notification);
|
||||
|
||||
log.info("已通知需求人 {} 任务 {} 已被 {} 接单", userId, item.getItemNo(), designer.getRealName());
|
||||
} catch (Exception e) {
|
||||
log.error("通知需求人 {} 任务接单失败: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知需求人:任务已完成
|
||||
*/
|
||||
@Async
|
||||
public void notifyTaskCompleted(TaskItem item, Task mainTask, User designer) {
|
||||
if (mainTask == null || designer == null) {
|
||||
log.warn("通知任务完成失败:主任务或设计师信息为空");
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Long> demandUserIds = getDemandUserIds(mainTask);
|
||||
if (demandUserIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String content = String.format(
|
||||
"您发布的任务《%s》已完成。\n" +
|
||||
"任务编号:%s\n" +
|
||||
"完成设计师:%s\n" +
|
||||
"完成时间:%s\n" +
|
||||
"请及时查收并确认交付物。",
|
||||
mainTask.getTitle(),
|
||||
item.getItemNo(),
|
||||
designer.getRealName(),
|
||||
formatTime(item.getCompleteTime() != null ? item.getCompleteTime() : LocalDateTime.now())
|
||||
);
|
||||
|
||||
for (Long userId : demandUserIds) {
|
||||
try {
|
||||
Notification notification = new Notification();
|
||||
notification.setReceiverId(userId);
|
||||
notification.setNotificationType("task_status");
|
||||
notification.setTitle("✅ 任务已完成");
|
||||
notification.setContent(content);
|
||||
notification.setRelatedId(item.getId());
|
||||
notification.setRelatedType("task_item");
|
||||
notification.setPriority(2); // 重要
|
||||
notification.setPushDingtalk(1);
|
||||
notificationService.sendNotification(notification);
|
||||
|
||||
log.info("已通知需求人 {} 任务 {} 已完成", userId, item.getItemNo());
|
||||
} catch (Exception e) {
|
||||
log.error("通知需求人 {} 任务完成失败: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量通知任务解锁
|
||||
*/
|
||||
@Async
|
||||
public void batchNotifyTasksUnlocked(java.util.List<TaskItem> items, java.util.Map<Long, Task> taskMap) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按主任务分组,避免同一任务的多个子任务重复通知
|
||||
java.util.Map<Long, java.util.List<TaskItem>> groupedByTask = items.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(TaskItem::getTaskId));
|
||||
|
||||
for (java.util.Map.Entry<Long, java.util.List<TaskItem>> entry : groupedByTask.entrySet()) {
|
||||
Long taskId = entry.getKey();
|
||||
java.util.List<TaskItem> taskItems = entry.getValue();
|
||||
Task mainTask = taskMap.get(taskId);
|
||||
|
||||
if (mainTask == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<Long> demandUserIds = getDemandUserIds(mainTask);
|
||||
if (demandUserIds.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 构建通知内容
|
||||
String itemNos = taskItems.stream()
|
||||
.map(TaskItem::getItemNo)
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
|
||||
String content = String.format(
|
||||
"您发布的任务《%s》有 %d 个子任务已完成排队,现已开放设计师抢单。\n" +
|
||||
"子任务编号:%s\n" +
|
||||
"设计师正在抢单中,请耐心等待...",
|
||||
mainTask.getTitle(),
|
||||
taskItems.size(),
|
||||
itemNos
|
||||
);
|
||||
|
||||
for (Long userId : demandUserIds) {
|
||||
try {
|
||||
Notification notification = new Notification();
|
||||
notification.setReceiverId(userId);
|
||||
notification.setNotificationType("task_status");
|
||||
notification.setTitle("📢 任务已开放抢单");
|
||||
notification.setContent(content);
|
||||
notification.setRelatedId(taskId);
|
||||
notification.setRelatedType("task");
|
||||
notification.setPriority(1);
|
||||
notification.setPushDingtalk(1);
|
||||
notificationService.sendNotification(notification);
|
||||
} catch (Exception e) {
|
||||
log.error("批量通知任务解锁失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务的所有需求人(发布者 + 详情填写人 + 一级发起人)
|
||||
*/
|
||||
private Set<Long> getDemandUserIds(Task task) {
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
|
||||
// 发布者
|
||||
if (task.getPublisherId() != null) {
|
||||
userIds.add(task.getPublisherId());
|
||||
}
|
||||
|
||||
// 详情填写人
|
||||
if (task.getDetailCompleterId() != null) {
|
||||
userIds.add(task.getDetailCompleterId());
|
||||
}
|
||||
|
||||
// 一级发起人
|
||||
if (task.getPrimaryPublisherId() != null) {
|
||||
userIds.add(task.getPrimaryPublisherId());
|
||||
}
|
||||
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
private String formatTime(LocalDateTime time) {
|
||||
if (time == null) {
|
||||
return "未知";
|
||||
}
|
||||
return time.format(DATE_TIME_FORMATTER);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,515 @@
|
||||
package com.designhub.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.designhub.entity.*;
|
||||
import com.designhub.mapper.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 任务排队解锁服务
|
||||
* 实现任务排队机制,确保先发布的任务优先被处理
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TaskUnlockService {
|
||||
|
||||
@Autowired
|
||||
private TaskUnlockStatusMapper unlockStatusMapper;
|
||||
|
||||
@Autowired
|
||||
private TaskItemMapper taskItemMapper;
|
||||
|
||||
@Autowired
|
||||
private TaskMapper taskMapper;
|
||||
|
||||
@Autowired
|
||||
private DepartmentMapper departmentMapper;
|
||||
|
||||
@Autowired
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
@Autowired
|
||||
private DemandTypeMapper demandTypeMapper;
|
||||
|
||||
@Autowired
|
||||
private TaskStatusNotificationService taskStatusNotificationService;
|
||||
|
||||
private static final int DEFAULT_DAILY_UNLOCK_MIN = 5;
|
||||
private static final int DEFAULT_BATCH_SIZE = 5;
|
||||
|
||||
// ==================== 配置相关 ====================
|
||||
|
||||
/**
|
||||
* 检查排队解锁功能是否启用
|
||||
*/
|
||||
public boolean isUnlockEnabled() {
|
||||
String enabled = systemConfigService.getConfigValue("task_unlock_enabled", "false");
|
||||
return "true".equalsIgnoreCase(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每日最少解锁数
|
||||
*/
|
||||
public int getDailyUnlockMin() {
|
||||
String value = systemConfigService.getConfigValue("task_unlock_daily_min", String.valueOf(DEFAULT_DAILY_UNLOCK_MIN));
|
||||
try {
|
||||
return Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return DEFAULT_DAILY_UNLOCK_MIN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解锁批次大小
|
||||
*/
|
||||
public int getBatchSize() {
|
||||
String value = systemConfigService.getConfigValue("task_unlock_batch_size", String.valueOf(DEFAULT_BATCH_SIZE));
|
||||
try {
|
||||
return Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return DEFAULT_BATCH_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 定时任务 ====================
|
||||
|
||||
/**
|
||||
* 每日解锁任务(每天凌晨1点执行)
|
||||
*/
|
||||
@Scheduled(cron = "0 0 1 * * ?")
|
||||
public void dailyUnlockTask() {
|
||||
if (!isUnlockEnabled()) {
|
||||
log.info("任务排队解锁功能已关闭,跳过每日解锁任务");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("========== 开始执行每日任务解锁 ==========");
|
||||
|
||||
// 获取所有接单部门
|
||||
List<Department> receivingDepts = departmentMapper.selectList(
|
||||
new LambdaQueryWrapper<Department>()
|
||||
.eq(Department::getDeptType, "receiving")
|
||||
.eq(Department::getStatus, 1)
|
||||
);
|
||||
|
||||
int totalUnlocked = 0;
|
||||
int dailyMin = getDailyUnlockMin();
|
||||
|
||||
for (Department dept : receivingDepts) {
|
||||
try {
|
||||
int count = unlockTasksForDept(dept.getId(), dailyMin);
|
||||
totalUnlocked += count;
|
||||
if (count > 0) {
|
||||
log.info("部门 {} ({}) 解锁了 {} 个任务", dept.getDeptName(), dept.getId(), count);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("部门 {} 解锁任务失败: {}", dept.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("========== 每日任务解锁完成,共解锁 {} 个任务 ==========", totalUnlocked);
|
||||
}
|
||||
|
||||
// ==================== 核心解锁逻辑 ====================
|
||||
|
||||
/**
|
||||
* 为指定部门解锁任务
|
||||
* @param deptId 部门ID
|
||||
* @param minCount 最少解锁数量
|
||||
* @return 实际解锁数量
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int unlockTasksForDept(Long deptId, int minCount) {
|
||||
// 1. 检查当前已解锁但未接单的任务数
|
||||
int currentUnlockedPending = unlockStatusMapper.countUnlockedPendingTasks(deptId);
|
||||
|
||||
// 如果已有足够的可接任务,不需要解锁更多
|
||||
if (currentUnlockedPending >= minCount) {
|
||||
log.debug("部门 {} 已有 {} 个可接任务,无需解锁更多", deptId, currentUnlockedPending);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 计算需要解锁的数量
|
||||
int needToUnlock = minCount - currentUnlockedPending;
|
||||
|
||||
// 3. 获取待解锁的任务ID列表(按发布日期排序)
|
||||
List<Long> tasksToUnlock = unlockStatusMapper.selectTasksToUnlock(deptId, needToUnlock);
|
||||
|
||||
if (tasksToUnlock.isEmpty()) {
|
||||
log.debug("部门 {} 没有待解锁的任务", deptId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 批量解锁
|
||||
int unlocked = unlockStatusMapper.batchUnlock(deptId, tasksToUnlock);
|
||||
|
||||
log.info("部门 {} 成功解锁 {} 个任务", deptId, unlocked);
|
||||
|
||||
// 5. 发送解锁通知给需求人
|
||||
try {
|
||||
notifyUnlockedTasks(tasksToUnlock);
|
||||
} catch (Exception e) {
|
||||
log.error("发送解锁通知失败: {}", e.getMessage());
|
||||
// 不影响解锁流程
|
||||
}
|
||||
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务被抢单后,检查并补充解锁
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void onTaskGrabbed(Long deptId) {
|
||||
if (!isUnlockEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查剩余可接任务数
|
||||
int remainingUnlocked = unlockStatusMapper.countUnlockedPendingTasks(deptId);
|
||||
int batchSize = getBatchSize();
|
||||
|
||||
// 如果少于批次大小,补充解锁
|
||||
if (remainingUnlocked < batchSize) {
|
||||
int unlocked = unlockTasksForDept(deptId, batchSize);
|
||||
if (unlocked > 0) {
|
||||
log.info("任务被接单后,为部门 {} 补充解锁 {} 个任务", deptId, unlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 任务入队逻辑 ====================
|
||||
|
||||
/**
|
||||
* 任务审核通过后,加入排队池
|
||||
* @param task 主任务
|
||||
* @param items 子任务列表
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void onTaskApproved(Task task, List<TaskItem> items) {
|
||||
if (!isUnlockEnabled()) {
|
||||
log.debug("排队解锁功能未启用,跳过入队处理");
|
||||
return;
|
||||
}
|
||||
|
||||
// 指定任务不进入排队池
|
||||
if ("assigned".equals(task.getReleaseType())) {
|
||||
log.info("指定任务 {} 不进入排队池,直接可见", task.getTaskNo());
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDate publishDate = task.getCreateTime().toLocalDate();
|
||||
int addedCount = 0;
|
||||
|
||||
for (TaskItem item : items) {
|
||||
// 获取目标接单部门
|
||||
Long deptId = getTargetDeptId(item, task);
|
||||
if (deptId == null) {
|
||||
log.warn("子任务 {} 无法确定目标部门,跳过入队", item.getItemNo());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
TaskUnlockStatus existing = unlockStatusMapper.selectOne(
|
||||
new LambdaQueryWrapper<TaskUnlockStatus>()
|
||||
.eq(TaskUnlockStatus::getTaskItemId, item.getId())
|
||||
.eq(TaskUnlockStatus::getDeptId, deptId)
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
continue; // 已存在,跳过
|
||||
}
|
||||
|
||||
// 创建排队记录
|
||||
TaskUnlockStatus unlockStatus = new TaskUnlockStatus();
|
||||
unlockStatus.setTaskItemId(item.getId());
|
||||
unlockStatus.setDeptId(deptId);
|
||||
unlockStatus.setPublishDate(publishDate);
|
||||
unlockStatus.setIsUnlocked(0); // 未解锁
|
||||
unlockStatus.setCreateTime(LocalDateTime.now());
|
||||
|
||||
unlockStatusMapper.insert(unlockStatus);
|
||||
addedCount++;
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
log.info("任务 {} 的 {} 个子任务已加入排队池", task.getTaskNo(), addedCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流水线解锁时,自动标记为已解锁(不走排队)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void onPipelineUnlock(TaskItem item, Long deptId) {
|
||||
if (!isUnlockEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已有记录
|
||||
TaskUnlockStatus existing = unlockStatusMapper.selectOne(
|
||||
new LambdaQueryWrapper<TaskUnlockStatus>()
|
||||
.eq(TaskUnlockStatus::getTaskItemId, item.getId())
|
||||
.eq(TaskUnlockStatus::getDeptId, deptId)
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
// 更新为已解锁
|
||||
existing.setIsUnlocked(1);
|
||||
existing.setUnlockTime(LocalDateTime.now());
|
||||
unlockStatusMapper.updateById(existing);
|
||||
} else {
|
||||
// 创建已解锁记录
|
||||
TaskUnlockStatus unlockStatus = new TaskUnlockStatus();
|
||||
unlockStatus.setTaskItemId(item.getId());
|
||||
unlockStatus.setDeptId(deptId);
|
||||
unlockStatus.setPublishDate(LocalDate.now());
|
||||
unlockStatus.setIsUnlocked(1); // 直接解锁
|
||||
unlockStatus.setUnlockTime(LocalDateTime.now());
|
||||
unlockStatus.setCreateTime(LocalDateTime.now());
|
||||
|
||||
unlockStatusMapper.insert(unlockStatus);
|
||||
}
|
||||
|
||||
log.info("流水线子任务 {} 已自动标记为已解锁", item.getItemNo());
|
||||
}
|
||||
|
||||
// ==================== 查询方法 ====================
|
||||
|
||||
/**
|
||||
* 获取已解锁的任务ID列表
|
||||
*/
|
||||
public List<Long> getUnlockedTaskItemIds(Long deptId) {
|
||||
return unlockStatusMapper.selectUnlockedTaskItemIds(deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排队中的任务ID列表
|
||||
*/
|
||||
public List<Long> getQueuedTaskItemIds(Long deptId) {
|
||||
return unlockStatusMapper.selectQueuedTaskItemIds(deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充任务的排队状态信息
|
||||
*/
|
||||
public void fillUnlockStatus(TaskItem item, Long deptId, String releaseType) {
|
||||
// 指定任务不显示排队状态
|
||||
if ("assigned".equals(releaseType)) {
|
||||
item.setUnlockStatus(null);
|
||||
item.setCanGrab(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 pending 状态不需要排队信息
|
||||
if (!"pending".equals(item.getStatus())) {
|
||||
item.setUnlockStatus(null);
|
||||
item.setCanGrab(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 功能未启用时,所有任务都可抢
|
||||
if (!isUnlockEnabled()) {
|
||||
item.setUnlockStatus("unlocked");
|
||||
item.setCanGrab(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询解锁状态
|
||||
Integer isUnlocked = unlockStatusMapper.isUnlocked(item.getId(), deptId);
|
||||
|
||||
if (isUnlocked == null) {
|
||||
// 没有记录,视为已解锁(兼容旧数据)
|
||||
item.setUnlockStatus("unlocked");
|
||||
item.setCanGrab(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnlocked == 1) {
|
||||
// 已解锁 = 抢单中
|
||||
item.setUnlockStatus("unlocked");
|
||||
item.setCanGrab(true);
|
||||
} else {
|
||||
// 未解锁 = 排队中
|
||||
item.setUnlockStatus("queuing");
|
||||
item.setCanGrab(false);
|
||||
|
||||
// 计算排队位置
|
||||
int position = unlockStatusMapper.calculateQueuePosition(item.getId(), deptId);
|
||||
item.setQueuePosition(position);
|
||||
|
||||
// 计算总排队数
|
||||
int totalQueue = unlockStatusMapper.countQueuedTasks(deptId);
|
||||
item.setTotalQueueSize(totalQueue);
|
||||
|
||||
// 预估解锁日期(每天解锁 dailyMin 个)
|
||||
int dailyMin = getDailyUnlockMin();
|
||||
int daysToUnlock = (int) Math.ceil((double) position / dailyMin);
|
||||
LocalDate unlockDate = LocalDate.now().plusDays(daysToUnlock);
|
||||
item.setEstimatedUnlockDate(unlockDate.format(DateTimeFormatter.ofPattern("MM月dd日")));
|
||||
|
||||
// 计算进度百分比
|
||||
int progress = totalQueue > 0 ?
|
||||
(int) Math.round((totalQueue - position + 1.0) / totalQueue * 100) : 0;
|
||||
item.setQueueProgress(Math.min(progress, 99)); // 最高99%,100%表示已解锁
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充任务的排队状态
|
||||
*/
|
||||
public void batchFillUnlockStatus(List<TaskItem> items, Long deptId) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (TaskItem item : items) {
|
||||
String releaseType = item.getReleaseType();
|
||||
fillUnlockStatus(item, deptId, releaseType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否可以抢单
|
||||
*/
|
||||
public boolean canGrab(Long taskItemId, Long deptId, String releaseType) {
|
||||
// 指定任务始终可抢(由其他逻辑控制权限)
|
||||
if ("assigned".equals(releaseType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 功能未启用时,都可抢
|
||||
if (!isUnlockEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查解锁状态
|
||||
Integer isUnlocked = unlockStatusMapper.isUnlocked(taskItemId, deptId);
|
||||
|
||||
// 没有记录或已解锁 = 可抢
|
||||
return isUnlocked == null || isUnlocked == 1;
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 获取任务的目标接单部门ID
|
||||
*/
|
||||
private Long getTargetDeptId(TaskItem item, Task task) {
|
||||
// 优先使用子任务的接单部门
|
||||
if (item.getReceiverDeptId() != null) {
|
||||
return item.getReceiverDeptId();
|
||||
}
|
||||
|
||||
// 从任务类型获取
|
||||
if (task.getDemandTypeId() != null) {
|
||||
DemandType demandType = demandTypeMapper.selectById(task.getDemandTypeId());
|
||||
if (demandType != null && demandType.getAllowedDeptIds() != null) {
|
||||
String[] deptIds = demandType.getAllowedDeptIds().split(",");
|
||||
if (deptIds.length > 0) {
|
||||
try {
|
||||
return Long.parseLong(deptIds[0].trim());
|
||||
} catch (NumberFormatException e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务的解锁状态(任务取消/删除时调用)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteUnlockStatus(Long taskItemId) {
|
||||
unlockStatusMapper.deleteByTaskItemId(taskItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知任务解锁
|
||||
*/
|
||||
private void notifyUnlockedTasks(List<Long> taskItemIds) {
|
||||
if (taskItemIds == null || taskItemIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询子任务和主任务信息
|
||||
List<TaskItem> items = taskItemMapper.selectBatchIds(taskItemIds);
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取主任务信息
|
||||
Set<Long> taskIds = new HashSet<>();
|
||||
for (TaskItem item : items) {
|
||||
if (item.getTaskId() != null) {
|
||||
taskIds.add(item.getTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
Map<Long, Task> taskMap = new HashMap<>();
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<Task> tasks = taskMapper.selectBatchIds(taskIds);
|
||||
for (Task task : tasks) {
|
||||
taskMap.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量通知
|
||||
taskStatusNotificationService.batchNotifyTasksUnlocked(items, taskMap);
|
||||
}
|
||||
|
||||
// ==================== 统计方法 ====================
|
||||
|
||||
/**
|
||||
* 获取解锁状态统计
|
||||
*/
|
||||
public Map<String, Object> getUnlockStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
|
||||
// 总待解锁数
|
||||
long totalQueued = unlockStatusMapper.selectCount(
|
||||
new LambdaQueryWrapper<TaskUnlockStatus>()
|
||||
.eq(TaskUnlockStatus::getIsUnlocked, 0)
|
||||
);
|
||||
stats.put("totalQueued", totalQueued);
|
||||
|
||||
// 总已解锁数
|
||||
long totalUnlocked = unlockStatusMapper.selectCount(
|
||||
new LambdaQueryWrapper<TaskUnlockStatus>()
|
||||
.eq(TaskUnlockStatus::getIsUnlocked, 1)
|
||||
);
|
||||
stats.put("totalUnlocked", totalUnlocked);
|
||||
|
||||
// 按部门统计
|
||||
List<Department> depts = departmentMapper.selectList(
|
||||
new LambdaQueryWrapper<Department>()
|
||||
.eq(Department::getDeptType, "receiving")
|
||||
.eq(Department::getStatus, 1)
|
||||
);
|
||||
|
||||
List<Map<String, Object>> deptStats = new ArrayList<>();
|
||||
for (Department dept : depts) {
|
||||
Map<String, Object> deptStat = new HashMap<>();
|
||||
deptStat.put("deptId", dept.getId());
|
||||
deptStat.put("deptName", dept.getDeptName());
|
||||
deptStat.put("queuedCount", unlockStatusMapper.countQueuedTasks(dept.getId()));
|
||||
deptStat.put("unlockedPendingCount", unlockStatusMapper.countUnlockedPendingTasks(dept.getId()));
|
||||
deptStats.add(deptStat);
|
||||
}
|
||||
stats.put("deptStats", deptStats);
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
64
database/V15__task_unlock_queue.sql
Normal file
64
database/V15__task_unlock_queue.sql
Normal file
@ -0,0 +1,64 @@
|
||||
-- =====================================================
|
||||
-- 任务排队解锁机制 - 数据库迁移脚本
|
||||
-- 版本: V15
|
||||
-- 日期: 2026-01-26
|
||||
-- 说明: 实现任务排队解锁功能,确保先发布的任务优先被处理
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 创建任务解锁状态表
|
||||
CREATE TABLE IF NOT EXISTS `task_unlock_status` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`task_item_id` BIGINT NOT NULL COMMENT '子任务ID',
|
||||
`dept_id` BIGINT NOT NULL COMMENT '接单部门ID',
|
||||
`publish_date` DATE NOT NULL COMMENT '任务发布日期(用于排队)',
|
||||
`is_unlocked` TINYINT(1) DEFAULT 0 COMMENT '是否已解锁: 0-未解锁(排队中), 1-已解锁(可抢单)',
|
||||
`unlock_time` DATETIME NULL COMMENT '解锁时间',
|
||||
`queue_position` INT DEFAULT 0 COMMENT '排队位置(动态计算,缓存用)',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_task_dept` (`task_item_id`, `dept_id`),
|
||||
INDEX `idx_dept_unlock` (`dept_id`, `is_unlocked`, `publish_date`),
|
||||
INDEX `idx_publish_date` (`publish_date`, `is_unlocked`),
|
||||
INDEX `idx_task_item` (`task_item_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务排队解锁状态表';
|
||||
|
||||
-- 2. 添加系统配置项(默认关闭,灰度测试后开启)
|
||||
INSERT INTO sys_config (config_key, config_value, config_group, config_name, description, value_type, built_in, sort_order) VALUES
|
||||
('task_unlock_enabled', 'false', 'task', '任务排队解锁开关', '是否启用任务排队解锁机制,启用后新任务需排队等待解锁', 'boolean', 1, 100),
|
||||
('task_unlock_daily_min', '5', 'task', '每日最少解锁数', '每个部门每天最少解锁的任务数量', 'number', 1, 101),
|
||||
('task_unlock_batch_size', '5', 'task', '解锁批次大小', '每次补充解锁的任务数量', 'number', 1, 102)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_value = VALUES(config_value),
|
||||
description = VALUES(description);
|
||||
|
||||
-- 3. 初始化现有可接任务为已解锁状态(确保上线不影响现有业务)
|
||||
-- 注意:只处理招募类任务(release_type != 'assigned'),指定任务不进入排队池
|
||||
INSERT IGNORE INTO task_unlock_status (task_item_id, dept_id, publish_date, is_unlocked, unlock_time, create_time)
|
||||
SELECT
|
||||
ti.id AS task_item_id,
|
||||
COALESCE(
|
||||
ti.receiver_dept_id,
|
||||
(SELECT CAST(SUBSTRING_INDEX(dt.allowed_dept_ids, ',', 1) AS UNSIGNED)
|
||||
FROM demand_type dt WHERE dt.id = t.demand_type_id LIMIT 1),
|
||||
t.publisher_dept_id
|
||||
) AS dept_id,
|
||||
DATE(ti.create_time) AS publish_date,
|
||||
1 AS is_unlocked, -- 已解锁
|
||||
NOW() AS unlock_time,
|
||||
NOW() AS create_time
|
||||
FROM task_item ti
|
||||
INNER JOIN task t ON ti.task_id = t.id
|
||||
WHERE ti.status = 'pending'
|
||||
AND ti.is_locked = 0
|
||||
AND t.deleted = 0
|
||||
AND t.approval_status = 'approved'
|
||||
AND (t.release_type IS NULL OR t.release_type != 'assigned'); -- 排除指定任务
|
||||
|
||||
-- 4. 输出初始化结果
|
||||
SELECT
|
||||
'初始化完成' AS status,
|
||||
COUNT(*) AS initialized_count,
|
||||
SUM(CASE WHEN is_unlocked = 1 THEN 1 ELSE 0 END) AS unlocked_count
|
||||
FROM task_unlock_status;
|
||||
1450
docs/任务排队解锁机制开发文档.md
Normal file
1450
docs/任务排队解锁机制开发文档.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -170,7 +170,7 @@ export interface TaskHallParams {
|
||||
keyword?: string
|
||||
publisherName?: string
|
||||
timeRange?: 'today' | 'week' | 'month'
|
||||
sortBy?: 'latest' | 'score' | 'deadline' | 'hot'
|
||||
sortBy?: 'oldest' | 'latest' | 'score' | 'deadline' | 'hot' // 🔥 新增 oldest 选项
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
@ -133,19 +133,19 @@
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<div class="task-footer">
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)">
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)" style="cursor: help;">
|
||||
<Icon name="calendar" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">发布: {{ formatDate(task.createTime) }}</span>
|
||||
</div>
|
||||
<div v-if="task.deadline && task.status === 'in_progress'" class="footer-item" :title="formatFullDateTime(task.deadline)">
|
||||
<div v-if="task.deadline && task.status === 'in_progress'" class="footer-item" :title="formatFullDateTime(task.deadline)" style="cursor: help;">
|
||||
<Icon name="clock" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">截止: {{ formatDate(task.deadline) }}</span>
|
||||
</div>
|
||||
<div v-if="task.completeTime && task.status === 'completed'" class="footer-item" :title="formatFullDateTime(task.completeTime)">
|
||||
<div v-if="task.completeTime && task.status === 'completed'" class="footer-item" :title="formatFullDateTime(task.completeTime)" style="cursor: help;">
|
||||
<Icon name="check" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">完成: {{ formatDate(task.completeTime) }}</span>
|
||||
</div>
|
||||
<div v-if="task.submitTime && task.status === 'review'" class="footer-item" :title="formatFullDateTime(task.submitTime)">
|
||||
<div v-if="task.submitTime && task.status === 'review'" class="footer-item" :title="formatFullDateTime(task.submitTime)" style="cursor: help;">
|
||||
<Icon name="upload" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">提交: {{ formatDate(task.submitTime) }}</span>
|
||||
</div>
|
||||
@ -155,7 +155,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { message, Tooltip } from 'ant-design-vue'
|
||||
import Icon from './Icon.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
@ -194,6 +194,14 @@ interface Task {
|
||||
submitTime?: string
|
||||
isPipeline?: number
|
||||
subTasks?: TaskItem[]
|
||||
// 排队解锁相关字段
|
||||
unlockStatus?: 'queuing' | 'unlocked' | null
|
||||
queuePosition?: number
|
||||
totalQueueSize?: number
|
||||
estimatedUnlockDate?: string
|
||||
queueProgress?: number
|
||||
canGrab?: boolean
|
||||
releaseType?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -231,6 +239,14 @@ const statusClass = computed(() => {
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
// 排队状态处理
|
||||
if (props.task.status === 'pending' && props.task.unlockStatus === 'queuing') {
|
||||
return 'badge-queuing'
|
||||
}
|
||||
if (props.task.status === 'pending' && props.task.unlockStatus === 'unlocked') {
|
||||
return 'badge-grabbing'
|
||||
}
|
||||
|
||||
const classMap: Record<string, string> = {
|
||||
'pending': 'badge-new',
|
||||
'in_progress': 'badge-progress',
|
||||
@ -242,6 +258,14 @@ const badgeClass = computed(() => {
|
||||
})
|
||||
|
||||
const badgeIcon = computed(() => {
|
||||
// 排队状态处理
|
||||
if (props.task.status === 'pending' && props.task.unlockStatus === 'queuing') {
|
||||
return 'clock'
|
||||
}
|
||||
if (props.task.status === 'pending' && props.task.unlockStatus === 'unlocked') {
|
||||
return 'zap'
|
||||
}
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
'pending': 'sparkles',
|
||||
'in_progress': 'clock',
|
||||
@ -253,6 +277,14 @@ const badgeIcon = computed(() => {
|
||||
})
|
||||
|
||||
const badgeIconColor = computed(() => {
|
||||
// 排队状态处理
|
||||
if (props.task.status === 'pending' && props.task.unlockStatus === 'queuing') {
|
||||
return '#fa8c16' // 橙色
|
||||
}
|
||||
if (props.task.status === 'pending' && props.task.unlockStatus === 'unlocked') {
|
||||
return '#52c41a' // 绿色
|
||||
}
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
'pending': '#31f08b',
|
||||
'in_progress': '#3B82F6',
|
||||
@ -264,6 +296,17 @@ const badgeIconColor = computed(() => {
|
||||
})
|
||||
|
||||
const badgeText = computed(() => {
|
||||
// 排队状态处理
|
||||
if (props.task.status === 'pending') {
|
||||
if (props.task.unlockStatus === 'queuing') {
|
||||
const position = props.task.queuePosition
|
||||
return position ? `排队中(第${position}位)` : '排队中'
|
||||
}
|
||||
if (props.task.unlockStatus === 'unlocked') {
|
||||
return '抢单中'
|
||||
}
|
||||
}
|
||||
|
||||
const textMap: Record<string, string> = {
|
||||
'pending': '待接单',
|
||||
'in_progress': '进行中',
|
||||
@ -645,6 +688,16 @@ const getSubTaskDeadline = (subTask: TaskItem) => {
|
||||
color: #31f08b;
|
||||
}
|
||||
|
||||
&.badge-queuing {
|
||||
background: rgba(250, 140, 22, 0.1);
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
&.badge-grabbing {
|
||||
background: rgba(82, 196, 26, 0.1);
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.badge-progress {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3B82F6;
|
||||
|
||||
184
frontend/src/components/TaskStatusBadge.vue
Normal file
184
frontend/src/components/TaskStatusBadge.vue
Normal file
@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="task-status-badge" :class="statusClass">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
<span v-if="showQueuePosition && queuePosition" class="queue-position">
|
||||
(第{{ queuePosition }}位)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status: string
|
||||
unlockStatus?: 'queuing' | 'unlocked' | null
|
||||
queuePosition?: number
|
||||
releaseType?: string
|
||||
showQueuePosition?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
unlockStatus: null,
|
||||
queuePosition: 0,
|
||||
releaseType: '',
|
||||
showQueuePosition: true
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
// 指定任务直接显示原状态
|
||||
if (props.releaseType === 'assigned') {
|
||||
return `status-${props.status}`
|
||||
}
|
||||
|
||||
if (props.status === 'pending') {
|
||||
// pending 状态需要根据解锁状态区分
|
||||
return props.unlockStatus === 'queuing' ? 'status-queuing' : 'status-grabbing'
|
||||
}
|
||||
return `status-${props.status}`
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
// 指定任务显示"待接单"
|
||||
if (props.releaseType === 'assigned' && props.status === 'pending') {
|
||||
return '待接单'
|
||||
}
|
||||
|
||||
if (props.status === 'pending') {
|
||||
return props.unlockStatus === 'queuing' ? '排队中' : '抢单中'
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'locked': '已锁定',
|
||||
'pending_approval': '审核中',
|
||||
'in_progress': '进行中',
|
||||
'review': '待审核',
|
||||
'completed': '已完成',
|
||||
'rejected': '已驳回',
|
||||
'cancelled': '已取消'
|
||||
}
|
||||
return statusMap[props.status] || props.status
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 排队中 - 橙色 */
|
||||
.status-queuing {
|
||||
background: rgba(250, 140, 22, 0.1);
|
||||
color: #fa8c16;
|
||||
}
|
||||
.status-queuing .status-dot {
|
||||
background: #fa8c16;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* 抢单中 - 绿色 */
|
||||
.status-grabbing {
|
||||
background: rgba(82, 196, 26, 0.1);
|
||||
color: #52c41a;
|
||||
}
|
||||
.status-grabbing .status-dot {
|
||||
background: #52c41a;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* 待接单(指定任务) - 蓝色 */
|
||||
.status-pending {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
}
|
||||
.status-pending .status-dot {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
/* 进行中 - 蓝色 */
|
||||
.status-in_progress {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
}
|
||||
.status-in_progress .status-dot {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
/* 待审核 - 橙色 */
|
||||
.status-review {
|
||||
background: rgba(250, 140, 22, 0.1);
|
||||
color: #fa8c16;
|
||||
}
|
||||
.status-review .status-dot {
|
||||
background: #fa8c16;
|
||||
}
|
||||
|
||||
/* 已完成 - 灰色 */
|
||||
.status-completed {
|
||||
background: rgba(140, 140, 140, 0.1);
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.status-completed .status-dot {
|
||||
background: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 已驳回 - 红色 */
|
||||
.status-rejected {
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.status-rejected .status-dot {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
/* 已锁定 - 灰色 */
|
||||
.status-locked {
|
||||
background: rgba(140, 140, 140, 0.1);
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.status-locked .status-dot {
|
||||
background: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 审核中 */
|
||||
.status-pending_approval {
|
||||
background: rgba(250, 140, 22, 0.1);
|
||||
color: #fa8c16;
|
||||
}
|
||||
.status-pending_approval .status-dot {
|
||||
background: #fa8c16;
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-position {
|
||||
margin-left: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@ -99,13 +99,17 @@ service.interceptors.response.use(
|
||||
message.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
message.error('服务器内部错误')
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
message.warning('系统升级中,请稍后再试')
|
||||
break
|
||||
default:
|
||||
message.error(error.response.data.message || '请求失败')
|
||||
message.error(error.response.data?.message || '请求失败')
|
||||
}
|
||||
} else {
|
||||
message.error('网络错误,请检查您的网络连接')
|
||||
// 网络错误或后端无响应
|
||||
message.warning('系统升级中,请稍后再试')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
|
||||
@ -1601,6 +1601,108 @@
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 任务排队配置 -->
|
||||
<a-tab-pane key="task-unlock" tab="任务排队配置">
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">任务排队解锁配置</h2>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
message="功能说明"
|
||||
description="启用任务排队机制后,新发布的任务会进入排队池,系统每天凌晨1点自动解锁一批任务。确保发布时间早的任务优先被处理,避免任务积压。指定任务和流水线任务不受此限制。"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 24px;"
|
||||
/>
|
||||
|
||||
<a-form :model="taskUnlockConfig" layout="vertical" class="task-unlock-form" style="max-width: 600px;">
|
||||
<a-form-item label="启用任务排队机制">
|
||||
<a-switch
|
||||
v-model:checked="taskUnlockConfig.enabled"
|
||||
checked-children="已启用"
|
||||
un-checked-children="未启用"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888;">
|
||||
启用后,招募类任务将进入排队池等待解锁,设计师只能看到已解锁的任务
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="每日最少解锁数">
|
||||
<a-input-number
|
||||
v-model:value="taskUnlockConfig.dailyMin"
|
||||
:min="1"
|
||||
:max="100"
|
||||
size="large"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888;">
|
||||
每个接单部门每天凌晨自动解锁的最少任务数量(按发布时间从早到晚解锁)
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="触发补充解锁数">
|
||||
<a-input-number
|
||||
v-model:value="taskUnlockConfig.batchSize"
|
||||
:min="1"
|
||||
:max="50"
|
||||
size="large"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888;">
|
||||
当设计师接单后,如果剩余可接任务少于此数量,将自动补充解锁新任务
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" size="large" @click="handleSaveTaskUnlockConfig" :loading="savingTaskUnlockConfig">
|
||||
<template #icon><Icon name="check" size="sm" /></template>
|
||||
保存配置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 解锁状态统计 -->
|
||||
<a-divider orientation="left">排队状态统计</a-divider>
|
||||
|
||||
<a-button
|
||||
@click="loadTaskUnlockStats"
|
||||
:loading="loadingTaskUnlockStats"
|
||||
style="margin-bottom: 16px; background: transparent; border-color: #31f08b; color: #31f08b;"
|
||||
>
|
||||
<template #icon><Icon name="refresh" size="sm" color="#31f08b" /></template>
|
||||
刷新统计
|
||||
</a-button>
|
||||
|
||||
<a-row :gutter="16" v-if="taskUnlockStats">
|
||||
<a-col :span="8">
|
||||
<a-statistic title="排队中任务" :value="taskUnlockStats.totalQueued" style="color: #fa8c16;">
|
||||
<template #prefix><Icon name="clock" size="sm" color="#fa8c16" /></template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-statistic title="已解锁任务" :value="taskUnlockStats.totalUnlocked" style="color: #52c41a;">
|
||||
<template #prefix><Icon name="unlock" size="sm" color="#52c41a" /></template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
v-if="taskUnlockStats && taskUnlockStats.deptStats"
|
||||
:columns="taskUnlockStatsColumns"
|
||||
:data-source="taskUnlockStats.deptStats"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
style="margin-top: 16px;"
|
||||
row-key="deptId"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 任务数据管理 -->
|
||||
<a-tab-pane key="task-management" tab="任务数据管理">
|
||||
<div class="settings-section">
|
||||
@ -4947,6 +5049,83 @@ const handleTestExternalAssetApi = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 任务排队解锁配置 ====================
|
||||
const taskUnlockConfig = reactive({
|
||||
enabled: false,
|
||||
dailyMin: 5,
|
||||
batchSize: 5
|
||||
})
|
||||
const savingTaskUnlockConfig = ref(false)
|
||||
const loadingTaskUnlockStats = ref(false)
|
||||
const taskUnlockStats = ref<any>(null)
|
||||
|
||||
// 任务排队统计表格列
|
||||
const taskUnlockStatsColumns = [
|
||||
{ title: '部门', dataIndex: 'deptName', key: 'deptName' },
|
||||
{ title: '排队中', dataIndex: 'queuedCount', key: 'queuedCount' },
|
||||
{ title: '已解锁待接单', dataIndex: 'unlockedPendingCount', key: 'unlockedPendingCount' }
|
||||
]
|
||||
|
||||
// 加载任务排队配置
|
||||
const loadTaskUnlockConfig = async () => {
|
||||
try {
|
||||
const response = await getSystemConfigsByGroup('task')
|
||||
const configs = response.data || []
|
||||
|
||||
const enabledConfig = configs.find((c: any) => c.configKey === 'task_unlock_enabled')
|
||||
const dailyMinConfig = configs.find((c: any) => c.configKey === 'task_unlock_daily_min')
|
||||
const batchSizeConfig = configs.find((c: any) => c.configKey === 'task_unlock_batch_size')
|
||||
|
||||
taskUnlockConfig.enabled = enabledConfig?.configValue === 'true'
|
||||
taskUnlockConfig.dailyMin = parseInt(dailyMinConfig?.configValue || '5')
|
||||
taskUnlockConfig.batchSize = parseInt(batchSizeConfig?.configValue || '5')
|
||||
} catch (error) {
|
||||
console.error('加载任务排队配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存任务排队配置
|
||||
const handleSaveTaskUnlockConfig = async () => {
|
||||
savingTaskUnlockConfig.value = true
|
||||
try {
|
||||
await batchUpdateSystemConfigs({
|
||||
'task_unlock_enabled': taskUnlockConfig.enabled.toString(),
|
||||
'task_unlock_daily_min': taskUnlockConfig.dailyMin.toString(),
|
||||
'task_unlock_batch_size': taskUnlockConfig.batchSize.toString()
|
||||
})
|
||||
message.success('任务排队配置保存成功')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '保存失败')
|
||||
} finally {
|
||||
savingTaskUnlockConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载任务排队统计
|
||||
const loadTaskUnlockStats = async () => {
|
||||
loadingTaskUnlockStats.value = true
|
||||
try {
|
||||
const response = await fetch('/api/admin/tasks/unlock/stats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 200) {
|
||||
taskUnlockStats.value = result.data
|
||||
} else {
|
||||
message.error(result.message || '加载统计失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载任务排队统计失败:', error)
|
||||
message.error('加载统计失败')
|
||||
} finally {
|
||||
loadingTaskUnlockStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载延误扣分配置
|
||||
const loadDelayPenaltyConfig = async () => {
|
||||
try {
|
||||
@ -5336,6 +5515,8 @@ onMounted(() => {
|
||||
loadDelayPenaltyConfig()
|
||||
// 加载第三方素材API配置
|
||||
loadExternalAssetApiConfig()
|
||||
// 加载任务排队配置
|
||||
loadTaskUnlockConfig()
|
||||
// 加载设计师目标积分配置
|
||||
loadTargetScoreDepartments()
|
||||
loadDesignerTargetList()
|
||||
@ -5694,6 +5875,10 @@ watch(activeTab, (value) => {
|
||||
if (value === 'third-party-db') {
|
||||
loadThirdPartyDbConfigs()
|
||||
}
|
||||
// 切换到任务排队配置 tab 时,加载统计数据
|
||||
if (value === 'task-unlock') {
|
||||
loadTaskUnlockStats()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -872,16 +872,16 @@
|
||||
title="复制任务编号"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)">
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)" style="cursor: help;">
|
||||
<Icon name="calendar" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">发布: {{ formatTime(task.createTime) }}</span>
|
||||
</div>
|
||||
<div class="footer-item" v-if="task.estimatedHours" :title="'时效要求: ' + formatEstimatedTime(task.estimatedHours)">
|
||||
<div class="footer-item" v-if="task.estimatedHours" :title="'时效要求: ' + formatEstimatedTime(task.estimatedHours)" style="cursor: help;">
|
||||
<Icon name="clock" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">时效要求: {{ formatEstimatedTime(task.estimatedHours) }}</span>
|
||||
<span style="color: #ffffff;">时效: {{ formatEstimatedTime(task.estimatedHours) }}</span>
|
||||
</div>
|
||||
<!-- 驳回时间 -->
|
||||
<div class="footer-item" v-if="task.approvalStatus?.includes('rejected') && task.updateTime" :title="formatFullDateTime(task.updateTime)">
|
||||
<div class="footer-item" v-if="task.approvalStatus?.includes('rejected') && task.updateTime" :title="formatFullDateTime(task.updateTime)" style="cursor: help;">
|
||||
<Icon name="x-circle" size="sm" color="#f5222d" />
|
||||
<span style="color: #f5222d;">驳回: {{ formatTime(task.updateTime) }}</span>
|
||||
</div>
|
||||
@ -1215,11 +1215,11 @@
|
||||
title="复制任务编号"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)">
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)" style="cursor: help;">
|
||||
<Icon name="calendar" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">发布: {{ formatDate(task.createTime) }}</span>
|
||||
</div>
|
||||
<div v-if="task.completeTime && task.status === 'completed'" class="footer-item" :title="formatFullDateTime(task.completeTime)">
|
||||
<div v-if="task.completeTime && task.status === 'completed'" class="footer-item" :title="formatFullDateTime(task.completeTime)" style="cursor: help;">
|
||||
<Icon name="check" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">完成: {{ formatDate(task.completeTime) }}</span>
|
||||
</div>
|
||||
@ -1551,16 +1551,16 @@
|
||||
title="复制任务编号"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)">
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)" style="cursor: help;">
|
||||
<Icon name="calendar" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">发布: {{ formatTime(task.createTime) }}</span>
|
||||
</div>
|
||||
<div class="footer-item" v-if="task.estimatedHours" :title="'时效要求: ' + formatEstimatedTime(task.estimatedHours)">
|
||||
<div class="footer-item" v-if="task.estimatedHours" :title="'时效要求: ' + formatEstimatedTime(task.estimatedHours)" style="cursor: help;">
|
||||
<Icon name="clock" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">时效要求: {{ formatEstimatedTime(task.estimatedHours) }}</span>
|
||||
<span style="color: #ffffff;">时效: {{ formatEstimatedTime(task.estimatedHours) }}</span>
|
||||
</div>
|
||||
<!-- 驳回时间 -->
|
||||
<div class="footer-item" v-if="task.approvalStatus?.includes('rejected') && task.updateTime" :title="formatFullDateTime(task.updateTime)">
|
||||
<div class="footer-item" v-if="task.approvalStatus?.includes('rejected') && task.updateTime" :title="formatFullDateTime(task.updateTime)" style="cursor: help;">
|
||||
<Icon name="x-circle" size="sm" color="#f5222d" />
|
||||
<span style="color: #f5222d;">驳回: {{ formatTime(task.updateTime) }}</span>
|
||||
</div>
|
||||
@ -1778,11 +1778,11 @@
|
||||
title="复制任务编号"
|
||||
/>
|
||||
</div>
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)">
|
||||
<div class="footer-item" :title="formatFullDateTime(task.createTime)" style="cursor: help;">
|
||||
<Icon name="calendar" size="sm" color="#31f08b" />
|
||||
<span style="color: #ffffff;">创建: {{ formatTime(task.createTime) }}</span>
|
||||
</div>
|
||||
<div class="footer-item" v-if="task.updateTime && task.updateTime !== task.createTime" :title="formatFullDateTime(task.updateTime)">
|
||||
<div class="footer-item" v-if="task.updateTime && task.updateTime !== task.createTime" :title="formatFullDateTime(task.updateTime)" style="cursor: help;">
|
||||
<Icon name="edit" size="sm" color="#faad14" />
|
||||
<span style="color: #faad14;">最后编辑: {{ formatTime(task.updateTime) }}</span>
|
||||
</div>
|
||||
@ -2829,7 +2829,7 @@ const filters = reactive({
|
||||
keyword: undefined as string | undefined,
|
||||
publisherName: undefined as string | undefined,
|
||||
timeRange: undefined as 'today' | 'week' | 'month' | undefined,
|
||||
sortBy: 'latest' as 'latest' | 'score' | 'deadline' | 'hot',
|
||||
sortBy: 'oldest' as 'oldest' | 'latest' | 'score' | 'deadline' | 'hot', // 🔥 默认改为最早优先
|
||||
filterDeptId: undefined as number | undefined // 🔥 按接单部门筛选
|
||||
})
|
||||
|
||||
@ -2898,6 +2898,7 @@ const reviewFilters = reactive({
|
||||
// ========== 筛选组件配置选项 ==========
|
||||
// 可接任务 - 排序选项
|
||||
const availableSortOptions = [
|
||||
{ value: 'oldest', label: '最早发布' }, // 🔥 新增:任务排队机制,最早优先
|
||||
{ value: 'latest', label: '最新发布' },
|
||||
{ value: 'score', label: '积分最高' },
|
||||
{ value: 'deadline', label: '时效最短' },
|
||||
@ -3475,7 +3476,7 @@ const handleReset = () => {
|
||||
filters.keyword = undefined
|
||||
filters.publisherName = undefined
|
||||
filters.timeRange = undefined
|
||||
filters.sortBy = 'latest'
|
||||
filters.sortBy = 'oldest' // 🔥 默认改为最早优先
|
||||
pagination.page = 1
|
||||
loadTasks(true) // 强制重新加载
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user