feat(admin): 后台新增「成绩复核」——检索考生 + 全卷逐题明细弹窗(R26)

- api/admin.js: 新增 reviewSearch(姓名/身份证/电话任一,可选限定组别)与 getContestantScore(group, id)
- Admin.vue: 菜单新增「成绩复核」(Search 图标);检索区(关键词 + 可选组别下拉)+ 结果列表 + 全卷明细弹窗
- 明细含客观题逐题:题号 / 题型 / 题干 / 选项 / 考生答案 / 正确答案 / 判定 / 得分/满分
- 异常标记:题干缺失(questionMissing)、答案与判定矛盾(answerMatches≠isCorrect)
- 判断题答案 'true'/'false' 显示为「正确/错误」;未作答显示「未作答」且不判错
- 综合大题展示三小问作答与得分;本科/教师组展示作品文件清单并支持下载(不回传 file_path)
- 弹窗用考生自身组别请求接口,避免前端传错组照错人(后端 409 兜底)
- 回传正确答案仅管理员可见,与后端 adminAuth 门禁配套

构建验证:vite build 通过(✓ built in 14.80s,退出码 0)
This commit is contained in:
2026-09-28 10:15:50 +08:00
parent bdfa4a9618
commit 37d2641e49
2 changed files with 516 additions and 1 deletions

View File

@@ -435,4 +435,39 @@ export function setBigAnswerScores(group, uid, scores) {
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores) return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
} }
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
/**
* 检索考生(姓名 / 身份证号 / 电话 任一,模糊匹配)
*
* 不传 group 则全组别检索 —— 复核时通常只拿到一个身份证号,不应强迫先猜组别。
* @param {string} keyword - 检索词
* @param {string} [group] - 可选:限定组别
* @returns {Promise} { list: [{ contestantId, name, idNumber, phone, group, objectiveApplicable }], total, truncated }
*/
export function reviewSearch(keyword, group) {
const params = { keyword }
if (group) params.group = group
return request.get('/admin/review/search', { params })
}
/**
* 考生全卷明细(复核)
*
* 返回客观题逐题(题干 / 选项 / 考生答案 / **正确答案** / 对错 / 得分 / 满分)
* + 综合大题(题干快照 + 三小问作答与得分)+ 本科/教师组作品文件清单。
* ⚠️ 含正确答案,仅管理员可用。
* @param {string} group - 组别标识(须与考生实际组别一致)
* @param {number|string} contestantId - 考生 ID(contestants.id)
* @returns {Promise} { name, idNumber, phone, group, objectiveApplicable, scorePublished,
* objective: [{ seq, questionType, questionUid, question, options, correctAnswer, maxScore,
* answer, answered, isCorrect, score, answerMatches, questionMissing }],
* summary: { answered, total, correctCount, autoScore, wordScore, totalScore },
* bigQuestion, works, warnings, matchedKeys, questionOrderFixed }
*/
export function getContestantScore(group, contestantId) {
return request.get(`/admin/review/score/${group}/${contestantId}`)
}

View File

