fix(exam): 选择题选项等宽居中 + 成绩发布门控加固 + 已交卷禁止再进入

This commit is contained in:
2026-09-30 15:46:41 +08:00
parent 0fca4bd996
commit 39d3da0c93
3 changed files with 87 additions and 15 deletions

View File

@@ -980,9 +980,10 @@ onMounted(async () => {
loading.value = true loading.value = true
try { try {
// 统一入口校验:考试组(中职/高职)与提交组(本科/教师)一致—— // 统一入口校验:
// 仅「截止后(status==='ended')」禁止进入;考试时间内(含已提交 / 意外退出)均可重新进入, // · 「截止后(status==='ended')」一律禁止进入;
// 应对断网、断电、误操作导致的意外退出(重进后由下方逻辑恢复已答题目)。 // · 考试组(中职/高职)「已正式交卷」禁止再进入(见下方 #3 判定);
// · 其余情况(未交卷的意外退出)可重进,由下方逻辑恢复已答题目。
const status = await examApi.getExamStatus(group) const status = await examApi.getExamStatus(group)
if (status.status === 'ended') { if (status.status === 'ended') {
ElMessage.warning(isExam ? '考试已结束,无法再次进入' : '作品提交已截止,无法再次进入') ElMessage.warning(isExam ? '考试已结束,无法再次进入' : '作品提交已截止,无法再次进入')
@@ -990,10 +991,18 @@ onMounted(async () => {
return return
} }
// 🔴 考试组(中职/高职)为一次性答题:**正式交卷后禁止再进入**(测试反馈 #3)。
// 只拦「已交卷」(submitted 仅在 submit_time 落库后为 true);未交卷的意外退出
// (断网/断电/误操作)仍可重进并恢复作答,保留原有的容错能力。
// 提交组(本科/教师)不受此限,截止前可重新进入重传作品。
if (isExam && status.submitted) {
ElMessage.warning('您已提交试卷,无法再次进入考试')
router.push('/dashboard')
return
}
if (isExam) { if (isExam) {
// 截止前可随意重新进入(含已提交 / 意外退出后重进),与顶部注释一致; // 考试组进入前仍受团队锁约束:同一时刻仅一名成员在考,避免重复进入互相干扰。
// 仅「截止后(status==='ended')」禁止进入(见上方判断)。考试组仍受团队锁约束,
// 同一时刻仅一名成员在考,避免重复进入互相干扰。
// 考试组(中职/高职):检查团队锁与活跃状态后进入考试 // 考试组(中职/高职):检查团队锁与活跃状态后进入考试
if (status.teamLocked) { if (status.teamLocked) {
ElMessage.warning(`您的小组已由 ${status.teamLockedBy || '队友'} 进入考试,不能重复进入`) ElMessage.warning(`您的小组已由 ${status.teamLockedBy || '队友'} 进入考试,不能重复进入`)
@@ -1293,15 +1302,31 @@ onUnmounted(() => {
.options-group { .options-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 覆盖 Element Plus `.el-radio-group{align-items:center}`:该声明会让各子项
按自身内容宽度收缩并居中,导致同题选项框宽窄不一(测试反馈 #1)。
显式 stretch 后子项统一撑满容器宽度。 */
align-items: stretch;
gap: 0.75rem; gap: 0.75rem;
width: 100%; /* 以「字符最长的选项」的边框宽度为统一宽度:容器收缩到最长子项(max-content),
再整体水平居中;文字在框内左对齐。 */
width: max-content;
max-width: 100%;
margin: 0 auto;
} }
.option-item { .option-item {
width: 100%;
box-sizing: border-box;
text-align: left;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
border-radius: 6px; border-radius: 6px;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
/* 超长选项不得撑破卡片:允许换行,避免 max-content 溢出 */
.options-group :deep(.el-radio__label) {
white-space: normal;
overflow-wrap: anywhere;
}
.option-item:hover { .option-item:hover {
border-color: #409eff; border-color: #409eff;
} }

View File

@@ -18,16 +18,20 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="exam-desc" v-if="contestStatus === 'ended'">考试已结束,无法再次进入</p> <p class="exam-desc" v-if="contestStatus === 'ended'">考试已结束,无法再次进入</p>
<p class="exam-desc" v-else-if="(!contestTime || contestStatus === 'not_started') && !hasSubmitted"> <p class="exam-desc" v-else-if="hasSubmitted && !isUploadGroup">
您已提交试卷,本次考试已结束,无法再次进入。
</p>
<p class="exam-desc" v-else-if="hasSubmitted">您已提交作品,截止前可重新提交。</p>
<p class="exam-desc" v-else-if="!contestTime || contestStatus === 'not_started'">
{{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }} {{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }}
</p> </p>
<p class="exam-desc" v-else-if="!hasSubmitted">请关注比赛时间安排</p> <p class="exam-desc" v-else>请关注比赛时间安排</p>
<el-button <el-button
type="primary" type="primary"
size="large" size="large"
class="card-action-btn" class="card-action-btn"
v-guard="() => enterContest()" v-guard="() => enterContest()"
:disabled="contestStatus === 'ended'" :disabled="enterDisabled"
> >
{{ enterBtnText }} {{ enterBtnText }}
</el-button> </el-button>
@@ -162,23 +166,43 @@ const isUploadGroup = computed(() => {
return g === 'benke' || g === 'jiaoshi' return g === 'benke' || g === 'jiaoshi'
}) })
const noScore = computed(() => !scoreData.value || scoreData.value.score === undefined || scoreData.value.score === null) // 🔴 成绩发布门控(铁律15):**必须**同时满足 published === true 才渲染分数。
// 历史实现只判 score 是否为 null —— 这与后端「未发布则 score 置 null」的约定形成
// 隐式耦合:一旦后端任一通道在未发布状态下带上数值 score,本卡片即直接泄露。
// Score.vue 早已用 `v-if="scoreData && scoreData.published"` 同源判定,此处对齐,
// 形成「两条展示通道同一判据」的纵深防御。
const noScore = computed(() =>
!scoreData.value ||
scoreData.value.published !== true ||
scoreData.value.score === undefined ||
scoreData.value.score === null
)
const scoreMessage = computed(() => { const scoreMessage = computed(() => {
if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告' if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告'
return '暂无成绩,请耐心等待成绩发布后查看' return '暂无成绩,请耐心等待成绩发布后查看'
}) })
// 进入比赛按钮文案:截止后→「已结束」;已提交→ 提交组「重新提交」/考试组「继续考试」; // 进入比赛按钮文案:
// 其余→「进入比赛」。考试组(中职/高职)已提交也可在截止前重进(应对意外退出)。 // 截止后 → 「已结束」(禁用)
// 考试组 + 已提交 → 「已提交」(禁用,不可再进入;测试反馈 #3)
// 提交组 + 已提交 → 「重新提交」(截止前可重传,用户既定需求)
// 其余 → 「进入比赛」
// ⚠️ 考试组(中职/高职)为一次性答题:正式交卷后不允许重进(防改答案再交卷)。
// 未交卷的意外退出(断网/断电)不受影响——submitted 仅在 submit_time 落库后为 true。
const enterBtnText = computed(() => { const enterBtnText = computed(() => {
if (contestStatus.value === 'ended') return '已结束' if (contestStatus.value === 'ended') return '已结束'
if (hasSubmitted.value) { if (hasSubmitted.value) {
return isUploadGroup.value ? '重新提交' : '继续考试' return isUploadGroup.value ? '重新提交' : '已提交'
} }
return '进入比赛' return '进入比赛'
}) })
// 考试组已交卷 ⇒ 禁止再次进入
const enterDisabled = computed(() =>
contestStatus.value === 'ended' || (hasSubmitted.value && !isUploadGroup.value)
)
// 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动 // 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动
// 后端 getExamStatus 返回 status ∈ {not_set, not_started, in_progress, ended}, // 后端 getExamStatus 返回 status ∈ {not_set, not_started, in_progress, ended},
// 此前前端误判 === 'open' 导致活动期恒定显示「未开始」;现统一在此映射并纳入已提交态。 // 此前前端误判 === 'open' 导致活动期恒定显示「未开始」;现统一在此映射并纳入已提交态。
@@ -212,10 +236,18 @@ function formatDate(dateStr) {
async function enterContest() { async function enterContest() {
// 所有组别统一:仅考试窗口内(isActive)允许进入;截止后(ended)统一禁止。 // 所有组别统一:仅考试窗口内(isActive)允许进入;截止后(ended)统一禁止。
// 考试组/提交组即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。 // 提交组(本科/教师)即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
// 考试组(中职/高职)为一次性答题:**正式交卷后禁止再进入**(测试反馈 #3),
// 退出重进只对「未交卷」的情况开放。
try { try {
const group = authStore.userGroup || 'zhongzhi' const group = authStore.userGroup || 'zhongzhi'
const isExam = group === 'zhongzhi' || group === 'gaozhi'
const data = await examApi.getExamStatus(group) const data = await examApi.getExamStatus(group)
if (isExam && data.submitted) {
hasSubmitted.value = true
ElMessage.warning('您已提交试卷,无法再次进入考试')
return
}
if (!data.isActive) { if (!data.isActive) {
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始')) ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
return return

View File

@@ -811,15 +811,30 @@ onUnmounted(() => {
.options-group { .options-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 与 Competition.vue 同款修正(测试反馈 #1):覆盖 Element Plus
`.el-radio-group{align-items:center}`,否则各选项按自身内容宽度收缩居中、宽窄不一。 */
align-items: stretch;
gap: 12px; gap: 12px;
/* 以最长选项的边框宽度为统一宽度,整块居中,文字左对齐 */
width: max-content;
max-width: 100%;
margin: 0 auto;
} }
.option-item { .option-item {
width: 100%;
box-sizing: border-box;
text-align: left;
padding: 12px 16px; padding: 12px 16px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
transition: background 0.2s; transition: background 0.2s;
} }
.option-item:hover { border-color: #409eff; } .option-item:hover { border-color: #409eff; }
/* 超长选项不得撑破卡片:允许换行,避免 max-content 溢出 */
.options-group :deep(.el-radio__label) {
white-space: normal;
overflow-wrap: anywhere;
}
.option-label { .option-label {
font-weight: 600; font-weight: 600;
color: #409eff; color: #409eff;