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:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user