refactor(前端): 接口错误改成 ApiError 类,catch 不再写 any

拦截器原来 reject 一个裸对象 `{ error, data }`,没有类型也没有类型守卫,于是 46 处
`catch (err: any)` 直接读 `err.data` / `err.error` —— 代码 bug 抛出的 TypeError 也按
接口错误读,弹出来的是 undefined 或英文。

- utils/api.ts:reject 改成 `ApiError extends Error`,字段名沿用 error(错误码)/
  data(文案),存量读法语义不变;加 isApiError / errorCode / errorMessage。
  errorMessage 对非接口错误一律用 fallback,不把 TypeError 的英文甩给学生
- 46 处 catch 全部换掉 any(45 处接口错误 + pythonSyntaxCheck 里 Skulpt 的错误对象)
- 断网等没有响应体的情况,文案从英文 "Request failed" 换成「网络异常,请检查网络后重试」

浏览器实跑核过:登录密码错误仍显示「用户名或密码不正确」(错误码分支)、
接口错误 reject 出来是 ApiError、网络不通时拿到中文文案、非接口错误走 fallback。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:12:40 +08:00
1 parent 6406794971
commit 7a390323fe
29 files changed
+167 -106

No files matched your search

+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import TextEditor from "shared/components/TextEditor.vue"
import type { AnnouncementEdit } from "utils/types"
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
@@ -63,8 +64,8 @@ async function submit() {
message.success("修改已保存")
}
router.push({ name: "admin announcement list" })
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
@@ -102,8 +103,8 @@ async function submit() {
message.success("修改已保存")
}
router.push({ name: "admin contest list" })
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
+9 -8
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions"
@@ -70,8 +71,8 @@ async function toggleChecked(item: HelperItem) {
submissions.value = [...submissions.value]
message.success(newChecked ? "已标记为已检查" : "已取消标记")
} catch (err: any) {
message.error(err.data || "操作失败")
} catch (err) {
message.error(errorMessage(err, "操作失败"))
}
}
@@ -101,9 +102,9 @@ async function markAllAsChecked() {
loadingMsg.destroy()
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
} catch (err: any) {
} catch (err) {
loadingMsg.destroy()
message.error(err.data || "批量操作失败")
message.error(errorMessage(err, "批量操作失败"))
}
}
@@ -159,8 +160,8 @@ async function viewSubmission(item: HelperItem) {
}
toggleCodePanel(true)
} catch (err: any) {
message.error(err.data || "加载提交失败")
} catch (err) {
message.error(errorMessage(err, "加载提交失败"))
}
}
@@ -174,8 +175,8 @@ async function loadData() {
// 再获取 AC 提交列表
const data = await getACMHelperList(Number(props.contestID))
submissions.value = data
} catch (err: any) {
message.error(err.data || "加载失败")
} catch (err) {
message.error(errorMessage(err, "加载失败"))
}
}
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorCode, errorMessage } from "utils/api"
import {
deleteContestProblem,
deleteProblem,
@@ -33,8 +34,8 @@ async function handleDeleteProblem() {
}
message.success("删除成功")
emit("updated")
} catch (err: any) {
if (err.error === "problem-has-submissions") {
} catch (err) {
if (errorCode(err) === "problem-has-submissions") {
message.error("这道题有提交之后,就不能被删除")
} else {
message.error("删除失败")
@@ -81,13 +82,13 @@ async function handleMakePublic() {
message.success("已成功转为公开题目(需要手动设置可见)")
showMakePublicModal.value = false
emit("updated") // 刷新列表
} catch (err: any) {
if (err.error === "display-id-exists") {
} catch (err) {
if (errorCode(err) === "display-id-exists") {
message.error("该题目编号已存在,请使用其他编号")
} else if (err.error === "already-public") {
} else if (errorCode(err) === "already-public") {
message.error("该题目已经是公开题目")
} else {
message.error("转换失败:" + (err.data || "未知错误"))
message.error("转换失败:" + errorMessage(err, "未知错误"))
}
}
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, errorMessage } from "utils/api"
import { addProblemForContest } from "admin/api"
interface Props {
@@ -22,13 +23,13 @@ async function addProblem() {
displayID.value,
)
emit("added")
} catch (err: any) {
if (err.error === "display-id-exists") {
} catch (err) {
if (errorCode(err) === "display-id-exists") {
message.error("显示编号重复了,请重新写一个")
} else if (err.error === "contest-ended") {
} else if (errorCode(err) === "contest-ended") {
message.error("这场比赛已经结束了,不能添加题目")
} else {
message.error(err.data)
message.error(errorMessage(err))
}
}
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, isApiError } from "utils/api"
import type { LANGUAGE, SqlDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
@@ -81,15 +82,15 @@ onMounted(async () => {
if (res.length) {
scripts.value = res.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch (err: any) {
} catch (err) {
// 新题、以及旧格式(非 SQL)测试点,后端回 404/409,保持空白就是对的,不该报错。
// 但**其它**失败必须说出来:读不出来时编辑器长得和「这题本来就没测试点」一模一样,
// 教师看不出区别,会以为要自己重新填。保存本身是安全的(后端读不到测试点信息会拒绝
// 整个保存),所以这里只提示、不阻塞。
const code = err?.error
const code = errorCode(err)
if (code && code !== "problem-not-found" && code !== "not-sql-test-case") {
// 后端那句话(如"测试点信息读取失败")只说了现象,教师需要的是「下面是空的、别存」
const detail = err?.data ? `(${err.data})` : ""
const detail = isApiError(err) && err.data ? `(${err.data})` : ""
loadError.value =
`已有测试点脚本没能读出来${detail}。下方是空白模板,不是本题真实的测试点 —— ` +
`直接保存不会丢数据(后端会拒绝),但也不会生效。请先排查测试点文件,或重新上传。`
+6 -5
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, errorMessage } from "utils/api"
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
@@ -484,11 +485,11 @@ async function submit() {
params: { contestID: props.contestID },
})
}
} catch (err: any) {
if (err.error === "display-id-exists") {
} catch (err) {
if (errorCode(err) === "display-id-exists") {
message.error("显示编号重复了,请换一个显示编号")
} else {
message.error(err.data)
message.error(errorMessage(err))
}
}
}
@@ -515,9 +516,9 @@ async function generateMermaid() {
)
problem.value.mermaidCode = res.flowchart
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
} catch (err: any) {
} catch (err) {
// 没有 finally 的话,AI 接口回 502 时按钮永远卡在 loading,只能刷页面
message.error(err.data || "生成失败,请稍后再试")
message.error(errorMessage(err, "生成失败,请稍后再试"))
} finally {
isAIGenerating.value = false
}
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
interface Props {
@@ -24,8 +25,8 @@ async function handleDeleteProblemSet() {
await deleteProblemSet(props.problemSetId)
message.success("删除成功")
emit("updated")
} catch (err: any) {
message.error("删除失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("删除失败:" + errorMessage(err, "未知错误"))
}
}
@@ -53,8 +54,8 @@ async function handleUpdateStatus() {
message.success("状态更新成功")
showStatusModal.value = false
emit("updated")
} catch (err: any) {
message.error("状态更新失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("状态更新失败:" + errorMessage(err, "未知错误"))
}
}
</script>
+23 -22
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
import type {
AdminProblemSet,
@@ -61,8 +62,8 @@ async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
}
@@ -70,8 +71,8 @@ async function loadProblems() {
try {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res
} catch (err: any) {
message.error("加载题目列表失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载题目列表失败:" + errorMessage(err, "未知错误"))
}
}
@@ -79,8 +80,8 @@ async function loadBadges() {
try {
const res = await getProblemSetBadges(problemSetId.value)
badges.value = res
} catch (err: any) {
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载奖章列表失败:" + errorMessage(err, "未知错误"))
}
}
@@ -88,8 +89,8 @@ async function loadProgress() {
try {
const res = await getProblemSetProgress(problemSetId.value)
progress.value = res
} catch (err: any) {
message.error("加载进度列表失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载进度列表失败:" + errorMessage(err, "未知错误"))
}
}
@@ -100,8 +101,8 @@ async function handleAddProblem(data: AddProblemToSetRequest) {
showAddProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("添加题目失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("添加题目失败:" + errorMessage(err, "未知错误"))
}
}
@@ -111,8 +112,8 @@ async function handleRemoveProblem(problemSetProblemId: number) {
message.success("题目移除成功")
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("移除题目失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("移除题目失败:" + errorMessage(err, "未知错误"))
}
}
@@ -124,8 +125,8 @@ async function handleEditProblem(data: UpdateProblemInSetRequest) {
message.success("题目编辑成功")
showEditProblemModal.value = false
loadProblems()
} catch (err: any) {
message.error("编辑题目失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
}
}
@@ -135,8 +136,8 @@ async function handleAddBadge(data: BadgeFormData) {
message.success("奖章创建成功")
showAddBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("创建奖章失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
}
}
@@ -145,8 +146,8 @@ async function handleDeleteBadge(badgeId: number) {
await deleteProblemSetBadge(problemSetId.value, badgeId)
message.success("奖章删除成功")
loadBadges()
} catch (err: any) {
message.error("删除奖章失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("删除奖章失败:" + errorMessage(err, "未知错误"))
}
}
@@ -158,8 +159,8 @@ async function handleEditBadge(data: BadgeFormData) {
message.success("奖章编辑成功")
showEditBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("编辑奖章失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
}
}
@@ -168,8 +169,8 @@ async function handleRemoveUser(userId: number) {
await removeUserFromProblemSet(problemSetId.value, userId)
message.success("用户移除成功")
loadProgress()
} catch (err: any) {
message.error("移除用户失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("移除用户失败:" + errorMessage(err, "未知错误"))
}
}
+5 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
@@ -60,8 +61,8 @@ async function loadProblemSetDetail() {
visible: data.visible,
endTime: data.endTime ? new Date(data.endTime) : null,
}
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
}
}
@@ -87,11 +88,11 @@ async function handleSubmit() {
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err: any) {
} catch (err) {
message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
(err.data || "未知错误"),
errorMessage(err, "未知错误"),
)
} finally {
loading.value = false
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
@@ -210,8 +211,8 @@ async function saveWebsiteConfig() {
}
try {
await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
} catch (err) {
message.error("保存失败:" + errorMessage(err))
return
}
message.success("网站配置保存成功")
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import { deleteTutorial } from "admin/api"
interface Props {
@@ -22,8 +23,8 @@ async function handleDelete() {
await deleteTutorial(props.tutorialID)
message.success("删除成功")
emit("deleted")
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
</script>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type {
Exercise,
ExerciseType,
@@ -317,8 +318,8 @@ async function save() {
}
showForm.value = false
await load()
} catch (e: any) {
message.error(e.data ?? "保存失败")
} catch (e) {
message.error(errorMessage(e, "保存失败"))
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorMessage } from "utils/api"
import CodeEditor from "shared/components/CodeEditor.vue"
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
import type { TutorialEdit } from "utils/types"
@@ -63,8 +64,8 @@ async function submit() {
await updateTutorial(tutorial)
message.success("修改已保存")
}
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { NSwitch } from "naive-ui"
import { parseTime } from "utils/functions"
import type { TutorialListItem } from "utils/types"
@@ -63,8 +64,8 @@ async function toggleVisible(tutorial: TutorialListItem) {
try {
await setTutorialVisibility(tutorial.id, tutorial.isPublic)
message.success("更新成功")
} catch (err: any) {
message.error(err.data)
} catch (err) {
message.error(errorMessage(err))
tutorial.isPublic = !tutorial.isPublic
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
@@ -66,8 +67,8 @@ async function uploadUsers() {
hiddenElement.download = prefix.value + ".csv"
hiddenElement.click()
hiddenElement.remove()
} catch (err: any) {
message.error("上传失败:" + err.data)
} catch (err) {
message.error("上传失败:" + errorMessage(err))
} finally {
toggleLoading(false)
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
@@ -250,8 +251,8 @@ async function handleEditUser() {
})
await editUser(user)
}
} catch (err: any) {
message.error("保存失败:" + err.data)
} catch (err) {
message.error("保存失败:" + errorMessage(err))
return
}
userEditing.value = null
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode, errorMessage } from "utils/api"
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { formatCode, getReaction, submitCode } from "oj/api"
@@ -133,10 +134,10 @@ async function submit() {
language: formatLang,
})
codeStore.setCode(res.code)
} catch (e: any) {
if (e?.error === "format-error") {
} catch (e) {
if (errorCode(e) === "format-error") {
// 仅 Python 会出现:代码本身存在语法错误
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
message.warning(`代码格式化失败:${errorMessage(e)},请检查代码后重试`)
return
}
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { errorCode } from "utils/api"
import { toRefs } from "vue"
// 工具函数
@@ -256,13 +257,13 @@ async function submitFlowchartData() {
}
message.success("流程图已提交,请耐心等待评分")
} catch (error: any) {
} catch (error) {
loading.value = false
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
// 只说「提交失败」的话,学生会以为是自己的图有问题,然后反复点,越点越久
if (error?.error === "too-many-submissions") {
if (errorCode(error) === "too-many-submissions") {
message.warning("提交太频繁了,缓一会儿再交")
} else if (error?.error === "flowchart-not-allowed") {
} else if (errorCode(error) === "flowchart-not-allowed") {
message.error("这道题不接受流程图提交")
} else {
message.error("流程图提交失败")
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { getProblem } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { storeToRefs } from "pinia"
@@ -120,9 +121,9 @@ async function init() {
try {
const res = await getProblem(problemID, contestID)
problem.value = res
} catch (err: any) {
} catch (err) {
problem.value = null
if (err.error === "contest-not-started") {
if (errorCode(err) === "contest-not-started") {
errMsg.value = "比赛还没有开始"
}
}
@@ -46,8 +46,10 @@ export async function checkPythonSyntax(
try {
Sk.compile(code, "prog.py", "exec")
return null
} catch (e: any) {
const line: number = e?.traceback?.[0]?.lineno ?? 1
} catch (e) {
// Skulpt 的语法错误对象,不是 Error 的子类
const traceback = (e as { traceback?: { lineno?: number }[] }).traceback
const line = traceback?.[0]?.lineno ?? 1
return { line }
}
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
@@ -68,10 +69,10 @@ async function loadUserProgress() {
if (res.problems) {
allProblems.value = res.problems
}
} catch (err: any) {
} catch (err) {
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
// 请求一失败(403、断网)转圈就永远停不下来
message.error("加载用户进度失败:" + (err.data || "未知错误"))
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
} finally {
loading.value = false
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import {
getProblemSetDetail,
getProblemSetProblems,
@@ -90,8 +91,8 @@ async function handleJoinProblemSet() {
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err: any) {
message.error("加入题单失败:" + (err.data || "未知错误"))
} catch (err) {
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
} finally {
isJoining.value = false
}
+2 -2
View File
@@ -505,7 +505,7 @@ async function listMyClassRank() {
if (myClassScope.value === "window") {
myClassQuery.page = 1
}
} catch (err: any) {
} catch (err) {
console.error(err)
}
}
@@ -517,7 +517,7 @@ async function listWeeklyRank() {
weeklyMe.value = res.me
weeklyTotal.value = res.total
weeklyStart.value = res.start
} catch (err: any) {
} catch (err) {
console.error(err)
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { errorCode } from "utils/api"
import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants"
@@ -96,9 +97,9 @@ export const useContestStore = defineStore("contest", () => {
_getProblems(contestID)
}
return null
} catch (err: any) {
} catch (err) {
toggleAccess(false)
return (err?.error as string | undefined) ?? "unknown"
return errorCode(err) ?? "unknown"
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { NButton, NFlex, NTag, NText } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import {
@@ -225,8 +226,8 @@ async function retryFlowchart(submissionId: string) {
}
try {
await retryFlowchartSubmission(submissionId)
} catch (err: any) {
message.error(retryTips[err?.error] ?? "重新评分失败")
} catch (err) {
message.error(retryTips[errorCode(err) ?? ""] ?? "重新评分失败")
return
}
message.success("重新评分已提交")
+5 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { getClassUsernames, login } from "../api"
@@ -124,13 +125,13 @@ function submit() {
merged.username = form.value.class + merged.username
}
await login(merged)
} catch (err: any) {
} catch (err) {
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」
if (err.error === "account-disabled") {
if (errorCode(err) === "account-disabled") {
authStore.setLoginError("此账号已被封禁")
} else if (err.error === "invalid-credentials") {
} else if (errorCode(err) === "invalid-credentials") {
authStore.setLoginError("用户名或密码不正确")
} else if (err.error === "too-many-login-attempts") {
} else if (errorCode(err) === "too-many-login-attempts") {
authStore.setLoginError("密码错误次数太多,请 15 分钟后再试")
} else {
authStore.setLoginError("无法登录")
+5 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorCode } from "utils/api"
import { signup } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
@@ -47,12 +48,12 @@ function submit() {
email: form.value.email,
password: form.value.password,
})
} catch (err: any) {
if (err.error === "username-exists") {
} catch (err) {
if (errorCode(err) === "username-exists") {
authStore.setSignupError("用户名已存在")
} else if (err.error === "email-exists") {
} else if (errorCode(err) === "email-exists") {
authStore.setSignupError("邮箱已存在")
} else if (err.error === "too-many-registrations") {
} else if (errorCode(err) === "too-many-registrations") {
authStore.setSignupError("注册太频繁,请稍后再试")
} else {
authStore.setSignupError("无法注册")
+37 -4
View File
@@ -6,13 +6,45 @@ import storage from "./storage"
const { message: toast } = createDiscreteApi(["message"])
interface ApiError {
/** 后端失败响应体的形状,见 apps/api/src/http.ts 的 failure */
interface ErrorPayload {
error?: {
code?: string
message?: string
}
}
/**
* 拦截器 reject 出来的就是它:`error` 是错误码,`data` 是后端文案。
*
* 字段名沿用原来那个裸对象 `{ error, data }`,存量调用点读 `err.error` / `err.data`
* 的语义不变;做成 Error 的子类是为了在 catch 里能和代码 bug(TypeError 之类)分开,
* 也顺带有了堆栈。catch 块里别写 `err: any`,用下面的 errorCode / errorMessage。
*/
export class ApiError extends Error {
constructor(
readonly error: string,
readonly data: string,
) {
super(data)
this.name = "ApiError"
}
}
export function isApiError(err: unknown): err is ApiError {
return err instanceof ApiError
}
/** 接口错误的错误码;不是接口错误(代码 bug、被中止……)就是 undefined */
export function errorCode(err: unknown) {
return isApiError(err) ? err.error : undefined
}
/** 给用户看的文案:接口错误用后端文案,其它一律 fallback —— 别把 TypeError 的英文甩给学生 */
export function errorMessage(err: unknown, fallback = "未知错误") {
return isApiError(err) && err.data ? err.data : fallback
}
/**
* 成功时直接拿到业务数据本身。后端成功响应是 `{ data }`(见 apps/api/src/http.ts
* 的 success),拦截器把 axios 外层和这层信封一起剥掉。
@@ -74,12 +106,13 @@ instance.interceptors.response.use(
response: AxiosResponse,
) => AxiosResponse,
(error) => {
const payload = error.response?.data as ApiError | undefined
const payload = error.response?.data as ErrorPayload | undefined
const code = payload?.error?.code ?? "network-error"
const message = payload?.error?.message ?? "Request failed"
// 没有响应体 = 请求根本没到后端(断网、反代挂了、超时),学生看到的就是这一句
const message = payload?.error?.message ?? "网络异常,请检查网络后重试"
handleGlobalApiError(code, message)
return Promise.reject({ error: code, data: message })
return Promise.reject(new ApiError(code, message))
},
)