feat: 评审规则嵌套活动详情、评委人数与提交流程联动

- 活动详情接口补充嵌套 reviewRule(含 judgeCount)
- 添加评委抽屉:人数提示对齐评审规则页,满足评委数量方可提交
- 作品分配评委:与规则人数一致,标题与确定按钮逻辑对齐
- 评审规则:去最高最低仅当评委数量大于阈值时可选用

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-14 18:51:56 +08:00
parent dfccb07f5a
commit ba93872922
4 changed files with 308 additions and 190 deletions

View File

@ -21,7 +21,9 @@ import com.lesingle.modules.biz.contest.mapper.ContestMapper;
import com.lesingle.modules.biz.contest.mapper.ContestRegistrationMapper;
import com.lesingle.modules.biz.contest.mapper.ContestWorkMapper;
import com.lesingle.modules.biz.contest.service.IContestService;
import com.lesingle.modules.biz.review.entity.BizContestReviewRule;
import com.lesingle.modules.biz.review.entity.BizContestWorkJudgeAssignment;
import com.lesingle.modules.biz.review.mapper.ContestReviewRuleMapper;
import com.lesingle.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper;
import com.lesingle.modules.sys.entity.SysTenant;
import com.lesingle.modules.sys.mapper.SysTenantMapper;
@ -46,6 +48,7 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
private final ContestRegistrationMapper contestRegistrationMapper;
private final ContestWorkMapper contestWorkMapper;
private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper;
private final ContestReviewRuleMapper contestReviewRuleMapper;
private final SysTenantMapper sysTenantMapper;
// 支持两种日期格式ISO 格式 (T 分隔) 和空格分隔格式
@ -302,6 +305,22 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
result.put("contestTenantInfos", tenantInfoList);
}
// 嵌套评审规则与评委端活动详情一致供前端每作品评委数等使用
if (contest.getReviewRuleId() != null) {
BizContestReviewRule rule = contestReviewRuleMapper.selectById(contest.getReviewRuleId());
if (rule != null) {
Map<String, Object> ruleMap = new LinkedHashMap<>();
ruleMap.put("id", rule.getId());
ruleMap.put("tenantId", rule.getTenantId());
ruleMap.put("ruleName", rule.getRuleName());
ruleMap.put("ruleDescription", rule.getRuleDescription());
ruleMap.put("judgeCount", rule.getJudgeCount());
ruleMap.put("dimensions", rule.getDimensions());
ruleMap.put("calculationRule", rule.getCalculationRule());
result.put("reviewRule", ruleMap);
}
}
return result;
}

View File

