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

1458 lines
47 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.wordTemplate">
<div class="nav-section-title">Word 答题</div>
<div class="question-nav">
<div
class="nav-item"
:class="{
'nav-answered': wordUploaded || uploadedWordFile,
'nav-current': currentQuestionIndex === wordQuestionIndex,
}"
@click="currentQuestionIndex = wordQuestionIndex"
>
{{ wordQuestionIndex + 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="examStore.questions.length > 0">
<!-- 选择题 -->
<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>
<!-- Word 答题 -->
<div v-if="currentQuestionIndex === wordQuestionIndex" class="question-card word-card">
<div class="question-header">
<span class="question-number">第 {{ wordQuestionIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合题</el-tag>
</div>
<div class="question-body">
<p class="question-text">请下载 Word 答题模板,在 Word 中完成答题后上传。</p>
<div class="word-sections">
<!-- 下载区域 -->
<div class="word-section">
<h4>第 1 步:下载模板</h4>
<el-button
type="primary"
:icon="Download"
v-guard="() => downloadTemplate()"
:loading="wordDownloading"
>
下载 Word 答题模板
</el-button>
<div v-if="wordDownloading" class="word-progress">
<el-progress :percentage="wordDownloadPercent" />
</div>
</div>
<el-divider />
<!-- 上传区域 -->
<div class="word-section">
<h4>第 2 步:上传答卷</h4>
<el-upload
class="word-upload"
drag
:auto-upload="false"
:limit="1"
:on-change="onWordFileChange"
:file-list="wordFileList"
accept=".doc,.docx"
>
<el-icon class="el-icon--upload" :size="36"><UploadFilled /></el-icon>
<div class="el-upload__text">
拖拽或 <em>点击上传</em> Word 文件
</div>
<template #tip>
<div class="el-upload__tip">仅支持 .doc / .docx 文件</div>
</template>
</el-upload>
<div v-if="wordUploading" class="word-progress">
<el-progress :percentage="wordUploadPercent" />
</div>
<div v-if="uploadedWordFile" class="uploaded-info">
<el-tag type="success" size="small">已上传</el-tag>
<span class="uploaded-name">{{ uploadedWordFile.fileName }}</span>
<span class="uploaded-size">{{ uploadedWordFile.fileSize }}</span>
<el-button
size="small"
type="danger"
plain
:loading="wordDeleting"
v-guard="() => deleteWordFile()"
>删除并重新上传</el-button>
</div>
</div>
</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">
请确保已检查所有题目和 Word 文件后提交
</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, Download, 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 {
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
const WORD_MAX = 50 * 1024 * 1024 // 与后端 uploadWord 服务端 50MB 上限一致
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)
// Word 上传相关
const wordFileList = ref([])
const wordUploading = ref(false)
const wordUploadPercent = ref(0)
const wordDeleting = ref(false)
const uploadedWordFile = ref(null)
const wordDownloading = ref(false)
const wordDownloadPercent = ref(0)
const wordUploaded = ref(false)
// ==================== 计算属性 ====================
const totalCount = computed(() => {
const base = examStore.questions.length
return examStore.wordTemplate ? base + 1 : base
})
// Word 题目的虚拟索引(最后一个)
const wordQuestionIndex = computed(() => examStore.questions.length)
const answeredCount = computed(() => {
let count = Object.keys(examStore.answers).length
if (wordUploaded.value || uploadedWordFile.value) count++
return count
})
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++
}
}
// ==================== Word 相关 ====================
/** 下载 Word 模板 */
async function downloadTemplate() {
if (!examStore.wordTemplate?.group) return
const token = localStorage.getItem('token')
const url = `/api/exam/${examStore.wordTemplate.group}/word-template?token=${encodeURIComponent(token || '')}`
wordDownloading.value = true
wordDownloadPercent.value = 0
try {
// 同时带上 Authorization 请求头(与上传一致),确保 auth 校验通过
const headers = token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetch(url, headers ? { headers } : undefined)
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const data = await response.json()
if (data.code !== 0) {
ElMessage.warning(data.message || '模板下载失败')
return
}
}
if (!response.ok) {
ElMessage.error('模板下载失败')
return
}
const disposition = response.headers.get('content-disposition')
let filename = 'template.docx'
if (disposition) {
const match = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i)
if (match) {
filename = decodeURIComponent(match[1])
}
}
// 🔴 流式读取,实时更新下载进度(1M 带宽下大文件较慢,必须有反馈,
// 消除「点击后无反应」的错觉,与上传进度体验一致)。
const reader = response.body.getReader()
const contentLength = parseInt(response.headers.get('content-length') || '0', 10)
let received = 0
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) {
wordDownloadPercent.value = Math.min(99, Math.floor((received / contentLength) * 100))
} else {
// 无 content-length(被分块/压缩)时给一个随已下载量缓增的估算进度,封顶 95
wordDownloadPercent.value = Math.min(95, Math.floor((received / (5 * 1024 * 1024)) * 100))
}
}
const blob = new Blob(chunks)
// 🔴 同源守卫(2026-08-13 推进 #12 / 同源扩大化测试):本函数走裸 fetch,
// 不经过 safeDownload/fetchBlobGuarded 的 assertRealFile。若 nginx /internal-downloads/
// 内部 location 未生效而回退吐 index.html(text/html),原逻辑会把 HTML 当 .docx/.pptx
// 存盘 → 打开「已损坏」。这里补同样的 HTML 嗅探 + X-File-Size 大小比对,命中即拒存盘。
const head = await blob.slice(0, 256).text().catch(() => '');
if (/^\s*(<!doctype\s+html|<html[\s>])/i.test(head)) {
ElMessage.error('服务器返回的是网页而非模板文件,下载通道配置异常(请联系管理员检查 nginx 内部下载 location)');
return;
}
const declared = parseInt(response.headers.get('x-file-size') || response.headers.get('content-length') || '0', 10);
if (Number.isFinite(declared) && declared > 0 && received !== declared) {
ElMessage.error(`模板传输不完整:应为 ${declared} 字节,实际 ${received} 字节,请重试`);
return;
}
wordDownloadPercent.value = 100
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
} catch (err) {
ElMessage.error('模板下载失败')
} finally {
wordDownloading.value = false
}
}
/** Word 文件选择变化 */
function onWordFileChange(file) {
const raw = file.raw || file
// 客户端先行校验大小(与后端 uploadWord 50MB 上限一致),超限直接拦截并给出带文件大小的明确提示
if (raw.size > WORD_MAX) {
ElMessage.error(`Word 文件「${raw.name}」大小为 ${formatFileSize(raw.size)},超过 50MB 限制,无法上传`)
wordFileList.value = []
return
}
wordFileList.value = [file]
uploadWordFile()
}
/** 上传 Word 文件 */
async function uploadWordFile() {
if (wordFileList.value.length === 0) return null
const rawFile = wordFileList.value[0]?.raw || wordFileList.value[0]
if (!rawFile) return null
wordUploading.value = true
wordUploadPercent.value = 0
try {
const formData = new FormData()
formData.append('file', rawFile)
const result = await examApi.uploadWord(examStore.currentGroup, formData)
wordUploadPercent.value = 100
wordUploaded.value = true
uploadedWordFile.value = {
fileName: rawFile.name,
fileSize: formatFileSize(rawFile.size),
md5: result?.md5 || '',
}
return result
} catch (err) {
ElMessage.error('Word 文件上传失败')
return null
} finally {
wordUploading.value = false
}
}
/** 删除已上传的 Word 文件(支持重新上传替换) */
async function deleteWordFile() {
try {
await ElMessageBox.confirm(
'确定要删除已上传的 Word 文件吗?删除后可重新上传进行替换。',
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
wordDeleting.value = true
try {
await examApi.deleteWord(examStore.currentGroup)
uploadedWordFile.value = null
wordFileList.value = []
wordUploaded.value = false
ElMessage.success('Word 文件已删除,可重新上传')
} catch (err) {
ElMessage.error(err?.response?.data?.message || '删除失败,请重试')
} finally {
wordDeleting.value = false
}
}
// ==================== 提交全部 ====================
async function handleSubmitAll() {
try {
await ElMessageBox.confirm(
'确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。',
'确认提交',
{ confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
await uploadWordFile()
}
const additionalData = {}
if (uploadedWordFile.value) {
additionalData.wordFile = uploadedWordFile.value
}
await examStore.submitAll(additionalData)
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 {
// 若尚有未上传的 Word 答题文件,先上传,避免作品被遗漏
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
await uploadWordFile()
}
const additionalData = {}
if (uploadedWordFile.value) {
additionalData.wordFile = uploadedWordFile.value
}
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
// 确保 submit_time 落库(管理后台提交时间显示为真实交卷时间,而非进入考试时间)。
additionalData.forceSubmit = true
// submitAll 内部会逐题保存答案并写入提交时间,完成「自动保存 + 自动提交」
await examStore.submitAll(additionalData)
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 preventCopy(e) {
e.preventDefault()
return false
}
// 禁止鼠标选中文字
function preventSelect(e) {
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
}
}
// 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) {
ElMessage.error('题目加载失败,请刷新页面重试')
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;
}
/* ==================== Word 答题卡片 ==================== */
.word-card .word-sections {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.word-section {
padding: 0.5rem 0;
}
.word-section h4 {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: #303133;
}
.word-upload {
width: 100%;
}
.word-progress {
margin-top: 0.75rem;
}
.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>