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:
@@ -386,11 +386,33 @@
|
||||
<el-option label="未抽题" value="undrawn" />
|
||||
</el-select>
|
||||
<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">
|
||||
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
|
||||
</el-tag>
|
||||
</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="idNumber" label="身份证号" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
@@ -423,11 +445,19 @@
|
||||
<span v-else style="color:#909399">未提交</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" align="center">
|
||||
<el-table-column label="操作" width="210" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" :disabled="!row.drawn" @click="openBigAnswerDetail(row)">
|
||||
{{ row.bigScore === null || row.bigScore === undefined ? '查看/评分' : '查看/改分' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="success"
|
||||
size="small"
|
||||
:disabled="!row.drawn"
|
||||
:loading="bigAnswersExporting"
|
||||
@click="exportBigAnswers([row.contestantId])"
|
||||
>导出Word</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -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 @@
|
||||
<span>{{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改)</span>
|
||||
</el-form-item>
|
||||
<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>
|
||||
<template #footer>
|
||||
@@ -801,7 +831,7 @@
|
||||
<el-button text type="primary" size="small" v-guard="() => downloadWork(row, f)">下载</el-button>
|
||||
<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>
|
||||
</div>
|
||||
<span v-if="!row.files || row.files.length === 0" style="color:#909399">未提交</span>
|
||||
@@ -1134,21 +1164,21 @@
|
||||
<el-table-column label="题目" min-width="270">
|
||||
<template #default="{ row }">
|
||||
<div class="review-question">{{ row.question || '(题库中已找不到该题)' }}</div>
|
||||
<ul v-if="row.options && row.options.length" class="review-options">
|
||||
<li v-for="op in row.options" :key="op.key">{{ op.key }}. {{ op.text }}</li>
|
||||
<ul v-if="reviewOptionList(row).length" class="review-options">
|
||||
<li v-for="(op, oi) in reviewOptionList(row)" :key="op.key || oi">{{ op.key }}. {{ op.text }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="考生作答" width="96" align="center">
|
||||
<el-table-column label="考生作答" width="130" align="center">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="正确答案" width="96" align="center">
|
||||
<el-table-column label="正确答案" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.correctAnswer != null && row.correctAnswer !== ''">
|
||||
{{ reviewDisplayAnswer(row.correctAnswer, row.questionType) }}
|
||||
{{ reviewAnswerCell(row, row.correctAnswer) }}
|
||||
</span>
|
||||
<span v-else class="review-empty">--</span>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐题异常标记(复核时最需要一眼看到的东西)
|
||||
* - 题目缺失:题库里已找不到该题(被删 / 改到别组)⇒ 题干与标准答案都无法核对
|
||||
|
||||
Reference in New Issue
Block a user