Compare commits

...

5 Commits

Author SHA1 Message Date
0fca4bd996 fix(ui): 其余视图错误提示收敛为「拦截器统一弹」,消除同族双弹
承接 R64(Admin.vue)已定口径,本轮覆盖其余全部视图。

根因:src/api/request.js 响应拦截器已覆盖全部失败路径,且每种失败只弹一次
⇒ 调用方 catch 内再 ElMessage.error 即必然双弹。helper.error 不改 HTTP 状态,
故删除调用方提示不会丢后端文案。

删除 11 处重复提示(跨 5 文件):
- Login.vue        管理员登录 catch(adminLogin 未传 silent ⇒ 拦截器必弹)
- Activate.vue     handleStep1 / handleActivate / resendSms
- ForgotPassword.vue  handleStep1 / handleReset / resendSms
- Competition.vue  submitUploadFiles / deleteUploadedWork / onMounted 外层 catch
- Dashboard.vue    enterContest(warning 形态,同族)

刻意保留(已有显式理由,不得误删):
- silent 路径调用方唯一提示:Login 选手登录、Competition 大题加载/保存/交卷/自动交卷
- 客户端校验(未发请求)、原生 fetch(MockExam 样例模板)、独立 axios 实例(Materials)、
  el-upload on-success(Admin)

另:
- src/api/request.js 增补「错误提示口径」权威块(全站单一源 + 四类例外),
  五个视图各加一行口径指针注释,防回退。
- 无行为变更:仅移除重复提示,finally/return/refreshCaptcha/router.push 全部保留。
2026-09-30 12:57:55 +08:00
ff7137aab6 fix(admin): 错误提示收敛为「拦截器统一弹」,消除同族双弹
背景:Admin.vue 大量 catch { ElMessage.error(...) } 与 api/request.js 响应拦截器重复——\n一次失败请求用户会同时看到两条报错。拦截器已覆盖全部失败路径(业务码≠0 / 403/404/500 /\n超时 / 网络异常),故调用方 catch 不再弹提示。

分类(23 处 → 删 15 / 留 8):\n- 删 15:通用失败(拦截器已弹),保留状态回滚与 console.error\n- 留 2 + 请求加 silent:true:两个导入接口——后端 admin.js 明写「前端已统一加\n  『Excel 导入失败:』前缀,这里只回传纯错误,避免重复前缀」,删调用方会破坏跨层契约\n- 留 1:resetTestData(已 silent);留 3:客户端校验(未发请求);留 2:非 axios 路径

Admin.vue 文件头新增「错误提示口径(R64 收敛)」约定块防回退。\n验证:静态终态 23→9(应删项 0 残留)/ TDZ 15 文件 / 真浏览器 dev+产物 max=1 /\n变异自证 max=2 且还原 md5 一致 / ui-runtime-check 5-5 / build 157 文件 70 .gz /\nci-gates.sh 10 步 EXIT=0。
2026-09-30 12:41:28 +08:00
d16c261273 fix(auth): 口令输入框提示与后端策略对齐(6-20 位,勿用弱口令)
配套 R63 后端口令强度策略:两处 placeholder 由「至少6位」改为「6-20 位,勿用 123456 等弱口令」,让考生在前端即知道限制,避免填完才被后端拒绝。

刻意不在前端实现黑名单校验:那会与后端 lib/password_blacklist.json 形成两份会漂移的清单(后端才是权威闸门)。
2026-09-30 11:56:45 +08:00
a47e823277 fix(auth): 激活页清除残留登录态 + 全站密码框显式 autocomplete 语义
R61/R62:两个独立缺陷(测试人员反馈「设置密码框已有 123456 + 激活后直接进管理后台」)。

