feat: 实现任务排队解锁机制

- 新增任务排队解锁功能,确保先发布的任务优先被处理
- 添加系统设置中的任务排队配置(启用开关、每日解锁数、补充解锁数)
- 新增 TaskUnlockStatus 实体和 Mapper
- 新增 TaskUnlockService 处理排队解锁逻辑
- 新增 TaskStatusNotificationService 发送钉钉通知
- 更新 TaskItemService 和 TaskItemController 支持排队状态
- 前端新增 TaskStatusBadge 组件显示排队/抢单状态
- 更新 TaskCard 和 TaskHall 支持排队状态展示
- 添加数据库迁移脚本 V15
This commit is contained in:
Your Name 2026-01-29 10:36:38 +08:00
parent 5a1a5fba84
commit 7bb3c953ef
16 changed files with 3177 additions and 77 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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());
// 不影响抢单流程
}
}
}
/**

View File

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

View File

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

View 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;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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) // 强制重新加载
}