diff --git a/docs/design/super-admin/review-progress-optimization.md b/docs/design/super-admin/review-progress-optimization.md index 9ec005f..9436331 100644 --- a/docs/design/super-admin/review-progress-optimization.md +++ b/docs/design/super-admin/review-progress-optimization.md @@ -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 评审进度前端过滤 diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/controller/ContestReviewController.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/controller/ContestReviewController.java index d76ac10..c48c83f 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/controller/ContestReviewController.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/controller/ContestReviewController.java @@ -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> 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>> getWorkScores(@PathVariable Long workId) { return Result.success(contestReviewService.getWorkScores(workId)); } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/dto/ReplaceJudgeDto.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/dto/ReplaceJudgeDto.java new file mode 100644 index 0000000..0a90d31 --- /dev/null +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/dto/ReplaceJudgeDto.java @@ -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; +} diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/IContestReviewService.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/IContestReviewService.java index b01f843..66815ec 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/IContestReviewService.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/IContestReviewService.java @@ -36,4 +36,9 @@ public interface IContestReviewService { Map calculateFinalScore(Long workId); Map getJudgeContestDetail(Long judgeId, Long contestId); + + /** + * 将某条作品评委分配替换为另一名评委(该分配下尚无有效评分记录时才允许)。 + */ + Map replaceJudge(Long assignmentId, Long newJudgeId, Long operatorId); } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/impl/ContestReviewServiceImpl.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/impl/ContestReviewServiceImpl.java index 6768aaf..f2836c8 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/impl/ContestReviewServiceImpl.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/review/service/impl/ContestReviewServiceImpl.java @@ -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> 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 aw = new LambdaQueryWrapper<>(); + aw.eq(BizContestWorkJudgeAssignment::getWorkId, workId); + aw.orderByAsc(BizContestWorkJudgeAssignment::getId); + List assignments = assignmentMapper.selectList(aw); + + if (assignments.isEmpty()) { + return listWorkScoresFallbackByScoresOnly(workId); + } + + Set judgeIds = assignments.stream() + .map(BizContestWorkJudgeAssignment::getJudgeId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map judgeUserById = loadUsersByIds(judgeIds); + Map tenantNameById = loadTenantNames(judgeUserById.values()); + + List> 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 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 loadUsersByIds(Set judgeIds) { + Map 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 loadTenantNames(Collection users) { + Set tenantIds = users.stream() + .map(SysUser::getTenantId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map 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 buildJudgeDetailMap(SysUser judgeUser, Map tenantNameById) { + Map 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 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 sw = new LambdaQueryWrapper<>(); + sw.eq(BizContestWorkScore::getAssignmentId, assignmentId); + sw.eq(BizContestWorkScore::getValidState, 1); + List byAssign = scoreMapper.selectList(sw); + if (!byAssign.isEmpty()) { + return pickLatestScore(byAssign); + } + if (judgeId == null) { + return null; + } + LambdaQueryWrapper 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 list = scoreMapper.selectList(legacy); + return list.isEmpty() ? null : pickLatestScore(list); + } + + private static BizContestWorkScore pickLatestScore(List 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> listWorkScoresFallbackByScoresOnly(Long workId) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(BizContestWorkScore::getWorkId, workId); wrapper.eq(BizContestWorkScore::getValidState, 1); wrapper.orderByAsc(BizContestWorkScore::getScoreTime); - List scores = scoreMapper.selectList(wrapper); Set judgeIds = scores.stream() .map(BizContestWorkScore::getJudgeId) .filter(Objects::nonNull) .collect(Collectors.toSet()); - Map judgeUserById = new HashMap<>(); - if (!judgeIds.isEmpty()) { - for (SysUser u : sysUserMapper.selectBatchIds(judgeIds)) { - if (u != null && u.getId() != null) { - judgeUserById.put(u.getId(), u); - } - } - } + Map judgeUserById = loadUsersByIds(judgeIds); + Map tenantNameById = loadTenantNames(judgeUserById.values()); return scores.stream().map(s -> { Map 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 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 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 noop = new LinkedHashMap<>(); + noop.put("assignmentId", assignmentId); + noop.put("changed", false); + return noop; + } + + LambdaQueryWrapper 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 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 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; + } } diff --git a/lesingle-creation-frontend/src/api/contests.ts b/lesingle-creation-frontend/src/api/contests.ts index 5e9be36..09e9a91 100644 --- a/lesingle-creation-frontend/src/api/contests.ts +++ b/lesingle-creation-frontend/src/api/contests.ts @@ -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 { diff --git a/lesingle-creation-frontend/src/views/contests/reviews/ProgressDetail.vue b/lesingle-creation-frontend/src/views/contests/reviews/ProgressDetail.vue index 461903a..471f873 100644 --- a/lesingle-creation-frontend/src/views/contests/reviews/ProgressDetail.vue +++ b/lesingle-creation-frontend/src/views/contests/reviews/ProgressDetail.vue @@ -96,7 +96,7 @@ + :row-key="(r: any) => (r.assignmentId != null ? r.assignmentId : r.scoreId ?? r.id)" size="small">