feat: 数据统计 API 与租户过滤对齐,补充 timeRange 与前端修复
Made-with: Cursor
This commit is contained in:
parent
d7dddd3058
commit
e9ae6aeb7e
@ -3,6 +3,7 @@ package com.competition.modules.biz.review.controller;
|
||||
import com.competition.common.result.Result;
|
||||
import com.competition.common.util.SecurityUtil;
|
||||
import com.competition.modules.biz.review.service.AnalyticsService;
|
||||
import com.competition.modules.sys.service.ISysTenantService;
|
||||
import com.competition.security.annotation.RequirePermission;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -18,6 +19,7 @@ import java.util.Map;
|
||||
public class AnalyticsController {
|
||||
|
||||
private final AnalyticsService analyticsService;
|
||||
private final ISysTenantService tenantService;
|
||||
|
||||
@GetMapping("/overview")
|
||||
@RequirePermission("contest:read")
|
||||
@ -26,15 +28,18 @@ public class AnalyticsController {
|
||||
@RequestParam(required = false) String timeRange,
|
||||
@RequestParam(required = false) Long contestId) {
|
||||
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")
|
||||
@RequirePermission("contest:read")
|
||||
@Operation(summary = "评审分析")
|
||||
public Result<Map<String, Object>> getReviewAnalysis(
|
||||
@RequestParam(required = false) String timeRange,
|
||||
@RequestParam(required = false) Long contestId) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -3,24 +3,29 @@ package com.competition.modules.biz.review.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.competition.common.enums.PublishStatus;
|
||||
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.BizContestRegistration;
|
||||
import com.competition.modules.biz.contest.entity.BizContestWork;
|
||||
import com.competition.modules.biz.contest.mapper.ContestMapper;
|
||||
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
|
||||
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
|
||||
import com.competition.modules.biz.review.entity.BizContestJudge;
|
||||
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 com.competition.modules.biz.review.mapper.AnalyticsMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 租户端数据统计(运营概览 / 评审分析)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsService {
|
||||
@ -28,45 +33,60 @@ public class AnalyticsService {
|
||||
private final ContestMapper contestMapper;
|
||||
private final ContestRegistrationMapper contestRegistrationMapper;
|
||||
private final ContestWorkMapper contestWorkMapper;
|
||||
private final ContestWorkScoreMapper contestWorkScoreMapper;
|
||||
private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper;
|
||||
private final ContestJudgeMapper contestJudgeMapper;
|
||||
private final AnalyticsMapper analyticsMapper;
|
||||
|
||||
/**
|
||||
* 数据概览
|
||||
* 运营概览
|
||||
*
|
||||
* @param timeRange month / quarter / year / all(空或 all 表示不限时间)
|
||||
*/
|
||||
public Map<String, Object> getOverview(Long tenantId, Long contestId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
public Map<String, Object> getOverview(Long tenantId, boolean isSuperTenant, Long contestId, String timeRange) {
|
||||
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 ---
|
||||
long totalContests = contestMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContest>()
|
||||
.eq(tenantId != null, BizContest::getContestState, PublishStatus.PUBLISHED.getValue()));
|
||||
long totalContests = contestIds.size();
|
||||
|
||||
long totalRegistrations = contestRegistrationMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestRegistration>()
|
||||
.eq(contestId != null, BizContestRegistration::getContestId, contestId));
|
||||
LambdaQueryWrapper<BizContestRegistration> regBase = new LambdaQueryWrapper<BizContestRegistration>()
|
||||
.in(BizContestRegistration::getContestId, contestIds);
|
||||
if (!isSuperTenant && tenantId != null) {
|
||||
regBase.eq(BizContestRegistration::getTenantId, tenantId);
|
||||
}
|
||||
applyRegistrationTimeRange(regBase, range);
|
||||
|
||||
long passedRegistrations = contestRegistrationMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestRegistration>()
|
||||
.eq(contestId != null, BizContestRegistration::getContestId, contestId)
|
||||
.eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue()));
|
||||
long totalRegistrations = contestRegistrationMapper.selectCount(regBase);
|
||||
|
||||
long totalWorks = contestWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestWork>()
|
||||
.eq(contestId != null, BizContestWork::getContestId, contestId)
|
||||
.eq(BizContestWork::getIsLatest, true));
|
||||
LambdaQueryWrapper<BizContestRegistration> regPassed = new LambdaQueryWrapper<BizContestRegistration>()
|
||||
.in(BizContestRegistration::getContestId, contestIds)
|
||||
.eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue());
|
||||
if (!isSuperTenant && tenantId != null) {
|
||||
regPassed.eq(BizContestRegistration::getTenantId, tenantId);
|
||||
}
|
||||
applyRegistrationTimeRange(regPassed, range);
|
||||
long passedRegistrations = contestRegistrationMapper.selectCount(regPassed);
|
||||
|
||||
long reviewedWorks = contestWorkScoreMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestWorkScore>()
|
||||
.eq(contestId != null, BizContestWorkScore::getContestId, contestId));
|
||||
LambdaQueryWrapper<BizContestWork> workBase = workBaseWrapper(contestIds, tenantId, isSuperTenant);
|
||||
applyWorkSubmitTimeRange(workBase, range);
|
||||
long totalWorks = contestWorkMapper.selectCount(workBase);
|
||||
|
||||
long awardedWorks = contestWorkMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestWork>()
|
||||
.eq(contestId != null, BizContestWork::getContestId, contestId)
|
||||
.eq(BizContestWork::getIsLatest, true)
|
||||
.isNotNull(BizContestWork::getAwardLevel)
|
||||
.ne(BizContestWork::getAwardLevel, "none"));
|
||||
LambdaQueryWrapper<BizContestWork> workReviewed = workBaseWrapper(contestIds, tenantId, isSuperTenant);
|
||||
workReviewed.in(BizContestWork::getStatus, WorkStatus.ACCEPTED.getValue(), WorkStatus.AWARDED.getValue());
|
||||
applyWorkSubmitTimeRange(workReviewed, range);
|
||||
long reviewedWorks = contestWorkMapper.selectCount(workReviewed);
|
||||
|
||||
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<>();
|
||||
summary.put("totalContests", totalContests);
|
||||
@ -77,97 +97,319 @@ public class AnalyticsService {
|
||||
summary.put("awardedWorks", awardedWorks);
|
||||
result.put("summary", summary);
|
||||
|
||||
// --- funnel ---
|
||||
List<Map<String, Object>> funnel = new ArrayList<>();
|
||||
funnel.add(Map.of("stage", "报名", "count", totalRegistrations));
|
||||
funnel.add(Map.of("stage", "审核通过", "count", passedRegistrations));
|
||||
funnel.add(Map.of("stage", "提交作品", "count", totalWorks));
|
||||
funnel.add(Map.of("stage", "已评审", "count", reviewedWorks));
|
||||
funnel.add(Map.of("stage", "获奖", "count", awardedWorks));
|
||||
// --- funnel(与 summary 同一口径)---
|
||||
Map<String, Object> funnel = new LinkedHashMap<>();
|
||||
funnel.put("registered", totalRegistrations);
|
||||
funnel.put("passed", passedRegistrations);
|
||||
funnel.put("submitted", totalWorks);
|
||||
funnel.put("reviewed", reviewedWorks);
|
||||
funnel.put("awarded", awardedWorks);
|
||||
result.put("funnel", funnel);
|
||||
|
||||
// --- 月度趋势(最近 6 个自然月窗口由 SQL 限定)---
|
||||
result.put("monthlyTrend", buildMonthlyTrend(contestIds, tenantId, isSuperTenant));
|
||||
|
||||
// --- 活动对比 ---
|
||||
result.put("contestComparison", buildContestComparison(contestIds, tenantId, isSuperTenant, range));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评审分析
|
||||
*/
|
||||
public Map<String, Object> getReviewAnalysis(Long tenantId, Long contestId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
public Map<String, Object> getReviewAnalysis(Long tenantId, boolean isSuperTenant, Long contestId, String timeRange) {
|
||||
List<Long> contestIds = resolveVisibleContestIds(tenantId, isSuperTenant, contestId);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// --- efficiency ---
|
||||
long totalAssignments = contestWorkJudgeAssignmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>()
|
||||
.eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId));
|
||||
if (contestIds.isEmpty()) {
|
||||
result.put("efficiency", emptyEfficiency());
|
||||
result.put("judgeWorkload", Collections.emptyList());
|
||||
result.put("awardDistribution", Collections.emptyList());
|
||||
return result;
|
||||
}
|
||||
|
||||
long completedAssignments = contestWorkJudgeAssignmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>()
|
||||
.eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)
|
||||
.eq(BizContestWorkJudgeAssignment::getStatus, "completed"));
|
||||
|
||||
long totalScores = contestWorkScoreMapper.selectCount(
|
||||
new LambdaQueryWrapper<BizContestWorkScore>()
|
||||
.eq(contestId != null, BizContestWorkScore::getContestId, contestId));
|
||||
BigDecimal avgDays = analyticsMapper.selectAvgReviewCycleDays(contestIds, tenantId, isSuperTenant);
|
||||
long scores30 = analyticsMapper.selectScoreCountLast30Days(contestIds, tenantId, isSuperTenant);
|
||||
long pending = analyticsMapper.selectPendingAssignments(contestIds);
|
||||
BigDecimal avgStd = analyticsMapper.selectAvgStddevAcrossWorks(contestIds, tenantId, isSuperTenant);
|
||||
|
||||
Map<String, Object> efficiency = new LinkedHashMap<>();
|
||||
efficiency.put("totalAssignments", totalAssignments);
|
||||
efficiency.put("completedAssignments", completedAssignments);
|
||||
efficiency.put("completionRate", totalAssignments > 0
|
||||
? Math.round(completedAssignments * 10000.0 / totalAssignments) / 100.0
|
||||
: 0);
|
||||
efficiency.put("totalScores", totalScores);
|
||||
efficiency.put("avgReviewDays", avgDays == null ? 0.0 : round2(avgDays.doubleValue()));
|
||||
efficiency.put("dailyReviewCount", Math.round(scores30 / 30.0 * 100.0) / 100.0);
|
||||
efficiency.put("pendingAssignments", pending);
|
||||
efficiency.put("avgScoreStddev", avgStd == null ? 0.0 : round2(avgStd.doubleValue()));
|
||||
|
||||
result.put("efficiency", efficiency);
|
||||
|
||||
// --- judgeWorkload ---
|
||||
List<BizContestJudge> judges = contestJudgeMapper.selectList(
|
||||
new LambdaQueryWrapper<BizContestJudge>()
|
||||
.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"));
|
||||
List<Map<String, Object>> judgeRows = analyticsMapper.selectJudgeWorkload(contestIds, tenantId, isSuperTenant);
|
||||
List<Map<String, Object>> judgeWorkload = new ArrayList<>();
|
||||
for (Map<String, Object> row : judgeRows) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("judgeId", judge.getJudgeId());
|
||||
item.put("specialty", judge.getSpecialty());
|
||||
item.put("assigned", assigned);
|
||||
item.put("completed", completed);
|
||||
item.put("completionRate", assigned > 0
|
||||
? Math.round(completed * 10000.0 / assigned) / 100.0
|
||||
: 0);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
Long judgeId = toLong(row.get("judgeId"));
|
||||
item.put("judgeId", judgeId != null ? judgeId : 0L);
|
||||
item.put("judgeName", Objects.toString(row.get("judgeName"), "评委"));
|
||||
item.put("contestCount", toLong(row.get("contestCount")));
|
||||
long assigned = toLong(row.get("assignedCount"));
|
||||
long scored = toLong(row.get("scoredCount"));
|
||||
item.put("assignedCount", assigned);
|
||||
item.put("scoredCount", scored);
|
||||
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);
|
||||
|
||||
// --- awardDistribution ---
|
||||
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);
|
||||
result.put("awardDistribution", buildAwardDistribution(contestIds, tenantId, isSuperTenant));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
220
backend-java/src/main/resources/mapper/biz/AnalyticsMapper.xml
Normal file
220
backend-java/src/main/resources/mapper/biz/AnalyticsMapper.xml
Normal 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>
|
||||
@ -19,7 +19,7 @@
|
||||
| 文档 | 模块 | 状态 | 日期 |
|
||||
|------|------|------|------|
|
||||
| [租户端全面优化](./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 |
|
||||
|
||||
## 用户端(公众端)
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# 租户端数据统计分析看板 — 设计方案
|
||||
|
||||
> 所属端:租户端(机构管理端)
|
||||
> 状态:已实现
|
||||
> 状态:已实现(后端契约与租户过滤已对齐 [2026-04-09])
|
||||
> 创建日期:2026-03-31
|
||||
> 最后更新:2026-03-31
|
||||
> 最后更新:2026-04-09
|
||||
|
||||
---
|
||||
|
||||
@ -197,16 +197,23 @@ GROUP BY award_name
|
||||
|
||||
## 5. 后端 API 设计
|
||||
|
||||
上下文路径以部署为准(开发环境一般为 `/api`)。实现类:`AnalyticsController` / `AnalyticsService` / `AnalyticsMapper`。
|
||||
|
||||
**多租户**:非超级租户时,活动范围由 `t_biz_contest.contest_tenants`(JSON 授权)限定;报名/作品/评分等子表再按 `tenant_id` 过滤。超级租户不按 `contest_tenants` 限活动。
|
||||
|
||||
### 5.1 运营概览
|
||||
|
||||
```
|
||||
GET /api/analytics/overview
|
||||
参数: timeRange(month/quarter/year/all), contestId?(可选)
|
||||
GET /analytics/overview
|
||||
参数:
|
||||
timeRange: 可选。month | quarter | year;不传或 all 表示不限时间。
|
||||
作用于 summary、funnel、contestComparison 的时间过滤(报名按 registration_time,作品按 submit_time)。
|
||||
contestId: 可选。指定单个活动;须属于当前租户可见活动。
|
||||
返回:
|
||||
{
|
||||
summary: { totalContests, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks },
|
||||
funnel: { registered, passed, submitted, reviewed, awarded },
|
||||
monthlyTrend: [{ month, registrations, works }],
|
||||
funnel: { registered, passed, submitted, reviewed, awarded }, // 对象,非数组
|
||||
monthlyTrend: [{ month: 'YYYY-MM', registrations, works }], // 最近 6 个自然月,与 timeRange 独立
|
||||
contestComparison: [{
|
||||
contestId, contestName,
|
||||
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 评审分析
|
||||
|
||||
```
|
||||
GET /api/analytics/review
|
||||
参数: timeRange, contestId?
|
||||
GET /analytics/review
|
||||
参数:
|
||||
contestId: 可选,同上。
|
||||
timeRange: 可传(与前端下拉一致);当前实现中评审分析各模块为「可见活动」全量统计,该参数预留,不参与过滤。
|
||||
返回:
|
||||
{
|
||||
efficiency: { avgReviewDays, dailyReviewCount, pendingAssignments, avgScoreStddev },
|
||||
@ -230,6 +241,8 @@ GET /api/analytics/review
|
||||
}
|
||||
```
|
||||
|
||||
`efficiency`:`avgReviewDays` 为作品提交至首次评分的平均天数;`dailyReviewCount` 为近 30 天评分条数 / 30;`pendingAssignments` 为分配状态 `assigned` 的条数;`avgScoreStddev` 为「多评委作品」分数标准差再对作品取平均。
|
||||
|
||||
## 6. 技术方案
|
||||
|
||||
- 前端图表库:使用 ECharts 或 Ant Design Charts(@ant-design/charts)
|
||||
|
||||
@ -57,6 +57,6 @@ export const analyticsApi = {
|
||||
getOverview: (params?: { timeRange?: string; contestId?: number }): Promise<OverviewData> =>
|
||||
request.get('/analytics/overview', { params }),
|
||||
|
||||
getReview: (params?: { contestId?: number }): Promise<ReviewData> =>
|
||||
getReview: (params?: { timeRange?: string; contestId?: number }): Promise<ReviewData> =>
|
||||
request.get('/analytics/review', { params }),
|
||||
}
|
||||
|
||||
@ -4,7 +4,14 @@
|
||||
<template #title>运营概览</template>
|
||||
<template #extra>
|
||||
<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>
|
||||
</a-space>
|
||||
@ -35,7 +42,9 @@
|
||||
<div class="funnel-header">
|
||||
<span class="funnel-label">{{ item.label }}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -56,9 +65,11 @@
|
||||
<!-- 活动对比 -->
|
||||
<div class="card-section" style="margin-top: 16px">
|
||||
<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 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>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'avgScore'">
|
||||
@ -91,6 +102,7 @@ use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<OverviewData | null>(null)
|
||||
const timeRange = ref<string>('all')
|
||||
const contestFilter = ref<number | undefined>(undefined)
|
||||
const contestOptions = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
@ -163,8 +175,11 @@ const fetchContestOptions = async () => {
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await analyticsApi.getOverview({ contestId: contestFilter.value })
|
||||
} catch { message.error('获取统计数据失败') }
|
||||
data.value = await analyticsApi.getOverview({
|
||||
contestId: contestFilter.value,
|
||||
timeRange: timeRange.value === 'all' ? undefined : timeRange.value,
|
||||
})
|
||||
} catch (error) { message.error('获取统计数据失败'); console.error(error) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@ -174,53 +189,201 @@ onMounted(() => { fetchContestOptions(); fetchData() })
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
.title-card {
|
||||
margin-bottom: 16px;
|
||||
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; }
|
||||
.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; }
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
: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 {
|
||||
background: #fff; 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; }
|
||||
background: #fff;
|
||||
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-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.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); }
|
||||
.funnel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.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-high { background: #ecfdf5; color: #10b981; }
|
||||
.rate-mid { background: #fffbeb; color: #f59e0b; }
|
||||
.rate-low { background: #fef2f2; color: #ef4444; }
|
||||
.rate-zero { background: #f3f4f6; color: #d1d5db; }
|
||||
.rate-pill {
|
||||
display: inline-flex;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.score-text { font-weight: 700; color: $primary; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
.rate-high {
|
||||
background: #ecfdf5;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
: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); }
|
||||
.rate-mid {
|
||||
background: #fffbeb;
|
||||
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>
|
||||
|
||||
@ -3,9 +3,18 @@
|
||||
<a-card class="title-card">
|
||||
<template #title>评审分析</template>
|
||||
<template #extra>
|
||||
<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>
|
||||
<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>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
@ -28,11 +37,13 @@
|
||||
<!-- 评委工作量 -->
|
||||
<div class="card-section col-span-3">
|
||||
<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 v-if="column.key === 'judgeName'">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@ -81,6 +92,7 @@ use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<ReviewData | null>(null)
|
||||
const timeRange = ref<string>('all')
|
||||
const contestFilter = ref<number | undefined>(undefined)
|
||||
const contestOptions = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
@ -154,8 +166,11 @@ const fetchContestOptions = async () => {
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await analyticsApi.getReview({ contestId: contestFilter.value })
|
||||
} catch { message.error('获取评审分析数据失败') }
|
||||
data.value = await analyticsApi.getReview({
|
||||
contestId: contestFilter.value,
|
||||
timeRange: timeRange.value === 'all' ? undefined : timeRange.value,
|
||||
})
|
||||
} catch (error) { message.error('获取评审分析数据失败'); console.error(error) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@ -165,54 +180,200 @@ onMounted(() => { fetchContestOptions(); fetchData() })
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
:deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
|
||||
:deep(.ant-card-body) { padding: 0; }
|
||||
}
|
||||
.title-card {
|
||||
margin-bottom: 16px;
|
||||
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; }
|
||||
.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; }
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-5-2 { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; }
|
||||
.col-span-3 { grid-column: 1; }
|
||||
.col-span-2 { grid-column: 2; }
|
||||
.stats-row {
|
||||
display: grid;
|
||||
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 {
|
||||
background: #fff; 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; }
|
||||
background: #fff;
|
||||
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 {
|
||||
width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0;
|
||||
width: 32px;
|
||||
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; }
|
||||
.rate-high { background: #ecfdf5; color: #10b981; }
|
||||
.rate-mid { background: #fffbeb; color: #f59e0b; }
|
||||
.rate-low { background: #fef2f2; color: #ef4444; }
|
||||
.judge-name {
|
||||
font-weight: 500;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
.score-text { font-weight: 700; color: $primary; }
|
||||
.text-muted { color: #d1d5db; }
|
||||
.rate-pill {
|
||||
display: inline-flex;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stddev-good { font-weight: 600; color: #10b981; }
|
||||
.stddev-ok { font-weight: 600; color: #f59e0b; }
|
||||
.stddev-bad { font-weight: 600; color: #ef4444; }
|
||||
.rate-high {
|
||||
background: #ecfdf5;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
: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); }
|
||||
.rate-mid {
|
||||
background: #fffbeb;
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user