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 随后端仓库提交。
This commit is contained in:
2026-09-21 11:17:59 +08:00
parent e8f0195b61
commit 1bfe007149
8 changed files with 1729 additions and 470 deletions

View File

@@ -347,4 +347,92 @@ export function resetTestData() {
return request.post('/admin/reset-test-data') return request.post('/admin/reset-test-data')
} }
// ==================== 综合大题管理(中职/高职) ====================
/**
* 获取综合大题题库(按组别)
*
* 仅中职/高职启用;本科/教师组调用返回 400(未启用综合大题)。
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @returns {Promise} {
* list: [{ id, questionUid, group, title, description,
* subPrompt1, subPrompt2, subPrompt3,
* scorePerSub, totalScore, sortOrder, status, createdAt, updatedAt }],
* total, scorePerSub, subCount, maxChars
* }
*/
export function getBigQuestions(group) {
return request.get(`/admin/big-questions/${group}`)
}
/**
* 新增大题
*
* 题干 description 为富文本 HTML(支持表格);三小问提示 subPrompt1~3 与题干同存一行。
* @param {string} group - 组别标识
* @param {object} data - { title, description, subPrompt1, subPrompt2, subPrompt3, scorePerSub, sortOrder, status }
* @returns {Promise} { id, questionUid }
*/
export function createBigQuestion(group, data) {
return request.post(`/admin/big-questions/${group}`, data)
}
/**
* 更新大题
*
* 注意:已被考生抽取的题目仍可修改,但考生端展示的是抽题时刻的题干快照,
* 不会受此修改影响(防纠纷设计)。
* @param {string} group - 组别标识
* @param {number|string} id - 大题 ID
* @param {object} data - 同 createBigQuestion
* @returns {Promise} { id }
*/
export function updateBigQuestion(group, id, data) {
return request.put(`/admin/big-questions/${group}/${id}`, data)
}
/**
* 删除大题
*
* 已被考生抽取的题目服务端拒绝删除(返回 409),避免作答记录悬空。
* @param {string} group - 组别标识
* @param {number|string} id - 大题 ID
* @returns {Promise} { deleted }
*/
export function deleteBigQuestion(group, id) {
return request.delete(`/admin/big-questions/${group}/${id}`)
}
/**
* 获取大题作答与查证列表(覆盖该组全部考生,含未抽题者)
*
* 每行含考生信息、抽题快照、三小问答案、已答数量、三小问得分、大题总分与提交时间,
* 供管理员核对考生实际抽到的题目与作答内容,避免考生纠纷。
* @param {string} group - 组别标识
* @returns {Promise} {
* list: [{ contestantId, aggregationUid, name, idNumber, phone, teamId,
* drawn, bigQuestionUid, questionSnapshot,
* answer1, answer2, answer3, answeredCount,
* score1, score2, score3, bigScore, wordScore, submitTime }],
* total, drawnCount
* }
*/
export function getBigAnswers(group) {
return request.get(`/admin/big-answers/${group}`)
}
/**
* 录入三小问得分(小题分)
*
* 服务端逐问校验 0 ~ scorePerSub 区间,累加后经唯一评分写路径
* service.admin.updateScores 落 contestants.word_score(中职/高职总分 = exam_score + word_score)。
* @param {string} group - 组别标识
* @param {number|string} uid - 考生聚合 ID(aggregation_uid)
* @param {{score_1: number, score_2: number, score_3: number}} scores
* @returns {Promise} { total, perSubScore }
*/
export function setBigAnswerScores(group, uid, scores) {
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
}

View File

