feat: 数据统计 API 与租户过滤对齐,补充 timeRange 与前端修复

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-09 17:13:46 +08:00
parent d7dddd3058
commit e9ae6aeb7e
9 changed files with 1066 additions and 201 deletions

View File

@ -3,6 +3,7 @@ package com.competition.modules.biz.review.controller;
import com.competition.common.result.Result; import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil; import com.competition.common.util.SecurityUtil;
import com.competition.modules.biz.review.service.AnalyticsService; import com.competition.modules.biz.review.service.AnalyticsService;
import com.competition.modules.sys.service.ISysTenantService;
import com.competition.security.annotation.RequirePermission; import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -18,6 +19,7 @@ import java.util.Map;
public class AnalyticsController { public class AnalyticsController {
private final AnalyticsService analyticsService; private final AnalyticsService analyticsService;
private final ISysTenantService tenantService;
@GetMapping("/overview") @GetMapping("/overview")
@RequirePermission("contest:read") @RequirePermission("contest:read")
@ -26,15 +28,18 @@ public class AnalyticsController {
@RequestParam(required = false) String timeRange, @RequestParam(required = false) String timeRange,
@RequestParam(required = false) Long contestId) { @RequestParam(required = false) Long contestId) {
Long tenantId = SecurityUtil.getCurrentTenantId(); Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(analyticsService.getOverview(tenantId, contestId)); boolean isSuperTenant = tenantService.isSuperTenant(tenantId);
return Result.success(analyticsService.getOverview(tenantId, isSuperTenant, contestId, timeRange));
} }
@GetMapping("/review") @GetMapping("/review")
@RequirePermission("contest:read") @RequirePermission("contest:read")
@Operation(summary = "评审分析") @Operation(summary = "评审分析")
public Result<Map<String, Object>> getReviewAnalysis( public Result<Map<String, Object>> getReviewAnalysis(
@RequestParam(required = false) String timeRange,
@RequestParam(required = false) Long contestId) { @RequestParam(required = false) Long contestId) {
Long tenantId = SecurityUtil.getCurrentTenantId(); Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(analyticsService.getReviewAnalysis(tenantId, contestId)); boolean isSuperTenant = tenantService.isSuperTenant(tenantId);
return Result.success(analyticsService.getReviewAnalysis(tenantId, isSuperTenant, contestId, timeRange));
} }
} }

View File

