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 })
|
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 - 组别标识
|
* @param {string} group - 组别标识
|
||||||
@@ -477,6 +488,61 @@ export function setBigAnswerScores(group, uid, scores) {
|
|||||||
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
|
return request.put(`/admin/big-answers/${group}/${uid}/scores`, scores)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载「综合大题成绩导入」模板(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'">
|
<template v-if="activeMenu === 'times'">
|
||||||
<h2 class="section-heading">时间设置</h2>
|
<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">
|
<div v-loading="timesLoading">
|
||||||
<el-form :model="timesForm" label-position="top" class="times-form">
|
<el-form :model="timesForm" label-position="top" class="times-form">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@@ -796,6 +808,106 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<!-- ========== 成绩管理 ========== -->
|
<!-- ========== 成绩管理 ========== -->
|
||||||
@@ -945,6 +1057,14 @@
|
|||||||
<template v-if="isExamScoreGroup && activeScoreTab === 'examSubmissions'">
|
<template v-if="isExamScoreGroup && activeScoreTab === 'examSubmissions'">
|
||||||
<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" />
|
||||||
|
<!-- 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
|
<el-date-picker
|
||||||
v-model="examSubsDateRange"
|
v-model="examSubsDateRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
@@ -979,6 +1099,17 @@
|
|||||||
<template #default="{ row }">{{ row.submitTime ? fmtDate(row.submitTime) : '--' }}</template>
|
<template #default="{ row }">{{ row.submitTime ? fmtDate(row.submitTime) : '--' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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">
|
<div class="pagination-wrap" v-if="filteredExamSubmissions.length > 0">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="examSubmissionsPage"
|
v-model:current-page="examSubmissionsPage"
|
||||||
@@ -1028,6 +1159,23 @@
|
|||||||
:loading="bigAnswersExporting"
|
:loading="bigAnswersExporting"
|
||||||
v-guard="() => exportBigAnswers()"
|
v-guard="() => exportBigAnswers()"
|
||||||
>导出全部 Word</el-button>
|
>导出全部 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">
|
<el-tag type="info" class="total-score-tag">
|
||||||
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
|
共 {{ bigAnswersTotal }} 人,已抽题 {{ bigAnswersDrawn }} 人
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -1043,6 +1191,14 @@
|
|||||||
>
|
>
|
||||||
<el-table-column type="selection" width="44" :selectable="row => row.drawn" />
|
<el-table-column type="selection" width="44" :selectable="row => row.drawn" />
|
||||||
<el-table-column prop="name" label="姓名" width="110" />
|
<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="idNumber" label="身份证号" width="180" show-overflow-tooltip />
|
||||||
<el-table-column prop="phone" label="手机号" width="130" />
|
<el-table-column prop="phone" label="手机号" width="130" />
|
||||||
<el-table-column label="抽题" width="90" align="center">
|
<el-table-column label="抽题" width="90" align="center">
|
||||||
@@ -1099,6 +1255,87 @@
|
|||||||
</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" />
|
||||||
|
|
||||||
|
<!-- 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><!-- /③ 考生大题作答查证 -->
|
</template><!-- /③ 考生大题作答查证 -->
|
||||||
|
|
||||||
<!-- 综合大题:作答详情 + 评分弹窗。
|
<!-- 综合大题:作答详情 + 评分弹窗。
|
||||||
@@ -1856,7 +2093,11 @@ function onMenuSelect(index) {
|
|||||||
}
|
}
|
||||||
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()
|
||||||
|
// 备份计划由考试时间推导 ⇒ 进入页面就展示当前排程与最近执行结论
|
||||||
|
fetchNodeBackupStatus()
|
||||||
|
}
|
||||||
if (index === 'scores') {
|
if (index === 'scores') {
|
||||||
fetchPublishStatus()
|
fetchPublishStatus()
|
||||||
fetchScoreList()
|
fetchScoreList()
|
||||||
@@ -2302,6 +2543,74 @@ const bigAnswerRow = ref({})
|
|||||||
const bigScoreSaving = ref(false)
|
const bigScoreSaving = ref(false)
|
||||||
const bigScoreForm = reactive({ score_1: 0, score_2: 0, score_3: 0 })
|
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 filteredBigQuestions = computed(() => {
|
||||||
const list = bigQuestions.value
|
const list = bigQuestions.value
|
||||||
if (!bigSearch.value) return list
|
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)。
|
* 「大题库 / 大题作答」数据当前所属组别(2026-09-23)。
|
||||||
*
|
|
||||||
* 修复「切中职/高职后必须刷新页面才显示新数据」:
|
* 修复「切中职/高职后必须刷新页面才显示新数据」:
|
||||||
* 原实现用 `bigQuestions.length === 0` 作为「要不要拉」的判据,但切组时
|
* 原实现用 `bigQuestions.length === 0` 作为「要不要拉」的判据,但切组时
|
||||||
* 上一组的数据仍在数组里(长度 ≠ 0)⇒ 永远不会重新拉取。
|
* 上一组的数据仍在数组里(长度 ≠ 0)⇒ 永远不会重新拉取。
|
||||||
@@ -2975,6 +3363,8 @@ const examSubmissionsPage = ref(1)
|
|||||||
const examSubmissionsPageSize = ref(10)
|
const examSubmissionsPageSize = ref(10)
|
||||||
const examSubsSearch = ref('')
|
const examSubsSearch = ref('')
|
||||||
const examSubsDateRange = ref(null)
|
const examSubsDateRange = ref(null)
|
||||||
|
// R165:提交状态筛选('' = 全部 / 'submitted' / 'unsubmitted')
|
||||||
|
const examSubsStatus = ref('')
|
||||||
|
|
||||||
const filteredExamSubmissions = computed(() => {
|
const filteredExamSubmissions = computed(() => {
|
||||||
let list = examSubmissions.value
|
let list = examSubmissions.value
|
||||||
@@ -2982,9 +3372,22 @@ const filteredExamSubmissions = computed(() => {
|
|||||||
const s = examSubsSearch.value.trim().toLowerCase()
|
const s = examSubsSearch.value.trim().toLowerCase()
|
||||||
list = list.filter(item => (item.userName || '').toLowerCase().includes(s))
|
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) {
|
if (examSubsDateRange.value && examSubsDateRange.value.length === 2) {
|
||||||
const [start, end] = examSubsDateRange.value
|
const [start, end] = examSubsDateRange.value
|
||||||
list = list.filter(item => {
|
list = list.filter(item => {
|
||||||
|
// 🔴 R165 保留原语义(无 submitTime 即淘汰),但**新增显式互斥提示**:
|
||||||
|
// 未提交者本就无 submitTime,故「未提交 + 日期区间」在数学上必为空集,
|
||||||
|
// 而空表 + el-table 的「暂无数据」**不解释原因** ⇒ 用户会误判为「筛选坏了」。
|
||||||
|
// ⛔ 不擅自改判定口径(会与 CSV 导出口径漂移),改为在 UI 上讲清。
|
||||||
if (!item.submitTime) return false
|
if (!item.submitTime) return false
|
||||||
const itemDate = new Date(item.submitTime)
|
const itemDate = new Date(item.submitTime)
|
||||||
return itemDate >= start && itemDate <= end
|
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 },
|
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
|
||||||
})
|
})
|
||||||
ElMessage.success('时间设置保存成功')
|
ElMessage.success('时间设置保存成功')
|
||||||
|
// 保存即重排三节点计划(生产环境)⇒ 立刻回读,让「新计划是否真的排上」当场可见
|
||||||
|
fetchNodeBackupStatus()
|
||||||
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false }
|
} 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. 成绩管理 ====================
|
// ==================== 6. 成绩管理 ====================
|
||||||
const scoresPublishedMap = reactive({})
|
const scoresPublishedMap = reactive({})
|
||||||
const scoreExportGroup = ref('')
|
const scoreExportGroup = ref('')
|
||||||
@@ -4107,12 +4609,18 @@ onMounted(() => {
|
|||||||
/* R30:`.exam-score-form` 相关样式已随「考生答题记录 · 评分」弹窗一并删除 */
|
/* R30:`.exam-score-form` 相关样式已随「考生答题记录 · 评分」弹窗一并删除 */
|
||||||
|
|
||||||
.search-input { width: 220px; }
|
.search-input { width: 220px; }
|
||||||
|
/* R165:答题记录筛选互斥提示(未提交 × 日期区间 ⇒ 必空集,需显式说明原因) */
|
||||||
|
.exam-subs-filter-hint { margin-top: 10px; }
|
||||||
.filter-select { width: 140px; }
|
.filter-select { width: 140px; }
|
||||||
|
|
||||||
.score-policy-tag { margin-left: 12px; }
|
.score-policy-tag { margin-left: 12px; }
|
||||||
.avail-hint { margin-left: 8px; color: #909399; font-size: 12px; }
|
.avail-hint { margin-left: 8px; color: #909399; font-size: 12px; }
|
||||||
.total-score-tag { margin-left: 4px; }
|
.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-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; }
|
.work-file-name { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
|
||||||
@@ -4216,6 +4724,36 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
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 {
|
.carousel-thumb {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
|
|||||||
Reference in New Issue
Block a user