feat(admin): R173/R174 大题成绩导入预览(三小问四列制)

- Admin.vue 预览弹窗:由「分数 + 均分到三小问」改为逐项展示第 1/2/3 小问原始填报值 + 合计列
  · 合计由三项求和实时显示,⛔ 表格不再出现均分口径
  · 新增 bigScoreSubCell(row, idx):非数值原样显示,便于定位"填错的是哪格"
  · 状态映射补 SUB_MISSING(小问留空)/ SUB_INVALID(小问非法)/ SUB_OUT_OF_RANGE(小问超上限)
  · 弹窗宽 920 → 1080(四列 + 合计 + 说明)
- src/api/admin.js:previewBigScoreImport 返回结构注释同步(subRaws / subs / total)
This commit is contained in:
2026-10-19 13:03:54 +08:00
parent 4e7dfe09c8
commit b7ec541223
2 changed files with 606 additions and 2 deletions

View File

@@ -84,6 +84,17 @@ export function setTimes(data) {
return request.put('/admin/times', { times })
}
/**
* 节点备份调度器状态(P1「生产备份静默失败」可见性)
*
* silent: true —— 本接口是「状态展示」而非「用户动作」,失败由页面内提示承载,
* 不希望拦截器再弹一次全局错误(同族双弹纪律)。
* @returns {Promise} { available, enabled, isProduction, buffer, keepDays, plans, history, artifacts, health, paths }
*/
export function getNodeBackupStatus() {
return request.get('/admin/node-backup/status', { silent: true })
}
/**
* 设置评分
* @param {string} group - 组别标识
@@ -477,6 +488,61 @@ export function setBigAnswerScores(group, uid, scores) {
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
}
/**
* 下载「综合大题成绩导入」模板(R172)
*
* 模板两页:`成绩导入`(小组编号 / 分数)+ `填写说明`。
* @param {string} group - 组别标识(zhongzhi / gaozhi)
* @returns {Promise}
*/
export function getBigScoreImportTemplate(group) {
return request.get(`/admin/big-answers/${group}/import-template`, {
responseType: 'blob',
timeout: 60000,
})
}
/**
* 预览成绩导入(R172 · ⛔ 零写入;R174 改三小问四列制)
*
* 服务端逐行判定:可导入 / 将覆盖 / 被拒绝(附原因),前端据此让管理员确认后再导入。
* @param {string} group
* @param {FormData} formData - 含 Excel 文件(字段名 file)
* @returns {Promise} {
* total, importable, overwritable, blocked, skippedBlank,
* rows: [{ row, teamNumber, subRaws, subs, total, status, message, blocking,
* contestantId, contestantName, contestantIdNumber, teamUid,
* memberCount, operatorCount, currentScores }]
* }
* · subRaws = 三小问原始填报值(报错行也有,用于定位)
* · subs = 校验通过后的三小问得分;total = 三者之和(判定通过时才有)
*/
export function previewBigScoreImport(group, formData) {
// silent: true —— 错误提示由调用方统一给出,避免拦截器 + 调用方双弹
return request.post(`/admin/big-answers/${group}/import-preview`, formData, {
timeout: 120000,
silent: true,
})
}
/**
* 执行成绩导入(R172)
*
* 写入仍走唯一评分路径(setSubScores → service.admin.updateScores),
* 不新增第二条写成绩通路。
* @param {string} group
* @param {FormData} formData - 含 Excel 文件(字段名 file)
* @param {boolean} [overwrite=false] - 是否覆盖已评分
* @returns {Promise} { imported, skipped, overwritten, errors: [{ row, teamNumber, message }] }
*/
export function importBigScores(group, formData, overwrite = false) {
return request.post(`/admin/big-answers/${group}/import-scores`, formData, {
params: { overwrite: overwrite ? 1 : 0 },
timeout: 300000,
silent: true,
})
}
// ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。

View File

