feat(admin): 成绩管理 Tab 分区 + 作品管理移除中职/高职 + 分页最小档补 5

R80 三项 UI 改造(单文件 src/views/Admin.vue,+78/-37):

1. 作品管理:移除「中职组 / 高职组」两个选项 —— 这两组已取消 Word 作品上传,
   留着会让人误以为可以下载到作品。顺带清掉因此变成恒真的死判据
   v-if="submissionGroup === 'benke' || submissionGroup === 'jiaoshi'"(批量下载作品按钮)。
   后端 getWorks 仍支持 4 组,保留兼容不删。

2. 分页:9 处 page-sizes 统一补最小 5 档([5,10,20,50];账号管理 [5,10,20,50,100])。
   试卷管理 3 处原本已是 5 档,无需改。动因:成绩管理表体量大、翻页粒度需要更细。

3. 成绩管理:由「一段纵向长页」改为「顶栏 + 三页签」——
   - 顶栏只留组别级动作:组别选择 / 发布·撤回成绩 / 导出Excel / 批量下载作品
   - 表级筛选(姓名 / 提交状态 / 日期范围)下沉到「成绩总览」页签,
     否则切到别的页签时筛选框仍在,会造成「筛的是哪张表」的歧义
   - el-tabs:成绩总览(始终)/ 考生答题记录(仅中职、高职)/ 考生大题作答查证(同上)
   - 大题作答详情 el-dialog 放在 el-tabs 之外:留在页签内会随 v-if 卸载而销毁弹窗状态

   🔴 唯一高危边界:activeScoreTab 停在后两个页签时切到本科/教师,两个页签 v-if 消失
   ⇒ el-tabs 无 active 内容 ⇒ 页签栏在、内容区全空(白屏)。治法:在
   watch(scoreExportGroup) 内复位为 'overview' —— 用 watch 而非 onScoreGroupChange,
   因为 watch 默认 pre-flush 会在组件重渲染之前执行,不会先闪一帧空白。

验证:verify_frontend_tdz PASS(activeScoreTab 声明顺序,本页历史真炸过的坑)·
@vue/compiler-sfc parse/compileTemplate 0 错 · ui-runtime-check 真 chromium 17 步 17/17 PASS。
变异自证 2 轮:恢复中职/高职选项 ⇒ expectNoText 2 项变红;拆掉切组复位 ⇒ 切本科组后
expectText("提交状态") 命中 0 处变红。
This commit is contained in:
2026-10-08 15:19:50 +08:00
parent 4e84257ed8
commit 680faacd1d

View File

