feat: add task progress command center with snapshot analytics
This commit is contained in:
parent
1a80f5482f
commit
e9e05613f5
@ -88,7 +88,18 @@ public class AnalyticsController {
|
||||
|
||||
return Result.success(analyticsService.getWarningTasks(currentUserId));
|
||||
}
|
||||
|
||||
|
||||
@Operation(summary = "获取全量任务进度总览")
|
||||
@GetMapping("/task-progress/overview")
|
||||
public Result<TaskProgressOverviewVO> getTaskProgressOverview(
|
||||
@RequestParam(required = false, defaultValue = "month") String dateRange,
|
||||
@RequestParam(required = false) String month,
|
||||
@RequestParam(required = false) Long deptId
|
||||
) {
|
||||
log.info("获取全量任务进度总览: dateRange={}, month={}, deptId={}", dateRange, month, deptId);
|
||||
return Result.success(analyticsService.getTaskProgressOverview(dateRange, month, deptId));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取设计师扣分明细")
|
||||
@GetMapping("/designer-deduction-details")
|
||||
public Result<DeductionDetailVO> getDesignerDeductionDetails(
|
||||
@ -177,4 +188,3 @@ public class AnalyticsController {
|
||||
return Result.success(analyticsService.getPrimaryDeptUserRanking(startTime, endTime));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package com.designhub.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("task_progress_snapshot")
|
||||
public class TaskProgressSnapshot implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private LocalDateTime snapshotTime;
|
||||
|
||||
private String metricsJson;
|
||||
|
||||
private String statusDistributionJson;
|
||||
|
||||
private String trendJson;
|
||||
|
||||
private String stageSegmentsJson;
|
||||
|
||||
private String departmentLoadJson;
|
||||
|
||||
private String riskTasksJson;
|
||||
|
||||
private String taskPointsJson;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.designhub.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.designhub.entity.TaskProgressSnapshot;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface TaskProgressSnapshotMapper extends BaseMapper<TaskProgressSnapshot> {
|
||||
}
|
||||
@ -17,6 +17,7 @@ import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -42,6 +43,17 @@ public class AnalyticsService {
|
||||
private final PlatformMapper platformMapper;
|
||||
private final DepartmentService departmentService;
|
||||
private final com.designhub.util.WorkHoursCalculator workHoursCalculator;
|
||||
private final TaskProgressSnapshotService taskProgressSnapshotService;
|
||||
|
||||
private static final Set<String> PENDING_STATUSES = new HashSet<>(Arrays.asList(
|
||||
"pending", "pending_audit", "pending_demand_approval", "pending_receiving_approval"
|
||||
));
|
||||
private static final Set<String> IN_PROGRESS_STATUSES = new HashSet<>(Collections.singletonList("in_progress"));
|
||||
private static final Set<String> REVIEW_STATUSES = new HashSet<>(Arrays.asList("review", "submitted"));
|
||||
private static final Set<String> COMPLETED_STATUSES = new HashSet<>(Collections.singletonList("completed"));
|
||||
private static final Set<String> CANCELED_STATUSES = new HashSet<>(Collections.singletonList("cancelled"));
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/**
|
||||
* 获取需求统计数据(根据角色返回不同维度的数据)
|
||||
@ -1349,6 +1361,14 @@ public class AnalyticsService {
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全量任务进度总览(走快照表,保护在线库)
|
||||
*/
|
||||
public TaskProgressOverviewVO getTaskProgressOverview(String dateRange, String month, Long deptId) {
|
||||
log.info("AnalyticsService#getTaskProgressOverview params dateRange={}, month={}, deptId={}", dateRange, month, deptId);
|
||||
return taskProgressSnapshotService.getOverview(dateRange, month, deptId);
|
||||
}
|
||||
|
||||
private WarningTaskVO buildWarningVO(Task task, String level, String type, String reason, Double hoursInfo) {
|
||||
String receiverName = null;
|
||||
@ -3284,4 +3304,3 @@ public class AnalyticsService {
|
||||
return analysis;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,580 @@
|
||||
package com.designhub.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.designhub.entity.Department;
|
||||
import com.designhub.entity.Task;
|
||||
import com.designhub.entity.TaskProgressSnapshot;
|
||||
import com.designhub.entity.User;
|
||||
import com.designhub.mapper.DepartmentMapper;
|
||||
import com.designhub.mapper.TaskMapper;
|
||||
import com.designhub.mapper.TaskProgressSnapshotMapper;
|
||||
import com.designhub.mapper.UserMapper;
|
||||
import com.designhub.vo.TaskProgressOverviewVO;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TaskProgressSnapshotService {
|
||||
|
||||
private final TaskMapper taskMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final DepartmentMapper departmentMapper;
|
||||
private final TaskProgressSnapshotMapper snapshotMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${analytics.task-progress.snapshot.enabled:true}")
|
||||
private boolean snapshotEnabled;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@Scheduled(cron = "0 0/10 * * * ?")
|
||||
public void scheduledSnapshot() {
|
||||
if (!snapshotEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
buildAndPersistSnapshot();
|
||||
} catch (Exception ex) {
|
||||
log.error("构建任务进度快照失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public TaskProgressOverviewVO getOverview(String dateRange, String month, Long deptId) {
|
||||
TaskProgressOverviewVO overview = loadLatestSnapshot()
|
||||
.orElseGet(this::buildAndPersistSnapshot);
|
||||
return applyFilters(overview, dateRange, month, deptId);
|
||||
}
|
||||
|
||||
private synchronized TaskProgressOverviewVO buildAndPersistSnapshot() {
|
||||
List<Task> tasks = loadTasks();
|
||||
Map<Long, User> userMap = loadUsers(tasks);
|
||||
Map<Long, Department> deptMap = loadDepartments(tasks);
|
||||
|
||||
TaskProgressOverviewVO overview = buildOverview(tasks, userMap, deptMap);
|
||||
persistSnapshot(overview);
|
||||
return overview;
|
||||
}
|
||||
|
||||
private List<Task> loadTasks() {
|
||||
LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
|
||||
.select(
|
||||
Task::getId,
|
||||
Task::getTaskNo,
|
||||
Task::getTitle,
|
||||
Task::getStatus,
|
||||
Task::getProgress,
|
||||
Task::getPriority,
|
||||
Task::getScore,
|
||||
Task::getPublisherId,
|
||||
Task::getPublisherDeptId,
|
||||
Task::getReceiverId,
|
||||
Task::getReceiverDeptId,
|
||||
Task::getCreateTime,
|
||||
Task::getActualStartTime,
|
||||
Task::getStartTime,
|
||||
Task::getSubmitTime,
|
||||
Task::getActualEndTime,
|
||||
Task::getCompleteTime,
|
||||
Task::getDeadline,
|
||||
Task::getAuditTime,
|
||||
Task::getEstimatedHours,
|
||||
Task::getDeleted
|
||||
)
|
||||
.eq(Task::getDeleted, 0)
|
||||
.ne(Task::getStatus, "draft");
|
||||
return taskMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
private Map<Long, User> loadUsers(List<Task> tasks) {
|
||||
Set<Long> userIds = tasks.stream()
|
||||
.flatMap(task -> List.of(task.getPublisherId(), task.getReceiverId()).stream())
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (CollectionUtils.isEmpty(userIds)) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
return userMapper.selectBatchIds(userIds).stream()
|
||||
.collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a));
|
||||
}
|
||||
|
||||
private Map<Long, Department> loadDepartments(List<Task> tasks) {
|
||||
Set<Long> deptIds = tasks.stream()
|
||||
.flatMap(task -> List.of(task.getReceiverDeptId(), task.getPublisherDeptId()).stream())
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (CollectionUtils.isEmpty(deptIds)) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
return departmentMapper.selectBatchIds(deptIds).stream()
|
||||
.collect(Collectors.toMap(Department::getId, dept -> dept, (a, b) -> a));
|
||||
}
|
||||
|
||||
private TaskProgressOverviewVO buildOverview(List<Task> tasks, Map<Long, User> userMap, Map<Long, Department> deptMap) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
Map<String, Long> statusDistribution = tasks.stream()
|
||||
.collect(Collectors.groupingBy(task -> Optional.ofNullable(task.getStatus()).orElse("unknown"), Collectors.counting()));
|
||||
|
||||
long total = tasks.size();
|
||||
long pending = tasks.stream().filter(task -> TaskStatusGroups.PENDING.contains(task.getStatus())).count();
|
||||
long inProgress = tasks.stream().filter(task -> TaskStatusGroups.IN_PROGRESS.contains(task.getStatus())).count();
|
||||
long review = tasks.stream().filter(task -> TaskStatusGroups.REVIEW.contains(task.getStatus())).count();
|
||||
long completed = tasks.stream().filter(task -> TaskStatusGroups.COMPLETED.contains(task.getStatus())).count();
|
||||
long overdue = tasks.stream().filter(task -> isOverdue(task, now)).count();
|
||||
long onTimeCompleted = tasks.stream().filter(task -> isCompletedOnTime(task)).count();
|
||||
|
||||
double completionRate = total > 0 ? completed * 100.0 / total : 0d;
|
||||
double onTimeRate = completed > 0 ? onTimeCompleted * 100.0 / completed : 0d;
|
||||
|
||||
double avgCycle = tasks.stream()
|
||||
.filter(task -> task.getCompleteTime() != null)
|
||||
.mapToDouble(task -> Duration.between(task.getCreateTime(), task.getCompleteTime()).toMinutes() / 60.0)
|
||||
.average()
|
||||
.orElse(0d);
|
||||
|
||||
TaskProgressOverviewVO.KpiMetrics metrics = TaskProgressOverviewVO.KpiMetrics.builder()
|
||||
.totalTasks(total)
|
||||
.pendingTasks(pending)
|
||||
.inProgressTasks(inProgress)
|
||||
.reviewTasks(review)
|
||||
.completedTasks(completed)
|
||||
.overdueTasks(overdue)
|
||||
.completionRate(round(completionRate))
|
||||
.onTimeRate(round(onTimeRate))
|
||||
.avgCycleHours(round(avgCycle))
|
||||
.build();
|
||||
|
||||
List<TaskProgressOverviewVO.TrendPoint> trend = buildTrend(tasks);
|
||||
List<TaskProgressOverviewVO.StageSegment> stageSegments = buildStageSegments(tasks);
|
||||
List<TaskProgressOverviewVO.DepartmentLoad> departmentLoads = buildDepartmentLoads(tasks, deptMap);
|
||||
List<TaskProgressOverviewVO.TaskPoint> taskPoints = buildTaskPoints(tasks, userMap, deptMap, now);
|
||||
List<TaskProgressOverviewVO.RiskTask> riskTasks = buildRiskTasks(taskPoints, now);
|
||||
|
||||
metrics.setRiskTaskCount(riskTasks.size());
|
||||
|
||||
return TaskProgressOverviewVO.builder()
|
||||
.snapshotTime(now)
|
||||
.metrics(metrics)
|
||||
.statusDistribution(statusDistribution.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new)))
|
||||
.trend(trend)
|
||||
.stageSegments(stageSegments)
|
||||
.departmentLoads(departmentLoads)
|
||||
.riskTasks(riskTasks)
|
||||
.taskPoints(taskPoints)
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<TaskProgressOverviewVO.TrendPoint> buildTrend(List<Task> tasks) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate start = today.minusDays(29);
|
||||
Map<String, TaskProgressOverviewVO.TrendPoint> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i < 30; i++) {
|
||||
LocalDate date = start.plusDays(i);
|
||||
map.put(date.format(DATE_FORMATTER), TaskProgressOverviewVO.TrendPoint.builder()
|
||||
.date(date.format(DATE_FORMATTER))
|
||||
.createdCount(0)
|
||||
.completedCount(0)
|
||||
.build());
|
||||
}
|
||||
|
||||
for (Task task : tasks) {
|
||||
if (task.getCreateTime() != null) {
|
||||
LocalDate date = task.getCreateTime().toLocalDate();
|
||||
if (!date.isBefore(start)) {
|
||||
map.computeIfPresent(date.format(DATE_FORMATTER), (k, point) -> {
|
||||
point.setCreatedCount(point.getCreatedCount() + 1);
|
||||
return point;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (task.getCompleteTime() != null) {
|
||||
LocalDate date = task.getCompleteTime().toLocalDate();
|
||||
if (!date.isBefore(start)) {
|
||||
map.computeIfPresent(date.format(DATE_FORMATTER), (k, point) -> {
|
||||
point.setCompletedCount(point.getCompletedCount() + 1);
|
||||
return point;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private List<TaskProgressOverviewVO.StageSegment> buildStageSegments(List<Task> tasks) {
|
||||
AtomicLong approvalMinutes = new AtomicLong();
|
||||
AtomicLong executionMinutes = new AtomicLong();
|
||||
AtomicLong reviewMinutes = new AtomicLong();
|
||||
AtomicLong approvalCount = new AtomicLong();
|
||||
AtomicLong executionCount = new AtomicLong();
|
||||
AtomicLong reviewCount = new AtomicLong();
|
||||
|
||||
tasks.forEach(task -> {
|
||||
if (task.getAuditTime() != null) {
|
||||
approvalMinutes.addAndGet(Duration.between(task.getCreateTime(), task.getAuditTime()).toMinutes());
|
||||
approvalCount.incrementAndGet();
|
||||
}
|
||||
LocalDateTime start = task.getActualStartTime() != null ? task.getActualStartTime() : task.getStartTime();
|
||||
LocalDateTime end = task.getActualEndTime() != null ? task.getActualEndTime() : task.getCompleteTime();
|
||||
if (start != null && end != null) {
|
||||
executionMinutes.addAndGet(Duration.between(start, end).toMinutes());
|
||||
executionCount.incrementAndGet();
|
||||
}
|
||||
if (task.getSubmitTime() != null && task.getCompleteTime() != null) {
|
||||
reviewMinutes.addAndGet(Duration.between(task.getSubmitTime(), task.getCompleteTime()).toMinutes());
|
||||
reviewCount.incrementAndGet();
|
||||
}
|
||||
});
|
||||
|
||||
double approvalAvg = approvalCount.get() == 0 ? 0d : approvalMinutes.get() / 60.0 / approvalCount.get();
|
||||
double executionAvg = executionCount.get() == 0 ? 0d : executionMinutes.get() / 60.0 / executionCount.get();
|
||||
double reviewAvg = reviewCount.get() == 0 ? 0d : reviewMinutes.get() / 60.0 / reviewCount.get();
|
||||
double total = approvalAvg + executionAvg + reviewAvg;
|
||||
|
||||
List<TaskProgressOverviewVO.StageSegment> segments = new ArrayList<>();
|
||||
segments.add(buildStage("审批环节", approvalAvg, total));
|
||||
segments.add(buildStage("制作环节", executionAvg, total));
|
||||
segments.add(buildStage("评审环节", reviewAvg, total));
|
||||
return segments;
|
||||
}
|
||||
|
||||
private TaskProgressOverviewVO.StageSegment buildStage(String stage, double avg, double total) {
|
||||
double percentage = total > 0 ? avg / total * 100 : 0;
|
||||
return TaskProgressOverviewVO.StageSegment.builder()
|
||||
.stage(stage)
|
||||
.avgHours(round(avg))
|
||||
.percentage(round(percentage))
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<TaskProgressOverviewVO.DepartmentLoad> buildDepartmentLoads(List<Task> tasks, Map<Long, Department> deptMap) {
|
||||
Map<Long, TaskProgressOverviewVO.DepartmentLoad> loads = new HashMap<>();
|
||||
for (Task task : tasks) {
|
||||
Long deptId = task.getReceiverDeptId() != null ? task.getReceiverDeptId() : task.getPublisherDeptId();
|
||||
if (deptId == null) {
|
||||
deptId = -1L;
|
||||
}
|
||||
TaskProgressOverviewVO.DepartmentLoad load = loads.computeIfAbsent(deptId, id -> {
|
||||
String name = id == -1L ? "未分配" : deptMap.getOrDefault(id, new Department()).getDeptName();
|
||||
return TaskProgressOverviewVO.DepartmentLoad.builder()
|
||||
.deptId(id)
|
||||
.deptName(name != null ? name : "未知部门")
|
||||
.build();
|
||||
});
|
||||
|
||||
String status = Optional.ofNullable(task.getStatus()).orElse("unknown");
|
||||
if (TaskStatusGroups.PENDING.contains(status)) {
|
||||
load.setPendingCount(load.getPendingCount() + 1);
|
||||
} else if (TaskStatusGroups.IN_PROGRESS.contains(status)) {
|
||||
load.setInProgressCount(load.getInProgressCount() + 1);
|
||||
} else if (TaskStatusGroups.REVIEW.contains(status)) {
|
||||
load.setReviewCount(load.getReviewCount() + 1);
|
||||
} else if (TaskStatusGroups.COMPLETED.contains(status)) {
|
||||
load.setCompletedCount(load.getCompletedCount() + 1);
|
||||
}
|
||||
if (isOverdue(task, LocalDateTime.now())) {
|
||||
load.setOverdueCount(load.getOverdueCount() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
loads.values().forEach(load -> {
|
||||
double workloadIndex = load.getPendingCount() * 0.5
|
||||
+ load.getInProgressCount()
|
||||
+ load.getReviewCount() * 0.8
|
||||
+ load.getOverdueCount() * 1.2;
|
||||
load.setWorkloadIndex(round(workloadIndex));
|
||||
});
|
||||
|
||||
return loads.values().stream()
|
||||
.sorted(Comparator.comparingDouble(TaskProgressOverviewVO.DepartmentLoad::getWorkloadIndex).reversed())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<TaskProgressOverviewVO.TaskPoint> buildTaskPoints(List<Task> tasks, Map<Long, User> userMap, Map<Long, Department> deptMap, LocalDateTime now) {
|
||||
return tasks.stream()
|
||||
.sorted(Comparator.comparing(Task::getCreateTime).reversed())
|
||||
.limit(200)
|
||||
.map(task -> {
|
||||
User publisher = task.getPublisherId() != null ? userMap.get(task.getPublisherId()) : null;
|
||||
User receiver = task.getReceiverId() != null ? userMap.get(task.getReceiverId()) : null;
|
||||
Long deptId = task.getReceiverDeptId() != null ? task.getReceiverDeptId() : task.getPublisherDeptId();
|
||||
Department dept = deptId != null ? deptMap.get(deptId) : null;
|
||||
return TaskProgressOverviewVO.TaskPoint.builder()
|
||||
.taskId(task.getId())
|
||||
.deptId(deptId)
|
||||
.taskNo(task.getTaskNo())
|
||||
.title(task.getTitle())
|
||||
.status(task.getStatus())
|
||||
.progress(task.getProgress() != null ? task.getProgress() : 0)
|
||||
.priority(task.getPriority())
|
||||
.score(task.getScore())
|
||||
.publisherName(publisher != null ? publisher.getRealName() : "")
|
||||
.receiverName(receiver != null ? receiver.getRealName() : "")
|
||||
.deptName(dept != null ? dept.getDeptName() : "未分配")
|
||||
.createTime(task.getCreateTime())
|
||||
.deadline(task.getDeadline())
|
||||
.overdue(isOverdue(task, now))
|
||||
.nearDeadline(isNearDeadline(task, now))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<TaskProgressOverviewVO.RiskTask> buildRiskTasks(List<TaskProgressOverviewVO.TaskPoint> taskPoints, LocalDateTime now) {
|
||||
List<TaskProgressOverviewVO.RiskTask> risks = new LinkedList<>();
|
||||
for (TaskProgressOverviewVO.TaskPoint point : taskPoints) {
|
||||
if (point.isOverdue() || point.isNearDeadline()) {
|
||||
TaskProgressOverviewVO.RiskTask risk = TaskProgressOverviewVO.RiskTask.builder()
|
||||
.taskId(point.getTaskId())
|
||||
.deptId(point.getDeptId())
|
||||
.taskNo(point.getTaskNo())
|
||||
.title(point.getTitle())
|
||||
.status(point.getStatus())
|
||||
.deptName(point.getDeptName())
|
||||
.ownerName(point.getReceiverName())
|
||||
.deadline(point.getDeadline())
|
||||
.createTime(point.getCreateTime())
|
||||
.warningType(point.isOverdue() ? "overdue" : "near_deadline")
|
||||
.warningReason(point.isOverdue() ? "任务已超时" : "任务即将超时")
|
||||
.severity(point.isOverdue() ? "danger" : "warning")
|
||||
.build();
|
||||
risks.add(risk);
|
||||
}
|
||||
}
|
||||
return risks.stream()
|
||||
.sorted(Comparator.comparing(TaskProgressOverviewVO.RiskTask::getSeverity)
|
||||
.thenComparing(risk -> Optional.ofNullable(risk.getDeadline()).orElse(now)))
|
||||
.limit(20)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private void persistSnapshot(TaskProgressOverviewVO overview) {
|
||||
try {
|
||||
TaskProgressSnapshot snapshot = new TaskProgressSnapshot();
|
||||
snapshot.setSnapshotTime(LocalDateTime.now());
|
||||
snapshot.setMetricsJson(objectMapper.writeValueAsString(overview.getMetrics()));
|
||||
snapshot.setStatusDistributionJson(objectMapper.writeValueAsString(overview.getStatusDistribution()));
|
||||
snapshot.setTrendJson(objectMapper.writeValueAsString(overview.getTrend()));
|
||||
snapshot.setStageSegmentsJson(objectMapper.writeValueAsString(overview.getStageSegments()));
|
||||
snapshot.setDepartmentLoadJson(objectMapper.writeValueAsString(overview.getDepartmentLoads()));
|
||||
snapshot.setRiskTasksJson(objectMapper.writeValueAsString(overview.getRiskTasks()));
|
||||
snapshot.setTaskPointsJson(objectMapper.writeValueAsString(overview.getTaskPoints()));
|
||||
snapshotMapper.insert(snapshot);
|
||||
} catch (Exception ex) {
|
||||
log.error("写入任务进度快照失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<TaskProgressOverviewVO> loadLatestSnapshot() {
|
||||
LambdaQueryWrapper<TaskProgressSnapshot> wrapper = new LambdaQueryWrapper<TaskProgressSnapshot>()
|
||||
.orderByDesc(TaskProgressSnapshot::getSnapshotTime)
|
||||
.last("LIMIT 1");
|
||||
TaskProgressSnapshot snapshot = snapshotMapper.selectOne(wrapper);
|
||||
if (snapshot == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(TaskProgressOverviewVO.builder()
|
||||
.snapshotTime(snapshot.getSnapshotTime())
|
||||
.metrics(read(snapshot.getMetricsJson(), TaskProgressOverviewVO.KpiMetrics.class))
|
||||
.statusDistribution(read(snapshot.getStatusDistributionJson(), new TypeReference<Map<String, Long>>() {}))
|
||||
.trend(read(snapshot.getTrendJson(), new TypeReference<List<TaskProgressOverviewVO.TrendPoint>>() {}))
|
||||
.stageSegments(read(snapshot.getStageSegmentsJson(), new TypeReference<List<TaskProgressOverviewVO.StageSegment>>() {}))
|
||||
.departmentLoads(read(snapshot.getDepartmentLoadJson(), new TypeReference<List<TaskProgressOverviewVO.DepartmentLoad>>() {}))
|
||||
.riskTasks(read(snapshot.getRiskTasksJson(), new TypeReference<List<TaskProgressOverviewVO.RiskTask>>() {}))
|
||||
.taskPoints(read(snapshot.getTaskPointsJson(), new TypeReference<List<TaskProgressOverviewVO.TaskPoint>>() {}))
|
||||
.build());
|
||||
} catch (Exception ex) {
|
||||
log.error("解析任务进度快照失败", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T read(String json, TypeReference<T> type) throws Exception {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
return objectMapper.readValue(json, type);
|
||||
}
|
||||
|
||||
private TaskProgressOverviewVO applyFilters(TaskProgressOverviewVO overview, String dateRange, String month, Long deptId) {
|
||||
if (overview == null) {
|
||||
return null;
|
||||
}
|
||||
LocalDateTime start = resolveStart(dateRange, month);
|
||||
LocalDateTime end = resolveEnd(month);
|
||||
|
||||
List<TaskProgressOverviewVO.TrendPoint> filteredTrend = Optional.ofNullable(overview.getTrend())
|
||||
.map(ArrayList::new)
|
||||
.orElseGet(ArrayList::new);
|
||||
if (start != null) {
|
||||
filteredTrend = filteredTrend.stream()
|
||||
.filter(point -> {
|
||||
LocalDate date = LocalDate.parse(point.getDate(), DATE_FORMATTER);
|
||||
return !date.atStartOfDay().isBefore(start);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
List<TaskProgressOverviewVO.TaskPoint> filteredTaskPoints = Optional.ofNullable(overview.getTaskPoints())
|
||||
.map(ArrayList::new)
|
||||
.orElseGet(ArrayList::new);
|
||||
if (start != null) {
|
||||
filteredTaskPoints = filteredTaskPoints.stream()
|
||||
.filter(point -> point.getCreateTime() == null || !point.getCreateTime().isBefore(start))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (end != null) {
|
||||
filteredTaskPoints = filteredTaskPoints.stream()
|
||||
.filter(point -> point.getCreateTime() == null || !point.getCreateTime().isAfter(end))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
TaskProgressOverviewVO.KpiMetrics metrics = overview.getMetrics();
|
||||
List<TaskProgressOverviewVO.DepartmentLoad> deptLoads = Optional.ofNullable(overview.getDepartmentLoads())
|
||||
.map(ArrayList::new)
|
||||
.orElseGet(ArrayList::new);
|
||||
List<TaskProgressOverviewVO.RiskTask> filteredRisks = Optional.ofNullable(overview.getRiskTasks())
|
||||
.map(ArrayList::new)
|
||||
.orElseGet(ArrayList::new);
|
||||
if (deptId != null) {
|
||||
deptLoads = deptLoads.stream()
|
||||
.filter(load -> load.getDeptId() != null && load.getDeptId().equals(deptId))
|
||||
.collect(Collectors.toList());
|
||||
if (!deptLoads.isEmpty()) {
|
||||
TaskProgressOverviewVO.DepartmentLoad load = deptLoads.get(0);
|
||||
long totalTasks = load.getPendingCount() + load.getInProgressCount() + load.getReviewCount() + load.getCompletedCount();
|
||||
double completionRate = totalTasks == 0 ? 0 : round((double) load.getCompletedCount() * 100 / totalTasks);
|
||||
metrics = TaskProgressOverviewVO.KpiMetrics.builder()
|
||||
.totalTasks(totalTasks)
|
||||
.pendingTasks(load.getPendingCount())
|
||||
.inProgressTasks(load.getInProgressCount())
|
||||
.reviewTasks(load.getReviewCount())
|
||||
.completedTasks(load.getCompletedCount())
|
||||
.overdueTasks(load.getOverdueCount())
|
||||
.completionRate(completionRate)
|
||||
.onTimeRate(overview.getMetrics().getOnTimeRate())
|
||||
.avgCycleHours(overview.getMetrics().getAvgCycleHours())
|
||||
.riskTaskCount(filteredRisks.stream()
|
||||
.filter(risk -> deptId.equals(risk.getDeptId()))
|
||||
.count())
|
||||
.build();
|
||||
}
|
||||
Long targetDeptId = deptId;
|
||||
filteredTaskPoints = filteredTaskPoints.stream()
|
||||
.filter(point -> Objects.equals(point.getDeptId(), targetDeptId))
|
||||
.collect(Collectors.toList());
|
||||
filteredRisks = filteredRisks.stream()
|
||||
.filter(risk -> Objects.equals(risk.getDeptId(), targetDeptId))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (metrics != null) {
|
||||
metrics.setRiskTaskCount(filteredRisks != null ? filteredRisks.size() : 0);
|
||||
}
|
||||
|
||||
return TaskProgressOverviewVO.builder()
|
||||
.snapshotTime(overview.getSnapshotTime())
|
||||
.metrics(metrics)
|
||||
.statusDistribution(overview.getStatusDistribution())
|
||||
.trend(filteredTrend)
|
||||
.stageSegments(overview.getStageSegments())
|
||||
.departmentLoads(deptLoads)
|
||||
.riskTasks(filteredRisks)
|
||||
.taskPoints(filteredTaskPoints)
|
||||
.build();
|
||||
}
|
||||
|
||||
private LocalDateTime resolveStart(String dateRange, String month) {
|
||||
if (month != null && !month.isEmpty()) {
|
||||
LocalDate date = LocalDate.parse(month + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
return date.atStartOfDay();
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (dateRange == null) {
|
||||
return null;
|
||||
}
|
||||
switch (dateRange) {
|
||||
case "week":
|
||||
return now.minusWeeks(1);
|
||||
case "month":
|
||||
return now.minusMonths(1);
|
||||
case "quarter":
|
||||
return now.minusMonths(3);
|
||||
case "year":
|
||||
return now.minusYears(1);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime resolveEnd(String month) {
|
||||
if (month == null || month.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
LocalDate date = LocalDate.parse(month + "-01", DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
return date.with(TemporalAdjusters.lastDayOfMonth()).atTime(LocalTime.MAX);
|
||||
}
|
||||
|
||||
private boolean isOverdue(Task task, LocalDateTime now) {
|
||||
return task.getDeadline() != null && task.getDeadline().isBefore(now)
|
||||
&& !TaskStatusGroups.COMPLETED.contains(task.getStatus());
|
||||
}
|
||||
|
||||
private boolean isNearDeadline(Task task, LocalDateTime now) {
|
||||
return task.getDeadline() != null
|
||||
&& !TaskStatusGroups.COMPLETED.contains(task.getStatus())
|
||||
&& Duration.between(now, task.getDeadline()).toHours() <= 12
|
||||
&& Duration.between(now, task.getDeadline()).toMinutes() >= 0;
|
||||
}
|
||||
|
||||
private boolean isCompletedOnTime(Task task) {
|
||||
if (!TaskStatusGroups.COMPLETED.contains(task.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (task.getDeadline() == null || task.getCompleteTime() == null) {
|
||||
return true;
|
||||
}
|
||||
return !task.getCompleteTime().isAfter(task.getDeadline());
|
||||
}
|
||||
|
||||
private double round(double value) {
|
||||
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
|
||||
}
|
||||
|
||||
private static class TaskStatusGroups {
|
||||
private static final Set<String> PENDING = Set.of("pending", "pending_audit", "pending_demand_approval", "pending_receiving_approval");
|
||||
private static final Set<String> IN_PROGRESS = Set.of("in_progress");
|
||||
private static final Set<String> REVIEW = Set.of("review", "submitted");
|
||||
private static final Set<String> COMPLETED = Set.of("completed");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package com.designhub.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 全量任务进度总览 VO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TaskProgressOverviewVO {
|
||||
|
||||
private LocalDateTime snapshotTime;
|
||||
private KpiMetrics metrics;
|
||||
private Map<String, Long> statusDistribution;
|
||||
private List<TrendPoint> trend;
|
||||
private List<StageSegment> stageSegments;
|
||||
private List<DepartmentLoad> departmentLoads;
|
||||
private List<RiskTask> riskTasks;
|
||||
private List<TaskPoint> taskPoints;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class KpiMetrics {
|
||||
private long totalTasks;
|
||||
private long pendingTasks;
|
||||
private long inProgressTasks;
|
||||
private long reviewTasks;
|
||||
private long completedTasks;
|
||||
private long overdueTasks;
|
||||
private double completionRate;
|
||||
private double onTimeRate;
|
||||
private double avgCycleHours;
|
||||
private long riskTaskCount;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class TrendPoint {
|
||||
private String date;
|
||||
private long createdCount;
|
||||
private long completedCount;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class StageSegment {
|
||||
private String stage;
|
||||
private double avgHours;
|
||||
private double percentage;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class DepartmentLoad {
|
||||
private Long deptId;
|
||||
private String deptName;
|
||||
private long pendingCount;
|
||||
private long inProgressCount;
|
||||
private long reviewCount;
|
||||
private long completedCount;
|
||||
private long overdueCount;
|
||||
private double workloadIndex;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class RiskTask {
|
||||
private Long taskId;
|
||||
private Long deptId;
|
||||
private String taskNo;
|
||||
private String title;
|
||||
private String status;
|
||||
private String warningType;
|
||||
private String warningReason;
|
||||
private String severity;
|
||||
private String deptName;
|
||||
private String ownerName;
|
||||
private LocalDateTime deadline;
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class TaskPoint {
|
||||
private Long taskId;
|
||||
private Long deptId;
|
||||
private String taskNo;
|
||||
private String title;
|
||||
private String status;
|
||||
private Integer progress;
|
||||
private Integer priority;
|
||||
private BigDecimal score;
|
||||
private String publisherName;
|
||||
private String receiverName;
|
||||
private String deptName;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime deadline;
|
||||
private boolean overdue;
|
||||
private boolean nearDeadline;
|
||||
}
|
||||
}
|
||||
@ -97,3 +97,8 @@ springdoc:
|
||||
path: /swagger-ui.html
|
||||
api-docs:
|
||||
enabled: true
|
||||
|
||||
analytics:
|
||||
task-progress:
|
||||
snapshot:
|
||||
enabled: true
|
||||
|
||||
@ -104,3 +104,7 @@ springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
|
||||
analytics:
|
||||
task-progress:
|
||||
snapshot:
|
||||
enabled: true
|
||||
|
||||
14
database/V16__task_progress_snapshot.sql
Normal file
14
database/V16__task_progress_snapshot.sql
Normal file
@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS `task_progress_snapshot` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`snapshot_time` DATETIME NOT NULL,
|
||||
`metrics_json` TEXT,
|
||||
`status_distribution_json` TEXT,
|
||||
`trend_json` TEXT,
|
||||
`stage_segments_json` TEXT,
|
||||
`department_load_json` MEDIUMTEXT,
|
||||
`risk_tasks_json` MEDIUMTEXT,
|
||||
`task_points_json` MEDIUMTEXT,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_snapshot_time` (`snapshot_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@ -430,3 +430,19 @@ CREATE INDEX idx_task_status_time ON `task` (`status`, `create_time`);
|
||||
CREATE INDEX idx_score_record_time ON `score_record` (`create_time`, `change_type`);
|
||||
CREATE INDEX idx_user_dept ON `sys_user` (`dept_id`, `status`);
|
||||
|
||||
|
||||
-- Task progress snapshot table
|
||||
DROP TABLE IF EXISTS `task_progress_snapshot`;
|
||||
CREATE TABLE `task_progress_snapshot` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`snapshot_time` DATETIME NOT NULL,
|
||||
`metrics_json` TEXT,
|
||||
`status_distribution_json` TEXT,
|
||||
`trend_json` TEXT,
|
||||
`stage_segments_json` TEXT,
|
||||
`department_load_json` MEDIUMTEXT,
|
||||
`risk_tasks_json` MEDIUMTEXT,
|
||||
`task_points_json` MEDIUMTEXT,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
154
docs/全量任务进度总览方案.md
Normal file
154
docs/全量任务进度总览方案.md
Normal file
@ -0,0 +1,154 @@
|
||||
# 全量任务进度可视化方案
|
||||
|
||||
> **Objective**:在“数据分析”模块新增一个“全量任务进度”页面,打造全角色可感知的进度指挥舱,实时洞察任务流转健康度,推动跨部门协作效率。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与成功指标
|
||||
|
||||
### 1.1 现状痛点
|
||||
- 任务状态分散在大厅、看板、审批列表,缺乏“一眼就懂”的整体态势。
|
||||
- 不同角色(需求方、接单部门、管理层)关注点不一致,难以共享同一数据语境。
|
||||
- 目前图表偏重统计分析,缺少“即时、故事化”的进度故事线,看不到卡点与追溯链路。
|
||||
|
||||
### 1.2 目标
|
||||
| 维度 | 目标 | 衡量指标 |
|
||||
| --- | --- | --- |
|
||||
| **透明度** | 任何人都能 5 秒内定位关键任务状态 | 页面跳出率 < 20%,核心指标点击率 > 60% |
|
||||
| **预警力** | 自动暴露卡点、积压、超时 | 每日自动推送 ≥ 1 条有效预警,超时识别准确率 > 90% |
|
||||
| **决策效率** | 管理层能够据此分配资源 | 需求到指派均值缩短 15%,跨部门协调反馈时间 < 30 min |
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户场景 & 信息需求
|
||||
|
||||
| 角色 | 关键问题 | 页面映射区 |
|
||||
| --- | --- | --- |
|
||||
| 运营主管 | “本周有哪些风险任务?瓶颈在哪?” | 顶部 Executive Overview + 风险流视图 |
|
||||
| 需求方 | “我发起的需求走到哪一步了?” | 我的任务筛选 + 全流程时间线 |
|
||||
| 接单部门负责人 | “团队负载如何?谁需要支援?” | 部门热力矩阵 + 设计师负载面板 |
|
||||
| 设计师 | “我手上的任务优先级?” | 个人进度雀巢卡片 + 待办序列 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 页面信息架构(Single Page / 4 Panels)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ HERO: 全局状态光幕 │
|
||||
│ • 4 颗氛围化 KPI(完成率、准时率、风险任务、平均周期) │
|
||||
│ • 实时流光进度条 + 时间刻度 │
|
||||
├─────────────────────────────┬──────────────────────────────────────────┤
|
||||
│ Left Column │ Right Column │
|
||||
│ 1. “任务银河图” (Galaxy Scatter) │ 3. “部门热力矩阵” (Treemap + Sortable) │
|
||||
│ - 每个节点 = 任务,颜色 = 状态,亮度 = 优先级 │ - 行: 接单部门;列: 状态(待审/处理中/已完结) │
|
||||
│ - 支持框选筛选、磁悬浮显示详情 │ - 点击可 Drill 到设计师列表 │
|
||||
│ 2. “流程轨迹时间线” (Segmented Timeline) │ 4. “风险雷达墙” (Stacked Cards) │
|
||||
│ - 展示草稿→审批→接单→生产→交付→完结多段耗时 │ - 模块含“即将超时”“积压”“阻塞审批”“过载设计师” │
|
||||
│ - 节点颜色对应 SLA,长段高亮 │ - 每张卡片可展开处理建议、责任人、快捷操作 │
|
||||
└─────────────────────────────┴──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **Interaction DNA**:所有模块共享统一筛选(时间、部门、任务类型、优先级)。跨模块联动——例如在银河图框选结果,时间线与热力矩阵同步聚焦,只看选中集群的数据。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据指标体系
|
||||
|
||||
### 4.1 全局 KPI
|
||||
| 指标 | 计算逻辑 | 刷新策略 |
|
||||
| --- | --- | --- |
|
||||
| 完成率 | `completed_tasks / total_tasks` | 每 5 分钟刷新(Redis 缓存 30s) |
|
||||
| 准时率 | `on_time_completed / completed_tasks` | 同上 |
|
||||
| 风险任务数 | `status in (pending,pending_approval,in_progress) AND risk_flag = 1` | 实时(WebSocket Push) |
|
||||
| 平均任务周期 | `AVG(completed_at - created_at)` | ETL 批量 + 增量修正 |
|
||||
|
||||
### 4.2 模块指标
|
||||
|
||||
- **任务银河图**:X 轴=任务创建时间,Y 轴=当前阶段进度百分比,点大小=任务积分,点轮廓=任务类型。
|
||||
- **流程轨迹时间线**:拆分 6 个里程碑节点,记录耗时、责任角色、最近操作人;支持查看历史版本(驳回重开)。
|
||||
- **部门热力矩阵**:使用 `task_status_aggregate` 物化视图,提供 `pending_count`、`in_progress_count`、`overdue_count`、`avg_cycle`。
|
||||
- **风险雷达墙**:统一 `risk_center` 表,写入风险类型、触发条件、建议动作、过期时间,提供“标记已处理”动作回写。
|
||||
|
||||
---
|
||||
|
||||
## 5. 视觉与交互设计语言
|
||||
|
||||
- **风格定位**:Futuristic Command Center(深色渐变基底 + 霓虹强调色 + 玻璃拟态面板),与现有抢单系统蓝绿主色保持一致。
|
||||
- **色板**:
|
||||
- 背景:`#050B16` → `#0F1A2A` 垂直渐变。
|
||||
- 主按钮 / 高亮:蓝 (`#3C7EFF`)、青 (`#24D1C4`)。
|
||||
- 风险态:预警琥珀 (`#F6A623`)、超时红 (`#FF5F6D`)。
|
||||
- **组件特性**:
|
||||
- KPI 光幕采用“数字雨”动画轻量循环。
|
||||
- 图表 hover 触发“光晕扩散”与磁性对齐,增强科技感。
|
||||
- 交互手势:框选、拖拽、命中列表项后右侧弹出“Action Drawer”(指派、催办、备注)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术实现方案
|
||||
|
||||
### 6.1 数据层
|
||||
- **实时数据**:借助 RocketMQ / Redis Stream 推送任务状态流,写入 `task_progress_stream` 缓存表;WebSocket 服务向前端推送。
|
||||
- **离线指标**:用现有 ETL(Spark / Flink)产出 `task_daily_metrics`、`dept_load_snapshot`,每小时重算。
|
||||
- **API 设计**:
|
||||
- `GET /api/analytics/task-progress/overview` → 全局 KPI + 过滤条件。
|
||||
- `GET /api/analytics/task-progress/galaxy` → 支持聚合/分页。
|
||||
- `GET /api/analytics/task-progress/timeline/{taskId}` → 单任务轨迹。
|
||||
- `GET /api/analytics/task-progress/risks` → 风险列表,支持 POST 标记处理。
|
||||
|
||||
### 6.2 前端
|
||||
- 基于现有前端栈(React + AntV/G2 + GPU Shader)。
|
||||
- Galaxy 图建议用 `three.js + instanced mesh` 渲染,带深度模糊和 Bloom 效果。
|
||||
- Timeline 使用 `AntV S2 + 自定义节点`,支持展开子任务。
|
||||
- 状态同步:利用 Zustand/Redux store 管理筛选器,所有模块订阅同一 slice。
|
||||
|
||||
### 6.3 性能与稳定性
|
||||
- 页面首屏加载目标 < 2.5s(拆分 Graph 组件延迟加载)。
|
||||
- WebSocket 消息控制在 1s 频率,过多聚合进入节流缓冲。
|
||||
- 错误兜底:当 3D 模块渲染失败,自动回退为二维气泡图。
|
||||
|
||||
---
|
||||
|
||||
## 7. 交互流程示例
|
||||
1. 用户进入页面,定位在“全量模式”,看到 KPI、银河图。
|
||||
2. 通过顶部筛选器选择“品牌视觉部 + 本周”。
|
||||
3. 框选银河图左下角一片积压任务 → 时间线立刻只展示这些任务,风险雷达同步列出责任人。
|
||||
4. 点击风险卡片“即将超时” → 右侧 Drawer 打开,可直接指派增援或发送钉钉催办。
|
||||
5. 全局面包屑支持“复制分享链接”到协作群。
|
||||
|
||||
---
|
||||
|
||||
## 8. 迭代节奏
|
||||
| Sprint | 目标 | 可交付 |
|
||||
| --- | --- | --- |
|
||||
| Sprint 1 | 数据接口梳理、Galaxy 原型(静态数据) | Mock API、Figma 高保真、接口契约文档 |
|
||||
| Sprint 2 | 指标计算、风险中心、WebSocket | 数据服务上线、风险推送、后端压测报告 |
|
||||
| Sprint 3 | 交互细节、性能优化、可用性测试 | 3D/2D 回退策略、动效资产、用户访谈报告 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
- 指标计算与任务列表结果一致,误差 < 1%。
|
||||
- 关键图表响应时间 < 400ms(筛选后首帧)。
|
||||
- 风险卡片点击后 3 秒内同步任务详情。
|
||||
- 用户可一键导出选中任务(CSV)且维持筛选条件。
|
||||
- 通过 5 位核心角色可用性测试(SUS ≥ 80)。
|
||||
|
||||
---
|
||||
|
||||
> 下一步:确认视觉走查与数据接口排期,敲定 Galaxy/Timeline 组件技术选型,然后可进入 UI 高保真与前后端并行开发阶段。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施落地记录(2026-02-06)
|
||||
|
||||
- **数据层**:新增 `task_progress_snapshot` 结构(V16 migration + schema.sql),由 `TaskProgressSnapshotService` 以 10 分钟定时快照方式物化 KPI、趋势、银河节点数据,默认可通过 `analytics.task-progress.snapshot.enabled` 开启/关闭;快照写入使用 JSON Blob,避免在线表扫描。
|
||||
- **服务层**:
|
||||
- `TaskProgressOverviewVO` 增补 `snapshotTime`、`deptId` 字段,支持部门过滤后返回一致的风险计数。
|
||||
- `TaskProgressSnapshotService` 提供 `getOverview(dateRange, month, deptId)`,并在 `AnalyticsService#getTaskProgressOverview` 暴露 REST API `/api/analytics/task-progress/overview`。
|
||||
- **定时与配置**:`@Scheduled` 表达式 `0 0/10 * * * ?`,可通过 `application-*.yml` 中 `analytics.task-progress.snapshot.enabled` 配置热切换,便于压测或紧急关闭。
|
||||
- **前端指挥舱**:
|
||||
- 新增路由 `#/analytics/task-progress`,页面 `TaskProgressOverview.vue` 使用 ECharts + 玻璃拟态布局,包含 KPI 光幕、任务银河图、阶段时间线、部门热力矩阵与风险雷达墙。
|
||||
- `Analytics.vue` 增设 CTA(“全量任务进度指挥舱”)引导用户进入新页面,视觉语言与方案一致。
|
||||
- **上线建议**:先执行数据库迁移 → 开启定时快照并观察负载 → 预热至少 2 轮快照后灰度开放前端入口;若需停用可仅关闭配置并保留前端入口提示。
|
||||
@ -196,6 +196,94 @@ export interface WarningTask {
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ==================== 全量任务进度类型 ====================
|
||||
|
||||
export interface TaskProgressMetrics {
|
||||
totalTasks: number;
|
||||
pendingTasks: number;
|
||||
inProgressTasks: number;
|
||||
reviewTasks: number;
|
||||
completedTasks: number;
|
||||
overdueTasks: number;
|
||||
completionRate: number;
|
||||
onTimeRate: number;
|
||||
avgCycleHours: number;
|
||||
riskTaskCount: number;
|
||||
}
|
||||
|
||||
export interface TaskProgressTrendPoint {
|
||||
date: string;
|
||||
createdCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
export interface TaskProgressStageSegment {
|
||||
stage: string;
|
||||
avgHours: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface TaskProgressDepartmentLoad {
|
||||
deptId: number;
|
||||
deptName: string;
|
||||
pendingCount: number;
|
||||
inProgressCount: number;
|
||||
reviewCount: number;
|
||||
completedCount: number;
|
||||
overdueCount: number;
|
||||
workloadIndex: number;
|
||||
}
|
||||
|
||||
export interface TaskProgressRiskTask {
|
||||
taskId: number;
|
||||
deptId?: number;
|
||||
taskNo: string;
|
||||
title: string;
|
||||
status: string;
|
||||
warningType: string;
|
||||
warningReason: string;
|
||||
severity: 'danger' | 'warning' | string;
|
||||
deptName: string;
|
||||
ownerName: string;
|
||||
deadline?: string;
|
||||
createTime?: string;
|
||||
}
|
||||
|
||||
export interface TaskProgressTaskPoint {
|
||||
taskId: number;
|
||||
deptId?: number;
|
||||
taskNo: string;
|
||||
title: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
priority?: number;
|
||||
score?: number;
|
||||
publisherName?: string;
|
||||
receiverName?: string;
|
||||
deptName?: string;
|
||||
createTime?: string;
|
||||
deadline?: string;
|
||||
overdue?: boolean;
|
||||
nearDeadline?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskProgressOverview {
|
||||
snapshotTime?: string;
|
||||
metrics: TaskProgressMetrics;
|
||||
statusDistribution: Record<string, number>;
|
||||
trend: TaskProgressTrendPoint[];
|
||||
stageSegments: TaskProgressStageSegment[];
|
||||
departmentLoads: TaskProgressDepartmentLoad[];
|
||||
riskTasks: TaskProgressRiskTask[];
|
||||
taskPoints: TaskProgressTaskPoint[];
|
||||
}
|
||||
|
||||
export interface TaskProgressOverviewQuery {
|
||||
dateRange?: string;
|
||||
month?: string;
|
||||
deptId?: number;
|
||||
}
|
||||
|
||||
// ==================== API函数 ====================
|
||||
|
||||
/**
|
||||
@ -356,6 +444,50 @@ export const getWarningTasks = async (): Promise<WarningTask[]> => {
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取全量任务进度概览
|
||||
*/
|
||||
const emptyMetrics: TaskProgressMetrics = {
|
||||
totalTasks: 0,
|
||||
pendingTasks: 0,
|
||||
inProgressTasks: 0,
|
||||
reviewTasks: 0,
|
||||
completedTasks: 0,
|
||||
overdueTasks: 0,
|
||||
completionRate: 0,
|
||||
onTimeRate: 0,
|
||||
avgCycleHours: 0,
|
||||
riskTaskCount: 0
|
||||
};
|
||||
|
||||
export const getTaskProgressOverview = async (
|
||||
params?: TaskProgressOverviewQuery
|
||||
): Promise<TaskProgressOverview> => {
|
||||
const response = await request.get('/analytics/task-progress/overview', {
|
||||
params: params || { dateRange: 'month' }
|
||||
});
|
||||
|
||||
if (response && typeof response === 'object') {
|
||||
if ('data' in response && response.data !== undefined && response.data !== null) {
|
||||
return response.data as TaskProgressOverview;
|
||||
}
|
||||
if ('metrics' in response || 'taskPoints' in response) {
|
||||
return response as unknown as TaskProgressOverview;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
snapshotTime: undefined,
|
||||
metrics: emptyMetrics,
|
||||
statusDistribution: {},
|
||||
trend: [],
|
||||
stageSegments: [],
|
||||
departmentLoads: [],
|
||||
riskTasks: [],
|
||||
taskPoints: []
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 扣分明细类型 ====================
|
||||
|
||||
export interface DelayDeductionItem {
|
||||
|
||||
@ -57,6 +57,14 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '数据分析'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/analytics/task-progress',
|
||||
name: 'TaskProgressOverview',
|
||||
component: () => import('@/views/TaskProgressOverview.vue'),
|
||||
meta: {
|
||||
title: '任务进度指挥舱'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
@ -289,4 +297,3 @@ router.beforeEach(async (to, from, next) => {
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@ -37,6 +37,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-cta">
|
||||
<div class="cta-left">
|
||||
<div class="cta-label">指挥舱升级</div>
|
||||
<h2>全量任务进度 · 银河视图现已开放</h2>
|
||||
<p>沉浸式 3D 任务银河、阶段时间线与部门热力矩阵,帮助管理层 5 秒锁定瓶颈。</p>
|
||||
</div>
|
||||
<div class="cta-actions">
|
||||
<a-button ghost size="large" @click="goToTaskProgress">
|
||||
<template #icon><Icon name="arrowRight" size="sm" /></template>
|
||||
打开任务进度指挥舱
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 核心指标卡片 -->
|
||||
<div class="overview-section">
|
||||
@ -1049,6 +1063,10 @@ const selectedMonth = ref<string | null>(null)
|
||||
const dateRange = ref('month')
|
||||
const activeTab = ref('demand')
|
||||
|
||||
const goToTaskProgress = () => {
|
||||
router.push('/analytics/task-progress')
|
||||
}
|
||||
|
||||
// 数据状态
|
||||
const statistics = reactive<DemandStatistics>({
|
||||
overview: {
|
||||
@ -2312,6 +2330,81 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.progress-cta {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $space-xl;
|
||||
margin-bottom: $space-xl;
|
||||
border-radius: $radius-xl;
|
||||
background: linear-gradient(135deg, rgba(32, 81, 255, 0.35), rgba(2, 222, 213, 0.28));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 40px rgba(34, 67, 255, 0.25);
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at top right, rgba(255, 255, 255, 0.2), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cta-left {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.cta-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: $space-sm;
|
||||
color: #e8f1ff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $space-xs 0;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.cta-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.ant-btn {
|
||||
border-color: rgba(255, 255, 255, 0.65);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
padding: 0 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: #24d1c4;
|
||||
color: #24d1c4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $space-md;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
|
||||
1099
frontend/src/views/TaskProgressOverview.vue
Normal file
1099
frontend/src/views/TaskProgressOverview.vue
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user