diff --git a/backend/src/main/java/com/designhub/controller/AnalyticsController.java b/backend/src/main/java/com/designhub/controller/AnalyticsController.java index 0ddfb3e..4798607 100644 --- a/backend/src/main/java/com/designhub/controller/AnalyticsController.java +++ b/backend/src/main/java/com/designhub/controller/AnalyticsController.java @@ -88,7 +88,18 @@ public class AnalyticsController { return Result.success(analyticsService.getWarningTasks(currentUserId)); } - + + @Operation(summary = "获取全量任务进度总览") + @GetMapping("/task-progress/overview") + public Result 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 getDesignerDeductionDetails( @@ -177,4 +188,3 @@ public class AnalyticsController { return Result.success(analyticsService.getPrimaryDeptUserRanking(startTime, endTime)); } } - diff --git a/backend/src/main/java/com/designhub/entity/TaskProgressSnapshot.java b/backend/src/main/java/com/designhub/entity/TaskProgressSnapshot.java new file mode 100644 index 0000000..eecc4b2 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskProgressSnapshot.java @@ -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; +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskProgressSnapshotMapper.java b/backend/src/main/java/com/designhub/mapper/TaskProgressSnapshotMapper.java new file mode 100644 index 0000000..435bb56 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskProgressSnapshotMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/designhub/service/AnalyticsService.java b/backend/src/main/java/com/designhub/service/AnalyticsService.java index 3ef84c8..b929a0c 100644 --- a/backend/src/main/java/com/designhub/service/AnalyticsService.java +++ b/backend/src/main/java/com/designhub/service/AnalyticsService.java @@ -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 PENDING_STATUSES = new HashSet<>(Arrays.asList( + "pending", "pending_audit", "pending_demand_approval", "pending_receiving_approval" + )); + private static final Set IN_PROGRESS_STATUSES = new HashSet<>(Collections.singletonList("in_progress")); + private static final Set REVIEW_STATUSES = new HashSet<>(Arrays.asList("review", "submitted")); + private static final Set COMPLETED_STATUSES = new HashSet<>(Collections.singletonList("completed")); + private static final Set 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; } } - diff --git a/backend/src/main/java/com/designhub/service/TaskProgressSnapshotService.java b/backend/src/main/java/com/designhub/service/TaskProgressSnapshotService.java new file mode 100644 index 0000000..928d6ef --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskProgressSnapshotService.java @@ -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 tasks = loadTasks(); + Map userMap = loadUsers(tasks); + Map deptMap = loadDepartments(tasks); + + TaskProgressOverviewVO overview = buildOverview(tasks, userMap, deptMap); + persistSnapshot(overview); + return overview; + } + + private List loadTasks() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 loadUsers(List tasks) { + Set 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 loadDepartments(List tasks) { + Set 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 tasks, Map userMap, Map deptMap) { + LocalDateTime now = LocalDateTime.now(); + + Map 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 trend = buildTrend(tasks); + List stageSegments = buildStageSegments(tasks); + List departmentLoads = buildDepartmentLoads(tasks, deptMap); + List taskPoints = buildTaskPoints(tasks, userMap, deptMap, now); + List riskTasks = buildRiskTasks(taskPoints, now); + + metrics.setRiskTaskCount(riskTasks.size()); + + return TaskProgressOverviewVO.builder() + .snapshotTime(now) + .metrics(metrics) + .statusDistribution(statusDistribution.entrySet().stream() + .sorted(Map.Entry.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 buildTrend(List tasks) { + LocalDate today = LocalDate.now(); + LocalDate start = today.minusDays(29); + Map 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 buildStageSegments(List 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 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 buildDepartmentLoads(List tasks, Map deptMap) { + Map 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 buildTaskPoints(List tasks, Map userMap, Map 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 buildRiskTasks(List taskPoints, LocalDateTime now) { + List 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 loadLatestSnapshot() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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>() {})) + .trend(read(snapshot.getTrendJson(), new TypeReference>() {})) + .stageSegments(read(snapshot.getStageSegmentsJson(), new TypeReference>() {})) + .departmentLoads(read(snapshot.getDepartmentLoadJson(), new TypeReference>() {})) + .riskTasks(read(snapshot.getRiskTasksJson(), new TypeReference>() {})) + .taskPoints(read(snapshot.getTaskPointsJson(), new TypeReference>() {})) + .build()); + } catch (Exception ex) { + log.error("解析任务进度快照失败", ex); + return Optional.empty(); + } + } + + private T read(String json, TypeReference 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 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 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 deptLoads = Optional.ofNullable(overview.getDepartmentLoads()) + .map(ArrayList::new) + .orElseGet(ArrayList::new); + List 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 PENDING = Set.of("pending", "pending_audit", "pending_demand_approval", "pending_receiving_approval"); + private static final Set IN_PROGRESS = Set.of("in_progress"); + private static final Set REVIEW = Set.of("review", "submitted"); + private static final Set COMPLETED = Set.of("completed"); + } +} diff --git a/backend/src/main/java/com/designhub/vo/TaskProgressOverviewVO.java b/backend/src/main/java/com/designhub/vo/TaskProgressOverviewVO.java new file mode 100644 index 0000000..77be18c --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/TaskProgressOverviewVO.java @@ -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 statusDistribution; + private List trend; + private List stageSegments; + private List departmentLoads; + private List riskTasks; + private List 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; + } +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 6cc9034..9100665 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -97,3 +97,8 @@ springdoc: path: /swagger-ui.html api-docs: enabled: true + +analytics: + task-progress: + snapshot: + enabled: true diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 0d51cdc..85b5a76 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -104,3 +104,7 @@ springdoc: api-docs: enabled: false +analytics: + task-progress: + snapshot: + enabled: true diff --git a/database/V16__task_progress_snapshot.sql b/database/V16__task_progress_snapshot.sql new file mode 100644 index 0000000..2fb2864 --- /dev/null +++ b/database/V16__task_progress_snapshot.sql @@ -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; diff --git a/database/schema.sql b/database/schema.sql index 2db0484..d1f8f18 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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; diff --git a/docs/全量任务进度总览方案.md b/docs/全量任务进度总览方案.md new file mode 100644 index 0000000..04d86a4 --- /dev/null +++ b/docs/全量任务进度总览方案.md @@ -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 轮快照后灰度开放前端入口;若需停用可仅关闭配置并保留前端入口提示。 diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts index 3ed922d..9bbd259 100644 --- a/frontend/src/api/analytics.ts +++ b/frontend/src/api/analytics.ts @@ -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; + 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 => { 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 => { + 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 { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4dae471..3e0583c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 - diff --git a/frontend/src/views/Analytics.vue b/frontend/src/views/Analytics.vue index ebd775b..a05d0a9 100644 --- a/frontend/src/views/Analytics.vue +++ b/frontend/src/views/Analytics.vue @@ -37,6 +37,20 @@ +
+
+
指挥舱升级
+

全量任务进度 · 银河视图现已开放

+

沉浸式 3D 任务银河、阶段时间线与部门热力矩阵,帮助管理层 5 秒锁定瓶颈。

+
+
+ + + 打开任务进度指挥舱 + +
+
+
@@ -1049,6 +1063,10 @@ const selectedMonth = ref(null) const dateRange = ref('month') const activeTab = ref('demand') +const goToTaskProgress = () => { + router.push('/analytics/task-progress') +} + // 数据状态 const statistics = reactive({ 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; diff --git a/frontend/src/views/TaskProgressOverview.vue b/frontend/src/views/TaskProgressOverview.vue new file mode 100644 index 0000000..b2e3b63 --- /dev/null +++ b/frontend/src/views/TaskProgressOverview.vue @@ -0,0 +1,1099 @@ + + + + +