@@ -22,15 +22,6 @@ export function getQuestions(group) {
return request.get(`/exam/${group}/questions`) return request.get(`/exam/${group}/questions`)
} }
/**
* 获取 Word 答题模板(返回文件流,前端通过 window.open 直接下载)
* @param {string} group - 组别标识
* @returns {Promise} 文件流
*/
export function getWordTemplate(group) {
return request.get(`/exam/${group}/word-template`)
}
/** /**
* 提交单个答案 * 提交单个答案
* @param {string} group - 组别标识 * @param {string} group - 组别标识
@@ -41,25 +32,41 @@ export function submitAnswers(group, answers) {
return request.post(`/exam/${group}/submit-answers`, { answers }, { silent: true }) return request.post(`/exam/${group}/submit-answers`, { answers }, { silent: true })
} }
// 注:原 getWordTemplate / uploadWord / deleteWord 三个封装已随「中职/高职取消 Word
// 上传题」一并移除(2026-09-21)。后端对应路由亦已对中职/高职封堵;本科/教师组的
// 文档上传走分片通道 @/api/upload,不经此处。
// ==================== 综合大题(中职/高职) ====================
/** /**
* 上传 Word 答题文件 * 获取综合大题(首次调用即抽题并落库快照,后续调用复用同一题)
* @param {string} group - 组别标识 *
* @param {FormData} formData - 包含 Word 文件的 FormData * 服务端保证「一考生一行、一题一快照」:抽题幂等(reused=true 表示复用),
* @returns {Promise} * 题干以抽题时刻的快照为准(管理员后续改题干不影响已抽考生,防纠纷)。
* 响应体不含任何分数字段(成绩发布门控)。
* @param {string} group - 组别标识(仅 zhongzhi / gaozhi)
* @returns {Promise} {
* question: { big_question_uid, title, description, sub_prompts, score_per_sub, total_score, snapshot_at },
* answers: { answer_1, answer_2, answer_3 },
* submitTime, maxChars, subCount, reused
* }
*/ */
export function uploadWord(group, formData) { export function getBigQuestion(group) {
return request.post(`/exam/${group}/upload-word`, formData, { // silent:本接口的失败(如 4004 该组暂无大题)由答题区自行展示 + 提供重试,
timeout: 60000, // 上传超时 60 秒 // 避免与页面其他提示重复弹窗。
}) return request.get(`/exam/${group}/big-question`, { silent: true })
} }
/** /**
* 删除已上传的 Word 答题文件(中职/高职组) * 保存综合大题三小问答案(整卷覆盖保存)
*
* 超长由前端 maxlength 与服务端双重拦截;服务端超限返回 400 且整批拒绝(不静默截断)。
* @param {string} group - 组别标识 * @param {string} group - 组别标识
* @returns {Promise} * @param {{answer_1: string, answer_2: string, answer_3: string, forceSubmit?: boolean}} data
* @returns {Promise} { filled, subCount, maxChars, savedAt }
*/ */
export function deleteWord(group) { export function saveBigAnswer(group, data) {
return request.delete(`/exam/${group}/word`, { timeout: 60000 }) return request.post(`/exam/${group}/big-answer`, data, { silent: true })
} }
/** /**

View File

@@ -20,7 +20,7 @@
<script setup> <script setup>
import '@wangeditor/editor/dist/css/style.css' import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue' import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -29,6 +29,9 @@ const props = defineProps({
height: { type: String, default: '320px' }, height: { type: String, default: '320px' },
placeholder: { type: String, default: '请输入内容…' }, placeholder: { type: String, default: '请输入内容…' },
mode: { type: String, default: 'default' }, // 'default' | 'simple' mode: { type: String, default: 'default' }, // 'default' | 'simple'
// 是否开放表格菜单。综合大题题干需要富文本表格,故显式开启;
// 默认 false 保持资讯正文等既有调用方的工具栏不变(行为兼容)。
enableTable: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -49,14 +52,15 @@ watch(
} }
) )
const toolbarConfig = { const toolbarConfig = computed(() => ({
excludeKeys: [ excludeKeys: [
'group-video', // 轮播正文不需要视频 'group-video', // 题干/正文不需要视频
'insertTable', // 未开启表格时隐藏插入表格入口;开启后除去该项,表格相关菜单恢复默认可见
...(props.enableTable ? [] : ['insertTable']),
'codeBlock', 'codeBlock',
'fullScreen', 'fullScreen',
], ],
} }))
const editorConfig = { const editorConfig = {
placeholder: props.placeholder, placeholder: props.placeholder,

View File

@@ -9,8 +9,6 @@ const questions = ref([])
const answers = ref({}) const answers = ref({})
const countdown = ref(0) const countdown = ref(0)
const examStarted = ref(false) const examStarted = ref(false)
const wordTemplate = ref(null)
const wordUploaded = ref(false)
const totalQuestions = ref(0) const totalQuestions = ref(0)
// 考试 session 标识(防止返回重新进入) // 考试 session 标识(防止返回重新进入)
@@ -19,6 +17,27 @@ let timerInterval = null
let resyncInterval = null let resyncInterval = null
let finalCheckInFlight = false let finalCheckInFlight = false
// ==================== 综合大题(中职/高职,替代原 Word 上传题) ====================
/** 启用综合大题的组别(与后端 service.bigQuestion.BIG_QUESTION_GROUPS 保持一致) */
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
/** 答题字数上限默认值(后端返回 maxChars 后以其为准) */
const DEFAULT_MAX_ANSWER_CHARS = 2000
/** 抽题快照(题干以抽题时刻为准,管理员后续改题干不影响已抽考生) */
const bigQuestion = ref(null)
/** 三小问答案 { answer_1, answer_2, answer_3 } */
const bigAnswers = ref({ answer_1: '', answer_2: '', answer_3: '' })
const bigSubmitTime = ref(null)
const bigMaxChars = ref(DEFAULT_MAX_ANSWER_CHARS)
const bigSubCount = ref(3)
const bigReused = ref(false)
const bigLoading = ref(false)
const bigLoaded = ref(false)
const bigError = ref('')
const bigSaving = ref(false)
let bigSaveTimer = null
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
const countdownDisplay = computed(() => { const countdownDisplay = computed(() => {
@@ -41,6 +60,58 @@ const progress = computed(() => {
const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice')) const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice'))
const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge')) const judgeQuestions = computed(() => questions.value.filter(q => q.question_type === 'judge'))
/** 当前组别是否启用综合大题(中职/高职启用,本科/教师不启用) */
const bigQuestionEnabled = computed(() => BIG_QUESTION_GROUPS.includes(currentGroup.value))
/** 三小问提示文案数组(从快照取,长度与 subCount 对齐) */
const bigSubPrompts = computed(() => {
const prompts = (bigQuestion.value && bigQuestion.value.sub_prompts) || []
const out = []
for (let i = 0; i < bigSubCount.value; i++) {
out.push(prompts[i] || '')
}
return out
})
/** 每小题分值(快照优先,回退 10 分) */
const bigScorePerSub = computed(() => {
const v = bigQuestion.value && bigQuestion.value.score_per_sub
return Number(v) > 0 ? Number(v) : 10
})
/** 大题总分(快照优先,回退「每小题分值 × 小问数」) */
const bigTotalScore = computed(() => {
const v = bigQuestion.value && bigQuestion.value.total_score
if (Number(v) > 0) return Number(v)
return bigScorePerSub.value * bigSubCount.value
})
/** 已作答(去空白后非空)的小问数量 */
const bigAnsweredCount = computed(() => {
let n = 0
for (let i = 1; i <= bigSubCount.value; i++) {
const v = bigAnswers.value[`answer_${i}`]
if (v != null && String(v).trim().length > 0) n++
}
return n
})
/** 三小问是否全部作答 */
const bigAllAnswered = computed(() => bigAnsweredCount.value >= bigSubCount.value)
/** 每个小问的字数统计 { answer_1: 12, ... }(用于右下角实时字数) */
const bigCharCounts = computed(() => {
const out = {}
for (let i = 1; i <= bigSubCount.value; i++) {
const v = bigAnswers.value[`answer_${i}`]
out[`answer_${i}`] = v == null ? 0 : String(v).length
}
return out
})
/** 综合大题完成状态(供进度/答题卡复用) */
const bigCompleted = computed(() => bigAllAnswered.value)
// ==================== 内部函数 ==================== // ==================== 内部函数 ====================
function parseOptions(q) { function parseOptions(q) {
@@ -158,7 +229,10 @@ async function startExam(group) {
totalQuestions.value = responseData.count || rawQuestions.length totalQuestions.value = responseData.count || rawQuestions.length
} }
wordTemplate.value = { group } // 中职/高职:进入考试即抽取综合大题(后端幂等,重进复用同一题与快照)
if (BIG_QUESTION_GROUPS.includes(group)) {
await loadBigQuestion(group)
}
examStarted.value = true examStarted.value = true
startTimer() startTimer()
@@ -224,6 +298,12 @@ async function submitAnswer(questionId, answer) {
} }
async function submitAll(additionalData = {}) { async function submitAll(additionalData = {}) {
// 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间,
// 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。
if (bigQuestionEnabled.value) {
await flushBigAnswers({ force: additionalData.forceSubmit === true, silent: true })
}
// 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空 // 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空
// 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。 // 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。
const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({ const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({
@@ -241,14 +321,131 @@ async function submitAll(additionalData = {}) {
return result return result
} }
// ==================== 综合大题方法 ====================
/**
* 拉取(首次即抽取并落库快照)当前考生的综合大题与已存答案
* @param {string} [group] - 组别,缺省用 currentGroup
* @returns {Promise<boolean>} 是否成功装载(失败时错误信息在 bigError)
*/
async function loadBigQuestion(group) {
const g = group || currentGroup.value
if (!g || !BIG_QUESTION_GROUPS.includes(g)) return false
bigLoading.value = true
bigError.value = ''
try {
const data = await examApi.getBigQuestion(g)
bigQuestion.value = data.question || null
bigAnswers.value = {
answer_1: (data.answers && data.answers.answer_1) || '',
answer_2: (data.answers && data.answers.answer_2) || '',
answer_3: (data.answers && data.answers.answer_3) || '',
}
bigSubmitTime.value = data.submitTime || null
if (Number(data.maxChars) > 0) bigMaxChars.value = Number(data.maxChars)
if (Number(data.subCount) > 0) bigSubCount.value = Number(data.subCount)
bigReused.value = data.reused === true
bigLoaded.value = !!bigQuestion.value
return bigLoaded.value
} catch (e) {
// 典型失败:4004 该组暂无大题 / 400 未启用 / 403 非本组考生
bigError.value = (e && e.message) || '综合大题加载失败'
bigQuestion.value = null
bigLoaded.value = false
return false
} finally {
bigLoading.value = false
}
}
/**
* 更新某小问答案(本地即时生效 + 防抖自动保存)
* @param {number} index - 小问序号 1~3
* @param {string} value - 文本
*/
function setBigAnswer(index, value) {
const key = `answer_${index}`
bigAnswers.value = { ...bigAnswers.value, [key]: value == null ? '' : String(value) }
scheduleBigSave()
}
/** 防抖自动保存(800ms 静默合并,避免逐字符打接口) */
function scheduleBigSave() {
if (bigSaveTimer) clearTimeout(bigSaveTimer)
bigSaveTimer = setTimeout(() => {
bigSaveTimer = null
flushBigAnswers({ silent: true })
}, 800)
}
/**
* 立即把三小问答案落库
* @param {{force?: boolean, silent?: boolean}} [opts]
* force - 时间窗口已关闭时仍允许落库(仅自动交卷通道使用)
* silent - 失败不弹窗(由调用方决定提示方式)
* @returns {Promise<{ok: boolean, message?: string}>}
*/
async function flushBigAnswers(opts = {}) {
if (!bigQuestionEnabled.value) return { ok: true }
if (!bigLoaded.value) return { ok: true } // 未抽到题,无需保存
if (bigSaveTimer) {
clearTimeout(bigSaveTimer)
bigSaveTimer = null
}
const payload = {
answer_1: bigAnswers.value.answer_1 || '',
answer_2: bigAnswers.value.answer_2 || '',
answer_3: bigAnswers.value.answer_3 || '',
}
if (opts.force === true) payload.forceSubmit = true
bigSaving.value = true
try {
await examApi.saveBigAnswer(currentGroup.value, payload)
bigError.value = ''
return { ok: true }
} catch (e) {
// 超长(服务端 400)等失败:保留本地内容并回传错误,由页面提示,避免静默丢答案
const message = (e && e.message) || '综合大题答案保存失败'
bigError.value = message
if (!opts.silent) {
console.warn('综合大题答案保存失败:', message)
}
return { ok: false, message }
} finally {
bigSaving.value = false
}
}
/** 清空综合大题本地状态(含未落库的防抖定时器) */
function resetBigQuestion() {
if (bigSaveTimer) {
clearTimeout(bigSaveTimer)
bigSaveTimer = null
}
bigQuestion.value = null
bigAnswers.value = { answer_1: '', answer_2: '', answer_3: '' }
bigSubmitTime.value = null
bigMaxChars.value = DEFAULT_MAX_ANSWER_CHARS
bigSubCount.value = 3
bigReused.value = false
bigLoading.value = false
bigLoaded.value = false
bigError.value = ''
bigSaving.value = false
}
function resetExam() { function resetExam() {
stopTimer() stopTimer()
resetBigQuestion()
questions.value = [] questions.value = []
answers.value = {} answers.value = {}
countdown.value = 0 countdown.value = 0
examStarted.value = false examStarted.value = false
wordTemplate.value = null totalQuestions.value = 0
wordUploaded.value = false
currentGroup.value = '' currentGroup.value = ''
examSessionId = '' examSessionId = ''
sessionStorage.removeItem('exam_session') sessionStorage.removeItem('exam_session')
@@ -273,15 +470,33 @@ export function useExamStore() {
answers, answers,
countdown, countdown,
examStarted, examStarted,
wordTemplate,
wordUploaded,
totalQuestions, totalQuestions,
// 综合大题状态
bigQuestion,
bigAnswers,
bigSubmitTime,
bigMaxChars,
bigSubCount,
bigReused,
bigLoading,
bigLoaded,
bigError,
bigSaving,
// 计算属性 // 计算属性
countdownDisplay, countdownDisplay,
isTimeUp, isTimeUp,
progress, progress,
choiceQuestions, choiceQuestions,
judgeQuestions, judgeQuestions,
// 综合大题计算属性
bigQuestionEnabled,
bigSubPrompts,
bigScorePerSub,
bigTotalScore,
bigAnsweredCount,
bigAllAnswered,
bigCharCounts,
bigCompleted,
// 方法 // 方法
startExam, startExam,
startUploadCountdown, startUploadCountdown,
@@ -291,5 +506,10 @@ export function useExamStore() {
resetExam, resetExam,
isSessionValid, isSessionValid,
restoreAnswers, restoreAnswers,
// 综合大题方法
loadBigQuestion,
setBigAnswer,
flushBigAnswers,
resetBigQuestion,
}) })
} }

228
src/utils/richtext.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* 富文本渲染安全工具(零依赖白名单消毒)
*
* 背景:综合大题题干由管理员通过 wangEditor 录入富文本(含表格),考生端以 v-html 渲染。
* v-html 直接插入未经处理的 HTML 等价于把渲染权交给内容作者,因此必须做纵深防御:
* 服务端已做基础危险片段拦截,前端在渲染前再做一次白名单收敛,保证即使内容被
* 绕过入口写入(历史数据 / 直接改库 / 其他写入通道),渲染侧依然无法执行脚本。
*
* 白名单策略(默认拒绝):
* - 标签:仅保留排版类标签(段落 / 标题 / 列表 / 表格 / 图片 / 链接 / 行内强调)
* - 属性:仅保留各标签的排版必需属性 + colspan/rowspan 等表格结构属性
* - 协议:href/src 仅允许 http / https / mailto / 站内相对路径 / data:image
* - style:保留(wangEditor 靠它输出对齐与颜色),但剔除 url()/expression() 等高危片段
*
* 未知标签走「解包」而非「整体删除」,避免把内容一起吃掉;危险标签整体丢弃并连同子树。
*/
// 整体丢弃(连同子树)——脚本/样式/嵌入对象/表单交互类
const DROP_TAGS = new Set([
'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
'link', 'meta', 'base', 'form', 'input', 'button', 'textarea', 'select', 'option',
'svg', 'math', 'template', 'noscript', 'audio', 'video', 'source', 'track',
'canvas', 'portal', 'dialog', 'marquee',
])
// 允许保留的标签(其余走解包)
const ALLOW_TAGS = new Set([
'p', 'br', 'hr', 'div', 'span', 'section', 'article',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col', 'tr', 'th', 'td',
'a', 'img', 'font',
])
// 允许保留的属性(全局)
const ALLOW_ATTRS_GLOBAL = new Set(['style', 'class', 'title', 'dir', 'lang'])
// 标签私有属性白名单
const ALLOW_ATTRS_BY_TAG = {
a: new Set(['href', 'target', 'rel']),
img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height']),
th: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'scope']),
col: new Set(['span', 'width']),
colgroup: new Set(['span', 'width']),
table: new Set(['border', 'cellpadding', 'cellspacing', 'width', 'align']),
ol: new Set(['start', 'type']),
font: new Set(['color', 'size', 'face']),
}
// style 属性内的危险片段(命中即整条 style 丢弃,宁可丢排版不丢安全)
const DANGEROUS_STYLE = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|\/\*/i
// 危险协议(含被空白/控制字符混淆的变体,如 "java\tscript:")
const DANGEROUS_PROTOCOL = /^(?:javascript|vbscript|file|blob)\s*:/i
/**
* 归一化协议文本:去掉 ASCII 控制字符与零宽字符后判协议
* (防止 "java&#9;script:" 之类的混淆绕过)
* @param {string} raw
* @returns {string}
*/
function normalizeUrl(raw) {
return String(raw || '')
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0020\u007f\u200b-\u200f\ufeff]/g, '')
.toLowerCase()
}
/**
* 判断 URL 属性值是否安全
* @param {string} raw - 原始属性值
* @returns {boolean}
*/
function isSafeUrl(raw) {
const value = String(raw || '').trim()
if (!value) return false
const normalized = normalizeUrl(value)
if (DANGEROUS_PROTOCOL.test(normalized)) return false
if (normalized.startsWith('data:')) {
// 仅允许内联图片,杜绝 data:text/html(可执行脚本)
return /^data:image\/(png|jpe?g|gif|webp|bmp|svg\+xml);/i.test(normalized)
}
// 站内相对路径、锚点、以及 http/https/mailto/tel 放行
return /^(https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i.test(normalized)
}
/**
* 递归消毒 DOM 子树
* @param {Node} node
*/
function sanitizeNode(node) {
// 从后往前遍历,删除时不影响未处理节点的索引
const children = Array.from(node.childNodes)
for (const child of children) {
// 文本节点:保留
if (child.nodeType === Node.TEXT_NODE) continue
// 注释 / CDATA / 处理指令:整体丢弃
if (child.nodeType !== Node.ELEMENT_NODE) {
child.remove()
continue
}
const tag = child.tagName.toLowerCase()
if (DROP_TAGS.has(tag)) {
child.remove()
continue
}
if (!ALLOW_TAGS.has(tag)) {
// 未知标签:解包(把子节点提到父级),保留其内容但去掉标签语义
sanitizeNode(child)
while (child.firstChild) {
node.insertBefore(child.firstChild, child)
}
child.remove()
continue
}
sanitizeElementAttrs(child, tag)
sanitizeNode(child)
}
}
/**
* 收敛单个元素的属性
* @param {Element} el
* @param {string} tag - 小写标签名
*/
function sanitizeElementAttrs(el, tag) {
const tagAttrs = ALLOW_ATTRS_BY_TAG[tag] || new Set()
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase()
const value = attr.value
// 所有 on* 事件属性一律移除;data-* 也一律移除(无渲染需要)
if (name.startsWith('on') || name.startsWith('data-')) {
el.removeAttribute(attr.name)
continue
}
if (!ALLOW_ATTRS_GLOBAL.has(name) && !tagAttrs.has(name)) {
el.removeAttribute(attr.name)
continue
}
if (name === 'style') {
if (DANGEROUS_STYLE.test(value)) {
el.removeAttribute(attr.name)
}
continue
}
if (name === 'href' || name === 'src') {
if (!isSafeUrl(value)) {
el.removeAttribute(attr.name)
}
continue
}
if (name === 'target') {
// 只允许新窗口,并强制补 rel 防 window.opener 劫持
if (value !== '_blank') el.removeAttribute(attr.name)
continue
}
if (name === 'rel') {
continue
}
// 数值型结构属性:仅允许纯数字
if (['colspan', 'rowspan', 'span', 'start', 'width', 'height', 'border',
'cellpadding', 'cellspacing'].includes(name)) {
if (!/^\d+$/.test(String(value).trim())) {
el.removeAttribute(attr.name)
}
}
}
if (tag === 'a' && el.getAttribute('target') === '_blank') {
el.setAttribute('rel', 'noopener noreferrer nofollow')
}
}
/**
* 消毒富文本 HTML,返回可直接用于 v-html 的安全字符串
*
* @param {string} html - 原始富文本 HTML
* @returns {string} 消毒后的 HTML(非法输入返回空串)
*/
export function sanitizeHtml(html) {
const raw = String(html == null ? '' : html)
if (!raw.trim()) return ''
// 无 DOM 环境(如 SSR/单测)时退化为「剥标签」而不是原样返回,避免降级后反而放开注入面
if (typeof window === 'undefined' || typeof window.DOMParser === 'undefined') {
return raw.replace(/<[^>]*>/g, '')
}
try {
// 用 <template> 承载:内容落在 inert 的 DocumentFragment 中,不会执行脚本,
// 也不存在「被内容里的 </div> 提前闭合包装层导致越界/丢内容」的问题。
const tpl = document.createElement('template')
tpl.innerHTML = raw
sanitizeNode(tpl.content)
return tpl.innerHTML
} catch (e) {
// 解析失败一律降级为纯文本,绝不回退到原始 HTML
return raw.replace(/<[^>]*>/g, '')
}
}
/**
* 判断富文本内容是否为空(含只有空白/空标签/&nbsp; 的情况)
* @param {string} html
* @returns {boolean}
*/
export function isRichTextEmpty(html) {
const text = String(html == null ? '' : html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/gi, '')
.replace(/[\s\u3000]/g, '')
return text.length === 0
}

File diff suppressed because it is too large Load Diff

View File

@@ -128,19 +128,24 @@
</div> </div>
</div> </div>
<!-- Word 答题 --> <!-- 综合大题(中职/高职,替代原 Word 上传题) -->
<div class="nav-section" v-if="examStore.wordTemplate"> <div class="nav-section" v-if="examStore.bigQuestionEnabled">
<div class="nav-section-title">Word 答题</div> <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="question-nav">
<div <div
class="nav-item" class="nav-item"
:class="{ :class="{
'nav-answered': wordUploaded || uploadedWordFile, 'nav-answered': examStore.bigAllAnswered,
'nav-current': currentQuestionIndex === wordQuestionIndex, 'nav-current': currentQuestionIndex === bigQuestionIndex,
}" }"
@click="currentQuestionIndex = wordQuestionIndex" @click="currentQuestionIndex = bigQuestionIndex"
> >
{{ wordQuestionIndex + 1 }} {{ bigQuestionIndex + 1 }}
</div> </div>
</div> </div>
</div> </div>
@@ -159,7 +164,7 @@
</div> </div>
<!-- 右侧题目区域 --> <!-- 右侧题目区域 -->
<div class="exam-main" v-if="examStore.questions.length > 0"> <div class="exam-main" v-if="hasExamContent">
<!-- 选择题 --> <!-- 选择题 -->
<div v-if="isCurrentChoice && currentQuestion.question_type === 'choice'" class="question-card"> <div v-if="isCurrentChoice && currentQuestion.question_type === 'choice'" class="question-card">
<div class="question-header"> <div class="question-header">
@@ -224,75 +229,87 @@
</div> </div>
</div> </div>
<!-- Word 答题 --> <!-- 综合大题(中职/高职):题干富文本 + 三小问作答 -->
<div v-if="currentQuestionIndex === wordQuestionIndex" class="question-card word-card"> <div v-if="isCurrentBig" class="question-card big-card">
<div class="question-header"> <div class="question-header">
<span class="question-number">第 {{ wordQuestionIndex + 1 }} 题</span> <span class="question-number">第 {{ bigQuestionIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合题</el-tag> <el-tag type="danger" size="small">综合大题</el-tag>
<span class="question-score" v-if="examStore.bigLoaded">
(每小题 {{ examStore.bigScorePerSub }} 分,共 {{ examStore.bigTotalScore }} 分)
</span>
</div> </div>
<div class="question-body">
<p class="question-text">请下载 Word 答题模板,在 Word 中完成答题后上传。</p>
<div class="word-sections"> <!-- 加载中 -->
<!-- 下载区域 --> <div class="question-body" v-if="examStore.bigLoading">
<div class="word-section"> <el-skeleton :rows="5" animated />
<h4>第 1 步:下载模板</h4> </div>
<el-button
type="primary" <!-- 抽题失败:给出明确原因与重试入口(不静默留白) -->
:icon="Download" <div class="question-body" v-else-if="!examStore.bigLoaded">
v-guard="() => downloadTemplate()" <el-alert
:loading="wordDownloading" type="error"
> :closable="false"
下载 Word 答题模板 show-icon
:title="examStore.bigError || '综合大题加载失败'"
description="若提示「该组别暂无大题」,请联系监考老师;若为网络问题,可点击下方按钮重试。"
/>
<div class="big-retry">
<el-button type="primary" :loading="examStore.bigLoading" v-guard="() => retryBigQuestion()">
重新加载题目
</el-button> </el-button>
<div v-if="wordDownloading" class="word-progress">
<el-progress :percentage="wordDownloadPercent" />
</div> </div>
</div> </div>
<el-divider /> <!-- 题干 + 三小问作答 -->
<div class="question-body" v-else>
<h3 class="big-title">{{ examStore.bigQuestion.title }}</h3>
<!-- 上传区域 --> <!-- 富文本题干(含表格):经白名单消毒后渲染 -->
<div class="word-section"> <div class="big-description" v-html="bigDescriptionHtml"></div>
<h4>第 2 步:上传答卷</h4>
<el-upload <el-alert
class="word-upload" v-if="!examStore.bigAllAnswered"
drag class="big-hint"
:auto-upload="false" type="info"
:limit="1" :closable="false"
:on-change="onWordFileChange" show-icon
:file-list="wordFileList" title="请在下方三个输入框中分别作答,每个输入框最多 2000 字,作答内容会实时自动保存。"
accept=".doc,.docx" />
<div
v-for="i in examStore.bigSubCount"
:key="i"
class="big-sub-item"
> >
<el-icon class="el-icon--upload" :size="36"><UploadFilled /></el-icon> <div class="big-sub-header">
<div class="el-upload__text"> <span class="big-sub-no">第 {{ i }} 小问</span>
拖拽或 <em>点击上传</em> Word 文件 <span class="big-sub-score">({{ examStore.bigScorePerSub }} 分)</span>
</div> </div>
<template #tip> <div v-if="examStore.bigSubPrompts[i - 1]" class="big-sub-prompt">{{ examStore.bigSubPrompts[i - 1] }}</div>
<div class="el-upload__tip">仅支持 .doc / .docx 文件</div> <el-input
</template> class="big-answer-input"
</el-upload> type="textarea"
:rows="6"
<div v-if="wordUploading" class="word-progress"> resize="vertical"
<el-progress :percentage="wordUploadPercent" /> :maxlength="examStore.bigMaxChars"
show-word-limit
:placeholder="`请输入第 ${i} 小问的答案(最多 ${examStore.bigMaxChars} 字)`"
:model-value="bigAnswerOf(i)"
@update:model-value="val => onBigAnswerInput(i, val)"
/>
</div> </div>
<div v-if="uploadedWordFile" class="uploaded-info"> <!-- 保存状态:让考生对「是否已存上」有明确反馈 -->
<el-tag type="success" size="small">已上传</el-tag> <div class="big-save-status">
<span class="uploaded-name">{{ uploadedWordFile.fileName }}</span> <el-tag v-if="examStore.bigSaving" size="small" type="warning" effect="plain">正在保存…</el-tag>
<span class="uploaded-size">{{ uploadedWordFile.fileSize }}</span> <el-tag v-else-if="bigSaveErrorShown" size="small" type="danger" effect="plain">
<el-button 保存失败:{{ examStore.bigError }}
size="small" </el-tag>
type="danger" <el-tag v-else size="small" type="success" effect="plain">已自动保存</el-tag>
plain <span class="big-save-note">已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问</span>
:loading="wordDeleting"
v-guard="() => deleteWordFile()"
>删除并重新上传</el-button>
</div>
</div>
</div> </div>
</div> </div>
<div class="question-footer"> <div class="question-footer">
<el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button> <el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion"> <el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion">
@@ -312,7 +329,9 @@
<div class="exam-bottom-bar" v-if="!isUploadMode"> <div class="exam-bottom-bar" v-if="!isUploadMode">
<div class="exam-bottom-inner"> <div class="exam-bottom-inner">
<span class="submit-hint"> <span class="submit-hint">
请确保已检查所有题目和 Word 文件后提交 {{ examStore.bigQuestionEnabled
? `请确保已检查所有题目和综合大题(已答 ${examStore.bigAnsweredCount}/${examStore.bigSubCount} 小问)后提交`
: '请确保已检查所有题目后提交' }}
</span> </span>
<el-button <el-button
type="danger" type="danger"
@@ -343,12 +362,13 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { Timer, Download, UploadFilled } from '@element-plus/icons-vue' import { Timer, UploadFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useExamStore } from '@/stores/exam' import { useExamStore } from '@/stores/exam'
import * as examApi from '@/api/exam' import * as examApi from '@/api/exam'
import * as uploadApi from '@/api/upload' import * as uploadApi from '@/api/upload'
import { formatFileSize } from '@/utils/upload' import { formatFileSize } from '@/utils/upload'
import { sanitizeHtml } from '@/utils/richtext'
import { import {
fileExt, fileExt,
categoryOf, categoryOf,
@@ -374,7 +394,6 @@ const uploadRef = ref(null)
const ZIP_MAX = 200 * 1024 * 1024 const ZIP_MAX = 200 * 1024 * 1024
const OTHER_MAX = 100 * 1024 * 1024 const OTHER_MAX = 100 * 1024 * 1024
const WORD_MAX = 50 * 1024 * 1024 // 与后端 uploadWord 服务端 50MB 上限一致
function fileTypeOf(name) { function fileTypeOf(name) {
const e = '.' + fileExt(name) const e = '.' + fileExt(name)
@@ -509,32 +528,45 @@ const loading = ref(true)
const submitting = ref(false) const submitting = ref(false)
const currentQuestionIndex = ref(0) 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 totalCount = computed(() => {
const base = examStore.questions.length const base = examStore.questions.length
return examStore.wordTemplate ? base + 1 : base return examStore.bigQuestionEnabled ? base + 1 : base
}) })
// Word 题目的虚拟索引(最后一个) // 综合大题的虚拟索引(最后一个)
const wordQuestionIndex = computed(() => examStore.questions.length) 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(() => { const answeredCount = computed(() => {
let count = Object.keys(examStore.answers).length let count = Object.keys(examStore.answers).length
if (wordUploaded.value || uploadedWordFile.value) count++ // 综合大题按「三小问全部作答」计为 1 题已答(部分作答可在侧栏看到 x/3 明细)
if (examStore.bigQuestionEnabled && examStore.bigAllAnswered) count++
return 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 remainingSeconds = computed(() => examStore.countdown)
const progressPercent = computed(() => { const progressPercent = computed(() => {
@@ -625,170 +657,56 @@ function nextQuestion() {
} }
} }
// ==================== Word 相关 ==================== // ==================== 综合大题(中职/高职) ====================
/** 下载 Word 模板 */ /** 取第 index 小问的当前答案(空值统一为 '',避免 el-input 出现 undefined 告警) */
async function downloadTemplate() { function bigAnswerOf(index) {
if (!examStore.wordTemplate?.group) return const v = examStore.bigAnswers[`answer_${index}`]
const token = localStorage.getItem('token') return v == null ? '' : v
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) { /** 输入即写本地 + 防抖自动保存(落库由 store 统一处理) */
ElMessage.error('模板下载失败') function onBigAnswerInput(index, value) {
return examStore.setBigAnswer(index, value)
} }
const disposition = response.headers.get('content-disposition') /** 抽题/加载失败后的手动重试 */
let filename = 'template.docx' async function retryBigQuestion() {
if (disposition) { const ok = await examStore.loadBigQuestion(examStore.currentGroup)
const match = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i) if (ok) {
if (match) { ElMessage.success('综合大题已加载')
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 { } else {
// 无 content-length(被分块/压缩)时给一个随已下载量缓增的估算进度,封顶 95 ElMessage.error(examStore.bigError || '综合大题加载失败')
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 * @param {{force?: boolean}} [opts]
// 客户端先行校验大小(与后端 uploadWord 50MB 上限一致),超限直接拦截并给出带文件大小的明确提示 * @returns {Promise<boolean>} 是否落库成功
if (raw.size > WORD_MAX) { */
ElMessage.error(`Word 文件「${raw.name}」大小为 ${formatFileSize(raw.size)},超过 50MB 限制,无法上传`) async function ensureBigAnswersSaved(opts = {}) {
wordFileList.value = [] if (!examStore.bigQuestionEnabled || !examStore.bigLoaded) return true
return const r = await examStore.flushBigAnswers(opts)
} if (!r.ok) {
wordFileList.value = [file] ElMessage.error(`综合大题答案保存失败:${r.message || '请检查网络后重试'}`)
uploadWordFile() return false
}
/** 上传 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
} }
return true
} }
// ==================== 提交全部 ==================== // ==================== 提交全部 ====================
async function handleSubmitAll() { async function handleSubmitAll() {
// 未答满时给出明确的「还差什么」,避免考生误交卷
let confirmText = '确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。'
if (examStore.bigQuestionEnabled && examStore.bigLoaded && !examStore.bigAllAnswered) {
const lack = examStore.bigSubCount - examStore.bigAnsweredCount
confirmText = `综合大题还有 ${lack} 个小问未作答,提交后将无法修改。确定要提交全部答案吗?`
}
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。', confirmText,
'确认提交', '确认提交',
{ confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' } { confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' }
) )
@@ -798,16 +716,11 @@ async function handleSubmitAll() {
submitting.value = true submitting.value = true
try { try {
if (wordFileList.value.length > 0 && !uploadedWordFile.value) { // 先把综合大题答案落库(submit-all 只处理客观题与提交时间),失败即中止交卷
await uploadWordFile() const saved = await ensureBigAnswersSaved()
} if (!saved) return
const additionalData = {} await examStore.submitAll({})
if (uploadedWordFile.value) {
additionalData.wordFile = uploadedWordFile.value
}
await examStore.submitAll(additionalData)
ElMessage.success('提交成功!') ElMessage.success('提交成功!')
} catch (err) { } catch (err) {
if (err?.message) ElMessage.error(err.message) if (err?.message) ElMessage.error(err.message)
@@ -841,19 +754,17 @@ async function autoSubmit() {
} }
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案') ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案')
try { try {
// 若尚有未上传的 Word 答题文件,先上传,避免作品被遗漏
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
await uploadWordFile()
}
const additionalData = {}
if (uploadedWordFile.value) {
additionalData.wordFile = uploadedWordFile.value
}
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门, // forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
// 确保 submit_time 落库(管理后台提交时间显示为真实交卷时间,而非进入考试时间)。 // 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。
additionalData.forceSubmit = true const saved = await ensureBigAnswersSaved({ force: true })
// submitAll 内部会逐题保存答案并写入提交时间,完成「自动保存 + 自动提交」 if (!saved) {
await examStore.submitAll(additionalData) // 落库失败不退化为静默丢答案:提示考生手动重试
ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试')
autoSubmitted.value = false
return
}
// submitAll 内部会逐题保存客观题答案并写入提交时间(大题已在上面落库)
await examStore.submitAll({ forceSubmit: true })
ElMessage.success('已自动保存并提交') ElMessage.success('已自动保存并提交')
router.push('/dashboard') router.push('/dashboard')
} catch (err) { } catch (err) {
@@ -927,27 +838,44 @@ function handleBeforeUnload(e) {
} }
} }
// 禁止复制 /**
* 事件目标是否为「可编辑元素」(综合大题的作答输入框)
*
* 考试页为保证题目不被外传,全局禁用了选中/复制/右键。但综合大题要求考生
* 在输入框内自行作答,若沿用同一套拦截会导致无法选中、修改、粘贴自己写的内容。
* 因此对可编辑元素做最小豁免:只放行「编辑自身答案」所需的操作,
* 题目区域(非可编辑)的防护行为完全不变。
*/
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) { function preventCopy(e) {
if (isEditableTarget(e)) return
e.preventDefault() e.preventDefault()
return false return false
} }
// 禁止鼠标选中文字 // 禁止鼠标选中文字(可编辑元素内放行,否则输入框内无法选中文字)
function preventSelect(e) { function preventSelect(e) {
if (isEditableTarget(e)) return
e.preventDefault() e.preventDefault()
return false return false
} }
// 禁止键盘快捷键:Ctrl+C、Ctrl+U、F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J // 禁止键盘快捷键:Ctrl+C、Ctrl+U、F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J
function preventKeyboard(e) { function preventKeyboard(e) {
// F12(开发者工具) // F12(开发者工具)—— 任何位置均拦截
if (e.key === 'F12' || e.keyCode === 123) { if (e.key === 'F12' || e.keyCode === 123) {
e.preventDefault() e.preventDefault()
return false return false
} }
// Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具) // Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具)—— 任何位置均拦截
if (e.ctrlKey && e.shiftKey) { if (e.ctrlKey && e.shiftKey) {
if (e.key === 'I' || e.key === 'i' || if (e.key === 'I' || e.key === 'i' ||
e.key === 'C' || e.key === 'c' || e.key === 'C' || e.key === 'c' ||
@@ -957,6 +885,9 @@ function preventKeyboard(e) {
} }
} }
// 以下剪贴/保存/打印类快捷键:仅在可编辑元素内放行(考生编辑自身答案所需)
if (isEditableTarget(e)) return
// Ctrl+C(复制) // Ctrl+C(复制)
if (e.ctrlKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) { if (e.ctrlKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) {
e.preventDefault() e.preventDefault()
@@ -1072,8 +1003,9 @@ onMounted(async () => {
await examStore.startExam(group) await examStore.startExam(group)
if (examStore.questions.length === 0) { // 客观题与综合大题都为空才算加载失败;只抽到大题(客观题库为空)时仍可正常作答
ElMessage.error('题目加载失败,请刷新页面重试') if (examStore.questions.length === 0 && !examStore.bigLoaded) {
ElMessage.error(examStore.bigError || '题目加载失败,请刷新页面重试')
router.push('/dashboard') router.push('/dashboard')
return return
} }
@@ -1350,26 +1282,116 @@ onUnmounted(() => {
border-top: 1px solid #ebeef5; border-top: 1px solid #ebeef5;
} }
/* ==================== Word 答题卡片 ==================== */ /* ==================== 综合大题卡片 ==================== */
.word-card .word-sections { .big-card .big-title {
display: flex; font-size: 1.05rem;
flex-direction: column;
gap: 0.5rem;
}
.word-section {
padding: 0.5rem 0;
}
.word-section h4 {
font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: #303133;
margin: 0 0 0.75rem 0; 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; color: #303133;
} }
.word-upload { .big-sub-score {
width: 100%; font-size: 0.85rem;
color: #909399;
} }
.word-progress { .big-sub-prompt {
margin-top: 0.75rem; 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 { .uploaded-info {
display: flex; display: flex;

View File

@@ -72,14 +72,14 @@
</div> </div>
</div> </div>
<!-- Word 答题 --> <!-- 最后一题:中职/高职 = 综合大题;本科/教师 = Word 答题(与正式考试一致) -->
<div class="nav-section"> <div class="nav-section">
<div class="nav-section-title">Word 答题</div> <div class="nav-section-title">{{ isBigQuestionGroup ? '综合大题' : 'Word 答题' }}</div>
<div class="question-nav"> <div class="question-nav">
<div <div
class="nav-item" class="nav-item"
:class="{ :class="{
'nav-answered': wordCompleted, 'nav-answered': lastSectionCompleted,
'nav-current': currentIndex === wordIndex, 'nav-current': currentIndex === wordIndex,
}" }"
@click="currentIndex = wordIndex" @click="currentIndex = wordIndex"
@@ -166,8 +166,45 @@
</div> </div>
</div> </div>
<!-- Word 答题 --> <!-- 综合大题(中职/高职组)—— 正式考试已取消 Word 上传题,改为在线作答 -->
<div v-if="currentIndex === wordIndex" class="question-card word-card"> <div v-if="currentIndex === wordIndex && isBigQuestionGroup" class="question-card word-card">
<div class="question-header">
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
<el-tag type="primary" size="small">综合大题</el-tag>
<span class="question-score">(30 分)</span>
</div>
<div class="question-body">
<p class="question-text">
模拟体验:正式考试中本题由系统从题库随机抽取(题干含文字描述与表格),
共 {{ mockBigSubPrompts.length }} 小问,每小问 10 分、作答上限 {{ MOCK_BIG_MAX_CHARS }} 字,
请直接在本页输入框中作答(模拟考试不会真实提交)。
</p>
<div class="big-sections">
<div v-for="(prompt, si) in mockBigSubPrompts" :key="'big-' + si" class="big-section">
<h4>第 {{ si + 1 }} 小问(10 分)</h4>
<p class="big-prompt">{{ prompt }}</p>
<el-input
v-model="bigAnswers[si]"
type="textarea"
:rows="5"
:maxlength="MOCK_BIG_MAX_CHARS"
show-word-limit
placeholder="请输入作答内容"
/>
</div>
</div>
</div>
<div class="question-footer">
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
<el-button type="primary" @click="nextQuestion">
下一题
</el-button>
</div>
</div>
<!-- Word 答题(本科/教师组;模拟考试,文件不会真实上传) -->
<div v-if="currentIndex === wordIndex && !isBigQuestionGroup" class="question-card word-card">
<div class="question-header"> <div class="question-header">
<span class="question-number">第 {{ wordIndex + 1 }} 题</span> <span class="question-number">第 {{ wordIndex + 1 }} 题</span>
<el-tag type="danger" size="small">综合题</el-tag> <el-tag type="danger" size="small">综合题</el-tag>
@@ -226,7 +263,7 @@
<div class="exam-bottom-bar"> <div class="exam-bottom-bar">
<div class="exam-bottom-inner"> <div class="exam-bottom-inner">
<span class="submit-hint"> <span class="submit-hint">
请确保已检查所有题目和 Word 文件后提交 {{ isBigQuestionGroup ? '请确保已检查所有题目和综合大题后提交' : '请确保已检查所有题目和 Word 文件后提交' }}
</span> </span>
<el-button <el-button
type="danger" type="danger"
@@ -267,7 +304,8 @@ const optionLabels = ['A', 'B', 'C', 'D']
// ==================== 模拟题目数据 ==================== // ==================== 模拟题目数据 ====================
// 设计意图(与用户明确要求一致): // 设计意图(与用户明确要求一致):
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道(+ 1 道 Word 综合题)。 // 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道
// (+ 1 道最后一题:中职/高职为「综合大题」在线作答,本科/教师为 Word 上传)。
// 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题), // 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题),
// 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。 // 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。
// 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。 // 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。
@@ -316,6 +354,24 @@ const mockJudgeQuestions = Array.from({ length: MOCK_JUDGE_COUNT }, (_, i) => ({
// 合并所有题目(选择题在前、判断题在后,与在线考试一致) // 合并所有题目(选择题在前、判断题在后,与在线考试一致)
const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions] const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions]
// ---------------------------------------------------------------------------
// 最后一题的形态随组别变化(与正式考试保持一致,2026-09-21 改造):
// · 中职组 / 高职组:正式考试已取消 Word 文件上传题,改为「综合大题」在线作答
// (题干含文字描述与表格,3 小问、每小问 10 分、作答上限 2000 字)。
// 模拟页必须同步呈现该形态,否则考生会在模拟里看到一道正式考试并不存在的题。
// · 本科组 / 教师组:正式考试仍为 Word 文档上传,模拟页保持原样。
// 注意:模拟页始终为**纯前端**演示,不对正式考试的题库/上传通道发起任何请求。
// ---------------------------------------------------------------------------
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
const MOCK_BIG_MAX_CHARS = 2000
// 综合大题样题(3 小问,仅用于模拟演示,与正式题库无关)
const mockBigSubPrompts = [
'请简述你对题干所给方案的总体思路。',
'请说明方案实施的关键步骤与所需条件。',
'请分析该方案可能存在的问题并给出改进建议。',
]
// ==================== 状态 ==================== // ==================== 状态 ====================
const currentIndex = ref(0) const currentIndex = ref(0)
const answers = ref({}) const answers = ref({})
@@ -324,13 +380,29 @@ const remainingSeconds = ref(3600) // 60分钟模拟
const wordCompleted = ref(false) const wordCompleted = ref(false)
const wordFileName = ref('') const wordFileName = ref('')
const wordFileList = ref([]) const wordFileList = ref([])
// 综合大题(中职/高职)模拟作答:与正式考试的 3 个输入框同口径
const bigAnswers = ref(mockBigSubPrompts.map(() => ''))
let timerInterval = null let timerInterval = null
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
const totalCount = computed(() => allQuestions.length + 1) // +1 for Word // 当前用户组是否走「综合大题」形态
const isBigQuestionGroup = computed(() => BIG_QUESTION_GROUPS.includes(authStore.userGroup))
const totalCount = computed(() => allQuestions.length + 1) // +1 为最后一题
const wordIndex = computed(() => allQuestions.length) const wordIndex = computed(() => allQuestions.length)
// 综合大题是否答满(3 小问均有非空内容,与后端 isCompleted 同口径)
const bigCompleted = computed(() =>
mockBigSubPrompts.length > 0 &&
bigAnswers.value.slice(0, mockBigSubPrompts.length).every(v => String(v || '').trim().length > 0)
)
// 最后一题是否已完成(按组别取对应口径,供导航着色与进度统计统一使用)
const lastSectionCompleted = computed(() =>
isBigQuestionGroup.value ? bigCompleted.value : wordCompleted.value
)
const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length) const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length)
const currentQuestion = computed(() => { const currentQuestion = computed(() => {
@@ -342,7 +414,7 @@ const currentQuestion = computed(() => {
const answeredCount = computed(() => { const answeredCount = computed(() => {
let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length
if (wordCompleted.value) count++ if (lastSectionCompleted.value) count++
return count return count
}) })
@@ -789,6 +861,24 @@ onUnmounted(() => {
color: #303133; color: #303133;
} }
/* 综合大题(中职/高职组) */
.big-sections {
display: flex;
flex-direction: column;
gap: 20px;
}
.big-section h4 {
font-size: 15px;
color: #1a3a5c;
margin-bottom: 8px;
}
.big-prompt {
font-size: 14px;
color: #606266;
line-height: 1.7;
margin-bottom: 10px;
}
/* ==================== 底部提交栏 ==================== */ /* ==================== 底部提交栏 ==================== */
.exam-bottom-bar { .exam-bottom-bar {
background: #fff; background: #fff;