@@ -752,6 +752,18 @@
<!-- ========== 时间设置 ========== -->
<template v-if="activeMenu === 'times'">
<h2 class="section-heading">时间设置</h2>
<!-- P1:节点备份异常必须在**进页第一屏**可见。
状态卡片在页面底部,正常时需要滚动才有意义;但异常时若也要滚动才看得到,
就退化成「没人看」——正是 P1 要解决的静默失败。故这里做一条汇总提示,
**只报「有几项异常 + 首条摘要」,明细仍只在底部卡片**(不重复细节)。 -->
<el-alert
v-if="backupHealthReasons.length"
:type="backupTopAlertType"
:title="backupTopAlertTitle"
:closable="false"
show-icon
class="backup-top-alert"
/>
<div v-loading="timesLoading">
<el-form :model="timesForm" label-position="top" class="times-form">
<el-row :gutter="20">
@@ -796,6 +808,106 @@
</el-form-item>
</el-form>
</div>
<!-- ========== 节点备份状态(P1:生产备份静默失败防护) ==========
为什么放在「时间设置」页:三节点备份的触发时刻完全由这里的考试时间
推导(开始前 30min / 结束后 10min),改完时间就地看到排程结果,
才不会出现「时间改了、备份计划没跟上」而无人察觉。
⛔ 本区块只读:刷新只发 GET,不触发备份、不排程。 -->
<el-card shadow="never" class="backup-card">
<template #header>
<div class="backup-head">
<span class="backup-title">节点备份状态</span>
<el-tag
v-if="backupStatus"
size="small"
:type="backupStatus.health && backupStatus.health.level === 'error' ? 'danger'
: backupStatus.health && backupStatus.health.level === 'warn' ? 'warning'
: backupStatus.enabled ? 'success' : 'info'"
>
{{ backupStatus.enabled ? '调度器已启用' : (backupStatus.isProduction ? '调度器未启用(异常)' : '非生产环境 · 不排程') }}
</el-tag>
<span class="backup-spacer" />
<el-button size="small" text :loading="backupStatusLoading" @click="fetchNodeBackupStatus">刷新</el-button>
</div>
</template>
<div v-loading="backupStatusLoading">
<p class="backup-hint">
考试开始前 <b>{{ backupBeforeStartMin }}</b> 分钟、考试结束后
<b>{{ backupAfterEndMin }}</b> 分钟、成绩发布瞬间,各自动备份一次生产库;
保留 {{ backupKeepDays }} 天。非生产环境不排程。
</p>
<!-- 健康判定:每一条都能对应一种「备份实际没做,界面上却看不出来」的故障形态 -->
<el-alert
v-for="r in backupHealthReasons"
:key="r.code"
:type="r.level === 'error' ? 'error' : 'warning'"
:title="r.message"
:closable="false"
show-icon
class="backup-alert"
/>
<template v-if="backupPlans.length">
<div class="backup-sub">待触发计划({{ backupPlans.length }})</div>
<el-table :data="backupPlans" size="small" border class="backup-table">
<el-table-column label="组别" width="100">
<template #default="{ row }">{{ backupGroupLabel(row.group) }}</template>
</el-table-column>
<el-table-column label="节点" width="130">
<template #default="{ row }">{{ backupNodeLabel(row.node) }}</template>
</el-table-column>
<el-table-column label="触发时刻" min-width="170">
<template #default="{ row }">{{ backupFmtTime(row.fireAt) }}</template>
</el-table-column>
<el-table-column label="倒计时" width="120">
<template #default="{ row }">{{ backupFmtRemain(row.remainingSec) }}</template>
</el-table-column>
</el-table>
</template>
<template v-if="backupHistory.length">
<div class="backup-sub">最近执行记录({{ backupHistory.length }})</div>
<el-table :data="backupHistory" size="small" border max-height="280" class="backup-table">
<el-table-column label="时间" width="165">
<template #default="{ row }">{{ backupFmtTime(row.ts) }}</template>
</el-table-column>
<el-table-column label="节点" width="120">
<template #default="{ row }">{{ backupNodeLabel(row.node) }}</template>
</el-table-column>
<el-table-column label="组别" width="90">
<template #default="{ row }">{{ backupGroupLabel(row.group) }}</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="{ row }">
<el-tag size="small" :type="backupEventTagType(row.event)">{{ backupEventLabel(row.event) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.message || row.reason || row.cmd || '-' }}</template>
</el-table-column>
</el-table>
</template>
<template v-if="backupArtifacts.length">
<div class="backup-sub">最近备份产物({{ backupArtifacts.length }})</div>
<ul class="backup-artifacts">
<li v-for="a in backupArtifacts" :key="a.name">
<span class="backup-artifact-name">{{ a.name }}</span>
<span class="backup-artifact-meta">{{ a.sizeMB }} MB · {{ backupFmtTime(a.mtime) }}</span>
</li>
</ul>
</template>
<el-empty
v-if="backupStatus && !backupPlans.length && !backupHistory.length && !backupArtifacts.length"
description="暂无待触发计划与执行记录(四组均未设置时间,或节点均已过期)"
:image-size="60"
/>
</div>
</el-card>
</template>
<!-- ========== 成绩管理 ========== -->
@@ -945,6 +1057,14 @@
<template v-if="isExamScoreGroup && activeScoreTab === 'examSubmissions'">
<div class="toolbar">
<el-input v-model="examSubsSearch" placeholder="搜索选手姓名" clearable size="small" class="search-input" @input="onExamSubsFilterChange" />
<!-- R165 提交状态筛选:后端 service/admin.js:823 已返回 submitted,表格亦有状态列,
但此前筛选只有姓名 + 日期两个维度 ⇒ 「找未提交考生」只能全量肉眼翻。
⚠️ 与日期区间并存时须注意:日期过滤对 submitTime 为空的行 return false,
故「未提交」只能与**空日期区间**组合使用(下方空态提示已显式说明)。 -->
<el-select v-model="examSubsStatus" placeholder="提交状态" clearable size="small" class="filter-select" @change="onExamSubsFilterChange">
<el-option label="已提交" value="submitted" />
<el-option label="未提交" value="unsubmitted" />
</el-select>
<el-date-picker
v-model="examSubsDateRange"
type="daterange"
@@ -979,6 +1099,17 @@
<template #default="{ row }">{{ row.submitTime ? fmtDate(row.submitTime) : '--' }}</template>
</el-table-column>
</el-table>
<!-- R165:空结果且命中「未提交 + 日期区间」互斥组合时,显式说明原因。
el-table 自带的「暂无数据」不解释「为什么没数据」,易被误判为功能故障。 -->
<el-alert
v-if="filteredExamSubmissions.length === 0 && examSubsStatus === 'unsubmitted' && examSubsDateRange && examSubsDateRange.length === 2"
type="warning"
:closable="false"
show-icon
title="当前筛选无结果"
description="「未提交」的考生没有提交时间,因此不能与提交日期区间同时使用。请清空日期区间后再查。"
class="exam-subs-filter-hint"
/>
<div class="pagination-wrap" v-if="filteredExamSubmissions.length > 0">
<el-pagination
v-model:current-page="examSubmissionsPage"
@@ -1028,6 +1159,23 @@
:loading="bigAnswersExporting"
v-guard="() => exportBigAnswers()"
>导出全部 Word</el-button>
<!-- R172:按小组编号批量导入大题成绩(两段式:预览 → 确认);R174 改三小问四列制 -->
<el-divider direction="vertical" />
<el-button
size="small"
type="info"
plain
title="下载成绩导入模板:四列 = 小组编号 + 第1/2/3小问,模板内附填写说明与常见被拒原因"
v-guard="() => downloadBigScoreTpl()"
>下载成绩模板</el-button>
<el-button
size="small"
type="warning"
:loading="bigScorePreviewing"
title="按小组编号批量导入大题成绩(三小问逐项填写)。先预览每一行的判定结果,确认后才会写入"
v-guard="() => triggerBigScoreImport()"
>导入成绩</el-button>
<input ref="bigScoreFileInput" type="file" accept=".xlsx,.xls" style="display:none" @change="onBigScoreFileChange" />
<el-tag type="info" class="total-score-tag">
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
</el-tag>
@@ -1043,6 +1191,14 @@
>
<el-table-column type="selection" width="44" :selectable="row => row.drawn" />
<el-table-column prop="name" label="姓名" width="110" />
<!-- R172:小组编号(成绩导入的定位键)。数据源 listAnswersForAdmin 的 teamNumber,
解析优先级 aggregation.team_uid → contestants.team_id,均按 (group, team_uid) 限定。 -->
<el-table-column label="小组编号" width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.teamNumber">{{ row.teamNumber }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<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">
@@ -1099,6 +1255,87 @@
</el-table-column>
</el-table>
<el-empty v-if="filteredBigAnswers.length === 0 && !bigAnswersLoading" description="该组暂无考生数据" :image-size="80" />
<!-- R172:成绩导入预览 / 确认。⛔ 预览零写入,确认后才落库;R174 改三小问四列制 -->
<el-dialog
v-model="bigScoreDialogVisible"
title="导入大题成绩(按小组编号)"
width="1080px"
:close-on-click-modal="false"
>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom:12px"
title="表格为「小组编号 + 第1/2/3小问」四列,三小问必须逐项填写(留空会整行被拒绝,不会按 0 分处理)。大题总分由系统对三小问求和得到,表中不需要填总分。成绩只写在参赛者名下,同组其他成员登录查分时自动看到同一份成绩。"
/>
<div v-if="bigScorePreview" class="big-score-summary">
<el-tag type="info">共 {{ bigScorePreview.total }} 行</el-tag>
<el-tag type="success">可导入 {{ bigScorePreview.importable }}</el-tag>
<el-tag type="warning">将覆盖 {{ bigScorePreview.overwritable }}</el-tag>
<el-tag type="danger">被拒绝 {{ bigScorePreview.blocked }}</el-tag>
<el-tag v-if="bigScorePreview.skippedBlank" type="info">空行 {{ bigScorePreview.skippedBlank }}</el-tag>
</div>
<el-table
:data="bigScorePreview ? bigScorePreview.rows : []"
size="small"
max-height="400"
stripe
>
<el-table-column prop="row" label="行" width="60" align="center" />
<el-table-column prop="teamNumber" label="小组编号" width="190" show-overflow-tooltip />
<el-table-column
v-for="(subLabel, subIdx) in ['第1小问', '第2小问', '第3小问']"
:key="subLabel"
:label="subLabel"
width="80"
align="center"
>
<template #default="{ row }">
<span v-if="bigScoreSubCell(row, subIdx) !== '--'">{{ bigScoreSubCell(row, subIdx) }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="合计" width="76" align="center">
<template #default="{ row }">
<strong v-if="row.total !== null && row.total !== undefined">{{ fmtScore(row.total) }}</strong>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="命中考生" width="100" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.contestantName">{{ row.contestantName }}</span>
<span v-else style="color:#909399">--</span>
</template>
</el-table-column>
<el-table-column label="状态" width="106" align="center">
<template #default="{ row }">
<el-tag :type="bigScoreStatusType(row.status)" size="small">{{ bigScoreStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="240" show-overflow-tooltip />
</el-table>
<template #footer>
<div class="big-score-footer">
<el-checkbox
v-model="bigScoreOverwrite"
:disabled="!bigScorePreview || !bigScorePreview.overwritable"
>
覆盖已评分({{ bigScorePreview ? bigScorePreview.overwritable : 0 }} 行)
</el-checkbox>
<div>
<el-button @click="bigScoreDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="bigScoreImporting"
:disabled="!bigScoreCanImport"
@click="confirmBigScoreImport"
>确认导入({{ bigScoreImportCount }} 行)</el-button>
</div>
</div>
</template>
</el-dialog>
</template><!-- /③ 考生大题作答查证 -->
<!-- 综合大题:作答详情 + 评分弹窗。
@@ -1856,7 +2093,11 @@ function onMenuSelect(index) {
}
if (index === 'materials') fetchAdminMaterials()
if (index === 'submissions' && submissionGroup.value) fetchSubmissions()
if (index === 'times') fetchTimes()
if (index === 'times') {
fetchTimes()
// 备份计划由考试时间推导 ⇒ 进入页面就展示当前排程与最近执行结论
fetchNodeBackupStatus()
}
if (index === 'scores') {
fetchPublishStatus()
fetchScoreList()
@@ -2302,6 +2543,74 @@ const bigAnswerRow = ref({})
const bigScoreSaving = ref(false)
const bigScoreForm = reactive({ score_1: 0, score_2: 0, score_3: 0 })
// ---- 成绩 Excel 导入(R172)----
// 两段式:选文件 → 预览(零写入)→ 勾选「覆盖已评分」→ 确认导入。
// ⛔ 保存 File 对象而非 FormData:FormData 提交过后部分实现会被消费,二次提交会空 body。
const bigScoreFileInput = ref(null)
const bigScoreFile = ref(null)
const bigScoreDialogVisible = ref(false)
const bigScorePreview = ref(null)
const bigScorePreviewing = ref(false)
const bigScoreImporting = ref(false)
const bigScoreOverwrite = ref(false)
/** 状态 → 中文标签(唯一口径,模板列与提示都走这里) */
const BIG_SCORE_STATUS_LABEL = {
OK: '可导入',
OVERWRITE: '将覆盖',
EMPTY_ROW: '空行',
TEAM_MISSING: '缺小组编号',
// R174:三小问逐项填报后,原来的「缺分数 / 分数非法 / 分数超上限」改为按小问报错
SUB_MISSING: '小问留空',
SUB_INVALID: '小问非法',
SUB_OUT_OF_RANGE: '小问超上限',
DUPLICATE_IN_FILE: '文件内重复',
TEAM_NOT_FOUND: '小组不存在',
TEAM_IN_OTHER_GROUP: '属于别组',
NO_OPERATOR: '无人抽题',
MULTI_OPERATOR: '多人抽题',
}
const BIG_SCORE_STATUS_TYPE = {
OK: 'success',
OVERWRITE: 'warning',
EMPTY_ROW: 'info',
}
function bigScoreStatusLabel(s) {
return BIG_SCORE_STATUS_LABEL[s] || s || '--'
}
function bigScoreStatusType(s) {
return BIG_SCORE_STATUS_TYPE[s] || 'danger'
}
/**
* 预览表格里「第 idx 个小问」的展示值(R174)。
*
* 为什么显示 `subRaws` 而不是 `subs`:
* `subs` 只在判定通过时才回带;而报错行恰恰最需要看清「到底填了什么」。
* 所以统一取原始单元格:数值格式化,非数值(如 'abc')原样显示,空白回 '--'。
*
* @param {Object} row 预览行
* @param {number} idx 0 基小问下标
* @returns {string}
*/
function bigScoreSubCell(row, idx) {
const list = row && Array.isArray(row.subRaws) ? row.subRaws : []
const v = list[idx]
if (v === null || v === undefined || String(v).trim() === '') return '--'
const n = Number(v)
if (!Number.isFinite(n)) return String(v)
return fmtScore(n)
}
/** 本次将写入的行数:可导入 + (勾选覆盖 ? 将覆盖 : 0) */
const bigScoreImportCount = computed(() => {
const p = bigScorePreview.value
if (!p) return 0
return (p.importable || 0) + (bigScoreOverwrite.value ? (p.overwritable || 0) : 0)
})
const bigScoreCanImport = computed(() => bigScoreImportCount.value > 0)
const filteredBigQuestions = computed(() => {
const list = bigQuestions.value
if (!bigSearch.value) return list
@@ -2596,9 +2905,88 @@ async function fetchBigAnswers() {
}
}
// ==================== 成绩 Excel 导入(R172) ====================
//
// 两段式:选文件 → 预览(服务端零写入)→ 勾选「覆盖已评分」→ 确认导入。
// ⚠️ 导入必须**重新上传同一个 File**(保存 File 对象而非 FormData:FormData 提交后
// 部分实现对已读流不可复用,二次提交会变成空 body)。
function triggerBigScoreImport() {
if (bigScoreFileInput.value) bigScoreFileInput.value.click()
}
async function downloadBigScoreTpl() {
await safeDownload(`/api/admin/big-answers/${scoreExportGroup.value}/import-template`, {
fallbackName: `综合大题成绩导入模板_${scoreExportGroup.value}.xlsx`,
})
}
/** 统一错误文案。⚠️ 业务错误经拦截器 reject 成 `new Error(message)`(**无 response**)⇒ 先读 message */
function bigScoreErrMsg(err) {
return (err && err.message)
|| (err && err.response && err.response.data && err.response.data.message)
|| '未知错误'
}
async function onBigScoreFileChange(e) {
const file = (e.target.files && e.target.files[0]) || null
e.target.value = ''
if (!file) return
bigScoreFile.value = file
bigScoreOverwrite.value = false
bigScorePreview.value = null
bigScorePreviewing.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res = await adminApi.previewBigScoreImport(scoreExportGroup.value, fd)
const r = res?.data || res
if (!r) {
ElMessage.error('预览失败:未返回判定结果')
return
}
bigScorePreview.value = r
bigScoreDialogVisible.value = true
} catch (err) {
ElMessage.error('预览失败:' + bigScoreErrMsg(err))
} finally {
bigScorePreviewing.value = false
}
}
async function confirmBigScoreImport() {
if (!bigScoreFile.value || !bigScoreCanImport.value) return
bigScoreImporting.value = true
try {
const fd = new FormData()
fd.append('file', bigScoreFile.value)
const res = await adminApi.importBigScores(scoreExportGroup.value, fd, bigScoreOverwrite.value)
const r = res?.data || res || {}
ElMessage.success(`导入完成:成功 ${r.imported || 0} 行(覆盖 ${r.overwritten || 0} 行),跳过 ${r.skipped || 0} 行`)
const errs = r.errors || []
if (errs.length) {
// 明细不能只 console.warn —— 管理员不会去看控制台,跳过原因等于丢失
const lines = errs.slice(0, 20).map(x => `第 ${x.row} 行 ${x.teamNumber || ''}:${x.message}`)
if (errs.length > 20) lines.push(`……另有 ${errs.length - 20} 条,详见导入日志`)
ElMessageBox.alert(lines.join('<br>'), '被跳过的行', {
dangerouslyUseHTMLString: true,
confirmButtonText: '知道了',
}).catch(() => {})
}
bigScoreDialogVisible.value = false
fetchBigAnswers()
} catch (err) {
ElMessage.error('导入失败:' + bigScoreErrMsg(err))
} finally {
bigScoreImporting.value = false
}
}
/**
* 「大题库 / 大题作答」数据当前所属组别(2026-09-23)。
*
* 修复「切中职/高职后必须刷新页面才显示新数据」:
* 原实现用 `bigQuestions.length === 0` 作为「要不要拉」的判据,但切组时
* 上一组的数据仍在数组里(长度 ≠ 0)⇒ 永远不会重新拉取。
@@ -2975,6 +3363,8 @@ const examSubmissionsPage = ref(1)
const examSubmissionsPageSize = ref(10)
const examSubsSearch = ref('')
const examSubsDateRange = ref(null)
// R165:提交状态筛选('' = 全部 / 'submitted' / 'unsubmitted')
const examSubsStatus = ref('')
const filteredExamSubmissions = computed(() => {
let list = examSubmissions.value
@@ -2982,9 +3372,22 @@ const filteredExamSubmissions = computed(() => {
const s = examSubsSearch.value.trim().toLowerCase()
list = list.filter(item => (item.userName || '').toLowerCase().includes(s))
}
// R165:提交状态维度。⛔ 严格用后端 submitted 字段,⛔ 不在前端另算一套
// (前端重算会与 service/admin.js:823 的 `!!formalSubmitTime || answeredCount>0` 漂移)。
if (examSubsStatus.value === 'submitted') {
list = list.filter(item => item.submitted === true)
} else if (examSubsStatus.value === 'unsubmitted') {
// 用 `!item.submitted` 而非 `=== false`:后端未回该字段时(undefined)也应算未提交,
// 与 el-tag 的 `row.submitted ? '已提交' : '未提交'` 判定同源,避免「列显示未提交却被筛掉」。
list = list.filter(item => !item.submitted)
}
if (examSubsDateRange.value && examSubsDateRange.value.length === 2) {
const [start, end] = examSubsDateRange.value
list = list.filter(item => {
// 🔴 R165 保留原语义(无 submitTime 即淘汰),但**新增显式互斥提示**:
// 未提交者本就无 submitTime,故「未提交 + 日期区间」在数学上必为空集,
// 而空表 + el-table 的「暂无数据」**不解释原因** ⇒ 用户会误判为「筛选坏了」。
// ⛔ 不擅自改判定口径(会与 CSV 导出口径漂移),改为在 UI 上讲清。
if (!item.submitTime) return false
const itemDate = new Date(item.submitTime)
return itemDate >= start && itemDate <= end
@@ -3289,9 +3692,108 @@ async function saveTimes() {
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
})
ElMessage.success('时间设置保存成功')
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
fetchNodeBackupStatus()
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false }
}
// ---- 节点备份状态(P1「生产备份静默失败防护」的可见性)----
// 为什么放在时间设置页内:三节点备份的触发时刻由考试时间推导,
// 改完时间立即看到排程结果,才不会被「时间改了、计划没跟上」蒙过去。
const backupStatus = ref(null)
const backupStatusLoading = ref(false)
const backupPlans = computed(() => (backupStatus.value && backupStatus.value.plans) || [])
const backupHistory = computed(() => (backupStatus.value && backupStatus.value.history) || [])
const backupArtifacts = computed(() => (backupStatus.value && backupStatus.value.artifacts) || [])
const backupHealthReasons = computed(() => {
const h = backupStatus.value && backupStatus.value.health
return h && Array.isArray(h.reasons) ? h.reasons : []
})
const backupBeforeStartMin = computed(
() => (backupStatus.value && backupStatus.value.buffer && backupStatus.value.buffer.beforeStartMin) || 30,
)
const backupAfterEndMin = computed(
() => (backupStatus.value && backupStatus.value.buffer && backupStatus.value.buffer.afterEndMin) || 10,
)
const backupKeepDays = computed(() => (backupStatus.value && backupStatus.value.keepDays) || 90)
// 顶部汇总提示:只给「几项 + 首条摘要」,明细留在底部卡片(避免同一信息两处展开)
const backupTopAlertType = computed(
() => (backupHealthReasons.value.some((r) => r.level === 'error') ? 'error' : 'warning'),
)
const backupTopAlertTitle = computed(() => {
const list = backupHealthReasons.value
if (!list.length) return ''
const first = list[0] && list[0].message ? list[0].message : '节点备份状态异常'
return `节点备份异常 ${list.length} 项:${first}(明细见页面底部「节点备份状态」)`
})
const BACKUP_GROUP_TEXT = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组', ALL: '全部组' }
const BACKUP_NODE_TEXT = { 'before-start': '考试开始前', 'after-end': '考试结束后', 'score-published': '成绩发布后' }
const BACKUP_EVENT_TEXT = {
plan: '已排程',
start: '执行中',
success: '成功',
fail: '失败',
'lock-skip': '另一进程执行',
'invalid-skip': '计划已作废',
}
function backupGroupLabel(g) { return BACKUP_GROUP_TEXT[g] || g || '-' }
function backupNodeLabel(n) { return BACKUP_NODE_TEXT[n] || n || '-' }
function backupEventLabel(e) { return BACKUP_EVENT_TEXT[e] || e || '-' }
function backupEventTagType(e) {
if (e === 'success') return 'success'
if (e === 'fail') return 'danger'
if (e === 'start') return 'warning'
return 'info'
}
function backupFmtTime(v) {
if (!v) return '-'
const d = new Date(v)
if (Number.isNaN(d.getTime())) return String(v)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
function backupFmtRemain(sec) {
if (sec == null || !Number.isFinite(Number(sec))) return '-'
const s = Math.max(0, Math.round(Number(sec)))
if (s < 60) return `${s} 秒后`
if (s < 3600) return `${Math.round(s / 60)} 分钟后`
if (s < 86400) return `${Math.floor(s / 3600)} 小时 ${Math.round((s % 3600) / 60)} 分后`
return `${Math.floor(s / 86400)} 天 ${Math.floor((s % 86400) / 3600)} 小时后`
}
async function fetchNodeBackupStatus() {
backupStatusLoading.value = true
try {
backupStatus.value = await adminApi.getNodeBackupStatus()
} catch (e) {
// api 层已 silent ⇒ 不弹全局错误;此处把失败本身做进健康判定,页面仍然可见
backupStatus.value = {
available: false,
enabled: false,
isProduction: false,
plans: [],
history: [],
artifacts: [],
health: {
level: 'warn',
reasons: [{
level: 'warn',
code: 'FETCH_FAILED',
message: '获取节点备份状态失败:' + (e && e.message ? e.message : '未知错误'),
}],
},
}
} finally {
backupStatusLoading.value = false
}
}
// ==================== 6. 成绩管理 ====================
const scoresPublishedMap = reactive({})
const scoreExportGroup = ref('')
@@ -4107,12 +4609,18 @@ onMounted(() => {
/* R30:`.exam-score-form` 相关样式已随「考生答题记录 · 评分」弹窗一并删除 */
.search-input { width: 220px; }
/* R165:答题记录筛选互斥提示(未提交 × 日期区间 ⇒ 必空集,需显式说明原因) */
.exam-subs-filter-hint { margin-top: 10px; }
.filter-select { width: 140px; }
.score-policy-tag { margin-left: 12px; }
.avail-hint { margin-left: 8px; color: #909399; font-size: 12px; }
.total-score-tag { margin-left: 4px; }
/* R172 成绩导入预览弹窗:汇总条与底部操作区 */
.big-score-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
.big-score-footer { display: flex; align-items: center; justify-content: space-between; }
/* ==================== 作品管理 ==================== */
.work-file-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; flex-wrap: wrap; }
.work-file-name { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
@@ -4216,6 +4724,36 @@ onMounted(() => {
font-weight: 600;
}
/* ---- 节点备份状态(P1)---- */
.backup-top-alert { margin-bottom: 14px; max-width: 900px; }
.backup-card { max-width: 900px; margin-top: 8px; }
.backup-head { display: flex; align-items: center; gap: 10px; }
.backup-title { font-size: 16px; font-weight: 600; }
.backup-spacer { flex: 1; }
.backup-hint {
margin: 0 0 12px;
color: #606266;
font-size: 13px;
line-height: 1.7;
}
.backup-alert { margin-bottom: 10px; }
.backup-sub {
margin: 16px 0 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.backup-table { margin-bottom: 4px; }
.backup-artifacts {
margin: 0;
padding-left: 18px;
font-size: 13px;
color: #303133;
}
.backup-artifacts li { line-height: 1.9; }
.backup-artifact-name { font-family: Menlo, Consolas, monospace; }
.backup-artifact-meta { margin-left: 10px; color: #909399; font-size: 12px; }
/* ==================== 轮播缩略图 ==================== */
.carousel-thumb {
width: 120px;