@ -0,0 +1,61 @@
package com.competition.modules.biz.review.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 数据统计分析聚合查询复杂 SQL
*/
@Mapper
public interface AnalyticsMapper {
/** 作品从提交到首次评分的平均天数 */
BigDecimal selectAvgReviewCycleDays(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 最近 30 天内评分记录条数(用于日均评审量) */
long selectScoreCountLast30Days(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 待评审积压:分配状态为 assigned */
long selectPendingAssignments(@Param("contestIds") List<Long> contestIds);
/** 每件作品多评委分数标准差,再取平均 */
BigDecimal selectAvgStddevAcrossWorks(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 按月份统计报名量(最近约 6 个月窗口由 SQL 限定) */
List<Map<String, Object>> selectMonthlyRegistrationCounts(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 按月份统计作品提交量 */
List<Map<String, Object>> selectMonthlyWorkCounts(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 奖项分布:按奖项聚合 */
List<Map<String, Object>> selectAwardDistribution(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 评委工作量聚合 */
List<Map<String, Object>> selectJudgeWorkload(@Param("contestIds") List<Long> contestIds,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant);
/** 活动对比:单行指标(单活动) */
Map<String, Object> selectContestComparisonRow(@Param("contestId") Long contestId,
@Param("tenantId") Long tenantId,
@Param("isSuperTenant") boolean isSuperTenant,
@Param("rangeStart") LocalDateTime rangeStart,
@Param("rangeEnd") LocalDateTime rangeEnd);
}

View File

@ -3,24 +3,29 @@ package com.competition.modules.biz.review.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.competition.common.enums.PublishStatus; import com.competition.common.enums.PublishStatus;
import com.competition.common.enums.RegistrationStatus; import com.competition.common.enums.RegistrationStatus;
import com.competition.common.enums.WorkStatus;
import com.competition.modules.biz.contest.entity.BizContest; import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.entity.BizContestRegistration; import com.competition.modules.biz.contest.entity.BizContestRegistration;
import com.competition.modules.biz.contest.entity.BizContestWork; import com.competition.modules.biz.contest.entity.BizContestWork;
import com.competition.modules.biz.contest.mapper.ContestMapper; import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper; import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.review.entity.BizContestJudge; import com.competition.modules.biz.review.mapper.AnalyticsMapper;
import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.competition.modules.biz.review.entity.BizContestWorkScore;
import com.competition.modules.biz.review.mapper.ContestJudgeMapper;
import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* 租户端数据统计运营概览 / 评审分析
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AnalyticsService { public class AnalyticsService {
@ -28,45 +33,60 @@ public class AnalyticsService {
private final ContestMapper contestMapper; private final ContestMapper contestMapper;
private final ContestRegistrationMapper contestRegistrationMapper; private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestWorkMapper contestWorkMapper; private final ContestWorkMapper contestWorkMapper;
private final ContestWorkScoreMapper contestWorkScoreMapper; private final AnalyticsMapper analyticsMapper;
private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper;
private final ContestJudgeMapper contestJudgeMapper;
/** /**
* 数据概览 * 运营概览
*
* @param timeRange month / quarter / year / all空或 all 表示不限时间
*/ */
public Map<String, Object> getOverview(Long tenantId, Long contestId) { public Map<String, Object> getOverview(Long tenantId, boolean isSuperTenant, Long contestId, String timeRange) {
Map<String, Object> result = new HashMap<>(); List<Long> contestIds = resolveVisibleContestIds(tenantId, isSuperTenant, contestId);
LocalDateTime[] range = resolveTimeRange(timeRange);
Map<String, Object> result = new LinkedHashMap<>();
if (contestIds.isEmpty()) {
result.put("summary", emptySummary());
result.put("funnel", emptyFunnel());
result.put("monthlyTrend", Collections.emptyList());
result.put("contestComparison", Collections.emptyList());
return result;
}
// --- summary --- // --- summary ---
long totalContests = contestMapper.selectCount( long totalContests = contestIds.size();
new LambdaQueryWrapper<BizContest>()
.eq(tenantId != null, BizContest::getContestState, PublishStatus.PUBLISHED.getValue()));
long totalRegistrations = contestRegistrationMapper.selectCount( LambdaQueryWrapper<BizContestRegistration> regBase = new LambdaQueryWrapper<BizContestRegistration>()
new LambdaQueryWrapper<BizContestRegistration>() .in(BizContestRegistration::getContestId, contestIds);
.eq(contestId != null, BizContestRegistration::getContestId, contestId)); if (!isSuperTenant && tenantId != null) {
regBase.eq(BizContestRegistration::getTenantId, tenantId);
}
applyRegistrationTimeRange(regBase, range);
long passedRegistrations = contestRegistrationMapper.selectCount( long totalRegistrations = contestRegistrationMapper.selectCount(regBase);
new LambdaQueryWrapper<BizContestRegistration>()
.eq(contestId != null, BizContestRegistration::getContestId, contestId)
.eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue()));
long totalWorks = contestWorkMapper.selectCount( LambdaQueryWrapper<BizContestRegistration> regPassed = new LambdaQueryWrapper<BizContestRegistration>()
new LambdaQueryWrapper<BizContestWork>() .in(BizContestRegistration::getContestId, contestIds)
.eq(contestId != null, BizContestWork::getContestId, contestId) .eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue());
.eq(BizContestWork::getIsLatest, true)); if (!isSuperTenant && tenantId != null) {
regPassed.eq(BizContestRegistration::getTenantId, tenantId);
}
applyRegistrationTimeRange(regPassed, range);
long passedRegistrations = contestRegistrationMapper.selectCount(regPassed);
long reviewedWorks = contestWorkScoreMapper.selectCount( LambdaQueryWrapper<BizContestWork> workBase = workBaseWrapper(contestIds, tenantId, isSuperTenant);
new LambdaQueryWrapper<BizContestWorkScore>() applyWorkSubmitTimeRange(workBase, range);
.eq(contestId != null, BizContestWorkScore::getContestId, contestId)); long totalWorks = contestWorkMapper.selectCount(workBase);
long awardedWorks = contestWorkMapper.selectCount( LambdaQueryWrapper<BizContestWork> workReviewed = workBaseWrapper(contestIds, tenantId, isSuperTenant);
new LambdaQueryWrapper<BizContestWork>() workReviewed.in(BizContestWork::getStatus, WorkStatus.ACCEPTED.getValue(), WorkStatus.AWARDED.getValue());
.eq(contestId != null, BizContestWork::getContestId, contestId) applyWorkSubmitTimeRange(workReviewed, range);
.eq(BizContestWork::getIsLatest, true) long reviewedWorks = contestWorkMapper.selectCount(workReviewed);
.isNotNull(BizContestWork::getAwardLevel)
.ne(BizContestWork::getAwardLevel, "none")); LambdaQueryWrapper<BizContestWork> workAwarded = workBaseWrapper(contestIds, tenantId, isSuperTenant);
workAwarded.isNotNull(BizContestWork::getAwardLevel).ne(BizContestWork::getAwardLevel, "none");
applyWorkSubmitTimeRange(workAwarded, range);
long awardedWorks = contestWorkMapper.selectCount(workAwarded);
Map<String, Object> summary = new LinkedHashMap<>(); Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalContests", totalContests); summary.put("totalContests", totalContests);
@ -77,97 +97,319 @@ public class AnalyticsService {
summary.put("awardedWorks", awardedWorks); summary.put("awardedWorks", awardedWorks);
result.put("summary", summary); result.put("summary", summary);
// --- funnel --- // --- funnel summary 同一口径---
List<Map<String, Object>> funnel = new ArrayList<>(); Map<String, Object> funnel = new LinkedHashMap<>();
funnel.add(Map.of("stage", "报名", "count", totalRegistrations)); funnel.put("registered", totalRegistrations);
funnel.add(Map.of("stage", "审核通过", "count", passedRegistrations)); funnel.put("passed", passedRegistrations);
funnel.add(Map.of("stage", "提交作品", "count", totalWorks)); funnel.put("submitted", totalWorks);
funnel.add(Map.of("stage", "已评审", "count", reviewedWorks)); funnel.put("reviewed", reviewedWorks);
funnel.add(Map.of("stage", "获奖", "count", awardedWorks)); funnel.put("awarded", awardedWorks);
result.put("funnel", funnel); result.put("funnel", funnel);
// --- 月度趋势最近 6 个自然月窗口由 SQL 限定---
result.put("monthlyTrend", buildMonthlyTrend(contestIds, tenantId, isSuperTenant));
// --- 活动对比 ---
result.put("contestComparison", buildContestComparison(contestIds, tenantId, isSuperTenant, range));
return result; return result;
} }
/** /**
* 评审分析 * 评审分析
*/ */
public Map<String, Object> getReviewAnalysis(Long tenantId, Long contestId) { public Map<String, Object> getReviewAnalysis(Long tenantId, boolean isSuperTenant, Long contestId, String timeRange) {
Map<String, Object> result = new HashMap<>(); List<Long> contestIds = resolveVisibleContestIds(tenantId, isSuperTenant, contestId);
Map<String, Object> result = new LinkedHashMap<>();
// --- efficiency --- if (contestIds.isEmpty()) {
long totalAssignments = contestWorkJudgeAssignmentMapper.selectCount( result.put("efficiency", emptyEfficiency());
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>() result.put("judgeWorkload", Collections.emptyList());
.eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)); result.put("awardDistribution", Collections.emptyList());
return result;
}
long completedAssignments = contestWorkJudgeAssignmentMapper.selectCount( BigDecimal avgDays = analyticsMapper.selectAvgReviewCycleDays(contestIds, tenantId, isSuperTenant);
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>() long scores30 = analyticsMapper.selectScoreCountLast30Days(contestIds, tenantId, isSuperTenant);
.eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId) long pending = analyticsMapper.selectPendingAssignments(contestIds);
.eq(BizContestWorkJudgeAssignment::getStatus, "completed")); BigDecimal avgStd = analyticsMapper.selectAvgStddevAcrossWorks(contestIds, tenantId, isSuperTenant);
long totalScores = contestWorkScoreMapper.selectCount(
new LambdaQueryWrapper<BizContestWorkScore>()
.eq(contestId != null, BizContestWorkScore::getContestId, contestId));
Map<String, Object> efficiency = new LinkedHashMap<>(); Map<String, Object> efficiency = new LinkedHashMap<>();
efficiency.put("totalAssignments", totalAssignments); efficiency.put("avgReviewDays", avgDays == null ? 0.0 : round2(avgDays.doubleValue()));
efficiency.put("completedAssignments", completedAssignments); efficiency.put("dailyReviewCount", Math.round(scores30 / 30.0 * 100.0) / 100.0);
efficiency.put("completionRate", totalAssignments > 0 efficiency.put("pendingAssignments", pending);
? Math.round(completedAssignments * 10000.0 / totalAssignments) / 100.0 efficiency.put("avgScoreStddev", avgStd == null ? 0.0 : round2(avgStd.doubleValue()));
: 0);
efficiency.put("totalScores", totalScores);
result.put("efficiency", efficiency); result.put("efficiency", efficiency);
// --- judgeWorkload --- List<Map<String, Object>> judgeRows = analyticsMapper.selectJudgeWorkload(contestIds, tenantId, isSuperTenant);
List<BizContestJudge> judges = contestJudgeMapper.selectList( List<Map<String, Object>> judgeWorkload = new ArrayList<>();
new LambdaQueryWrapper<BizContestJudge>() for (Map<String, Object> row : judgeRows) {
.eq(contestId != null, BizContestJudge::getContestId, contestId));
List<Map<String, Object>> judgeWorkload = judges.stream().map(judge -> {
long assigned = contestWorkJudgeAssignmentMapper.selectCount(
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>()
.eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId())
.eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId));
long completed = contestWorkJudgeAssignmentMapper.selectCount(
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>()
.eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId())
.eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)
.eq(BizContestWorkJudgeAssignment::getStatus, "completed"));
Map<String, Object> item = new LinkedHashMap<>(); Map<String, Object> item = new LinkedHashMap<>();
item.put("judgeId", judge.getJudgeId()); Long judgeId = toLong(row.get("judgeId"));
item.put("specialty", judge.getSpecialty()); item.put("judgeId", judgeId != null ? judgeId : 0L);
item.put("assigned", assigned); item.put("judgeName", Objects.toString(row.get("judgeName"), "评委"));
item.put("completed", completed); item.put("contestCount", toLong(row.get("contestCount")));
item.put("completionRate", assigned > 0 long assigned = toLong(row.get("assignedCount"));
? Math.round(completed * 10000.0 / assigned) / 100.0 long scored = toLong(row.get("scoredCount"));
: 0); item.put("assignedCount", assigned);
return item; item.put("scoredCount", scored);
}).collect(Collectors.toList()); double completion = assigned > 0 ? Math.round(scored * 10000.0 / assigned) / 100.0 : 0;
item.put("completionRate", completion);
BigDecimal avgSc = toBigDecimal(row.get("avgScore"));
BigDecimal stdSc = toBigDecimal(row.get("scoreStddev"));
item.put("avgScore", avgSc == null ? null : round2(avgSc.doubleValue()));
item.put("scoreStddev", stdSc == null ? 0.0 : round2(stdSc.doubleValue()));
judgeWorkload.add(item);
}
result.put("judgeWorkload", judgeWorkload); result.put("judgeWorkload", judgeWorkload);
// --- awardDistribution --- result.put("awardDistribution", buildAwardDistribution(contestIds, tenantId, isSuperTenant));
long totalWorks = contestWorkMapper.selectCount(
new LambdaQueryWrapper<BizContestWork>()
.eq(contestId != null, BizContestWork::getContestId, contestId)
.eq(BizContestWork::getIsLatest, true));
List<String> awardLevels = List.of("first", "second", "third", "excellent");
List<Map<String, Object>> awardDistribution = awardLevels.stream().map(level -> {
long count = contestWorkMapper.selectCount(
new LambdaQueryWrapper<BizContestWork>()
.eq(contestId != null, BizContestWork::getContestId, contestId)
.eq(BizContestWork::getIsLatest, true)
.eq(BizContestWork::getAwardLevel, level));
Map<String, Object> item = new LinkedHashMap<>();
item.put("level", level);
item.put("count", count);
item.put("ratio", totalWorks > 0
? Math.round(count * 10000.0 / totalWorks) / 100.0
: 0);
return item;
}).collect(Collectors.toList());
result.put("awardDistribution", awardDistribution);
return result; return result;
} }
// --- 可见活动 ID已发布 + 租户授权---
private List<Long> resolveVisibleContestIds(Long tenantId, boolean isSuperTenant, Long contestId) {
LambdaQueryWrapper<BizContest> w = new LambdaQueryWrapper<>();
w.eq(BizContest::getValidState, 1);
w.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue());
if (!isSuperTenant && tenantId != null) {
w.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId);
}
if (contestId != null) {
w.eq(BizContest::getId, contestId);
}
return contestMapper.selectList(w).stream().map(BizContest::getId).filter(Objects::nonNull).toList();
}
private static LambdaQueryWrapper<BizContestWork> workBaseWrapper(List<Long> contestIds, Long tenantId, boolean isSuperTenant) {
LambdaQueryWrapper<BizContestWork> w = new LambdaQueryWrapper<BizContestWork>()
.in(BizContestWork::getContestId, contestIds)
.eq(BizContestWork::getIsLatest, true)
.eq(BizContestWork::getValidState, 1);
if (!isSuperTenant && tenantId != null) {
w.eq(BizContestWork::getTenantId, tenantId);
}
return w;
}
private static void applyRegistrationTimeRange(LambdaQueryWrapper<BizContestRegistration> w, LocalDateTime[] range) {
if (range != null) {
w.ge(BizContestRegistration::getRegistrationTime, range[0]);
w.le(BizContestRegistration::getRegistrationTime, range[1]);
}
}
private static void applyWorkSubmitTimeRange(LambdaQueryWrapper<BizContestWork> w, LocalDateTime[] range) {
if (range != null) {
w.ge(BizContestWork::getSubmitTime, range[0]);
w.le(BizContestWork::getSubmitTime, range[1]);
}
}
/**
* 时间范围本月 / 本季 / 本年all 或空为不限
*/
private static LocalDateTime[] resolveTimeRange(String timeRange) {
if (!StringUtils.hasText(timeRange) || "all".equalsIgnoreCase(timeRange.trim())) {
return null;
}
LocalDateTime now = LocalDateTime.now();
LocalDate today = now.toLocalDate();
LocalDateTime start;
switch (timeRange.trim().toLowerCase()) {
case "month" -> {
LocalDate first = today.withDayOfMonth(1);
start = LocalDateTime.of(first, LocalTime.MIN);
}
case "quarter" -> {
int m = today.getMonthValue();
int qStart = ((m - 1) / 3) * 3 + 1;
LocalDate first = today.withMonth(qStart).withDayOfMonth(1);
start = LocalDateTime.of(first, LocalTime.MIN);
}
case "year" -> {
LocalDate first = today.withDayOfYear(1);
start = LocalDateTime.of(first, LocalTime.MIN);
}
default -> {
return null;
}
}
return new LocalDateTime[]{start, now};
}
private List<Map<String, Object>> buildMonthlyTrend(List<Long> contestIds, Long tenantId, boolean isSuperTenant) {
List<Map<String, Object>> regRows = analyticsMapper.selectMonthlyRegistrationCounts(contestIds, tenantId, isSuperTenant);
List<Map<String, Object>> workRows = analyticsMapper.selectMonthlyWorkCounts(contestIds, tenantId, isSuperTenant);
Map<String, Long> regMap = regRows.stream().collect(Collectors.toMap(
r -> Objects.toString(r.get("ym"), ""),
r -> toLong(r.get("cnt")),
Long::sum
));
Map<String, Long> workMap = workRows.stream().collect(Collectors.toMap(
r -> Objects.toString(r.get("ym"), ""),
r -> toLong(r.get("cnt")),
Long::sum
));
YearMonth now = YearMonth.now();
List<Map<String, Object>> trend = new ArrayList<>();
for (int i = 5; i >= 0; i--) {
YearMonth ym = now.minusMonths(i);
String key = ym.toString();
Map<String, Object> row = new LinkedHashMap<>();
row.put("month", key);
row.put("registrations", regMap.getOrDefault(key, 0L).intValue());
row.put("works", workMap.getOrDefault(key, 0L).intValue());
trend.add(row);
}
return trend;
}
private List<Map<String, Object>> buildContestComparison(List<Long> contestIds, Long tenantId, boolean isSuperTenant,
LocalDateTime[] range) {
List<Map<String, Object>> list = new ArrayList<>();
for (Long cid : contestIds) {
BizContest c = contestMapper.selectById(cid);
if (c == null) {
continue;
}
Map<String, Object> raw = analyticsMapper.selectContestComparisonRow(cid, tenantId, isSuperTenant,
range != null ? range[0] : null, range != null ? range[1] : null);
if (raw == null) {
continue;
}
long registrations = toLong(raw.get("registrations"));
long passedRegs = toLong(raw.get("passedRegs"));
long workCount = toLong(raw.get("workCount"));
long reviewedCount = toLong(raw.get("reviewedCount"));
long awardedCount = toLong(raw.get("awardedCount"));
double passRate = registrations > 0 ? round2(passedRegs * 100.0 / registrations) : 0;
double submitRate = passedRegs > 0 ? round2(workCount * 100.0 / passedRegs) : 0;
double reviewRate = workCount > 0 ? round2(reviewedCount * 100.0 / workCount) : 0;
double awardRate = workCount > 0 ? round2(awardedCount * 100.0 / workCount) : 0;
Map<String, Object> row = new LinkedHashMap<>();
row.put("contestId", cid);
row.put("contestName", c.getContestName());
row.put("registrations", (int) registrations);
row.put("passRate", passRate);
row.put("submitRate", submitRate);
row.put("reviewRate", reviewRate);
row.put("awardRate", awardRate);
BigDecimal avg = toBigDecimal(raw.get("avgScore"));
row.put("avgScore", avg != null ? round2(avg.doubleValue()) : null);
list.add(row);
}
return list;
}
private List<Map<String, Object>> buildAwardDistribution(List<Long> contestIds, Long tenantId, boolean isSuperTenant) {
List<Map<String, Object>> rows = analyticsMapper.selectAwardDistribution(contestIds, tenantId, isSuperTenant);
Map<String, Long> merged = new LinkedHashMap<>();
for (Map<String, Object> r : rows) {
String level = Objects.toString(r.get("awardLevel"), "");
String rawName = r.get("awardNameRaw") != null ? Objects.toString(r.get("awardNameRaw"), "") : "";
String label = awardDisplayName(level, rawName);
long cnt = toLong(r.get("cnt"));
merged.merge(label, cnt, Long::sum);
}
long total = merged.values().stream().mapToLong(Long::longValue).sum();
List<Map<String, Object>> out = new ArrayList<>();
for (Map.Entry<String, Long> e : merged.entrySet()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("awardName", e.getKey());
item.put("count", e.getValue().intValue());
double pct = total > 0 ? Math.round(e.getValue() * 10000.0 / total) / 100.0 : 0;
item.put("percentage", pct);
out.add(item);
}
return out;
}
private static String awardDisplayName(String level, String awardName) {
if (StringUtils.hasText(awardName)) {
return awardName.trim();
}
if (!StringUtils.hasText(level)) {
return "其他";
}
return switch (level) {
case "first" -> "一等奖";
case "second" -> "二等奖";
case "third" -> "三等奖";
case "excellent" -> "优秀奖";
default -> level;
};
}
private static Map<String, Object> emptySummary() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("totalContests", 0);
m.put("totalRegistrations", 0);
m.put("passedRegistrations", 0);
m.put("totalWorks", 0);
m.put("reviewedWorks", 0);
m.put("awardedWorks", 0);
return m;
}
private static Map<String, Object> emptyFunnel() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("registered", 0);
m.put("passed", 0);
m.put("submitted", 0);
m.put("reviewed", 0);
m.put("awarded", 0);
return m;
}
private static Map<String, Object> emptyEfficiency() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("avgReviewDays", 0);
m.put("dailyReviewCount", 0);
m.put("pendingAssignments", 0);
m.put("avgScoreStddev", 0);
return m;
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
if (o instanceof Number n) {
return n.longValue();
}
try {
return Long.parseLong(o.toString());
} catch (NumberFormatException e) {
return 0L;
}
}
private static BigDecimal toBigDecimal(Object o) {
if (o == null) {
return null;
}
if (o instanceof BigDecimal b) {
return b;
}
if (o instanceof Number n) {
return BigDecimal.valueOf(n.doubleValue());
}
try {
return new BigDecimal(o.toString());
} catch (Exception e) {
return null;
}
}
private static double round2(double v) {
return Math.round(v * 100.0) / 100.0;
}
} }

