Files
competition-website/src/views/Competition.vue

1480 lines
48 KiB
Vue
Raw Normal View History

<template>
<div class="competition-page">
<!-- ==================== 顶部倒计时栏 ==================== -->
<div class="exam-top-bar" v-if="examStore.examStarted" :class="{ 'time-critical': remainingSeconds < 300 && remainingSeconds > 0 }">
<div class="exam-top-inner">
<span class="exam-title">{{ isUploadMode ? '在线提交' : '在线答题' }}</span>
<div class="exam-timer">
<el-icon :size="20"><Timer /></el-icon>
<span class="timer-display" :class="{ 'timer-danger': remainingSeconds < 300 }">
{{ examStore.countdownDisplay }}
</span>
</div>
<div class="exam-progress" v-if="!isUploadMode">
<span>已答 {{ answeredCount }} / {{ totalCount }} 题</span>
<el-progress :percentage="progressPercent" :stroke-width="8" :color="progressColor" />
</div>
</div>
</div>
<!-- ==================== 本科/教师组:答案文件上传模式(无客观题) ==================== -->
<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>
</template>
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
</el-alert>
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
:file-list="uploadFileList"
:on-change="onUploadFileChange"
multiple
drag
class="upload-drag"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">docx / pdf / ppt / zip;zip 单独上传且自动覆盖其他;pdf 与 docx 互斥;仅允许 zip / (pdf+ppt) / (docx+ppt) 三种组合</div>
</template>
</el-upload>
<!-- 待上传文件(已选择,尚未提交) -->
<div v-if="uploadFileList.length" class="pending-list">
<div class="pending-list-title">待上传文件(确认无误后点击「上传所选文件」):</div>
<div v-for="f in uploadFileList" :key="f.uid" class="uploaded-item pending-item" :class="{ 'file-over-limit': isOverLimit(f) }">
<span class="uploaded-name">{{ (f.raw || f).name }}</span>
<span class="uploaded-size" :class="{ 'size-over': isOverLimit(f) }">{{ formatFileSize((f.raw || f).size) }}</span>
<el-tag size="small" :type="categoryTagType(categoryOf((f.raw || f).name))">{{ categoryLabel(categoryOf((f.raw || f).name)) }}</el-tag>
<el-tag v-if="isOverLimit(f)" size="small" type="danger">超出限制</el-tag>
<el-button size="small" type="danger" plain @click="removePendingFile(f)">删除</el-button>
</div>
<el-alert
v-if="hasOverLimitFile"
type="error"
:closable="false"
show-icon
style="margin-top:10px"
title="存在超出大小限制的文件"
description="标红的文件已超过上传上限(zip 200MB / 其他 100MB),请删除后重新选择符合要求的文件再提交。"
/>
</div>
<div class="upload-actions">
<el-button type="primary" :loading="uploading" :disabled="pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
</div>
<el-divider v-if="uploadedWorks.length" />
<div v-if="uploadedWorks.length" class="uploaded-list">
<div class="pending-list-title">已上传文件:</div>
<div v-for="w in uploadedWorks" :key="w.id" class="uploaded-item">
<span class="uploaded-name">{{ w.fileName }}</span>
<el-tag size="small" :type="categoryTagType(w.category)">{{ categoryLabel(w.category) }}</el-tag>
<el-tag size="small" type="success">已上传</el-tag>
<el-button size="small" type="danger" plain :loading="w.deleting" v-guard="() => deleteUploadedWork(w)">删除</el-button>
</div>
</div>
</el-card>
</div>
<!-- ==================== 主布局(客观题考试) ==================== -->
<div class="exam-body" v-if="!isUploadMode">
<!-- 左侧题目导航 -->
<div class="exam-sidebar">
<h4 class="sidebar-title">题目导航</h4>
<!-- 选择题 -->
<div class="nav-section" v-if="examStore.choiceQuestions.length > 0">
<div class="nav-section-title">选择题 ({{ examStore.choiceQuestions.length }}题)</div>
<div class="question-nav">
<div
v-for="(q, idx) in examStore.choiceQuestions"
:key="q.question_uid"
class="nav-item"
:class="{
'nav-answered': isAnswered(q.question_uid),
'nav-current': currentQuestionIndex === findQuestionIndex(q.question_uid),
}"
@click="goToQuestion(findQuestionIndex(q.question_uid))"
>
{{ findQuestionIndex(q.question_uid) + 1 }}
</div>
</div>
</div>
<!-- 判断题 -->
<div class="nav-section" v-if="examStore.judgeQuestions.length > 0">
<div class="nav-section-title">判断题 ({{ examStore.judgeQuestions.length }}题)</div>
<div class="question-nav">
<div
v-for="(q, idx) in examStore.judgeQuestions"
:key="q.question_uid"
class="nav-item"
:class="{
'nav-answered': isAnswered(q.question_uid),
'nav-current': currentQuestionIndex === findQuestionIndex(q.question_uid),
}"
@click="goToQuestion(findQuestionIndex(q.question_uid))"
>
{{ findQuestionIndex(q.question_uid) + 1 }}
</div>
</div>
</div>
<!-- 综合大题(中职/高职,替代原 Word 上传题) -->
<div class="nav-section" v-if="examStore.bigQuestionEnabled">
<div class="nav-section-title">
综合大题
<span v-if="examStore.bigLoaded" class="nav-subcount">
(已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问)
</span>
</div>
<div class="question-nav">
<div
class="nav-item"
:class="{
'nav-answered': examStore.bigAllAnswered,
'nav-current': currentQuestionIndex === bigQuestionIndex,
}"
@click="currentQuestionIndex = bigQuestionIndex"
>
{{ bigQuestionIndex + 1 }}
</div>
</div>
</div>
<div class="sidebar-legend">
<span class="legend-item">
<span class="legend-dot answered"></span> 已答
</span>
<span class="legend-item">
<span class="legend-dot current"></span> 当前
</span>
<span class="legend-item">
<span class="legend-dot unanswered"></span> 未答
</span>
</div>
</div>
<!-- 右侧题目区域 -->
<div class="exam-main" v-if="hasExamContent">
<!-- 选择题 -->
<div v-if="isCurrentChoice && currentQuestion.question_type === 'choice'" class="question-card">
<div class="question-header">
<span class="question-number">第 {{ currentQuestionIndex + 1 }} 题</span>
<el-tag type="primary" size="small">选择题</el-tag>
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
</div>
<div class="question-body">
<p class="question-text">{{ currentQuestion.question }}</p>
<el-radio-group
v-model="currentAnswer"
class="options-group"
@change="onAnswerChange"
>
<div
v-for="(opt, oi) in currentOptions"
:key="oi"
class="option-item"
>
<el-radio :value="getOptionKey(opt, oi)">
<span class="option-label">{{ optionLabels[oi] }}.</span>
{{ getOptionText(opt, oi) }}
</el-radio>
</div>
</el-radio-group>
</div>
<div class="question-footer">
<el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- 判断题 -->
<div v-if="isCurrentChoice && currentQuestion.question_type === 'judge'" class="question-card">
<div class="question-header">
<span class="question-number">第 {{ currentQuestionIndex + 1 }} 题</span>
<el-tag type="success" size="small">判断题</el-tag>
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
</div>
<div class="question-body">
<p class="question-text">{{ currentQuestion.question }}</p>
<el-radio-group
v-model="currentAnswer"
class="options-group"
@change="onAnswerChange"
>
<div class="option-item">
<el-radio value="true">正确</el-radio>
</div>
<div class="option-item">
<el-radio value="false">错误</el-radio>
</div>
</el-radio-group>
</div>
<div class="question-footer">
<el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- 综合大题(中职/高职):题干富文本 + 三小问作答 -->
<div v-if="isCurrentBig" class="question-card big-card">
<div class="question-header">
<span class="question-number">第 {{ bigQuestionIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合大题</el-tag>
<span class="question-score" v-if="examStore.bigLoaded">
(每小题 {{ examStore.bigScorePerSub }} 分,共 {{ examStore.bigTotalScore }} 分)
</span>
</div>
<!-- 加载中 -->
<div class="question-body" v-if="examStore.bigLoading">
<el-skeleton :rows="5" animated />
</div>
<!-- 抽题失败:给出明确原因与重试入口(不静默留白) -->
<div class="question-body" v-else-if="!examStore.bigLoaded">
<el-alert
type="error"
:closable="false"
show-icon
:title="examStore.bigError || '综合大题加载失败'"
description="若提示「该组别暂无大题」,请联系监考老师;若为网络问题,可点击下方按钮重试。"
/>
<div class="big-retry">
<el-button type="primary" :loading="examStore.bigLoading" v-guard="() => retryBigQuestion()">
重新加载题目
</el-button>
</div>
</div>
<!-- 题干 + 三小问作答 -->
<div class="question-body" v-else>
<h3 class="big-title">{{ examStore.bigQuestion.title }}</h3>
<!-- 富文本题干(含表格):经白名单消毒后渲染 -->
<div class="big-description" v-html="bigDescriptionHtml"></div>
<el-alert
v-if="!examStore.bigAllAnswered"
class="big-hint"
type="info"
:closable="false"
show-icon
title="请在下方三个输入框中分别作答,每个输入框最多 2000 字,作答内容会实时自动保存。"
/>
<div
v-for="i in examStore.bigSubCount"
:key="i"
class="big-sub-item"
>
<div class="big-sub-header">
<span class="big-sub-no">第 {{ i }} 小问</span>
<span class="big-sub-score">({{ examStore.bigScorePerSub }} 分)</span>
</div>
<div v-if="examStore.bigSubPrompts[i - 1]" class="big-sub-prompt">{{ examStore.bigSubPrompts[i - 1] }}</div>
<el-input
class="big-answer-input"
type="textarea"
:rows="6"
resize="vertical"
:maxlength="examStore.bigMaxChars"
show-word-limit
:placeholder="`请输入第 ${i} 小问的答案(最多 ${examStore.bigMaxChars} 字)`"
:model-value="bigAnswerOf(i)"
@update:model-value="val => onBigAnswerInput(i, val)"
/>
</div>
<!-- 保存状态:让考生对「是否已存上」有明确反馈 -->
<div class="big-save-status">
<el-tag v-if="examStore.bigSaving" size="small" type="warning" effect="plain">正在保存…</el-tag>
<el-tag v-else-if="bigSaveErrorShown" size="small" type="danger" effect="plain">
保存失败:{{ examStore.bigError }}
</el-tag>
<el-tag v-else size="small" type="success" effect="plain">已自动保存</el-tag>
<span class="big-save-note">已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问</span>
</div>
</div>
<div class="question-footer">
<el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div class="exam-main" v-else v-loading="loading">
<el-empty description="暂无题目,请确认考试时间是否已开始" :image-size="120" />
</div>
</div>
<!-- ==================== 底部提交栏 ==================== -->
<div class="exam-bottom-bar" v-if="!isUploadMode">
<div class="exam-bottom-inner">
<span class="submit-hint">
{{ examStore.bigQuestionEnabled
? `请确保已检查所有题目和综合大题(已答 ${examStore.bigAnsweredCount}/${examStore.bigSubCount} 小问)后提交`
: '请确保已检查所有题目后提交' }}
</span>
<el-button
type="danger"
size="large"
v-guard="() => handleSubmitAll()"
:loading="submitting"
>
提交全部
</el-button>
</div>
</div>
</div>
</template>
<script setup>
/**
* Competition.vue - 在线答题页面
*
* 安全特性:
* - 禁止鼠标选中文字(selectstart + CSS user-select)
* - 禁止复制(copy 事件)
* - 禁止右键菜单(contextmenu 事件)
* - 禁止键盘快捷键(Ctrl+C/F12/Ctrl+Shift+I 等)
* - 浏览器刷新/关闭警告
* - 路由离开确认
* - 倒计时归零自动提交
*/
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 { useAuthStore } from '@/stores/auth'
import { useExamStore } from '@/stores/exam'
import * as examApi from '@/api/exam'
import * as uploadApi from '@/api/upload'
import { formatFileSize } from '@/utils/upload'
import { sanitizeHtml } from '@/utils/richtext'
import {
fileExt,
categoryOf,
categoryLabel,
categoryTagType,
validateSingleFile,
finalComboCheck,
reconcileFiles,
} from '@/utils/uploadCombo'
const router = useRouter()
const authStore = useAuthStore()
const examStore = useExamStore()
// ==================== 本科/教师组:答案文件上传模式 ====================
const isUploadMode = computed(() => !['zhongzhi', 'gaozhi'].includes(authStore.userGroup))
const groupLabel = computed(() => ({ zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }[authStore.userGroup] || authStore.userGroup))
const uploadPanelLoading = ref(false)
const uploading = ref(false)
const uploadFileList = ref([]) // el-upload 绑定列表
const uploadedWorks = ref([])
const uploadRef = ref(null)
const ZIP_MAX = 200 * 1024 * 1024
const OTHER_MAX = 100 * 1024 * 1024
function fileTypeOf(name) {
const e = '.' + fileExt(name)
if (['.doc', '.docx', '.pdf'].includes(e)) return 'word'
if (['.ppt', '.pptx'].includes(e)) return 'ppt'
if (e === '.zip') return 'word' // zip 作为答案包,归入 word 评分项
return 'word'
}
const uploadComboValid = computed(() => {
const items = uploadFileList.value
.filter(f => !isOverLimit(f))
.map(f => ({ name: (f.raw || f).name, size: (f.raw || f).size }))
return finalComboCheck(items).ok
})
const pendingUploadFiles = computed(() => uploadFileList.value.filter(f => !f.uploaded && !isOverLimit(f)))
const hasOverLimitFile = computed(() => uploadFileList.value.some(f => isOverLimit(f)))
// 单文件是否超过上传大小限制(zip 200MB / 其他 100MB,来自 uploadCombo.validateSingleFile)
function isOverLimit(file) {
const f = file.raw || file
return validateSingleFile(f.name, f.size) !== null
}
// 选择文件时自动覆盖/互斥校验:新加入的文件覆盖冲突的旧文件(zip 独占;pdf↔docx 互斥)
function onUploadFileChange(file, fileList) {
const files = fileList.map(f => ({ uid: f.uid, name: (f.raw || f).name, size: (f.raw || f).size }))
const added = { uid: file.uid, name: (file.raw || file).name, size: (file.raw || file).size }
const r = reconcileFiles(files, added)
// 保留全部已选文件(含超限文件,便于以红色高亮提示);仅剔除被组合规则覆盖的旧文件
const keepUids = new Set(r.list.map(x => x.uid))
uploadFileList.value = fileList.filter(f => keepUids.has(f.uid) || isOverLimit(f))
// 超限文件明确高亮提示(不静默丢弃,符合"超限要有明显样式提醒")
const over = uploadFileList.value.filter(f => isOverLimit(f))
if (over.length) {
const names = over.map(f => (f.raw || f).name).join('、')
ElMessage.error(`文件「${names}」已超过大小限制(zip 200MB / 其他 100MB),已标红且无法提交`)
} else if (r.dropped.length) {
ElMessage.info('已按规则自动覆盖冲突文件(zip 独占 / pdf 与 docx 互斥)')
}
if (!r.valid) ElMessage.error(r.error)
}
// 从待上传列表中删除单个文件(Issue ⑨:传错可手动删除并重传)
function removePendingFile(f) {
uploadFileList.value = uploadFileList.value.filter(x => x.uid !== f.uid)
}
function clearUploadFiles() {
uploadFileList.value = []
uploadRef.value?.clearFiles?.()
}
async function submitUploadFiles() {
const files = pendingUploadFiles.value
if (!files.length) return
const items = files.map(f => ({ name: (f.raw || f).name, size: (f.raw || f).size }))
const check = finalComboCheck(items)
if (!check.ok) { ElMessage.error(check.error); return }
uploading.value = true
try {
// 处理与已上传文件的覆盖关系(zip 独占等,Issue ⑫)
const pendingCats = files.map(f => categoryOf((f.raw || f).name))
const pendingHasZip = pendingCats.includes('zip')
if (pendingHasZip) {
// zip 覆盖一切:先删除服务端所有已上传文件
for (const w of uploadedWorks.value.slice()) await deleteUploadedWork(w, true)
} else {
// 本次不含 zip,但若服务端有 zip,则删除服务端 zip(被新文件覆盖)
const serverZip = uploadedWorks.value.find(w => w.category === 'zip')
if (serverZip) await deleteUploadedWork(serverZip, true)
}
for (const f of files) {
const raw = f.raw || f
const formData = new FormData()
// Egg stream 模式要求「文本字段在文件字段之前」,否则 file_type 会被丢弃,
// 导致后端把作品类型默认当成 word(影响本科/教师组的文件类型判定)
formData.append('file_type', fileTypeOf(raw.name))
formData.append('file', raw)
await uploadApi.uploadWork(authStore.userGroup, formData)
}
ElMessage.success('上传成功,作品已提交')
uploadFileList.value = []
uploadRef.value?.clearFiles?.()
await fetchUploadedWorks()
examStore.stopExam()
} catch (e) {
ElMessage.error(e?.message || '上传失败')
return
} finally {
uploading.value = false
}
// 成功提示与返回(点击 × 关闭弹窗不视为上传失败)
try {
await ElMessageBox.alert('您的作品已成功提交,感谢您的参与!', '提交成功', {
confirmButtonText: '返回工作台',
type: 'success',
})
} catch (e) {
/* 用户关闭弹窗,忽略 */
}
router.push('/dashboard')
}
// 删除已上传到服务端的作品文件(Issue ⑨)
async function deleteUploadedWork(w, silent = false) {
if (w.deleting) return
w.deleting = true
try {
await uploadApi.deleteWork(authStore.userGroup, w.id)
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
if (!silent) ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e?.message || '删除失败')
} finally {
w.deleting = false
}
}
async function fetchUploadedWorks() {
try {
const res = await uploadApi.getSubmissions(authStore.userGroup)
const list = res?.list || []
uploadedWorks.value = list.map(s => ({ id: s.id, fileName: s.file_name, category: categoryOf(s.file_name) }))
} catch (e) { /* ignore */ }
}
// ==================== 状态 ====================
const loading = ref(true)
const submitting = ref(false)
const currentQuestionIndex = ref(0)
// ==================== 计算属性 ====================
// 综合大题作为最后一道「题」参与导航与题量统计(中职/高职启用,本科/教师不启用)
const totalCount = computed(() => {
const base = examStore.questions.length
return examStore.bigQuestionEnabled ? base + 1 : base
})
// 综合大题的虚拟索引(最后一个)
const bigQuestionIndex = computed(() => examStore.questions.length)
// 当前是否停在综合大题
const isCurrentBig = computed(() => {
return examStore.bigQuestionEnabled && currentQuestionIndex.value === bigQuestionIndex.value
})
// 右侧题目区是否有内容可渲染(客观题与综合大题任一可用即渲染,避免只抽到大题时误显示空状态)
const hasExamContent = computed(() => {
return examStore.questions.length > 0 || (examStore.bigQuestionEnabled && examStore.bigLoaded)
})
const answeredCount = computed(() => {
let count = Object.keys(examStore.answers).length
// 综合大题按「三小问全部作答」计为 1 题已答(部分作答可在侧栏看到 x/3 明细)
if (examStore.bigQuestionEnabled && examStore.bigAllAnswered) count++
return count
})
/** 题干富文本:白名单消毒后再交给 v-html 渲染 */
const bigDescriptionHtml = computed(() => {
const q = examStore.bigQuestion
return sanitizeHtml((q && q.description) || '')
})
/** 是否处于「保存失败」状态(保存中优先展示加载态,失败时给出明确提示) */
const bigSaveErrorShown = computed(() => {
return !examStore.bigSaving && !!examStore.bigError && examStore.bigLoaded
})
const remainingSeconds = computed(() => examStore.countdown)
const progressPercent = computed(() => {
if (totalCount.value === 0) return 0
return Math.round((answeredCount.value / totalCount.value) * 100)
})
const progressColor = computed(() => {
if (progressPercent.value >= 80) return '#67c23a'
if (progressPercent.value >= 50) return '#409eff'
return '#e6a23c'
})
const currentQuestion = computed(() => {
return examStore.questions[currentQuestionIndex.value] || {}
})
const isCurrentChoice = computed(() => {
return currentQuestionIndex.value < examStore.questions.length
})
const currentOptions = computed(() => {
const q = currentQuestion.value
return q.options || []
})
const currentAnswer = computed({
get() {
const qId = currentQuestion.value.question_uid
return examStore.answers[qId] || ''
},
set(val) {
const qId = currentQuestion.value.question_uid
examStore.submitAnswer(qId, val)
},
})
const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
// ==================== 选项辅助函数 ====================
function getOptionKey(opt, index) {
if (typeof opt === 'object' && opt !== null) {
return opt.key || opt.value || String(index)
}
return String(opt)
}
function getOptionText(opt, index) {
if (typeof opt === 'object' && opt !== null) {
return opt.text || opt.label || opt.key || String(opt)
}
return String(opt)
}
// ==================== 导航方法 ====================
function findQuestionIndex(questionUid) {
return examStore.questions.findIndex(q => q.question_uid === questionUid)
}
function goToQuestion(index) {
if (index >= 0 && index < totalCount.value) {
currentQuestionIndex.value = index
}
}
function isAnswered(qId) {
return examStore.answers[qId] !== undefined && examStore.answers[qId] !== ''
}
// ==================== 答题方法 ====================
function onAnswerChange(val) {
const qId = currentQuestion.value.question_uid
examStore.submitAnswer(qId, val)
}
function prevQuestion() {
if (currentQuestionIndex.value > 0) {
currentQuestionIndex.value--
}
}
function nextQuestion() {
if (currentQuestionIndex.value < totalCount.value - 1) {
currentQuestionIndex.value++
}
}
// ==================== 综合大题(中职/高职) ====================
/** 取第 index 小问的当前答案(空值统一为 '',避免 el-input 出现 undefined 告警) */
function bigAnswerOf(index) {
const v = examStore.bigAnswers[`answer_${index}`]
return v == null ? '' : v
}
/** 输入即写本地 + 防抖自动保存(落库由 store 统一处理) */
function onBigAnswerInput(index, value) {
examStore.setBigAnswer(index, value)
}
/** 抽题/加载失败后的手动重试 */
async function retryBigQuestion() {
const ok = await examStore.loadBigQuestion(examStore.currentGroup)
if (ok) {
ElMessage.success('综合大题已加载')
} else {
ElMessage.error(examStore.bigError || '综合大题加载失败')
}
}
/**
* 交卷前确保综合大题答案已落库
* @param {{force?: boolean}} [opts]
* @returns {Promise<boolean>} 是否落库成功
*/
async function ensureBigAnswersSaved(opts = {}) {
if (!examStore.bigQuestionEnabled || !examStore.bigLoaded) return true
const r = await examStore.flushBigAnswers(opts)
if (!r.ok) {
ElMessage.error(`综合大题答案保存失败:${r.message || '请检查网络后重试'}`)
return false
}
return true
}
// ==================== 提交全部 ====================
async function handleSubmitAll() {
// 未答满时给出明确的「还差什么」,避免考生误交卷
let confirmText = '确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。'
if (examStore.bigQuestionEnabled && examStore.bigLoaded && !examStore.bigAllAnswered) {
const lack = examStore.bigSubCount - examStore.bigAnsweredCount
confirmText = `综合大题还有 ${lack} 个小问未作答,提交后将无法修改。确定要提交全部答案吗?`
}
try {
await ElMessageBox.confirm(
confirmText,
'确认提交',
{ confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
// 先把综合大题答案落库(submit-all 只处理客观题与提交时间),失败即中止交卷
const saved = await ensureBigAnswersSaved()
if (!saved) return
await examStore.submitAll({})
ElMessage.success('提交成功!')
} catch (err) {
if (err?.message) ElMessage.error(err.message)
return
} finally {
submitting.value = false
}
// 成功提示与返回(点击 × 关闭弹窗不视为失败)
try {
await ElMessageBox.alert('您的答案已成功提交,感谢您的参与!', '提交成功', {
confirmButtonText: '返回工作台',
type: 'success',
})
} catch (e) {
/* 用户关闭弹窗,忽略 */
}
router.push('/dashboard')
}
/** 自动提交(倒计时归零):考试组自动保存并交卷;提交组封锁上传并返回工作台 */
const autoSubmitted = ref(false)
async function autoSubmit() {
if (autoSubmitted.value) return
autoSubmitted.value = true
if (isUploadMode.value) {
// 提交组(本科/教师):作品已在每次上传时保存,时间到直接封锁并返回
ElMessage.warning('提交时间已到,系统将自动保存您的作品并返回')
examStore.stopExam()
router.push('/dashboard')
return
}
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案')
try {
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
// 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。
const saved = await ensureBigAnswersSaved({ force: true })
if (!saved) {
// 落库失败不退化为静默丢答案:提示考生手动重试
ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试')
autoSubmitted.value = false
return
}
// submitAll 内部会逐题保存客观题答案并写入提交时间(大题已在上面落库)
await examStore.submitAll({ forceSubmit: true })
ElMessage.success('已自动保存并提交')
router.push('/dashboard')
} catch (err) {
autoSubmitted.value = false
console.error('自动提交失败', err)
ElMessage.error('自动提交失败,请手动点击「提交全部」')
}
}
// ==================== 监听倒计时 ====================
watch(
() => examStore.isTimeUp,
(isTimeUp) => {
if (isTimeUp) {
autoSubmit()
}
}
)
// 倒计时临近结束提醒(仅提示一次,避免频繁弹窗打扰)
// 考试组与提交组均生效;新增 10 分钟档;文案按组别使用「考试 / 提交」
const warnedEarlyTime = ref(false)
const warnedLowTime = ref(false)
const warnedCriticalTime = ref(false)
watch(
() => remainingSeconds.value,
(sec) => {
if (!examStore.examStarted) return
const verb = isUploadMode.value ? '提交' : '考试'
if (!warnedEarlyTime.value && sec > 0 && sec <= 600) {
warnedEarlyTime.value = true
ElNotification({
title: `${verb}时间提醒`,
message: `据${verb}结束还有 10 分钟,请及时核对并保存好您的答案`,
type: 'info',
duration: 8000,
position: 'top-right',
})
}
if (!warnedLowTime.value && sec > 0 && sec <= 300) {
warnedLowTime.value = true
ElNotification({
title: `${verb}时间提醒`,
message: `据${verb}结束还有 5 分钟,请及时核对并保存好您的答案`,
type: 'warning',
duration: 8000,
position: 'top-right',
})
}
if (!warnedCriticalTime.value && sec > 0 && sec <= 60) {
warnedCriticalTime.value = true
ElNotification({
title: `${verb}时间提醒`,
message: `据${verb}结束仅剩 1 分钟,请及时核对并保存好您的答案`,
type: 'error',
duration: 8000,
position: 'top-right',
})
}
}
)
// ==================== 安全防护:禁止选中、复制、右键、键盘快捷键 ====================
// 浏览器刷新/关闭警告
function handleBeforeUnload(e) {
if (examStore.examStarted) {
e.preventDefault()
e.returnValue = ''
}
}
/**
* 事件目标是否为「可编辑元素」(综合大题的作答输入框)
*
* 考试页为保证题目不被外传,全局禁用了选中/复制/右键。但综合大题要求考生
* 在输入框内自行作答,若沿用同一套拦截会导致无法选中、修改、粘贴自己写的内容。
* 因此对可编辑元素做最小豁免:只放行「编辑自身答案」所需的操作,
* 题目区域(非可编辑)的防护行为完全不变。
*/
function isEditableTarget(e) {
const el = e && e.target
if (!el) return false
const tag = (el.tagName || '').toLowerCase()
return tag === 'textarea' || tag === 'input' || el.isContentEditable === true
}
// 禁止复制(可编辑元素内放行,否则考生无法复制/粘贴自己的作答)
function preventCopy(e) {
if (isEditableTarget(e)) return
e.preventDefault()
return false
}
// 禁止鼠标选中文字(可编辑元素内放行,否则输入框内无法选中文字)
function preventSelect(e) {
if (isEditableTarget(e)) return
e.preventDefault()
return false
}
// 禁止键盘快捷键:Ctrl+C、Ctrl+U、F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J
function preventKeyboard(e) {
// F12(开发者工具)—— 任何位置均拦截
if (e.key === 'F12' || e.keyCode === 123) {
e.preventDefault()
return false
}
// Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具)—— 任何位置均拦截
if (e.ctrlKey && e.shiftKey) {
if (e.key === 'I' || e.key === 'i' ||
e.key === 'C' || e.key === 'c' ||
e.key === 'J' || e.key === 'j') {
e.preventDefault()
return false
}
}
// 以下剪贴/保存/打印类快捷键:仅在可编辑元素内放行(考生编辑自身答案所需)
if (isEditableTarget(e)) return
// Ctrl+C(复制)
if (e.ctrlKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) {
e.preventDefault()
return false
}
// Ctrl+U(查看源代码)
if (e.ctrlKey && (e.key === 'U' || e.key === 'u' || e.keyCode === 85)) {
e.preventDefault()
return false
}
// Ctrl+S(保存页面)
if (e.ctrlKey && (e.key === 'S' || e.key === 's' || e.keyCode === 83)) {
e.preventDefault()
return false
}
// Ctrl+P(打印)
if (e.ctrlKey && (e.key === 'P' || e.key === 'p' || e.keyCode === 80)) {
e.preventDefault()
return false
}
}
// 路由离开警告
onBeforeRouteLeave((to, from, next) => {
if (examStore.examStarted && !examStore.isTimeUp) {
const verb = isUploadMode.value ? '提交' : '答题'
ElMessageBox.confirm(
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
'警告',
{ confirmButtonText: '确定离开', cancelButtonText: `继续${verb}`, type: 'warning' }
).then(() => {
examStore.resetExam()
next()
}).catch(() => {
next(false)
})
} else {
next()
}
})
// ==================== 生命周期 ====================
function attachSecurityListeners() {
document.addEventListener('copy', preventCopy)
document.addEventListener('contextmenu', preventCopy)
document.addEventListener('selectstart', preventSelect)
document.addEventListener('dragstart', preventCopy)
document.addEventListener('keydown', preventKeyboard)
}
function removeSecurityListeners() {
document.removeEventListener('copy', preventCopy)
document.removeEventListener('contextmenu', preventCopy)
document.removeEventListener('selectstart', preventSelect)
document.removeEventListener('dragstart', preventCopy)
document.removeEventListener('keydown', preventKeyboard)
}
onMounted(async () => {
// 附加安全防护
attachSecurityListeners()
const group = authStore.userGroup
const isExam = ['zhongzhi', 'gaozhi'].includes(group)
loading.value = true
try {
// 统一入口校验:考试组(中职/高职)与提交组(本科/教师)一致——
// 仅「截止后(status==='ended')」禁止进入;考试时间内(含已提交 / 意外退出)均可重新进入,
// 应对断网、断电、误操作导致的意外退出(重进后由下方逻辑恢复已答题目)。
const status = await examApi.getExamStatus(group)
if (status.status === 'ended') {
ElMessage.warning(isExam ? '考试已结束,无法再次进入' : '作品提交已截止,无法再次进入')
router.push('/dashboard')
return
}
if (isExam) {
// 截止前可随意重新进入(含已提交 / 意外退出后重进),与顶部注释一致;
// 仅「截止后(status==='ended')」禁止进入(见上方判断)。考试组仍受团队锁约束,
// 同一时刻仅一名成员在考,避免重复进入互相干扰。
// 考试组(中职/高职):检查团队锁与活跃状态后进入考试
if (status.teamLocked) {
ElMessage.warning(`您的小组已由 ${status.teamLockedBy || '队友'} 进入考试,不能重复进入`)
router.push('/dashboard')
return
}
if (!status.isActive) {
ElMessage.warning(status.message || '考试未在进行中,请等待考试开始')
router.push('/dashboard')
return
}
// 检查 sessionStorage 中是否有考试 session
const savedSession = sessionStorage.getItem('exam_session')
if (savedSession) {
try {
await ElMessageBox.confirm(
'检测到您已有正在进行的考试,是否继续之前的考试?(点击"取消"将返回首页)',
'考试提示',
{ confirmButtonText: '继续考试', cancelButtonText: '返回首页', type: 'warning' }
)
} catch {
sessionStorage.removeItem('exam_session')
router.push('/dashboard')
return
}
}
await examStore.startExam(group)
// 客观题与综合大题都为空才算加载失败;只抽到大题(客观题库为空)时仍可正常作答
if (examStore.questions.length === 0 && !examStore.bigLoaded) {
ElMessage.error(examStore.bigError || '题目加载失败,请刷新页面重试')
router.push('/dashboard')
return
}
// 恢复已有答案:考试组在「已提交 / 意外退出后重进」时始终尝试恢复,
// 避免断网、断电或误操作退出导致答题进度丢失(仅重载答案,不返回评分与对错)。
try {
const answersData = await examApi.getAnswers(group)
if (answersData && answersData.answers && answersData.answers.length > 0) {
const restored = {}
answersData.answers.forEach(a => {
if (a.answer_data) {
restored[a.question_uid] = a.answer_data
}
})
if (Object.keys(restored).length > 0) {
examStore.restoreAnswers(restored)
}
}
} catch (e) {
console.warn('恢复已有答案失败', e)
}
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
uploadPanelLoading.value = true
try {
await fetchUploadedWorks()
} finally {
uploadPanelLoading.value = false
}
examStore.startUploadCountdown(group)
window.addEventListener('beforeunload', handleBeforeUnload)
}
} catch (err) {
ElMessage.error('加载考试信息失败')
router.push('/dashboard')
} finally {
loading.value = false
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
removeSecurityListeners()
})
</script>
<style scoped>
.competition-page {
min-height: 100vh;
background: #f0f2f5;
display: flex;
flex-direction: column;
/* 考试页面禁止选中和复制文字 */
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* ==================== 顶部栏 ==================== */
.exam-top-bar {
background: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0.75rem 1.5rem;
position: sticky;
top: 0;
z-index: 100;
}
.exam-top-bar.time-critical {
background: #fef0f0;
border-bottom-color: #f56c6c;
}
.exam-top-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 2rem;
}
.exam-title {
font-size: 1.1rem;
font-weight: 600;
color: #303133;
}
.exam-timer {
display: flex;
align-items: center;
gap: 0.5rem;
color: #606266;
}
.timer-display {
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #303133;
}
.timer-danger {
color: #f56c6c;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.exam-progress {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.exam-progress span {
font-size: 0.9rem;
color: #909399;
white-space: nowrap;
}
/* ==================== 主布局 ==================== */
.exam-body {
flex: 1;
display: flex;
max-width: 1200px;
margin: 0 auto;
width: 100%;
padding: 1rem 1.5rem;
gap: 1.5rem;
}
/* ==================== 左侧导航 ==================== */
.exam-sidebar {
width: 200px;
flex-shrink: 0;
background: #fff;
border-radius: 8px;
padding: 1rem;
border: 1px solid #e4e7ed;
max-height: calc(100vh - 160px);
overflow-y: auto;
}
.sidebar-title {
font-size: 0.9rem;
font-weight: 600;
color: #303133;
margin: 0 0 0.75rem 0;
}
.nav-section {
margin-bottom: 1rem;
}
.nav-section-title {
font-size: 0.75rem;
color: #909399;
margin-bottom: 0.5rem;
padding-left: 2px;
}
.question-nav {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.nav-item {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
background: #f5f7fa;
color: #909399;
border: 1px solid #e4e7ed;
transition: all 0.2s;
user-select: none;
}
.nav-item:hover {
border-color: #409eff;
color: #409eff;
}
.nav-answered {
background: #ecf5ff;
color: #409eff;
border-color: #b3d8ff;
}
.nav-current {
background: #67c23a;
color: #fff;
border-color: #67c23a;
}
.sidebar-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #ebeef5;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
color: #909399;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot.answered { background: #409eff; }
.legend-dot.current { background: #67c23a; }
.legend-dot.unanswered { background: #e4e7ed; }
/* ==================== 右侧题目区 ==================== */
.exam-main {
flex: 1;
min-width: 0;
}
.question-card {
background: #fff;
border-radius: 8px;
padding: 1.5rem 2rem;
border: 1px solid #e4e7ed;
}
.question-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #ebeef5;
}
.question-number {
font-size: 1rem;
font-weight: 600;
color: #303133;
}
.question-score {
font-size: 0.85rem;
color: #909399;
}
.question-text {
font-size: 1rem;
line-height: 1.8;
color: #303133;
margin: 0 0 1.5rem 0;
}
.options-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.option-item {
padding: 0.6rem 1rem;
border: 1px solid #e4e7ed;
border-radius: 6px;
transition: border-color 0.2s;
}
.option-item:hover {
border-color: #409eff;
}
.option-label {
font-weight: 600;
margin-right: 0.25rem;
color: #409eff;
}
.question-footer {
display: flex;
justify-content: space-between;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #ebeef5;
}
/* ==================== 综合大题卡片 ==================== */
.big-card .big-title {
font-size: 1.05rem;
font-weight: 600;
color: #303133;
margin: 0 0 0.75rem 0;
line-height: 1.6;
}
/* 富文本题干:scoped CSS 不作用于 v-html 子节点,须用 :deep() 兜底可读性 */
.big-description {
font-size: 0.95rem;
line-height: 1.8;
color: #303133;
margin-bottom: 1rem;
word-break: break-word;
}
.big-description :deep(p) {
margin: 0.4rem 0;
}
.big-description :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.9rem;
}
.big-description :deep(th),
.big-description :deep(td) {
border: 1px solid #dcdfe6;
padding: 0.5rem 0.65rem;
text-align: left;
vertical-align: top;
}
.big-description :deep(th) {
background: #f5f7fa;
font-weight: 600;
}
.big-description :deep(img) {
max-width: 100%;
height: auto;
}
.big-hint {
margin-bottom: 1rem;
}
.big-sub-item {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px dashed #ebeef5;
}
.big-sub-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.big-sub-no {
font-weight: 600;
color: #303133;
}
.big-sub-score {
font-size: 0.85rem;
color: #909399;
}
.big-sub-prompt {
font-size: 0.9rem;
color: #606266;
line-height: 1.7;
margin-bottom: 0.5rem;
/* 小题提示为纯文本,保留换行与缩进 */
white-space: pre-wrap;
word-break: break-word;
}
/* 作答输入框:考试页全局禁用选中,这里必须恢复,否则考生无法编辑自己的答案 */
.big-answer-input :deep(.el-textarea__inner) {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
font-size: 0.95rem;
line-height: 1.7;
}
/* 字数统计(el-input show-word-limit 默认渲染在右下角) */
.big-answer-input :deep(.el-input__count) {
background: transparent;
color: #909399;
font-size: 0.8rem;
}
.big-save-status {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.big-save-note {
font-size: 0.85rem;
color: #909399;
}
.big-retry {
margin-top: 1rem;
}
/* 侧栏综合大题小问计数 */
.nav-subcount {
font-size: 0.8rem;
color: #909399;
font-weight: 400;
}
.uploaded-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: #f0f9eb;
border-radius: 6px;
font-size: 0.85rem;
}
.uploaded-name {
color: #303133;
font-weight: 500;
}
.uploaded-size {
color: #909399;
}
.pending-list {
margin-top: 1rem;
}
.pending-list-title {
font-size: 0.85rem;
color: #606266;
margin-bottom: 0.5rem;
}
.pending-item {
justify-content: space-between;
}
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
.file-over-limit {
background: #fef0f0;
border: 1px solid #f56c6c;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.file-over-limit .uploaded-name {
color: #f56c6c;
font-weight: 600;
}
.size-over {
color: #f56c6c !important;
font-weight: 600;
}
/* ==================== 底部提交栏 ==================== */
.exam-bottom-bar {
background: #fff;
border-top: 1px solid #e4e7ed;
padding: 0.75rem 1.5rem;
position: sticky;
bottom: 0;
z-index: 100;
}
.exam-bottom-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.submit-hint {
font-size: 0.85rem;
color: #909399;
}
/* ==================== 响应式 ==================== */
@media (max-width: 768px) {
.exam-body {
flex-direction: column;
padding: 0.75rem;
}
.exam-sidebar {
width: 100%;
max-height: none;
}
.question-card {
padding: 1rem;
}
.exam-top-inner {
flex-wrap: wrap;
gap: 0.5rem;
}
}
</style>