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。
This commit is contained in:
@@ -28,8 +28,14 @@ export function importExcel(formData) {
|
|||||||
// 注意:不要手动设置 Content-Type。axios/浏览器在发送 FormData 时会自动补上
|
// 注意:不要手动设置 Content-Type。axios/浏览器在发送 FormData 时会自动补上
|
||||||
// `multipart/form-data; boundary=...`;手动写死 'multipart/form-data'(无 boundary)
|
// `multipart/form-data; boundary=...`;手动写死 'multipart/form-data'(无 boundary)
|
||||||
// 会导致后端 getFileStream 解析失败(Missing boundary)-> 500,且永不落库。
|
// 会导致后端 getFileStream 解析失败(Missing boundary)-> 500,且永不落库。
|
||||||
|
//
|
||||||
|
// silent: true —— 抑制响应拦截器的通用错误提示。本接口的**失败提示由调用方负责**:
|
||||||
|
// 后端刻意只回传纯错误(admin.js importExcel catch 注释:「前端已统一加
|
||||||
|
// 「Excel 导入失败:」前缀,这里只回传纯错误,避免重复前缀」),故此处必须静默,
|
||||||
|
// 否则会与 Admin.vue 的「Excel 导入失败:<原因>」构成同族双弹。
|
||||||
return request.post('/admin/import-excel', formData, {
|
return request.post('/admin/import-excel', formData, {
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
silent: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,8 +140,11 @@ export function setQuestions(group, questionType, data) {
|
|||||||
*/
|
*/
|
||||||
export function importQuestionsExcel(group, formData) {
|
export function importQuestionsExcel(group, formData) {
|
||||||
// 同上:不要手动设置 Content-Type,避免缺失 boundary 导致后端解析 500。
|
// 同上:不要手动设置 Content-Type,避免缺失 boundary 导致后端解析 500。
|
||||||
|
// silent: true —— 同 importExcel:失败提示由调用方(Admin.vue onExcelFileChange)
|
||||||
|
// 以「Excel 导入失败:<原因>」形式统一给出,此处静默以避免同族双弹。
|
||||||
return request.post(`/admin/exam/${group}/questions/import-excel`, formData, {
|
return request.post(`/admin/exam/${group}/questions/import-excel`, formData, {
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
|
silent: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1532,6 +1532,18 @@
|
|||||||
* 6. 成绩管理 - 发布/撤回/导出/批量下载
|
* 6. 成绩管理 - 发布/撤回/导出/批量下载
|
||||||
* 7. 新闻管理 - CRUD + 置顶/分组
|
* 7. 新闻管理 - CRUD + 置顶/分组
|
||||||
* 8. 轮播管理 - 添加/删除/排序/启用
|
* 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 { ref, reactive, onMounted, watch, computed, nextTick, defineAsyncComponent, h } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -1677,10 +1689,10 @@ async function handleToggleTestFlag(row, val) {
|
|||||||
await adminApi.toggleTestFlag(row.uid, val)
|
await adminApi.toggleTestFlag(row.uid, val)
|
||||||
const label = val === 1 ? '测试数据' : '生产数据'
|
const label = val === 1 ? '测试数据' : '生产数据'
|
||||||
ElMessage.success(`选手 ${row.name} 已标记为${label}`)
|
ElMessage.success(`选手 ${row.name} 已标记为${label}`)
|
||||||
} catch (e) {
|
} catch {
|
||||||
// 回滚开关状态
|
// 回滚开关状态
|
||||||
row.testFlag = val === 1 ? 0 : 1
|
row.testFlag = val === 1 ? 0 : 1
|
||||||
ElMessage.error('标记失败:' + (e.message || '未知错误'))
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2064,8 +2076,8 @@ async function saveBigQuestion() {
|
|||||||
}
|
}
|
||||||
bigDialogVisible.value = false
|
bigDialogVisible.value = false
|
||||||
await fetchBigQuestions()
|
await fetchBigQuestions()
|
||||||
} catch (e) {
|
} catch {
|
||||||
ElMessage.error(e?.message || '保存失败')
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
} finally {
|
} finally {
|
||||||
bigSaving.value = false
|
bigSaving.value = false
|
||||||
}
|
}
|
||||||
@@ -2087,9 +2099,9 @@ async function deleteBigQuestion(row) {
|
|||||||
await fetchBigQuestions()
|
await fetchBigQuestions()
|
||||||
// 已删除的行可能仍被勾选(reserve-selection 会保留),导出前清掉避免请求到已删 ID
|
// 已删除的行可能仍被勾选(reserve-selection 会保留),导出前清掉避免请求到已删 ID
|
||||||
clearBigSelection()
|
clearBigSelection()
|
||||||
} catch (e) {
|
} catch {
|
||||||
// 409:该题已被抽取,服务端拒绝删除;给出可执行的处理建议
|
// 409:该题已被抽取,服务端拒绝删除 —— 后端给出的可执行处理建议
|
||||||
ElMessage.error(e?.message || '删除失败')
|
// 由响应拦截器统一展示,此处不再重复弹窗(同族双弹)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2322,8 +2334,8 @@ async function saveBigScores() {
|
|||||||
// 一次刷新全部相关列表:成绩明细(总分 = 客观分 + 大题分)+ 大题作答 + 考生答题记录。
|
// 一次刷新全部相关列表:成绩明细(总分 = 客观分 + 大题分)+ 大题作答 + 考生答题记录。
|
||||||
// 原实现只调 fetchBigAnswers(),测试人员看到另一处仍是旧分只能刷整页。
|
// 原实现只调 fetchBigAnswers(),测试人员看到另一处仍是旧分只能刷整页。
|
||||||
await refreshScorePageData(true)
|
await refreshScorePageData(true)
|
||||||
} catch (e) {
|
} catch {
|
||||||
ElMessage.error(e?.message || '评分保存失败')
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
} finally {
|
} finally {
|
||||||
bigScoreSaving.value = false
|
bigScoreSaving.value = false
|
||||||
}
|
}
|
||||||
@@ -2430,7 +2442,7 @@ async function saveQuestion() {
|
|||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
questionDialogVisible.value = false
|
questionDialogVisible.value = false
|
||||||
fetchQuestions()
|
fetchQuestions()
|
||||||
} catch (e) { ElMessage.error('保存失败') } finally { questionSaving.value = false }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { questionSaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteQuestion(row, qType) {
|
async function deleteQuestion(row, qType) {
|
||||||
@@ -2624,7 +2636,7 @@ async function onMaterialUpload(file) {
|
|||||||
ElMessage.success('资料上传成功')
|
ElMessage.success('资料上传成功')
|
||||||
materialsPage.value = 1
|
materialsPage.value = 1
|
||||||
fetchAdminMaterials()
|
fetchAdminMaterials()
|
||||||
} catch (e) { ElMessage.error('上传失败') }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMaterialsGroupChange() {
|
function onMaterialsGroupChange() {
|
||||||
@@ -2764,7 +2776,7 @@ async function batchSaveWorkScores() {
|
|||||||
await fetchSubmissions()
|
await fetchSubmissions()
|
||||||
// R29:「考生答题记录」的组别已改跟随成绩管理页(scoreExportGroup)
|
// R29:「考生答题记录」的组别已改跟随成绩管理页(scoreExportGroup)
|
||||||
if (submissionGroup.value === scoreExportGroup.value) fetchExamSubmissions()
|
if (submissionGroup.value === scoreExportGroup.value) fetchExamSubmissions()
|
||||||
} catch (e) { ElMessage.error('保存失败') }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 5. 时间设置 ====================
|
// ==================== 5. 时间设置 ====================
|
||||||
@@ -2816,7 +2828,7 @@ 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('时间设置保存成功')
|
||||||
} catch (e) { ElMessage.error('保存失败') } finally { timesSaving.value = false }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { timesSaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 6. 成绩管理 ====================
|
// ==================== 6. 成绩管理 ====================
|
||||||
@@ -2919,7 +2931,7 @@ async function togglePublish() {
|
|||||||
await adminApi.publishScores(group, !current)
|
await adminApi.publishScores(group, !current)
|
||||||
scoresPublishedMap[group] = !current
|
scoresPublishedMap[group] = !current
|
||||||
ElMessage.success(!current ? '成绩已发布' : '成绩已撤回')
|
ElMessage.success(!current ? '成绩已发布' : '成绩已撤回')
|
||||||
} catch (e) { ElMessage.error('操作失败') }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
@@ -3060,7 +3072,7 @@ async function saveNews() {
|
|||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
newsDialogVisible.value = false
|
newsDialogVisible.value = false
|
||||||
fetchAdminNews()
|
fetchAdminNews()
|
||||||
} catch (e) { ElMessage.error('保存失败') } finally { newsSaving.value = false }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { newsSaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAdminNews(row) {
|
async function deleteAdminNews(row) {
|
||||||
@@ -3078,9 +3090,9 @@ async function togglePinned(row, val) {
|
|||||||
await adminApi.updateNews(row.id, { pinned: val })
|
await adminApi.updateNews(row.id, { pinned: val })
|
||||||
row.pinned = val
|
row.pinned = val
|
||||||
ElMessage.success(val ? '已置顶' : '已取消置顶')
|
ElMessage.success(val ? '已置顶' : '已取消置顶')
|
||||||
} catch (e) {
|
} catch {
|
||||||
row.pinned = !val
|
row.pinned = !val
|
||||||
ElMessage.error('更新失败')
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3100,7 +3112,7 @@ async function saveNewsOrder() {
|
|||||||
fetchAdminNews()
|
fetchAdminNews()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('新闻排序保存失败:', e)
|
console.error('新闻排序保存失败:', e)
|
||||||
ElMessage.error('排序保存失败,请刷新重试')
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3258,7 +3270,7 @@ async function saveCarousel() {
|
|||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
carouselDialogVisible.value = false
|
carouselDialogVisible.value = false
|
||||||
fetchCarousels()
|
fetchCarousels()
|
||||||
} catch (e) { ElMessage.error('保存失败') } finally { carouselSaving.value = false }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ } finally { carouselSaving.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCarouselItem(row) {
|
async function deleteCarouselItem(row) {
|
||||||
@@ -3273,7 +3285,7 @@ async function deleteCarouselItem(row) {
|
|||||||
async function toggleCarousel(row) {
|
async function toggleCarousel(row) {
|
||||||
try {
|
try {
|
||||||
await adminApi.updateCarousel(row.id, { enabled: row.enabled })
|
await adminApi.updateCarousel(row.id, { enabled: row.enabled })
|
||||||
} catch (e) { ElMessage.error('更新失败') }
|
} catch { /* 失败提示由响应拦截器统一给出,不再重复弹窗(同族双弹) */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动保存轮播排序 */
|
/** 手动保存轮播排序 */
|
||||||
@@ -3287,7 +3299,7 @@ async function saveCarouselOrder() {
|
|||||||
ElMessage.success('排序保存成功')
|
ElMessage.success('排序保存成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('轮播排序保存失败:', e)
|
console.error('轮播排序保存失败:', e)
|
||||||
ElMessage.error('排序保存失败,请刷新重试')
|
// 失败提示由响应拦截器统一给出,此处不再重复弹窗(同族双弹)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user