From 1bfe007149eb0707ca1188f4e91b5245e7125686 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 21 Sep 2026 11:17:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=BC=E5=90=88=E5=A4=A7=E9=A2=98?= =?UTF-8?q?=E6=94=B9=E9=80=A0=EF=BC=88=E6=9B=BF=E4=BB=A3=20Word=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=EF=BC=89+=20MockExam=20=E6=8C=89=E7=BB=84?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中职/高职组以「综合大题」替代原 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 随后端仓库提交。 --- src/api/admin.js | 88 ++++ src/api/exam.js | 49 +- src/components/RichEditor.vue | 14 +- src/stores/exam.js | 234 ++++++++- src/utils/richtext.js | 228 +++++++++ src/views/Admin.vue | 896 ++++++++++++++++++++++++++++------ src/views/Competition.vue | 582 +++++++++++----------- src/views/MockExam.vue | 108 +++- 8 files changed, 1729 insertions(+), 470 deletions(-) create mode 100644 src/utils/richtext.js diff --git a/src/api/admin.js b/src/api/admin.js index ff86413..b857bab 100644 --- a/src/api/admin.js +++ b/src/api/admin.js @@ -347,4 +347,92 @@ export function resetTestData() { 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) +} + diff --git a/src/api/exam.js b/src/api/exam.js index 10a7a69..37c5c8b 100644 --- a/src/api/exam.js +++ b/src/api/exam.js @@ -22,15 +22,6 @@ export function getQuestions(group) { 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 - 组别标识 @@ -41,25 +32,41 @@ export function submitAnswers(group, answers) { 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 - * @returns {Promise} + * 获取综合大题(首次调用即抽题并落库快照,后续调用复用同一题) + * + * 服务端保证「一考生一行、一题一快照」:抽题幂等(reused=true 表示复用), + * 题干以抽题时刻的快照为准(管理员后续改题干不影响已抽考生,防纠纷)。 + * 响应体不含任何分数字段(成绩发布门控)。 + * @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) { - return request.post(`/exam/${group}/upload-word`, formData, { - timeout: 60000, // 上传超时 60 秒 - }) +export function getBigQuestion(group) { + // silent:本接口的失败(如 4004 该组暂无大题)由答题区自行展示 + 提供重试, + // 避免与页面其他提示重复弹窗。 + return request.get(`/exam/${group}/big-question`, { silent: true }) } /** - * 删除已上传的 Word 答题文件(中职/高职组) + * 保存综合大题三小问答案(整卷覆盖保存) + * + * 超长由前端 maxlength 与服务端双重拦截;服务端超限返回 400 且整批拒绝(不静默截断)。 * @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) { - return request.delete(`/exam/${group}/word`, { timeout: 60000 }) +export function saveBigAnswer(group, data) { + return request.post(`/exam/${group}/big-answer`, data, { silent: true }) } /** diff --git a/src/components/RichEditor.vue b/src/components/RichEditor.vue index 0a08caa..6fdc085 100644 --- a/src/components/RichEditor.vue +++ b/src/components/RichEditor.vue @@ -20,7 +20,7 @@