feat(admin): 功能分布优化——板块迁入成绩管理 + 导出 Excel + 废弃按钮清理(R29)

- 试卷管理只留试卷相关:「考生答题记录」(含评分弹窗)与「考生大题作答查证」
  整段迁至成绩管理页,外层 <template v-if="isExamScoreGroup"> 仅中职/高职可见
- 组别绑定改跟随成绩页 scoreExportGroup(避免串组):新增 isExamScoreGroup /
  refreshExamCtxData / refreshScorePageData + watch(scoreExportGroup);ensureBigData 收窄为只刷题库
- 导出按钮:导出CSV → 导出Excel(GET /api/admin/export-excel/:group,fallback .xlsx)
- 中职/高职已取消 Word 作品上传:成绩页与作品管理页「批量下载作品」按组别 v-if 隐藏
- 评分弹窗文字错位修复:label-width 120px + label-position="left" + 标签左对齐不换行
- 5 个纯白按钮补 type(info/primary plain)提升可见度
This commit is contained in:
2026-09-28 16:42:39 +08:00
parent fb9c48aaac
commit c470e7922e

View File

@@ -212,7 +212,7 @@
<el-button type="primary" @click="openQuestionDialog(null, activeQuestionType)">添加题目</el-button> <el-button type="primary" @click="openQuestionDialog(null, activeQuestionType)">添加题目</el-button>
<el-tag type="info" size="small" class="score-policy-tag">客观题分值口径:每小题固定 1 分</el-tag> <el-tag type="info" size="small" class="score-policy-tag">客观题分值口径:每小题固定 1 分</el-tag>
<el-divider direction="vertical" /> <el-divider direction="vertical" />
<el-button size="small" v-guard="() => downloadQuestionTpl()">下载Excel模板</el-button> <el-button size="small" type="info" plain v-guard="() => downloadQuestionTpl()">下载Excel模板</el-button>
<el-button size="small" type="success" v-guard="() => triggerExcelImport()">Excel批量导入</el-button> <el-button size="small" type="success" v-guard="() => triggerExcelImport()">Excel批量导入</el-button>
<input ref="excelFileInput" type="file" accept=".xlsx,.xls" style="display:none" @change="onExcelFileChange" /> <input ref="excelFileInput" type="file" accept=".xlsx,.xls" style="display:none" @change="onExcelFileChange" />
<el-button size="small" type="danger" :disabled="selectedQuestionRows.length === 0" v-guard="() => batchDeleteSelectedQuestions()">批量删除({{ selectedQuestionRows.length }})</el-button> <el-button size="small" type="danger" :disabled="selectedQuestionRows.length === 0" v-guard="() => batchDeleteSelectedQuestions()">批量删除({{ selectedQuestionRows.length }})</el-button>
@@ -223,7 +223,7 @@
<template v-else> <template v-else>
<el-input v-model="bigSearch" placeholder="搜索大题标题/题干" clearable size="small" class="search-input" /> <el-input v-model="bigSearch" placeholder="搜索大题标题/题干" clearable size="small" class="search-input" />
<el-button type="primary" v-guard="() => openBigDialog(null)">新增大题</el-button> <el-button type="primary" v-guard="() => openBigDialog(null)">新增大题</el-button>
<el-button size="small" :loading="bigQuestionsLoading" @click="fetchBigQuestions">刷新</el-button> <el-button size="small" type="info" plain :loading="bigQuestionsLoading" @click="fetchBigQuestions">刷新</el-button>
<el-button <el-button
size="small" size="small"
type="warning" type="warning"
@@ -367,103 +367,6 @@
<el-empty v-if="activeQuestionType !== 'big' && currentQuestions.length === 0 && !questionsLoading" :description="'暂无' + questionTypeLabel + '题目'" :image-size="80" /> <el-empty v-if="activeQuestionType !== 'big' && currentQuestions.length === 0 && !questionsLoading" :description="'暂无' + questionTypeLabel + '题目'" :image-size="80" />
<!-- ========== 综合大题:考生作答查证 ========== -->
<template v-if="activeQuestionType === 'big'">
<el-divider />
<h3 class="section-subheading">考生大题作答查证</h3>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom:12px"
title="用于核对考生实际抽到的题目与其提交的答案,处理考生纠纷。题干为该考生抽题时刻的快照,管理员后续修改题库不会影响此处显示。"
/>
<div class="toolbar">
<el-input v-model="bigAnswerSearch" placeholder="搜索考生姓名 / 身份证号" clearable size="small" class="search-input" />
<el-select v-model="bigAnswerFilter" size="small" class="filter-select">
<el-option label="全部考生" value="all" />
<el-option label="已抽题" value="drawn" />
<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
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" />
<el-table-column label="抽题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.drawn ? 'success' : 'info'" size="small">{{ row.drawn ? '已抽题' : '未抽题' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="抽到大题" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.questionSnapshot">{{ row.questionSnapshot.title }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="已答小问" width="100" align="center">
<template #default="{ row }">
<span v-if="row.drawn">{{ row.answeredCount }} / {{ bigSubCount }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="大题得分" width="110" align="center">
<template #default="{ row }">
<span v-if="row.bigScore !== null && row.bigScore !== undefined">{{ fmtScore(row.bigScore) }} 分</span>
<span v-else style="color:#909399">未评分</span>
</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
<template #default="{ row }">
<span v-if="row.submitTime">{{ formatDateTime(row.submitTime) }}</span>
<span v-else style="color:#909399">未提交</span>
</template>
</el-table-column>
<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>
<el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" />
</template>
<!-- 题目编辑弹窗 --> <!-- 题目编辑弹窗 -->
<el-dialog v-model="questionDialogVisible" :title="dialogTitle" width="640px"> <el-dialog v-model="questionDialogVisible" :title="dialogTitle" width="640px">
<el-form :model="questionForm" label-position="top"> <el-form :model="questionForm" label-position="top">
@@ -597,158 +500,6 @@
</div> </div>
</el-dialog> </el-dialog>
<!-- 综合大题:作答详情 + 评分弹窗 -->
<el-dialog
v-model="bigAnswerDialogVisible"
:title="'大题作答详情 — ' + (bigAnswerRow.name || '')"
width="880px"
top="5vh"
>
<template v-if="bigAnswerRow.contestantId">
<el-descriptions :column="3" border size="small" class="big-answer-meta">
<el-descriptions-item label="姓名">{{ bigAnswerRow.name }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ bigAnswerRow.idNumber }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ bigAnswerRow.phone }}</el-descriptions-item>
<el-descriptions-item label="抽题状态">{{ bigAnswerRow.drawn ? '已抽题' : '未抽题' }}</el-descriptions-item>
<el-descriptions-item label="抽到题号">{{ bigAnswerRow.bigQuestionUid || '--' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">
{{ bigAnswerRow.submitTime ? formatDateTime(bigAnswerRow.submitTime) : '未提交' }}
</el-descriptions-item>
</el-descriptions>
<template v-if="bigAnswerRow.drawn">
<h4 class="big-answer-h4">考生抽到的题目(抽题时刻快照,防纠纷)</h4>
<div class="big-snapshot">
<div class="big-snapshot-title">
{{ bigAnswerRow.questionSnapshot && bigAnswerRow.questionSnapshot.title }}
</div>
<div
class="big-preview-body"
v-html="previewHtml(bigAnswerRow.questionSnapshot && bigAnswerRow.questionSnapshot.description)"
></div>
</div>
<h4 class="big-answer-h4">考生提交的答案与评分</h4>
<div v-for="i in bigSubCount" :key="i" class="big-answer-sub">
<div class="big-answer-sub-head">
<span>第 {{ i }} 小问</span>
<span class="big-answer-chars">
{{ bigAnswerTextOf(i).length }} 字
</span>
</div>
<div v-if="bigSubPromptsOfRow[i - 1]" class="big-answer-prompt">
{{ bigSubPromptsOfRow[i - 1] }}
</div>
<div class="big-answer-text">
<span v-if="bigAnswerTextOf(i).trim()">{{ bigAnswerTextOf(i) }}</span>
<span v-else class="big-answer-empty">(未作答)</span>
</div>
<div class="big-answer-score">
<span class="big-answer-score-label">得分:</span>
<el-input-number
v-model="bigScoreForm[`score_${i}`]"
:min="0"
:max="bigScorePerSub"
:step="0.25"
:precision="2"
size="small"
controls-position="right"
/>
<span class="big-answer-score-max">/ {{ bigScorePerSub }} 分</span>
</div>
</div>
<div class="big-answer-total">
本大题合计:
<strong>{{ fmtScore(bigScoreTotal) }}</strong> 分(满分 {{ bigScorePerSub * bigSubCount }} 分)
<span class="big-answer-tip">保存后将写入该考生「作品评分」,总分 = 客观题得分 + 作品评分</span>
</div>
</template>
<el-alert v-else type="info" :closable="false" show-icon title="该考生尚未抽取综合大题(可能未进入考试)" />
</template>
<template #footer>
<el-button @click="bigAnswerDialogVisible = false">关闭</el-button>
<el-button
v-if="bigAnswerRow.drawn"
type="primary"
:loading="bigScoreSaving"
v-guard="() => saveBigScores()"
>保存评分</el-button>
</template>
</el-dialog>
<!-- 考生答题记录 -->
<el-divider />
<h3 class="section-subheading">考生答题记录</h3>
<div class="toolbar">
<el-input v-model="examSubsSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onExamSubsFilterChange" />
<el-date-picker
v-model="examSubsDateRange"
type="daterange"
range-separator="至"
start-placeholder="提交开始日期"
end-placeholder="提交结束日期"
align="right"
size="small"
class="date-range"
@change="onExamSubsFilterChange"
/>
<el-button text type="primary" v-guard="() => fetchExamSubmissions()" :loading="examSubmissionsLoading">刷新</el-button>
</div>
<el-table :data="pagedExamSubmissions" stripe v-loading="examSubmissionsLoading" class="admin-table">
<el-table-column prop="userName" label="选手姓名" width="120" />
<el-table-column label="提交状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.submitted ? 'success' : 'info'" size="small">
{{ row.submitted ? '已提交' : '未提交' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="答题情况" min-width="220" align="center">
<template #default="{ row }">
<span>已答 {{ row.answeredCount || 0 }} / {{ row.totalQuestions || 0 }} 题</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">客观分:{{ row.examScore ?? 0 }}</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">大题分:{{ row.wordScore ?? 0 }}</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">总分:{{ row.totalScore ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button text type="warning" size="small" @click="openExamScoreDialog(row)">评分</el-button>
</template>
</el-table-column>
<el-table-column prop="submitTime" label="提交时间" width="160" align="center">
<template #default="{ row }">{{ row.submitTime ? fmtDate(row.submitTime) : '--' }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="filteredExamSubmissions.length > 0">
<el-pagination
v-model:current-page="examSubmissionsPage"
v-model:page-size="examSubmissionsPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredExamSubmissions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<!-- 考生大题评分弹窗(客观分只读,大题得分可改) -->
<el-dialog v-model="examScoreDialogVisible" title="评分" width="460px">
<el-form label-width="100px" v-if="examScoreDialogRow">
<el-form-item label="选手">{{ examScoreDialogRow.userName }}</el-form-item>
<el-form-item label="客观分(卷面)">
<span>{{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改)</span>
</el-form-item>
<el-form-item label="大题分">
<el-input-number v-model="examScoreDialogWord" :min="0" :max="100" :step="0.25" :precision="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="examScoreDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="examScoreSaving" v-guard="() => saveExamScore()">保存评分</el-button>
</template>
</el-dialog>
</template> </template>
<!-- ========== 备赛资料管理 ========== --> <!-- ========== 备赛资料管理 ========== -->
@@ -816,7 +567,12 @@
<el-option label="教师组" value="jiaoshi" /> <el-option label="教师组" value="jiaoshi" />
</el-select> </el-select>
<el-input v-model="submissionSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onSubmissionFilterChange" /> <el-input v-model="submissionSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onSubmissionFilterChange" />
<el-button type="success" v-guard="() => batchDownloadWorks()">批量下载作品</el-button> <!-- R29:中职/高职组已取消 Word 作品上传 ⇒ 这两组不显示「批量下载作品」 -->
<el-button
v-if="submissionGroup === 'benke' || submissionGroup === 'jiaoshi'"
type="success"
v-guard="() => batchDownloadWorks()"
>批量下载作品</el-button>
</div> </div>
<el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table"> <el-table :data="pagedSubmissions" stripe v-loading="submissionsLoading" class="admin-table">
<el-table-column prop="name" label="选手姓名" width="120" /> <el-table-column prop="name" label="选手姓名" width="120" />
@@ -946,8 +702,15 @@
<el-button :type="scoresPublishedMap[scoreExportGroup] ? 'warning' : 'success'" v-guard="() => togglePublish()" :disabled="!scoreExportGroup"> <el-button :type="scoresPublishedMap[scoreExportGroup] ? 'warning' : 'success'" v-guard="() => togglePublish()" :disabled="!scoreExportGroup">
{{ scoresPublishedMap[scoreExportGroup] ? '撤回成绩' : '发布成绩' }} {{ scoresPublishedMap[scoreExportGroup] ? '撤回成绩' : '发布成绩' }}
</el-button> </el-button>
<el-button v-guard="() => exportCsv()" :disabled="!scoreExportGroup">导出CSV</el-button> <!-- R29:由「导出CSV」改为直接导出真正的 Excel(.xlsx) -->
<el-button v-guard="() => batchDownload()" :disabled="!scoreExportGroup">批量下载作品</el-button> <el-button type="primary" plain v-guard="() => exportExcel()" :disabled="!scoreExportGroup">导出Excel</el-button>
<!-- R29:中职/高职组已取消 Word 作品上传 ⇒ 这两组不显示「批量下载作品」 -->
<el-button
v-if="!isExamScoreGroup"
type="success"
v-guard="() => batchDownload()"
:disabled="!scoreExportGroup"
>批量下载作品</el-button>
</div> </div>
<!-- 成绩明细表 --> <!-- 成绩明细表 -->
@@ -1028,6 +791,264 @@
{{ scoresPublishedMap[scoreExportGroup] ? '成绩已发布' : '成绩未发布' }} {{ scoresPublishedMap[scoreExportGroup] ? '成绩已发布' : '成绩未发布' }}
</template> </template>
</el-result> </el-result>
<!-- ========== R29:由「试卷管理」迁入(仅中职/高职有客观题与综合大题) ========== -->
<template v-if="isExamScoreGroup">
<!-- 考生答题记录 -->
<el-divider />
<h3 class="section-subheading">考生答题记录</h3>
<div class="toolbar">
<el-input v-model="examSubsSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onExamSubsFilterChange" />
<el-date-picker
v-model="examSubsDateRange"
type="daterange"
range-separator="至"
start-placeholder="提交开始日期"
end-placeholder="提交结束日期"
align="right"
size="small"
class="date-range"
@change="onExamSubsFilterChange"
/>
<el-button text type="primary" v-guard="() => fetchExamSubmissions()" :loading="examSubmissionsLoading">刷新</el-button>
</div>
<el-table :data="pagedExamSubmissions" stripe v-loading="examSubmissionsLoading" class="admin-table">
<el-table-column prop="userName" label="选手姓名" width="120" />
<el-table-column label="提交状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.submitted ? 'success' : 'info'" size="small">
{{ row.submitted ? '已提交' : '未提交' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="答题情况" min-width="220" align="center">
<template #default="{ row }">
<span>已答 {{ row.answeredCount || 0 }} / {{ row.totalQuestions || 0 }} 题</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">客观分:{{ row.examScore ?? 0 }}</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">大题分:{{ row.wordScore ?? 0 }}</span>
<span style="margin-left:10px;color:#1a3a5c;font-weight:600">总分:{{ row.totalScore ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button text type="warning" size="small" @click="openExamScoreDialog(row)">评分</el-button>
</template>
</el-table-column>
<el-table-column prop="submitTime" label="提交时间" width="160" align="center">
<template #default="{ row }">{{ row.submitTime ? fmtDate(row.submitTime) : '--' }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="filteredExamSubmissions.length > 0">
<el-pagination
v-model:current-page="examSubmissionsPage"
v-model:page-size="examSubmissionsPageSize"
:page-sizes="[10, 20, 50]"
:total="filteredExamSubmissions.length"
layout="total, sizes, prev, pager, next, jumper"
size="small"
/>
</div>
<!-- 考生大题评分弹窗(客观分只读,大题得分可改) -->
<!-- R29:标签改「左对齐 + 定宽 120px」,修复「客观分(卷面)」被折行成
「客观分(卷」/「面)」造成的文字错位(测试人员反馈)。 -->
<el-dialog v-model="examScoreDialogVisible" title="评分" width="520px">
<el-form
v-if="examScoreDialogRow"
class="exam-score-form"
label-width="120px"
label-position="left"
>
<el-form-item label="选手">{{ examScoreDialogRow.userName }}</el-form-item>
<el-form-item label="客观分(卷面)">
<span class="exam-score-readonly">{{ examScoreDialogRow.examScore ?? 0 }} 分(系统自动评分,不可修改)</span>
</el-form-item>
<el-form-item label="大题分">
<el-input-number v-model="examScoreDialogWord" :min="0" :max="100" :step="0.25" :precision="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="examScoreDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="examScoreSaving" v-guard="() => saveExamScore()">保存评分</el-button>
</template>
</el-dialog>
<!-- ========== 考生大题作答查证 ========== -->
<el-divider />
<h3 class="section-subheading">考生大题作答查证</h3>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom:12px"
title="用于核对考生实际抽到的题目与其提交的答案,处理考生纠纷。题干为该考生抽题时刻的快照,管理员后续修改题库不会影响此处显示。"
/>
<div class="toolbar">
<el-input v-model="bigAnswerSearch" placeholder="搜索考生姓名 / 身份证号" clearable size="small" class="search-input" />
<el-select v-model="bigAnswerFilter" size="small" class="filter-select">
<el-option label="全部考生" value="all" />
<el-option label="已抽题" value="drawn" />
<el-option label="未抽题" value="undrawn" />
</el-select>
<el-button size="small" type="info" plain :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
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" />
<el-table-column label="抽题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.drawn ? 'success' : 'info'" size="small">{{ row.drawn ? '已抽题' : '未抽题' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="抽到大题" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.questionSnapshot">{{ row.questionSnapshot.title }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="已答小问" width="100" align="center">
<template #default="{ row }">
<span v-if="row.drawn">{{ row.answeredCount }} / {{ bigSubCount }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="大题得分" width="110" align="center">
<template #default="{ row }">
<span v-if="row.bigScore !== null && row.bigScore !== undefined">{{ fmtScore(row.bigScore) }} 分</span>
<span v-else style="color:#909399">未评分</span>
</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
<template #default="{ row }">
<span v-if="row.submitTime">{{ formatDateTime(row.submitTime) }}</span>
<span v-else style="color:#909399">未提交</span>
</template>
</el-table-column>
<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>
<el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" />
<!-- 综合大题:作答详情 + 评分弹窗 -->
<el-dialog
v-model="bigAnswerDialogVisible"
:title="'大题作答详情 — ' + (bigAnswerRow.name || '')"
width="880px"
top="5vh"
>
<template v-if="bigAnswerRow.contestantId">
<el-descriptions :column="3" border size="small" class="big-answer-meta">
<el-descriptions-item label="姓名">{{ bigAnswerRow.name }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ bigAnswerRow.idNumber }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ bigAnswerRow.phone }}</el-descriptions-item>
<el-descriptions-item label="抽题状态">{{ bigAnswerRow.drawn ? '已抽题' : '未抽题' }}</el-descriptions-item>
<el-descriptions-item label="抽到题号">{{ bigAnswerRow.bigQuestionUid || '--' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">
{{ bigAnswerRow.submitTime ? formatDateTime(bigAnswerRow.submitTime) : '未提交' }}
</el-descriptions-item>
</el-descriptions>
<template v-if="bigAnswerRow.drawn">
<h4 class="big-answer-h4">考生抽到的题目(抽题时刻快照,防纠纷)</h4>
<div class="big-snapshot">
<div class="big-snapshot-title">
{{ bigAnswerRow.questionSnapshot && bigAnswerRow.questionSnapshot.title }}
</div>
<div
class="big-preview-body"
v-html="previewHtml(bigAnswerRow.questionSnapshot && bigAnswerRow.questionSnapshot.description)"
></div>
</div>
<h4 class="big-answer-h4">考生提交的答案与评分</h4>
<div v-for="i in bigSubCount" :key="i" class="big-answer-sub">
<div class="big-answer-sub-head">
<span>第 {{ i }} 小问</span>
<span class="big-answer-chars">
{{ bigAnswerTextOf(i).length }} 字
</span>
</div>
<div v-if="bigSubPromptsOfRow[i - 1]" class="big-answer-prompt">
{{ bigSubPromptsOfRow[i - 1] }}
</div>
<div class="big-answer-text">
<span v-if="bigAnswerTextOf(i).trim()">{{ bigAnswerTextOf(i) }}</span>
<span v-else class="big-answer-empty">(未作答)</span>
</div>
<div class="big-answer-score">
<span class="big-answer-score-label">得分:</span>
<el-input-number
v-model="bigScoreForm[`score_${i}`]"
:min="0"
:max="bigScorePerSub"
:step="0.25"
:precision="2"
size="small"
controls-position="right"
/>
<span class="big-answer-score-max">/ {{ bigScorePerSub }} 分</span>
</div>
</div>
<div class="big-answer-total">
本大题合计:
<strong>{{ fmtScore(bigScoreTotal) }}</strong> 分(满分 {{ bigScorePerSub * bigSubCount }} 分)
<span class="big-answer-tip">保存后将写入该考生「作品评分」,总分 = 客观题得分 + 作品评分</span>
</div>
</template>
<el-alert v-else type="info" :closable="false" show-icon title="该考生尚未抽取综合大题(可能未进入考试)" />
</template>
<template #footer>
<el-button @click="bigAnswerDialogVisible = false">关闭</el-button>
<el-button
v-if="bigAnswerRow.drawn"
type="primary"
:loading="bigScoreSaving"
v-guard="() => saveBigScores()"
>保存评分</el-button>
</template>
</el-dialog>
</template>
</template> </template>
<!-- ========== 成绩复核 ========== --> <!-- ========== 成绩复核 ========== -->
@@ -1302,7 +1323,7 @@
@change="onNewsFilterChange" @change="onNewsFilterChange"
/> />
<el-button type="primary" @click="openNewsDialog()">添加新闻</el-button> <el-button type="primary" @click="openNewsDialog()">添加新闻</el-button>
<el-button v-guard="() => saveNewsOrder()" :disabled="adminNews.length < 2">保存排序</el-button> <el-button type="primary" plain v-guard="() => saveNewsOrder()" :disabled="adminNews.length < 2">保存排序</el-button>
</div> </div>
<div v-loading="adminNewsLoading" class="news-card-list" ref="newsCardListRef"> <div v-loading="adminNewsLoading" class="news-card-list" ref="newsCardListRef">
<div <div
@@ -1392,7 +1413,7 @@
<h2 class="section-heading">轮播管理</h2> <h2 class="section-heading">轮播管理</h2>
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button> <el-button type="primary" @click="openCarouselDialog()">添加轮播</el-button>
<el-button v-guard="() => saveCarouselOrder()" :disabled="adminCarousels.length < 2">保存排序</el-button> <el-button type="primary" plain v-guard="() => saveCarouselOrder()" :disabled="adminCarousels.length < 2">保存排序</el-button>
<span class="drag-hint" v-if="adminCarousels.length > 1"> <span class="drag-hint" v-if="adminCarousels.length > 1">
<el-icon><Rank /></el-icon> 拖拽卡片可调整排序 <el-icon><Rank /></el-icon> 拖拽卡片可调整排序
</span> </span>
@@ -1563,13 +1584,18 @@ function onMenuSelect(index) {
if (index === 'dashboard') fetchStats() if (index === 'dashboard') fetchStats()
if (index === 'accounts') fetchAccounts() if (index === 'accounts') fetchAccounts()
if (index === 'questions' && questionGroup.value) { if (index === 'questions' && questionGroup.value) {
// 单一刷新入口:题库 + 考生答题记录 +(大题库 / 大题作答) // R29:试卷管理只刷题库(含大题库);「考生答题记录 / 大题作答查证」已迁到成绩管理
refreshQuestionData(true) refreshQuestionData(true)
} }
if (index === 'materials') fetchAdminMaterials() if (index === 'materials') fetchAdminMaterials()
if (index === 'submissions' && submissionGroup.value) fetchSubmissions() if (index === 'submissions' && submissionGroup.value) fetchSubmissions()
if (index === 'times') fetchTimes() if (index === 'times') fetchTimes()
if (index === 'scores') { fetchPublishStatus(); fetchScoreList() } if (index === 'scores') {
fetchPublishStatus()
fetchScoreList()
// R29:成绩管理还承载两个由试卷管理迁入的板块(仅中职/高职)
refreshExamCtxData(true)
}
if (index === 'news') fetchAdminNews() if (index === 'news') fetchAdminNews()
if (index === 'carousel') fetchCarousels() if (index === 'carousel') fetchCarousels()
} }
@@ -2056,7 +2082,8 @@ function onBigAnswerSelectionChange(rows) {
* ⚠️ 与同页「导出选中/全部 Word」(exportBigQuestions,导出的是**题库题目**)是两个功能,勿混。 * ⚠️ 与同页「导出选中/全部 Word」(exportBigQuestions,导出的是**题库题目**)是两个功能,勿混。
*/ */
async function exportBigAnswers(ids) { async function exportBigAnswers(ids) {
const g = questionGroup.value // R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页
const g = scoreExportGroup.value
const groupLabel = g === 'zhongzhi' ? '中职组' : '高职组' const groupLabel = g === 'zhongzhi' ? '中职组' : '高职组'
const isAll = !Array.isArray(ids) const isAll = !Array.isArray(ids)
// 兜底:剔除非本组 / 未抽题的残留 ID(与 exportBigQuestions 同源处理)。 // 兜底:剔除非本组 / 未抽题的残留 ID(与 exportBigQuestions 同源处理)。
@@ -2086,20 +2113,21 @@ async function exportBigAnswers(ids) {
} }
async function fetchBigAnswers() { async function fetchBigAnswers() {
// 同 fetchBigQuestions:固定发起时的组别,防止切组竞态把别组数据写回 // R29:本板块已由「试卷管理」迁到「成绩管理」⇒ 组别跟随成绩页的 scoreExportGroup(中职/高职)。
const g = questionGroup.value // 仍固定发起时的组别,防止切组竞态把别组数据写回。
const g = scoreExportGroup.value
bigAnswersLoading.value = true bigAnswersLoading.value = true
try { try {
const data = await adminApi.getBigAnswers(g) const data = await adminApi.getBigAnswers(g)
const result = data?.data || data const result = data?.data || data
if (questionGroup.value !== g) return if (scoreExportGroup.value !== g) return
bigAnswers.value = result?.list || [] bigAnswers.value = result?.list || []
bigAnswersTotal.value = Number(result?.total) || bigAnswers.value.length bigAnswersTotal.value = Number(result?.total) || bigAnswers.value.length
bigAnswersDrawn.value = Number(result?.drawnCount) || 0 bigAnswersDrawn.value = Number(result?.drawnCount) || 0
if (Number(result?.subCount) > 0) bigSubCount.value = Number(result.subCount) if (Number(result?.subCount) > 0) bigSubCount.value = Number(result.subCount)
} catch (e) { } catch (e) {
console.warn('获取大题作答失败', e) console.warn('获取大题作答失败', e)
if (questionGroup.value !== g) return if (scoreExportGroup.value !== g) return
bigAnswers.value = [] bigAnswers.value = []
bigAnswersTotal.value = 0 bigAnswersTotal.value = 0
bigAnswersDrawn.value = 0 bigAnswersDrawn.value = 0
@@ -2118,26 +2146,46 @@ async function fetchBigAnswers() {
*/ */
const bigDataGroup = ref('') const bigDataGroup = ref('')
/** 保证大题库 + 大题作答与当前组别一致(同组且非强制时不重复请求) */ /** 保证大题库与当前组别一致(同组且非强制时不重复请求) */
async function ensureBigData(force = false) { async function ensureBigData(force = false) {
const g = questionGroup.value const g = questionGroup.value
if (!force && bigDataGroup.value === g && bigQuestions.value.length > 0) return if (!force && bigDataGroup.value === g && bigQuestions.value.length > 0) return
await Promise.allSettled([fetchBigQuestions(), fetchBigAnswers()]) await fetchBigQuestions()
if (questionGroup.value === g) bigDataGroup.value = g if (questionGroup.value === g) bigDataGroup.value = g
} }
/** /**
* 刷新「题目管理」菜单下与组别相关的全部数据(2026-09-23)。 * 刷新「试卷管理」菜单下的数据(R29 收敛)。
* *
* 原先各入口各刷一半(切组只刷题库、评分只刷大题作答),导致另一处仍是旧值, * ⚠️ R29 起「考生答题记录 / 大题作答查证」已迁到「成绩管理」,故此处**只刷题库**
* 测试人员只能手动刷新整页。此处收敛为**唯一刷新入口**,避免再出现遗漏。 * (含大题库);那两块改由 refreshExamCtxData 负责 —— 原来一个入口刷三块,
* 迁走后若仍在这里刷,就会用**试卷页的组别**去拉**成绩页的板块**,必然串组。
*/ */
async function refreshQuestionData(force = false) { async function refreshQuestionData(force = false) {
await Promise.allSettled([ await Promise.allSettled([fetchQuestions(), ensureBigData(force)])
fetchQuestions(), }
fetchExamSubmissions(),
ensureBigData(force), /** 「考生答题记录 / 大题作答查证」数据当前所属组别(用于避免重复拉取) */
]) const examCtxDataGroup = ref('')
/**
* 刷新成绩管理页内两个由试卷管理迁入的板块(R29)。
* 仅中职/高职有客观题与综合大题;本科/教师是 Word/PPT 作品,无此二块 ⇒ 直接跳过。
*/
async function refreshExamCtxData(force = false) {
const g = scoreExportGroup.value
if (g !== 'zhongzhi' && g !== 'gaozhi') return
if (!force && examCtxDataGroup.value === g) return
await Promise.allSettled([fetchExamSubmissions(), fetchBigAnswers()])
if (scoreExportGroup.value === g) examCtxDataGroup.value = g
}
/**
* 刷新「成绩管理」页全部数据(R29 收敛):成绩明细 + 两个迁入板块。
* 评分保存后必须走这里,否则明细表与答题记录会出现「一处已更新、一处还是旧分」。
*/
async function refreshScorePageData(force = false) {
await Promise.allSettled([fetchScoreList(), refreshExamCtxData(force)])
} }
/** ==================== 大题导出 Word(2026-09-23) ==================== */ /** ==================== 大题导出 Word(2026-09-23) ==================== */
@@ -2225,16 +2273,17 @@ async function saveBigScores() {
} }
bigScoreSaving.value = true bigScoreSaving.value = true
try { try {
const res = await adminApi.setBigAnswerScores(questionGroup.value, row.aggregationUid, { // R29:组别跟随「成绩管理」页(本弹窗已随板块迁入成绩管理)
const res = await adminApi.setBigAnswerScores(scoreExportGroup.value, row.aggregationUid, {
score_1: Number(bigScoreForm.score_1) || 0, score_1: Number(bigScoreForm.score_1) || 0,
score_2: Number(bigScoreForm.score_2) || 0, score_2: Number(bigScoreForm.score_2) || 0,
score_3: Number(bigScoreForm.score_3) || 0, score_3: Number(bigScoreForm.score_3) || 0,
}) })
ElMessage.success(`评分已保存(大题合计 ${res?.total ?? bigScoreTotal.value} 分)`) ElMessage.success(`评分已保存(大题合计 ${res?.total ?? bigScoreTotal.value} 分)`)
bigAnswerDialogVisible.value = false bigAnswerDialogVisible.value = false
// 一次刷新全部相关列表:大题作答(含大题得分列)+ 考生答题记录(卷面分/大题分)。 // 一次刷新全部相关列表:成绩明细(总分 = 客观分 + 大题分)+ 大题作答 + 考生答题记录。
// 原实现只调 fetchBigAnswers(),切到「考生答题记录」仍是旧分数 ⇒ 测试人员只能刷整页。 // 原实现只调 fetchBigAnswers(),测试人员看到另一处仍是旧分只能刷整页。
await refreshQuestionData(true) await refreshScorePageData(true)
} catch (e) { } catch (e) {
ElMessage.error(e?.message || '评分保存失败') ElMessage.error(e?.message || '评分保存失败')
} finally { } finally {
@@ -2453,9 +2502,10 @@ const pagedExamSubmissions = computed(() => {
}) })
async function fetchExamSubmissions() { async function fetchExamSubmissions() {
// R29:本板块已迁入「成绩管理」⇒ 组别跟随成绩页
examSubmissionsLoading.value = true examSubmissionsLoading.value = true
try { try {
const data = await adminApi.getSubmissions(questionGroup.value) const data = await adminApi.getSubmissions(scoreExportGroup.value)
examSubmissions.value = Array.isArray(data) ? data : (data?.list || []) examSubmissions.value = Array.isArray(data) ? data : (data?.list || [])
} catch (e) { } catch (e) {
console.warn('获取答题记录失败', e) console.warn('获取答题记录失败', e)
@@ -2481,7 +2531,8 @@ async function saveExamScore() {
if (!examScoreDialogRow.value) return if (!examScoreDialogRow.value) return
examScoreSaving.value = true examScoreSaving.value = true
try { try {
await adminApi.setScores(questionGroup.value, { // R29:组别跟随「成绩管理」页
await adminApi.setScores(scoreExportGroup.value, {
scores: [{ scores: [{
contestantId: examScoreDialogRow.value.contestantId, contestantId: examScoreDialogRow.value.contestantId,
// 客观分回传弹窗所见值,避免后端按答题记录重算导致与所见不一致 // 客观分回传弹窗所见值,避免后端按答题记录重算导致与所见不一致
@@ -2491,8 +2542,8 @@ async function saveExamScore() {
}) })
ElMessage.success('评分已保存') ElMessage.success('评分已保存')
examScoreDialogVisible.value = false examScoreDialogVisible.value = false
// 同上:客观分 / 大题分同时影响两张表,必须一起刷新(原仅刷考生答题记录) // 客观分 / 大题分同时影响成绩明细表与考生答题记录,必须一起刷新
await refreshQuestionData(true) await refreshScorePageData(true)
} catch (e) { ElMessage.error('保存失败') } finally { examScoreSaving.value = false } } catch (e) { ElMessage.error('保存失败') } finally { examScoreSaving.value = false }
} }
@@ -2503,6 +2554,12 @@ watch(questionGroup, () => {
if (activeMenu.value === 'questions') refreshQuestionData(true) if (activeMenu.value === 'questions') refreshQuestionData(true)
}) })
// R29:成绩管理页切组 → 刷新成绩明细 + 两个由试卷管理迁入的板块
// (这两个板块的组别跟随 scoreExportGroup,故其刷新必须挂在这里)
watch(scoreExportGroup, () => {
if (activeMenu.value === 'scores') refreshExamCtxData(true)
})
// ==================== 3.5 备赛资料管理 ==================== // ==================== 3.5 备赛资料管理 ====================
const materialsGroup = ref('benke') const materialsGroup = ref('benke')
const adminMaterials = ref([]) const adminMaterials = ref([])
@@ -2691,7 +2748,8 @@ async function batchSaveWorkScores() {
ElMessage.success('评分保存成功') ElMessage.success('评分保存成功')
// 保存后立即刷新作品列表 + 考生答题记录,保证两处分数同步可见(评分不同步修复) // 保存后立即刷新作品列表 + 考生答题记录,保证两处分数同步可见(评分不同步修复)
await fetchSubmissions() await fetchSubmissions()
if (submissionGroup.value === questionGroup.value) fetchExamSubmissions() // R29:「考生答题记录」的组别已改跟随成绩管理页(scoreExportGroup)
if (submissionGroup.value === scoreExportGroup.value) fetchExamSubmissions()
} catch (e) { ElMessage.error('保存失败') } } catch (e) { ElMessage.error('保存失败') }
} }
@@ -2750,6 +2808,12 @@ async function saveTimes() {
// ==================== 6. 成绩管理 ==================== // ==================== 6. 成绩管理 ====================
const scoresPublishedMap = reactive({}) const scoresPublishedMap = reactive({})
const scoreExportGroup = ref('') const scoreExportGroup = ref('')
/**
* 是否为「考试组」(中职 / 高职)——R29 由「试卷管理」迁入的两个板块
* (考生答题记录 / 考生大题作答查证)只对这两组有意义:
* 本科 / 教师组提交的是 Word / PPT 作品,没有客观题与综合大题数据。
*/
const isExamScoreGroup = computed(() => scoreExportGroup.value === 'zhongzhi' || scoreExportGroup.value === 'gaozhi')
const scoreList = ref([]) const scoreList = ref([])
const scoreListLoading = ref(false) const scoreListLoading = ref(false)
const scoreListPage = ref(1) const scoreListPage = ref(1)
@@ -2835,10 +2899,11 @@ async function togglePublish() {
} catch (e) { ElMessage.error('操作失败') } } catch (e) { ElMessage.error('操作失败') }
} }
async function exportCsv() { async function exportExcel() {
// safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .csv // R29:由 CSV 改为直接导出 .xlsx(CSV 在 Excel 中中文易乱码、身份证/手机号变科学计数法)
await safeDownload(`/api/admin/export-csv/${scoreExportGroup.value}`, { // safeDownload 自带「HTTP 状态 + content-type」双重校验,避免把错误 JSON 存成 .xlsx
fallbackName: `${scoreExportGroup.value}_scores.csv`, await safeDownload(`/api/admin/export-excel/${scoreExportGroup.value}`, {
fallbackName: `${scoreExportGroup.value}_scores.xlsx`,
}) })
} }
@@ -3477,6 +3542,15 @@ onMounted(() => {
align-items: center; align-items: center;
} }
/* R29:评分弹窗标签左对齐且禁止折行(「客观分(卷面)」曾折行成两行导致错位) */
.exam-score-form .el-form-item__label {
text-align: left;
white-space: nowrap;
}
.exam-score-form .exam-score-readonly {
color: #606266;
}
.search-input { width: 220px; } .search-input { width: 220px; }
.filter-select { width: 140px; } .filter-select { width: 140px; }