View File

@ -0,0 +1,220 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.competition.modules.biz.review.mapper.AnalyticsMapper">
<sql id="contestIn">
<foreach collection="contestIds" item="cid" open="(" separator="," close=")">
#{cid}
</foreach>
</sql>
<select id="selectAvgReviewCycleDays" resultType="java.math.BigDecimal">
SELECT AVG(day_diff)
FROM (
SELECT DATEDIFF(MIN(s.score_time), w.submit_time) AS day_diff
FROM t_biz_contest_work w
INNER JOIN t_biz_contest_work_score s ON s.work_id = w.id AND s.deleted = 0
WHERE w.deleted = 0
AND w.is_latest = 1
AND w.valid_state = 1
AND w.contest_id IN <include refid="contestIn"/>
AND w.submit_time IS NOT NULL
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
GROUP BY w.id
HAVING MIN(s.score_time) IS NOT NULL
) t
</select>
<select id="selectScoreCountLast30Days" resultType="long">
SELECT COUNT(*)
FROM t_biz_contest_work_score s
WHERE s.deleted = 0
AND s.score_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND s.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND s.tenant_id = #{tenantId}
</if>
</select>
<select id="selectPendingAssignments" resultType="long">
SELECT COUNT(*)
FROM t_biz_contest_work_judge_assignment a
WHERE a.status = 'assigned'
AND a.contest_id IN <include refid="contestIn"/>
</select>
<select id="selectAvgStddevAcrossWorks" resultType="java.math.BigDecimal">
SELECT AVG(work_std)
FROM (
SELECT STDDEV_POP(s.total_score) AS work_std
FROM t_biz_contest_work_score s
WHERE s.deleted = 0
AND s.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND s.tenant_id = #{tenantId}
</if>
GROUP BY s.work_id
HAVING COUNT(*) > 1
) x
</select>
<select id="selectMonthlyRegistrationCounts" resultType="map">
SELECT DATE_FORMAT(r.registration_time, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM t_biz_contest_registration r
WHERE r.deleted = 0
AND r.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND r.tenant_id = #{tenantId}
</if>
AND r.registration_time >= DATE_SUB(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 5 MONTH)
GROUP BY DATE_FORMAT(r.registration_time, '%Y-%m')
ORDER BY ym
</select>
<select id="selectMonthlyWorkCounts" resultType="map">
SELECT DATE_FORMAT(w.submit_time, '%Y-%m') AS ym, COUNT(*) AS cnt
FROM t_biz_contest_work w
WHERE w.deleted = 0
AND w.is_latest = 1
AND w.valid_state = 1
AND w.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
AND w.submit_time IS NOT NULL
AND w.submit_time >= DATE_SUB(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 5 MONTH)
GROUP BY DATE_FORMAT(w.submit_time, '%Y-%m')
ORDER BY ym
</select>
<select id="selectAwardDistribution" resultType="map">
SELECT
w.award_level AS awardLevel,
w.award_name AS awardNameRaw,
COUNT(*) AS cnt
FROM t_biz_contest_work w
WHERE w.deleted = 0
AND w.is_latest = 1
AND w.valid_state = 1
AND w.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
AND w.award_level IS NOT NULL
AND w.award_level != 'none'
GROUP BY w.award_level, w.award_name
</select>
<select id="selectJudgeWorkload" resultType="map">
SELECT
j.judge_id AS judgeId,
COALESCE(
(SELECT MAX(sc.judge_name) FROM t_biz_contest_work_score sc
WHERE sc.judge_id = j.judge_id AND sc.deleted = 0
AND sc.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND sc.tenant_id = #{tenantId}
</if>
),
MAX(u.nickname),
'评委'
) AS judgeName,
COUNT(DISTINCT j.contest_id) AS contestCount,
(SELECT COUNT(*) FROM t_biz_contest_work_judge_assignment a
WHERE a.judge_id = j.judge_id AND a.contest_id IN <include refid="contestIn"/>
) AS assignedCount,
(SELECT COUNT(*) FROM t_biz_contest_work_score sc2
WHERE sc2.judge_id = j.judge_id AND sc2.deleted = 0
AND sc2.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND sc2.tenant_id = #{tenantId}
</if>
) AS scoredCount,
(SELECT AVG(sc3.total_score) FROM t_biz_contest_work_score sc3
WHERE sc3.judge_id = j.judge_id AND sc3.deleted = 0
AND sc3.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND sc3.tenant_id = #{tenantId}
</if>
) AS avgScore,
(SELECT STDDEV_POP(sc4.total_score) FROM t_biz_contest_work_score sc4
WHERE sc4.judge_id = j.judge_id AND sc4.deleted = 0
AND sc4.contest_id IN <include refid="contestIn"/>
<if test="!isSuperTenant and tenantId != null">
AND sc4.tenant_id = #{tenantId}
</if>
) AS scoreStddev
FROM t_biz_contest_judge j
LEFT JOIN t_sys_user u ON u.id = j.judge_id AND u.deleted = 0
WHERE j.contest_id IN <include refid="contestIn"/>
AND j.deleted = 0
GROUP BY j.judge_id
ORDER BY j.judge_id
</select>
<!-- 单活动对比行:报名数、通过数、作品数、已评审、获奖、均分 -->
<select id="selectContestComparisonRow" resultType="map">
SELECT
(SELECT COUNT(*) FROM t_biz_contest_registration r
WHERE r.deleted = 0 AND r.contest_id = #{contestId}
<if test="!isSuperTenant and tenantId != null">
AND r.tenant_id = #{tenantId}
</if>
<if test="rangeStart != null and rangeEnd != null">
AND r.registration_time BETWEEN #{rangeStart} AND #{rangeEnd}
</if>
) AS registrations,
(SELECT COUNT(*) FROM t_biz_contest_registration r
WHERE r.deleted = 0 AND r.contest_id = #{contestId}
AND r.registration_state = 'passed'
<if test="!isSuperTenant and tenantId != null">
AND r.tenant_id = #{tenantId}
</if>
<if test="rangeStart != null and rangeEnd != null">
AND r.registration_time BETWEEN #{rangeStart} AND #{rangeEnd}
</if>
) AS passedRegs,
(SELECT COUNT(*) FROM t_biz_contest_work w
WHERE w.deleted = 0 AND w.is_latest = 1 AND w.valid_state = 1 AND w.contest_id = #{contestId}
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
<if test="rangeStart != null and rangeEnd != null">
AND w.submit_time BETWEEN #{rangeStart} AND #{rangeEnd}
</if>
) AS workCount,
(SELECT COUNT(*) FROM t_biz_contest_work w
WHERE w.deleted = 0 AND w.is_latest = 1 AND w.valid_state = 1 AND w.contest_id = #{contestId}
AND w.status IN ('accepted', 'awarded')
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
<if test="rangeStart != null and rangeEnd != null">
AND w.submit_time BETWEEN #{rangeStart} AND #{rangeEnd}
</if>
) AS reviewedCount,
(SELECT COUNT(*) FROM t_biz_contest_work w
WHERE w.deleted = 0 AND w.is_latest = 1 AND w.valid_state = 1 AND w.contest_id = #{contestId}
AND w.award_level IS NOT NULL AND w.award_level != 'none'
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
<if test="rangeStart != null and rangeEnd != null">
AND w.submit_time BETWEEN #{rangeStart} AND #{rangeEnd}
</if>
) AS awardedCount,
(SELECT AVG(w.final_score) FROM t_biz_contest_work w
WHERE w.deleted = 0 AND w.is_latest = 1 AND w.valid_state = 1 AND w.contest_id = #{contestId}
AND w.final_score IS NOT NULL
<if test="!isSuperTenant and tenantId != null">
AND w.tenant_id = #{tenantId}
</if>
<if test="rangeStart != null and rangeEnd != null">
AND w.submit_time BETWEEN #{rangeStart} AND #{rangeEnd}
</if>
) AS avgScore
</select>
</mapper>

View File

@ -19,7 +19,7 @@
| 文档 | 模块 | 状态 | 日期 | | 文档 | 模块 | 状态 | 日期 |
|------|------|------|------| |------|------|------|------|
| [租户端全面优化](./org-admin/tenant-portal-optimization.md) | 全模块 | 已优化 | 2026-03-31 | | [租户端全面优化](./org-admin/tenant-portal-optimization.md) | 全模块 | 已优化 | 2026-03-31 |
| [数据统计看板](./org-admin/data-analytics-dashboard.md) | 数据统计 | 已实现 | 2026-03-31 | | [数据统计看板](./org-admin/data-analytics-dashboard.md) | 数据统计 | 已实现API 契约与租户过滤见文档 §5 | 2026-04-09 |
## 用户端(公众端) ## 用户端(公众端)

View File

@ -1,9 +1,9 @@
# 租户端数据统计分析看板 — 设计方案 # 租户端数据统计分析看板 — 设计方案
> 所属端:租户端(机构管理端) > 所属端:租户端(机构管理端)
> 状态:已实现 > 状态:已实现(后端契约与租户过滤已对齐 [2026-04-09]
> 创建日期2026-03-31 > 创建日期2026-03-31
> 最后更新2026-03-31 > 最后更新2026-04-09
--- ---
@ -197,16 +197,23 @@ GROUP BY award_name
## 5. 后端 API 设计 ## 5. 后端 API 设计
上下文路径以部署为准(开发环境一般为 `/api`)。实现类:`AnalyticsController` / `AnalyticsService` / `AnalyticsMapper`
**多租户**:非超级租户时,活动范围由 `t_biz_contest.contest_tenants`JSON 授权)限定;报名/作品/评分等子表再按 `tenant_id` 过滤。超级租户不按 `contest_tenants` 限活动。
### 5.1 运营概览 ### 5.1 运营概览
``` ```
GET /api/analytics/overview GET /analytics/overview
参数: timeRange(month/quarter/year/all), contestId?(可选) 参数:
timeRange: 可选。month | quarter | year不传或 all 表示不限时间。
作用于 summary、funnel、contestComparison 的时间过滤(报名按 registration_time作品按 submit_time
contestId: 可选。指定单个活动;须属于当前租户可见活动。
返回: 返回:
{ {
summary: { totalContests, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks }, summary: { totalContests, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks },
funnel: { registered, passed, submitted, reviewed, awarded }, funnel: { registered, passed, submitted, reviewed, awarded }, // 对象,非数组
monthlyTrend: [{ month, registrations, works }], monthlyTrend: [{ month: 'YYYY-MM', registrations, works }], // 最近 6 个自然月,与 timeRange 独立
contestComparison: [{ contestComparison: [{
contestId, contestName, contestId, contestName,
registrations, passRate, submitRate, reviewRate, awardRate, avgScore registrations, passRate, submitRate, reviewRate, awardRate, avgScore
@ -214,11 +221,15 @@ GET /api/analytics/overview
} }
``` ```
指标口径简述:`reviewedWorks` / 漏斗 `reviewed` 为作品 `status IN ('accepted','awarded')``awardedWorks` 为 `award_level` 非空且不为 `none`
### 5.2 评审分析 ### 5.2 评审分析
``` ```
GET /api/analytics/review GET /analytics/review
参数: timeRange, contestId? 参数:
contestId: 可选,同上。
timeRange: 可传(与前端下拉一致);当前实现中评审分析各模块为「可见活动」全量统计,该参数预留,不参与过滤。
返回: 返回:
{ {
efficiency: { avgReviewDays, dailyReviewCount, pendingAssignments, avgScoreStddev }, efficiency: { avgReviewDays, dailyReviewCount, pendingAssignments, avgScoreStddev },
@ -230,6 +241,8 @@ GET /api/analytics/review
} }
``` ```
`efficiency``avgReviewDays` 为作品提交至首次评分的平均天数;`dailyReviewCount` 为近 30 天评分条数 / 30`pendingAssignments` 为分配状态 `assigned` 的条数;`avgScoreStddev` 为「多评委作品」分数标准差再对作品取平均。
## 6. 技术方案 ## 6. 技术方案
- 前端图表库:使用 ECharts 或 Ant Design Charts@ant-design/charts - 前端图表库:使用 ECharts 或 Ant Design Charts@ant-design/charts

View File

@ -57,6 +57,6 @@ export const analyticsApi = {
getOverview: (params?: { timeRange?: string; contestId?: number }): Promise<OverviewData> => getOverview: (params?: { timeRange?: string; contestId?: number }): Promise<OverviewData> =>
request.get('/analytics/overview', { params }), request.get('/analytics/overview', { params }),
getReview: (params?: { contestId?: number }): Promise<ReviewData> => getReview: (params?: { timeRange?: string; contestId?: number }): Promise<ReviewData> =>
request.get('/analytics/review', { params }), request.get('/analytics/review', { params }),
} }

View File

@ -4,7 +4,14 @@
<template #title>运营概览</template> <template #title>运营概览</template>
<template #extra> <template #extra>
<a-space> <a-space>
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear @change="fetchData"> <a-select v-model:value="timeRange" style="width: 120px" @change="fetchData">
<a-select-option value="all">全部时间</a-select-option>
<a-select-option value="month">本月</a-select-option>
<a-select-option value="quarter">本季度</a-select-option>
<a-select-option value="year">本年</a-select-option>
</a-select>
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear
@change="fetchData">
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option> <a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
</a-select> </a-select>
</a-space> </a-space>
@ -35,7 +42,9 @@
<div class="funnel-header"> <div class="funnel-header">
<span class="funnel-label">{{ item.label }}</span> <span class="funnel-label">{{ item.label }}</span>
<div class="funnel-values"> <div class="funnel-values">
<span v-if="idx > 0" class="funnel-rate" :style="{ background: item.rateBg, color: item.rateColor }">{{ item.rate }}</span> <span v-if="idx > 0" class="funnel-rate"
:style="{ background: item.rateBg, color: item.rateColor }">{{
item.rate }}</span>
<span class="funnel-count">{{ item.value }}</span> <span class="funnel-count">{{ item.value }}</span>
</div> </div>
</div> </div>
@ -56,9 +65,11 @@
<!-- 活动对比 --> <!-- 活动对比 -->
<div class="card-section" style="margin-top: 16px"> <div class="card-section" style="margin-top: 16px">
<h3 class="section-title">活动对比</h3> <h3 class="section-title">活动对比</h3>
<a-table :columns="comparisonColumns" :data-source="data?.contestComparison || []" :pagination="false" row-key="contestId" size="small"> <a-table :columns="comparisonColumns" :data-source="data?.contestComparison || []" :pagination="false"
row-key="contestId" size="small">
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'passRate' || column.key === 'submitRate' || column.key === 'reviewRate' || column.key === 'awardRate'"> <template
v-if="column.key === 'passRate' || column.key === 'submitRate' || column.key === 'reviewRate' || column.key === 'awardRate'">
<span class="rate-pill" :class="getRateClass(record[column.key])">{{ record[column.key] }}%</span> <span class="rate-pill" :class="getRateClass(record[column.key])">{{ record[column.key] }}%</span>
</template> </template>
<template v-else-if="column.key === 'avgScore'"> <template v-else-if="column.key === 'avgScore'">
@ -91,6 +102,7 @@ use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent
const loading = ref(true) const loading = ref(true)
const data = ref<OverviewData | null>(null) const data = ref<OverviewData | null>(null)
const timeRange = ref<string>('all')
const contestFilter = ref<number | undefined>(undefined) const contestFilter = ref<number | undefined>(undefined)
const contestOptions = ref<{ id: number; name: string }[]>([]) const contestOptions = ref<{ id: number; name: string }[]>([])
@ -163,8 +175,11 @@ const fetchContestOptions = async () => {
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
data.value = await analyticsApi.getOverview({ contestId: contestFilter.value }) data.value = await analyticsApi.getOverview({
} catch { message.error('获取统计数据失败') } contestId: contestFilter.value,
timeRange: timeRange.value === 'all' ? undefined : timeRange.value,
})
} catch (error) { message.error('获取统计数据失败'); console.error(error) }
finally { loading.value = false } finally { loading.value = false }
} }
@ -174,53 +189,201 @@ onMounted(() => { fetchContestOptions(); fetchData() })
<style scoped lang="scss"> <style scoped lang="scss">
$primary: #6366f1; $primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); .title-card {
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } } margin-bottom: 16px;
:deep(.ant-card-body) { padding: 0; } border: none;
} border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
.stats-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 16px; } :deep(.ant-card-head) {
.stat-card { border-bottom: none;
display: flex; align-items: center; gap: 12px; padding: 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); } .ant-card-head-title {
.stat-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; } font-size: 18px;
.stat-info { display: flex; flex-direction: column; font-weight: 600;
.stat-count { font-size: 22px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } }
.stat-label { font-size: 12px; color: #9ca3af; } }
:deep(.ant-card-body) {
padding: 0;
} }
} }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .stats-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba($primary, 0.12);
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
.stat-count {
font-size: 22px;
font-weight: 700;
color: #1e1b4b;
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
}
}
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.card-section { .card-section {
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px; background: #fff;
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; } border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
padding: 24px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #1e1b4b;
margin: 0 0 16px;
}
} }
// //
.funnel-list { display: flex; flex-direction: column; gap: 12px; } .funnel-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.funnel-item { .funnel-item {
.funnel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } .funnel-header {
.funnel-label { font-size: 13px; font-weight: 500; color: #374151; } display: flex;
.funnel-values { display: flex; align-items: center; gap: 8px; } justify-content: space-between;
.funnel-count { font-size: 14px; font-weight: 700; color: #1e1b4b; } align-items: center;
.funnel-rate { display: inline-flex; padding: 1px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; } margin-bottom: 6px;
.funnel-bar-bg { height: 28px; background: #f3f4f6; border-radius: 8px; overflow: hidden; } }
.funnel-bar { height: 100%; border-radius: 8px; transition: width 0.8s cubic-bezier(0.4,0,0.2,1); }
.funnel-label {
font-size: 13px;
font-weight: 500;
color: #374151;
}
.funnel-values {
display: flex;
align-items: center;
gap: 8px;
}
.funnel-count {
font-size: 14px;
font-weight: 700;
color: #1e1b4b;
}
.funnel-rate {
display: inline-flex;
padding: 1px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.funnel-bar-bg {
height: 28px;
background: #f3f4f6;
border-radius: 8px;
overflow: hidden;
}
.funnel-bar {
height: 100%;
border-radius: 8px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
} }
// //
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; } .rate-pill {
.rate-high { background: #ecfdf5; color: #10b981; } display: inline-flex;
.rate-mid { background: #fffbeb; color: #f59e0b; } padding: 2px 10px;
.rate-low { background: #fef2f2; color: #ef4444; } border-radius: 12px;
.rate-zero { background: #f3f4f6; color: #d1d5db; } font-size: 12px;
font-weight: 600;
}
.score-text { font-weight: 700; color: $primary; } .rate-high {
.text-muted { color: #d1d5db; } background: #ecfdf5;
color: #10b981;
}
:deep(.ant-table-wrapper) { background: transparent; .rate-mid {
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; } background: #fffbeb;
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } color: #f59e0b;
}
.rate-low {
background: #fef2f2;
color: #ef4444;
}
.rate-zero {
background: #f3f4f6;
color: #d1d5db;
}
.score-text {
font-weight: 700;
color: $primary;
}
.text-muted {
color: #d1d5db;
}
:deep(.ant-table-wrapper) {
background: transparent;
.ant-table-thead>tr>th {
background: #fafafa;
font-weight: 600;
font-size: 13px;
}
.ant-table-tbody>tr:hover>td {
background: rgba($primary, 0.03);
}
} }
</style> </style>

View File

@ -3,9 +3,18 @@
<a-card class="title-card"> <a-card class="title-card">
<template #title>评审分析</template> <template #title>评审分析</template>
<template #extra> <template #extra>
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear @change="fetchData"> <a-space>
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option> <a-select v-model:value="timeRange" style="width: 120px" @change="fetchData">
</a-select> <a-select-option value="all">全部时间</a-select-option>
<a-select-option value="month">本月</a-select-option>
<a-select-option value="quarter">本季度</a-select-option>
<a-select-option value="year">本年</a-select-option>
</a-select>
<a-select v-model:value="contestFilter" style="width: 200px" placeholder="全部活动" allow-clear
@change="fetchData">
<a-select-option v-for="c in contestOptions" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
</a-select>
</a-space>
</template> </template>
</a-card> </a-card>
@ -28,11 +37,13 @@
<!-- 评委工作量 --> <!-- 评委工作量 -->
<div class="card-section col-span-3"> <div class="card-section col-span-3">
<h3 class="section-title">评委工作量</h3> <h3 class="section-title">评委工作量</h3>
<a-table :columns="judgeColumns" :data-source="data?.judgeWorkload || []" :pagination="false" row-key="judgeId" size="small"> <a-table :columns="judgeColumns" :data-source="data?.judgeWorkload || []" :pagination="false"
row-key="judgeId" size="small">
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'"> <template v-if="column.key === 'judgeName'">
<div class="judge-cell"> <div class="judge-cell">
<div class="judge-avatar" :style="{ background: getAvatarColor(record.judgeName) }">{{ record.judgeName?.charAt(0) }}</div> <div class="judge-avatar" :style="{ background: getAvatarColor(record.judgeName) }">{{
record.judgeName?.charAt(0) }}</div>
<span class="judge-name">{{ record.judgeName }}</span> <span class="judge-name">{{ record.judgeName }}</span>
</div> </div>
</template> </template>
@ -81,6 +92,7 @@ use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const loading = ref(true) const loading = ref(true)
const data = ref<ReviewData | null>(null) const data = ref<ReviewData | null>(null)
const timeRange = ref<string>('all')
const contestFilter = ref<number | undefined>(undefined) const contestFilter = ref<number | undefined>(undefined)
const contestOptions = ref<{ id: number; name: string }[]>([]) const contestOptions = ref<{ id: number; name: string }[]>([])
@ -154,8 +166,11 @@ const fetchContestOptions = async () => {
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
data.value = await analyticsApi.getReview({ contestId: contestFilter.value }) data.value = await analyticsApi.getReview({
} catch { message.error('获取评审分析数据失败') } contestId: contestFilter.value,
timeRange: timeRange.value === 'all' ? undefined : timeRange.value,
})
} catch (error) { message.error('获取评审分析数据失败'); console.error(error) }
finally { loading.value = false } finally { loading.value = false }
} }
@ -165,54 +180,200 @@ onMounted(() => { fetchContestOptions(); fetchData() })
<style scoped lang="scss"> <style scoped lang="scss">
$primary: #6366f1; $primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); .title-card {
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } } margin-bottom: 16px;
:deep(.ant-card-body) { padding: 0; } border: none;
} border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; } :deep(.ant-card-head) {
.stat-card { border-bottom: none;
display: flex; align-items: center; gap: 12px; padding: 18px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); transition: all 0.2s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba($primary, 0.12); } .ant-card-head-title {
.stat-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; } font-size: 18px;
.stat-info { display: flex; flex-direction: column; font-weight: 600;
.stat-count { font-size: 24px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } }
.stat-unit { font-size: 13px; font-weight: 400; color: #9ca3af; margin-left: 2px; } }
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
.stat-hint { font-size: 10px; color: #d1d5db; } :deep(.ant-card-body) {
padding: 0;
} }
} }
.grid-5-2 { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; } .stats-row {
.col-span-3 { grid-column: 1; } display: grid;
.col-span-2 { grid-column: 2; } grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 18px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba($primary, 0.12);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
.stat-count {
font-size: 24px;
font-weight: 700;
color: #1e1b4b;
line-height: 1.2;
}
.stat-unit {
font-size: 13px;
font-weight: 400;
color: #9ca3af;
margin-left: 2px;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.stat-hint {
font-size: 10px;
color: #d1d5db;
}
}
}
.grid-5-2 {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
}
.col-span-3 {
grid-column: 1;
}
.col-span-2 {
grid-column: 2;
}
.card-section { .card-section {
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); padding: 24px; background: #fff;
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; } border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
padding: 24px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #1e1b4b;
margin: 0 0 16px;
}
}
.judge-cell {
display: flex;
align-items: center;
gap: 10px;
} }
.judge-cell { display: flex; align-items: center; gap: 10px; }
.judge-avatar { .judge-avatar {
width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; width: 32px;
color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0; height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
} }
.judge-name { font-weight: 500; color: #1e1b4b; }
.rate-pill { display: inline-flex; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; } .judge-name {
.rate-high { background: #ecfdf5; color: #10b981; } font-weight: 500;
.rate-mid { background: #fffbeb; color: #f59e0b; } color: #1e1b4b;
.rate-low { background: #fef2f2; color: #ef4444; } }
.score-text { font-weight: 700; color: $primary; } .rate-pill {
.text-muted { color: #d1d5db; } display: inline-flex;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.stddev-good { font-weight: 600; color: #10b981; } .rate-high {
.stddev-ok { font-weight: 600; color: #f59e0b; } background: #ecfdf5;
.stddev-bad { font-weight: 600; color: #ef4444; } color: #10b981;
}
:deep(.ant-table-wrapper) { background: transparent; .rate-mid {
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; font-size: 13px; } background: #fffbeb;
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } color: #f59e0b;
}
.rate-low {
background: #fef2f2;
color: #ef4444;
}
.score-text {
font-weight: 700;
color: $primary;
}
.text-muted {
color: #d1d5db;
}
.stddev-good {
font-weight: 600;
color: #10b981;
}
.stddev-ok {
font-weight: 600;
color: #f59e0b;
}
.stddev-bad {
font-weight: 600;
color: #ef4444;
}
:deep(.ant-table-wrapper) {
background: transparent;
.ant-table-thead>tr>th {
background: #fafafa;
font-weight: 600;
font-size: 13px;
}
.ant-table-tbody>tr:hover>td {
background: rgba($primary, 0.03);
}
} }
</style> </style>