Files
competition-website/src/views/Competition.vue
qubiaobiao 1bfe007149 feat: 综合大题改造(替代 Word 上传)+ MockExam 按组分支
中职/高职组以「综合大题」替代原 Word 文件上传:管理员在后台录入题干
(wangEditor 富文本,含表格),系统按组随机抽题,考生在 3 个小问内作答,
每小题 10 分、大题合计 30 分。

- api/admin.js / api/exam.js / stores/exam.js:大题题库 CRUD、抽题、作答与评分链路
- Competition.vue:Word 上传区块替换为大题作答区(3 小问 + 字数上限 + 富文本题干渲染)
- Admin.vue:大题题库管理、考生作答查看与人工评分
- MockExam.vue:按组分支——中职/高职显示综合大题,其余组保持 Word 答题;侧栏与提交提示同步
- RichEditor.vue + utils/richtext.js:题干富文本渲染前做白名单消毒(零依赖,纵深防御)

说明:big_questions / big_question_answers 建表迁移 023/024 随后端仓库提交。
2026-09-21 11:17:59 +08:00

1480 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>