Compare commits

...

3 Commits

Author SHA1 Message Date
45e77d9f69 build(deps): vite 5.4.21 → 8.3.4(含 plugin-vue 6)+ 修 CSS 拆块导致的主题/悬停回归
安全:消掉 2 条 high。vite 跨 3 个 major(5→8,rollup→rolldown);
@vitejs/plugin-vue 5.2.4 → 6.0.10(peer 覆盖 vite ^5||^6||^7||^8);
锁文件同步刷新使 nanoid / postcss / source-map-js 三条一并消解。

🔴 升级引入的 CSS 顺序回归(本轮实测发现并修复,不是理论风险):
rolldown 把 Element Plus 基础样式拆成独立 css-*.css 块,且 index.html 中
排在入口 CSS **之后**;旧 rollup 把 EP 与 global.css 合并在同一入口 CSS,
EP 在前、项目在后。两者覆盖语句特异性相同 ⇒ 谁在后谁赢 ⇒ 新顺序下反被 EP 压过。
取证:生产旧包 index-CVAb-2kd.css 内 EP 规则 @27768 < 项目覆盖 @57506(旧=正确);
新包 index-rMbH9bHe.css(先)vs css-CNpL_Sag.css(后)。
· --el-color-primary 退回 #409eff ⇒ 登录按钮由深藏青变 Element 亮蓝;
· 文字型按钮 hover 底退回不透明 #f5f7fa ⇒ 此前修好的「白底白字」缺陷复现。

修法=让覆盖与加载顺序无关(⛔ 不是调顺序,顺序由打包器决定不可靠):
· :root → html:root   (0,0,1,1 > 0,0,1,0)
· .el-button.is-text:not(.is-disabled):hover/:focus 加 :root 前缀(4 → 5 个类级)

验证(四级对照,全部可复现):
1. 顺序单变量实验(两组页面除 <link> 顺序外完全相同):
   修复前 A=SAME? → primary #1a3a5c vs #409eff、hover rgba(...) vs #f5f7fa(DIFF);
   修复后两种顺序计算样式完全相同(PASS)。
2. 与生产旧包(旧 vite5 构建)逐项比对 computed style:primary / light-3 /
   主按钮底色 / hover 底色 全部 SAME。
3. 登录页截图 sha256 与生产旧包**逐字节相同**(f5052aa7260b7b47)——
   修复前为 9f074bb1c44329b3。
4. 真 chromium 加载构建产物(vite preview):/login、/admin 首屏、
   成绩管理、选手管理全部 0 console error / 0 pageerror;
   TDZ 静态门禁 38 文件 PASS。
产物:入口 index-DTFvE02d.js · 157 资源 · 73 个 .gz(两次构建 hash 一致=可复现)。
全量电池 57 步:退出码 0 · FAIL 0 · SKIP 0 · 未运行 0(1 项 N/A 为数据前提不成立)。
2026-10-21 12:05:29 +08:00
c8097fab00 feat(admin): R176-R180 公示表设置/新闻附件与撤回/管理员权限页 + 小组编号列 + 筛选清空修复
- R176 公示表设置区(赛项名称/编号两个配置项)+ 成绩管理下载半成品
- R177 新闻详情附件块 + 「发布 ↔ 撤回」按钮成对(v-if/v-else),撤回不删除
- R178 管理员账号页:新增/编辑权限/重置口令/禁用/删除;侧栏身份区显示角色
- R178 无权限空态;菜单按 can() 裁剪(体验层,最终以后端 403 为准)
- R178 成绩总览新增「小组编号」列(三态:编号 / 队伍 #N / 未编组)
- R178 筛选清空修复:Element Plus value-on-clear 默认 undefined ⇒ 改白名单判定 + value-on-clear=""
2026-10-20 11:07:31 +08:00
b7ec541223 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)
2026-10-19 13:03:54 +08:00
7 changed files with 2845 additions and 444 deletions

1474
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,8 @@
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^6.0.10",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^5.0.0" "vite": "^8.3.4"
} }
} }

View File

