feat: 作品列表终分回算、评审进度详情展示对齐及评委管理优化

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-08 10:53:07 +08:00
parent bc7c17b281
commit 180c22fe49
18 changed files with 1401 additions and 1059 deletions

View File

@ -18,6 +18,8 @@ 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.contest.service.IContestService;
import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.competition.modules.sys.entity.SysTenant;
import com.competition.modules.sys.mapper.SysTenantMapper;
import lombok.RequiredArgsConstructor;
@ -39,6 +41,7 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
private final ContestAttachmentMapper contestAttachmentMapper;
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestWorkMapper contestWorkMapper;
private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper;
private final SysTenantMapper sysTenantMapper;
// 支持两种日期格式ISO 格式 (T 分隔) 和空格分隔格式
@ -160,6 +163,7 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
Map<Long, Long> registrationCountMap = new HashMap<>();
Map<Long, Long> workCountMap = new HashMap<>();
Map<Long, Long> reviewedWorkCountMap = new HashMap<>();
if (!contestIds.isEmpty()) {
// 报名数所有状态
contestRegistrationMapper.selectList(
@ -169,23 +173,42 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
.collect(Collectors.groupingBy(BizContestRegistration::getContestId, Collectors.counting()))
.forEach(registrationCountMap::put);
// 作品最新版本
contestWorkMapper.selectList(
// 作品最新有效版本评审完成数 = 已分配且该作品全部分配记录均为 completed与评委端ProgressDetail 一致
List<BizContestWork> contestWorks = contestWorkMapper.selectList(
new LambdaQueryWrapper<BizContestWork>()
.in(BizContestWork::getContestId, contestIds)
.eq(BizContestWork::getIsLatest, true))
.stream()
.collect(Collectors.groupingBy(BizContestWork::getContestId, Collectors.counting()))
.forEach(workCountMap::put);
.eq(BizContestWork::getIsLatest, true)
.eq(BizContestWork::getValidState, 1));
Set<Long> workIdSet = contestWorks.stream().map(BizContestWork::getId).collect(Collectors.toSet());
Map<Long, List<BizContestWorkJudgeAssignment>> assignByWorkId = new HashMap<>();
if (!workIdSet.isEmpty()) {
List<BizContestWorkJudgeAssignment> allAssign = contestWorkJudgeAssignmentMapper.selectList(
new LambdaQueryWrapper<BizContestWorkJudgeAssignment>()
.in(BizContestWorkJudgeAssignment::getWorkId, workIdSet));
assignByWorkId = allAssign.stream()
.collect(Collectors.groupingBy(BizContestWorkJudgeAssignment::getWorkId));
}
for (BizContestWork w : contestWorks) {
Long cid = w.getContestId();
workCountMap.merge(cid, 1L, Long::sum);
List<BizContestWorkJudgeAssignment> assigns = assignByWorkId.getOrDefault(w.getId(), Collections.emptyList());
if (!assigns.isEmpty() && assigns.stream().allMatch(a -> "completed".equals(a.getStatus()))) {
reviewedWorkCountMap.merge(cid, 1L, Long::sum);
}
}
}
List<Map<String, Object>> voList = result.getRecords().stream()
.map(entity -> {
Map<String, Object> map = entityToMap(entity);
Map<String, Object> countMap = new LinkedHashMap<>();
long works = workCountMap.getOrDefault(entity.getId(), 0L);
long reviewedWorks = reviewedWorkCountMap.getOrDefault(entity.getId(), 0L);
countMap.put("registrations", registrationCountMap.getOrDefault(entity.getId(), 0L));
countMap.put("works", workCountMap.getOrDefault(entity.getId(), 0L));
countMap.put("works", works);
map.put("_count", countMap);
map.put("totalWorksCount", works);
map.put("reviewedCount", reviewedWorks);
return map;
})
.collect(Collectors.toList());

View File

@ -18,8 +18,15 @@ import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkAttachmentMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.contest.service.IContestWorkService;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.entity.BizContestReviewRule;
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.ContestReviewRuleMapper;
import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper;
import com.competition.modules.biz.review.util.ContestFinalScoreCalculator;
import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
@ -28,6 +35,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@ -46,6 +54,9 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
private final ContestMapper contestMapper;
private final ContestWorkJudgeAssignmentMapper assignmentMapper;
private final SysUserMapper sysUserMapper;
private final ContestWorkScoreMapper contestWorkScoreMapper;
private final ContestReviewRuleMapper contestReviewRuleMapper;
private final ContestJudgeMapper contestJudgeMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@ -355,6 +366,49 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
Map<Long, BizContest> finalContestMap = contestMap;
Map<Long, List<BizContestWorkJudgeAssignment>> finalAssignmentMap = assignmentMap;
Map<Long, String> finalJudgeNameMap = judgeNameMap;
// 批量评分列表评委评分与详情抽屉一致作品表 final_score 未落库时按评审规则从评分表回算
Map<Long, List<BizContestWorkScore>> scoresByWorkId = new HashMap<>();
if (!workIds.isEmpty()) {
LambdaQueryWrapper<BizContestWorkScore> scoreWrapper = new LambdaQueryWrapper<>();
scoreWrapper.in(BizContestWorkScore::getWorkId, workIds);
scoreWrapper.eq(BizContestWorkScore::getValidState, 1);
List<BizContestWorkScore> allPageScores = contestWorkScoreMapper.selectList(scoreWrapper);
scoresByWorkId = allPageScores.stream()
.collect(Collectors.groupingBy(BizContestWorkScore::getWorkId));
}
Map<Long, String> contestCalculationRuleCache = new HashMap<>();
Map<Long, Map<Long, BigDecimal>> contestWeightMapCache = new HashMap<>();
for (Long cid : contestIds) {
BizContest c = contestMap.get(cid);
if (c == null) {
continue;
}
String calculationRule = "average";
if (c.getReviewRuleId() != null) {
BizContestReviewRule rule = contestReviewRuleMapper.selectById(c.getReviewRuleId());
if (rule != null && StringUtils.hasText(rule.getCalculationRule())) {
calculationRule = rule.getCalculationRule();
}
}
contestCalculationRuleCache.put(cid, calculationRule);
LambdaQueryWrapper<BizContestJudge> judgeWrapper = new LambdaQueryWrapper<>();
judgeWrapper.eq(BizContestJudge::getContestId, cid);
judgeWrapper.eq(BizContestJudge::getValidState, 1);
List<BizContestJudge> judges = contestJudgeMapper.selectList(judgeWrapper);
Map<Long, BigDecimal> weightMap = new HashMap<>();
for (BizContestJudge j : judges) {
weightMap.put(j.getJudgeId(), j.getWeight() != null ? j.getWeight() : BigDecimal.ONE);
}
contestWeightMapCache.put(cid, weightMap);
}
Map<Long, List<BizContestWorkScore>> finalScoresByWorkId = scoresByWorkId;
Map<Long, String> finalContestCalculationRuleCache = contestCalculationRuleCache;
Map<Long, Map<Long, BigDecimal>> finalContestWeightMapCache = contestWeightMapCache;
List<Map<String, Object>> voList = result.getRecords().stream()
.map(work -> {
Map<String, Object> map = workToMap(work);
@ -413,6 +467,27 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
countVo.put("assignments", workAssignments.size());
map.put("_count", countVo);
int totalJudgesCount = workAssignments.size();
long reviewedCount = workAssignments.stream()
.filter(a -> "completed".equals(a.getStatus()))
.count();
map.put("totalJudgesCount", totalJudgesCount);
map.put("reviewedCount", reviewedCount);
BigDecimal displayFinal = work.getFinalScore();
if (displayFinal == null) {
List<BizContestWorkScore> scores = finalScoresByWorkId.getOrDefault(work.getId(), Collections.emptyList());
if (!scores.isEmpty()) {
String rule = finalContestCalculationRuleCache.getOrDefault(work.getContestId(), "average");
Map<Long, BigDecimal> wm = finalContestWeightMapCache.getOrDefault(work.getContestId(), Collections.emptyMap());
displayFinal = ContestFinalScoreCalculator.compute(scores, rule, wm);
}
}
if (displayFinal != null) {
map.put("finalScore", displayFinal);
map.put("averageScore", displayFinal);
}
return map;
})
.collect(Collectors.toList());

View File

@ -1,6 +1,7 @@
package com.competition.modules.biz.review.controller;
import com.competition.common.result.Result;
import com.competition.modules.biz.review.dto.ContestJudgesForContestVo;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.service.IContestJudgeService;
import com.competition.security.annotation.RequirePermission;
@ -10,7 +11,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Tag(name = "赛事评委")
@ -35,8 +35,9 @@ public class ContestJudgeController {
@GetMapping("/contest/{contestId}")
@RequirePermission("contest:read")
@Operation(summary = "查询赛事评委列表")
public Result<List<Map<String, Object>>> findByContest(@PathVariable Long contestId) {
@Operation(summary = "查询赛事评委列表",
description = "返回 assigned显式关联与 implicitPool平台隐式池。添加评委抽屉仅用 assigned 回显;作品分配可选池为 assigned implicitPool前端合并")
public Result<ContestJudgesForContestVo> findByContest(@PathVariable Long contestId) {
return Result.success(contestJudgeService.findByContest(contestId));
}

View File

@ -0,0 +1,21 @@
package com.competition.modules.biz.review.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 某赛事下的评委数据显式关联与平台隐式池分离避免扁平列表语义混淆
*/
@Data
@Schema(description = "赛事评委查询结果")
public class ContestJudgesForContestVo {
@Schema(description = "机构为该赛事显式添加的评委t_biz_contest_judge每条必有 id、judgeId添加评委抽屉回显与提交差集仅基于此列表")
private List<Map<String, Object>> assigned;
@Schema(description = "平台评委租户下对该赛事默认可用、未写入关联表的用户id 为 nullisPlatform 为 true可与 assigned 合并作为作品分配可选池")
private List<Map<String, Object>> implicitPool;
}

View File

@ -1,17 +1,21 @@
package com.competition.modules.biz.review.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.biz.review.dto.ContestJudgesForContestVo;
import com.competition.modules.biz.review.entity.BizContestJudge;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public interface IContestJudgeService extends IService<BizContestJudge> {
BizContestJudge createJudge(Long contestId, Long judgeId, String specialty, BigDecimal weight, String description);
List<Map<String, Object>> findByContest(Long contestId);
/**
* 查询某赛事评委{@link ContestJudgesForContestVo#getAssigned()} 为显式关联
* {@link ContestJudgesForContestVo#getImplicitPool()} 为平台默认可用未落库项
*/
ContestJudgesForContestVo findByContest(Long contestId);
Map<String, Object> findDetail(Long id);

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.competition.common.enums.ErrorCode;
import com.competition.common.exception.BusinessException;
import com.competition.modules.biz.review.dto.ContestJudgesForContestVo;
import com.competition.modules.biz.review.entity.BizContestJudge;
import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.competition.modules.biz.review.mapper.ContestJudgeMapper;
@ -57,7 +58,7 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
}
@Override
public List<Map<String, Object>> findByContest(Long contestId) {
public ContestJudgesForContestVo findByContest(Long contestId) {
log.info("查询赛事评委列表赛事ID{}", contestId);
// 获取平台评委租户 ID
@ -111,7 +112,8 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
assignedCountMap.put(judgeId, assignmentMapper.selectCount(assignWrapper));
}
List<Map<String, Object>> result = new ArrayList<>();
List<Map<String, Object>> assigned = new ArrayList<>();
List<Map<String, Object>> implicitPool = new ArrayList<>();
// 构建已显式分配的评委数据
for (BizContestJudge j : judges) {
@ -136,10 +138,10 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
} else {
map.put("isPlatform", false);
}
result.add(map);
assigned.add(map);
}
// 追加未显式分配的平台评委
// 未显式分配的平台评委隐式池
for (SysUser platformJudge : platformJudges) {
if (assignedJudgeIds.contains(platformJudge.getId())) {
continue; // 已在显式分配列表中跳过
@ -159,10 +161,13 @@ public class ContestJudgeServiceImpl extends ServiceImpl<ContestJudgeMapper, Biz
map.put("status", platformJudge.getStatus());
map.put("organization", platformJudge.getOrganization());
map.put("isPlatform", true);
result.add(map);
implicitPool.add(map);
}
return result;
ContestJudgesForContestVo vo = new ContestJudgesForContestVo();
vo.setAssigned(assigned);
vo.setImplicitPool(implicitPool);
return vo;
}
/**

View File

@ -313,19 +313,30 @@ public class ContestReviewServiceImpl implements IContestReviewService {
public List<Map<String, Object>> getJudgeContests(Long judgeId) {
log.info("查询评委关联赛事评委ID{}", judgeId);
LambdaQueryWrapper<BizContestJudge> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestJudge::getJudgeId, judgeId);
wrapper.eq(BizContestJudge::getValidState, 1);
Set<Long> contestIds = new LinkedHashSet<>();
List<BizContestJudge> judgeRecords = judgeMapper.selectList(wrapper);
Set<Long> contestIds = judgeRecords.stream().map(BizContestJudge::getContestId).collect(Collectors.toSet());
LambdaQueryWrapper<BizContestJudge> judgeQw = new LambdaQueryWrapper<>();
judgeQw.eq(BizContestJudge::getJudgeId, judgeId);
judgeQw.eq(BizContestJudge::getValidState, 1);
for (BizContestJudge r : judgeMapper.selectList(judgeQw)) {
contestIds.add(r.getContestId());
}
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignQw = new LambdaQueryWrapper<>();
assignQw.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
assignQw.select(BizContestWorkJudgeAssignment::getContestId);
for (BizContestWorkJudgeAssignment a : assignmentMapper.selectList(assignQw)) {
contestIds.add(a.getContestId());
}
if (contestIds.isEmpty()) {
return Collections.emptyList();
}
List<BizContest> contests = contestMapper.selectBatchIds(contestIds);
return contests.stream().map(c -> {
return contests.stream()
.sorted(Comparator.comparing(BizContest::getId).reversed())
.map(c -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("contestId", c.getId());
map.put("contestName", c.getContestName());
@ -333,8 +344,24 @@ public class ContestReviewServiceImpl implements IContestReviewService {
map.put("status", c.getStatus());
map.put("reviewStartTime", c.getReviewStartTime());
map.put("reviewEndTime", c.getReviewEndTime());
LambdaQueryWrapper<BizContestWorkJudgeAssignment> totalW = new LambdaQueryWrapper<>();
totalW.eq(BizContestWorkJudgeAssignment::getContestId, c.getId());
totalW.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
long totalAssigned = assignmentMapper.selectCount(totalW);
LambdaQueryWrapper<BizContestWorkJudgeAssignment> doneW = new LambdaQueryWrapper<>();
doneW.eq(BizContestWorkJudgeAssignment::getContestId, c.getId());
doneW.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
doneW.eq(BizContestWorkJudgeAssignment::getStatus, "completed");
long reviewed = assignmentMapper.selectCount(doneW);
map.put("totalAssigned", totalAssigned);
map.put("reviewed", reviewed);
map.put("pending", totalAssigned - reviewed);
return map;
}).collect(Collectors.toList());
})
.collect(Collectors.toList());
}
@Override
@ -348,8 +375,14 @@ public class ContestReviewServiceImpl implements IContestReviewService {
wrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId);
if (StringUtils.hasText(reviewStatus)) {
if ("reviewed".equalsIgnoreCase(reviewStatus)) {
wrapper.eq(BizContestWorkJudgeAssignment::getStatus, "completed");
} else if ("pending".equalsIgnoreCase(reviewStatus)) {
wrapper.ne(BizContestWorkJudgeAssignment::getStatus, "completed");
} else {
wrapper.eq(BizContestWorkJudgeAssignment::getStatus, reviewStatus);
}
}
wrapper.orderByAsc(BizContestWorkJudgeAssignment::getAssignmentTime);
@ -675,14 +708,20 @@ public class ContestReviewServiceImpl implements IContestReviewService {
public Map<String, Object> getJudgeContestDetail(Long judgeId, Long contestId) {
log.info("获取评委赛事详情评委ID{}赛事ID{}", judgeId, contestId);
// 验证评委属于该赛事
// 显式评委 已有作品分配记录
LambdaQueryWrapper<BizContestJudge> judgeWrapper = new LambdaQueryWrapper<>();
judgeWrapper.eq(BizContestJudge::getContestId, contestId);
judgeWrapper.eq(BizContestJudge::getJudgeId, judgeId);
judgeWrapper.eq(BizContestJudge::getValidState, 1);
if (judgeMapper.selectCount(judgeWrapper) == 0) {
boolean explicitJudge = judgeMapper.selectCount(judgeWrapper) > 0;
if (!explicitJudge) {
LambdaQueryWrapper<BizContestWorkJudgeAssignment> assignCheck = new LambdaQueryWrapper<>();
assignCheck.eq(BizContestWorkJudgeAssignment::getContestId, contestId);
assignCheck.eq(BizContestWorkJudgeAssignment::getJudgeId, judgeId);
if (assignmentMapper.selectCount(assignCheck) == 0) {
throw BusinessException.of(ErrorCode.FORBIDDEN, "您不是该赛事的评委");
}
}
BizContest contest = contestMapper.selectById(contestId);
if (contest == null) {

View File

@ -0,0 +1,72 @@
package com.competition.modules.biz.review.util;
import com.competition.modules.biz.review.entity.BizContestWorkScore;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* {@code ContestReviewServiceImpl#calculateFinalScore}成果发布计算逻辑一致的终分计算列表场景对不足条数规则做温和回退
*/
public final class ContestFinalScoreCalculator {
private ContestFinalScoreCalculator() {
}
public static BigDecimal compute(
List<BizContestWorkScore> scores,
String calculationRule,
Map<Long, BigDecimal> weightMap) {
if (scores == null || scores.isEmpty()) {
return null;
}
String rule = calculationRule != null ? calculationRule : "average";
Map<Long, BigDecimal> wm = weightMap != null ? weightMap : Collections.emptyMap();
List<BigDecimal> scoreValues = scores.stream()
.map(BizContestWorkScore::getTotalScore)
.sorted()
.collect(Collectors.toList());
switch (rule) {
case "max":
return Collections.max(scoreValues);
case "min":
return Collections.min(scoreValues);
case "weighted":
BigDecimal weightedSum = BigDecimal.ZERO;
BigDecimal totalWeight = BigDecimal.ZERO;
for (BizContestWorkScore s : scores) {
BigDecimal w = wm.getOrDefault(s.getJudgeId(), BigDecimal.ONE);
weightedSum = weightedSum.add(s.getTotalScore().multiply(w));
totalWeight = totalWeight.add(w);
}
return totalWeight.compareTo(BigDecimal.ZERO) > 0
? weightedSum.divide(totalWeight, 2, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
case "remove_max_min":
if (scoreValues.size() < 3) {
BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP);
}
List<BigDecimal> trimmed = scoreValues.subList(1, scoreValues.size() - 1);
BigDecimal trimmedSum = trimmed.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return trimmedSum.divide(BigDecimal.valueOf(trimmed.size()), 2, RoundingMode.HALF_UP);
case "remove_min":
if (scoreValues.size() < 2) {
return scoreValues.get(0);
}
List<BigDecimal> withoutMin = scoreValues.subList(1, scoreValues.size());
BigDecimal withoutMinSum = withoutMin.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return withoutMinSum.divide(BigDecimal.valueOf(withoutMin.size()), 2, RoundingMode.HALF_UP);
case "average":
default:
BigDecimal sum = scoreValues.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(scoreValues.size()), 2, RoundingMode.HALF_UP);
}
}
}

View File

@ -31,4 +31,6 @@
## 评委端
(暂无)
| 文档 | 模块 | 状态 | 日期 |
|------|------|------|------|
| [评审任务](./judge-portal/review-tasks.md) | 评审任务 / 作品列表 | 已实现 | 2026-04-08 |

View File

@ -0,0 +1,60 @@
# 评委端:评审任务
> 所属端:评委端(`tenant_id` 对应评委租户,如 `code=judge`
> 菜单与定位见 [菜单配置说明](../menu-config.md) 中「评委端」章节:仅能查看**被分配**的活动与作品。
## 活动列表
**接口**`GET /contests/reviews/judge/contests`
**活动来源(并集)**
1. `t_biz_contest_judge``judge_id``valid_state=1` 的赛事(机构显式添加的评委);
2. `t_biz_contest_work_judge_assignment` 中该评委出现过的 `contest_id`(含仅通过作品分配参与的隐式场景)。
**响应字段(与前端 `activities/Review.vue` 对齐)**
| 字段 | 说明 |
|------|------|
| `contestId` | 活动 ID |
| `contestName` | 活动名称 |
| `contestState` / `status` | 活动状态 |
| `reviewStartTime` / `reviewEndTime` | 评审时间窗 |
| `totalAssigned` | 该评委在该活动下的分配记录总数 |
| `reviewed` | 其中 `status=completed` 的数量(已提交评分) |
| `pending` | `totalAssigned - reviewed`(待评审) |
## 活动下作品列表
**接口**`GET /contests/reviews/judge/contests/{contestId}/works`
**分配状态 `reviewStatus` 查询参数(与库表兼容)**
- 库中 `t_biz_contest_work_judge_assignment.status` 实际使用:`assigned`(已分配未评完)、`completed`(已评审)。
- 前端下拉「未评审」传 `pending` → 后端按 **`status != completed`** 筛选。
- 前端「已评审」传 `reviewed` → 后端按 **`status = completed`** 筛选。
**作品编号**`workNo` 为空时,前端可用作品 `workId` 展示兜底(如 `#123`)。
## 活动详情(含评审规则)
**接口**`GET /contests/reviews/judge/contests/{contestId}/detail`
**权限**:满足以下**任一**即可:
- 存在有效的 `t_biz_contest_judge` 关联;或
- 存在该 `contestId` + `judgeId` 的作品分配记录。
避免「列表能进、详情 403」与隐式评委场景不一致。
---
## 与租户端「评审进度」的口径对齐
| 维度 | 租户机构端 `contests/reviews/progress`(活动列表行) | 评委端 `activities/review`(上表) |
|------|------------------------------------------------------|-------------------------------------|
| 数据来源 | `GET /contests` 列表项中的 `reviewedCount` / `totalWorksCount` | `GET /contests/reviews/judge/contests` |
| 含义 | `totalWorksCount`:该活动最新有效作品总数。`reviewedCount`**该活动下已分配评委且全部分配记录均为 `completed` 的作品数**(与分配表 `t_biz_contest_work_judge_assignment` 一致,与作品表 `accepted`/`awarded` 终态无关) | **评委维度**`reviewed`/`totalAssigned`/`pending` 为该评委在分配表上的任务数;与租户「整作品是否全部评委评完」为不同聚合粒度 |
| 作品列表/详情 | `GET /contests/works` 每条作品含 `reviewedCount`/`totalJudgesCount`(按该作品分配条数统计) | 评委在单活动下作品列表同样基于分配 `completed` |
说明:顶部「作品统计」卡片若仍按作品 `status` 汇总,可能与逐活动行「分配完成作品数」不完全同数,属汇总维度不同;列表/详情/评委任务以分配表为准。

View File

@ -132,6 +132,8 @@
**定位**:评委评审工作台,只能看到自己被分配的活动和作品。
**详细接口与字段说明**[评委端评审任务](./judge-portal/review-tasks.md)。
**一级菜单**1 个(我的评审)
```

View File

@ -3,7 +3,7 @@
> 所属端:租户端(机构管理员视角)
> 状态:已优化
> 创建日期2026-03-31
> 最后更新2026-03-31
> 最后更新2026-04-08
---
@ -58,6 +58,12 @@
- [x] 主色调统一 #6366f1
- [x] 冻结/解冻二次确认
#### 赛事评委接口(`GET /contests/judges/contest/:id`
- 响应为结构化对象,包含两部分:**`assigned`**(机构在该赛事下**显式添加**的评委,对应 `t_biz_contest_judge`,每条均有 `id`、`judgeId` 等)与 **`implicitPool`**(平台评委租户下对该赛事**默认可用**、尚未写入关联表的用户,`id` 为 null`isPlatform` 为 true
- **添加评委抽屉**:「已选」回显与提交时的增删差集**仅基于 `assigned`**;可选评委仍来自评委管理分页接口。
- **作品分配**:可选评委池为 **`assigned` `implicitPool`**(前端合并);表格行键与选中状态统一使用 **`judgeId`**,与分配接口提交的 `judgeIds` 一致。
### 报名管理Index
- [x] 去掉个人/团队 Tab合并展示加类型列
- [x] 统计概览(总报名/待审核/已通过/已拒绝)
@ -93,6 +99,7 @@
- [x] 评审状态改用实际完成率(无作品/未开始/进行中/已完成)
- [x] 进度数字颜色区分
- [x] 评审进度详情页筛选修复(评审进度前端过滤生效)
- 活动列表接口 `GET /contests` 为每行返回 `reviewedCount`(该活动下**已分配且全部分配均为 completed** 的作品数)与 `totalWorksCount`(最新有效作品总数),与分配表及评委端评审任务口径一致;见 [评委端评审任务](../judge-portal/review-tasks.md#与租户端评审进度的口径对齐)。
### 评审规则
- [x] 组件映射修复

View File

@ -66,6 +66,10 @@ export interface Contest {
teams: number;
judges: number;
};
/** 评审进度(列表接口 findAll 填充):已分配且全部分配均为 completed 的作品数(与分配表一致) */
reviewedCount?: number;
/** 评审进度(列表接口 findAll 填充):最新有效作品总数,与 _count.works 一致 */
totalWorksCount?: number;
}
export interface CreateContestForm {
@ -119,7 +123,12 @@ export interface QueryContestParams extends PaginationParams {
status?: "ongoing" | "finished";
contestType?: string;
visibility?: string;
stage?: "unpublished" | "registering" | "submitting" | "reviewing" | "finished";
stage?:
| "unpublished"
| "registering"
| "submitting"
| "reviewing"
| "finished";
creatorTenantId?: number;
role?: "student" | "teacher" | "judge";
}
@ -383,6 +392,8 @@ export interface ContestWork {
// 评审统计字段(由后端计算返回)
reviewedCount?: number;
totalJudgesCount?: number;
/** 终分(作品列表 workToMap 等接口返回) */
finalScore?: number | null;
averageScore?: number | null;
}
@ -459,6 +470,8 @@ export interface ContestWorkJudgeAssignment {
}
export interface ContestWorkScore {
/** 与 id 同源,评分列表扁平接口常用 scoreId */
scoreId?: number;
id: number;
tenantId: number;
contestId: number;
@ -523,7 +536,8 @@ export interface CreateNoticeForm {
// ==================== 评委相关类型 ====================
export interface ContestJudge {
id: number;
/** t_biz_contest_judge 主键implicitPool 中隐式平台评委为 null */
id?: number | null;
contestId: number;
judgeId: number;
specialty?: string;
@ -534,6 +548,14 @@ export interface ContestJudge {
createTime?: string;
modifyTime?: string;
validState?: number;
/** 隐式平台评委(未写入关联表时由后端追加) */
isPlatform?: boolean;
judgeName?: string;
judgeUsername?: string;
assignedCount?: number;
organization?: string;
status?: string;
tenantId?: number;
contest?: Contest;
judge?: {
id: number;
@ -541,8 +563,8 @@ export interface ContestJudge {
nickname: string;
email?: string;
phone?: string;
gender?: 'male' | 'female';
status?: 'enabled' | 'disabled';
gender?: "male" | "female";
status?: "enabled" | "disabled";
tenantId?: number;
tenant?: {
id: number;
@ -562,6 +584,19 @@ export interface ContestJudge {
};
}
/** GET /contests/judges/contest/:id 结构化响应:显式关联与隐式平台池分离 */
export interface ContestJudgesForContestResponse {
assigned: ContestJudge[];
implicitPool: ContestJudge[];
}
/** 作品分配等场景合并为可选评委池assigned implicitPool */
export function flattenContestJudgePool(
r: ContestJudgesForContestResponse,
): ContestJudge[] {
return [...(r.assigned ?? []), ...(r.implicitPool ?? [])];
}
export interface CreateJudgeForm {
contestId: number;
judgeId: number;
@ -582,22 +617,22 @@ export const contestsApi = {
// 获取活动列表
getList: async (
params: QueryContestParams
params: QueryContestParams,
): Promise<PaginationResponse<Contest>> => {
const response = await request.get<any, PaginationResponse<Contest>>(
"/contests",
{ params }
{ params },
);
return response;
},
// 获取我参与的活动列表
getMyContests: async (
params: QueryContestParams
params: QueryContestParams,
): Promise<PaginationResponse<Contest>> => {
const response = await request.get<any, PaginationResponse<Contest>>(
"/contests/my-contests",
{ params }
{ params },
);
return response;
},
@ -623,11 +658,11 @@ export const contestsApi = {
// 发布/撤回活动
publish: async (
id: number,
contestState: "unpublished" | "published"
contestState: "unpublished" | "published",
): Promise<Contest> => {
const response = await request.patch<any, Contest>(
`/contests/${id}/publish`,
{ contestState }
{ contestState },
);
return response;
},
@ -639,13 +674,17 @@ export const contestsApi = {
// 标记活动完结
finish: async (id: number): Promise<Contest> => {
const response = await request.patch<any, Contest>(`/contests/${id}/finish`);
const response = await request.patch<any, Contest>(
`/contests/${id}/finish`,
);
return response;
},
// 重新开启已完结的活动
reopen: async (id: number): Promise<Contest> => {
const response = await request.patch<any, Contest>(`/contests/${id}/reopen`);
const response = await request.patch<any, Contest>(
`/contests/${id}/reopen`,
);
return response;
},
};
@ -655,7 +694,7 @@ export const attachmentsApi = {
// 获取活动附件列表
getList: async (contestId: number): Promise<ContestAttachment[]> => {
const response = await request.get<any, ContestAttachment[]>(
`/contests/attachments/contest/${contestId}`
`/contests/attachments/contest/${contestId}`,
);
return response;
},
@ -664,7 +703,7 @@ export const attachmentsApi = {
create: async (data: CreateAttachmentForm): Promise<ContestAttachment> => {
const response = await request.post<any, ContestAttachment>(
"/contests/attachments",
data
data,
);
return response;
},
@ -710,25 +749,30 @@ export interface QueryReviewRuleParams {
export const reviewRulesApi = {
// 获取评审规则列表
getList: async (params?: QueryReviewRuleParams): Promise<{
getList: async (
params?: QueryReviewRuleParams,
): Promise<{
list: ReviewRule[];
total: number;
page: number;
pageSize: number;
}> => {
const response = await request.get<any, {
const response = await request.get<
any,
{
list: ReviewRule[];
total: number;
page: number;
pageSize: number;
}>("/contests/review-rules", { params });
}
>("/contests/review-rules", { params });
return response;
},
// 获取所有可用的评审规则(用于活动创建时选择)
getForSelect: async (): Promise<ReviewRuleForSelect[]> => {
const response = await request.get<any, ReviewRuleForSelect[]>(
"/contests/review-rules/select"
"/contests/review-rules/select",
);
return response;
},
@ -736,7 +780,7 @@ export const reviewRulesApi = {
// 获取评审规则详情
getDetail: async (id: number): Promise<ReviewRule> => {
const response = await request.get<any, ReviewRule>(
`/contests/review-rules/${id}`
`/contests/review-rules/${id}`,
);
return response;
},
@ -745,7 +789,7 @@ export const reviewRulesApi = {
create: async (data: CreateReviewRuleForm): Promise<ReviewRule> => {
const response = await request.post<any, ReviewRule>(
"/contests/review-rules",
data
data,
);
return response;
},
@ -753,11 +797,11 @@ export const reviewRulesApi = {
// 更新评审规则
update: async (
id: number,
data: Partial<CreateReviewRuleForm>
data: Partial<CreateReviewRuleForm>,
): Promise<ReviewRule> => {
const response = await request.patch<any, ReviewRule>(
`/contests/review-rules/${id}`,
data
data,
);
return response;
},
@ -776,14 +820,14 @@ export const registrationsApi = {
if (contestId) params.contestId = contestId;
const response = await request.get<any, RegistrationStats>(
"/contests/registrations/stats",
{ params }
{ params },
);
return response;
},
// 获取报名列表
getList: async (
params: QueryRegistrationParams
params: QueryRegistrationParams,
): Promise<PaginationResponse<ContestRegistration>> => {
const response = await request.get<
any,
@ -795,28 +839,28 @@ export const registrationsApi = {
// 获取报名详情
getDetail: async (id: number): Promise<ContestRegistration> => {
const response = await request.get<any, ContestRegistration>(
`/contests/registrations/${id}`
`/contests/registrations/${id}`,
);
return response;
},
// 获取当前用户在某活动中的报名记录(包括作为团队成员的情况)
getMyRegistration: async (
contestId: number
contestId: number,
): Promise<ContestRegistration | null> => {
const response = await request.get<any, ContestRegistration | null>(
`/contests/registrations/my/${contestId}`
`/contests/registrations/my/${contestId}`,
);
return response;
},
// 创建报名
create: async (
data: CreateRegistrationForm
data: CreateRegistrationForm,
): Promise<ContestRegistration> => {
const response = await request.post<any, ContestRegistration>(
"/contests/registrations",
data
data,
);
return response;
},
@ -824,11 +868,11 @@ export const registrationsApi = {
// 添加指导老师
addTeacher: async (
registrationId: number,
teacherUserId: number
teacherUserId: number,
): Promise<any> => {
const response = await request.post<any, any>(
`/contests/registrations/${registrationId}/teachers`,
{ teacherUserId }
{ teacherUserId },
);
return response;
},
@ -836,33 +880,42 @@ export const registrationsApi = {
// 移除指导老师
removeTeacher: async (
registrationId: number,
teacherUserId: number
teacherUserId: number,
): Promise<void> => {
await request.delete(
`/contests/registrations/${registrationId}/teachers/${teacherUserId}`
`/contests/registrations/${registrationId}/teachers/${teacherUserId}`,
);
},
// 审核报名
review: async (
id: number,
data: ReviewRegistrationForm
data: ReviewRegistrationForm,
): Promise<ContestRegistration> => {
const response = await request.patch<any, ContestRegistration>(
`/contests/registrations/${id}/review`,
data
data,
);
return response;
},
// 撤销报名审核
revokeReview: async (id: number): Promise<ContestRegistration> => {
return await request.patch<any, ContestRegistration>(`/contests/registrations/${id}/revoke`);
return await request.patch<any, ContestRegistration>(
`/contests/registrations/${id}/revoke`,
);
},
// 批量审核报名
batchReview: async (data: { ids: number[]; registrationState: string; reason?: string }): Promise<{ success: boolean; count: number }> => {
return await request.post<any, { success: boolean; count: number }>('/contests/registrations/batch-review', data);
batchReview: async (data: {
ids: number[];
registrationState: string;
reason?: string;
}): Promise<{ success: boolean; count: number }> => {
return await request.post<any, { success: boolean; count: number }>(
"/contests/registrations/batch-review",
data,
);
},
// 删除报名
@ -876,7 +929,7 @@ export const teamsApi = {
// 获取团队列表
getList: async (contestId: number): Promise<ContestTeam[]> => {
const response = await request.get<any, ContestTeam[]>(
`/contests/teams/contest/${contestId}`
`/contests/teams/contest/${contestId}`,
);
return response;
},
@ -884,7 +937,7 @@ export const teamsApi = {
// 获取团队详情
getDetail: async (id: number): Promise<ContestTeam> => {
const response = await request.get<any, ContestTeam>(
`/contests/teams/${id}`
`/contests/teams/${id}`,
);
return response;
},
@ -893,7 +946,7 @@ export const teamsApi = {
create: async (data: CreateTeamForm): Promise<ContestTeam> => {
const response = await request.post<any, ContestTeam>(
"/contests/teams",
data
data,
);
return response;
},
@ -901,11 +954,11 @@ export const teamsApi = {
// 更新团队
update: async (
id: number,
data: Partial<CreateTeamForm>
data: Partial<CreateTeamForm>,
): Promise<ContestTeam> => {
const response = await request.patch<any, ContestTeam>(
`/contests/teams/${id}`,
data
data,
);
return response;
},
@ -913,11 +966,11 @@ export const teamsApi = {
// 邀请成员
inviteMember: async (
teamId: number,
data: InviteMemberForm
data: InviteMemberForm,
): Promise<ContestTeamMember> => {
const response = await request.post<any, ContestTeamMember>(
`/contests/teams/${teamId}/members`,
data
data,
);
return response;
},
@ -925,7 +978,7 @@ export const teamsApi = {
// 移除成员
removeMember: async (teamId: number, userId: number): Promise<void> => {
return await request.delete<any, void>(
`/contests/teams/${teamId}/members/${userId}`
`/contests/teams/${teamId}/members/${userId}`,
);
},
@ -956,29 +1009,29 @@ export const worksApi = {
if (contestId) params.contestId = contestId;
const response = await request.get<any, WorksStats>(
"/contests/works/stats",
{ params }
{ params },
);
return response;
},
// 获取作品列表
getList: async (
params: QueryWorkParams
params: QueryWorkParams,
): Promise<PaginationResponse<ContestWork>> => {
const response = await request.get<any, PaginationResponse<ContestWork>>(
"/contests/works",
{ params }
{ params },
);
return response;
},
// 获取教师指导的作品列表
getGuidedWorks: async (
params: QueryGuidedWorkParams
params: QueryGuidedWorkParams,
): Promise<PaginationResponse<GuidedWork>> => {
const response = await request.get<any, PaginationResponse<GuidedWork>>(
"/contests/works/guided",
{ params }
{ params },
);
return response;
},
@ -986,7 +1039,7 @@ export const worksApi = {
// 获取作品详情
getDetail: async (id: number): Promise<ContestWork> => {
const response = await request.get<any, ContestWork>(
`/contests/works/${id}`
`/contests/works/${id}`,
);
return response;
},
@ -995,7 +1048,7 @@ export const worksApi = {
submit: async (data: SubmitWorkForm): Promise<ContestWork> => {
const response = await request.post<any, ContestWork>(
"/contests/works/submit",
data
data,
);
return response;
},
@ -1003,7 +1056,7 @@ export const worksApi = {
// 获取作品版本列表
getVersions: async (registrationId: number): Promise<ContestWork[]> => {
const response = await request.get<any, ContestWork[]>(
`/contests/works/registration/${registrationId}/versions`
`/contests/works/registration/${registrationId}/versions`,
);
return response;
},
@ -1107,11 +1160,11 @@ export const reviewsApi = {
// 分配作品给评委
assignWork: async (
contestId: number,
data: AssignWorkForm
data: AssignWorkForm,
): Promise<ContestWorkJudgeAssignment[]> => {
const response = await request.post<any, ContestWorkJudgeAssignment[]>(
`/contests/reviews/assign?contestId=${contestId}`,
data
data,
);
return response;
},
@ -1119,11 +1172,11 @@ export const reviewsApi = {
// 批量分配作品给评委
batchAssignWorks: async (
contestId: number,
data: BatchAssignForm
data: BatchAssignForm,
): Promise<BatchAssignResult> => {
const response = await request.post<any, BatchAssignResult>(
`/contests/reviews/batch-assign?contestId=${contestId}`,
data
data,
);
return response;
},
@ -1131,7 +1184,7 @@ export const reviewsApi = {
// 自动分配作品给评委
autoAssignWorks: async (contestId: number): Promise<AutoAssignResult> => {
const response = await request.post<any, AutoAssignResult>(
`/contests/reviews/auto-assign?contestId=${contestId}`
`/contests/reviews/auto-assign?contestId=${contestId}`,
);
return response;
},
@ -1140,7 +1193,7 @@ export const reviewsApi = {
score: async (data: CreateScoreForm): Promise<ContestWorkScore> => {
const response = await request.post<any, ContestWorkScore>(
"/contests/reviews/score",
data
data,
);
return response;
},
@ -1148,21 +1201,21 @@ export const reviewsApi = {
// 更新评分
updateScore: async (
scoreId: number,
data: Partial<CreateScoreForm>
data: Partial<CreateScoreForm>,
): Promise<ContestWorkScore> => {
const response = await request.patch<any, ContestWorkScore>(
`/contests/reviews/score/${scoreId}`,
data
data,
);
return response;
},
// 获取分配给当前评委的作品
getAssignedWorks: async (
contestId: number
contestId: number,
): Promise<ContestWorkJudgeAssignment[]> => {
const response = await request.get<any, ContestWorkJudgeAssignment[]>(
`/contests/reviews/assigned?contestId=${contestId}`
`/contests/reviews/assigned?contestId=${contestId}`,
);
return response;
},
@ -1170,7 +1223,7 @@ export const reviewsApi = {
// 获取评审进度统计
getReviewProgress: async (contestId: number): Promise<ReviewProgress> => {
const response = await request.get<any, ReviewProgress>(
`/contests/reviews/progress/${contestId}`
`/contests/reviews/progress/${contestId}`,
);
return response;
},
@ -1178,7 +1231,7 @@ export const reviewsApi = {
// 获取作品状态统计
getWorkStatusStats: async (contestId: number): Promise<WorkStatusStats> => {
const response = await request.get<any, WorkStatusStats>(
`/contests/reviews/work-status/${contestId}`
`/contests/reviews/work-status/${contestId}`,
);
return response;
},
@ -1186,14 +1239,14 @@ export const reviewsApi = {
// 获取作品评分列表
getWorkScores: async (workId: number): Promise<ContestWorkScore[]> => {
const response = await request.get<any, ContestWorkScore[]>(
`/contests/reviews/work/${workId}/scores`
`/contests/reviews/work/${workId}/scores`,
);
return response;
},
// 计算最终得分
calculateFinalScore: async (
workId: number
workId: number,
): Promise<{
finalScore: number;
scoreCount: number;
@ -1209,18 +1262,18 @@ export const reviewsApi = {
// 替换评委
replaceJudge: async (
assignmentId: number,
newJudgeId: number
newJudgeId: number,
): Promise<void> => {
await request.post<any, void>(
`/contests/reviews/replace-judge`,
{ assignmentId, newJudgeId }
);
await request.post<any, void>(`/contests/reviews/replace-judge`, {
assignmentId,
newJudgeId,
});
},
// 获取评委参与的活动列表
getJudgeContests: async (): Promise<any[]> => {
const response = await request.get<any, any[]>(
`/contests/reviews/judge/contests`
`/contests/reviews/judge/contests`,
);
return response;
},
@ -1234,8 +1287,13 @@ export const reviewsApi = {
workNo?: string;
accountNo?: string;
reviewStatus?: string;
}
): Promise<{ list: any[]; total: number; page: number; pageSize: number }> => {
},
): Promise<{
list: any[];
total: number;
page: number;
pageSize: number;
}> => {
const response = await request.get<
any,
{ list: any[]; total: number; page: number; pageSize: number }
@ -1246,7 +1304,7 @@ export const reviewsApi = {
// 获取评委视角的赛事详情(含评审规则)
getJudgeContestDetail: async (contestId: number): Promise<any> => {
const response = await request.get<any, any>(
`/contests/reviews/judge/contests/${contestId}/detail`
`/contests/reviews/judge/contests/${contestId}/detail`,
);
return response;
},
@ -1257,7 +1315,7 @@ export const noticesApi = {
// 获取公告列表(按活动)
getList: async (contestId: number): Promise<ContestNotice[]> => {
const response = await request.get<any, ContestNotice[]>(
`/contests/notices/contest/${contestId}`
`/contests/notices/contest/${contestId}`,
);
return response;
},
@ -1270,8 +1328,8 @@ export const noticesApi = {
pageSize?: number;
}): Promise<PaginationResponse<ContestNotice>> => {
const response = await request.get<any, PaginationResponse<ContestNotice>>(
'/contests/notices',
{ params }
"/contests/notices",
{ params },
);
return response;
},
@ -1279,7 +1337,7 @@ export const noticesApi = {
// 获取公告详情
getDetail: async (id: number): Promise<ContestNotice> => {
const response = await request.get<any, ContestNotice>(
`/contests/notices/${id}`
`/contests/notices/${id}`,
);
return response;
},
@ -1288,7 +1346,7 @@ export const noticesApi = {
create: async (data: CreateNoticeForm): Promise<ContestNotice> => {
const response = await request.post<any, ContestNotice>(
"/contests/notices",
data
data,
);
return response;
},
@ -1296,11 +1354,11 @@ export const noticesApi = {
// 更新公告
update: async (
id: number,
data: Partial<CreateNoticeForm>
data: Partial<CreateNoticeForm>,
): Promise<ContestNotice> => {
const response = await request.patch<any, ContestNotice>(
`/contests/notices/${id}`,
data
data,
);
return response;
},
@ -1373,7 +1431,7 @@ export interface SetAwardForm {
export interface BatchSetAwardsForm {
awards: Array<{
workId: number;
awardLevel: 'first' | 'second' | 'third' | 'excellent' | 'none';
awardLevel: "first" | "second" | "third" | "excellent" | "none";
awardName?: string;
}>;
}
@ -1385,44 +1443,61 @@ export interface AutoSetAwardsForm {
// 成果管理
export const resultsApi = {
// 计算所有作品的最终得分
calculateScores: async (contestId: number): Promise<{ message: string; calculatedCount: number; calculationRule: string }> => {
calculateScores: async (
contestId: number,
): Promise<{
message: string;
calculatedCount: number;
calculationRule: string;
}> => {
const response = await request.post<any, any>(
`/contests/results/${contestId}/calculate-scores`
`/contests/results/${contestId}/calculate-scores`,
);
return response;
},
// 计算排名
calculateRankings: async (contestId: number): Promise<{ message: string; rankedCount: number }> => {
calculateRankings: async (
contestId: number,
): Promise<{ message: string; rankedCount: number }> => {
const response = await request.post<any, any>(
`/contests/results/${contestId}/calculate-rankings`
`/contests/results/${contestId}/calculate-rankings`,
);
return response;
},
// 设置单个作品奖项
setAward: async (workId: number, data: SetAwardForm): Promise<ContestResult> => {
setAward: async (
workId: number,
data: SetAwardForm,
): Promise<ContestResult> => {
const response = await request.patch<any, ContestResult>(
`/contests/results/work/${workId}/award`,
data
data,
);
return response;
},
// 批量设置奖项
batchSetAwards: async (contestId: number, data: BatchSetAwardsForm): Promise<any> => {
batchSetAwards: async (
contestId: number,
data: BatchSetAwardsForm,
): Promise<any> => {
const response = await request.post<any, any>(
`/contests/results/${contestId}/batch-set-awards`,
data
data,
);
return response;
},
// 根据排名自动设置奖项
autoSetAwards: async (contestId: number, data: AutoSetAwardsForm): Promise<any> => {
autoSetAwards: async (
contestId: number,
data: AutoSetAwardsForm,
): Promise<any> => {
const response = await request.post<any, any>(
`/contests/results/${contestId}/auto-set-awards`,
data
data,
);
return response;
},
@ -1430,7 +1505,7 @@ export const resultsApi = {
// 发布成果
publish: async (contestId: number): Promise<any> => {
const response = await request.post<any, any>(
`/contests/results/${contestId}/publish`
`/contests/results/${contestId}/publish`,
);
return response;
},
@ -1438,7 +1513,7 @@ export const resultsApi = {
// 撤回发布
unpublish: async (contestId: number): Promise<any> => {
const response = await request.post<any, any>(
`/contests/results/${contestId}/unpublish`
`/contests/results/${contestId}/unpublish`,
);
return response;
},
@ -1451,11 +1526,17 @@ export const resultsApi = {
pageSize?: number;
workNo?: string;
accountNo?: string;
} = {}
} = {},
): Promise<ResultsResponse> => {
const response = await request.get<any, ResultsResponse>(
`/contests/results/${contestId}`,
{ params: { page: params.page || 1, pageSize: params.pageSize || 10, ...params } }
{
params: {
page: params.page || 1,
pageSize: params.pageSize || 10,
...params,
},
},
);
return response;
},
@ -1463,7 +1544,7 @@ export const resultsApi = {
// 获取活动结果统计摘要
getSummary: async (contestId: number): Promise<ResultsSummary> => {
const response = await request.get<any, ResultsSummary>(
`/contests/results/${contestId}/summary`
`/contests/results/${contestId}/summary`,
);
return response;
},
@ -1471,10 +1552,12 @@ export const resultsApi = {
// 评委管理
export const judgesApi = {
// 获取评委列表
getList: async (contestId: number): Promise<ContestJudge[]> => {
const response = await request.get<any, ContestJudge[]>(
`/contests/judges/contest/${contestId}`
// 获取赛事评委assigned + implicitPool
getList: async (
contestId: number,
): Promise<ContestJudgesForContestResponse> => {
const response = await request.get<any, ContestJudgesForContestResponse>(
`/contests/judges/contest/${contestId}`,
);
return response;
},
@ -1482,7 +1565,7 @@ export const judgesApi = {
// 获取评委详情
getDetail: async (id: number): Promise<ContestJudge> => {
const response = await request.get<any, ContestJudge>(
`/contests/judges/${id}`
`/contests/judges/${id}`,
);
return response;
},
@ -1491,7 +1574,7 @@ export const judgesApi = {
create: async (data: CreateJudgeForm): Promise<ContestJudge> => {
const response = await request.post<any, ContestJudge>(
"/contests/judges",
data
data,
);
return response;
},
@ -1499,11 +1582,11 @@ export const judgesApi = {
// 更新评委
update: async (
id: number,
data: Partial<CreateJudgeForm>
data: Partial<CreateJudgeForm>,
): Promise<ContestJudge> => {
const response = await request.patch<any, ContestJudge>(
`/contests/judges/${id}`,
data
data,
);
return response;
},

View File

@ -29,7 +29,8 @@
<span class="progress-text">{{ record.reviewed }}/{{ record.totalAssigned }}</span>
</template>
<template v-else-if="column.key === 'reviewStatus'">
<a-tag v-if="record.pending === 0" color="success">已完成</a-tag>
<a-tag v-if="record.totalAssigned > 0 && record.pending === 0" color="success">已完成</a-tag>
<a-tag v-else-if="record.totalAssigned === 0" color="default">无分配</a-tag>
<a-tag v-else color="processing">评审中</a-tag>
</template>
<template v-else-if="column.key === 'action'">
@ -99,8 +100,9 @@ const getProgressPercent = (record: any) => {
return Math.round((record.reviewed / record.totalAssigned) * 100)
}
//
// success
const getProgressStatus = (record: any) => {
if (record.totalAssigned === 0) return "normal"
if (record.pending === 0) return "success"
return "active"
}

View File

@ -72,7 +72,9 @@
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'workNo'">
<a @click="handleViewWork(record)">{{ record.workNo || "-" }}</a>
<a @click="handleViewWork(record)">{{
record.workNo || (record.workId != null ? `#${record.workId}` : "-")
}}</a>
</template>
<template v-else-if="column.key === 'score'">
<span v-if="record.totalScore != null" class="score">

View File

@ -4,30 +4,24 @@
<a-card class="mb-4" size="small">
<a-form layout="inline" :model="searchParams" @finish="handleSearch">
<a-form-item label="姓名">
<a-input
v-model:value="searchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 150px"
@press-enter="handleSearch"
/>
<a-input v-model:value="searchParams.nickname" placeholder="请输入姓名" allow-clear style="width: 150px"
@press-enter="handleSearch" />
</a-form-item>
<a-form-item label="所属单位">
<a-input
v-model:value="searchParams.organization"
placeholder="请输入所属单位"
allow-clear
style="width: 200px"
@press-enter="handleSearch"
/>
<a-input v-model:value="searchParams.organization" placeholder="请输入所属单位" allow-clear style="width: 200px"
@press-enter="handleSearch" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-form-item>
@ -37,22 +31,14 @@
<!-- 全部评委列表 -->
<a-card class="mb-4" size="small">
<template #title>全部评委</template>
<a-table
:columns="judgeColumns"
:data-source="judgeList"
:loading="judgeLoading"
:pagination="judgePagination"
<a-table :columns="judgeColumns" :data-source="judgeList" :loading="judgeLoading" :pagination="judgePagination"
:row-selection="{
selectedRowKeys: selectedJudgeIds,
onChange: handleJudgeSelectionChange,
getCheckboxProps: (record: Judge) => ({
disabled: isJudgeSelected(record.id),
}),
}"
row-key="id"
size="small"
@change="handleJudgeTableChange"
>
}" row-key="id" size="small" @change="handleJudgeTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'organization'">
{{ record.organization || "-" }}
@ -69,19 +55,18 @@
<!-- 已选评委区域 -->
<a-card size="small">
<template #title>
已选 {{ selectedJudges.length }} / {{ judgeCount || 0 }}
<span
v-if="selectedJudges.length > (judgeCount || 0)"
class="warning-text"
>
超出{{ selectedJudges.length - (judgeCount || 0) }}
<template v-if="judgeCount > 0">
已选 {{ selectedJudges.length }} / {{ judgeCount }}
<span v-if="selectedJudges.length > judgeCount" class="warning-text">
超出{{ selectedJudges.length - judgeCount }}
</span>
</template>
<a-list
:data-source="selectedJudges"
:loading="selectedJudgesLoading"
size="small"
>
<template v-else>
已选 {{ selectedJudges.length }}
<span class="hint-text">评审规则未配置人数</span>
</template>
</template>
<a-list :data-source="selectedJudges" :loading="selectedJudgesLoading" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
@ -93,12 +78,7 @@
</template>
</a-list-item-meta>
<template #actions>
<a-button
type="link"
danger
size="small"
@click="handleRemoveJudge(item.id)"
>
<a-button type="link" danger size="small" @click="handleRemoveJudge(item.id)">
移除
</a-button>
</template>
@ -114,12 +94,7 @@
<div class="drawer-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="submitLoading"
:disabled="selectedJudges.length === 0"
@click="handleSubmit"
>
<a-button type="primary" :loading="submitLoading" :disabled="submitLoading" @click="handleSubmit">
确定
</a-button>
</a-space>
@ -235,9 +210,8 @@ const loadJudges = async () => {
const loadSelectedJudges = async () => {
selectedJudgesLoading.value = true
try {
const judges = await judgesApi.getList(props.contestId)
// ID
const judgeIds = judges.map((j) => j.judgeId)
const { assigned } = await judgesApi.getList(props.contestId)
const judgeIds = assigned.map((j) => j.judgeId)
selectedJudgeIds.value = judgeIds
//
@ -317,40 +291,48 @@ const handleJudgeTableChange = (pag: any) => {
loadJudges()
}
//
// assigned +
const handleSubmit = async () => {
if (selectedJudges.value.length === 0) {
message.warning("请至少选择一个评委")
submitLoading.value = true
try {
const { assigned } = await judgesApi.getList(props.contestId)
const currentJudgeIds = assigned.map((j) => j.judgeId)
const toRemoveContestJudgeIds = assigned
.filter(
(j) => j.id != null && !selectedJudgeIds.value.includes(j.judgeId),
)
.map((j) => j.id as number)
const toAddIds = selectedJudgeIds.value.filter(
(id) => !currentJudgeIds.includes(id),
)
if (toRemoveContestJudgeIds.length === 0 && toAddIds.length === 0) {
message.info("没有变更")
return
}
submitLoading.value = true
try {
// ID
const currentJudgeIds = await judgesApi
.getList(props.contestId)
.then((judges) => judges.map((j) => j.judgeId))
//
const toAddIds = selectedJudgeIds.value.filter(
(id) => !currentJudgeIds.includes(id)
if (toRemoveContestJudgeIds.length > 0) {
await Promise.all(
toRemoveContestJudgeIds.map((id) => judgesApi.delete(id)),
)
}
//
if (toAddIds.length > 0) {
await Promise.all(
toAddIds.map((judgeId) =>
judgesApi.create({
contestId: props.contestId,
judgeId: judgeId,
})
)
}),
),
)
}
emit("success")
} catch (error: any) {
message.error(error?.response?.data?.message || "添加评委失败")
message.error(error?.response?.data?.message || "保存评委失败")
} finally {
submitLoading.value = false
}
@ -396,6 +378,12 @@ onMounted(() => {
font-size: 12px;
}
.hint-text {
color: #8c8c8c;
font-size: 12px;
font-weight: normal;
}
.drawer-footer {
position: sticky;
bottom: 0;

View File

@ -85,16 +85,15 @@
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
</template>
<template v-else-if="column.key === 'workNo'">
<a @click="handleViewWorkDetail(record)">{{ record.workNo || "-" }}</a>
<a @click="handleViewWorkDetail(record)">{{
record.workNo || (record.id != null ? `#${record.id}` : "-")
}}</a>
</template>
<template v-else-if="column.key === 'username'">
{{ record.submitterAccountNo || record.registration?.user?.username || "-" }}
</template>
<template v-else-if="column.key === 'judgeScore'">
<span v-if="record.averageScore !== undefined && record.averageScore !== null">
{{ record.averageScore.toFixed(2) }}
</span>
<span v-else>-</span>
{{ formatWorkJudgeScore(record) ?? "-" }}
</template>
<template v-else-if="column.key === 'reviewProgress'">
<a-tag :color="getProgressColor(record)">
@ -121,12 +120,17 @@
:data-source="scoreList"
:loading="scoreLoading"
:pagination="false"
row-key="id"
row-key="scoreId"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.judge?.nickname || record.judge?.username || "-" }}
{{
record.judgeName ||
record.judge?.nickname ||
record.judge?.username ||
"-"
}}
</template>
<template v-else-if="column.key === 'phone'">
{{ record.judge?.phone || "-" }}
@ -402,6 +406,13 @@ const formatDate = (dateStr?: string) => {
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
/** 主表「评委评分」:列表接口优先返回 finalScore兼容 averageScore */
const formatWorkJudgeScore = (record: ContestWork): string | null => {
const v = record.finalScore ?? record.averageScore
if (v === undefined || v === null) return null
return Number(v).toFixed(2)
}
//
const fetchContestInfo = async () => {
try {
@ -607,7 +618,10 @@ const handleConfirmReplace = async () => {
replaceLoading.value = true
try {
// TODO: API
await reviewsApi.replaceJudge(currentReplaceScore.value.id, selectedJudgeRow.value.id)
await reviewsApi.replaceJudge(
currentReplaceScore.value.assignmentId ?? currentReplaceScore.value.id,
selectedJudgeRow.value.id,
)
message.success("替换成功")
replaceJudgeDrawerVisible.value = false
//

View File

@ -15,11 +15,7 @@
</a-space>
</template>
<template #extra>
<a-button
type="primary"
:disabled="selectedRowKeys.length === 0"
@click="handleBatchAssign"
>
<a-button type="primary" :disabled="selectedRowKeys.length === 0" @click="handleBatchAssign">
分配评委
</a-button>
</template>
@ -39,98 +35,54 @@
</div>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item :label="contestType === 'team' ? '队伍名称' : '选手名称'">
<a-input
v-model:value="searchParams.name"
:placeholder="
contestType === 'team' ? '请输入队伍名称' : '请输入选手名称'
"
allow-clear
style="width: 150px"
/>
<a-input v-model:value="searchParams.name" :placeholder="contestType === 'team' ? '请输入队伍名称' : '请输入选手名称'
" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="报名账号">
<a-input
v-model:value="searchParams.username"
placeholder="请输入报名账号"
allow-clear
style="width: 150px"
/>
<a-input v-model:value="searchParams.username" placeholder="请输入报名账号" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="作品编号">
<a-input
v-model:value="searchParams.workNo"
placeholder="请输入作品编号"
allow-clear
style="width: 150px"
/>
<a-input v-model:value="searchParams.workNo" placeholder="请输入作品编号" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="分配状态">
<a-select
v-model:value="searchParams.assignStatus"
placeholder="请选择"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select v-model:value="searchParams.assignStatus" placeholder="请选择" allow-clear style="width: 120px"
@change="handleSearch">
<a-select-option value="assigned">已分配</a-select-option>
<a-select-option value="unassigned">未分配</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="递交时间">
<a-range-picker
v-model:value="searchParams.submitTimeRange"
style="width: 240px"
@change="handleSearch"
/>
<a-range-picker v-model:value="searchParams.submitTimeRange" style="width: 240px" @change="handleSearch" />
</a-form-item>
<a-form-item v-if="isSuperAdmin" label="机构">
<a-select
v-model:value="searchParams.tenantId"
placeholder="请选择机构"
allow-clear
style="width: 150px"
show-search
:filter-option="filterOption"
@change="handleSearch"
>
<a-select-option
v-for="tenant in tenants"
:key="tenant.id"
:value="tenant.id"
>
<a-select v-model:value="searchParams.tenantId" placeholder="请选择机构" allow-clear style="width: 150px" show-search
:filter-option="filterOption" @change="handleSearch">
<a-select-option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">
{{ tenant.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
row-key="id"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination"
:row-selection="rowSelection" row-key="id" @change="handleTableChange">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
@ -157,18 +109,13 @@
{{ formatDate(record.submitTime) }}
</template>
<template v-else-if="column.key === 'assignStatus'">
<a-tag v-if="record._count?.assignments > 0" color="success"
>已分配</a-tag
>
<a-tag v-if="record._count?.assignments > 0" color="success">已分配</a-tag>
<a-tag v-else color="default">未分配</a-tag>
</template>
<template v-else-if="column.key === 'judges'">
<template v-if="record.assignments && record.assignments.length > 0">
<a-space wrap>
<a-tag
v-for="assignment in record.assignments"
:key="assignment.id"
>
<a-tag v-for="assignment in record.assignments" :key="assignment.id">
{{
assignment.judge?.nickname ||
assignment.judge?.username ||
@ -188,49 +135,34 @@
</a-table>
<!-- 作品详情弹框 -->
<WorkDetailModal
v-model:open="workModalVisible"
:work-id="currentWorkId"
/>
<WorkDetailModal v-model:open="workModalVisible" :work-id="currentWorkId" />
<!-- 分配评委抽屉 -->
<a-drawer
v-model:open="assignModalVisible"
title="分配评委"
placement="right"
width="800px"
:footer-style="{ textAlign: 'right' }"
@close="handleAssignDrawerClose"
>
<a-drawer v-model:open="assignModalVisible" title="分配评委" placement="right" width="800px"
:footer-style="{ textAlign: 'right' }" @close="handleAssignDrawerClose">
<div class="assign-judge-drawer">
<!-- 搜索区域 -->
<a-card class="mb-4" size="small">
<a-form layout="inline" :model="judgeSearchParams" @finish="handleSearchJudges">
<a-form-item label="姓名">
<a-input
v-model:value="judgeSearchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 150px"
@press-enter="handleSearchJudges"
/>
<a-input v-model:value="judgeSearchParams.nickname" placeholder="请输入姓名" allow-clear style="width: 150px"
@press-enter="handleSearchJudges" />
</a-form-item>
<a-form-item label="所属单位">
<a-input
v-model:value="judgeSearchParams.tenantName"
placeholder="请输入所属单位"
allow-clear
style="width: 200px"
@press-enter="handleSearchJudges"
/>
<a-input v-model:value="judgeSearchParams.tenantName" placeholder="请输入所属单位" allow-clear
style="width: 200px" @press-enter="handleSearchJudges" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleResetJudgeSearch">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-form-item>
@ -240,19 +172,11 @@
<!-- 全部评委列表 -->
<a-card class="mb-4" size="small">
<template #title>全部评委</template>
<a-table
:columns="judgeColumns"
:data-source="judgeList"
:loading="judgeListLoading"
:pagination="judgePagination"
:row-selection="{
<a-table :columns="judgeColumns" :data-source="judgeList" :loading="judgeListLoading"
:pagination="judgePagination" :row-selection="{
selectedRowKeys: selectedJudgeKeys,
onChange: handleJudgeSelectionChange,
}"
row-key="id"
size="small"
@change="handleJudgeTableChange"
>
}" row-key="judgeId" size="small" @change="handleJudgeTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{ record.judgeName || record.judgeUsername || "-" }}
@ -275,10 +199,7 @@
<template #title>
已选 {{ selectedJudgeRows.length }} 位评委
</template>
<a-list
:data-source="selectedJudgeRows"
size="small"
>
<a-list :data-source="selectedJudgeRows" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
@ -290,12 +211,7 @@
</template>
</a-list-item-meta>
<template #actions>
<a-button
type="link"
danger
size="small"
@click="handleRemoveSelectedJudge(item.id)"
>
<a-button type="link" danger size="small" @click="handleRemoveSelectedJudge(item.judgeId)">
移除
</a-button>
</template>
@ -311,12 +227,8 @@
<div class="drawer-footer">
<a-space>
<a-button @click="handleAssignDrawerClose">取消</a-button>
<a-button
type="primary"
:loading="assignLoading"
:disabled="selectedJudgeRows.length === 0"
@click="handleConfirmAssign"
>
<a-button type="primary" :loading="assignLoading" :disabled="selectedJudgeRows.length === 0"
@click="handleConfirmAssign">
确定
</a-button>
</a-space>
@ -348,6 +260,7 @@ import {
worksApi,
reviewsApi,
judgesApi,
flattenContestJudgePool,
type ContestWork,
type ContestJudge,
} from "@/api/contests"
@ -457,29 +370,23 @@ const judgeSearchParams = reactive({
const selectedJudgeKeys = ref<number[]>([])
const selectedJudgeRows = ref<ContestJudge[]>([])
//
// 使 judgeId
const handleJudgeSelectionChange = (selectedKeys: number[]) => {
//
const newSelectedIds = selectedKeys.filter(
(id) => !selectedJudgeKeys.value.includes(id)
)
//
const removedIds = selectedJudgeKeys.value.filter(
(id) => !selectedKeys.includes(id)
)
// ID
selectedJudgeKeys.value = selectedKeys
//
//
selectedJudgeRows.value = selectedJudgeRows.value.filter(
(judge) => !removedIds.includes(judge.id)
(judge) => !removedIds.includes(judge.judgeId)
)
//
const newSelectedJudges = judgeList.value.filter((judge) =>
newSelectedIds.includes(judge.id)
newSelectedIds.includes(judge.judgeId)
)
selectedJudgeRows.value = [...selectedJudgeRows.value, ...newSelectedJudges]
}
@ -488,7 +395,7 @@ const handleJudgeSelectionChange = (selectedKeys: number[]) => {
const handleRemoveSelectedJudge = (judgeId: number) => {
selectedJudgeKeys.value = selectedJudgeKeys.value.filter((id) => id !== judgeId)
selectedJudgeRows.value = selectedJudgeRows.value.filter(
(judge) => judge.id !== judgeId
(judge) => judge.judgeId !== judgeId
)
}
@ -556,13 +463,14 @@ const fetchList = async () => {
}
}
//
// +
const fetchJudgeList = async () => {
judgeListLoading.value = true
try {
const response = await judgesApi.getList(contestId)
judgeList.value = response
judgePagination.total = response.length
const pool = flattenContestJudgePool(response)
judgeList.value = pool
judgePagination.total = pool.length
} catch (error) {
message.error("获取评委列表失败")
} finally {
@ -621,7 +529,7 @@ const handleAssignJudge = async (record: ContestWork) => {
const matchedJudges = judgeList.value.filter((judge) =>
assignedJudgeUserIds.includes(judge.judgeId)
)
selectedJudgeKeys.value = matchedJudges.map((j) => j.id)
selectedJudgeKeys.value = matchedJudges.map((j) => j.judgeId)
selectedJudgeRows.value = matchedJudges
}
}
@ -717,14 +625,48 @@ onMounted(() => {
<style scoped lang="scss">
$primary: #6366f1;
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stats-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column;
.stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; }
.stat-label { font-size: 12px; color: #9ca3af; }
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
.stat-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
.stat-count {
font-size: 18px;
font-weight: 700;
color: #1e1b4b;
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
}
}
}