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

@@ -9,8 +9,6 @@ const questions = ref([])
const answers = ref({})
const countdown = ref(0)
const examStarted = ref(false)
const wordTemplate = ref(null)
const wordUploaded = ref(false)
const totalQuestions = ref(0)
// 考试 session 标识(防止返回重新进入)
@@ -19,6 +17,27 @@ let timerInterval = null
let resyncInterval = null
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(() => {
@@ -41,6 +60,58 @@ const progress = computed(() => {
const choiceQuestions = computed(() => questions.value.filter(q => q.question_type === 'choice'))
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) {
@@ -158,7 +229,10 @@ async function startExam(group) {
totalQuestions.value = responseData.count || rawQuestions.length
}
wordTemplate.value = { group }
// 中职/高职:进入考试即抽取综合大题(后端幂等,重进复用同一题与快照)
if (BIG_QUESTION_GROUPS.includes(group)) {
await loadBigQuestion(group)
}
examStarted.value = true
startTimer()
@@ -224,6 +298,12 @@ async function submitAnswer(questionId, answer) {
}
async function submitAll(additionalData = {}) {
// 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间,
// 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。
if (bigQuestionEnabled.value) {
await flushBigAnswers({ force: additionalData.forceSubmit === true, silent: true })
}
// 每条答案都必须带合法 question_type:优先用 uid 前缀推导(questions 为空
// 的恢复会话场景依然可靠),否则后端校验拒绝整卷提交。
const answersArray = Object.entries(answers.value).map(([questionUid, answer]) => ({
@@ -241,14 +321,131 @@ async function submitAll(additionalData = {}) {
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() {
stopTimer()
resetBigQuestion()
questions.value = []
answers.value = {}
countdown.value = 0
examStarted.value = false
wordTemplate.value = null
wordUploaded.value = false
totalQuestions.value = 0
currentGroup.value = ''
examSessionId = ''
sessionStorage.removeItem('exam_session')
@@ -273,15 +470,33 @@ export function useExamStore() {
answers,
countdown,
examStarted,
wordTemplate,
wordUploaded,
totalQuestions,
// 综合大题状态
bigQuestion,
bigAnswers,
bigSubmitTime,
bigMaxChars,
bigSubCount,
bigReused,
bigLoading,
bigLoaded,
bigError,
bigSaving,
// 计算属性
countdownDisplay,
isTimeUp,
progress,
choiceQuestions,
judgeQuestions,
// 综合大题计算属性
bigQuestionEnabled,
bigSubPrompts,
bigScorePerSub,
bigTotalScore,
bigAnsweredCount,
bigAllAnswered,
bigCharCounts,
bigCompleted,
// 方法
startExam,
startUploadCountdown,
@@ -291,5 +506,10 @@ export function useExamStore() {
resetExam,
isSessionValid,
restoreAnswers,
// 综合大题方法
loadBigQuestion,
setBigAnswer,
flushBigAnswers,
resetBigQuestion,
})
}