feat: 作品编号 workNo 生成与回填(公开端/作业/Flyway V8)及评审与前端展示

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-08 14:44:16 +08:00
parent 430dce1f09
commit 36cd01c585
10 changed files with 239 additions and 52 deletions

View File

@ -11,6 +11,11 @@ import java.util.Map;
public interface IContestWorkService extends IService<BizContestWork> {
/**
* 为指定赛事生成下一个作品编号 {@link #submitWork} 所用规则一致W{contestId}-{序号}
*/
String nextContestWorkNo(Long contestId);
Map<String, Object> submitWork(SubmitWorkDto dto, Long tenantId, Long submitterId);
PageResult<Map<String, Object>> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant);

View File

@ -110,7 +110,7 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
}
// 生成作品编号
String workNo = generateWorkNo(contestId);
String workNo = nextContestWorkNo(contestId);
// 创建作品
BizContestWork work = new BizContestWork();
@ -658,7 +658,8 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
return isStart ? date.atStartOfDay() : date.atTime(23, 59, 59);
}
private String generateWorkNo(Long contestId) {
@Override
public String nextContestWorkNo(Long contestId) {
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWork::getContestId, contestId);
long count = count(wrapper);

View File

@ -55,10 +55,17 @@ public class HomeworkSubmissionServiceImpl extends ServiceImpl<HomeworkSubmissio
throw BusinessException.of(ErrorCode.CONFLICT, "您已提交过该作业");
}
LambdaQueryWrapper<BizHomeworkSubmission> seqWrapper = new LambdaQueryWrapper<>();
seqWrapper.eq(BizHomeworkSubmission::getHomeworkId, dto.getHomeworkId());
seqWrapper.eq(BizHomeworkSubmission::getValidState, 1);
long homeworkSubmitCount = count(seqWrapper);
String workNo = "H" + dto.getHomeworkId() + "-" + (homeworkSubmitCount + 1);
BizHomeworkSubmission entity = new BizHomeworkSubmission();
entity.setTenantId(tenantId);
entity.setHomeworkId(dto.getHomeworkId());
entity.setStudentId(studentId);
entity.setWorkNo(workNo);
entity.setWorkName(dto.getWorkName());
entity.setWorkDescription(dto.getWorkDescription());
entity.setFiles(dto.getFiles());

View File