@@ -48,6 +48,10 @@
<el-icon><Trophy /></el-icon> <el-icon><Trophy /></el-icon>
<span>成绩管理</span> <span>成绩管理</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="review">
<el-icon><Search /></el-icon>
<span>成绩复核</span>
</el-menu-item>
<el-menu-item index="news"> <el-menu-item index="news">
<el-icon><Notebook /></el-icon> <el-icon><Notebook /></el-icon>
<span>新闻管理</span> <span>新闻管理</span>
@@ -990,6 +994,255 @@
</el-result> </el-result>
</template> </template>
<!-- ========== 成绩复核 ========== -->
<template v-if="activeMenu === 'review'">
<h2 class="section-heading">成绩复核</h2>
<el-alert
type="info"
:closable="false"
show-icon
class="review-privacy-tip"
title="本页会显示客观题的标准答案,仅限管理员内部核对使用,请勿截图外传。"
description="用于考生对成绩提出异议时:按 姓名 / 身份证号 / 电话 任一检索出该考生,核对他的全部题目与实际作答。"
/>
<div class="toolbar review-toolbar">
<el-input
v-model="reviewKeyword"
placeholder="姓名 / 身份证号 / 电话(任一)"
clearable
size="default"
class="search-input"
@keyup.enter="doReviewSearch"
/>
<el-select v-model="reviewGroup" placeholder="全部组别" size="default" class="review-group-select">
<el-option label="全部组别" value="" />
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" />
</el-select>
<el-button type="primary" :loading="reviewSearching" @click="doReviewSearch">
<el-icon><Search /></el-icon>搜索考生
</el-button>
</div>
<el-alert
v-if="reviewTruncated"
type="warning"
:closable="false"
show-icon
class="review-warn"
title="命中超过 50 条,已截断显示。请补充更多字符(例如完整身份证号)以精确定位。"
/>
<el-table
v-if="reviewResults.length"
:data="reviewResults"
stripe
v-loading="reviewSearching"
class="admin-table"
>
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column prop="idNumber" label="身份证号" min-width="190" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column label="组别" width="90" align="center">
<template #default="{ row }">{{ reviewGroupLabel(row.group) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openReviewDetail(row)">查看全卷</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-else-if="reviewSearched && !reviewSearching"
description="没有匹配的考生,请检查姓名 / 身份证号 / 电话是否输入正确"
:image-size="80"
/>
<!-- 全卷复核弹窗 -->
<el-dialog
v-model="reviewDialogVisible"
:title="'全卷复核 — ' + (reviewDetail ? reviewDetail.name : '')"
width="980px"
top="4vh"
>
<div v-loading="reviewLoading">
<template v-if="reviewDetail">
<div class="review-meta">
<span>姓名:<b>{{ reviewDetail.name }}</b></span>
<span>身份证号:{{ reviewDetail.idNumber || '--' }}</span>
<span>电话:{{ reviewDetail.phone || '--' }}</span>
<span>组别:{{ reviewGroupLabel(reviewDetail.group) }}</span>
<el-tag :type="reviewDetail.scorePublished ? 'success' : 'info'" size="small">
{{ reviewDetail.scorePublished ? '成绩已发布' : '成绩未发布' }}
</el-tag>
</div>
<el-descriptions :column="4" border size="small" class="review-summary">
<el-descriptions-item label="客观题题数">{{ reviewDetail.summary ? reviewDetail.summary.answered : 0 }}</el-descriptions-item>
<el-descriptions-item label="客观题得分">{{ reviewDetail.summary ? reviewDetail.summary.autoScore : 0 }}</el-descriptions-item>
<el-descriptions-item label="综合大题得分">{{ reviewDetail.summary ? reviewDetail.summary.wordScore : 0 }}</el-descriptions-item>
<el-descriptions-item label="总分">{{ reviewDetail.summary ? reviewDetail.summary.totalScore : 0 }}</el-descriptions-item>
</el-descriptions>
<el-alert
v-for="(w, i) in (reviewDetail.warnings || [])"
:key="'rvw' + i"
type="warning"
:closable="false"
show-icon
class="review-warn"
:title="w"
/>
<!-- 客观题逐题 -->
<template v-if="reviewDetail.objectiveApplicable">
<h3 class="section-subheading">客观题逐题作答(共 {{ reviewDetail.objective.length }} 题)</h3>
<el-alert
v-if="!reviewDetail.questionOrderFixed"
type="info"
:closable="false"
show-icon
class="review-warn"
title="该考生没有固化题目顺序(历史数据),题目按「选择 → 判断」题号升序展示,可能与其当时所见的题序不同。"
/>
<el-table
:data="reviewDetail.objective"
stripe
size="small"
class="admin-table review-table"
max-height="440"
>
<el-table-column label="#" width="50" align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="题型" width="68" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.questionType === 'choice' ? 'info' : 'warning'">
{{ row.questionType === 'choice' ? '选择' : '判断' }}
</el-tag>
</template>
</el-table-column>
<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>
</template>
</el-table-column>
<el-table-column label="考生作答" width="96" align="center">
<template #default="{ row }">
<span v-if="row.answered">{{ reviewDisplayAnswer(row.answer, row.questionType) }}</span>
<span v-else class="review-empty">未作答</span>
</template>
</el-table-column>
<el-table-column label="正确答案" width="96" align="center">
<template #default="{ row }">
<span v-if="row.correctAnswer != null && row.correctAnswer !== ''">
{{ reviewDisplayAnswer(row.correctAnswer, row.questionType) }}
</span>
<span v-else class="review-empty">--</span>
</template>
</el-table-column>
<el-table-column label="判定" width="76" align="center">
<template #default="{ row }">
<el-tag v-if="row.isCorrect === true" type="success" size="small">正确</el-tag>
<el-tag v-else-if="row.isCorrect === false" type="danger" size="small">错误</el-tag>
<span v-else class="review-empty">未判</span>
</template>
</el-table-column>
<el-table-column label="得分" width="86" align="center">
<template #default="{ row }">
<span v-if="row.score != null">{{ row.score }}</span>
<span v-else class="review-empty">--</span>
<span class="review-max">/{{ row.maxScore != null ? row.maxScore : '--' }}</span>
</template>
</el-table-column>
<el-table-column label="异常" width="66" align="center">
<template #default="{ row }">
<el-tooltip
v-if="reviewRowAnomaly(row)"
placement="top"
:content="reviewRowAnomaly(row)"
>
<el-tag type="danger" size="small">!</el-tag>
</el-tooltip>
<span v-else class="review-empty">—</span>
</template>
</el-table-column>
</el-table>
</template>
<el-alert
v-else
type="info"
:closable="false"
show-icon
class="review-warn"
title="该组别(本科 / 教师)为作品提交制,没有客观题作答记录。"
/>
<!-- 综合大题 -->
<template v-if="reviewDetail.bigQuestion">
<h3 class="section-subheading">综合大题(抽题快照)</h3>
<div class="review-big">
<div class="review-big-title">{{ reviewDetail.bigQuestion.title || '(无标题)' }}</div>
<div
v-if="reviewDetail.bigQuestion.description"
class="review-big-desc"
v-html="sanitizeHtml(reviewDetail.bigQuestion.description)"
></div>
<div v-for="a in reviewDetail.bigQuestion.answers" :key="'rba' + a.index" class="review-big-sub">
<div class="review-big-prompt">
第 {{ a.index }} 小问<span v-if="a.prompt">:{{ a.prompt }}</span>
</div>
<div class="review-big-answer">
<span v-if="a.answered">{{ a.answer }}</span>
<span v-else class="review-empty">(未作答)</span>
</div>
<div class="review-big-score">
得分:{{ a.score != null ? a.score : '--' }}
<span v-if="reviewDetail.bigQuestion.scorePerSub"> / {{ reviewDetail.bigQuestion.scorePerSub }}</span>
</div>
</div>
<div class="review-big-total">
大题合计:<b>{{ reviewDetail.bigQuestion.totalScore != null ? reviewDetail.bigQuestion.totalScore : '--' }}</b>
<span v-if="reviewDetail.bigQuestion.maxTotal"> / {{ reviewDetail.bigQuestion.maxTotal }}</span>
</div>
</div>
</template>
<!-- 本科/教师:作品文件 -->
<template v-if="reviewDetail.works && reviewDetail.works.length">
<h3 class="section-subheading">作品文件(共 {{ reviewDetail.works.length }} 个)</h3>
<el-table :data="reviewDetail.works" stripe size="small" class="admin-table">
<el-table-column prop="workName" label="作品名称" min-width="150" />
<el-table-column prop="fileName" label="文件名" min-width="190" />
<el-table-column label="大小" width="96" align="center">
<template #default="{ row }">
{{ row.fileSize != null ? formatFileSize(row.fileSize) : '--' }}
</template>
</el-table-column>
<el-table-column label="上传时间" width="160" align="center">
<template #default="{ row }">{{ row.uploadTime ? fmtDate(row.uploadTime) : '--' }}</template>
</el-table-column>
<el-table-column label="得分" width="80" align="center">
<template #default="{ row }">{{ row.score != null ? row.score : '--' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="downloadReviewWork(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</template>
</template>
</div>
</el-dialog>
</template>
<!-- ========== 新闻管理 ========== --> <!-- ========== 新闻管理 ========== -->
<template v-if="activeMenu === 'news'"> <template v-if="activeMenu === 'news'">
<h2 class="section-heading">新闻管理</h2> <h2 class="section-heading">新闻管理</h2>
@@ -1241,7 +1494,7 @@ import { ref, reactive, onMounted, watch, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
DataAnalysis, User, EditPen, FolderOpened, DataAnalysis, User, EditPen, FolderOpened,
Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete Clock, Trophy, Notebook, PictureFilled, Rank, Files, Plus, Delete, Search
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import * as adminApi from '@/api/admin' import * as adminApi from '@/api/admin'
@@ -2902,6 +3155,103 @@ function initCarouselSortable() {
/** 保存轮播排序(批量更新,点击"保存排序"按钮触发) */ /** 保存轮播排序(批量更新,点击"保存排序"按钮触发) */
// ==================== 成绩复核(按姓名/身份证号/电话检索 → 核对全卷) ====================
const REVIEW_GROUP_LABELS = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }
const reviewKeyword = ref('')
const reviewGroup = ref('')
const reviewResults = ref([])
const reviewSearching = ref(false)
// 区分「还没搜」与「搜过但无结果」:否则首次进入就显示「没有匹配的考生」,属误导
const reviewSearched = ref(false)
const reviewTruncated = ref(false)
const reviewDialogVisible = ref(false)
const reviewLoading = ref(false)
const reviewDetail = ref(null)
function reviewGroupLabel(g) {
return REVIEW_GROUP_LABELS[g] || g || '--'
}
/**
* 判断题在库里存的是 'true' / 'false',直接展示复核人读不出来,统一映射为「正确 / 错误」。
* 选择题('A' / 'AB')与历史脏值**原样返回** —— 复核要看到库里的真实值,不做清洗。
*/
function reviewDisplayAnswer(ans, type) {
const v = ans == null ? '' : String(ans)
if (type === 'judge') {
const low = v.trim().toLowerCase()
if (low === 'true') return '正确'
if (low === 'false') return '错误'
}
return v
}
/**
* 逐题异常标记(复核时最需要一眼看到的东西)
* - 题目缺失:题库里已找不到该题(被删 / 改到别组)⇒ 题干与标准答案都无法核对
* - 判定矛盾:考生答案与标准答案的比对结果,与系统判定相反 ⇒ 疑似判分异常
* @returns {string} 空串表示无异常
*/
function reviewRowAnomaly(row) {
if (row.questionMissing) {
return '题库中已找不到该题(可能已被删除或改到别的组别),无法核对题干与标准答案'
}
if (row.answerMatches != null && row.isCorrect != null && row.answerMatches !== row.isCorrect) {
return '答案与判定不一致:考生答案与标准答案的比对结果和系统判定相反,疑似判分异常'
}
return ''
}
async function doReviewSearch() {
const kw = reviewKeyword.value.trim()
if (!kw) {
ElMessage.warning('请输入 姓名 / 身份证号 / 电话 中的任意一项')
return
}
reviewSearching.value = true
try {
const data = await adminApi.reviewSearch(kw, reviewGroup.value || undefined)
const result = data?.data || data
reviewResults.value = result?.list || []
reviewTruncated.value = !!result?.truncated
reviewSearched.value = true
} catch (e) {
console.warn('复核检索失败', e)
reviewResults.value = []
reviewTruncated.value = false
reviewSearched.value = true
} finally {
reviewSearching.value = false
}
}
async function openReviewDetail(row) {
reviewDialogVisible.value = true
reviewLoading.value = true
reviewDetail.value = null
try {
// 组别用该考生自身的 group(检索结果里已带),而不是界面筛选器 ——
// 避免「筛选器切到别组后点开旧结果」造成跨组串查。
const data = await adminApi.getContestantScore(row.group, row.contestantId)
reviewDetail.value = data?.data || data
} catch (e) {
console.warn('获取考生全卷明细失败', e)
reviewDialogVisible.value = false
} finally {
reviewLoading.value = false
}
}
async function downloadReviewWork(row) {
if (!reviewDetail.value) return
await safeDownload(`/api/admin/submission-file/${reviewDetail.value.group}/${row.id}`, {
fallbackName: row.fileName || 'file',
})
}
// ==================== 初始化 ==================== // ==================== 初始化 ====================
onMounted(() => { onMounted(() => {
// 首次加载时根据当前活动菜单获取对应数据 // 首次加载时根据当前活动菜单获取对应数据
@@ -3574,4 +3924,134 @@ onMounted(() => {
@media (max-width: 480px) { @media (max-width: 480px) {
.admin-sidebar { display: none; } .admin-sidebar { display: none; }
} }
/* ==================== 成绩复核 ==================== */
.review-privacy-tip {
margin-bottom: 14px;
}
.review-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.review-toolbar .search-input {
max-width: 300px;
}
.review-group-select {
width: 140px;
}
.review-warn {
margin-bottom: 12px;
}
.review-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 12px;
font-size: 13px;
color: #303133;
}
.review-summary {
margin-bottom: 14px;
}
/* 逐题表格:题干可能很长,限高滚动,避免弹窗被撑爆 */
.review-table :deep(.el-table__cell) {
vertical-align: top;
}
.review-question {
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.review-options {
margin: 6px 0 0;
padding-left: 18px;
color: #606266;
font-size: 12px;
line-height: 1.6;
}
.review-empty {
color: #a8abb2;
}
.review-max {
color: #a8abb2;
font-size: 12px;
margin-left: 2px;
}
/* 综合大题(快照) */
.review-big {
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px 14px;
margin-bottom: 6px;
}
.review-big-title {
font-weight: 600;
margin-bottom: 8px;
color: #1a3a5c;
}
.review-big-desc {
font-size: 13px;
line-height: 1.7;
color: #606266;
margin-bottom: 12px;
word-break: break-word;
}
.review-big-sub {
border-top: 1px dashed #e4e7ed;
padding-top: 10px;
margin-top: 10px;
}
.review-big-prompt {
font-size: 13px;
color: #303133;
margin-bottom: 6px;
}
/* 考生作答按原样换行显示,避免长答案挤成一坨 */
.review-big-answer {
font-size: 13px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
background: #fafafa;
border-radius: 3px;
padding: 8px 10px;
max-height: 220px;
overflow-y: auto;
}
.review-big-score {
font-size: 12px;
color: #909399;
margin-top: 6px;
}
.review-big-total {
border-top: 1px solid #e4e7ed;
margin-top: 12px;
padding-top: 10px;
text-align: right;
font-size: 13px;
color: #1a3a5c;
}
</style> </style>