From fb9c48aaacb1685b4f99c0d824b33922d704466d Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 28 Sep 2026 15:50:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=A4=8D=E6=A0=B8=E9=A1=B5?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E9=A2=98=E9=80=89=E9=A1=B9=E4=B8=8E=E7=AD=94?= =?UTF-8?q?=E6=A1=88=E6=98=BE=E7=A4=BA=20+=20=E9=98=85=E5=8D=B7=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20Word=20=E6=8C=89=E9=92=AE=20+=20=E8=AF=84=E5=88=86?= =?UTF-8?q?=E4=B8=A4=E4=BD=8D=E5=B0=8F=E6=95=B0=EF=BC=88R28=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 reviewOptionList() / reviewOptionTextOf() / reviewAnswerCell(): 防御性解包 options(非数组一律降级空数组),选项列表改为 v-for 遍历对象数组; 选择题的「考生作答」「正确答案」显示为「A. GPS」,彻底消除「只显示字母不知指向哪个选项」的疑虑; 判断题仍显示「正确/错误」。两列宽 96 -> 130。 - 阅卷区:el-table 增加 type="selection" + row-key="contestantId"; 新增「导出选中 Word(n)」「导出全部 Word」及行内「导出Word」, 沿用 safeDownload 直接命中 GET /api/admin/big-answers/:group/export?ids=。 - fmtScore / bigScoreTotal 改两位小数;3 处 el-input-number 改 :precision="2" :step="0.25"。 构建验证:node ./node_modules/vite/bin/vite.js build => VITE_EXIT=0(built in 19.61s), 产物中含 review-options / 导出全部 Word / big-answers/{group}/export 三元 fallbackName(.docx|.zip)。 --- src/views/Admin.vue | 160 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 17 deletions(-) diff --git a/src/views/Admin.vue b/src/views/Admin.vue index b08eca9..d9fb3e1 100644 --- a/src/views/Admin.vue +++ b/src/views/Admin.vue @@ -386,11 +386,33 @@ 刷新 + 导出选中 Word({{ bigAnswerSelectedIds.length }}) + 导出全部 Word 共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人 - + + @@ -423,11 +445,19 @@ 未提交 - + {{ row.bigScore === null || row.bigScore === undefined ? '查看/评分' : '查看/改分' }} + 导出Word @@ -619,8 +649,8 @@ v-model="bigScoreForm[`score_${i}`]" :min="0" :max="bigScorePerSub" - :step="0.5" - :precision="1" + :step="0.25" + :precision="2" size="small" controls-position="right" /> @@ -711,7 +741,7 @@ {{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改) - + @@ -801,7 +831,7 @@ 下载 评分 - + 未提交 @@ -1134,21 +1164,21 @@ {{ row.question || '(题库中已找不到该题)' }} - - {{ op.key }}. {{ op.text }} + + {{ op.key }}. {{ op.text }} - + - {{ reviewDisplayAnswer(row.answer, row.questionType) }} + {{ reviewAnswerCell(row, row.answer) }} 未作答 - + - {{ reviewDisplayAnswer(row.correctAnswer, row.questionType) }} + {{ reviewAnswerCell(row, row.correctAnswer) }} -- @@ -1855,14 +1885,14 @@ const bigRowScorePerSub = computed(() => { }) /** - * 分数显示格式化(2026-09-23):人工评分允许小数点后一位。 - * 整数不带小数点(27 而非 27.0),非整数保留一位(27.4);空/非法值回 '--'。 + * 分数显示格式化(2026-09-28):人工评分精确到小数点后**两位**。 + * 整数不带小数点(27 而非 27.00),非整数最多两位(27.4 / 8.25);空/非法值回 '--'。 */ function fmtScore(v) { if (v === null || v === undefined || v === '') return '--' const n = Number(v) if (!Number.isFinite(n)) return '--' - return Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10) + return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100) } const bigScoreTotal = computed(() => { @@ -1870,8 +1900,8 @@ const bigScoreTotal = computed(() => { for (let i = 1; i <= bigSubCount.value; i++) { sum += Number(bigScoreForm[`score_${i}`]) || 0 } - // 浮点求和收敛到一位小数,避免 0.1+0.2=0.30000000000000004 之类的脏值显示 - return Math.round(sum * 10) / 10 + // 浮点求和收敛到两位小数,避免 0.1+0.2=0.30000000000000004 之类的脏值显示 + return Math.round(sum * 100) / 100 }) /** 剥标签取纯文本(列表内搜索用,不用于渲染) */ @@ -2006,6 +2036,55 @@ function openBigPreview(row) { bigPreviewVisible.value = true } +/** 阅卷导出(R28):选中态 + 导出中态 + 表格引用 */ +const bigAnswersTableRef = ref(null) +const bigAnswerSelectedIds = ref([]) +const bigAnswersExporting = ref(false) + +function onBigAnswerSelectionChange(rows) { + bigAnswerSelectedIds.value = (rows || []) + .map(r => Number(r && r.contestantId)) + .filter(v => Number.isInteger(v) && v > 0) +} + +/** + * 导出考生**大题答卷**(试题快照 + 考生作答 + 逐问得分)为 Word(R28 测试人员需求)。 + * · 传数组 ⇒ 导出选中考生;不传 ⇒ 导出该组**全部已抽题**考生; + * · 后端按「每位考生一个独立 .docx」生成,命中多位时打包为 .zip + * (⛔ 不是把所有考生塞进同一个 Word —— 那样无法分别打印 / 分发 / 归档); + * · 命中 1 位 ⇒ 直接下载 .docx。 + * ⚠️ 与同页「导出选中/全部 Word」(exportBigQuestions,导出的是**题库题目**)是两个功能,勿混。 + */ +async function exportBigAnswers(ids) { + const g = questionGroup.value + const groupLabel = g === 'zhongzhi' ? '中职组' : '高职组' + const isAll = !Array.isArray(ids) + // 兜底:剔除非本组 / 未抽题的残留 ID(与 exportBigQuestions 同源处理)。 + // 若不区分「全部」与「选中」,选中项全失效时会静默退化成导出全部,与用户意图相反。 + const available = new Set( + bigAnswers.value.filter(r => r && r.drawn).map(r => Number(r.contestantId)) + ) + const list = (Array.isArray(ids) ? ids : []) + .map(v => Number(v)) + .filter(v => Number.isInteger(v) && v > 0 && available.has(v)) + if (!isAll && list.length === 0) { + ElMessage.warning('选中的考生未抽题或已失效,请刷新后重试') + return + } + const qs = list.length ? `?ids=${list.join(',')}` : '' + bigAnswersExporting.value = true + try { + await safeDownload(`/api/admin/big-answers/${g}/export${qs}`, { + fallbackName: list.length === 1 + ? `${groupLabel}_大题答卷.docx` + : `${groupLabel}_大题答卷.zip`, + onSuccess: name => ElMessage.success(`已开始下载:${name}`), + }) + } finally { + bigAnswersExporting.value = false + } +} + async function fetchBigAnswers() { // 同 fetchBigQuestions:固定发起时的组别,防止切组竞态把别组数据写回 const g = questionGroup.value @@ -3195,6 +3274,53 @@ function reviewDisplayAnswer(ans, type) { return v } +/** + * 【R28】客观题选项列表(防御性解包)。 + * 后端 options 存于 text 列、内容是 JSON 字符串;曾原样透传 ⇒ `v-for` 对字符串**逐字符**迭代, + * 每个字符渲染成一行空内容(op.key / op.text 皆 undefined),表现即「选择题大量换行导致空白」。 + * 后端已修(service/admin.js parseOptions),此处做第二道防线:非数组一律降级为空数组。 + */ +function reviewOptionList(row) { + const raw = row && row.options + if (Array.isArray(raw)) return raw + if (typeof raw === 'string' && raw.trim()) { + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch (e) { + return [] + } + } + return [] +} + +/** 在选项列表里按字母取选项文本(找不到返回空串) */ +function reviewOptionTextOf(options, key) { + const k = String(key == null ? '' : key).trim().toUpperCase() + if (!k || !Array.isArray(options)) return '' + for (const op of options) { + if (op && typeof op === 'object') { + const ok = String(op.key == null ? '' : op.key).trim().toUpperCase() + if (ok === k) return String(op.text == null ? '' : op.text).trim() + } + } + return '' +} + +/** + * 选择题「考生作答 / 正确答案」单元格:字母 + 选项文本(如「A. GPS」)。 + * 测试人员疑问「只显示正确答案的选项,不知道正确答案具体是哪一个」的正面答复: + * 客观题**选项顺序与考生所见完全一致**(后端只用 RAND() 随机抽题,从不乱序选项; + * 前端 Competition.vue 也按 options 原序渲染)⇒ 字母本就无歧义;再补选项文本即彻底消除疑虑。 + * 判断题仍走「正确/错误」;找不到选项文本时退回纯字母。 + */ +function reviewAnswerCell(row, ans) { + const base = reviewDisplayAnswer(ans, row && row.questionType) + if (!row || row.questionType !== 'choice' || !base) return base + const text = reviewOptionTextOf(reviewOptionList(row), base) + return text ? `${base}. ${text}` : base +} + /** * 逐题异常标记(复核时最需要一眼看到的东西) * - 题目缺失:题库里已找不到该题(被删 / 改到别组)⇒ 题干与标准答案都无法核对