@ -55,15 +55,21 @@
<!-- 已选评委区域 -->
<a-card size="small">
<template #title>
<template v-if="judgeCount > 0">
已选 {{ selectedJudges.length }} / {{ judgeCount }}
<span v-if="selectedJudges.length > judgeCount" class="warning-text">
超出{{ selectedJudges.length - judgeCount }}
<template v-if="perWorkJudgeCount > 0">
已选 {{ selectedJudges.length }} / {{ perWorkJudgeCount }}
<span
v-if="selectedJudges.length > perWorkJudgeCount"
class="warning-text"
>
超出{{ selectedJudges.length - perWorkJudgeCount }}
</span>
</template>
<template v-else>
已选 {{ selectedJudges.length }}
<span class="hint-text">评审规则未配置人数</span>
<span v-if="!linkedReviewRule" class="hint-text"
>活动未关联评审规则</span
>
<span v-else class="hint-text">评审规则中评委数量未设置</span>
</template>
</template>
<a-list :data-source="selectedJudges" :loading="selectedJudgesLoading" size="small">
@ -94,7 +100,12 @@
<div class="drawer-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" :disabled="submitLoading" @click="handleSubmit">
<a-button
type="primary"
:loading="submitLoading"
:disabled="submitLoading || !canSubmitJudges"
@click="handleSubmit"
>
确定
</a-button>
</a-space>
@ -108,7 +119,7 @@ import { message } from "ant-design-vue"
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
import type { Judge, QueryJudgeParams } from "@/api/judges-management"
import { judgesManagementApi } from "@/api/judges-management"
import { judgesApi } from "@/api/contests"
import { judgesApi, reviewRulesApi } from "@/api/contests"
import type { Contest } from "@/api/contests"
interface Props {
@ -121,11 +132,48 @@ const emit = defineEmits<{
success: []
}>()
//
const judgeCount = computed(() => {
return props.contest.reviewRule?.judgeCount || 0
/** 活动是否已选择评审规则reviewRuleId */
const linkedReviewRule = computed(
() => props.contest.reviewRuleId != null || props.contest.reviewRule?.id != null
)
/** 从接口额外拉取的规则上的评委数量(兼容旧版活动详情未嵌套 reviewRule */
const resolvedRuleJudgeCount = ref<number | null>(null)
/** 与「评审规则」页「评委数量」一致:每位作品需要的评委人数 */
const perWorkJudgeCount = computed(() => {
const fromDetail = props.contest.reviewRule?.judgeCount
if (typeof fromDetail === "number" && fromDetail > 0) {
return fromDetail
}
const r = resolvedRuleJudgeCount.value
if (typeof r === "number" && r > 0) {
return r
}
return 0
})
/** 打开抽屉时若详情无嵌套人数则补拉评审规则详情 */
async function ensureRuleJudgeCount() {
resolvedRuleJudgeCount.value = null
const rid = props.contest.reviewRuleId ?? props.contest.reviewRule?.id
if (!rid) {
return
}
const nested = props.contest.reviewRule?.judgeCount
if (typeof nested === "number" && nested > 0) {
return
}
try {
const rule = await reviewRulesApi.getDetail(rid)
if (typeof rule.judgeCount === "number" && rule.judgeCount > 0) {
resolvedRuleJudgeCount.value = rule.judgeCount
}
} catch {
/* 忽略 */
}
}
//
const searchParams = reactive<QueryJudgeParams>({
page: 1,
@ -150,6 +198,15 @@ const selectedJudgeIds = ref<number[]>([])
const selectedJudges = ref<Judge[]>([])
const selectedJudgesLoading = ref(false)
/** 活动侧已选评委 ≥ 评审规则「评委数量」时才允许提交;规则未配置人数时不拦截 */
const canSubmitJudges = computed(() => {
const need = perWorkJudgeCount.value
if (need <= 0) {
return true
}
return selectedJudges.value.length >= need
})
//
const submitLoading = ref(false)
@ -295,6 +352,13 @@ const handleJudgeTableChange = (pag: any) => {
// assigned +
const handleSubmit = async () => {
const need = perWorkJudgeCount.value
if (need > 0 && selectedJudges.value.length < need) {
message.warning(
`活动评委至少需要 ${need} 人(与评审规则中的「评委数量」一致)`,
)
return
}
submitLoading.value = true
try {
const { assigned } = await judgesApi.getList(props.contestId)
@ -368,6 +432,7 @@ watch(
)
onMounted(() => {
ensureRuleJudgeCount()
loadJudges()
loadSelectedJudges()
})

View File

@ -3,51 +3,34 @@
<a-card class="mb-4">
<template #title>评审规则</template>
<template #extra>
<a-button
v-permission="'contest:update'"
type="primary"
@click="handleAdd"
>新建规则</a-button
>
<a-button v-permission="'contest:update'" type="primary" @click="handleAdd">新建规则</a-button>
</template>
</a-card>
<!-- 搜索表单 -->
<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="规则名称">
<a-input
v-model:value="searchParams.ruleName"
placeholder="请输入规则名称"
allow-clear
style="width: 200px"
/>
<a-input v-model:value="searchParams.ruleName" placeholder="请输入规则名称" allow-clear style="width: 200px" />
</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-key="id"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id"
@change="handleTableChange">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
@ -69,25 +52,18 @@
</template>
<template v-else-if="column.key === 'contests'">
<span v-if="record.contests && record.contests.length > 0">
{{ record.contests.map((c: any) => c.contestName).join("、") }}
{{record.contests.map((c: any) => c.contestName).join("、")}}
</span>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
v-permission="'contest:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
<a-button v-permission="'contest:update'" type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-popconfirm
v-permission="'contest:update'"
<a-popconfirm v-permission="'contest:update'"
:title="record.contests?.length > 0 ? `该规则已关联${record.contests.length}个活动,确定删除吗?` : '确定要删除这个评审规则吗?'"
@confirm="handleDelete(record.id)"
>
@confirm="handleDelete(record.id)">
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
@ -96,117 +72,60 @@
</a-table>
<!-- 新增/编辑评审规则抽屉 -->
<a-drawer
v-model:open="modalVisible"
:title="isEditing ? '编辑规则' : '新建规则'"
placement="right"
width="600px"
:footer-style="{ textAlign: 'right' }"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-drawer v-model:open="modalVisible" :title="isEditing ? '编辑规则' : '新建规则'" placement="right" width="600px"
:footer-style="{ textAlign: 'right' }">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-form-item label="规则名称" name="ruleName">
<a-input
v-model:value="form.ruleName"
placeholder="请输入规则名称"
:maxlength="100"
/>
<a-input v-model:value="form.ruleName" placeholder="请输入规则名称" :maxlength="100" />
</a-form-item>
<a-form-item label="规则说明" name="ruleDescription">
<a-textarea
v-model:value="form.ruleDescription"
placeholder="请输入规则说明"
:rows="4"
:maxlength="500"
/>
<a-textarea v-model:value="form.ruleDescription" placeholder="请输入规则说明" :rows="4" :maxlength="500" />
</a-form-item>
<a-form-item label="评委数量" name="judgeCount">
<a-input-number
v-model:value="form.judgeCount"
placeholder="请输入评委数量"
:min="1"
style="width: 100%"
/>
<a-input-number v-model:value="form.judgeCount" placeholder="请输入评委数量" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="评分标准" name="dimensions" required>
<div class="scoring-standards">
<div
v-for="(dimension, index) in form.dimensions"
:key="index"
class="scoring-standard-item"
>
<div v-for="(dimension, index) in form.dimensions" :key="index" class="scoring-standard-item">
<a-row :gutter="16" class="scoring-input-row">
<a-col :span="12">
<a-input
v-model:value="dimension.name"
placeholder="请输入评分项,如创意分、技术分"
:maxlength="50"
/>
<a-input v-model:value="dimension.name" placeholder="请输入评分项,如创意分、技术分" :maxlength="50" />
</a-col>
<a-col :span="8">
<a-input-number
v-model:value="dimension.percentage"
placeholder="请输入分值"
:min="0"
:max="100"
:precision="2"
style="width: 100%"
>
<a-input-number v-model:value="dimension.percentage" placeholder="请输入分值" :min="0" :max="100"
:precision="2" style="width: 100%">
<template #addonAfter>%</template>
</a-input-number>
</a-col>
<a-col
:span="4"
style="
<a-col :span="4" style="
display: flex;
align-items: center;
justify-content: center;
"
>
<a-button
v-if="form.dimensions.length > 1"
type="text"
danger
@click="removeDimension(index)"
style="padding: 0; height: auto"
>
<template #icon><DeleteOutlined /></template>
">
<a-button v-if="form.dimensions.length > 1" type="text" danger @click="removeDimension(index)"
style="padding: 0; height: auto">
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-col>
</a-row>
<a-textarea
v-model:value="dimension.description"
placeholder="请输入评分标准说明"
:rows="2"
:maxlength="150"
show-count
style="margin-top: 12px"
/>
<a-textarea v-model:value="dimension.description" placeholder="请输入评分标准说明" :rows="2" :maxlength="150"
show-count style="margin-top: 12px" />
</div>
<a-button type="dashed" style="width: 100%" @click="addDimension">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
添加评分标准
</a-button>
</div>
</a-form-item>
<a-form-item label="分数计算" name="calculationRule">
<a-radio-group
v-model:value="form.calculationRule"
style="width: 100%"
>
<a-radio-group v-model:value="form.calculationRule" style="width: 100%">
<a-space direction="vertical" :size="8" style="width: 100%">
<a-radio
v-for="option in availableCalculationOptions"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
style="display: block; margin-bottom: 8px"
>
<a-radio v-for="option in availableCalculationOptions" :key="option.value" :value="option.value"
:disabled="option.disabled" style="display: block; margin-bottom: 8px">
{{ option.label }}
</a-radio>
</a-space>
@ -214,12 +133,8 @@
</a-form-item>
</a-form>
<template #footer>
<a-button style="margin-right: 8px" @click="handleCancel"
>取消</a-button
>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit"
>确定</a-button
>
<a-button style="margin-right: 8px" @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</a-button>
</template>
</a-drawer>
</div>
@ -286,25 +201,21 @@ const calculationOptions = [
{ value: "removeMin", label: "去掉最低分的均值" },
]
//
// > 4
const availableCalculationOptions = computed(() => {
const judgeCount = form.judgeCount || 0
if (judgeCount === 1) {
return calculationOptions.map((opt) => ({
...opt,
disabled: opt.value !== "average",
}))
} else if (judgeCount === 2) {
return calculationOptions.map((opt) => ({
...opt,
disabled: opt.value === "removeMin",
}))
} else {
return calculationOptions.map((opt) => ({
...opt,
disabled: false,
}))
}
const judgeCount = form.judgeCount ?? 0
return calculationOptions.map((opt) => {
let disabled = false
if (judgeCount === 1) {
disabled = opt.value !== "average"
} else if (judgeCount === 2) {
disabled = opt.value === "removeMin" || opt.value === "removeMaxMin"
} else {
disabled =
opt.value === "removeMaxMin" ? judgeCount <= 3 : false
}
return { ...opt, disabled }
})
})
//
@ -315,6 +226,12 @@ watch(
form.calculationRule = "average"
} else if (newVal === 2 && form.calculationRule === "removeMin") {
form.calculationRule = "average"
} else if (
newVal !== undefined &&
newVal <= 4 &&
form.calculationRule === "removeMaxMin"
) {
form.calculationRule = "average"
}
}
)
@ -472,6 +389,13 @@ const handleEdit = async (record: ReviewRule) => {
calculationRule = "removeMin"
}
form.calculationRule = calculationRule
// 4 退
if (
(form.judgeCount ?? 0) <= 4 &&
form.calculationRule === "removeMaxMin"
) {
form.calculationRule = "average"
}
//
try {
@ -482,28 +406,28 @@ const handleEdit = async (record: ReviewRule) => {
form.dimensions =
Array.isArray(dims) && dims.length > 0
? dims.map((d: any) => {
//
if (d.percentage !== undefined) {
return {
name: d.name || "",
percentage: d.percentage || 0,
description: d.description || "",
}
} else {
// maxScore weight percentage
const total = dims.reduce(
(sum: number, item: any) => sum + (item.weight || 1),
0
)
const percentage =
total > 0 ? ((d.weight || 1) / total) * 100 : 0
return {
name: d.name || "",
percentage: percentage,
description: d.description || "",
}
//
if (d.percentage !== undefined) {
return {
name: d.name || "",
percentage: d.percentage || 0,
description: d.description || "",
}
})
} else {
// maxScore weight percentage
const total = dims.reduce(
(sum: number, item: any) => sum + (item.weight || 1),
0
)
const percentage =
total > 0 ? ((d.weight || 1) / total) * 100 : 0
return {
name: d.name || "",
percentage: percentage,
description: d.description || "",
}
}
})
: [{ name: "", percentage: 0, description: "" }]
} catch {
form.dimensions = [{ name: "", percentage: 0, description: "" }]
@ -570,7 +494,7 @@ const handleSubmit = async () => {
}
message.error(
error?.response?.data?.message ||
(isEditing.value ? "编辑失败" : "创建失败")
(isEditing.value ? "编辑失败" : "创建失败")
)
} finally {
submitLoading.value = false
@ -589,24 +513,62 @@ $primary: #6366f1;
.review-rules-page {
:deep(.ant-card) {
border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px;
.ant-card-head { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } }
.ant-card-body { padding: 0; }
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
.ant-card-head {
border-bottom: none;
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
}
}
.ant-card-body {
padding: 0;
}
}
:deep(.ant-table-wrapper) {
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
.ant-table-thead>tr>th {
background: #fafafa;
font-weight: 600;
}
.ant-table-tbody>tr:hover>td {
background: rgba($primary, 0.03);
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
}
}
.text-desc { font-size: 12px; color: #6b7280; }
.text-muted { color: #d1d5db; }
.text-desc {
font-size: 12px;
color: #6b7280;
}
.text-muted {
color: #d1d5db;
}
.search-form {
margin-bottom: 16px; padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
margin-bottom: 16px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.scoring-standards {

View File

@ -208,7 +208,18 @@
<!-- 已选评委区域 -->
<a-card size="small">
<template #title>
已选 {{ selectedJudgeRows.length }} 位评委
<template v-if="perWorkJudgeCount > 0">
已选 {{ selectedJudgeRows.length }} / {{ perWorkJudgeCount }} 位评委
<span
v-if="selectedJudgeRows.length > perWorkJudgeCount"
class="assign-warning-text"
>
超出{{ selectedJudgeRows.length - perWorkJudgeCount }}
</span>
</template>
<template v-else>
已选 {{ selectedJudgeRows.length }} 位评委
</template>
</template>
<a-list :data-source="selectedJudgeRows" size="small">
<template #renderItem="{ item }">
@ -241,7 +252,12 @@
<div class="drawer-footer">
<a-space>
<a-button @click="handleAssignDrawerClose">取消</a-button>
<a-button type="primary" :loading="assignLoading" @click="handleConfirmAssign">
<a-button
type="primary"
:loading="assignLoading"
:disabled="assignLoading || !canConfirmAssign"
@click="handleConfirmAssign"
>
确定
</a-button>
</a-space>
@ -273,6 +289,8 @@ import {
worksApi,
reviewsApi,
judgesApi,
reviewRulesApi,
type Contest,
type ContestWork,
type ContestJudge,
} from "@/api/contests"
@ -303,8 +321,22 @@ const fetchDetailStats = async () => {
try { detailStats.value = await worksApi.getStats(contestId) } catch { /* */ }
}
//
//
const contestName = ref("")
const contestDetail = ref<Contest | null>(null)
const resolvedRuleJudgeCount = ref<number | null>(null)
const perWorkJudgeCount = computed(() => {
const fromDetail = contestDetail.value?.reviewRule?.judgeCount
if (typeof fromDetail === "number" && fromDetail > 0) {
return fromDetail
}
const r = resolvedRuleJudgeCount.value
if (typeof r === "number" && r > 0) {
return r
}
return 0
})
//
const tenants = ref<Tenant[]>([])
@ -381,6 +413,15 @@ const judgeSearchParams = reactive({
const selectedJudgeKeys = ref<number[]>([])
const selectedJudgeRows = ref<ContestJudge[]>([])
/** 分配所选评委人数 ≥ 规则要求时才可确定(规则未配置人数时不拦截) */
const canConfirmAssign = computed(() => {
const need = perWorkJudgeCount.value
if (need <= 0) {
return true
}
return selectedJudgeRows.value.length >= need
})
/** 将作品上已分配但不在活动显式名单中的评委并入列表,避免误删历史分配 */
function mergeOrphanJudges(record: ContestWork, base: ContestJudge[]): ContestJudge[] {
const seen = new Set(base.map((j) => j.judgeId))
@ -488,6 +529,24 @@ const fetchContestInfo = async () => {
try {
const contest = await contestsApi.getDetail(contestId)
contestName.value = contest.contestName
contestDetail.value = contest
resolvedRuleJudgeCount.value = null
const rid = contest.reviewRuleId ?? contest.reviewRule?.id
if (!rid) {
return
}
const nested = contest.reviewRule?.judgeCount
if (typeof nested === "number" && nested > 0) {
return
}
try {
const rule = await reviewRulesApi.getDetail(rid)
if (typeof rule.judgeCount === "number" && rule.judgeCount > 0) {
resolvedRuleJudgeCount.value = rule.judgeCount
}
} catch {
/* 忽略 */
}
} catch (error) {
console.error("获取活动信息失败", error)
}
@ -647,6 +706,14 @@ const handleJudgeTableChange = (pag: any) => {
//
const handleConfirmAssign = async () => {
const need = perWorkJudgeCount.value
if (need > 0 && selectedJudgeRows.value.length < need) {
message.warning(
`每位作品至少需要 ${need} 位评委(与评审规则中的「评委数量」一致)`,
)
return
}
if (isBatchAssign.value && selectedJudgeRows.value.length === 0) {
message.warning("批量分配请至少选择一名评委")
return
@ -826,6 +893,11 @@ $primary: #6366f1;
}
.assign-judge-drawer {
.assign-warning-text {
color: #ff4d4f;
font-size: 12px;
}
.drawer-footer {
position: sticky;
bottom: 0;