@ -7,8 +7,10 @@ import com.competition.common.enums.PublishStatus;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.biz.contest.entity.BizContest;
import com.competition.modules.biz.contest.entity.BizContestRegistration;
import com.competition.modules.biz.contest.entity.BizContestWork;
import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.review.dto.AutoSetAwardsDto;
import com.competition.modules.biz.review.dto.BatchSetAwardsDto;
@ -38,6 +40,7 @@ public class ContestResultServiceImpl implements IContestResultService {
private final ContestWorkMapper workMapper;
private final ContestMapper contestMapper;
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestReviewRuleMapper reviewRuleMapper;
private final ContestWorkScoreMapper scoreMapper;
private final ContestJudgeMapper judgeMapper;
@ -309,7 +312,19 @@ public class ContestResultServiceImpl implements IContestResultService {
wrapper.like(BizContestWork::getWorkNo, workNo);
}
if (StringUtils.hasText(accountNo)) {
wrapper.like(BizContestWork::getSubmitterAccountNo, accountNo);
LambdaQueryWrapper<BizContestRegistration> regQw = new LambdaQueryWrapper<>();
regQw.eq(BizContestRegistration::getContestId, contestId);
regQw.eq(BizContestRegistration::getValidState, 1);
regQw.like(BizContestRegistration::getAccountNo, accountNo);
List<Long> matchRegIds = contestRegistrationMapper.selectList(regQw).stream()
.map(BizContestRegistration::getId)
.collect(Collectors.toList());
wrapper.and(q -> {
q.like(BizContestWork::getSubmitterAccountNo, accountNo);
if (!matchRegIds.isEmpty()) {
q.or().in(BizContestWork::getRegistrationId, matchRegIds);
}
});
}
wrapper.orderByDesc(BizContestWork::getFinalScore);
@ -317,8 +332,21 @@ public class ContestResultServiceImpl implements IContestResultService {
Page<BizContestWork> pageObj = new Page<>(page, pageSize);
Page<BizContestWork> result = workMapper.selectPage(pageObj, wrapper);
List<Map<String, Object>> voList = result.getRecords().stream().map(w -> {
List<BizContestWork> records = result.getRecords();
Set<Long> registrationIds = records.stream()
.map(BizContestWork::getRegistrationId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, BizContestRegistration> registrationById = new HashMap<>();
if (!registrationIds.isEmpty()) {
for (BizContestRegistration reg : contestRegistrationMapper.selectBatchIds(registrationIds)) {
registrationById.put(reg.getId(), reg);
}
}
List<Map<String, Object>> voList = records.stream().map(w -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", w.getId());
map.put("workId", w.getId());
map.put("workNo", w.getWorkNo());
map.put("title", w.getTitle());
@ -330,6 +358,10 @@ public class ContestResultServiceImpl implements IContestResultService {
map.put("awardName", w.getAwardName());
map.put("certificateUrl", w.getCertificateUrl());
map.put("submitTime", w.getSubmitTime());
BizContestRegistration reg = w.getRegistrationId() != null
? registrationById.get(w.getRegistrationId())
: null;
map.put("registration", reg != null ? registrationToNestedMap(reg) : null);
return map;
}).collect(Collectors.toList());
@ -397,20 +429,63 @@ public class ContestResultServiceImpl implements IContestResultService {
}
}
BizContest contest = contestMapper.selectById(contestId);
if (contest == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "赛事不存在");
}
Map<String, Object> contestMap = new LinkedHashMap<>();
contestMap.put("id", contest.getId());
contestMap.put("contestName", contest.getContestName());
contestMap.put("resultState", contest.getResultState());
contestMap.put("resultPublishTime", contest.getResultPublishTime());
contestMap.put("contestType", contest.getContestType());
long unscoredWorks = Math.max(0, totalWorks - scoredWorks);
Map<String, Object> summaryMap = new LinkedHashMap<>();
summaryMap.put("totalWorks", totalWorks);
summaryMap.put("scoredWorks", scoredWorks);
summaryMap.put("rankedWorks", rankedWorks);
summaryMap.put("awardedWorks", awardedWorks);
summaryMap.put("unscoredWorks", unscoredWorks);
Map<String, Object> scoreStats = new LinkedHashMap<>();
scoreStats.put("avgScore", scoredList.isEmpty() ? null : avgScore.toPlainString());
scoreStats.put("maxScore", scoredList.isEmpty() ? null : maxScore.toPlainString());
scoreStats.put("minScore", scoredList.isEmpty() ? null : minScore.toPlainString());
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalWorks", totalWorks);
result.put("scoredWorks", scoredWorks);
result.put("rankedWorks", rankedWorks);
result.put("awardedWorks", awardedWorks);
result.put("avgScore", avgScore);
result.put("maxScore", maxScore);
result.put("minScore", minScore);
result.put("contest", contestMap);
result.put("summary", summaryMap);
result.put("scoreStats", scoreStats);
result.put("awardDistribution", awardDistribution);
return result;
}
// ====== 私有辅助方法 ======
/**
* 与前端成果列表/作品管理一致registration.user / registration.team 结构
*/
private Map<String, Object> registrationToNestedMap(BizContestRegistration reg) {
Map<String, Object> regMap = new LinkedHashMap<>();
Map<String, Object> user = new LinkedHashMap<>();
user.put("id", reg.getUserId());
user.put("username", reg.getAccountNo());
user.put("nickname", reg.getAccountName());
regMap.put("user", user);
Map<String, Object> team = null;
if ("team".equals(reg.getRegistrationType())
&& (reg.getTeamId() != null || StringUtils.hasText(reg.getTeamName()))) {
team = new LinkedHashMap<>();
team.put("id", reg.getTeamId());
team.put("teamName", reg.getTeamName());
}
regMap.put("team", team);
return regMap;
}
private BigDecimal doCalculate(List<BizContestWorkScore> scores, String calculationRule, Map<Long, BigDecimal> weightMap) {
List<BigDecimal> scoreValues = scores.stream()
.map(BizContestWorkScore::getTotalScore)

View File

@ -621,12 +621,40 @@ public class ContestReviewServiceImpl implements IContestReviewService {
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);
}
}
}
return scores.stream().map(s -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", s.getId());
map.put("scoreId", s.getId());
map.put("assignmentId", s.getAssignmentId());
map.put("judgeId", s.getJudgeId());
map.put("judgeName", s.getJudgeName());
SysUser judgeUser = s.getJudgeId() != null ? judgeUserById.get(s.getJudgeId()) : null;
String judgeName = s.getJudgeName();
if (!StringUtils.hasText(judgeName) && judgeUser != null) {
judgeName = StringUtils.hasText(judgeUser.getNickname())
? judgeUser.getNickname()
: judgeUser.getUsername();
}
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("dimensionScores", s.getDimensionScores());
map.put("totalScore", s.getTotalScore());
map.put("comments", s.getComments());

View File

@ -13,6 +13,7 @@ import com.competition.modules.biz.contest.entity.BizContestWork;
import com.competition.modules.biz.contest.mapper.ContestMapper;
import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.competition.modules.biz.contest.mapper.ContestWorkMapper;
import com.competition.modules.biz.contest.service.IContestWorkService;
import com.competition.modules.pub.dto.PublicRegisterActivityDto;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
@ -37,6 +38,7 @@ public class PublicActivityService {
private final ContestMapper contestMapper;
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestWorkMapper contestWorkMapper;
private final IContestWorkService contestWorkService;
private final UserChildMapper userChildMapper;
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
@ -378,6 +380,8 @@ public class PublicActivityService {
work.setSubmitTime(LocalDateTime.now());
work.setVersion(nextVersion);
work.setIsLatest(true);
work.setWorkNo(contestWorkService.nextContestWorkNo(contestId));
work.setSubmitterAccountNo(reg.getAccountNo());
// 从作品库选择作品提交快照复制
if (dto.get("userWorkId") != null) {

View File

@ -0,0 +1,9 @@
-- 回填历史数据中空的作品编号与运行时生成形态一致W{contestId}-* / H{homeworkId}-*
UPDATE t_biz_contest_work
SET work_no = CONCAT('W', contest_id, '-', id)
WHERE work_no IS NULL OR TRIM(work_no) = '';
UPDATE t_biz_homework_submission
SET work_no = CONCAT('H', homework_id, '-', id)
WHERE work_no IS NULL OR TRIM(work_no) = '';

View File

@ -1374,6 +1374,8 @@ export interface ContestResult {
id: number;
workNo: string | null;
title: string;
/** 作品上冗余的提交账号,与报名账号一致时优先展示 */
submitterAccountNo?: string | null;
finalScore: number | null;
rank: number | null;
awardLevel: string | null;
@ -1404,6 +1406,7 @@ export interface ResultsSummary {
contest: {
id: number;
contestName: string;
contestType?: "individual" | "team";
resultState: string;
resultPublishTime: string | null;
};

View File

@ -84,17 +84,19 @@
</div>
</div>
<!-- 评审记录 -->
<div class="section">
<!-- 评审记录纵向卡片避免多评委时 Tab 横向溢出 -->
<div class="section section-review">
<div class="section-title">评审记录</div>
<div class="review-records">
<template v-if="reviewRecords && reviewRecords.length > 0">
<a-tabs v-model:activeKey="activeReviewTab">
<a-tab-pane
v-for="record in reviewRecords"
:key="record.id"
:tab="record.judge?.nickname || record.judge?.username || '评委'"
<div
v-for="(record, index) in reviewRecords"
:key="record.scoreId ?? record.id ?? index"
class="review-judge-block"
>
<div class="review-judge-header">
<span class="review-judge-title">{{ judgeDisplayName(record, index) }}</span>
</div>
<div class="review-card">
<div class="review-item">
<span class="review-label">作品评分</span>
@ -105,7 +107,7 @@
</div>
<div class="review-item">
<span class="review-label">评委老师</span>
<span class="review-value">{{ record.judge?.nickname || record.judge?.username || '-' }}</span>
<span class="review-value">{{ judgeNameText(record) || '-' }}</span>
</div>
<div class="review-item">
<span class="review-label">评分时间</span>
@ -118,8 +120,7 @@
<span class="review-value">{{ record.comments }}</span>
</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
</template>
<a-empty v-else description="暂无评审记录" :image="false" />
</div>
@ -161,9 +162,29 @@ const visible = ref(false)
const loading = ref(false)
const workDetail = ref<ContestWork | null>(null)
const reviewRecords = ref<any[]>([])
const activeReviewTab = ref<number | string>("")
const showPreviewBtn = ref(false)
/** 评委展示名:接口扁平字段 judgeName或嵌套 judge与列表接口一致 */
const judgeNameText = (record: any) => {
const flat = record?.judgeName
if (flat != null && String(flat).trim() !== '') return String(flat).trim()
return (
record?.judge?.nickname ||
record?.judge?.username ||
''
)
}
/** 纵向列表标题:有姓名用姓名,否则「评委一、评委二…」 */
const judgeDisplayName = (record: any, index: number) => {
const name = judgeNameText(record)
if (name) return name
const numerals = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const n = index + 1
if (n >= 1 && n <= 10) return `评委${numerals[n - 1]}`
return `评委 ${n}`
}
//
const drawerTitle = computed(() => {
if (workDetail.value) {
@ -268,9 +289,6 @@ const fetchReviewRecords = async (workId: number) => {
try {
const records = await reviewsApi.getWorkScores(workId)
reviewRecords.value = records || []
if (records && records.length > 0) {
activeReviewTab.value = records[0].id
}
} catch (error) {
console.error("获取评审记录失败", error)
reviewRecords.value = []
@ -354,6 +372,9 @@ watch(
.work-detail-drawer {
:deep(.ant-drawer-body) {
padding: 16px 24px;
overflow-x: hidden;
max-width: 100%;
box-sizing: border-box;
}
}
@ -475,9 +496,31 @@ watch(
}
}
.section-review {
min-width: 0;
}
.review-records {
:deep(.ant-tabs-nav) {
min-width: 0;
width: 100%;
.review-judge-block {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.review-judge-header {
margin-bottom: 8px;
}
.review-judge-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
word-break: break-all;
}
.review-card {
@ -486,11 +529,15 @@ watch(
padding: 16px;
border-radius: 0 8px 8px 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
max-width: 100%;
box-sizing: border-box;
.review-item {
margin-bottom: 12px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
overflow-wrap: anywhere;
&:last-child {
margin-bottom: 0;
@ -498,6 +545,7 @@ watch(
.review-label {
color: #666;
flex-shrink: 0;
}
.review-value {

View File

@ -105,10 +105,15 @@
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'nickname'">
<template v-if="contestInfo?.contestType === 'team'">
{{ record.registration?.team?.teamName || record.registration?.user?.nickname || '-' }}
</template>
<template v-else>
{{ record.registration?.user?.nickname || record.registration?.team?.teamName || '-' }}
</template>
</template>
<template v-else-if="column.key === 'username'">
{{ record.registration?.user?.username || '-' }}
{{ record.submitterAccountNo || record.registration?.user?.username || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<a-button v-if="!isSuperAdmin && contestInfo?.resultState !== 'published'" type="link" size="small" @click="openSetAward(record)">设奖</a-button>
@ -277,7 +282,9 @@ const fetchList = async () => {
workNo: searchParams.workNo || undefined,
accountNo: searchParams.accountNo || undefined,
})
if (response.contest) {
contestInfo.value = response.contest
}
let list = response.list
//
if (searchParams.awardLevel) {