@@ -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 - 组别标识
@@ -266,6 +277,91 @@ export function getPublishStatus(group) {
return request.get('/admin/publish-scores', group ? { params: { group } } : undefined) return request.get('/admin/publish-scores', group ? { params: { group } } : undefined)
} }
// ==================== 成绩公示表(R176) ====================
/**
* 公示表导出前的统计预检(⛔ 不下载文件,只回统计)
*
* 为什么要先预检:成绩公示表是**对外的正式文书**,缺数据不能静默带出。
* 本接口回传「无小组归属人数 / 测试数据人数 / 赛项名称是否已配置」,
* 前端据此在执行下载前让管理员知情并确认,而不是下载完才发现表格有空白列。
*
* ⚠️ 为什么不用响应头传这些字段:生产经 nginx `X-Accel-Redirect` 时
* 上游自定义响应头会被丢弃(本项目已实测),故改用 `?meta=1` 走 JSON。
*
* silent: true —— 属「预检」而非用户动作,失败由调用方按业务语境提示。
* @param {string} group - 组别标识
* @returns {Promise<{fileName:string,rowCount:number,testCount:number,
* noTeamCount:number,totalContestants:number,itemName:string,itemCode:string}>}
*/
export function getPublicityMeta(group) {
return request.get(`/admin/export-publicity/${group}`, {
params: { meta: 1 },
silent: true,
})
}
/**
* 下载成绩公示表半成品(按模板填充,获奖等级列留空由人工填写)
*
* 下载动作本身由 `utils/download.js#safeDownload` 发起(含 HTTP 状态 +
* content-type + 文件头三重校验),故这里不提供 axios 封装,
* 调用方直接用 `/api/admin/export-publicity/${group}` 走 safeDownload。
*
* 留此常量而非散落字符串:URL 前缀被前端(safeDownload)与后端路由共同约束,
* 改成单点引用可避免两处漂移。
* @param {string} group
* @returns {string} 下载 URL
*/
export function publicityExportUrl(group) {
return `/api/admin/export-publicity/${group}`
}
/**
* 读取赛项信息(赛项名称 / 赛项编号)
*
* 这两项用于公示表标题(模板第 1~3 行)。用户裁定:不做硬编码,
* 改由管理人员在后台填写 —— 因为赛项名称/编号每届都会变。
* @returns {Promise<{itemName:string,itemCode:string}>}
*/
export function getCompetitionItem() {
return request.get('/admin/competition-item', { silent: true })
}
/**
* 保存赛项信息
* @param {{itemName?: string, itemCode?: string}} data
* @returns {Promise<{itemName:string,itemCode:string}>}
*/
export function updateCompetitionItem(data) {
// silent: true —— 越界(名称 >120 字 / 编号 >60 字)是业务性 400,
// 后端 message 已可读,由调用方在弹窗内就地提示,避免拦截器再弹一次(同族双弹)
return request.put('/admin/competition-item', data, { silent: true })
}
// ==================== 指导教师回填(R176) ====================
/**
* 从报名表回填「指导教师」
*
* 背景:报名表导入时按用户裁定只录入参赛选手,指导教师的姓名没有落库。
* 公示表需要「指导教师」列,故提供一次性回填:重新上传同一份报名表,
* 服务端只读其中的「指导教师」行,按「组别 + 小组编号」定位到该组全体组员,
* 写入 `contestants.teacher_name`(组级属性,冗余到每位组员)。
*
* ⛔ 不新建账号、不改组别/成绩,只更新这一个列。
* silent: true —— 失败提示由调用方统一给出,避免同族双弹。
* @param {FormData} formData - 含报名表(字段名 file)
* @returns {Promise<{ok:boolean,code:string,message:string,stats:object,
* teams:object,updated:number,unchanged:number,unmatched:Array,warnings:string[]}>}
*/
export function backfillTeachers(formData) {
return request.post('/admin/contestants/backfill-teacher', formData, {
timeout: 120000,
silent: true,
})
}
// ==================== 新闻管理 ==================== // ==================== 新闻管理 ====================
/** /**
@@ -313,6 +409,28 @@ export function updateNewsOrder(orders) {
return request.put('/admin/news/order', { orders }) return request.put('/admin/news/order', { orders })
} }
/**
* 上传新闻附件(R176「缺口2:新闻管理新增文件附件能力」)
*
* 场景:成绩公示表这类**文件类公示**要在新闻里对外提供下载。
* 附件与正文分开存:`attachment_url`(服务端返回的访问路径)+ `attachment_name`
* (展示用的原始文件名,中文原样保留)。
*
* ⚠️ 不要手动设置 Content-Type:axios 会在 FormData 上自动补 boundary,
* 写死 'multipart/form-data' 会让后端 getFileStream 报 Missing boundary → 500。
*
* silent: true —— 后端对「格式不支持 / 超 20MB」已给出可读文案,
* 由调用方就地提示,避免拦截器再弹一次(同族双弹)。
* @param {FormData} formData - 含文件(字段名 file)
* @returns {Promise<{url:string,name:string,size:number}>}
*/
export function uploadNewsFile(formData) {
return request.post('/admin/upload/news-file', formData, {
timeout: 120000,
silent: true,
})
}
// ==================== 轮播图管理 ==================== // ==================== 轮播图管理 ====================
/** /**
@@ -477,6 +595,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,
})
}
// ==================== 成绩复核 ==================== // ==================== 成绩复核 ====================
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。 // 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
@@ -540,4 +713,53 @@ export function updateContestant(id, payload) {
return request.put(`/admin/contestants/${id}`, payload, { silent: true }) return request.put(`/admin/contestants/${id}`, payload, { silent: true })
} }
// ==================== R178 多管理员 + 权限 ====================
/**
* 当前登录管理员的自助信息(权限点 + 权限目录)
*
* 为什么需要它:权限被最高管理员改动后,前端手里那份(登录时落盘的)会过期。
* 进页面时拉一次,能让「权限已被收回」被立刻感知,而不是点每个按钮都撞 403。
*
* silent: true —— 该接口在 401(会话失效)时由拦截器统一跳登录,业务上不产生
* 需要额外提示的失败形态,避免与拦截器双弹。
*/
export function getAdminMe() {
return request.get('/admin/me', { silent: true })
}
/** 管理员列表(不含口令) */
export function listAdminUsers() {
return request.get('/admin/admin-users')
}
/**
* 新增管理员
* @param {{username:string,password:string,permissions:string[],remark?:string}} data
*/
export function createAdminUser(data) {
return request.post('/admin/admin-users', data)
}
/** 编辑权限与备注(对目标账号立即生效,无需其重新登录) */
export function updateAdminUserPermissions(id, data) {
return request.put(`/admin/admin-users/${id}/permissions`, data)
}
/** 重置口令(不要求旧口令;成功后目标账号所有在线会话立即失效) */
export function resetAdminUserPassword(id, data) {
return request.put(`/admin/admin-users/${id}/password`, data)
}
/** 启用 / 禁用(status: 1 启用 / 0 禁用;禁用会立即踢下线) */
export function setAdminUserStatus(id, data) {
return request.put(`/admin/admin-users/${id}/status`, data)
}
/** 删除管理员(最高管理员与本人账号不可删,后端会拒绝) */
export function deleteAdminUser(id) {
return request.delete(`/admin/admin-users/${id}`)
}

