fix(ui): 组锁成员可从提交页返回工作台 + 免假警告(R192)

现象(用户实测):本科组其他成员进入提交页 → 组锁生效不能提交,但页面无任何返回
入口,只能浏览器后退;移动端/内嵌浏览器更退不出去 ⇒ 被困在提交页。

根因三层,缺一不可:
1. /competition 是全屏页(App.vue 只有 <router-view />,无全局导航)⇒ 页面内不给
   按钮就真的没有出口。
2. 上传模式的「被锁」分支只有 el-alert 文案 + disabled 上传控件,从来没有动作按钮
   (客观题模式被锁是 router.push('/dashboard') 直接踢走,故历史上未暴露)。
3. onBeforeRouteLeave 对 examStarted && !isTimeUp 一律弹「离开将丢失当前提交进度」——
   对被锁用户是假警告(本机没有任何可提交内容),加了按钮也会被二次劝退。

改动(唯一文件):
- 上传卡片 header 新增常驻「← 返回工作台」——不依赖 examStarted(顶部倒计时栏在未开始
  时不存在,放那儿会漏)。
- 组锁 prompt 下方新增 .team-locked-actions:主按钮 + 引导「本组作品已由队友提交,您无需
  任何操作」;alert description 补「您本人无需提交,可直接返回工作台」。
- 新增 leaveCompetition():仅「已选文件未上传」才确认一次 → resetExam() → push('/dashboard')。
  ⛔ 必须先 resetExam() 再 push:否则 ①守卫再弹一次(双确认)②store 是全局单例,
  定时器残留 ⇒ 回工作台后倒计时仍在跑。用 resetExam 而非只 stopExam,是为了与守卫里
  「确认离开后」的清理同口径(两套清理逻辑必漏项)。
- onBeforeRouteLeave 新增豁免 lockedNoContent = isUploadMode && teamLocked。
  ⛔ 豁免面必须写窄,不能只判 teamLocked:当前两者等价(考试组不拉 uploadStatus),
  但只写 teamLocked 会把考试组一并纳入,将来考试组一旦拉 status 就静默取消「交卷前离开」的确认。

验证(真实组锁数据 + 真 chromium,非构造):
- 库里本就有现场:benke_submissions 归属 contestant 11(郑佳怡),contestant 12 同
  team_uid=1 ⇒ 真接口返回 teamLocked=true / memberCount=5 / canUpload=false。
- 登录态用 jwt_fallback_secret 自签 token 注入 localStorage(未改任何人口令)。
- 组锁视角 8/8 PASS(header 出口 · 组锁区块 + 引导 · 上传仍 disabled · 点击落 /dashboard ·
  messageBox=0 · console 零错误);归属人视角 5/5 PASS(无组锁区块 · 出口仍在 · 不误弹)。
- 变异验证:两按钮加 v-if="false" ⇒ 4 条断言转红;备份还原后 diff 为空、复跑 8/8 复绿。
- 数据零改动:12 号口令未变 · benke_submissions 仍 1 行 · 落盘 PDF 字节数原样。
- 静态:verify_frontend_static.mjs PASS(TDZ 0 / F2 0)· SFC compileTemplate 0 error。
This commit is contained in:
2026-10-21 16:49:11 +08:00
parent 96d16129f7
commit 4fd04f106a

View File

