feat: add task progress command center with snapshot analytics

This commit is contained in:
Your Name 2026-02-07 10:51:45 +08:00
parent 1a80f5482f
commit e9e05613f5
15 changed files with 2308 additions and 4 deletions

View File

@ -88,7 +88,18 @@ public class AnalyticsController {
return Result.success(analyticsService.getWarningTasks(currentUserId)); 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 = "获取设计师扣分明细") @Operation(summary = "获取设计师扣分明细")
@GetMapping("/designer-deduction-details") @GetMapping("/designer-deduction-details")
public Result<DeductionDetailVO> getDesignerDeductionDetails( public Result<DeductionDetailVO> getDesignerDeductionDetails(
@ -177,4 +188,3 @@ public class AnalyticsController {
return Result.success(analyticsService.getPrimaryDeptUserRanking(startTime, endTime)); return Result.success(analyticsService.getPrimaryDeptUserRanking(startTime, endTime));
} }
} }

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import java.time.LocalTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.*; import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -42,6 +43,17 @@ public class AnalyticsService {
private final PlatformMapper platformMapper; private final PlatformMapper platformMapper;
private final DepartmentService departmentService; private final DepartmentService departmentService;
private final com.designhub.util.WorkHoursCalculator workHoursCalculator; 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; 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) { private WarningTaskVO buildWarningVO(Task task, String level, String type, String reason, Double hoursInfo) {
String receiverName = null; String receiverName = null;
@ -3284,4 +3304,3 @@ public class AnalyticsService {
return analysis; return analysis;
} }
} }

View File

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

View File

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

View File

@ -97,3 +97,8 @@ springdoc:
path: /swagger-ui.html path: /swagger-ui.html
api-docs: api-docs:
enabled: true enabled: true
analytics:
task-progress:
snapshot:
enabled: true

View File

@ -104,3 +104,7 @@ springdoc:
api-docs: api-docs:
enabled: false enabled: false
analytics:
task-progress:
snapshot:
enabled: true

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

View File

@ -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_score_record_time ON `score_record` (`create_time`, `change_type`);
CREATE INDEX idx_user_dept ON `sys_user` (`dept_id`, `status`); 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;

View 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 轮快照后灰度开放前端入口;若需停用可仅关闭配置并保留前端入口提示。

View File

@ -196,6 +196,94 @@ export interface WarningTask {
score: number; 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函数 ==================== // ==================== API函数 ====================
/** /**
@ -356,6 +444,50 @@ export const getWarningTasks = async (): Promise<WarningTask[]> => {
return []; 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 { export interface DelayDeductionItem {

View File

@ -57,6 +57,14 @@ const routes: RouteRecordRaw[] = [
title: '数据分析' title: '数据分析'
} }
}, },
{
path: '/analytics/task-progress',
name: 'TaskProgressOverview',
component: () => import('@/views/TaskProgressOverview.vue'),
meta: {
title: '任务进度指挥舱'
}
},
{ {
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',
@ -289,4 +297,3 @@ router.beforeEach(async (to, from, next) => {
}) })
export default router export default router

View File

@ -37,6 +37,20 @@
</div> </div>
</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"> <a-spin :spinning="loading">
<!-- 核心指标卡片 --> <!-- 核心指标卡片 -->
<div class="overview-section"> <div class="overview-section">
@ -1049,6 +1063,10 @@ const selectedMonth = ref<string | null>(null)
const dateRange = ref('month') const dateRange = ref('month')
const activeTab = ref('demand') const activeTab = ref('demand')
const goToTaskProgress = () => {
router.push('/analytics/task-progress')
}
// 数据状态 // 数据状态
const statistics = reactive<DemandStatistics>({ const statistics = reactive<DemandStatistics>({
overview: { 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 { .section-title {
font-size: $font-size-lg; font-size: $font-size-lg;
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;

File diff suppressed because it is too large Load Diff