@@ -187,7 +187,7 @@
<el-pagination <el-pagination
v-model:current-page="accountsPage" v-model:current-page="accountsPage"
v-model:page-size="accountsPageSize" v-model:page-size="accountsPageSize"
:page-sizes="[10, 20, 50, 100]" :page-sizes="[5, 10, 20, 50, 100]"
:total="accountsTotal" :total="accountsTotal"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
size="small" size="small"
@@ -562,7 +562,7 @@
<el-pagination <el-pagination
v-model:current-page="materialsPage" v-model:current-page="materialsPage"
v-model:page-size="materialsPageSize" v-model:page-size="materialsPageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[5, 10, 20, 50]"
:total="filteredAdminMaterials.length" :total="filteredAdminMaterials.length"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
size="small" size="small"
@@ -575,16 +575,14 @@
<template v-if="activeMenu === 'submissions'"> <template v-if="activeMenu === 'submissions'">
<h2 class="section-heading">作品管理</h2> <h2 class="section-heading">作品管理</h2>
<div class="toolbar"> <div class="toolbar">
<!-- R80:中职组 / 高职组已取消 Word 作品上传 ⇒ 作品管理不再提供这两个选项 -->
<el-select v-model="submissionGroup" placeholder="选择组别" class="filter-select" @change="onSubmissionGroupChange"> <el-select v-model="submissionGroup" placeholder="选择组别" class="filter-select" @change="onSubmissionGroupChange">
<el-option label="中职组" value="zhongzhi" />
<el-option label="高职组" value="gaozhi" />
<el-option label="本科组" value="benke" /> <el-option label="本科组" value="benke" />
<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" />
<!-- R29:中职/高职组已取消 Word 作品上传 ⇒ 这两组不显示「批量下载作品」 --> <!-- R80:组别只剩本科/教师(两组都需下载作品)⇒ 原「仅 benke/jiaoshi 显示」判据已恒真,移除死判据 -->
<el-button <el-button
v-if="submissionGroup === 'benke' || submissionGroup === 'jiaoshi'"
type="success" type="success"
v-guard="() => batchDownloadWorks()" v-guard="() => batchDownloadWorks()"
>批量下载作品</el-button> >批量下载作品</el-button>
@@ -613,7 +611,7 @@
<el-pagination <el-pagination
v-model:current-page="submissionsPage" v-model:current-page="submissionsPage"
v-model:page-size="submissionsPageSize" v-model:page-size="submissionsPageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[5, 10, 20, 50]"
:total="filteredSubmissions.length" :total="filteredSubmissions.length"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
size="small" size="small"
@@ -691,6 +689,10 @@
<!-- ========== 成绩管理 ========== --> <!-- ========== 成绩管理 ========== -->
<template v-if="activeMenu === 'scores'"> <template v-if="activeMenu === 'scores'">
<h2 class="section-heading">成绩管理</h2> <h2 class="section-heading">成绩管理</h2>
<!-- R80:顶栏只保留「组别级」动作(组别 / 发布 / 导出 / 批量下载)。
表级筛选(姓名 / 提交状态 / 日期)随表格下沉到「成绩总览」页签 ——
否则切到别的页签时这些筛选框仍然在,会造成「筛的是哪张表」的歧义。 -->
<div class="toolbar"> <div class="toolbar">
<el-select v-model="scoreExportGroup" placeholder="选择组别" class="filter-select" @change="onScoreGroupChange"> <el-select v-model="scoreExportGroup" placeholder="选择组别" class="filter-select" @change="onScoreGroupChange">
<el-option label="中职组" value="zhongzhi" /> <el-option label="中职组" value="zhongzhi" />
@@ -698,6 +700,37 @@
<el-option label="本科组" value="benke" /> <el-option label="本科组" value="benke" />
<el-option label="教师组" value="jiaoshi" /> <el-option label="教师组" value="jiaoshi" />
</el-select> </el-select>
<el-button :type="scoresPublishedMap[scoreExportGroup] ? 'warning' : 'success'" v-guard="() => togglePublish()" :disabled="!scoreExportGroup">
{{ scoresPublishedMap[scoreExportGroup] ? '撤回成绩' : '发布成绩' }}
</el-button>
<!-- R29:由「导出CSV」改为直接导出真正的 Excel(.xlsx) -->
<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>
<!-- R80:功能分区页签(交互口径对齐「试卷管理」的题型 Tab ——
el-tabs 只声明 label / name,各区块内容自己用 v-if 控制渲染)。
后两个页签仅中职 / 高职有意义:只有这两组才有客观题与综合大题;
本科 / 教师提交的是 Word/PPT 作品,故按组别隐藏。
🔴 切到本科 / 教师时必须把 activeScoreTab 复位为 overview,
否则 v-model 指向一个已不存在的页签 ⇒ 页签栏在、内容区全空(白屏)。
⚠️ 以下三个分区 <template> 仅作外层包裹,内部区块缩进保持原样,
以免 300 行缩进重排制造 diff 噪声。 -->
<el-tabs v-model="activeScoreTab">
<el-tab-pane label="成绩总览" name="overview" />
<el-tab-pane v-if="isExamScoreGroup" label="考生答题记录" name="examSubmissions" />
<el-tab-pane v-if="isExamScoreGroup" label="考生大题作答查证" name="bigAnswers" />
</el-tabs>
<!-- ① 成绩总览 -->
<template v-if="activeScoreTab === 'overview'">
<div class="toolbar">
<el-input v-model="scoreSearch" placeholder="搜索姓名/身份证号" clearable size="small" class="search-input" @input="onScoreFilterChange" /> <el-input v-model="scoreSearch" placeholder="搜索姓名/身份证号" clearable size="small" class="search-input" @input="onScoreFilterChange" />
<el-select v-model="scoreStatusFilter" placeholder="提交状态" clearable size="small" class="filter-select" @change="onScoreFilterChange"> <el-select v-model="scoreStatusFilter" placeholder="提交状态" clearable size="small" class="filter-select" @change="onScoreFilterChange">
<el-option label="已提交" value="true" /> <el-option label="已提交" value="true" />
@@ -714,18 +747,6 @@
class="date-range" class="date-range"
@change="onScoreFilterChange" @change="onScoreFilterChange"
/> />
<el-button :type="scoresPublishedMap[scoreExportGroup] ? 'warning' : 'success'" v-guard="() => togglePublish()" :disabled="!scoreExportGroup">
{{ scoresPublishedMap[scoreExportGroup] ? '撤回成绩' : '发布成绩' }}
</el-button>
<!-- R29:由「导出CSV」改为直接导出真正的 Excel(.xlsx) -->
<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>
<!-- 成绩明细表 --> <!-- 成绩明细表 -->
@@ -788,7 +809,7 @@
<el-pagination <el-pagination
v-model:current-page="scoreListPage" v-model:current-page="scoreListPage"
v-model:page-size="scoreListPageSize" v-model:page-size="scoreListPageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[5, 10, 20, 50]"
:total="filteredScoreList.length" :total="filteredScoreList.length"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
size="small" size="small"
@@ -806,11 +827,10 @@
{{ scoresPublishedMap[scoreExportGroup] ? '成绩已发布' : '成绩未发布' }} {{ scoresPublishedMap[scoreExportGroup] ? '成绩已发布' : '成绩未发布' }}
</template> </template>
</el-result> </el-result>
<!-- ========== R29:由「试卷管理」迁入(仅中职/高职有客观题与综合大题) ========== --> </template><!-- /① 成绩总览 -->
<template v-if="isExamScoreGroup">
<!-- 考生答题记录 --> <!-- ② 考生答题记录(R29 由「试卷管理」迁入;仅中职/高职有客观题数据) -->
<el-divider /> <template v-if="isExamScoreGroup && activeScoreTab === 'examSubmissions'">
<h3 class="section-subheading">考生答题记录</h3>
<div class="toolbar"> <div class="toolbar">
<el-input v-model="examSubsSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onExamSubsFilterChange" /> <el-input v-model="examSubsSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onExamSubsFilterChange" />
<el-date-picker <el-date-picker
@@ -851,23 +871,23 @@
<el-pagination <el-pagination
v-model:current-page="examSubmissionsPage" v-model:current-page="examSubmissionsPage"
v-model:page-size="examSubmissionsPageSize" v-model:page-size="examSubmissionsPageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[5, 10, 20, 50]"
:total="filteredExamSubmissions.length" :total="filteredExamSubmissions.length"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
size="small" size="small"
/> />
</div> </div>
</template><!-- /② 考生答题记录 -->
<!-- R30:原「考生答题记录 · 评分」弹窗已删除。 <!-- R30:原「考生答题记录 · 评分」弹窗已删除。
原因:大题评分统一在下方「考生大题作答查证」按小问给分 原因:大题评分统一在下方「考生大题作答查证」按小问给分
(PUT /api/admin/big-answers/:group/:uid/scores), (PUT /api/admin/big-answers/:group/:uid/scores),
避免同一份 word_score 存在两条入口、出现口径不一致。 避免同一份 word_score 存在两条入口、出现口径不一致。
⛔ 后端 PUT /api/admin/scores/:group 与 adminApi.setScores 均**保留**: ⛔ 后端 PUT /api/admin/scores/:group 与 adminApi.setScores 均**保留**:
前者被 10+ 个门禁脚本依赖,后者作品管理页(本科/教师)仍在使用。 --> 前者被 10+ 个门禁脚本依赖,后者作品管理页(本科/教师)仍在使用。 -->
<!-- ========== 考生大题作答查证 ========== --> <!-- ③ 考生大题作答查证(仅中职/高职) -->
<el-divider /> <template v-if="isExamScoreGroup && activeScoreTab === 'bigAnswers'">
<h3 class="section-subheading">考生大题作答查证</h3>
<el-alert <el-alert
type="info" type="info"
:closable="false" :closable="false"
@@ -967,8 +987,11 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" /> <el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" />
</template><!-- /③ 考生大题作答查证 -->
<!-- 综合大题:作答详情 + 评分弹窗 --> <!-- 综合大题:作答详情 + 评分弹窗。
R80:弹窗移到页签之外 —— 若留在页签内,切换页签会随 v-if 卸载而销毁弹窗状态。 -->
<template v-if="isExamScoreGroup">
<el-dialog <el-dialog
v-model="bigAnswerDialogVisible" v-model="bigAnswerDialogVisible"
:title="'大题作答详情 — ' + (bigAnswerRow.name || '')" :title="'大题作答详情 — ' + (bigAnswerRow.name || '')"
@@ -1355,7 +1378,7 @@
<el-pagination <el-pagination
v-model:current-page="adminNewsPage" v-model:current-page="adminNewsPage"
v-model:page-size="adminNewsPageSize" v-model:page-size="adminNewsPageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[5, 10, 20, 50]"
:total="filteredAdminNews.length" :total="filteredAdminNews.length"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
size="small" size="small"
@@ -2893,11 +2916,29 @@ const scoreExportGroup = ref('')
*/ */
const isExamScoreGroup = computed(() => scoreExportGroup.value === 'zhongzhi' || scoreExportGroup.value === 'gaozhi') const isExamScoreGroup = computed(() => scoreExportGroup.value === 'zhongzhi' || scoreExportGroup.value === 'gaozhi')
/**
* R80:成绩管理页「功能分区页签」当前项。
*
* 三个页签:overview(成绩总览,任何组别都有)/ examSubmissions(考生答题记录)/
* bigAnswers(考生大题作答查证)—— 后两个仅中职 / 高职存在。
*
* 🔴 TDZ:本 ref 必须声明在下方 watch() **之前**。watch() 是顶层语句、会立即执行
* (取值收集依赖),若引用其后声明的 const,setup 阶段会抛
* "Cannot access 'activeScoreTab' before initialization"。
* 验证:node .workbuddy/tools/verify_frontend_tdz.mjs frontend/src/views/Admin.vue
*/
const activeScoreTab = ref('overview')
// R29:成绩管理页切组 → 刷新成绩明细 + 两个由试卷管理迁入的板块 // R29:成绩管理页切组 → 刷新成绩明细 + 两个由试卷管理迁入的板块
// (这两个板块的组别跟随 scoreExportGroup,故其刷新必须挂在这里) // (这两个板块的组别跟随 scoreExportGroup,故其刷新必须挂在这里)
// 🔴 必须写在 `scoreExportGroup` 声明**之后**:watch() 会立即取值收集依赖, // 🔴 必须写在 `scoreExportGroup` 声明**之后**:watch() 会立即取值收集依赖,
// 前置引用会在 setup 阶段触发 TDZ(Cannot access 'scoreExportGroup' before initialization)。 // 前置引用会在 setup 阶段触发 TDZ(Cannot access 'scoreExportGroup' before initialization)。
watch(scoreExportGroup, () => { watch(scoreExportGroup, () => {
// R80:切到本科 / 教师时两个考试组专属页签会消失。若 activeScoreTab 仍停在
// 被隐藏的页签上,el-tabs 将没有任何 active 内容 ⇒ 页签栏在、内容区全空。
// 放在此处而非 onScoreGroupChange:watch 默认 pre-flush,会在组件重渲染**之前**
// 执行,因此不会先渲染一次空白再复位(无闪屏)。
if (!isExamScoreGroup.value) activeScoreTab.value = 'overview'
if (activeMenu.value === 'scores') refreshExamCtxData(true) if (activeMenu.value === 'scores') refreshExamCtxData(true)
}) })