Compare commits
23 Commits
4e7493a809
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4cfc280e | |||
| 4fd04f106a | |||
| 96d16129f7 | |||
| ab1c67dbc5 | |||
| 45e77d9f69 | |||
| c8097fab00 | |||
| b7ec541223 | |||
| 4e7dfe09c8 | |||
| 1b569a38e6 | |||
| 3085616605 | |||
| 092910404a | |||
| 27665c2186 | |||
| e7179bfe54 | |||
| 680faacd1d | |||
| 4e84257ed8 | |||
| ac7637f3e1 | |||
| 39d3da0c93 | |||
| 0fca4bd996 | |||
| ff7137aab6 | |||
| d16c261273 | |||
| a47e823277 | |||
| ef32d30a57 | |||
| f77330ad27 |
1474
package-lock.json
generated
1474
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,8 @@
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^8.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
313
src/api/admin.js
313
src/api/admin.js
@@ -19,6 +19,15 @@ export function getAccounts(params) {
|
||||
return request.get('/admin/accounts', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小组(团队)激活状态清单(R84 续六)
|
||||
* @param {object} params - { group, activated, includeTest }
|
||||
* @returns {Promise} { list, summary, filteredTotal, filters }
|
||||
*/
|
||||
export function getTeams(params) {
|
||||
return request.get('/admin/teams', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 Excel 批量创建账号
|
||||
* @param {FormData} formData - 包含 Excel 文件的 FormData
|
||||
@@ -28,8 +37,14 @@ export function importExcel(formData) {
|
||||
// 注意:不要手动设置 Content-Type。axios/浏览器在发送 FormData 时会自动补上
|
||||
// `multipart/form-data; boundary=...`;手动写死 'multipart/form-data'(无 boundary)
|
||||
// 会导致后端 getFileStream 解析失败(Missing boundary)-> 500,且永不落库。
|
||||
//
|
||||
// silent: true —— 抑制响应拦截器的通用错误提示。本接口的**失败提示由调用方负责**:
|
||||
// 后端刻意只回传纯错误(admin.js importExcel catch 注释:「前端已统一加
|
||||
// 「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀」),故此处必须静默,
|
||||
// 否则会与 Admin.vue 的「Excel 导入失败:<原因>」构成同族双弹。
|
||||
return request.post('/admin/import-excel', formData, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,8 +53,8 @@ export function importExcel(formData) {
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise} 作品列表
|
||||
*/
|
||||
export function getSubmissions(group) {
|
||||
return request.get(`/admin/submissions/${group}`)
|
||||
export function getSubmissions(group, params) {
|
||||
return request.get(`/admin/submissions/${group}`, { params })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +68,11 @@ export function getTimes() {
|
||||
/**
|
||||
* 设置比赛时间
|
||||
* @param {Object} data - { zhongzhi: {start, end}, gaozhi: ... }
|
||||
* @param {Object} [expected] - B② 乐观锁:打开表单时读到的旧值
|
||||
* { zhongzhi: { start, end, deadline }, ... };不传 ⇒ 后端不校验(旧行为)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function setTimes(data) {
|
||||
export function setTimes(data, expected) {
|
||||
// 后端要求 times 数组,格式:[{ group, startTime, endTime, deadline, choiceCount, judgeCount }]
|
||||
// 注意:必须携带 choiceCount / judgeCount,否则后端按缺省 50/20 处理,后台「抽取数量」配置无法落库
|
||||
const times = Object.entries(data).map(([group, item]) => ({
|
||||
@@ -66,7 +83,21 @@ export function setTimes(data) {
|
||||
choiceCount: item.choiceCount != null ? parseInt(item.choiceCount, 10) : 50,
|
||||
judgeCount: item.judgeCount != null ? parseInt(item.judgeCount, 10) : 20,
|
||||
}))
|
||||
return request.put('/admin/times', { times })
|
||||
const payload = { times }
|
||||
// ⛔ 只在真的有基线时才带:空对象会让后端误以为「所有组都要求校验」而恒冲突
|
||||
if (expected && Object.keys(expected).length > 0) payload.expectedTimes = expected
|
||||
return request.put('/admin/times', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点备份调度器状态(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 })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,8 +165,26 @@ export function setQuestions(group, questionType, data) {
|
||||
*/
|
||||
export function importQuestionsExcel(group, formData) {
|
||||
// 同上:不要手动设置 Content-Type,避免缺失 boundary 导致后端解析 500。
|
||||
// silent: true —— 同 importExcel:失败提示由调用方(Admin.vue onExcelFileChange)
|
||||
// 以「Excel 导入失败:<原因>」形式统一给出,此处静默以避免同族双弹。
|
||||
return request.post(`/admin/exam/${group}/questions/import-excel`, formData, {
|
||||
timeout: 120000,
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合大题 Excel 批量导入(R73)
|
||||
* 首 sheet 为数据表(表头在第 1 行),可携带「填写说明」sheet
|
||||
* @param {string} group - 组别标识(zhongzhi / gaozhi)
|
||||
* @param {FormData} formData - 包含 Excel 文件的 FormData(字段名 file)
|
||||
* @returns {Promise} { imported, skipped, errors: [{ row, message }] }
|
||||
*/
|
||||
export function importBigQuestionsExcel(group, formData) {
|
||||
// silent: true —— 与 importQuestionsExcel 同口径:错误提示由调用方统一给出,避免同族双弹
|
||||
return request.post(`/admin/big-questions/${group}/import-excel`, formData, {
|
||||
timeout: 300000,
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,8 +256,8 @@ export function deleteMaterial(group, fileName) {
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise} 成绩列表
|
||||
*/
|
||||
export function getScores(group) {
|
||||
return request.get(`/admin/scores/${group}`)
|
||||
export function getScores(group, params) {
|
||||
return request.get(`/admin/scores/${group}`, { params })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +282,91 @@ export function getPublishStatus(group) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 新闻管理 ====================
|
||||
|
||||
/**
|
||||
@@ -280,6 +414,28 @@ export function updateNewsOrder(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,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 轮播图管理 ====================
|
||||
|
||||
/**
|
||||
@@ -341,10 +497,19 @@ export function toggleTestFlag(id, testFlag) {
|
||||
|
||||
/**
|
||||
* 重置测试数据(仅重置 test_flag=1 的选手及其提交记录)
|
||||
* @returns {Promise}
|
||||
*
|
||||
* returnMessage: true —— 保留后端 message(不再被响应拦截器丢弃)。
|
||||
* 后端提示含「已恢复为『未激活 + 默认口令 X』,需先用默认口令完成短信激活即可登录重测」
|
||||
* 这一关键指引;前端自拼文案曾写成「可直接用默认口令登录重测」,
|
||||
* 与后端 activated:false 自相矛盾(用户反馈 Req2)。以后端文案为唯一真相源。
|
||||
* @returns {Promise<{code:number,message:string,data:object}>}
|
||||
*/
|
||||
export function resetTestData() {
|
||||
return request.post('/admin/reset-test-data')
|
||||
// silent: true —— 抑制响应拦截器(request.js)的通用错误提示。
|
||||
// 本调用方在 Admin.vue 的 catch 里已按上下文弹一次「重置失败:<后端原文>」,
|
||||
// 若不再静默,同一 403(生产环境禁止重置测试数据)会被拦截器与 catch 各弹一次,
|
||||
// 用户会看到两条完全相同的报错(R58 用户反馈)。
|
||||
return request.post('/admin/reset-test-data', null, { returnMessage: true, silent: true })
|
||||
}
|
||||
|
||||
// ==================== 综合大题管理(中职/高职) ====================
|
||||
@@ -435,6 +600,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,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 成绩复核 ====================
|
||||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
|
||||
@@ -470,4 +690,81 @@ export function getContestantScore(group, contestantId) {
|
||||
return request.get(`/admin/review/score/${group}/${contestantId}`)
|
||||
}
|
||||
|
||||
// ==================== 选手信息编辑(R108 新增) ====================
|
||||
|
||||
/**
|
||||
* 编辑已入库选手的身份字段(姓名 / 手机号 / 身份证号)
|
||||
*
|
||||
* 背景:此前选手信息只有「Excel 导入」一个写入口,录错了既改不了也删不掉。
|
||||
*
|
||||
* silent: true —— 抑制响应拦截器的通用错误提示,由调用方按业务码分支提示。
|
||||
* 本接口的失败形态是**业务性**的(400 入参非法 / 404 选手不存在 / 409 证号被占用),
|
||||
* 后端 message 已含可读原因(如「身份证号 X 已被占用:张三 / id=123 / 组别=zhongzhi」),
|
||||
* 拦截器再弹一次「请求失败」即为同族双弹(见 frontend-error-notify.md 口径)。
|
||||
*
|
||||
* @param {number|string} id - contestants.id
|
||||
* @param {{name?: string, phone?: string, id_number?: string}} payload
|
||||
* 仅这三项可改;传其他字段后端会显式 400 拒绝(调用方切勿透传整行对象,
|
||||
* 否则 group / score 等只读字段会把请求打成 400)。
|
||||
* @returns {Promise<{code:number,message:string,data:{
|
||||
* ok:boolean, changed:string[], warnings:string[],
|
||||
* contestant:{id:number,name:string,phone:string,id_number:string,group:string,
|
||||
* team_id:number|null,activated:boolean,test_flag:number}
|
||||
* }}>}
|
||||
* `changed` 为空数组 = 提交值与库内一致,未落库(后端 message 为「未检测到需要修改的内容」)。
|
||||
* `warnings` 非空须展示(典型:改号后口令不重置,需走「忘记密码」)。
|
||||
*/
|
||||
export function updateContestant(id, payload) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -148,6 +148,21 @@ export function initProactiveTokenRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 错误提示口径(R64/R65 收敛 · 全站唯一权威) ====================
|
||||
// 🔴 本拦截器已覆盖**全部**失败路径,且每种失败**只弹一次**:
|
||||
// 业务码 ≠0/≠200 → `res.message`;403/404/500 → 固定文案;其它 HTTP 码 →
|
||||
// `data.message`;超时(ECONNABORTED);无 response 的网络层错误。
|
||||
// ⇒ **调用方 catch 内不要再 `ElMessage.error(...)`**,否则用户同时看到两条 = 同族双弹。
|
||||
// ⇒ 已按此口径收敛:Admin.vue、Login.vue、Activate.vue、ForgotPassword.vue、
|
||||
// Competition.vue、Dashboard.vue。
|
||||
// 例外(**必须**由调用方负责提示,出现时须显式注明原因):
|
||||
// ① 请求带 `silent: true` ⇒ 拦截器不说话,调用方是唯一提示方
|
||||
// (登录 / 交卷 / 逐题保存 / 大题加载与保存 / 两个 Excel 导入 / resetTestData);
|
||||
// ② 客户端校验失败(**根本没发请求**);
|
||||
// ③ 非 axios 路径:`utils/download.js`(独立实例、不挂本拦截器)、原生 `fetch`、
|
||||
// el-upload 的 on-success 手工判响应;
|
||||
// ④ 401 由 `forceLogout()` 提示,**不受 `silent` 影响**(token 真失效必须让用户看到)。
|
||||
|
||||
// ==================== 响应拦截器 ====================
|
||||
|
||||
request.interceptors.response.use(
|
||||
@@ -168,6 +183,13 @@ request.interceptors.response.use(
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
// 正常返回,只暴露 data 部分
|
||||
// 例外:调用方显式传 config.returnMessage = true 时返回**整个业务体**(含 message)。
|
||||
// 用途:需要原样透传后端提示文案的场景(如「重置测试数据」的结果指引含
|
||||
// 「需先用默认口令完成短信激活」等关键信息,前端自拼文案会与后端契约漂移)。
|
||||
// ⛔ 默认行为完全不变(不传该开关时仍只返回 data)。
|
||||
if (response.config && response.config.returnMessage) {
|
||||
return res
|
||||
}
|
||||
return res.data !== undefined ? res.data : res
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import request from './request'
|
||||
/**
|
||||
* 获取上传状态
|
||||
* @param {string} group - 组别标识
|
||||
* @returns {Promise} { submitted, deadline, canSubmit }
|
||||
* @param {Object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器报错)
|
||||
* @returns {Promise} { submitted, deadline, canSubmit, canUpload, teamLocked, teamLockMessage, teamFiles, ... }
|
||||
*/
|
||||
export function getUploadStatus(group) {
|
||||
return request.get(`/upload/${group}/status`)
|
||||
export function getUploadStatus(group, config = {}) {
|
||||
return request.get(`/upload/${group}/status`, config)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -19,6 +19,7 @@ declare module 'vue' {
|
||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
|
||||
@@ -5,7 +5,18 @@
|
||||
=========================================================== */
|
||||
|
||||
/* ============ 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;
|
||||
/* 辅助色:金色,用于强调、高亮、获奖标识等 */
|
||||
@@ -371,7 +382,14 @@ input:-webkit-autofill {
|
||||
本规则只改背景、不动文字颜色,故不会影响任何页面的文字配色设计。
|
||||
新增页面自动继承此保障,无需逐页排查。
|
||||
=========================================================== */
|
||||
.el-button.is-text:not(.is-disabled):hover,
|
||||
.el-button.is-text:not(.is-disabled):focus {
|
||||
/* 🔴 选择器前缀 :root 是**必需**的(2026-10-21 R188 实测),不能省:
|
||||
本规则与 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);
|
||||
|
||||
}
|
||||
@@ -95,6 +95,32 @@ function getHeader(headers, name) {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取服务端声明的「真实字节数」哨兵(下载完整性校验用)。
|
||||
*
|
||||
* 🔴 R103(2026-10-12 生产实测):X-Accel-Redirect 分支下 nginx 会**丢弃上游自定义响应头**。
|
||||
* 双通道实测同一文件:直连后端 ⇒ 带 `x-file-size: 19009`;经 nginx ⇒ 返回 19009 字节
|
||||
* 但该头缺失(而 `Content-Disposition` 能穿透)。
|
||||
* ⇒ 后端把同一字节数同时写入 RFC 6266 §4.1 的 disposition 扩展参数 `size=`,
|
||||
* 这里按「x-file-size → CD 的 size=」顺序回落;两通道同源同值,任一缺失只降级不误伤。
|
||||
*
|
||||
* 判据可靠性:`filename*=` 的值是百分号编码的,其中不可能出现字面量 `size=`,
|
||||
* 且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。
|
||||
*
|
||||
* @returns {number} 声明字节数;两通道都取不到时返回 NaN(调用方跳过校验,优雅降级)
|
||||
*/
|
||||
function readDeclaredSize(headers) {
|
||||
const direct = parseInt(getHeader(headers, 'x-file-size'), 10)
|
||||
if (Number.isFinite(direct) && direct > 0) return direct
|
||||
const cd = getHeader(headers, 'content-disposition')
|
||||
const m = /(?:^|;)\s*size\s*=\s*(\d+)\s*(?:;|$)/i.exec(cd || '')
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return NaN
|
||||
}
|
||||
|
||||
// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
|
||||
function normalizeUrl(url) {
|
||||
if (url && url.startsWith('/api')) return url.slice(4)
|
||||
@@ -155,8 +181,10 @@ function triggerDownload(blob, filename, originalUrl) {
|
||||
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
|
||||
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
|
||||
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <html 开头;
|
||||
// ② 大小哨兵:后端透出 X-File-Size(服务端 stat 的真实字节数),与 blob.size 比对,
|
||||
// 不一致即说明内容被替换或传输被截断。该头缺失时跳过此校验(优雅降级,不误伤)。
|
||||
// ② 大小哨兵:后端透出「服务端 stat 的真实字节数」,与 blob.size 比对,
|
||||
// 不一致即说明内容被替换或传输被截断。取头顺序(见 readDeclaredSize):
|
||||
// X-File-Size → Content-Disposition 的 `size=` 扩展参数 —— 两通道同源同值,
|
||||
// 都取不到时跳过此校验(优雅降级,不误伤)。
|
||||
async function assertRealFile(res) {
|
||||
const blob = res.data
|
||||
if (!blob) return '下载失败:响应内容为空'
|
||||
@@ -176,7 +204,9 @@ async function assertRealFile(res) {
|
||||
}
|
||||
|
||||
// ② 大小哨兵:与服务端 stat 的真实字节数比对
|
||||
const declared = parseInt(getHeader(res.headers, 'x-file-size'), 10)
|
||||
// 🔴 R103:头来源改为 readDeclaredSize —— 优先 x-file-size,缺失时回落 Content-Disposition
|
||||
// 的 `size=` 扩展参数(X-Accel 分支下 nginx 会丢掉前者,见 readDeclaredSize 注释)。
|
||||
const declared = readDeclaredSize(res.headers)
|
||||
if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
|
||||
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
|
||||
}
|
||||
|
||||
36
src/utils/passwordField.js
Normal file
36
src/utils/passwordField.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* passwordField.js — 密码输入框「反浏览器自动填充」兜底
|
||||
*
|
||||
* 背景(2026-10-08 测试人员反馈):
|
||||
* 激活页 / 忘记密码页进入「设置密码」步骤时,输入框内已存在内容(形如 123456),
|
||||
* 而用户尚未进行任何输入。经全仓取证:前端不存在任何向密码框写值的赋值点
|
||||
* (密码框初值为空、autocomplete="new-password"、无 :value 绑定、无历史版本预填),
|
||||
* 故残留只能来自浏览器密码管理器 —— 测试账号的默认口令恰为 123456,
|
||||
* 浏览器保存后会在同源同类表单上自动回填。
|
||||
*
|
||||
* 治法:在「进入设置密码步骤」时做一次确定性兜底清理。
|
||||
*
|
||||
* ⚠️ 安全前提:仅当表单模型本身为空时才清 DOM(isModelEmpty 为真),
|
||||
* 因此绝不会覆盖用户已经真实输入的内容。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 清除浏览器在密码框上留下的自动填充值。
|
||||
*
|
||||
* @param {string} scopeSelector 限定范围(默认 body 即整页)
|
||||
* @param {boolean} isModelEmpty 表单模型是否为空;为 false 时整体跳过,不动 DOM
|
||||
* @returns {number} 实际清除的输入框数量(便于验证/日志)
|
||||
*/
|
||||
export function purgeAutofilledPasswords(scopeSelector = 'body', isModelEmpty = true) {
|
||||
if (!isModelEmpty || typeof document === 'undefined') return 0
|
||||
let cleared = 0
|
||||
document.querySelectorAll(`${scopeSelector} input[type="password"]`).forEach((el) => {
|
||||
if (el.value) {
|
||||
el.value = ''
|
||||
// 同步通知 v-model:此时模型本为空,回调只是把状态与 DOM 拉回一致
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
cleared++
|
||||
}
|
||||
})
|
||||
return cleared
|
||||
}
|
||||
@@ -135,6 +135,7 @@
|
||||
:model="step3Form"
|
||||
:rules="step3Rules"
|
||||
label-position="top"
|
||||
autocomplete="off"
|
||||
>
|
||||
<!-- 基本信息确认 -->
|
||||
<div class="info-confirm">
|
||||
@@ -149,16 +150,21 @@
|
||||
<el-input
|
||||
v-model="step3Form.password"
|
||||
type="password"
|
||||
placeholder="请设置登录密码(至少6位)"
|
||||
autocomplete="new-password"
|
||||
placeholder="请设置登录密码(6-20 位)"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
<!-- 弱口令示例放在独立提示行,而不是 placeholder:
|
||||
写在 placeholder 里会被用户误读成「默认密码就是 123456」(2026-10-08 测试人员反馈) -->
|
||||
<div class="pwd-tip">不得使用 123456 等常见弱口令</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="step3Form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
@@ -197,6 +203,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ===== 错误提示口径(R65 收敛)=====
|
||||
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
|
||||
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
|
||||
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
|
||||
/**
|
||||
* Activate.vue - 账号激活页面(公开访问)
|
||||
*
|
||||
@@ -205,14 +215,21 @@
|
||||
* 步骤2:发送短信验证码 -> 输入 6 位验证码 -> 验证
|
||||
* 步骤3:确认基本信息 -> 设置密码 + 确认密码 -> 完成激活
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { purgeAutofilledPasswords } from '@/utils/passwordField'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
|
||||
import * as authApi from '@/api/auth'
|
||||
import { showTestSmsCode } from '@/utils/testSms'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
// 激活页语义 = 未登录。必须主动清除浏览器中可能残留的登录态:
|
||||
// 否则激活成功/点「返回登录」跳 /login 时,router guard(router/index.js:203-222)
|
||||
// 见到 isLoggedIn && role==='admin' 会把用户重定向进 /admin —— 公用电脑上
|
||||
// 前一位管理员未退出时,下一位激活的考生会被直接送进管理后台且接口权限可用(R61)。
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
@@ -369,8 +386,8 @@ async function handleStep1() {
|
||||
currentStep.value = 1
|
||||
// 启动倒计时
|
||||
startSmsCountdown()
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
// 刷新验证码
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
@@ -386,7 +403,7 @@ async function handleStep2() {
|
||||
// 验证短信码(此处通过获取用户信息间接验证)
|
||||
step2Loading.value = true
|
||||
// 短信码的验证由后端在激活时完成,此处仅进入下一步
|
||||
currentStep.value = 2
|
||||
enterStep3()
|
||||
ElMessage.success('短信验证通过')
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
@@ -395,6 +412,22 @@ async function handleStep2() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入「设置密码」步骤。
|
||||
*
|
||||
* 到达该步骤时输入框必须为空(2026-10-08 测试人员反馈:进入本步骤时密码框内已出现 123456)。
|
||||
* 前端源码不存在任何回填密码的赋值点(初值为空 + autocomplete=new-password + 无历史预填版本),
|
||||
* 残留只可能来自浏览器密码管理器自动填充(测试账号默认口令恰为 123456,会被浏览器保存并回填),
|
||||
* 故此处做确定性兜底,保证「到达即空」;实现与前提见 utils/passwordField.js。
|
||||
*/
|
||||
function enterStep3() {
|
||||
step3Form.password = ''
|
||||
step3Form.confirmPassword = ''
|
||||
currentStep.value = 2
|
||||
// DOM 挂载后再兜底一次:仅当表单模型为空、DOM 却有值时清除,绝不覆盖用户真实输入
|
||||
nextTick(() => purgeAutofilledPasswords('.step-card', !step3Form.password && !step3Form.confirmPassword))
|
||||
}
|
||||
|
||||
/** 步骤3:完成激活 */
|
||||
async function handleActivate() {
|
||||
if (!step3FormRef.value) return
|
||||
@@ -408,12 +441,15 @@ async function handleActivate() {
|
||||
smsCode: step2Form.smsCode,
|
||||
})
|
||||
ElMessage.success('账号激活成功!请登录参赛')
|
||||
// 兜底再清一次(R61):确保跳转瞬间 localStorage 内无残留登录态,
|
||||
// 否则 /login 的守卫会把用户送进 /admin。
|
||||
authStore.clearAuth()
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
} finally {
|
||||
step3Loading.value = false
|
||||
}
|
||||
@@ -430,8 +466,8 @@ async function resendSms() {
|
||||
}
|
||||
ElMessage.success('短信验证码已重新发送')
|
||||
startSmsCountdown()
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
refreshCaptcha()
|
||||
}
|
||||
}
|
||||
@@ -452,6 +488,9 @@ function startSmsCountdown() {
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 进入激活页即按「未登录」处理(R61):清掉残留 token/user,
|
||||
// 覆盖两条会跳 /login 的路径 —— ①本页「返回登录」按钮 ②激活成功后的跳转。
|
||||
authStore.clearAuth()
|
||||
refreshCaptcha()
|
||||
})
|
||||
|
||||
@@ -632,6 +671,13 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.pwd-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
|
||||
2485
src/views/Admin.vue
2485
src/views/Admin.vue
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,46 @@
|
||||
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
|
||||
<el-card class="upload-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="upload-header">
|
||||
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
|
||||
<!-- R192:页面级常驻出口。本页无全局导航(App.vue 只有 router-view),
|
||||
组锁 / 倒计时归零 / 只是想放弃提交 的用户若无此按钮就只能在浏览器后退,
|
||||
移动端甚至退不出去(体验反馈:成员进入后被锁在提交页)。 -->
|
||||
<el-button text type="primary" :icon="Back" @click="leaveCompetition">返回工作台</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
||||
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
|
||||
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
|
||||
</el-alert>
|
||||
|
||||
<!-- R190:小组作品状态(组内互斥)—— 组内已有人上传后,其他成员不能再传。
|
||||
文案直接取后端 teamLockMessage,⛔ 前端不另拼一套口径(避免与后端判定漂移)。 -->
|
||||
<div
|
||||
v-if="teamPanelVisible"
|
||||
class="team-status-panel"
|
||||
:class="{ 'is-locked': teamLocked }"
|
||||
>
|
||||
<div class="team-status-head">
|
||||
<span class="team-status-title">
|
||||
<template v-if="teamLocked">本组作品已由其他成员上传,您不能再上传</template>
|
||||
<template v-else-if="uploadStatus.teamSubmitted">本组作品已由您上传,截止前可随时替换</template>
|
||||
<template v-else>本组尚无人上传作品</template>
|
||||
</span>
|
||||
<el-tag v-if="uploadStatus.teamNumber" size="small" type="info">小组编号 {{ uploadStatus.teamNumber }}</el-tag>
|
||||
<el-tag v-if="uploadStatus.memberCount > 1" size="small" type="info">组内 {{ uploadStatus.memberCount }} 人</el-tag>
|
||||
</div>
|
||||
<div v-if="uploadStatus.teamLockMessage" class="team-status-msg">{{ uploadStatus.teamLockMessage }}</div>
|
||||
<div v-if="teamSharedFiles.length" class="team-status-files">
|
||||
<div class="pending-list-title">组内已上传文件:</div>
|
||||
<div v-for="(f, i) in teamSharedFiles" :key="i" class="team-status-file">
|
||||
<span class="uploaded-name">{{ f.fileName }}</span>
|
||||
<el-tag size="small" :type="categoryTagType(categoryOf(f.fileName))">{{ categoryLabel(categoryOf(f.fileName)) }}</el-tag>
|
||||
<span class="team-status-uploader">上传者:{{ f.uploaderName || '组内成员' }}{{ f.isMine ? '(我)' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
@@ -34,6 +68,7 @@
|
||||
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
|
||||
:file-list="uploadFileList"
|
||||
:on-change="onUploadFileChange"
|
||||
:disabled="teamLocked"
|
||||
multiple
|
||||
drag
|
||||
class="upload-drag"
|
||||
@@ -67,9 +102,25 @@
|
||||
</div>
|
||||
|
||||
<div class="upload-actions">
|
||||
<el-button type="primary" :loading="uploading" :disabled="pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
||||
<el-button type="primary" :loading="uploading" :disabled="teamLocked || pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
||||
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
|
||||
</div>
|
||||
<!-- 被组锁时,明确告知「为什么按钮点不动」,避免用户以为是页面故障 -->
|
||||
<el-alert
|
||||
v-if="teamLocked"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-top:12px"
|
||||
title="组内作品唯一:本组已有成员上传,您无法再上传"
|
||||
description="如需替换作品,请联系上传者本人操作(仅首个上传者可在截止前替换)。您本人无需提交,可直接返回工作台。"
|
||||
/>
|
||||
<!-- 组锁的出口按钮必须**紧贴提示**:视线焦点在这里,且此处的「返回」不弹
|
||||
「丢失提交进度」确认(团队作品已由队友提交,本机没有可丢的内容)。 -->
|
||||
<div v-if="teamLocked" class="team-locked-actions">
|
||||
<el-button type="primary" @click="leaveCompetition">返回工作台</el-button>
|
||||
<span class="team-locked-hint">本组作品已由队友提交,您无需任何操作。</span>
|
||||
</div>
|
||||
|
||||
<el-divider v-if="uploadedWorks.length" />
|
||||
<div v-if="uploadedWorks.length" class="uploaded-list">
|
||||
@@ -347,6 +398,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ===== 错误提示口径(R65 收敛)=====
|
||||
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
|
||||
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
|
||||
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
|
||||
/**
|
||||
* Competition.vue - 在线答题页面
|
||||
*
|
||||
@@ -362,7 +417,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||
import { Timer, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { Timer, UploadFilled, Back } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import * as examApi from '@/api/exam'
|
||||
@@ -392,6 +447,31 @@ const uploadFileList = ref([]) // el-upload 绑定列表
|
||||
const uploadedWorks = ref([])
|
||||
const uploadRef = ref(null)
|
||||
|
||||
// ---------- R190:小组作品状态(组内互斥)----------
|
||||
// 后端 `GET /api/upload/:group/status` 是组锁判定的**唯一权威**,前端只负责展示:
|
||||
// teamLocked / teamLockMessage / teamFiles 等一律原样取用,⛔ 不在前端重算一套规则
|
||||
//(重算必然与后端漂移,且学生端与下载端的「谁是归属人」会打架)。
|
||||
const uploadStatus = ref({
|
||||
submitted: false,
|
||||
canUpload: true,
|
||||
deadlineValid: true,
|
||||
teamNumber: null,
|
||||
folderName: '',
|
||||
memberCount: 1,
|
||||
teamSubmitted: false,
|
||||
teamOwnerName: null,
|
||||
isTeamOwner: false,
|
||||
teamLocked: false,
|
||||
teamLockMessage: null,
|
||||
teamFiles: [],
|
||||
})
|
||||
const teamLocked = computed(() => !!uploadStatus.value.teamLocked)
|
||||
// 有小组编号 or 组内多于一人时展示小组状态面板;独立参赛者保持原界面不变
|
||||
const teamPanelVisible = computed(() => !!uploadStatus.value.teamNumber || uploadStatus.value.memberCount > 1)
|
||||
// 被锁时才展示「组内已上传文件」——归属人(本人)在下方「已上传文件」里已有可删除列表,
|
||||
// 两处同列会重复且按钮语义不同,容易误删。
|
||||
const teamSharedFiles = computed(() => (teamLocked.value ? (uploadStatus.value.teamFiles || []) : []))
|
||||
|
||||
const ZIP_MAX = 200 * 1024 * 1024
|
||||
const OTHER_MAX = 100 * 1024 * 1024
|
||||
|
||||
@@ -448,6 +528,36 @@ function clearUploadFiles() {
|
||||
uploadRef.value?.clearFiles?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回工作台(R192)—— 上传模式的统一出口。
|
||||
*
|
||||
* 🔴 必须先 resetExam() 再 push,理由有三条:
|
||||
* ① 不清 `examStarted` ⇒ `onBeforeRouteLeave` 会再弹一次「离开页面将丢失当前提交进度」。
|
||||
* 上传模式下用户常常只是放弃提交(甚至被组锁、本机根本没有可提交内容),
|
||||
* 这句文案既失真又构成二次打扰;
|
||||
* ② 不清 timer ⇒ store 是全局单例,回到工作台后倒计时仍在跑,下次进入会看到陈旧剩余时间;
|
||||
* ③ 用 resetExam(而非只 stopExam)与 `onBeforeRouteLeave` 里确认离开后的清理**同一口径**,
|
||||
* ⛔ 不留「按钮退出」与「路由守卫退出」两套清理逻辑(漏项会以「下次进入状态诡异」的形式出现)。
|
||||
*
|
||||
* 只有「待上传列表里有已选文件」才算真有内容会丢,此时才确认一次。
|
||||
* 组锁用户的上传控件是 disabled 的,选不出文件 ⇒ 不弹确认,直接离开。
|
||||
*/
|
||||
async function leaveCompetition() {
|
||||
if (!teamLocked.value && uploadFileList.value.length > 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'已选择的文件尚未上传,离开后需要重新选择。确定返回工作台吗?',
|
||||
'提示',
|
||||
{ confirmButtonText: '返回工作台', cancelButtonText: '继续上传', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
examStore.resetExam()
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
async function submitUploadFiles() {
|
||||
const files = pendingUploadFiles.value
|
||||
if (!files.length) return
|
||||
@@ -479,10 +589,11 @@ async function submitUploadFiles() {
|
||||
ElMessage.success('上传成功,作品已提交')
|
||||
uploadFileList.value = []
|
||||
uploadRef.value?.clearFiles?.()
|
||||
await fetchUploadedWorks()
|
||||
// 上传成功后必须同步组锁状态:归属人变成本人、teamSubmitted 变 true(R190)
|
||||
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
|
||||
examStore.stopExam()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
return
|
||||
} finally {
|
||||
uploading.value = false
|
||||
@@ -506,9 +617,14 @@ async function deleteUploadedWork(w, silent = false) {
|
||||
try {
|
||||
await uploadApi.deleteWork(authStore.userGroup, w.id)
|
||||
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
|
||||
if (!silent) ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
// 🔴 只在「用户手动删除」时刷新组锁状态:silent=true 的批量删除是 submitUploadFiles
|
||||
// 的中间步骤,此时并发刷新可能在上传完成后才返回旧快照,把面板刷回陈旧结论。
|
||||
if (!silent) {
|
||||
ElMessage.success('已删除')
|
||||
fetchUploadStatus()
|
||||
}
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
} finally {
|
||||
w.deleting = false
|
||||
}
|
||||
@@ -522,6 +638,31 @@ async function fetchUploadedWorks() {
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取小组提交状态(R190)。
|
||||
* 🔴 失败**不阻断**上传面板:状态查询只是辅助展示,真正的上传校验在后端(6006)。
|
||||
* 用 silent 抑制拦截器提示,避免辅助查询失败时给考生弹一条看不懂的报错。
|
||||
*/
|
||||
async function fetchUploadStatus() {
|
||||
try {
|
||||
const d = (await uploadApi.getUploadStatus(authStore.userGroup, { silent: true })) || {}
|
||||
uploadStatus.value = {
|
||||
submitted: !!d.submitted,
|
||||
canUpload: d.canUpload !== false,
|
||||
deadlineValid: d.deadlineValid !== false,
|
||||
teamNumber: d.teamNumber || null,
|
||||
folderName: d.folderName || '',
|
||||
memberCount: Number(d.memberCount) || 1,
|
||||
teamSubmitted: !!d.teamSubmitted,
|
||||
teamOwnerName: d.teamOwnerName || null,
|
||||
isTeamOwner: !!d.isTeamOwner,
|
||||
teamLocked: !!d.teamLocked,
|
||||
teamLockMessage: d.teamLockMessage || null,
|
||||
teamFiles: Array.isArray(d.teamFiles) ? d.teamFiles : [],
|
||||
}
|
||||
} catch (e) { /* 辅助状态查询失败保持默认(不锁),真正拦截在后端 */ }
|
||||
}
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -932,7 +1073,13 @@ function preventKeyboard(e) {
|
||||
|
||||
// 路由离开警告
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
if (examStore.examStarted && !examStore.isTimeUp) {
|
||||
// 🔴 组锁用户不弹:上传控件 disabled、无交卷动作 ⇒ 本机没有任何可丢的内容。
|
||||
// 对这类用户弹「离开将丢失提交进度」是假警告(体验反馈:被锁成员被困在提交页)。
|
||||
// 判定写成「上传模式 && teamLocked」而不是只看 teamLocked:后者当前虽等价
|
||||
// (客观题模式不拉 uploadStatus),但语义上把考试组也纳入了豁免面,将来一旦
|
||||
// 考试组也拉 status,就会静默取消「交卷前离开」的确认 —— 这是不可接受的放松。
|
||||
const lockedNoContent = isUploadMode.value && teamLocked.value
|
||||
if (examStore.examStarted && !examStore.isTimeUp && !lockedNoContent) {
|
||||
const verb = isUploadMode.value ? '提交' : '答题'
|
||||
ElMessageBox.confirm(
|
||||
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
|
||||
@@ -976,9 +1123,10 @@ onMounted(async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 统一入口校验:考试组(中职/高职)与提交组(本科/教师)一致——
|
||||
// 仅「截止后(status==='ended')」禁止进入;考试时间内(含已提交 / 意外退出)均可重新进入,
|
||||
// 应对断网、断电、误操作导致的意外退出(重进后由下方逻辑恢复已答题目)。
|
||||
// 统一入口校验:
|
||||
// · 「截止后(status==='ended')」一律禁止进入;
|
||||
// · 考试组(中职/高职)「已正式交卷」禁止再进入(见下方 #3 判定);
|
||||
// · 其余情况(未交卷的意外退出)可重进,由下方逻辑恢复已答题目。
|
||||
const status = await examApi.getExamStatus(group)
|
||||
if (status.status === 'ended') {
|
||||
ElMessage.warning(isExam ? '考试已结束,无法再次进入' : '作品提交已截止,无法再次进入')
|
||||
@@ -986,10 +1134,18 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔴 考试组(中职/高职)为一次性答题:**正式交卷后禁止再进入**(测试反馈 #3)。
|
||||
// 只拦「已交卷」(submitted 仅在 submit_time 落库后为 true);未交卷的意外退出
|
||||
// (断网/断电/误操作)仍可重进并恢复作答,保留原有的容错能力。
|
||||
// 提交组(本科/教师)不受此限,截止前可重新进入重传作品。
|
||||
if (isExam && status.submitted) {
|
||||
ElMessage.warning('您已提交试卷,无法再次进入考试')
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (isExam) {
|
||||
// 截止前可随意重新进入(含已提交 / 意外退出后重进),与顶部注释一致;
|
||||
// 仅「截止后(status==='ended')」禁止进入(见上方判断)。考试组仍受团队锁约束,
|
||||
// 同一时刻仅一名成员在考,避免重复进入互相干扰。
|
||||
// 考试组进入前仍受团队锁约束:同一时刻仅一名成员在考,避免重复进入互相干扰。
|
||||
// 考试组(中职/高职):检查团队锁与活跃状态后进入考试
|
||||
if (status.teamLocked) {
|
||||
ElMessage.warning(`您的小组已由 ${status.teamLockedBy || '队友'} 进入考试,不能重复进入`)
|
||||
@@ -1065,15 +1221,16 @@ onMounted(async () => {
|
||||
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
|
||||
uploadPanelLoading.value = true
|
||||
try {
|
||||
await fetchUploadedWorks()
|
||||
// 两个查询互不依赖,并行发起:作品列表 + 组锁状态(R190)
|
||||
await Promise.all([fetchUploadedWorks(), fetchUploadStatus()])
|
||||
} finally {
|
||||
uploadPanelLoading.value = false
|
||||
}
|
||||
examStore.startUploadCountdown(group)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('加载考试信息失败')
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
router.push('/dashboard')
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -1289,15 +1446,31 @@ onUnmounted(() => {
|
||||
.options-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 覆盖 Element Plus `.el-radio-group{align-items:center}`:该声明会让各子项
|
||||
按自身内容宽度收缩并居中,导致同题选项框宽窄不一(测试反馈 #1)。
|
||||
显式 stretch 后子项统一撑满容器宽度。 */
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
/* 以「字符最长的选项」的边框宽度为统一宽度:容器收缩到最长子项(max-content),
|
||||
再整体水平居中;文字在框内左对齐。 */
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.option-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
/* 超长选项不得撑破卡片:允许换行,避免 max-content 溢出 */
|
||||
.options-group :deep(.el-radio__label) {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.option-item:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
@@ -1454,6 +1627,81 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ==================== R192:上传页出口(组锁 / 主动放弃提交) ==================== */
|
||||
/* 本页无全局导航(App.vue 只有 router-view)⇒ 出口按钮必须常驻,否则被组锁的
|
||||
成员只能靠浏览器后退离开(移动端/内嵌浏览器常常退不出去)。 */
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.team-locked-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.team-locked-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* ==================== R190:小组作品状态面板 ==================== */
|
||||
.team-status-panel {
|
||||
border: 1px solid #c6e2ff;
|
||||
background: #f0f7ff;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.team-status-panel.is-locked {
|
||||
border-color: #f5dab1;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
.team-status-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.team-status-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.team-status-panel.is-locked .team-status-title {
|
||||
color: #b88230;
|
||||
}
|
||||
.team-status-msg {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
}
|
||||
.team-status-files {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.team-status-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.team-status-file + .team-status-file {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.team-status-uploader {
|
||||
color: #909399;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
|
||||
.file-over-limit {
|
||||
background: #fef0f0;
|
||||
|
||||
@@ -18,16 +18,20 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="exam-desc" v-if="contestStatus === 'ended'">考试已结束,无法再次进入</p>
|
||||
<p class="exam-desc" v-else-if="(!contestTime || contestStatus === 'not_started') && !hasSubmitted">
|
||||
<p class="exam-desc" v-else-if="hasSubmitted && !isUploadGroup">
|
||||
您已提交试卷,本次考试已结束,无法再次进入。
|
||||
</p>
|
||||
<p class="exam-desc" v-else-if="hasSubmitted">您已提交作品,截止前可重新提交。</p>
|
||||
<p class="exam-desc" v-else-if="!contestTime || contestStatus === 'not_started'">
|
||||
{{ isUploadGroup ? '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始提交作品。' : '点击下方按钮进入比赛页面,请确保网络稳定,准备好后开始答题。' }}
|
||||
</p>
|
||||
<p class="exam-desc" v-else-if="!hasSubmitted">请关注比赛时间安排</p>
|
||||
<p class="exam-desc" v-else>请关注比赛时间安排</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="card-action-btn"
|
||||
v-guard="() => enterContest()"
|
||||
:disabled="contestStatus === 'ended'"
|
||||
:disabled="enterDisabled"
|
||||
>
|
||||
{{ enterBtnText }}
|
||||
</el-button>
|
||||
@@ -112,6 +116,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ===== 错误提示口径(R65 收敛)=====
|
||||
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
|
||||
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
|
||||
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
|
||||
/**
|
||||
* Dashboard.vue - 选手工作台
|
||||
*
|
||||
@@ -158,23 +166,43 @@ const isUploadGroup = computed(() => {
|
||||
return g === 'benke' || g === 'jiaoshi'
|
||||
})
|
||||
|
||||
const noScore = computed(() => !scoreData.value || scoreData.value.score === undefined || scoreData.value.score === null)
|
||||
// 🔴 成绩发布门控(铁律15):**必须**同时满足 published === true 才渲染分数。
|
||||
// 历史实现只判 score 是否为 null —— 这与后端「未发布则 score 置 null」的约定形成
|
||||
// 隐式耦合:一旦后端任一通道在未发布状态下带上数值 score,本卡片即直接泄露。
|
||||
// Score.vue 早已用 `v-if="scoreData && scoreData.published"` 同源判定,此处对齐,
|
||||
// 形成「两条展示通道同一判据」的纵深防御。
|
||||
const noScore = computed(() =>
|
||||
!scoreData.value ||
|
||||
scoreData.value.published !== true ||
|
||||
scoreData.value.score === undefined ||
|
||||
scoreData.value.score === null
|
||||
)
|
||||
|
||||
const scoreMessage = computed(() => {
|
||||
if (contestStatus.value === 'ended') return '成绩暂未发布,请关注公告'
|
||||
return '暂无成绩,请耐心等待成绩发布后查看'
|
||||
})
|
||||
|
||||
// 进入比赛按钮文案:截止后→「已结束」;已提交→ 提交组「重新提交」/考试组「继续考试」;
|
||||
// 其余→「进入比赛」。考试组(中职/高职)已提交也可在截止前重进(应对意外退出)。
|
||||
// 进入比赛按钮文案:
|
||||
// 截止后 → 「已结束」(禁用)
|
||||
// 考试组 + 已提交 → 「已提交」(禁用,不可再进入;测试反馈 #3)
|
||||
// 提交组 + 已提交 → 「重新提交」(截止前可重传,用户既定需求)
|
||||
// 其余 → 「进入比赛」
|
||||
// ⚠️ 考试组(中职/高职)为一次性答题:正式交卷后不允许重进(防改答案再交卷)。
|
||||
// 未交卷的意外退出(断网/断电)不受影响——submitted 仅在 submit_time 落库后为 true。
|
||||
const enterBtnText = computed(() => {
|
||||
if (contestStatus.value === 'ended') return '已结束'
|
||||
if (hasSubmitted.value) {
|
||||
return isUploadGroup.value ? '重新提交' : '继续考试'
|
||||
return isUploadGroup.value ? '重新提交' : '已提交'
|
||||
}
|
||||
return '进入比赛'
|
||||
})
|
||||
|
||||
// 考试组已交卷 ⇒ 禁止再次进入
|
||||
const enterDisabled = computed(() =>
|
||||
contestStatus.value === 'ended' || (hasSubmitted.value && !isUploadGroup.value)
|
||||
)
|
||||
|
||||
// 在线答题卡片右上角状态标签:随「考试窗口状态 + 本人是否提交」联动
|
||||
// 后端 getExamStatus 返回 status ∈ {not_set, not_started, in_progress, ended},
|
||||
// 此前前端误判 === 'open' 导致活动期恒定显示「未开始」;现统一在此映射并纳入已提交态。
|
||||
@@ -208,16 +236,24 @@ function formatDate(dateStr) {
|
||||
|
||||
async function enterContest() {
|
||||
// 所有组别统一:仅考试窗口内(isActive)允许进入;截止后(ended)统一禁止。
|
||||
// 考试组/提交组即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
|
||||
// 提交组(本科/教师)即便已提交,也可在截止前重新进入(应对断网/断电/误操作退出)。
|
||||
// 考试组(中职/高职)为一次性答题:**正式交卷后禁止再进入**(测试反馈 #3),
|
||||
// 退出重进只对「未交卷」的情况开放。
|
||||
try {
|
||||
const group = authStore.userGroup || 'zhongzhi'
|
||||
const isExam = group === 'zhongzhi' || group === 'gaozhi'
|
||||
const data = await examApi.getExamStatus(group)
|
||||
if (isExam && data.submitted) {
|
||||
hasSubmitted.value = true
|
||||
ElMessage.warning('您已提交试卷,无法再次进入考试')
|
||||
return
|
||||
}
|
||||
if (!data.isActive) {
|
||||
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.warning('获取考试状态失败,请稍后重试')
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
return
|
||||
}
|
||||
router.push('/competition')
|
||||
|
||||
@@ -134,21 +134,27 @@
|
||||
:model="step3Form"
|
||||
:rules="step3Rules"
|
||||
label-position="top"
|
||||
autocomplete="off"
|
||||
>
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-input
|
||||
v-model="step3Form.password"
|
||||
type="password"
|
||||
placeholder="请设置新密码(至少6位)"
|
||||
autocomplete="new-password"
|
||||
placeholder="请设置新密码(6-20 位)"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
<!-- 弱口令示例放在独立提示行,而不是 placeholder:
|
||||
写在 placeholder 里会被用户误读成「默认密码就是 123456」(2026-10-08 测试人员反馈) -->
|
||||
<div class="pwd-tip">不得使用 123456 等常见弱口令</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="step3Form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="请再次输入新密码"
|
||||
size="large"
|
||||
show-password
|
||||
@@ -187,6 +193,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ===== 错误提示口径(R65 收敛)=====
|
||||
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
|
||||
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
|
||||
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
|
||||
/**
|
||||
* ForgotPassword.vue - 忘记密码页面(公开访问)
|
||||
*
|
||||
@@ -195,7 +205,8 @@
|
||||
* 步骤2:短信验证码验证
|
||||
* 步骤3:设置新密码
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { purgeAutofilledPasswords } from '@/utils/passwordField'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, HomeFilled, Refresh } from '@element-plus/icons-vue'
|
||||
@@ -343,8 +354,8 @@ async function handleStep1() {
|
||||
ElMessage.success('短信验证码已发送')
|
||||
currentStep.value = 1
|
||||
startSmsCountdown()
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
step1Loading.value = false
|
||||
@@ -358,7 +369,7 @@ async function handleStep2() {
|
||||
await step2FormRef.value.validate()
|
||||
step2Loading.value = true
|
||||
// 短信码由后端在重置密码时验证
|
||||
currentStep.value = 2
|
||||
enterStep3()
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} finally {
|
||||
@@ -366,6 +377,22 @@ async function handleStep2() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入「重置密码」步骤。
|
||||
*
|
||||
* 到达该步骤时输入框必须为空(2026-10-08 测试人员反馈:进入本步骤时密码框内已出现 123456)。
|
||||
* 前端源码不存在任何回填密码的赋值点(初值为空 + autocomplete=new-password),
|
||||
* 残留只可能来自浏览器密码管理器自动填充,故此处做确定性兜底,保证「到达即空」;
|
||||
* 实现与前提见 utils/passwordField.js。
|
||||
*/
|
||||
function enterStep3() {
|
||||
step3Form.password = ''
|
||||
step3Form.confirmPassword = ''
|
||||
currentStep.value = 2
|
||||
// DOM 挂载后再兜底一次:仅当表单模型为空、DOM 却有值时清除,绝不覆盖用户真实输入
|
||||
nextTick(() => purgeAutofilledPasswords('.step-card', !step3Form.password && !step3Form.confirmPassword))
|
||||
}
|
||||
|
||||
/** 步骤3:重置密码 */
|
||||
async function handleReset() {
|
||||
if (!step3FormRef.value) return
|
||||
@@ -381,8 +408,8 @@ async function handleReset() {
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
} finally {
|
||||
step3Loading.value = false
|
||||
}
|
||||
@@ -399,8 +426,8 @@ async function resendSms() {
|
||||
}
|
||||
ElMessage.success('短信验证码已重新发送')
|
||||
startSmsCountdown()
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
refreshCaptcha()
|
||||
}
|
||||
}
|
||||
@@ -582,6 +609,13 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pwd-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<el-input
|
||||
v-model="contestantForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
@@ -103,6 +104,7 @@
|
||||
<el-input
|
||||
v-model="adminForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
@@ -137,6 +139,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ===== 错误提示口径(R65 收敛)=====
|
||||
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
|
||||
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
|
||||
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
|
||||
/**
|
||||
* Login.vue - 登录页面(公开访问)
|
||||
*
|
||||
@@ -252,10 +258,8 @@ async function handleAdminLogin() {
|
||||
ElMessage.success('管理员登录成功')
|
||||
const redirect = route.query.redirect || '/admin'
|
||||
router.push(redirect)
|
||||
} catch (err) {
|
||||
if (err?.message) {
|
||||
ElMessage.error(err.message)
|
||||
}
|
||||
} catch {
|
||||
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||
} finally {
|
||||
adminLoading.value = false
|
||||
}
|
||||
|
||||
@@ -811,15 +811,30 @@ onUnmounted(() => {
|
||||
.options-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 与 Competition.vue 同款修正(测试反馈 #1):覆盖 Element Plus
|
||||
`.el-radio-group{align-items:center}`,否则各选项按自身内容宽度收缩居中、宽窄不一。 */
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
/* 以最长选项的边框宽度为统一宽度,整块居中,文字左对齐 */
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.option-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.option-item:hover { border-color: #409eff; }
|
||||
/* 超长选项不得撑破卡片:允许换行,避免 max-content 溢出 */
|
||||
.options-group :deep(.el-radio__label) {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.option-label {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
|
||||
@@ -49,6 +49,22 @@
|
||||
|
||||
<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">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
@@ -251,6 +267,36 @@ onMounted(() => {
|
||||
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 {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user