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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 成绩复核 ====================
|
||||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user