① 激活后进管理后台 —— 安全缺陷(公用电脑 = 越权)
Activate.vue 激活成功与「返回登录」都跳 /login,而 router guard
(router/index.js:203-222)见到 isLoggedIn && role==='admin' 会重定向到 /admin。
浏览器残留 admin 登录态时(测试人员刚用 admin 操作过后台),考生会被
直接送进管理后台;且 /api/admin/* 全部挂 adminAuth(router.js:25)
⇒ 残留 token 有效时管理接口真能调。
修法:激活页按「未登录」语义处理 —— onMounted 与激活成功后均 clearAuth()。

② 设置密码页密码框被浏览器预填
密码框未显式声明 autocomplete,而 EP el-input 该 prop 默认 "off",
Chromium 对 type=password 会忽略 off,改用该站点已保存凭据自动填充。
密码规则仅 min:6 ⇒ "123456" 恰好合法 ⇒ 用户不知情下即把密码设成 123456。
修法:6 个密码框显式声明语义 —— 设密码/重置密码 new-password(4 处),
登录 current-password(2 处,含管理员 tab)。

验证:真浏览器 21/21 断言通过(含「残留 admin 态直接访问 /login 必跳 /admin」
对照臂,证明断言非恒真);TDZ 扫描 PASS(26 文件);权威脚本构建
157 文件 / 70 .gz;生产零中断上线(MainPID/NRestarts/ActiveEnterTimestamp 未变)。
2026-09-30 11:39:53 +08:00
ef32d30a57 fix(admin): 「重置测试数据」报错只提示一次(收敛重复弹窗)
resetTestData() 未传 silent,生产环境下后端 403(生产环境禁止重置测试数据)会被响应拦截器(request.js:166)与调用方 catch(Admin.vue:1707)各弹一次,用户在后台看到两条完全相同的报错(R58 反馈)。

改为 silent: true —— 由调用方按上下文统一弹一次「重置失败:<后端原文>」。
2026-09-30 11:06:08 +08:00
8 changed files with 124 additions and 48 deletions

View File

@@ -28,8 +28,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,
})
}
@@ -134,8 +140,11 @@ 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,
})
}
@@ -349,7 +358,11 @@ export function toggleTestFlag(id, testFlag) {
* @returns {Promise<{code:number,message:string,data:object}>}
*/
export function resetTestData() {
return request.post('/admin/reset-test-data', null, { returnMessage: true })
// silent: true —— 抑制响应拦截器(request.js)的通用错误提示。
// 本调用方在 Admin.vue 的 catch 里已按上下文弹一次「重置失败:<后端原文>」,
// 若不再静默,同一 403(生产环境禁止重置测试数据)会被拦截器与 catch 各弹一次,
// 用户会看到两条完全相同的报错(R58 用户反馈)。
return request.post('/admin/reset-test-data', null, { returnMessage: true, silent: true })
}
// ==================== 综合大题管理(中职/高职) ====================

View File

