feat: 评委替换接口与作品评分列表按分配返回(含未评分行与 canReplaceJudge)

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-16 16:21:25 +08:00
parent 4d0b13524c
commit 4ba2af18f6
7 changed files with 296 additions and 28 deletions

View File

@ -20,7 +20,7 @@
| 评审状态筛选无效 | 第一层评审状态下拉选了不生效(前端计算但未传后端) |
| 缺少全局统计 | 没有全平台评审总进度概览 |
| 无跨活动维度 | 不能跨活动查看所有作品的评审状态 |
| TODO 未实现 | 未提交作品列表、评委替换 API 均为空实现 |
| ~~TODO~~ 部分已补 | 未提交作品列表仍为 TODO**评委替换**已由 `POST /api/contests/reviews/replace-judge` 实现(分配 ID + 新评委用户 ID未评分前可换 |
| 样式不一致 | 主色 `#1890ff` |
---
@ -133,10 +133,11 @@
## 4. 后端改动
无新增接口。复用已有能力:
- 统计:复用 `GET /api/contests/works/stats`(已有 submitted/reviewing/reviewed
- 列表:复用 `GET /api/contests/works`(已返回 reviewedCount/totalJudgesCount/averageScore/assignments
- 评分详情:复用 `GET /api/contests/reviews/works/:workId/scores`(已有)
- **评委替换**`POST /api/contests/reviews/replace-judge`(权限 `review:assign`),请求体 `{ "assignmentId": long, "newJudgeId": long }`;该分配下若已有有效评分则不允许替换。
- 其余复用已有能力:
- 统计:复用 `GET /api/contests/works/stats`(已有 submitted/reviewing/reviewed
- 列表:复用 `GET /api/contests/works`(已返回 reviewedCount/totalJudgesCount/averageScore/assignments
- 评分详情:复用 `GET /api/contests/reviews/work/{workId}/scores`(已有)
### 4.1 评审进度前端过滤

View File

@ -7,6 +7,7 @@ import com.lesingle.modules.biz.review.dto.AssignWorkDto;
import com.lesingle.modules.biz.review.dto.BatchAssignDto;
import com.lesingle.modules.biz.review.dto.CreateScoreDto;
import com.lesingle.modules.biz.review.dto.MarkContestWorkViolationDto;
import com.lesingle.modules.biz.review.dto.ReplaceJudgeDto;
import com.lesingle.modules.biz.review.service.IContestReviewService;
import com.lesingle.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
@ -36,6 +37,15 @@ public class ContestReviewController {
return Result.success(contestReviewService.assignWork(contestId, dto.getWorkId(), dto.getJudgeIds(), creatorId));
}
@PostMapping("/replace-judge")
@RequirePermission("review:assign")
@Operation(summary = "替换评委", description = "将某条作品-评委分配改为另一名评委;该分配下若已有有效评分则不允许替换")
public Result<Map<String, Object>> replaceJudge(@Valid @RequestBody ReplaceJudgeDto dto) {
Long operatorId = SecurityUtil.getCurrentUserId();
return Result.success(contestReviewService.replaceJudge(
dto.getAssignmentId(), dto.getNewJudgeId(), operatorId));
}
@PostMapping("/batch-assign")
@RequirePermission("review:assign")
@Operation(summary = "批量分配作品给评委")
@ -129,7 +139,7 @@ public class ContestReviewController {
@GetMapping("/work/{workId}/scores")
@RequirePermission("review:read")
@Operation(summary = "查询作品评分记录")
@Operation(summary = "查询作品评委与评分", description = "按作品评委分配逐行返回未评分行仍可替换评委canReplaceJudge=true已评分为 false")
public Result<List<Map<String, Object>>> getWorkScores(@PathVariable Long workId) {
return Result.success(contestReviewService.getWorkScores(workId));
}

View File

@ -0,0 +1,18 @@
package com.lesingle.modules.biz.review.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "替换作品评委分配(仅未提交评分时可替换)")
public class ReplaceJudgeDto {
@NotNull(message = "分配记录ID不能为空")
@Schema(description = "作品评委分配表主键 t_biz_contest_work_judge_assignment.id")
private Long assignmentId;
@NotNull(message = "新评委用户ID不能为空")
@Schema(description = "新评委用户ID须已加入本活动评委")
private Long newJudgeId;
}

View File

@ -36,4 +36,9 @@ public interface IContestReviewService {
Map<String, Object> calculateFinalScore(Long workId);
Map<String, Object> getJudgeContestDetail(Long judgeId, Long contestId);
/**
* 将某条作品评委分配替换为另一名评委该分配下尚无有效评分记录时才允许
*/
Map<String, Object> replaceJudge(Long assignmentId, Long newJudgeId, Long operatorId);
}

View File

@ -23,7 +23,9 @@ import com.lesingle.modules.biz.review.mapper.ContestReviewRuleMapper;
import com.lesingle.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.lesingle.modules.biz.review.mapper.ContestWorkScoreMapper;
import com.lesingle.modules.biz.review.service.IContestReviewService;
import com.lesingle.modules.sys.entity.SysTenant;
import com.lesingle.modules.sys.entity.SysUser;
import com.lesingle.modules.sys.mapper.SysTenantMapper;
import com.lesingle.modules.sys.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -34,7 +36,18 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@ -50,6 +63,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
private final ContestRegistrationMapper registrationMapper;
private final ContestReviewRuleMapper reviewRuleMapper;
private final SysUserMapper sysUserMapper;
private final SysTenantMapper sysTenantMapper;
/**
* 仅允许将已在本活动评委表 t_biz_contest_judge 中显式配置的评委新分配到作品
@ -692,27 +706,179 @@ public class ContestReviewServiceImpl implements IContestReviewService {
@Override
public List<Map<String, Object>> getWorkScores(Long workId) {
log.info("查询作品评分列表作品ID{}", workId);
log.info("查询作品评分列表(按评委分配)作品ID{}", workId);
BizContestWork work = workMapper.selectById(workId);
if (work == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "作品不存在");
}
LambdaQueryWrapper<BizContestWorkJudgeAssignment> aw = new LambdaQueryWrapper<>();
aw.eq(BizContestWorkJudgeAssignment::getWorkId, workId);
aw.orderByAsc(BizContestWorkJudgeAssignment::getId);
List<BizContestWorkJudgeAssignment> assignments = assignmentMapper.selectList(aw);
if (assignments.isEmpty()) {
return listWorkScoresFallbackByScoresOnly(workId);
}
Set<Long> judgeIds = assignments.stream()
.map(BizContestWorkJudgeAssignment::getJudgeId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, SysUser> judgeUserById = loadUsersByIds(judgeIds);
Map<Long, String> tenantNameById = loadTenantNames(judgeUserById.values());
List<Map<String, Object>> rows = new ArrayList<>();
for (BizContestWorkJudgeAssignment a : assignments) {
Long assignmentId = a.getId();
Long judgeId = a.getJudgeId();
BizContestWorkScore s = findValidScoreForAssignment(workId, assignmentId, judgeId);
boolean scored = s != null;
boolean canReplaceJudge = !scored;
SysUser judgeUser = judgeId != null ? judgeUserById.get(judgeId) : null;
String judgeName = scored && StringUtils.hasText(s.getJudgeName())
? s.getJudgeName()
: null;
if (!StringUtils.hasText(judgeName) && judgeUser != null) {
judgeName = StringUtils.hasText(judgeUser.getNickname())
? judgeUser.getNickname()
: judgeUser.getUsername();
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("assignmentId", assignmentId);
map.put("judgeId", judgeId);
map.put("judgeName", judgeName);
map.put("canReplaceJudge", canReplaceJudge);
map.put("scored", scored);
if (judgeUser != null) {
map.put("judge", buildJudgeDetailMap(judgeUser, tenantNameById));
}
if (s != null) {
map.put("id", s.getId());
map.put("scoreId", s.getId());
map.put("tenantId", s.getTenantId());
map.put("contestId", s.getContestId());
map.put("workId", s.getWorkId());
map.put("dimensionScores", s.getDimensionScores());
map.put("totalScore", s.getTotalScore());
map.put("comments", s.getComments());
map.put("scoreTime", s.getScoreTime());
} else {
map.put("id", assignmentId);
map.put("scoreId", null);
map.put("tenantId", judgeUser != null ? judgeUser.getTenantId() : null);
map.put("contestId", work.getContestId());
map.put("workId", workId);
map.put("dimensionScores", null);
map.put("totalScore", null);
map.put("comments", null);
map.put("scoreTime", null);
}
rows.add(map);
}
return rows;
}
private Map<Long, SysUser> loadUsersByIds(Set<Long> judgeIds) {
Map<Long, SysUser> judgeUserById = new HashMap<>();
if (judgeIds == null || judgeIds.isEmpty()) {
return judgeUserById;
}
for (SysUser u : sysUserMapper.selectBatchIds(judgeIds)) {
if (u != null && u.getId() != null) {
judgeUserById.put(u.getId(), u);
}
}
return judgeUserById;
}
private Map<Long, String> loadTenantNames(Collection<SysUser> users) {
Set<Long> tenantIds = users.stream()
.map(SysUser::getTenantId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> tenantNameById = new HashMap<>();
for (Long tid : tenantIds) {
SysTenant t = sysTenantMapper.selectById(tid);
if (t != null && StringUtils.hasText(t.getName())) {
tenantNameById.put(tid, t.getName());
}
}
return tenantNameById;
}
private Map<String, Object> buildJudgeDetailMap(SysUser judgeUser, Map<Long, String> tenantNameById) {
Map<String, Object> judge = new LinkedHashMap<>();
judge.put("id", judgeUser.getId());
judge.put("username", judgeUser.getUsername());
judge.put("nickname", judgeUser.getNickname());
judge.put("phone", judgeUser.getPhone());
if (judgeUser.getTenantId() != null) {
String tn = tenantNameById.get(judgeUser.getTenantId());
if (StringUtils.hasText(tn)) {
Map<String, Object> tenant = new LinkedHashMap<>();
tenant.put("name", tn);
judge.put("tenant", tenant);
}
}
return judge;
}
/**
* 优先按分配 ID 关联评分若无则按作品+评委兜底历史数据 assignment_id 为空
*/
private BizContestWorkScore findValidScoreForAssignment(Long workId, Long assignmentId, Long judgeId) {
LambdaQueryWrapper<BizContestWorkScore> sw = new LambdaQueryWrapper<>();
sw.eq(BizContestWorkScore::getAssignmentId, assignmentId);
sw.eq(BizContestWorkScore::getValidState, 1);
List<BizContestWorkScore> byAssign = scoreMapper.selectList(sw);
if (!byAssign.isEmpty()) {
return pickLatestScore(byAssign);
}
if (judgeId == null) {
return null;
}
LambdaQueryWrapper<BizContestWorkScore> legacy = new LambdaQueryWrapper<>();
legacy.eq(BizContestWorkScore::getWorkId, workId);
legacy.eq(BizContestWorkScore::getJudgeId, judgeId);
legacy.eq(BizContestWorkScore::getValidState, 1);
legacy.and(w -> w.isNull(BizContestWorkScore::getAssignmentId)
.or()
.eq(BizContestWorkScore::getAssignmentId, assignmentId));
List<BizContestWorkScore> list = scoreMapper.selectList(legacy);
return list.isEmpty() ? null : pickLatestScore(list);
}
private static BizContestWorkScore pickLatestScore(List<BizContestWorkScore> list) {
return list.stream()
.max(Comparator.comparing(BizContestWorkScore::getScoreTime, Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(BizContestWorkScore::getId, Comparator.nullsLast(Long::compareTo)))
.orElse(list.get(0));
}
/**
* 无分配记录时仍返回历史评分行 assignment 则不可替换评委
*/
private List<Map<String, Object>> listWorkScoresFallbackByScoresOnly(Long workId) {
LambdaQueryWrapper<BizContestWorkScore> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWorkScore::getWorkId, workId);
wrapper.eq(BizContestWorkScore::getValidState, 1);
wrapper.orderByAsc(BizContestWorkScore::getScoreTime);
List<BizContestWorkScore> scores = scoreMapper.selectList(wrapper);
Set<Long> judgeIds = scores.stream()
.map(BizContestWorkScore::getJudgeId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, SysUser> judgeUserById = new HashMap<>();
if (!judgeIds.isEmpty()) {
for (SysUser u : sysUserMapper.selectBatchIds(judgeIds)) {
if (u != null && u.getId() != null) {
judgeUserById.put(u.getId(), u);
}
}
}
Map<Long, SysUser> judgeUserById = loadUsersByIds(judgeIds);
Map<Long, String> tenantNameById = loadTenantNames(judgeUserById.values());
return scores.stream().map(s -> {
Map<String, Object> map = new LinkedHashMap<>();
@ -720,6 +886,8 @@ public class ContestReviewServiceImpl implements IContestReviewService {
map.put("scoreId", s.getId());
map.put("assignmentId", s.getAssignmentId());
map.put("judgeId", s.getJudgeId());
map.put("canReplaceJudge", false);
map.put("scored", true);
SysUser judgeUser = s.getJudgeId() != null ? judgeUserById.get(s.getJudgeId()) : null;
String judgeName = s.getJudgeName();
if (!StringUtils.hasText(judgeName) && judgeUser != null) {
@ -729,12 +897,11 @@ public class ContestReviewServiceImpl implements IContestReviewService {
}
map.put("judgeName", judgeName);
if (judgeUser != null) {
Map<String, Object> judge = new LinkedHashMap<>();
judge.put("id", judgeUser.getId());
judge.put("username", judgeUser.getUsername());
judge.put("nickname", judgeUser.getNickname());
map.put("judge", judge);
map.put("judge", buildJudgeDetailMap(judgeUser, tenantNameById));
}
map.put("tenantId", s.getTenantId());
map.put("contestId", s.getContestId());
map.put("workId", s.getWorkId());
map.put("dimensionScores", s.getDimensionScores());
map.put("totalScore", s.getTotalScore());
map.put("comments", s.getComments());
@ -900,4 +1067,59 @@ public class ContestReviewServiceImpl implements IContestReviewService {
return result;
}
// ====== 替换评委 ======
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> replaceJudge(Long assignmentId, Long newJudgeId, Long operatorId) {
log.info("替换评委分配assignmentId{}newJudgeId{}", assignmentId, newJudgeId);
BizContestWorkJudgeAssignment assignment = assignmentMapper.selectById(assignmentId);
if (assignment == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "分配记录不存在");
}
Long contestId = assignment.getContestId();
Long workId = assignment.getWorkId();
Long oldJudgeId = assignment.getJudgeId();
if (newJudgeId != null && newJudgeId.equals(oldJudgeId)) {
Map<String, Object> noop = new LinkedHashMap<>();
noop.put("assignmentId", assignmentId);
noop.put("changed", false);
return noop;
}
LambdaQueryWrapper<BizContestWorkScore> scoreWrapper = new LambdaQueryWrapper<>();
scoreWrapper.eq(BizContestWorkScore::getAssignmentId, assignmentId);
scoreWrapper.eq(BizContestWorkScore::getValidState, 1);
if (scoreMapper.selectCount(scoreWrapper) > 0) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "该评委已提交评分,无法替换");
}
assertJudgeAssignedToContest(contestId, newJudgeId);
LambdaQueryWrapper<BizContestWorkJudgeAssignment> dupWrapper = new LambdaQueryWrapper<>();
dupWrapper.eq(BizContestWorkJudgeAssignment::getContestId, contestId);
dupWrapper.eq(BizContestWorkJudgeAssignment::getWorkId, workId);
dupWrapper.eq(BizContestWorkJudgeAssignment::getJudgeId, newJudgeId);
dupWrapper.ne(BizContestWorkJudgeAssignment::getId, assignmentId);
if (assignmentMapper.selectCount(dupWrapper) > 0) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "该评委已负责本作品,无需重复分配");
}
assignment.setJudgeId(newJudgeId);
assignment.setModifier(operatorId != null ? operatorId.intValue() : null);
assignment.setModifyTime(LocalDateTime.now());
assignmentMapper.updateById(assignment);
Map<String, Object> result = new LinkedHashMap<>();
result.put("assignmentId", assignmentId);
result.put("workId", workId);
result.put("contestId", contestId);
result.put("oldJudgeId", oldJudgeId);
result.put("newJudgeId", newJudgeId);
result.put("changed", true);
return result;
}
}

View File

@ -504,17 +504,18 @@ export interface ContestWorkJudgeAssignment {
export interface ContestWorkScore {
/** 与 id 同源,评分列表扁平接口常用 scoreId */
scoreId?: number;
/** 有评分记录时为评分表 id仅有分配未评分时与 assignmentId 相同 */
id: number;
tenantId: number;
tenantId?: number;
contestId: number;
workId: number;
assignmentId: number;
assignmentId?: number;
judgeId: number;
judgeName: string;
dimensionScores: any;
totalScore: number;
totalScore: number | null;
comments?: string;
scoreTime: string;
scoreTime: string | null;
creator?: number;
modifier?: number;
createTime?: string;
@ -525,7 +526,12 @@ export interface ContestWorkScore {
id: number;
username: string;
nickname: string;
phone?: string;
tenant?: { name?: string };
};
/** 后端按分配返回:未提交有效评分前为 true已评分则为 false仅可读的评审详情可隐藏「替换评委」 */
canReplaceJudge?: boolean;
scored?: boolean;
}
export interface AssignWorkForm {

View File

@ -96,7 +96,7 @@
<!-- 评审详情抽屉 -->
<a-drawer v-model:open="scoreDrawerVisible" title="评审详情" placement="right" width="700">
<a-table :columns="scoreColumns" :data-source="scoreList" :loading="scoreLoading" :pagination="false"
row-key="scoreId" size="small">
:row-key="(r: any) => (r.assignmentId != null ? r.assignmentId : r.scoreId ?? r.id)" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'">
{{
@ -122,9 +122,15 @@
{{ formatDate(record.scoreTime) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleReplaceJudge(record)">
<a-button
v-if="record.canReplaceJudge === true"
type="link"
size="small"
@click="handleReplaceJudge(record)"
>
替换评委
</a-button>
<span v-else class="text-gray"></span>
</template>
</template>
</a-table>