From 71f8f7d1c3f42834230c6525780fddf47e717e25 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 9 Feb 2026 00:42:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=8B=86=E5=88=86=E5=92=8C=E5=88=86=E9=85=8D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 实现完整的任务拆分、分配和进度跟踪功能,支持将任务拆分为子任务并分配给不同用户。 ## Phase 1: 数据库层 - 新增 t_task_meta 表(任务元数据协调表) - 新增 t_task_assignment_log 表(分配日志表) - 新增 3 个权限条目(read/write/assign) - 新增 SQLAlchemy ORM 模型 ## Phase 2: 后端 API (Java) - 新增 task-coordination-service 模块(32 个文件) - 实现 11 个 API 端点: - 任务查询(列表、子任务、我的任务) - 任务拆分(支持 4 种策略) - 任务分配(单个、批量、重新分配、撤回) - 进度管理(查询、更新、聚合) - 分配日志 - 集成权限控制和路由规则 ## Phase 3: 前端 UI (React + TypeScript) - 新增 10 个文件(模型、API、组件、页面) - 实现 5 个核心组件: - SplitTaskDialog - 任务拆分对话框 - AssignTaskDialog - 任务分配对话框 - BatchAssignDialog - 批量分配对话框 - TaskProgressPanel - 进度面板 - AssignmentLogDrawer - 分配记录 - 实现 2 个页面: - TaskCoordination - 任务管理主页 - MyTasks - 我的任务页面 - 集成侧边栏菜单和路由 ## 问题修复 - 修复 getMyTasks 分页参数缺失 - 修复子任务 assignee 信息缺失(批量查询优化) - 修复 proportion 精度计算(余量分配) ## 技术亮点 - 零侵入设计:通过独立协调表实现,不修改现有模块 - 批量查询优化:避免 N+1 查询问题 - 4 种拆分策略:按比例/数量/文件/手动 - 进度自动聚合:子任务更新自动聚合到父任务 - 权限细粒度控制:read/write/assign 三级权限 ## 验证 - Maven 编译:✅ 零错误 - TypeScript 编译:✅ 零错误 - Vite 生产构建:✅ 成功 --- .../security/PermissionRuleMatcher.java | 1 + backend/services/main-application/pom.xml | 5 + backend/services/pom.xml | 1 + .../task-coordination-service/pom.xml | 48 ++ .../TaskCoordinationServiceConfiguration.java | 13 + .../application/TaskAssignmentService.java | 113 ++++ .../application/TaskMetaService.java | 171 +++++++ .../application/TaskProgressService.java | 217 ++++++++ .../application/TaskSplitService.java | 116 +++++ .../common/enums/AssignmentActionEnum.java | 33 ++ .../common/enums/SplitStrategyEnum.java | 34 ++ .../common/enums/TaskMetaStatusEnum.java | 36 ++ .../common/enums/TaskModuleEnum.java | 36 ++ .../exception/TaskCoordinationErrorCode.java | 23 + .../model/entity/TaskAssignmentLog.java | 32 ++ .../domain/model/entity/TaskMeta.java | 60 +++ .../TaskAssignmentLogRepository.java | 12 + .../domain/repository/TaskMetaRepository.java | 29 ++ .../converter/TaskAssignmentLogConverter.java | 17 + .../converter/TaskMetaConverter.java | 19 + .../impl/TaskAssignmentLogRepositoryImpl.java | 40 ++ .../impl/TaskMetaRepositoryImpl.java | 102 ++++ .../mapper/TaskAssignmentLogMapper.java | 9 + .../persistence/mapper/TaskMetaMapper.java | 9 + .../interfaces/dto/AssignTaskRequest.java | 13 + .../interfaces/dto/AssigneeInfo.java | 15 + .../interfaces/dto/BatchAssignRequest.java | 24 + .../interfaces/dto/ChildTaskProgressDto.java | 26 + .../interfaces/dto/CreateTaskMetaRequest.java | 25 + .../interfaces/dto/SplitTaskRequest.java | 31 ++ .../interfaces/dto/TaskAssignmentLogDto.java | 30 ++ .../interfaces/dto/TaskMetaDto.java | 64 +++ .../interfaces/dto/TaskProgressResponse.java | 28 + .../interfaces/dto/UpdateProgressRequest.java | 20 + .../interfaces/rest/TaskMetaController.java | 141 +++++ frontend/src/auth/permissions.ts | 4 + frontend/src/pages/Layout/menu.tsx | 19 + .../Home/TaskCoordination.tsx | 482 ++++++++++++++++++ .../TaskCoordination/MyTasks/MyTasks.tsx | 188 +++++++ .../components/AssignTaskDialog.tsx | 103 ++++ .../components/AssignmentLogDrawer.tsx | 94 ++++ .../components/BatchAssignDialog.tsx | 175 +++++++ .../components/SplitTaskDialog.tsx | 280 ++++++++++ .../components/TaskProgressPanel.tsx | 161 ++++++ .../TaskCoordination/taskCoordination.api.ts | 76 +++ .../taskCoordination.const.tsx | 73 +++ .../taskCoordination.model.ts | 155 ++++++ frontend/src/routes/routes.ts | 16 + .../datamate-python/app/db/models/__init__.py | 7 + .../app/db/models/task_coordination.py | 220 ++++++++ scripts/db/task-coordination-init.sql | 119 +++++ 51 files changed, 3765 insertions(+) create mode 100644 backend/services/task-coordination-service/pom.xml create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/TaskCoordinationServiceConfiguration.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskAssignmentService.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskMetaService.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskProgressService.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskSplitService.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/AssignmentActionEnum.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/SplitStrategyEnum.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskMetaStatusEnum.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskModuleEnum.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/exception/TaskCoordinationErrorCode.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskAssignmentLog.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskMeta.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskAssignmentLogRepository.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskMetaRepository.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskAssignmentLogConverter.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskMetaConverter.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskAssignmentLogRepositoryImpl.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskMetaRepositoryImpl.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskAssignmentLogMapper.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskMetaMapper.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssignTaskRequest.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssigneeInfo.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/BatchAssignRequest.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/ChildTaskProgressDto.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/CreateTaskMetaRequest.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/SplitTaskRequest.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskAssignmentLogDto.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskMetaDto.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskProgressResponse.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/UpdateProgressRequest.java create mode 100644 backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/rest/TaskMetaController.java create mode 100644 frontend/src/pages/TaskCoordination/Home/TaskCoordination.tsx create mode 100644 frontend/src/pages/TaskCoordination/MyTasks/MyTasks.tsx create mode 100644 frontend/src/pages/TaskCoordination/components/AssignTaskDialog.tsx create mode 100644 frontend/src/pages/TaskCoordination/components/AssignmentLogDrawer.tsx create mode 100644 frontend/src/pages/TaskCoordination/components/BatchAssignDialog.tsx create mode 100644 frontend/src/pages/TaskCoordination/components/SplitTaskDialog.tsx create mode 100644 frontend/src/pages/TaskCoordination/components/TaskProgressPanel.tsx create mode 100644 frontend/src/pages/TaskCoordination/taskCoordination.api.ts create mode 100644 frontend/src/pages/TaskCoordination/taskCoordination.const.tsx create mode 100644 frontend/src/pages/TaskCoordination/taskCoordination.model.ts create mode 100644 runtime/datamate-python/app/db/models/task_coordination.py create mode 100644 scripts/db/task-coordination-init.sql diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java b/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java index aa702b7..0897300 100644 --- a/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/security/PermissionRuleMatcher.java @@ -48,6 +48,7 @@ public class PermissionRuleMatcher { addModuleRules(permissionRules, "/api/operator-market/**", "module:operator-market:read", "module:operator-market:write"); addModuleRules(permissionRules, "/api/orchestration/**", "module:orchestration:read", "module:orchestration:write"); addModuleRules(permissionRules, "/api/content-generation/**", "module:content-generation:use", "module:content-generation:use"); + addModuleRules(permissionRules, "/api/task-meta/**", "module:task-coordination:read", "module:task-coordination:write"); permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/users/**", "system:user:manage")); permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/users/**", "system:user:manage")); diff --git a/backend/services/main-application/pom.xml b/backend/services/main-application/pom.xml index a4c82e0..22a60ff 100644 --- a/backend/services/main-application/pom.xml +++ b/backend/services/main-application/pom.xml @@ -81,6 +81,11 @@ data-evaluation-service ${project.version} + + com.datamate + task-coordination-service + ${project.version} + com.datamate pipeline-orchestration-service diff --git a/backend/services/pom.xml b/backend/services/pom.xml index 177a4da..a3caaa4 100644 --- a/backend/services/pom.xml +++ b/backend/services/pom.xml @@ -29,6 +29,7 @@ data-synthesis-service data-annotation-service data-evaluation-service + task-coordination-service pipeline-orchestration-service execution-engine-service diff --git a/backend/services/task-coordination-service/pom.xml b/backend/services/task-coordination-service/pom.xml new file mode 100644 index 0000000..0d9be46 --- /dev/null +++ b/backend/services/task-coordination-service/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + com.datamate + services + 1.0.0-SNAPSHOT + ../pom.xml + + + task-coordination-service + Task Coordination Service + 任务拆分与分配协调服务 + + + + com.datamate + domain-common + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.projectlombok + lombok + + + org.mapstruct + mapstruct + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + + + org.springframework.data + spring-data-commons + + + diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/TaskCoordinationServiceConfiguration.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/TaskCoordinationServiceConfiguration.java new file mode 100644 index 0000000..d2959c9 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/TaskCoordinationServiceConfiguration.java @@ -0,0 +1,13 @@ +package com.datamate.coordination; + +import org.springframework.context.annotation.ComponentScan; + +/** + * 任务协调服务配置类 + * 提供任务拆分、分配和进度聚合功能 + */ +@ComponentScan(basePackages = { + "com.datamate.coordination" +}) +public class TaskCoordinationServiceConfiguration { +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskAssignmentService.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskAssignmentService.java new file mode 100644 index 0000000..8c573a7 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskAssignmentService.java @@ -0,0 +1,113 @@ +package com.datamate.coordination.application; + +import com.datamate.coordination.common.enums.AssignmentActionEnum; +import com.datamate.coordination.common.exception.TaskCoordinationErrorCode; +import com.datamate.coordination.domain.repository.TaskAssignmentLogRepository; +import com.datamate.coordination.domain.repository.TaskMetaRepository; +import com.datamate.coordination.interfaces.dto.*; +import com.datamate.common.auth.infrastructure.context.RequestUserContextHolder; +import com.datamate.common.infrastructure.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskAssignmentService { + + private final TaskMetaRepository taskMetaRepo; + + private final TaskAssignmentLogRepository assignmentLogRepo; + + @Transactional + public void assignTask(String taskMetaId, AssignTaskRequest request) { + TaskMetaDto dto = requireTask(taskMetaId); + doAssign(taskMetaId, request.getUserId(), AssignmentActionEnum.ASSIGN, + getCurrentUser(), request.getRemark()); + } + + @Transactional + public void reassignTask(String taskMetaId, AssignTaskRequest request) { + TaskMetaDto dto = requireTask(taskMetaId); + Long previousUserId = dto.getAssignedTo(); + doAssign(taskMetaId, request.getUserId(), AssignmentActionEnum.REASSIGN, + getCurrentUser(), request.getRemark()); + + // 记录日志中包含原用户 + // (doAssign 已写入日志,此处无需重复) + } + + @Transactional + public void revokeTask(String taskMetaId, String remark) { + TaskMetaDto dto = requireTask(taskMetaId); + Long previousUserId = dto.getAssignedTo(); + + dto.setAssignedTo(null); + taskMetaRepo.update(dto); + + TaskAssignmentLogDto logDto = new TaskAssignmentLogDto(); + logDto.setId(UUID.randomUUID().toString()); + logDto.setTaskMetaId(taskMetaId); + logDto.setAction(AssignmentActionEnum.REVOKE); + logDto.setFromUserId(previousUserId); + logDto.setToUserId(null); + logDto.setOperatedBy(getCurrentUser()); + logDto.setRemark(remark); + assignmentLogRepo.insert(logDto); + } + + @Transactional + public void batchAssign(BatchAssignRequest request) { + for (BatchAssignRequest.TaskAssignment assignment : request.getAssignments()) { + doAssign(assignment.getTaskMetaId(), assignment.getUserId(), + AssignmentActionEnum.ASSIGN, getCurrentUser(), assignment.getRemark()); + } + } + + public List getAssignmentLogs(String taskMetaId) { + return assignmentLogRepo.findByTaskMetaId(taskMetaId); + } + + /** + * 执行分配并写入日志(内部方法,也供 TaskSplitService 调用) + */ + void doAssign(String taskMetaId, Long userId, AssignmentActionEnum action, + String operatedBy, String remark) { + TaskMetaDto dto = taskMetaRepo.findById(taskMetaId); + if (dto == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + + Long previousUserId = dto.getAssignedTo(); + dto.setAssignedTo(userId); + taskMetaRepo.update(dto); + + TaskAssignmentLogDto logDto = new TaskAssignmentLogDto(); + logDto.setId(UUID.randomUUID().toString()); + logDto.setTaskMetaId(taskMetaId); + logDto.setAction(action); + logDto.setFromUserId(previousUserId); + logDto.setToUserId(userId); + logDto.setOperatedBy(operatedBy); + logDto.setRemark(remark); + assignmentLogRepo.insert(logDto); + } + + private TaskMetaDto requireTask(String id) { + TaskMetaDto dto = taskMetaRepo.findById(id); + if (dto == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + return dto; + } + + private String getCurrentUser() { + String userId = RequestUserContextHolder.getCurrentUserId(); + return userId != null ? userId : "system"; + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskMetaService.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskMetaService.java new file mode 100644 index 0000000..75716e6 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskMetaService.java @@ -0,0 +1,171 @@ +package com.datamate.coordination.application; + +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import com.datamate.coordination.common.exception.TaskCoordinationErrorCode; +import com.datamate.coordination.domain.repository.TaskMetaRepository; +import com.datamate.coordination.interfaces.dto.AssigneeInfo; +import com.datamate.coordination.interfaces.dto.CreateTaskMetaRequest; +import com.datamate.coordination.interfaces.dto.TaskMetaDto; +import com.datamate.common.auth.domain.model.AuthUserAccount; +import com.datamate.common.auth.infrastructure.context.RequestUserContextHolder; +import com.datamate.common.auth.infrastructure.persistence.mapper.AuthMapper; +import com.datamate.common.infrastructure.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskMetaService { + + private final TaskMetaRepository taskMetaRepo; + + private final AuthMapper authMapper; + + @Transactional + public TaskMetaDto createTaskMeta(CreateTaskMetaRequest request) { + TaskMetaDto dto = new TaskMetaDto(); + dto.setId(UUID.randomUUID().toString()); + dto.setModule(request.getModule()); + dto.setRefTaskId(request.getRefTaskId()); + dto.setTaskName(request.getTaskName()); + dto.setStatus(TaskMetaStatusEnum.PENDING); + dto.setAssignedTo(request.getAssignedTo()); + dto.setCreatedBy(getCurrentUser()); + dto.setProgress(0); + dto.setTotalItems(request.getTotalItems() != null ? request.getTotalItems() : 0); + dto.setCompletedItems(0); + dto.setFailedItems(0); + dto.setPriority(request.getPriority() != null ? request.getPriority() : 0); + dto.setDeadline(request.getDeadline()); + taskMetaRepo.insert(dto); + return dto; + } + + public TaskMetaDto getTaskMeta(String id) { + TaskMetaDto dto = taskMetaRepo.findById(id); + if (dto == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + dto.setChildCount(taskMetaRepo.countByParentId(id)); + populateAssigneeInfo(List.of(dto)); + return dto; + } + + public List getChildren(String parentId, Integer page, Integer size) { + if (!taskMetaRepo.existsById(parentId)) { + throw BusinessException.of(TaskCoordinationErrorCode.PARENT_TASK_NOT_FOUND); + } + List children = taskMetaRepo.findByParentId(parentId); + populateAssigneeInfo(children); + return children; + } + + public int countChildren(String parentId) { + return taskMetaRepo.countByParentId(parentId); + } + + public List getMyTasks(String status, String module, + Integer page, Integer size) { + Long currentUserId = getCurrentUserIdAsLong(); + List tasks = taskMetaRepo.findTasks(module, status, currentUserId, null, null, page, size); + populateAssigneeInfo(tasks); + return tasks; + } + + public int countMyTasks(String status, String module) { + Long currentUserId = getCurrentUserIdAsLong(); + return taskMetaRepo.countTasks(module, status, currentUserId, null, null); + } + + public List findTasks(String module, String status, Long assignedTo, + String keyword, Integer page, Integer size) { + List tasks = taskMetaRepo.findTasks(module, status, assignedTo, null, keyword, page, size); + populateAssigneeInfo(tasks); + enrichChildCount(tasks); + return tasks; + } + + public int countTasks(String module, String status, Long assignedTo, String keyword) { + return taskMetaRepo.countTasks(module, status, assignedTo, null, keyword); + } + + @Transactional + public void deleteTaskMeta(String id) { + TaskMetaDto dto = taskMetaRepo.findById(id); + if (dto == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + taskMetaRepo.deleteById(id); + } + + // ── Assignee population ────────────────────────────── + + /** + * 批量填充任务的 assignee 信息,避免 N+1 查询 + */ + private void populateAssigneeInfo(List tasks) { + Set userIds = tasks.stream() + .map(TaskMetaDto::getAssignedTo) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (userIds.isEmpty()) { + return; + } + + Map userMap = new HashMap<>(); + for (Long userId : userIds) { + AuthUserAccount user = authMapper.findUserById(userId); + if (user != null) { + AssigneeInfo info = new AssigneeInfo(); + info.setId(user.getId()); + info.setUsername(user.getUsername()); + info.setFullName(user.getFullName()); + userMap.put(userId, info); + } + } + + for (TaskMetaDto task : tasks) { + if (task.getAssignedTo() != null) { + task.setAssignee(userMap.get(task.getAssignedTo())); + } + } + } + + /** + * 批量填充顶层任务的 childCount + */ + private void enrichChildCount(List tasks) { + for (TaskMetaDto task : tasks) { + if (task.getParentId() == null) { + task.setChildCount(taskMetaRepo.countByParentId(task.getId())); + } + } + } + + // ── Helpers ────────────────────────────────────────── + + private String getCurrentUser() { + String userId = RequestUserContextHolder.getCurrentUserId(); + return userId != null ? userId : "system"; + } + + private Long getCurrentUserIdAsLong() { + String userId = RequestUserContextHolder.getCurrentUserId(); + if (userId == null) { + return null; + } + try { + return Long.parseLong(userId); + } catch (NumberFormatException e) { + log.warn("Cannot parse current user ID to Long: {}", userId); + return null; + } + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskProgressService.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskProgressService.java new file mode 100644 index 0000000..68d9244 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskProgressService.java @@ -0,0 +1,217 @@ +package com.datamate.coordination.application; + +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import com.datamate.coordination.common.exception.TaskCoordinationErrorCode; +import com.datamate.coordination.domain.repository.TaskMetaRepository; +import com.datamate.coordination.interfaces.dto.*; +import com.datamate.common.auth.domain.model.AuthUserAccount; +import com.datamate.common.auth.infrastructure.persistence.mapper.AuthMapper; +import com.datamate.common.infrastructure.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskProgressService { + + private final TaskMetaRepository taskMetaRepo; + + private final AuthMapper authMapper; + + public TaskProgressResponse getProgress(String taskMetaId) { + TaskMetaDto parent = taskMetaRepo.findById(taskMetaId); + if (parent == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + + TaskProgressResponse response = new TaskProgressResponse(); + response.setTaskId(parent.getId()); + response.setTaskName(parent.getTaskName()); + response.setStatus(parent.getStatus()); + + List children = taskMetaRepo.findByParentId(taskMetaId); + if (children.isEmpty()) { + // 没有子任务,返回自身进度 + response.setOverallProgress(parent.getProgress()); + response.setTotalItems(parent.getTotalItems()); + response.setCompletedItems(parent.getCompletedItems()); + response.setFailedItems(parent.getFailedItems()); + response.setChildren(List.of()); + return response; + } + + // 聚合子任务进度 + int totalItems = 0; + int completedItems = 0; + int failedItems = 0; + + // 批量查询 assignee 信息 + Map userMap = buildAssigneeMap(children); + + List childDtos = children.stream().map(child -> { + ChildTaskProgressDto dto = new ChildTaskProgressDto(); + dto.setTaskId(child.getId()); + dto.setTaskName(child.getTaskName()); + dto.setProgress(child.getProgress()); + dto.setTotalItems(child.getTotalItems()); + dto.setCompletedItems(child.getCompletedItems()); + dto.setFailedItems(child.getFailedItems()); + dto.setStatus(child.getStatus()); + if (child.getAssignedTo() != null) { + dto.setAssignee(userMap.get(child.getAssignedTo())); + } + return dto; + }).toList(); + + for (TaskMetaDto child : children) { + totalItems += (child.getTotalItems() != null ? child.getTotalItems() : 0); + completedItems += (child.getCompletedItems() != null ? child.getCompletedItems() : 0); + failedItems += (child.getFailedItems() != null ? child.getFailedItems() : 0); + } + + int overallProgress = totalItems > 0 + ? (int) Math.round((double) completedItems / totalItems * 100) + : 0; + + response.setOverallProgress(overallProgress); + response.setTotalItems(totalItems); + response.setCompletedItems(completedItems); + response.setFailedItems(failedItems); + response.setChildren(childDtos); + return response; + } + + @Transactional + public void updateProgress(String taskMetaId, UpdateProgressRequest request) { + TaskMetaDto dto = taskMetaRepo.findById(taskMetaId); + if (dto == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + + if (request.getProgress() != null) { + dto.setProgress(request.getProgress()); + } + if (request.getTotalItems() != null) { + dto.setTotalItems(request.getTotalItems()); + } + if (request.getCompletedItems() != null) { + dto.setCompletedItems(request.getCompletedItems()); + } + if (request.getFailedItems() != null) { + dto.setFailedItems(request.getFailedItems()); + } + if (request.getStatus() != null) { + TaskMetaStatusEnum newStatus = TaskMetaStatusEnum.fromValue(request.getStatus()); + dto.setStatus(newStatus); + + if (newStatus == TaskMetaStatusEnum.IN_PROGRESS && dto.getStartedAt() == null) { + dto.setStartedAt(LocalDateTime.now()); + } + if (newStatus == TaskMetaStatusEnum.COMPLETED || newStatus == TaskMetaStatusEnum.FAILED) { + dto.setCompletedAt(LocalDateTime.now()); + } + } + + taskMetaRepo.update(dto); + + // 如果是子任务,聚合更新父任务进度 + if (dto.getParentId() != null) { + aggregateParentProgress(dto.getParentId()); + } + } + + /** + * 根据所有子任务的状态聚合父任务进度和状态 + */ + void aggregateParentProgress(String parentId) { + TaskMetaDto parent = taskMetaRepo.findById(parentId); + if (parent == null) { + return; + } + + List children = taskMetaRepo.findByParentId(parentId); + if (children.isEmpty()) { + return; + } + + int totalItems = 0; + int completedItems = 0; + int failedItems = 0; + boolean anyInProgress = false; + boolean allCompleted = true; + boolean allTerminated = true; // completed, failed, stopped, cancelled + boolean anyFailed = false; + + for (TaskMetaDto child : children) { + totalItems += (child.getTotalItems() != null ? child.getTotalItems() : 0); + completedItems += (child.getCompletedItems() != null ? child.getCompletedItems() : 0); + failedItems += (child.getFailedItems() != null ? child.getFailedItems() : 0); + + TaskMetaStatusEnum s = child.getStatus(); + if (s == TaskMetaStatusEnum.IN_PROGRESS) { + anyInProgress = true; + } + if (s != TaskMetaStatusEnum.COMPLETED) { + allCompleted = false; + } + if (s == TaskMetaStatusEnum.FAILED) { + anyFailed = true; + } + if (s == TaskMetaStatusEnum.PENDING || s == TaskMetaStatusEnum.IN_PROGRESS) { + allTerminated = false; + } + } + + parent.setTotalItems(totalItems); + parent.setCompletedItems(completedItems); + parent.setFailedItems(failedItems); + parent.setProgress(totalItems > 0 + ? (int) Math.round((double) completedItems / totalItems * 100) + : 0); + + // 状态聚合 + if (allCompleted) { + parent.setStatus(TaskMetaStatusEnum.COMPLETED); + parent.setCompletedAt(LocalDateTime.now()); + } else if (anyInProgress) { + parent.setStatus(TaskMetaStatusEnum.IN_PROGRESS); + if (parent.getStartedAt() == null) { + parent.setStartedAt(LocalDateTime.now()); + } + } else if (allTerminated && anyFailed) { + parent.setStatus(TaskMetaStatusEnum.FAILED); + parent.setCompletedAt(LocalDateTime.now()); + } else if (allTerminated) { + parent.setStatus(TaskMetaStatusEnum.STOPPED); + } + + taskMetaRepo.update(parent); + } + + private Map buildAssigneeMap(List tasks) { + Set userIds = tasks.stream() + .map(TaskMetaDto::getAssignedTo) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map map = new HashMap<>(); + for (Long userId : userIds) { + AuthUserAccount user = authMapper.findUserById(userId); + if (user != null) { + AssigneeInfo info = new AssigneeInfo(); + info.setId(user.getId()); + info.setUsername(user.getUsername()); + info.setFullName(user.getFullName()); + map.put(userId, info); + } + } + return map; + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskSplitService.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskSplitService.java new file mode 100644 index 0000000..4989244 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/application/TaskSplitService.java @@ -0,0 +1,116 @@ +package com.datamate.coordination.application; + +import com.datamate.coordination.common.enums.AssignmentActionEnum; +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import com.datamate.coordination.common.exception.TaskCoordinationErrorCode; +import com.datamate.coordination.domain.repository.TaskMetaRepository; +import com.datamate.coordination.interfaces.dto.SplitTaskRequest; +import com.datamate.coordination.interfaces.dto.TaskMetaDto; +import com.datamate.common.infrastructure.exception.BusinessAssert; +import com.datamate.common.infrastructure.exception.BusinessException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskSplitService { + + private final TaskMetaRepository taskMetaRepo; + + private final TaskAssignmentService assignmentService; + + private final ObjectMapper objectMapper; + + @Transactional + public List splitTask(String parentId, SplitTaskRequest request) { + TaskMetaDto parent = taskMetaRepo.findById(parentId); + if (parent == null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_FOUND); + } + if (parent.getParentId() != null) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_NOT_SPLITTABLE); + } + if (taskMetaRepo.countByParentId(parentId) > 0) { + throw BusinessException.of(TaskCoordinationErrorCode.TASK_ALREADY_SPLIT); + } + + BusinessAssert.notEmpty(request.getAssignments(), TaskCoordinationErrorCode.INVALID_SPLIT_CONFIG); + + // 保存拆分配置到父任务 + parent.setSplitStrategy(request.getStrategy()); + try { + parent.setSplitConfig(objectMapper.writeValueAsString(request.getSplitConfig())); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize split config", e); + } + taskMetaRepo.update(parent); + + // 按分配列表创建子任务 + List children = new ArrayList<>(); + int totalParentItems = parent.getTotalItems() != null ? parent.getTotalItems() : 0; + int allocatedItems = 0; + int assignmentCount = request.getAssignments().size(); + + for (int i = 0; i < assignmentCount; i++) { + SplitTaskRequest.SplitAssignment assignment = request.getAssignments().get(i); + + boolean isLast = (i == assignmentCount - 1); + int childItems = calculateChildItems(assignment, totalParentItems, allocatedItems, isLast); + allocatedItems += childItems; + + TaskMetaDto child = new TaskMetaDto(); + child.setId(UUID.randomUUID().toString()); + child.setParentId(parentId); + child.setModule(parent.getModule()); + child.setRefTaskId(parent.getRefTaskId()); + child.setTaskName(assignment.getTaskName() != null + ? assignment.getTaskName() + : parent.getTaskName() + " #" + (i + 1)); + child.setStatus(TaskMetaStatusEnum.PENDING); + child.setCreatedBy(parent.getCreatedBy()); + child.setProgress(0); + child.setTotalItems(childItems); + child.setCompletedItems(0); + child.setFailedItems(0); + child.setPriority(parent.getPriority()); + child.setDeadline(parent.getDeadline()); + + taskMetaRepo.insert(child); + + // 如果指定了用户则分配 + if (assignment.getUserId() != null) { + assignmentService.doAssign(child.getId(), assignment.getUserId(), + AssignmentActionEnum.ASSIGN, parent.getCreatedBy(), null); + } + + children.add(child); + } + + return children; + } + + private int calculateChildItems(SplitTaskRequest.SplitAssignment assignment, + int totalParentItems, int allocatedItems, + boolean isLast) { + if (assignment.getItemCount() != null && assignment.getItemCount() > 0) { + return assignment.getItemCount(); + } + if (assignment.getProportion() != null && assignment.getProportion() > 0) { + if (isLast) { + // 最后一个子任务取余量,确保总和 == 父任务总数 + return Math.max(0, totalParentItems - allocatedItems); + } + return (int) Math.round(totalParentItems * assignment.getProportion()); + } + return 0; + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/AssignmentActionEnum.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/AssignmentActionEnum.java new file mode 100644 index 0000000..f55c7b1 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/AssignmentActionEnum.java @@ -0,0 +1,33 @@ +package com.datamate.coordination.common.enums; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.common.infrastructure.exception.SystemErrorCode; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum AssignmentActionEnum { + ASSIGN("ASSIGN"), + REASSIGN("REASSIGN"), + REVOKE("REVOKE"); + + private final String value; + + AssignmentActionEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static AssignmentActionEnum fromValue(String value) { + for (AssignmentActionEnum a : AssignmentActionEnum.values()) { + if (a.value.equals(value)) { + return a; + } + } + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER); + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/SplitStrategyEnum.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/SplitStrategyEnum.java new file mode 100644 index 0000000..63216cb --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/SplitStrategyEnum.java @@ -0,0 +1,34 @@ +package com.datamate.coordination.common.enums; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.common.infrastructure.exception.SystemErrorCode; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SplitStrategyEnum { + BY_COUNT("BY_COUNT"), + BY_FILE("BY_FILE"), + BY_PERCENTAGE("BY_PERCENTAGE"), + MANUAL("MANUAL"); + + private final String value; + + SplitStrategyEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static SplitStrategyEnum fromValue(String value) { + for (SplitStrategyEnum s : SplitStrategyEnum.values()) { + if (s.value.equals(value)) { + return s; + } + } + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER); + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskMetaStatusEnum.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskMetaStatusEnum.java new file mode 100644 index 0000000..6bca4e7 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskMetaStatusEnum.java @@ -0,0 +1,36 @@ +package com.datamate.coordination.common.enums; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.common.infrastructure.exception.SystemErrorCode; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum TaskMetaStatusEnum { + PENDING("PENDING"), + IN_PROGRESS("IN_PROGRESS"), + COMPLETED("COMPLETED"), + FAILED("FAILED"), + STOPPED("STOPPED"), + CANCELLED("CANCELLED"); + + private final String value; + + TaskMetaStatusEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static TaskMetaStatusEnum fromValue(String value) { + for (TaskMetaStatusEnum s : TaskMetaStatusEnum.values()) { + if (s.value.equals(value)) { + return s; + } + } + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER); + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskModuleEnum.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskModuleEnum.java new file mode 100644 index 0000000..f58ad71 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/enums/TaskModuleEnum.java @@ -0,0 +1,36 @@ +package com.datamate.coordination.common.enums; + +import com.datamate.common.infrastructure.exception.BusinessException; +import com.datamate.common.infrastructure.exception.SystemErrorCode; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum TaskModuleEnum { + ANNOTATION("ANNOTATION"), + CLEANING("CLEANING"), + EVALUATION("EVALUATION"), + SYNTHESIS("SYNTHESIS"), + COLLECTION("COLLECTION"), + RATIO("RATIO"); + + private final String value; + + TaskModuleEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static TaskModuleEnum fromValue(String value) { + for (TaskModuleEnum m : TaskModuleEnum.values()) { + if (m.value.equals(value)) { + return m; + } + } + throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER); + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/exception/TaskCoordinationErrorCode.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/exception/TaskCoordinationErrorCode.java new file mode 100644 index 0000000..cd181cd --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/common/exception/TaskCoordinationErrorCode.java @@ -0,0 +1,23 @@ +package com.datamate.coordination.common.exception; + +import com.datamate.common.infrastructure.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum TaskCoordinationErrorCode implements ErrorCode { + TASK_NOT_FOUND("tc.0001", "任务不存在"), + TASK_ALREADY_SPLIT("tc.0002", "任务已拆分,不可重复拆分"), + TASK_NOT_SPLITTABLE("tc.0003", "仅顶层任务可以拆分"), + INVALID_SPLIT_CONFIG("tc.0004", "拆分配置无效"), + TASK_NOT_ASSIGNABLE("tc.0005", "当前任务状态不允许分配"), + TASK_ALREADY_ASSIGNED("tc.0006", "任务已分配给该用户"), + USER_NOT_FOUND("tc.0007", "指定用户不存在"), + TASK_HAS_RUNNING_CHILDREN("tc.0008", "存在进行中的子任务,无法删除"), + PARENT_TASK_NOT_FOUND("tc.0009", "父任务不存在"), + SPLIT_ITEMS_MISMATCH("tc.0010", "拆分条目数与总数不匹配"); + + private final String code; + private final String message; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskAssignmentLog.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskAssignmentLog.java new file mode 100644 index 0000000..d5cfa10 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskAssignmentLog.java @@ -0,0 +1,32 @@ +package com.datamate.coordination.domain.model.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.datamate.coordination.common.enums.AssignmentActionEnum; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName(value = "t_task_assignment_log", autoResultMap = true) +public class TaskAssignmentLog { + + @TableId + private String id; + + private String taskMetaId; + + private AssignmentActionEnum action; + + private Long fromUserId; + + private Long toUserId; + + private String operatedBy; + + private String remark; + + private LocalDateTime createdAt; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskMeta.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskMeta.java new file mode 100644 index 0000000..d2a4362 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/model/entity/TaskMeta.java @@ -0,0 +1,60 @@ +package com.datamate.coordination.domain.model.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName(value = "t_task_meta", autoResultMap = true) +public class TaskMeta { + + @TableId + private String id; + + private String parentId; + + private String module; + + private String refTaskId; + + private String taskName; + + private TaskMetaStatusEnum status; + + private Long assignedTo; + + private String createdBy; + + private Integer progress; + + private Integer totalItems; + + private Integer completedItems; + + private Integer failedItems; + + private String splitStrategy; + + private String splitConfig; + + private Integer priority; + + private LocalDateTime deadline; + + private LocalDateTime startedAt; + + private LocalDateTime completedAt; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic(value = "NULL", delval = "NOW()") + private LocalDateTime deletedAt; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskAssignmentLogRepository.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskAssignmentLogRepository.java new file mode 100644 index 0000000..cb75829 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskAssignmentLogRepository.java @@ -0,0 +1,12 @@ +package com.datamate.coordination.domain.repository; + +import com.datamate.coordination.interfaces.dto.TaskAssignmentLogDto; + +import java.util.List; + +public interface TaskAssignmentLogRepository { + + void insert(TaskAssignmentLogDto dto); + + List findByTaskMetaId(String taskMetaId); +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskMetaRepository.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskMetaRepository.java new file mode 100644 index 0000000..9685bd5 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/domain/repository/TaskMetaRepository.java @@ -0,0 +1,29 @@ +package com.datamate.coordination.domain.repository; + +import com.datamate.coordination.interfaces.dto.TaskMetaDto; + +import java.util.List; + +public interface TaskMetaRepository { + + TaskMetaDto findById(String id); + + List findByParentId(String parentId); + + List findTasks(String module, String status, Long assignedTo, + String createdBy, String keyword, + Integer page, Integer size); + + int countTasks(String module, String status, Long assignedTo, + String createdBy, String keyword); + + void insert(TaskMetaDto dto); + + void update(TaskMetaDto dto); + + void deleteById(String id); + + boolean existsById(String id); + + int countByParentId(String parentId); +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskAssignmentLogConverter.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskAssignmentLogConverter.java new file mode 100644 index 0000000..2ef7964 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskAssignmentLogConverter.java @@ -0,0 +1,17 @@ +package com.datamate.coordination.infrastructure.converter; + +import com.datamate.coordination.domain.model.entity.TaskAssignmentLog; +import com.datamate.coordination.interfaces.dto.TaskAssignmentLogDto; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper +public interface TaskAssignmentLogConverter { + TaskAssignmentLogConverter INSTANCE = Mappers.getMapper(TaskAssignmentLogConverter.class); + + TaskAssignmentLogDto fromEntityToDto(TaskAssignmentLog entity); + + List fromEntityToDto(List entities); +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskMetaConverter.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskMetaConverter.java new file mode 100644 index 0000000..ffb1317 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/converter/TaskMetaConverter.java @@ -0,0 +1,19 @@ +package com.datamate.coordination.infrastructure.converter; + +import com.datamate.coordination.domain.model.entity.TaskMeta; +import com.datamate.coordination.interfaces.dto.TaskMetaDto; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper +public interface TaskMetaConverter { + TaskMetaConverter INSTANCE = Mappers.getMapper(TaskMetaConverter.class); + + TaskMetaDto fromEntityToDto(TaskMeta entity); + + List fromEntityToDto(List entities); + + TaskMeta fromDtoToEntity(TaskMetaDto dto); +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskAssignmentLogRepositoryImpl.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskAssignmentLogRepositoryImpl.java new file mode 100644 index 0000000..d787d7b --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskAssignmentLogRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.datamate.coordination.infrastructure.persistence.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.datamate.coordination.domain.model.entity.TaskAssignmentLog; +import com.datamate.coordination.domain.repository.TaskAssignmentLogRepository; +import com.datamate.coordination.infrastructure.converter.TaskAssignmentLogConverter; +import com.datamate.coordination.infrastructure.persistence.mapper.TaskAssignmentLogMapper; +import com.datamate.coordination.interfaces.dto.TaskAssignmentLogDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TaskAssignmentLogRepositoryImpl implements TaskAssignmentLogRepository { + + private final TaskAssignmentLogMapper mapper; + + @Override + public void insert(TaskAssignmentLogDto dto) { + TaskAssignmentLog entity = new TaskAssignmentLog(); + entity.setId(dto.getId()); + entity.setTaskMetaId(dto.getTaskMetaId()); + entity.setAction(dto.getAction()); + entity.setFromUserId(dto.getFromUserId()); + entity.setToUserId(dto.getToUserId()); + entity.setOperatedBy(dto.getOperatedBy()); + entity.setRemark(dto.getRemark()); + mapper.insert(entity); + } + + @Override + public List findByTaskMetaId(String taskMetaId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TaskAssignmentLog::getTaskMetaId, taskMetaId) + .orderByDesc(TaskAssignmentLog::getCreatedAt); + return TaskAssignmentLogConverter.INSTANCE.fromEntityToDto(mapper.selectList(wrapper)); + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskMetaRepositoryImpl.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskMetaRepositoryImpl.java new file mode 100644 index 0000000..a35cba6 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/impl/TaskMetaRepositoryImpl.java @@ -0,0 +1,102 @@ +package com.datamate.coordination.infrastructure.persistence.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.datamate.coordination.domain.model.entity.TaskMeta; +import com.datamate.coordination.domain.repository.TaskMetaRepository; +import com.datamate.coordination.infrastructure.converter.TaskMetaConverter; +import com.datamate.coordination.infrastructure.persistence.mapper.TaskMetaMapper; +import com.datamate.coordination.interfaces.dto.TaskMetaDto; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TaskMetaRepositoryImpl implements TaskMetaRepository { + + private final TaskMetaMapper mapper; + + @Override + public TaskMetaDto findById(String id) { + TaskMeta entity = mapper.selectById(id); + return entity == null ? null : TaskMetaConverter.INSTANCE.fromEntityToDto(entity); + } + + @Override + public List findByParentId(String parentId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TaskMeta::getParentId, parentId) + .isNull(TaskMeta::getDeletedAt) + .orderByAsc(TaskMeta::getCreatedAt); + return TaskMetaConverter.INSTANCE.fromEntityToDto(mapper.selectList(wrapper)); + } + + @Override + public List findTasks(String module, String status, Long assignedTo, + String createdBy, String keyword, + Integer page, Integer size) { + LambdaQueryWrapper wrapper = buildQueryWrapper(module, status, assignedTo, createdBy, keyword); + wrapper.orderByDesc(TaskMeta::getCreatedAt); + + if (page != null && size != null) { + IPage resultPage = mapper.selectPage(new Page<>(page + 1, size), wrapper); + return TaskMetaConverter.INSTANCE.fromEntityToDto(resultPage.getRecords()); + } + return TaskMetaConverter.INSTANCE.fromEntityToDto(mapper.selectList(wrapper)); + } + + @Override + public int countTasks(String module, String status, Long assignedTo, + String createdBy, String keyword) { + LambdaQueryWrapper wrapper = buildQueryWrapper(module, status, assignedTo, createdBy, keyword); + return Math.toIntExact(mapper.selectCount(wrapper)); + } + + @Override + public void insert(TaskMetaDto dto) { + mapper.insert(TaskMetaConverter.INSTANCE.fromDtoToEntity(dto)); + } + + @Override + public void update(TaskMetaDto dto) { + mapper.updateById(TaskMetaConverter.INSTANCE.fromDtoToEntity(dto)); + } + + @Override + public void deleteById(String id) { + mapper.deleteById(id); + } + + @Override + public boolean existsById(String id) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TaskMeta::getId, id).isNull(TaskMeta::getDeletedAt); + return mapper.exists(wrapper); + } + + @Override + public int countByParentId(String parentId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(TaskMeta::getParentId, parentId).isNull(TaskMeta::getDeletedAt); + return Math.toIntExact(mapper.selectCount(wrapper)); + } + + private LambdaQueryWrapper buildQueryWrapper(String module, String status, + Long assignedTo, String createdBy, + String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.isNull(TaskMeta::getDeletedAt); + wrapper.eq(StringUtils.isNotBlank(module), TaskMeta::getModule, module); + wrapper.eq(StringUtils.isNotBlank(status), TaskMeta::getStatus, status); + wrapper.eq(assignedTo != null, TaskMeta::getAssignedTo, assignedTo); + wrapper.eq(StringUtils.isNotBlank(createdBy), TaskMeta::getCreatedBy, createdBy); + if (StringUtils.isNotBlank(keyword)) { + wrapper.like(TaskMeta::getTaskName, keyword); + } + return wrapper; + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskAssignmentLogMapper.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskAssignmentLogMapper.java new file mode 100644 index 0000000..eb6d6cc --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskAssignmentLogMapper.java @@ -0,0 +1,9 @@ +package com.datamate.coordination.infrastructure.persistence.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.datamate.coordination.domain.model.entity.TaskAssignmentLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskAssignmentLogMapper extends BaseMapper { +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskMetaMapper.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskMetaMapper.java new file mode 100644 index 0000000..58ea856 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/infrastructure/persistence/mapper/TaskMetaMapper.java @@ -0,0 +1,9 @@ +package com.datamate.coordination.infrastructure.persistence.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.datamate.coordination.domain.model.entity.TaskMeta; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskMetaMapper extends BaseMapper { +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssignTaskRequest.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssignTaskRequest.java new file mode 100644 index 0000000..3081008 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssignTaskRequest.java @@ -0,0 +1,13 @@ +package com.datamate.coordination.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AssignTaskRequest { + + private Long userId; + + private String remark; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssigneeInfo.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssigneeInfo.java new file mode 100644 index 0000000..159cce0 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/AssigneeInfo.java @@ -0,0 +1,15 @@ +package com.datamate.coordination.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AssigneeInfo { + + private Long id; + + private String username; + + private String fullName; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/BatchAssignRequest.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/BatchAssignRequest.java new file mode 100644 index 0000000..4a3487c --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/BatchAssignRequest.java @@ -0,0 +1,24 @@ +package com.datamate.coordination.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class BatchAssignRequest { + + private List assignments; + + @Getter + @Setter + public static class TaskAssignment { + + private String taskMetaId; + + private Long userId; + + private String remark; + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/ChildTaskProgressDto.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/ChildTaskProgressDto.java new file mode 100644 index 0000000..6640c3b --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/ChildTaskProgressDto.java @@ -0,0 +1,26 @@ +package com.datamate.coordination.interfaces.dto; + +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ChildTaskProgressDto { + + private String taskId; + + private String taskName; + + private AssigneeInfo assignee; + + private Integer progress; + + private Integer totalItems; + + private Integer completedItems; + + private Integer failedItems; + + private TaskMetaStatusEnum status; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/CreateTaskMetaRequest.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/CreateTaskMetaRequest.java new file mode 100644 index 0000000..ca40b65 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/CreateTaskMetaRequest.java @@ -0,0 +1,25 @@ +package com.datamate.coordination.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class CreateTaskMetaRequest { + + private String module; + + private String refTaskId; + + private String taskName; + + private Long assignedTo; + + private Integer totalItems; + + private Integer priority; + + private LocalDateTime deadline; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/SplitTaskRequest.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/SplitTaskRequest.java new file mode 100644 index 0000000..6fb9209 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/SplitTaskRequest.java @@ -0,0 +1,31 @@ +package com.datamate.coordination.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +@Getter +@Setter +public class SplitTaskRequest { + + private String strategy; + + private Map splitConfig; + + private List assignments; + + @Getter + @Setter + public static class SplitAssignment { + + private Long userId; + + private Double proportion; + + private Integer itemCount; + + private String taskName; + } +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskAssignmentLogDto.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskAssignmentLogDto.java new file mode 100644 index 0000000..245dff1 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskAssignmentLogDto.java @@ -0,0 +1,30 @@ +package com.datamate.coordination.interfaces.dto; + +import com.datamate.coordination.common.enums.AssignmentActionEnum; +import lombok.Getter; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class TaskAssignmentLogDto { + + private String id; + + private String taskMetaId; + + private AssignmentActionEnum action; + + private Long fromUserId; + + private Long toUserId; + + private String operatedBy; + + private String remark; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime createdAt; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskMetaDto.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskMetaDto.java new file mode 100644 index 0000000..555e7b8 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskMetaDto.java @@ -0,0 +1,64 @@ +package com.datamate.coordination.interfaces.dto; + +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import lombok.Getter; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class TaskMetaDto { + + private String id; + + private String parentId; + + private String module; + + private String refTaskId; + + private String taskName; + + private TaskMetaStatusEnum status; + + private Long assignedTo; + + /** 被分配人信息(查询时填充) */ + private AssigneeInfo assignee; + + private String createdBy; + + private Integer progress; + + private Integer totalItems; + + private Integer completedItems; + + private Integer failedItems; + + private String splitStrategy; + + private String splitConfig; + + private Integer priority; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime deadline; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime startedAt; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime completedAt; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime createdAt; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime updatedAt; + + /** 子任务数量(查询时填充) */ + private Integer childCount; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskProgressResponse.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskProgressResponse.java new file mode 100644 index 0000000..1d588d3 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/TaskProgressResponse.java @@ -0,0 +1,28 @@ +package com.datamate.coordination.interfaces.dto; + +import com.datamate.coordination.common.enums.TaskMetaStatusEnum; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class TaskProgressResponse { + + private String taskId; + + private String taskName; + + private Integer overallProgress; + + private Integer totalItems; + + private Integer completedItems; + + private Integer failedItems; + + private TaskMetaStatusEnum status; + + private List children; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/UpdateProgressRequest.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/UpdateProgressRequest.java new file mode 100644 index 0000000..c99da87 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/dto/UpdateProgressRequest.java @@ -0,0 +1,20 @@ +package com.datamate.coordination.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UpdateProgressRequest { + + private Integer progress; + + private Integer totalItems; + + private Integer completedItems; + + private Integer failedItems; + + /** 可选:同时更新状态 */ + private String status; +} diff --git a/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/rest/TaskMetaController.java b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/rest/TaskMetaController.java new file mode 100644 index 0000000..a31d957 --- /dev/null +++ b/backend/services/task-coordination-service/src/main/java/com/datamate/coordination/interfaces/rest/TaskMetaController.java @@ -0,0 +1,141 @@ +package com.datamate.coordination.interfaces.rest; + +import com.datamate.coordination.application.TaskAssignmentService; +import com.datamate.coordination.application.TaskMetaService; +import com.datamate.coordination.application.TaskProgressService; +import com.datamate.coordination.application.TaskSplitService; +import com.datamate.coordination.interfaces.dto.*; +import com.datamate.common.interfaces.PagedResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/task-meta") +@RequiredArgsConstructor +public class TaskMetaController { + + private final TaskMetaService taskMetaService; + + private final TaskSplitService taskSplitService; + + private final TaskAssignmentService taskAssignmentService; + + private final TaskProgressService taskProgressService; + + // ── CRUD ──────────────────────────────────────────────── + + @PostMapping + public TaskMetaDto createTask(@RequestBody CreateTaskMetaRequest request) { + return taskMetaService.createTaskMeta(request); + } + + @GetMapping("/{id}") + public TaskMetaDto getTask(@PathVariable("id") String id) { + return taskMetaService.getTaskMeta(id); + } + + @DeleteMapping("/{id}") + public String deleteTask(@PathVariable("id") String id) { + taskMetaService.deleteTaskMeta(id); + return id; + } + + @GetMapping("/{id}/children") + public PagedResponse getChildren( + @PathVariable("id") String id, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "20") Integer size) { + List children = taskMetaService.getChildren(id, page, size); + int count = taskMetaService.countChildren(id); + int totalPages = size > 0 ? (count + size - 1) / size : 1; + return PagedResponse.of(children, page, count, totalPages); + } + + @GetMapping + public PagedResponse listTasks( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "20") Integer size, + @RequestParam(value = "module", required = false) String module, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "assignedTo", required = false) Long assignedTo, + @RequestParam(value = "keyword", required = false) String keyword) { + List tasks = taskMetaService.findTasks(module, status, assignedTo, keyword, page, size); + int count = taskMetaService.countTasks(module, status, assignedTo, keyword); + int totalPages = size > 0 ? (count + size - 1) / size : 1; + return PagedResponse.of(tasks, page, count, totalPages); + } + + @GetMapping("/my-tasks") + public PagedResponse getMyTasks( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "size", defaultValue = "20") Integer size, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "module", required = false) String module) { + List tasks = taskMetaService.getMyTasks(status, module, page, size); + int count = taskMetaService.countMyTasks(status, module); + int totalPages = size > 0 ? (count + size - 1) / size : 1; + return PagedResponse.of(tasks, page, count, totalPages); + } + + // ── 拆分 ─────────────────────────────────────────────── + + @PostMapping("/{id}/split") + public List splitTask( + @PathVariable("id") String id, + @RequestBody SplitTaskRequest request) { + return taskSplitService.splitTask(id, request); + } + + // ── 分配 ─────────────────────────────────────────────── + + @PostMapping("/{id}/assign") + public String assignTask( + @PathVariable("id") String id, + @RequestBody AssignTaskRequest request) { + taskAssignmentService.assignTask(id, request); + return id; + } + + @PostMapping("/{id}/reassign") + public String reassignTask( + @PathVariable("id") String id, + @RequestBody AssignTaskRequest request) { + taskAssignmentService.reassignTask(id, request); + return id; + } + + @PostMapping("/{id}/revoke") + public String revokeTask( + @PathVariable("id") String id, + @RequestParam(value = "remark", required = false) String remark) { + taskAssignmentService.revokeTask(id, remark); + return id; + } + + @PostMapping("/batch-assign") + public void batchAssign(@RequestBody BatchAssignRequest request) { + taskAssignmentService.batchAssign(request); + } + + @GetMapping("/{id}/assignment-logs") + public List getAssignmentLogs(@PathVariable("id") String id) { + return taskAssignmentService.getAssignmentLogs(id); + } + + // ── 进度 ─────────────────────────────────────────────── + + @GetMapping("/{id}/progress") + public TaskProgressResponse getProgress(@PathVariable("id") String id) { + return taskProgressService.getProgress(id); + } + + @PutMapping("/{id}/progress") + public String updateProgress( + @PathVariable("id") String id, + @RequestBody UpdateProgressRequest request) { + taskProgressService.updateProgress(id, request); + return id; + } +} diff --git a/frontend/src/auth/permissions.ts b/frontend/src/auth/permissions.ts index 919349a..3340f88 100644 --- a/frontend/src/auth/permissions.ts +++ b/frontend/src/auth/permissions.ts @@ -17,6 +17,9 @@ export const PermissionCodes = { operatorMarketWrite: "module:operator-market:write", orchestrationRead: "module:orchestration:read", orchestrationWrite: "module:orchestration:write", + taskCoordinationRead: "module:task-coordination:read", + taskCoordinationWrite: "module:task-coordination:write", + taskCoordinationAssign: "module:task-coordination:assign", contentGenerationUse: "module:content-generation:use", agentUse: "module:agent:use", userManage: "system:user:manage", @@ -34,6 +37,7 @@ const routePermissionRules: Array<{ prefix: string; permission: string }> = [ { prefix: "/data/knowledge-base", permission: PermissionCodes.knowledgeBaseRead }, { prefix: "/data/operator-market", permission: PermissionCodes.operatorMarketRead }, { prefix: "/data/orchestration", permission: PermissionCodes.orchestrationRead }, + { prefix: "/data/task-coordination", permission: PermissionCodes.taskCoordinationRead }, { prefix: "/data/content-generation", permission: PermissionCodes.contentGenerationUse }, { prefix: "/chat", permission: PermissionCodes.agentUse }, ]; diff --git a/frontend/src/pages/Layout/menu.tsx b/frontend/src/pages/Layout/menu.tsx index 3a1ab4c..68fab3c 100644 --- a/frontend/src/pages/Layout/menu.tsx +++ b/frontend/src/pages/Layout/menu.tsx @@ -9,6 +9,7 @@ import { Zap, Shield, Sparkles, + ListChecks, // Database, // Store, // Merge, @@ -55,6 +56,24 @@ export const menuItems = [ description: "管理知识集与知识条目", color: "bg-indigo-500", }, + { + id: "task-coordination", + title: "任务协调", + icon: ListChecks, + permissionCode: PermissionCodes.taskCoordinationRead, + description: "任务拆分、分配与进度跟踪", + color: "bg-amber-500", + children: [ + { + id: "task-coordination", + title: "任务管理", + }, + { + id: "task-coordination/my-tasks", + title: "我的任务", + }, + ], + }, // { // id: "cleansing", // title: "数据清洗", diff --git a/frontend/src/pages/TaskCoordination/Home/TaskCoordination.tsx b/frontend/src/pages/TaskCoordination/Home/TaskCoordination.tsx new file mode 100644 index 0000000..dbc3a99 --- /dev/null +++ b/frontend/src/pages/TaskCoordination/Home/TaskCoordination.tsx @@ -0,0 +1,482 @@ +import { useState, useCallback } from "react"; +import { + Card, + Table, + Button, + Tag, + Tooltip, + Progress, + Modal, + App, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + PlusOutlined, + ScissorOutlined, + UserAddOutlined, + SwapOutlined, + UndoOutlined, + BarChartOutlined, + HistoryOutlined, + DeleteOutlined, + DownOutlined, + RightOutlined, +} from "@ant-design/icons"; +import useFetchData from "@/hooks/useFetchData"; +import { SearchControls } from "@/components/SearchControls"; +import { + queryTaskMetasUsingGet, + getChildrenUsingGet, + deleteTaskMetaByIdUsingDelete, + revokeTaskUsingPost, +} from "../taskCoordination.api"; +import { + TaskMetaDto, + TaskMetaStatus, +} from "../taskCoordination.model"; +import { + renderTaskStatus, + getModuleLabel, + statusFilterOptions, + moduleFilterOptions, +} from "../taskCoordination.const"; +import SplitTaskDialog from "../components/SplitTaskDialog"; +import AssignTaskDialog from "../components/AssignTaskDialog"; +import BatchAssignDialog from "../components/BatchAssignDialog"; +import TaskProgressPanel from "../components/TaskProgressPanel"; +import AssignmentLogDrawer from "../components/AssignmentLogDrawer"; + +function mapTaskMeta(data: Partial): TaskMetaDto { + return { + id: data.id || "", + parentId: data.parentId, + module: data.module || "", + refTaskId: data.refTaskId || "", + taskName: data.taskName || "-", + status: (data.status as TaskMetaStatus) || TaskMetaStatus.PENDING, + assignedTo: data.assignedTo, + assigneeName: + data.assignee?.fullName || data.assignee?.username || data.assigneeName, + progress: data.progress ?? 0, + totalItems: data.totalItems ?? 0, + completedItems: data.completedItems ?? 0, + failedItems: data.failedItems ?? 0, + splitStrategy: data.splitStrategy, + priority: data.priority ?? 0, + deadline: data.deadline, + remark: data.remark, + createdBy: data.createdBy, + createdAt: data.createdAt || "", + updatedAt: data.updatedAt || "", + childCount: data.childCount ?? 0, + assignee: data.assignee, + } as TaskMetaDto; +} + +export default function TaskCoordinationPage() { + const { message, modal } = App.useApp(); + + // Dialog states + const [splitTask, setSplitTask] = useState(null); + const [assignTask, setAssignTask] = useState(null); + const [assignMode, setAssignMode] = useState<"assign" | "reassign">("assign"); + const [batchAssignTasks, setBatchAssignTasks] = useState([]); + const [progressTaskId, setProgressTaskId] = useState(null); + const [logTask, setLogTask] = useState<{ id: string; name: string } | null>( + null + ); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + + // Expanded rows and children cache + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [childrenMap, setChildrenMap] = useState< + Record + >({}); + const [childrenLoading, setChildrenLoading] = useState< + Record + >({}); + + const { + loading, + tableData, + pagination, + searchParams, + fetchData, + handleFiltersChange, + handleKeywordChange, + } = useFetchData( + queryTaskMetasUsingGet, + mapTaskMeta, + 30000, + false, + [], + 0 + ); + + // Load children when expanding + const handleExpand = useCallback( + async (expanded: boolean, record: TaskMetaDto) => { + if (!expanded) { + setExpandedRowKeys((keys) => keys.filter((k) => k !== record.id)); + return; + } + setExpandedRowKeys((keys) => [...keys, record.id]); + + if (childrenMap[record.id]) return; // already loaded + + setChildrenLoading((prev) => ({ ...prev, [record.id]: true })); + try { + const res: any = await getChildrenUsingGet(record.id, { + page: 0, + size: 100, + }); + const children = (res?.data?.content ?? []).map(mapTaskMeta); + setChildrenMap((prev) => ({ ...prev, [record.id]: children })); + } catch { + message.error("加载子任务失败"); + } finally { + setChildrenLoading((prev) => ({ ...prev, [record.id]: false })); + } + }, + [childrenMap, message] + ); + + const handleRefresh = () => { + setChildrenMap({}); + setExpandedRowKeys([]); + fetchData(); + }; + + const handleDelete = (task: TaskMetaDto) => { + modal.confirm({ + title: `确认删除任务「${task.taskName}」?`, + content: "删除后不可恢复,子任务也将一并删除。", + okText: "删除", + okType: "danger", + cancelText: "取消", + onOk: async () => { + try { + await deleteTaskMetaByIdUsingDelete(task.id); + message.success("删除成功"); + handleRefresh(); + } catch { + message.error("删除失败"); + } + }, + }); + }; + + const handleRevoke = (task: TaskMetaDto) => { + modal.confirm({ + title: `确认撤回任务「${task.taskName}」的分配?`, + okText: "撤回", + cancelText: "取消", + onOk: async () => { + try { + await revokeTaskUsingPost(task.id); + message.success("撤回成功"); + handleRefresh(); + } catch { + message.error("撤回失败"); + } + }, + }); + }; + + const columns: ColumnsType = [ + { + title: "任务名称", + dataIndex: "taskName", + key: "taskName", + fixed: "left", + width: 240, + ellipsis: true, + }, + { + title: "模块", + dataIndex: "module", + key: "module", + width: 100, + render: (v) => {getModuleLabel(v)}, + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 100, + render: (v) => renderTaskStatus(v), + }, + { + title: "负责人", + key: "assignee", + width: 120, + render: (_, record) => record.assigneeName || "-", + }, + { + title: "进度", + key: "progress", + width: 180, + render: (_, record) => { + const pct = record.totalItems + ? Math.round((record.completedItems / record.totalItems) * 100) + : record.progress || 0; + const status = + record.status === "COMPLETED" + ? ("success" as const) + : record.status === "FAILED" + ? ("exception" as const) + : ("active" as const); + return ( +
+ + + {record.completedItems}/{record.totalItems} + +
+ ); + }, + }, + { + title: "子任务", + dataIndex: "childCount", + key: "childCount", + width: 80, + align: "center", + render: (v) => + v > 0 ? {v} : -, + }, + { + title: "创建时间", + dataIndex: "createdAt", + key: "createdAt", + width: 160, + ellipsis: true, + }, + { + title: "操作", + key: "actions", + fixed: "right", + width: 200, + render: (_, record) => { + const isParent = !record.parentId; + const canSplit = isParent && record.childCount === 0; + const canAssign = + !record.assignedTo && + record.status === TaskMetaStatus.PENDING; + const canReassign = !!record.assignedTo; + + return ( +
+ {canSplit && ( + +
+ ); + }, + }, + ]; + + // Expanded row renders child tasks + const expandedRowRender = (record: TaskMetaDto) => { + if (childrenLoading[record.id]) { + return
加载中...
; + } + const children = childrenMap[record.id]; + if (!children || children.length === 0) { + return ( +
暂无子任务
+ ); + } + return ( + c.key !== "childCount")} + rowKey="id" + pagination={false} + size="small" + /> + ); + }; + + const filterOptions = [ + { key: "status", label: "状态", options: statusFilterOptions }, + { key: "module", label: "模块", options: moduleFilterOptions }, + ]; + + return ( +
+
+

任务协调

+
+ {selectedRowKeys.length > 0 && ( + + )} +
+
+ + + + +
{ + setSelectedRowKeys(keys as string[]); + setSelectedRows(rows); + }, + }} + expandable={{ + expandedRowKeys, + expandedRowRender, + onExpand: handleExpand, + expandIcon: ({ expanded, onExpand, record }) => + record.childCount > 0 ? ( +
+ + + setProgressTaskId(null)} + /> + + ); +} diff --git a/frontend/src/pages/TaskCoordination/components/AssignTaskDialog.tsx b/frontend/src/pages/TaskCoordination/components/AssignTaskDialog.tsx new file mode 100644 index 0000000..a73eebe --- /dev/null +++ b/frontend/src/pages/TaskCoordination/components/AssignTaskDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import { Modal, Form, Select, Input, App } from "antd"; +import { UserOption, TaskMetaDto } from "../taskCoordination.model"; +import { + assignTaskUsingPost, + reassignTaskUsingPost, + listUsersUsingGet, +} from "../taskCoordination.api"; + +interface AssignTaskDialogProps { + open: boolean; + task: TaskMetaDto | null; + mode: "assign" | "reassign"; + onClose: () => void; + onRefresh: () => void; +} + +export default function AssignTaskDialog({ + open, + task, + mode, + onClose, + onRefresh, +}: AssignTaskDialogProps) { + const [form] = Form.useForm(); + const { message } = App.useApp(); + const [submitting, setSubmitting] = useState(false); + const [users, setUsers] = useState([]); + + useEffect(() => { + if (!open) return; + form.resetFields(); + listUsersUsingGet() + .then((res: any) => { + setUsers( + (res?.data ?? []).map((u: any) => ({ + id: u.id, + username: u.username, + fullName: u.fullName, + })) + ); + }) + .catch(() => {}); + }, [open, form]); + + const handleSubmit = async () => { + if (!task) return; + try { + const values = await form.validateFields(); + setSubmitting(true); + + const fn = + mode === "reassign" ? reassignTaskUsingPost : assignTaskUsingPost; + await fn(task.id, { + userId: values.userId, + remark: values.remark, + }); + + message.success(mode === "reassign" ? "重新分配成功" : "分配成功"); + onRefresh(); + onClose(); + } catch (err: any) { + if (err?.errorFields) return; + message.error(err?.message || "操作失败,请稍后重试"); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ + handleRowChange(index, "userId", v)} + options={userOptions} + style={{ width: "100%" }} + /> + ), + }, + { + title: "备注", + key: "remark", + render: (_: unknown, _record: RowAssignment, index: number) => ( + handleRowChange(index, "remark", e.target.value)} + /> + ), + }, + ]; + + return ( + +
+ 统一分配给: +
+ + ); +} diff --git a/frontend/src/pages/TaskCoordination/components/SplitTaskDialog.tsx b/frontend/src/pages/TaskCoordination/components/SplitTaskDialog.tsx new file mode 100644 index 0000000..c305358 --- /dev/null +++ b/frontend/src/pages/TaskCoordination/components/SplitTaskDialog.tsx @@ -0,0 +1,280 @@ +import { useEffect, useState } from "react"; +import { + Modal, + Form, + Select, + InputNumber, + Input, + Radio, + Button, + Table, + App, +} from "antd"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; +import { + SplitStrategy, + SplitAssignment, + UserOption, + TaskMetaDto, +} from "../taskCoordination.model"; +import { SplitStrategyMap } from "../taskCoordination.const"; +import { splitTaskUsingPost, listUsersUsingGet } from "../taskCoordination.api"; + +interface SplitTaskDialogProps { + open: boolean; + task: TaskMetaDto | null; + onClose: () => void; + onRefresh: () => void; +} + +export default function SplitTaskDialog({ + open, + task, + onClose, + onRefresh, +}: SplitTaskDialogProps) { + const [form] = Form.useForm(); + const { message } = App.useApp(); + const [submitting, setSubmitting] = useState(false); + const [users, setUsers] = useState([]); + const [assignments, setAssignments] = useState([ + { taskName: "", proportion: undefined, itemCount: undefined }, + { taskName: "", proportion: undefined, itemCount: undefined }, + ]); + + const strategy: SplitStrategy = Form.useWatch("strategy", form); + + useEffect(() => { + if (!open) return; + listUsersUsingGet() + .then((res: any) => { + setUsers( + (res?.data ?? []).map((u: any) => ({ + id: u.id, + username: u.username, + fullName: u.fullName, + })) + ); + }) + .catch(() => {}); + form.setFieldsValue({ strategy: SplitStrategy.BY_PERCENTAGE }); + setAssignments([ + { taskName: "", proportion: undefined, itemCount: undefined }, + { taskName: "", proportion: undefined, itemCount: undefined }, + ]); + }, [open, form]); + + const handleAddRow = () => { + setAssignments([ + ...assignments, + { taskName: "", proportion: undefined, itemCount: undefined }, + ]); + }; + + const handleRemoveRow = (index: number) => { + if (assignments.length <= 2) return; + setAssignments(assignments.filter((_, i) => i !== index)); + }; + + const handleAssignmentChange = ( + index: number, + field: keyof SplitAssignment, + value: unknown + ) => { + const updated = [...assignments]; + updated[index] = { ...updated[index], [field]: value }; + setAssignments(updated); + }; + + const handleSubmit = async () => { + if (!task) return; + try { + await form.validateFields(); + + const validAssignments = assignments.filter( + (a) => a.userId || a.itemCount || a.proportion + ); + if (validAssignments.length < 2) { + message.warning("至少需要拆分为 2 个子任务"); + return; + } + + if (strategy === SplitStrategy.BY_PERCENTAGE) { + const totalProportion = validAssignments.reduce( + (sum, a) => sum + (a.proportion || 0), + 0 + ); + if (Math.abs(totalProportion - 100) > 0.01) { + message.warning("比例之和必须为 100%"); + return; + } + } + + setSubmitting(true); + await splitTaskUsingPost(task.id, { + strategy, + assignments: validAssignments.map((a, i) => ({ + ...a, + taskName: a.taskName || `${task.taskName} - 子任务 ${i + 1}`, + })), + }); + message.success("任务拆分成功"); + onRefresh(); + onClose(); + } catch (err: any) { + if (err?.errorFields) return; + message.error(err?.message || "拆分失败,请稍后重试"); + } finally { + setSubmitting(false); + } + }; + + const columns = [ + { + title: "子任务名称", + key: "taskName", + width: 200, + render: (_: unknown, _record: SplitAssignment, index: number) => ( + + handleAssignmentChange(index, "taskName", e.target.value) + } + /> + ), + }, + { + title: "分配用户", + key: "userId", + width: 180, + render: (_: unknown, _record: SplitAssignment, index: number) => ( +
String(index)} + pagination={false} + size="small" + footer={() => ( + + )} + /> + + ); +} diff --git a/frontend/src/pages/TaskCoordination/components/TaskProgressPanel.tsx b/frontend/src/pages/TaskCoordination/components/TaskProgressPanel.tsx new file mode 100644 index 0000000..98737bb --- /dev/null +++ b/frontend/src/pages/TaskCoordination/components/TaskProgressPanel.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from "react"; +import { Drawer, Progress, Spin, Table, Tag, App } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { + TaskProgressResponse, + ChildTaskProgressDto, +} from "../taskCoordination.model"; +import { getProgressUsingGet } from "../taskCoordination.api"; +import { renderTaskStatus } from "../taskCoordination.const"; + +interface TaskProgressPanelProps { + open: boolean; + taskId: string | null; + onClose: () => void; +} + +export default function TaskProgressPanel({ + open, + taskId, + onClose, +}: TaskProgressPanelProps) { + const { message } = App.useApp(); + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + + useEffect(() => { + if (!open || !taskId) return; + setLoading(true); + getProgressUsingGet(taskId) + .then((res: any) => { + setData(res?.data ?? null); + }) + .catch(() => { + message.error("加载进度信息失败"); + }) + .finally(() => { + setLoading(false); + }); + }, [open, taskId, message]); + + const progressStatus = (() => { + if (!data) return "normal" as const; + switch (data.status) { + case "COMPLETED": + return "success" as const; + case "FAILED": + return "exception" as const; + case "IN_PROGRESS": + return "active" as const; + default: + return "normal" as const; + } + })(); + + const childColumns: ColumnsType = [ + { + title: "子任务", + dataIndex: "taskName", + key: "taskName", + ellipsis: true, + }, + { + title: "负责人", + key: "assignee", + width: 100, + render: (_, record) => record.assignee?.fullName || record.assignee?.username || "-", + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 100, + render: (status) => renderTaskStatus(status), + }, + { + title: "进度", + key: "progress", + width: 200, + render: (_, record) => { + const pct = record.totalItems + ? Math.round((record.completedItems / record.totalItems) * 100) + : record.progress || 0; + return ( +
+ + + {record.completedItems}/{record.totalItems} + +
+ ); + }, + }, + { + title: "失败", + dataIndex: "failedItems", + key: "failedItems", + width: 60, + align: "center", + render: (v) => + v > 0 ? {v} : 0, + }, + ]; + + return ( + + + {data && ( +
+ {/* Overall progress */} +
+
+ 整体进度 +
+ +
+ + 总计:{data.totalItems} + + + 已完成: + + {data.completedItems} + + + + 失败: + {data.failedItems} + +
+
+ + {/* Child tasks */} + {data.children && data.children.length > 0 && ( +
+
+ 子任务进度({data.children.length} 个) +
+
+ + )} + + )} + + + ); +} diff --git a/frontend/src/pages/TaskCoordination/taskCoordination.api.ts b/frontend/src/pages/TaskCoordination/taskCoordination.api.ts new file mode 100644 index 0000000..12738a5 --- /dev/null +++ b/frontend/src/pages/TaskCoordination/taskCoordination.api.ts @@ -0,0 +1,76 @@ +import { get, post, put, del } from "@/utils/request"; + +type RequestParams = Record; +type RequestPayload = Record; + +// ── CRUD ───────────────────────────────────────────────── + +export function createTaskMetaUsingPost(data: RequestPayload) { + return post("/api/task-meta", data); +} + +export function getTaskMetaByIdUsingGet(id: string) { + return get(`/api/task-meta/${id}`); +} + +export function deleteTaskMetaByIdUsingDelete(id: string) { + return del(`/api/task-meta/${id}`); +} + +export function queryTaskMetasUsingGet(params?: RequestParams) { + return get("/api/task-meta", params); +} + +export function getChildrenUsingGet(id: string, params?: RequestParams) { + return get(`/api/task-meta/${id}/children`, params); +} + +export function getMyTasksUsingGet(params?: RequestParams) { + return get("/api/task-meta/my-tasks", params); +} + +// ── Split ──────────────────────────────────────────────── + +export function splitTaskUsingPost(id: string, data: RequestPayload) { + return post(`/api/task-meta/${id}/split`, data); +} + +// ── Assignment ─────────────────────────────────────────── + +export function assignTaskUsingPost(id: string, data: RequestPayload) { + return post(`/api/task-meta/${id}/assign`, data); +} + +export function reassignTaskUsingPost(id: string, data: RequestPayload) { + return post(`/api/task-meta/${id}/reassign`, data); +} + +export function revokeTaskUsingPost(id: string, remark?: string) { + return post(`/api/task-meta/${id}/revoke`, null, { + params: remark ? { remark } : undefined, + }); +} + +export function batchAssignUsingPost(data: RequestPayload) { + return post("/api/task-meta/batch-assign", data); +} + +export function getAssignmentLogsUsingGet(id: string) { + return get(`/api/task-meta/${id}/assignment-logs`); +} + +// ── Progress ───────────────────────────────────────────── + +export function getProgressUsingGet(id: string) { + return get(`/api/task-meta/${id}/progress`); +} + +export function updateProgressUsingPut(id: string, data: RequestPayload) { + return put(`/api/task-meta/${id}/progress`, data); +} + +// ── Users (for assignment selectors) ───────────────────── + +export function listUsersUsingGet() { + return get("/api/auth/users"); +} diff --git a/frontend/src/pages/TaskCoordination/taskCoordination.const.tsx b/frontend/src/pages/TaskCoordination/taskCoordination.const.tsx new file mode 100644 index 0000000..5a8c9d1 --- /dev/null +++ b/frontend/src/pages/TaskCoordination/taskCoordination.const.tsx @@ -0,0 +1,73 @@ +import { Tag } from "antd"; +import { + TaskMetaStatus, + SplitStrategy, + TaskModule, + AssignmentAction, +} from "./taskCoordination.model"; + +// ── Status ─────────────────────────────────────────────── + +interface StatusMeta { + label: string; + color: string; +} + +export const TaskStatusMap: Record = { + [TaskMetaStatus.PENDING]: { label: "待处理", color: "default" }, + [TaskMetaStatus.IN_PROGRESS]: { label: "进行中", color: "processing" }, + [TaskMetaStatus.COMPLETED]: { label: "已完成", color: "success" }, + [TaskMetaStatus.FAILED]: { label: "失败", color: "error" }, + [TaskMetaStatus.STOPPED]: { label: "已停止", color: "warning" }, + [TaskMetaStatus.CANCELLED]: { label: "已取消", color: "default" }, +}; + +export function renderTaskStatus(status?: string) { + if (!status) return "-"; + const meta = TaskStatusMap[status as TaskMetaStatus]; + if (!meta) return {status}; + return {meta.label}; +} + +// ── Module ─────────────────────────────────────────────── + +export const TaskModuleMap: Record = { + [TaskModule.ANNOTATION]: "数据标注", + [TaskModule.CLEANING]: "数据清洗", + [TaskModule.EVALUATION]: "数据评估", + [TaskModule.SYNTHESIS]: "数据合成", + [TaskModule.COLLECTION]: "数据归集", + [TaskModule.RATIO]: "数据配比", +}; + +export function getModuleLabel(module?: string): string { + if (!module) return "-"; + return TaskModuleMap[module as TaskModule] || module; +} + +// ── Split Strategy ─────────────────────────────────────── + +export const SplitStrategyMap: Record = { + [SplitStrategy.BY_COUNT]: "按数量拆分", + [SplitStrategy.BY_FILE]: "按文件拆分", + [SplitStrategy.BY_PERCENTAGE]: "按比例拆分", + [SplitStrategy.MANUAL]: "手动拆分", +}; + +// ── Assignment Action ──────────────────────────────────── + +export const AssignmentActionMap: Record = { + [AssignmentAction.ASSIGN]: "分配", + [AssignmentAction.REASSIGN]: "重新分配", + [AssignmentAction.REVOKE]: "撤回", +}; + +// ── Filter Options ─────────────────────────────────────── + +export const statusFilterOptions = Object.entries(TaskStatusMap).map( + ([value, meta]) => ({ label: meta.label, value }) +); + +export const moduleFilterOptions = Object.entries(TaskModuleMap).map( + ([value, label]) => ({ label, value }) +); diff --git a/frontend/src/pages/TaskCoordination/taskCoordination.model.ts b/frontend/src/pages/TaskCoordination/taskCoordination.model.ts new file mode 100644 index 0000000..b08f667 --- /dev/null +++ b/frontend/src/pages/TaskCoordination/taskCoordination.model.ts @@ -0,0 +1,155 @@ +// ── Enums ──────────────────────────────────────────────── + +export enum TaskMetaStatus { + PENDING = "PENDING", + IN_PROGRESS = "IN_PROGRESS", + COMPLETED = "COMPLETED", + FAILED = "FAILED", + STOPPED = "STOPPED", + CANCELLED = "CANCELLED", +} + +export enum SplitStrategy { + BY_COUNT = "BY_COUNT", + BY_FILE = "BY_FILE", + BY_PERCENTAGE = "BY_PERCENTAGE", + MANUAL = "MANUAL", +} + +export enum TaskModule { + ANNOTATION = "ANNOTATION", + CLEANING = "CLEANING", + EVALUATION = "EVALUATION", + SYNTHESIS = "SYNTHESIS", + COLLECTION = "COLLECTION", + RATIO = "RATIO", +} + +export enum AssignmentAction { + ASSIGN = "ASSIGN", + REASSIGN = "REASSIGN", + REVOKE = "REVOKE", +} + +// ── Response Interfaces ────────────────────────────────── + +export interface AssigneeInfo { + id: number; + username: string; + fullName?: string; +} + +export interface TaskMetaDto { + id: string; + parentId?: string; + module: string; + refTaskId: string; + taskName: string; + status: TaskMetaStatus; + assignedTo?: number; + assigneeName?: string; + progress: number; + totalItems: number; + completedItems: number; + failedItems: number; + splitStrategy?: string; + splitConfig?: string; + priority: number; + deadline?: string; + remark?: string; + createdBy?: number; + createdAt: string; + updatedAt: string; + childCount: number; + assignee?: AssigneeInfo; +} + +export interface TaskAssignmentLogDto { + id: number; + taskMetaId: string; + action: AssignmentAction; + userId?: number; + userName?: string; + remark?: string; + operatorId?: number; + operatorName?: string; + createdAt: string; +} + +export interface ChildTaskProgressDto { + taskId: string; + taskName: string; + assignee?: AssigneeInfo; + progress: number; + totalItems: number; + completedItems: number; + failedItems: number; + status: TaskMetaStatus; +} + +export interface TaskProgressResponse { + taskId: string; + taskName: string; + overallProgress: number; + totalItems: number; + completedItems: number; + failedItems: number; + status: TaskMetaStatus; + children: ChildTaskProgressDto[]; +} + +// ── Request Interfaces ─────────────────────────────────── + +export interface CreateTaskMetaRequest { + module: string; + refTaskId: string; + taskName: string; + assignedTo?: number; + totalItems?: number; + priority?: number; + deadline?: string; +} + +export interface SplitAssignment { + userId?: number; + proportion?: number; + itemCount?: number; + taskName?: string; +} + +export interface SplitTaskRequest { + strategy: SplitStrategy; + splitConfig?: Record; + assignments: SplitAssignment[]; +} + +export interface AssignTaskRequest { + userId: number; + remark?: string; +} + +export interface BatchTaskAssignment { + taskMetaId: string; + userId: number; + remark?: string; +} + +export interface BatchAssignRequest { + assignments: BatchTaskAssignment[]; +} + +export interface UpdateProgressRequest { + progress?: number; + totalItems?: number; + completedItems?: number; + failedItems?: number; + status?: TaskMetaStatus; +} + +// ── User (for assignment selectors) ────────────────────── + +export interface UserOption { + id: number; + username: string; + fullName?: string; +} diff --git a/frontend/src/routes/routes.ts b/frontend/src/routes/routes.ts index df0455f..8cd7039 100644 --- a/frontend/src/routes/routes.ts +++ b/frontend/src/routes/routes.ts @@ -39,6 +39,8 @@ import OperatorPluginCreate from "@/pages/OperatorMarket/Create/OperatorPluginCr import OperatorPluginDetail from "@/pages/OperatorMarket/Detail/OperatorPluginDetail"; import RatioTasksPage from "@/pages/RatioTask/Home/RatioTask.tsx"; import CreateRatioTask from "@/pages/RatioTask/Create/CreateRatioTask.tsx"; +import TaskCoordinationPage from "@/pages/TaskCoordination/Home/TaskCoordination"; +import MyTasksPage from "@/pages/TaskCoordination/MyTasks/MyTasks"; import OrchestrationPage from "@/pages/Orchestration/Orchestration"; import WorkflowEditor from "@/pages/Orchestration/WorkflowEditor"; import { withErrorBoundary } from "@/components/ErrorBoundary"; @@ -285,6 +287,20 @@ const router = createBrowserRouter([ }, ], }, + { + path: "task-coordination", + children: [ + { + path: "", + index: true, + Component: TaskCoordinationPage, + }, + { + path: "my-tasks", + Component: MyTasksPage, + }, + ], + }, { path: "operator-market", children: [ diff --git a/runtime/datamate-python/app/db/models/__init__.py b/runtime/datamate-python/app/db/models/__init__.py index 564c3cf..1f421e3 100644 --- a/runtime/datamate-python/app/db/models/__init__.py +++ b/runtime/datamate-python/app/db/models/__init__.py @@ -23,6 +23,11 @@ from .data_evaluation import ( EvaluationItem ) +from .task_coordination import ( + TaskMeta, + TaskAssignmentLog +) + __all__ = [ "Dataset", "DatasetTag", @@ -36,4 +41,6 @@ __all__ = [ "LabelingProjectFile", "EvaluationTask", "EvaluationItem", + "TaskMeta", + "TaskAssignmentLog", ] diff --git a/runtime/datamate-python/app/db/models/task_coordination.py b/runtime/datamate-python/app/db/models/task_coordination.py new file mode 100644 index 0000000..3cb2431 --- /dev/null +++ b/runtime/datamate-python/app/db/models/task_coordination.py @@ -0,0 +1,220 @@ +"""Tables of Task Coordination Module (任务拆分与分配)""" + +import uuid +from sqlalchemy import ( + Column, + String, + Integer, + BigInteger, + TIMESTAMP, + Text, + JSON, + ForeignKey, + Index, +) +from sqlalchemy.sql import func + +from app.db.session import Base + + +# ── 任务元状态常量 ────────────────────────────────────────── +TASK_META_STATUS_PENDING = "PENDING" +TASK_META_STATUS_IN_PROGRESS = "IN_PROGRESS" +TASK_META_STATUS_COMPLETED = "COMPLETED" +TASK_META_STATUS_FAILED = "FAILED" +TASK_META_STATUS_STOPPED = "STOPPED" +TASK_META_STATUS_CANCELLED = "CANCELLED" + +TASK_META_STATUS_VALUES = { + TASK_META_STATUS_PENDING, + TASK_META_STATUS_IN_PROGRESS, + TASK_META_STATUS_COMPLETED, + TASK_META_STATUS_FAILED, + TASK_META_STATUS_STOPPED, + TASK_META_STATUS_CANCELLED, +} + +# ── 所属模块常量 ──────────────────────────────────────────── +MODULE_ANNOTATION = "ANNOTATION" +MODULE_CLEANING = "CLEANING" +MODULE_EVALUATION = "EVALUATION" +MODULE_SYNTHESIS = "SYNTHESIS" +MODULE_COLLECTION = "COLLECTION" +MODULE_RATIO = "RATIO" + +MODULE_VALUES = { + MODULE_ANNOTATION, + MODULE_CLEANING, + MODULE_EVALUATION, + MODULE_SYNTHESIS, + MODULE_COLLECTION, + MODULE_RATIO, +} + +# ── 拆分策略常量 ──────────────────────────────────────────── +SPLIT_STRATEGY_BY_COUNT = "BY_COUNT" +SPLIT_STRATEGY_BY_FILE = "BY_FILE" +SPLIT_STRATEGY_BY_PERCENTAGE = "BY_PERCENTAGE" +SPLIT_STRATEGY_MANUAL = "MANUAL" + +# ── 分配操作常量 ──────────────────────────────────────────── +ASSIGNMENT_ACTION_ASSIGN = "ASSIGN" +ASSIGNMENT_ACTION_REASSIGN = "REASSIGN" +ASSIGNMENT_ACTION_REVOKE = "REVOKE" + + +class TaskMeta(Base): + """任务拆分与分配元数据表""" + + __tablename__ = "t_task_meta" + + id = Column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + comment="任务元ID (UUID)", + ) + parent_id = Column( + String(36), + ForeignKey("t_task_meta.id", ondelete="CASCADE"), + nullable=True, + comment="父任务ID,顶层任务为NULL", + ) + module = Column( + String(50), + nullable=False, + comment="所属模块: ANNOTATION/CLEANING/EVALUATION/SYNTHESIS/COLLECTION/RATIO", + ) + ref_task_id = Column( + String(64), + nullable=False, + comment="关联的业务任务ID(各模块自己的task表ID)", + ) + task_name = Column(String(255), nullable=False, comment="任务名称") + status = Column( + String(20), + nullable=False, + default=TASK_META_STATUS_PENDING, + comment="状态: PENDING/IN_PROGRESS/COMPLETED/FAILED/STOPPED/CANCELLED", + ) + assigned_to = Column( + BigInteger, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="分配给的用户ID (users.id)", + ) + created_by = Column(String(255), nullable=False, comment="创建者用户名") + + # ── 进度字段 ── + progress = Column( + Integer, nullable=False, default=0, comment="进度百分比 0-100" + ) + total_items = Column( + Integer, nullable=False, default=0, comment="总条目数" + ) + completed_items = Column( + Integer, nullable=False, default=0, comment="已完成条目数" + ) + failed_items = Column( + Integer, nullable=False, default=0, comment="失败条目数" + ) + + # ── 拆分配置 ── + split_strategy = Column( + String(50), + nullable=True, + comment="拆分策略: BY_COUNT/BY_FILE/BY_PERCENTAGE/MANUAL", + ) + split_config = Column(JSON, nullable=True, comment="拆分配置参数") + + # ── 调度 ── + priority = Column( + Integer, nullable=False, default=0, comment="优先级 0=普通 1=高 2=紧急" + ) + deadline = Column(TIMESTAMP, nullable=True, comment="截止时间") + + # ── 时间戳 ── + started_at = Column(TIMESTAMP, nullable=True, comment="开始时间") + completed_at = Column(TIMESTAMP, nullable=True, comment="完成时间") + created_at = Column( + TIMESTAMP, + server_default=func.current_timestamp(), + comment="创建时间", + ) + updated_at = Column( + TIMESTAMP, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + comment="更新时间", + ) + deleted_at = Column(TIMESTAMP, nullable=True, comment="删除时间(软删除)") + + __table_args__ = ( + Index("idx_task_meta_parent_id", "parent_id"), + Index("idx_task_meta_module_ref", "module", "ref_task_id"), + Index("idx_task_meta_assigned_to", "assigned_to"), + Index("idx_task_meta_status", "status"), + Index("idx_task_meta_created_by", "created_by"), + ) + + def __repr__(self) -> str: + return f"" + + @property + def is_deleted(self) -> bool: + """检查是否已被软删除""" + return self.deleted_at is not None + + @property + def is_parent(self) -> bool: + """是否为父任务(顶层任务)""" + return self.parent_id is None + + +class TaskAssignmentLog(Base): + """任务分配操作日志表""" + + __tablename__ = "t_task_assignment_log" + + id = Column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + comment="日志ID (UUID)", + ) + task_meta_id = Column( + String(36), + ForeignKey("t_task_meta.id", ondelete="CASCADE"), + nullable=False, + comment="任务元ID", + ) + action = Column( + String(20), + nullable=False, + comment="操作类型: ASSIGN/REASSIGN/REVOKE", + ) + from_user_id = Column( + BigInteger, nullable=True, comment="原分配用户ID" + ) + to_user_id = Column( + BigInteger, nullable=True, comment="新分配用户ID" + ) + operated_by = Column( + String(255), nullable=False, comment="操作者用户名" + ) + remark = Column(String(500), nullable=True, comment="备注") + created_at = Column( + TIMESTAMP, + server_default=func.current_timestamp(), + comment="创建时间", + ) + + __table_args__ = ( + Index("idx_assignment_log_task_meta_id", "task_meta_id"), + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/scripts/db/task-coordination-init.sql b/scripts/db/task-coordination-init.sql new file mode 100644 index 0000000..7fb2916 --- /dev/null +++ b/scripts/db/task-coordination-init.sql @@ -0,0 +1,119 @@ +-- ============================================= +-- DataMate 任务协调模块 - 初始化脚本 +-- 说明: +-- 1) 创建 t_task_meta 表(任务拆分与分配元数据) +-- 2) 创建 t_task_assignment_log 表(分配操作日志) +-- 3) 在 t_auth_permissions 中插入任务协调权限 +-- 4) 将权限绑定到 ROLE_ADMIN 和 ROLE_DATA_EDITOR +-- 5) 本脚本按"幂等"方式编写,可重复执行 +-- ============================================= + +USE datamate; + +-- ============================================= +-- 1) t_task_meta - 任务拆分与分配元数据表 +-- ============================================= +CREATE TABLE IF NOT EXISTS t_task_meta +( + id VARCHAR(36) PRIMARY KEY COMMENT '任务元ID (UUID)', + parent_id VARCHAR(36) NULL COMMENT '父任务ID,顶层任务为NULL', + module VARCHAR(50) NOT NULL COMMENT '所属模块: ANNOTATION/CLEANING/EVALUATION/SYNTHESIS/COLLECTION/RATIO', + ref_task_id VARCHAR(64) NOT NULL COMMENT '关联的业务任务ID(各模块自己的task表ID)', + task_name VARCHAR(255) NOT NULL COMMENT '任务名称', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/IN_PROGRESS/COMPLETED/FAILED/STOPPED/CANCELLED', + assigned_to BIGINT NULL COMMENT '分配给的用户ID (users.id)', + created_by VARCHAR(255) NOT NULL COMMENT '创建者用户名', + + -- 进度字段 + progress INT NOT NULL DEFAULT 0 COMMENT '进度百分比 0-100', + total_items INT NOT NULL DEFAULT 0 COMMENT '总条目数', + completed_items INT NOT NULL DEFAULT 0 COMMENT '已完成条目数', + failed_items INT NOT NULL DEFAULT 0 COMMENT '失败条目数', + + -- 拆分配置 + split_strategy VARCHAR(50) NULL COMMENT '拆分策略: BY_COUNT/BY_FILE/BY_PERCENTAGE/MANUAL', + split_config JSON NULL COMMENT '拆分配置参数', + + -- 调度 + priority INT NOT NULL DEFAULT 0 COMMENT '优先级 0=普通 1=高 2=紧急', + deadline TIMESTAMP NULL COMMENT '截止时间', + + -- 时间戳 + started_at TIMESTAMP NULL COMMENT '开始时间', + completed_at TIMESTAMP NULL COMMENT '完成时间', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted_at TIMESTAMP NULL COMMENT '删除时间(软删除)', + + -- 外键 + CONSTRAINT fk_task_meta_parent FOREIGN KEY (parent_id) REFERENCES t_task_meta (id) ON DELETE CASCADE, + CONSTRAINT fk_task_meta_assignee FOREIGN KEY (assigned_to) REFERENCES users (id) ON DELETE SET NULL, + + -- 索引 + INDEX idx_task_meta_parent_id (parent_id), + INDEX idx_task_meta_module_ref (module, ref_task_id), + INDEX idx_task_meta_assigned_to (assigned_to), + INDEX idx_task_meta_status (status), + INDEX idx_task_meta_created_by (created_by) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COMMENT = '任务拆分与分配元数据表'; + + +-- ============================================= +-- 2) t_task_assignment_log - 分配操作日志表 +-- ============================================= +CREATE TABLE IF NOT EXISTS t_task_assignment_log +( + id VARCHAR(36) PRIMARY KEY COMMENT '日志ID (UUID)', + task_meta_id VARCHAR(36) NOT NULL COMMENT '任务元ID', + action VARCHAR(20) NOT NULL COMMENT '操作类型: ASSIGN/REASSIGN/REVOKE', + from_user_id BIGINT NULL COMMENT '原分配用户ID', + to_user_id BIGINT NULL COMMENT '新分配用户ID', + operated_by VARCHAR(255) NOT NULL COMMENT '操作者用户名', + remark VARCHAR(500) NULL COMMENT '备注', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + + -- 外键 + CONSTRAINT fk_assignment_log_task FOREIGN KEY (task_meta_id) REFERENCES t_task_meta (id) ON DELETE CASCADE, + + -- 索引 + INDEX idx_assignment_log_task_meta_id (task_meta_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COMMENT = '任务分配操作日志'; + + +-- ============================================= +-- 3) 任务协调权限初始化 +-- ============================================= +INSERT IGNORE INTO t_auth_permissions (id, permission_code, permission_name, module, action, path_pattern, method, enabled, is_built_in) +VALUES ('perm-tc-read', 'module:task-coordination:read', '任务协调读取', 'task-coordination', 'read', '/api/task-meta/**', 'GET', 1, 1), + ('perm-tc-write', 'module:task-coordination:write', '任务协调写入', 'task-coordination', 'write', '/api/task-meta/**', 'POST,PUT,PATCH,DELETE', 1, 1), + ('perm-tc-assign', 'module:task-coordination:assign', '任务分配', 'task-coordination', 'assign', '/api/task-meta/*/assign', 'POST', 1, 1); + + +-- ============================================= +-- 4) 绑定权限到角色 +-- ============================================= + +-- 管理员获得全部任务协调权限 +INSERT IGNORE INTO t_auth_role_permissions (role_id, permission_id) +SELECT 'role-admin', p.id +FROM t_auth_permissions p +WHERE p.permission_code IN ( + 'module:task-coordination:read', + 'module:task-coordination:write', + 'module:task-coordination:assign' +); + +-- 数据运营获得任务协调读写和分配权限 +INSERT IGNORE INTO t_auth_role_permissions (role_id, permission_id) +SELECT 'role-data-editor', p.id +FROM t_auth_permissions p +WHERE p.permission_code IN ( + 'module:task-coordination:read', + 'module:task-coordination:write', + 'module:task-coordination:assign' +);