@@ -21,7 +21,13 @@
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
<el-card class="upload-card" shadow="never">
<template #header>
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
<div class="upload-header">
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
<!-- R192:页面级常驻出口。本页无全局导航(App.vue 只有 router-view),
组锁 / 倒计时归零 / 只是想放弃提交 的用户若无此按钮就只能在浏览器后退,
移动端甚至退不出去(体验反馈:成员进入后被锁在提交页)。 -->
<el-button text type="primary" :icon="Back" @click="leaveCompetition">返回工作台</el-button>
</div>
</template>
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
@@ -107,8 +113,14 @@
show-icon
style="margin-top:12px"
title="组内作品唯一:本组已有成员上传,您无法再上传"
description="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。"
description="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。您本人无需提交,可直接返回工作台。"
/>
<!-- 组锁的出口按钮必须**紧贴提示**:视线焦点在这里,且此处的「返回」不弹
「丢失提交进度」确认(团队作品已由队友提交,本机没有可丢的内容)。 -->
<div v-if="teamLocked" class="team-locked-actions">
<el-button type="primary" @click="leaveCompetition">返回工作台</el-button>
<span class="team-locked-hint">本组作品已由队友提交,您无需任何操作。</span>
</div>
<el-divider v-if="uploadedWorks.length" />
<div v-if="uploadedWorks.length" class="uploaded-list">
@@ -405,7 +417,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { Timer, UploadFilled } from '@element-plus/icons-vue'
import { Timer, UploadFilled, Back } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useExamStore } from '@/stores/exam'
import * as examApi from '@/api/exam'
@@ -516,6 +528,36 @@ function clearUploadFiles() {
uploadRef.value?.clearFiles?.()
}
/**
* 返回工作台(R192)—— 上传模式的统一出口。
*
* 🔴 必须先 resetExam() 再 push,理由有三条:
* ① 不清 `examStarted` ⇒ `onBeforeRouteLeave` 会再弹一次「离开页面将丢失当前提交进度」。
* 上传模式下用户常常只是放弃提交(甚至被组锁、本机根本没有可提交内容),
* 这句文案既失真又构成二次打扰;
* ② 不清 timer ⇒ store 是全局单例,回到工作台后倒计时仍在跑,下次进入会看到陈旧剩余时间;
* ③ 用 resetExam(而非只 stopExam)与 `onBeforeRouteLeave` 里确认离开后的清理**同一口径**,
* ⛔ 不留「按钮退出」与「路由守卫退出」两套清理逻辑(漏项会以「下次进入状态诡异」的形式出现)。
*
* 只有「待上传列表里有已选文件」才算真有内容会丢,此时才确认一次。
* 组锁用户的上传控件是 disabled 的,选不出文件 ⇒ 不弹确认,直接离开。
*/
async function leaveCompetition() {
if (!teamLocked.value && uploadFileList.value.length > 0) {
try {
await ElMessageBox.confirm(
'已选择的文件尚未上传,离开后需要重新选择。确定返回工作台吗?',
'提示',
{ confirmButtonText: '返回工作台', cancelButtonText: '继续上传', type: 'warning' }
)
} catch {
return
}
}
examStore.resetExam()
router.push('/dashboard')
}
async function submitUploadFiles() {
const files = pendingUploadFiles.value
if (!files.length) return
@@ -1031,7 +1073,13 @@ function preventKeyboard(e) {
// 路由离开警告
onBeforeRouteLeave((to, from, next) => {
if (examStore.examStarted && !examStore.isTimeUp) {
// 🔴 组锁用户不弹:上传控件 disabled、无交卷动作 ⇒ 本机没有任何可丢的内容。
// 对这类用户弹「离开将丢失提交进度」是假警告(体验反馈:被锁成员被困在提交页)。
// 判定写成「上传模式 && teamLocked」而不是只看 teamLocked:后者当前虽等价
// (客观题模式不拉 uploadStatus),但语义上把考试组也纳入了豁免面,将来一旦
// 考试组也拉 status,就会静默取消「交卷前离开」的确认 —— 这是不可接受的放松。
const lockedNoContent = isUploadMode.value && teamLocked.value
if (examStore.examStarted && !examStore.isTimeUp && !lockedNoContent) {
const verb = isUploadMode.value ? '提交' : '答题'
ElMessageBox.confirm(
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
@@ -1579,6 +1627,28 @@ onUnmounted(() => {
justify-content: space-between;
}
/* ==================== R192:上传页出口(组锁 / 主动放弃提交) ==================== */
/* 本页无全局导航(App.vue 只有 router-view)⇒ 出口按钮必须常驻,否则被组锁的
成员只能靠浏览器后退离开(移动端/内嵌浏览器常常退不出去)。 */
.upload-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.team-locked-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.75rem;
}
.team-locked-hint {
font-size: 0.85rem;
color: #909399;
}
/* ==================== R190:小组作品状态面板 ==================== */
.team-status-panel {
border: 1px solid #c6e2ff;