@@ -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(

View File

@@ -149,7 +149,8 @@
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置登录密码(至少6位)"
autocomplete="new-password"
placeholder="请设置登录密码(6-20 位,勿用 123456 等弱口令)"
size="large"
show-password
/>
@@ -159,6 +160,7 @@
<el-input
v-model="step3Form.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入密码"
size="large"
show-password
@@ -197,6 +199,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* Activate.vue - 账号激活页面(公开访问)
*
@@ -211,8 +217,14 @@ 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 +381,8 @@ async function handleStep1() {
currentStep.value = 1
// 启动倒计时
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
// 刷新验证码
refreshCaptcha()
} finally {
@@ -408,12 +420,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 +445,8 @@ async function resendSms() {
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
refreshCaptcha()
}
}
@@ -452,6 +467,9 @@ function startSmsCountdown() {
// ==================== 生命周期 ====================
onMounted(() => {
// 进入激活页即按「未登录」处理(R61):清掉残留 token/user,
// 覆盖两条会跳 /login 的路径 —— ①本页「返回登录」按钮 ②激活成功后的跳转。
authStore.clearAuth()
refreshCaptcha()
})

View File

@@ -1532,6 +1532,18 @@
* 6. 成绩管理 - 发布/撤回/导出/批量下载
* 7. 新闻管理 - CRUD + 置顶/分组
* 8. 轮播管理 - 添加/删除/排序/启用
*
* 【错误提示口径(R64 收敛)—— 改本文件前必读】
* 本文件**不在 catch 里弹错误提示**:请求失败的业务码 / HTTP 码提示,
* 一律由 `src/api/request.js` 的响应拦截器**统一给出**(它已覆盖全部失败路径:
* 业务码≠0、403/404/500、超时、网络异常 —— 每种都会弹、且只弹一次)。
* 若在 catch 里再 `ElMessage.error(...)`,就是**同族双弹**:用户同时看到两条相同报错。
* ⇒ 例外仅有三类,出现时**必须显式注明原因**:
* ① 该请求已带 `silent: true` ⇒ 调用方即唯一提示方
* (如两个导入接口需加「Excel 导入失败:」前缀 —— 见后端 admin.js 注释
* 「前端已统一加前缀,这里只回传纯错误,避免重复前缀」);
* ② 客户端校验(**根本没发请求**,如文件类型/大小、分值范围);
* ③ 非 axios 路径(`fetchBlobGuarded` / el-upload 的 on-success 手工判响应)。
*/
import { ref, reactive, onMounted, watch, computed, nextTick, defineAsyncComponent, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -1677,10 +1689,10 @@ async function handleToggleTestFlag(row, val) {
await adminApi.toggleTestFlag(row.uid, val)
const label = val === 1 ? '测试数据' : '生产数据'
ElMessage.success(`选手 ${row.name} 已标记为${label}`)
} catch (e) {
} catch {
// 回滚开关状态
row.testFlag = val === 1 ? 0 : 1
ElMessage.error('标记失败:' + (e.message || '未知错误'))
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
}
}
@@ -2064,8 +2076,8 @@ async function saveBigQuestion() {
}
bigDialogVisible.value = false
await fetchBigQuestions()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally {
bigSaving.value = false
}
@@ -2087,9 +2099,9 @@ async function deleteBigQuestion(row) {
await fetchBigQuestions()
// 已删除的行可能仍被勾选(reserve-selection 会保留),导出前清掉避免请求到已删 ID
clearBigSelection()
} catch (e) {
// 409:该题已被抽取,服务端拒绝删除;给出可执行的处理建议
ElMessage.error(e?.message || '删除失败')
} catch {
// 409:该题已被抽取,服务端拒绝删除 —— 后端给出的可执行处理建议
// 由响应拦截器统一展示,此处不再重复弹窗(同族双弹)
}
}
@@ -2322,8 +2334,8 @@ async function saveBigScores() {
// 一次刷新全部相关列表:成绩明细(总分 = 客观分 + 大题分)+ 大题作答 + 考生答题记录。
// 原实现只调 fetchBigAnswers(),测试人员看到另一处仍是旧分只能刷整页。
await refreshScorePageData(true)
} catch (e) {
ElMessage.error(e?.message || '评分保存失败')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally {
bigScoreSaving.value = false
}
@@ -2430,7 +2442,7 @@ async function saveQuestion() {
ElMessage.success('保存成功')
questionDialogVisible.value = false
fetchQuestions()
} catch (e) { ElMessage.error('保存失败') } finally { questionSaving.value = false }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { questionSaving.value = false }
}
async function deleteQuestion(row, qType) {
@@ -2624,7 +2636,7 @@ async function onMaterialUpload(file) {
ElMessage.success('资料上传成功')
materialsPage.value = 1
fetchAdminMaterials()
} catch (e) { ElMessage.error('上传失败') }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
}
function onMaterialsGroupChange() {
@@ -2764,7 +2776,7 @@ async function batchSaveWorkScores() {
await fetchSubmissions()
// R29:「考生答题记录」的组别已改跟随成绩管理页(scoreExportGroup)
if (submissionGroup.value === scoreExportGroup.value) fetchExamSubmissions()
} catch (e) { ElMessage.error('保存失败') }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
}
// ==================== 5. 时间设置 ====================
@@ -2816,7 +2828,7 @@ async function saveTimes() {
jiaoshi: { start: timesForm.jiaoshiStart, end: timesForm.jiaoshiEnd, deadline: timesForm.jiaoshiDeadline, choiceCount: timesForm.jiaoshiChoiceCount, judgeCount: timesForm.jiaoshiJudgeCount },
})
ElMessage.success('时间设置保存成功')
} catch (e) { ElMessage.error('保存失败') } finally { timesSaving.value = false }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false }
}
// ==================== 6. 成绩管理 ====================
@@ -2919,7 +2931,7 @@ async function togglePublish() {
await adminApi.publishScores(group, !current)
scoresPublishedMap[group] = !current
ElMessage.success(!current ? '成绩已发布' : '成绩已撤回')
} catch (e) { ElMessage.error('操作失败') }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
}
async function exportExcel() {
@@ -3060,7 +3072,7 @@ async function saveNews() {
ElMessage.success('保存成功')
newsDialogVisible.value = false
fetchAdminNews()
} catch (e) { ElMessage.error('保存失败') } finally { newsSaving.value = false }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { newsSaving.value = false }
}
async function deleteAdminNews(row) {
@@ -3078,9 +3090,9 @@ async function togglePinned(row, val) {
await adminApi.updateNews(row.id, { pinned: val })
row.pinned = val
ElMessage.success(val ? '已置顶' : '已取消置顶')
} catch (e) {
} catch {
row.pinned = !val
ElMessage.error('更新失败')
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
}
}
@@ -3100,7 +3112,7 @@ async function saveNewsOrder() {
fetchAdminNews()
} catch (e) {
console.error('新闻排序保存失败:', e)
ElMessage.error('排序保存失败,请刷新重试')
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
}
}
@@ -3258,7 +3270,7 @@ async function saveCarousel() {
ElMessage.success('保存成功')
carouselDialogVisible.value = false
fetchCarousels()
} catch (e) { ElMessage.error('保存失败') } finally { carouselSaving.value = false }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { carouselSaving.value = false }
}
async function deleteCarouselItem(row) {
@@ -3273,7 +3285,7 @@ async function deleteCarouselItem(row) {
async function toggleCarousel(row) {
try {
await adminApi.updateCarousel(row.id, { enabled: row.enabled })
} catch (e) { ElMessage.error('更新失败') }
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
}
/** 手动保存轮播排序 */
@@ -3287,7 +3299,7 @@ async function saveCarouselOrder() {
ElMessage.success('排序保存成功')
} catch (e) {
console.error('轮播排序保存失败:', e)
ElMessage.error('排序保存失败,请刷新重试')
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
}
}

View File

@@ -347,6 +347,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* Competition.vue - 在线答题页面
*
@@ -481,8 +485,8 @@ async function submitUploadFiles() {
uploadRef.value?.clearFiles?.()
await fetchUploadedWorks()
examStore.stopExam()
} catch (e) {
ElMessage.error(e?.message || '上传失败')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
return
} finally {
uploading.value = false
@@ -507,8 +511,8 @@ async function deleteUploadedWork(w, silent = false) {
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 || '删除失败')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
} finally {
w.deleting = false
}
@@ -1072,8 +1076,8 @@ onMounted(async () => {
examStore.startUploadCountdown(group)
window.addEventListener('beforeunload', handleBeforeUnload)
}
} catch (err) {
ElMessage.error('加载考试信息失败')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
router.push('/dashboard')
} finally {
loading.value = false

View File

@@ -112,6 +112,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* Dashboard.vue - 选手工作台
*
@@ -216,8 +220,8 @@ async function enterContest() {
ElMessage.warning(data.message || (data.status === 'ended' ? '考试已结束,无法进入' : '考试未在进行中,请等待考试开始'))
return
}
} catch (e) {
ElMessage.warning('获取考试状态失败,请稍后重试')
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
return
}
router.push('/competition')

View File

@@ -139,7 +139,8 @@
<el-input
v-model="step3Form.password"
type="password"
placeholder="请设置新密码(至少6位)"
autocomplete="new-password"
placeholder="请设置新密码(6-20 位,勿用 123456 等弱口令)"
size="large"
show-password
/>
@@ -149,6 +150,7 @@
<el-input
v-model="step3Form.confirmPassword"
type="password"
autocomplete="new-password"
placeholder="请再次输入新密码"
size="large"
show-password
@@ -187,6 +189,10 @@
</template>
<script setup>
// ===== 错误提示口径(R65 收敛)=====
// 请求失败的提示一律由 src/api/request.js 响应拦截器统一给出(覆盖全部失败路径),
// 本文件 **不在 catch 内重复弹窗**(同族双弹)。例外:客户端校验(未发请求)/
// 已带 silent(调用方即唯一提示方)/ 非 axios 路径(原生 fetch、独立 axios 实例)。
/**
* ForgotPassword.vue - 忘记密码页面(公开访问)
*
@@ -343,8 +349,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
@@ -381,8 +387,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 +405,8 @@ async function resendSms() {
}
ElMessage.success('短信验证码已重新发送')
startSmsCountdown()
} catch (err) {
if (err?.message) ElMessage.error(err.message)
} catch {
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
refreshCaptcha()
}
}

View File

@@ -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
}