fix(admin): 复核页选择题选项与答案显示 + 阅卷导出 Word 按钮 + 评分两位小数(R28)

- 新增 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)。
This commit is contained in:
2026-09-28 15:50:56 +08:00
parent 397356f141
commit fb9c48aaac

View File

@@ -386,11 +386,33 @@
<el-option label="未抽题" value="undrawn" /> <el-option label="未抽题" value="undrawn" />
</el-select> </el-select>
<el-button size="small" :loading="bigAnswersLoading" @click="fetchBigAnswers">刷新</el-button> <el-button size="small" :loading="bigAnswersLoading" @click="fetchBigAnswers">刷新</el-button>
<el-button
size="small"
type="primary"
:disabled="bigAnswerSelectedIds.length === 0"
:loading="bigAnswersExporting"
v-guard="() => exportBigAnswers(bigAnswerSelectedIds)"
>导出选中 Word({{ bigAnswerSelectedIds.length }})</el-button>
<el-button
size="small"
type="success"
:loading="bigAnswersExporting"
v-guard="() => exportBigAnswers()"
>导出全部 Word</el-button>
<el-tag type="info" class="total-score-tag"> <el-tag type="info" class="total-score-tag">
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人 共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
</el-tag> </el-tag>
</div> </div>
<el-table :data="filteredBigAnswers" stripe v-loading="bigAnswersLoading" class="admin-table"> <el-table
ref="bigAnswersTableRef"
:data="filteredBigAnswers"
stripe
v-loading="bigAnswersLoading"
class="admin-table"
row-key="contestantId"
@selection-change="onBigAnswerSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="row => row.drawn" />
<el-table-column prop="name" label="姓名" width="110" /> <el-table-column prop="name" label="姓名" width="110" />
<el-table-column prop="idNumber" label="身份证号" width="180" show-overflow-tooltip /> <el-table-column prop="idNumber" label="身份证号" width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" /> <el-table-column prop="phone" label="手机号" width="130" />
@@ -423,11 +445,19 @@
<span v-else style="color:#909399">未提交</span> <span v-else style="color:#909399">未提交</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="130" align="center"> <el-table-column label="操作" width="210" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button text type="primary" size="small" :disabled="!row.drawn" @click="openBigAnswerDetail(row)"> <el-button text type="primary" size="small" :disabled="!row.drawn" @click="openBigAnswerDetail(row)">
{{ row.bigScore === null || row.bigScore === undefined ? '查看/评分' : '查看/改分' }} {{ row.bigScore === null || row.bigScore === undefined ? '查看/评分' : '查看/改分' }}
</el-button> </el-button>
<el-button
text
type="success"
size="small"
:disabled="!row.drawn"
:loading="bigAnswersExporting"
@click="exportBigAnswers([row.contestantId])"
>导出Word</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -619,8 +649,8 @@
v-model="bigScoreForm[`score_${i}`]" v-model="bigScoreForm[`score_${i}`]"
:min="0" :min="0"
:max="bigScorePerSub" :max="bigScorePerSub"
:step="0.5" :step="0.25"
:precision="1" :precision="2"
size="small" size="small"
controls-position="right" controls-position="right"
/> />
@@ -711,7 +741,7 @@
<span>{{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改)</span> <span>{{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改)</span>
</el-form-item> </el-form-item>
<el-form-item label="大题分"> <el-form-item label="大题分">
<el-input-number v-model="examScoreDialogWord" :min="0" :max="100" :step="0.5" :precision="1" /> <el-input-number v-model="examScoreDialogWord" :min="0" :max="100" :step="0.25" :precision="2" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -801,7 +831,7 @@
<el-button text type="primary" size="small" v-guard="() => downloadWork(row, f)">下载</el-button> <el-button text type="primary" size="small" v-guard="() => downloadWork(row, f)">下载</el-button>
<span class="score-inline"> <span class="score-inline">
评分 评分
<el-input-number v-model="f._score" :min="0" :max="100" :step="0.5" :precision="1" size="small" controls-position="right" class="score-input" /> <el-input-number v-model="f._score" :min="0" :max="100" :step="0.25" :precision="2" size="small" controls-position="right" class="score-input" />
</span> </span>
</div> </div>
<span v-if="!row.files || row.files.length === 0" style="color:#909399">未提交</span> <span v-if="!row.files || row.files.length === 0" style="color:#909399">未提交</span>
@@ -1134,21 +1164,21 @@
<el-table-column label="题目" min-width="270"> <el-table-column label="题目" min-width="270">
<template #default="{ row }"> <template #default="{ row }">
<div class="review-question">{{ row.question || '(题库中已找不到该题)' }}</div> <div class="review-question">{{ row.question || '(题库中已找不到该题)' }}</div>
<ul v-if="row.options && row.options.length" class="review-options"> <ul v-if="reviewOptionList(row).length" class="review-options">
<li v-for="op in row.options" :key="op.key">{{ op.key }}. {{ op.text }}</li> <li v-for="(op, oi) in reviewOptionList(row)" :key="op.key || oi">{{ op.key }}. {{ op.text }}</li>
</ul> </ul>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="考生作答" width="96" align="center"> <el-table-column label="考生作答" width="130" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.answered">{{ reviewDisplayAnswer(row.answer, row.questionType) }}</span> <span v-if="row.answered">{{ reviewAnswerCell(row, row.answer) }}</span>
<span v-else class="review-empty">未作答</span> <span v-else class="review-empty">未作答</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="正确答案" width="96" align="center"> <el-table-column label="正确答案" width="130" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.correctAnswer != null && row.correctAnswer !== ''"> <span v-if="row.correctAnswer != null && row.correctAnswer !== ''">
{{ reviewDisplayAnswer(row.correctAnswer, row.questionType) }} {{ reviewAnswerCell(row, row.correctAnswer) }}
</span> </span>
<span v-else class="review-empty">--</span> <span v-else class="review-empty">--</span>
</template> </template>
@@ -1855,14 +1885,14 @@ const bigRowScorePerSub = computed(() => {
}) })
/** /**
* 分数显示格式化(2026-09-23):人工评分允许小数点后一位。 * 分数显示格式化(2026-09-28):人工评分精确到小数点后**两位**。
* 整数不带小数点(27 而非 27.0),非整数保留一位(27.4);空/非法值回 '--'。 * 整数不带小数点(27 而非 27.00),非整数最多两位(27.4 / 8.25);空/非法值回 '--'。
*/ */
function fmtScore(v) { function fmtScore(v) {
if (v === null || v === undefined || v === '') return '--' if (v === null || v === undefined || v === '') return '--'
const n = Number(v) const n = Number(v)
if (!Number.isFinite(n)) return '--' 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(() => { const bigScoreTotal = computed(() => {
@@ -1870,8 +1900,8 @@ const bigScoreTotal = computed(() => {
for (let i = 1; i <= bigSubCount.value; i++) { for (let i = 1; i <= bigSubCount.value; i++) {
sum += Number(bigScoreForm[`score_${i}`]) || 0 sum += Number(bigScoreForm[`score_${i}`]) || 0
} }
// 浮点求和收敛到一位小数,避免 0.1+0.2=0.30000000000000004 之类的脏值显示 // 浮点求和收敛到两位小数,避免 0.1+0.2=0.30000000000000004 之类的脏值显示
return Math.round(sum * 10) / 10 return Math.round(sum * 100) / 100
}) })
/** 剥标签取纯文本(列表内搜索用,不用于渲染) */ /** 剥标签取纯文本(列表内搜索用,不用于渲染) */
@@ -2006,6 +2036,55 @@ function openBigPreview(row) {
bigPreviewVisible.value = true 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() { async function fetchBigAnswers() {
// 同 fetchBigQuestions:固定发起时的组别,防止切组竞态把别组数据写回 // 同 fetchBigQuestions:固定发起时的组别,防止切组竞态把别组数据写回
const g = questionGroup.value const g = questionGroup.value
@@ -3195,6 +3274,53 @@ function reviewDisplayAnswer(ans, type) {
return v 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
}
/** /**
* 逐题异常标记(复核时最需要一眼看到的东西) * 逐题异常标记(复核时最需要一眼看到的东西)
* - 题目缺失:题库里已找不到该题(被删 / 改到别组)⇒ 题干与标准答案都无法核对 * - 题目缺失:题库里已找不到该题(被删 / 改到别组)⇒ 题干与标准答案都无法核对