1
src/components.d.ts vendored
View File

@@ -19,6 +19,7 @@ declare module 'vue' {
ElCarousel: typeof import('element-plus/es')['ElCarousel'] ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']

View File

@@ -5,7 +5,18 @@
=========================================================== */ =========================================================== */
/* ============ CSS 变量定义 ============ */ /* ============ CSS 变量定义 ============ */
:root { /* 🔴 选择器必须是 html:root 而不是 :root(2026-10-21 R188 实测):
Element Plus 默认主题在同一 `:root` 上定义 `--el-color-primary:#409eff`,
两者特异性相同 ⇒ **谁后加载谁赢**。
· vite 5(rollup):EP 基础样式与 global.css 合并在同一个入口 CSS 内,
EP 在前、本文件在后 ⇒ 覆盖生效(生产旧包 index-CVAb-2kd.css 实测:
EP 规则 @27768 < 本文件覆盖 @57506)。
· vite 8(rolldown):EP 基础样式被拆成独立的 css-*.css 块,且
index.html 里排在入口 CSS **之后** ⇒ EP 默认值反过来赢,
主色退回 #409eff(登录按钮由深藏青变亮蓝)。
html:root = (0,0,1,1) 高于 :root = (0,0,1,0),与加载顺序无关。
⛔ 不要改回 :root —— 除非能证明 EP 样式一定排在前面。 */
html:root {
/* 主色调:深蓝色,用于导航栏、按钮等关键元素 */ /* 主色调:深蓝色,用于导航栏、按钮等关键元素 */
--primary-color: #1a3a5c; --primary-color: #1a3a5c;
/* 辅助色:金色,用于强调、高亮、获奖标识等 */ /* 辅助色:金色,用于强调、高亮、获奖标识等 */
@@ -371,7 +382,14 @@ input:-webkit-autofill {
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。 本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
新增页面自动继承此保障,无需逐页排查。 新增页面自动继承此保障,无需逐页排查。
=========================================================== */ =========================================================== */
.el-button.is-text:not(.is-disabled):hover, /* 🔴 选择器前缀 :root 是**必需**的(2026-10-21 R188 实测),不能省:
.el-button.is-text:not(.is-disabled):focus { 本规则与 EP 的 .el-button.is-text:not(.is-disabled):hover 同为 4 个类级,
旧构建靠「本文件在后」取胜;vite 8 把 EP 样式拆块后排在入口 CSS 之后,
同特异性下 EP 反过来赢 ⇒ hover 底变回不透明近白 #f5f7fa ⇒
「白底白字」缺陷复现(该缺陷此前已修复,见上方说明)。
前缀 :root 使特异性升到 5 个类级 ⇒ 与顺序无关,重新压过 EP。 */
:root .el-button.is-text:not(.is-disabled):hover,
:root .el-button.is-text:not(.is-disabled):focus {
background-color: rgba(120, 130, 145, 0.16); background-color: rgba(120, 130, 145, 0.16);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,22 @@
<el-divider /> <el-divider />
<!-- R176:附件下载(成绩公示表等「文件类公示」)
放在正文之前:附件是这类新闻的**主内容**,正文只是说明。
⛔ 链接直接用后端返回的 /public/... 相对路径(dev 由 vite 代理、
生产由 nginx 直出),不要拼 /api 前缀。 -->
<div v-if="news.attachment_url" class="article-attachment">
<el-icon class="article-attachment__icon"><Paperclip /></el-icon>
<span class="article-attachment__label">附件下载:</span>
<a
:href="news.attachment_url"
class="article-attachment__link"
target="_blank"
rel="noopener"
:download="news.attachment_name || ''"
>{{ news.attachment_name || '点击下载附件' }}</a>
</div>
<!-- 微信链接型新闻 --> <!-- 微信链接型新闻 -->
<div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section"> <div v-if="news.news_type === 'wx_link' && news.wx_link" class="wx-link-section">
<el-alert type="info" :closable="false" show-icon> <el-alert type="info" :closable="false" show-icon>
@@ -251,6 +267,36 @@ onMounted(() => {
padding: 20px 0; padding: 20px 0;
} }
/* R176:附件下载块(成绩公示表等文件类公示) */
.article-attachment {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 12px 16px;
margin: 8px 0 16px;
background: #f0f6ff;
border: 1px solid #d6e4f5;
border-radius: 6px;
}
.article-attachment__icon {
color: #1a3a5c;
}
.article-attachment__label {
font-size: 14px;
color: #4b5563;
}
.article-attachment__link {
font-size: 14px;
font-weight: 600;
color: #1a3a5c;
text-decoration: underline;
word-break: break-all;
}
.article-attachment__link:hover {
color: #c9a84c;
}
.wx-link-btn { .wx-link-btn {
text-decoration: none; text-decoration: none;
} }