From d4aadc77220ef86d48b79632a225781c1684e4ea Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 16 Oct 2026 15:07:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=90=8E=E5=8F=B0=E5=88=97=E8=A1=A8):=20?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E6=9C=89=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E6=85=A2=E7=9A=84=E6=97=B6=E5=80=99=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E8=BD=AC=E5=9C=88=E3=80=81=E6=97=A7=E8=AF=B7=E6=B1=82=E6=99=9A?= =?UTF-8?q?=E5=9B=9E=E6=9D=A5=E4=B8=8D=E5=86=8D=E7=9B=96=E6=8E=89=E6=96=B0?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台八个列表(题目、比赛、用户、公告、题单、AI 报告、从题库加题、标签下的题)原来是 `const res = await getXxx(); rows.value = res.results`:接口 500 或断网时什么都不提示, 表格停在旧数据上;关键词防抖和翻页一交错,先发的晚回来会把新结果盖掉。 shared/composables/listLoader.ts 的 track() 把请求包一层:带请求序号、出错按后端文案提示 (登录过期、没权限这几种全局拦截器已经提示过,不弹第二遍)、表格挂 loading。 实跑:六个列表各 10 行正常;拦掉题目列表的接口再翻页,弹「网络异常,请检查网络后重试」 (原来没有任何反应)。 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/admin/ai/list.vue | 5 ++- apps/web/src/admin/announcement/list.vue | 13 +++++- apps/web/src/admin/contest/list.vue | 13 +++++- .../components/AddFromLibraryModal.vue | 7 ++- .../problem/components/TagProblemsModal.vue | 9 +++- apps/web/src/admin/problem/list.vue | 24 ++++++---- apps/web/src/admin/problemset/list.vue | 13 +++++- apps/web/src/admin/user/list.vue | 13 +++--- apps/web/src/shared/composables/listLoader.ts | 45 +++++++++++++++++++ apps/web/src/utils/api.ts | 7 +++ 10 files changed, 122 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/shared/composables/listLoader.ts diff --git a/apps/web/src/admin/ai/list.vue b/apps/web/src/admin/ai/list.vue index e226f08..bec6ba0 100644 --- a/apps/web/src/admin/ai/list.vue +++ b/apps/web/src/admin/ai/list.vue @@ -10,6 +10,7 @@ import UserName from "shared/components/UserName.vue" import { parseTime } from "utils/functions" import type { AdminAiReport, AdminAiReportListItem } from "utils/types" import { getAIReportDetail, getAIReportList, pinAIReport } from "../api" +import { useListLoader } from "shared/composables/listLoader" // Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面 const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue")) @@ -52,9 +53,11 @@ const detail = ref(null) const loadingDetail = ref(false) const selected = computed(() => reports.value.find((r) => r.id === selectedId.value) ?? null) +const { track } = useListLoader() async function listReports() { const offset = (query.page - 1) * query.limit - const res = await getAIReportList(offset, query.limit, query.username) + const res = await track(getAIReportList(offset, query.limit, query.username)) + if (!res) return reports.value = res.results total.value = res.total // 第一次进来、或者翻页 / 搜索之后选中的那份不在这一页了:默认看第一份 diff --git a/apps/web/src/admin/announcement/list.vue b/apps/web/src/admin/announcement/list.vue index ac94f93..218af72 100644 --- a/apps/web/src/admin/announcement/list.vue +++ b/apps/web/src/admin/announcement/list.vue @@ -8,6 +8,7 @@ import type { AdminAnnouncementListItem } from "utils/types" import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api" import Actions from "./components/Actions.vue" import { useBreakpoints } from "shared/composables/breakpoints" +import { useListLoader } from "shared/composables/listLoader" const message = useMessage() const total = ref(0) @@ -136,9 +137,11 @@ async function toggle(announcement: AdminAnnouncementListItem, field: "visible" } } +const { loading, track } = useListLoader() async function listAnnouncements() { const offset = (query.page - 1) * query.limit - const res = await getAnnouncementList(offset, query.limit) + const res = await track(getAnnouncementList(offset, query.limit)) + if (!res) return announcements.value = res.results total.value = res.total } @@ -155,7 +158,13 @@ watch(query, listAnnouncements, { deep: true }) - + diff --git a/apps/web/src/admin/contest/list.vue b/apps/web/src/admin/contest/list.vue index 61e4633..bb72385 100644 --- a/apps/web/src/admin/contest/list.vue +++ b/apps/web/src/admin/contest/list.vue @@ -9,6 +9,7 @@ import { parseTime } from "utils/functions" import { editContest, getContestList } from "../api" import Actions from "./components/Actions.vue" import { useBreakpoints } from "shared/composables/breakpoints" +import { useListLoader } from "shared/composables/listLoader" /** * 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标, @@ -155,9 +156,11 @@ const desktopColumns: DataTableColumn[] = [ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)) +const { loading, track } = useListLoader() async function listContests() { const offset = (query.page - 1) * query.limit - const res = await getContestList(offset, query.limit, query.keyword) + const res = await track(getContestList(offset, query.limit, query.keyword)) + if (!res) return contests.value = res.results total.value = res.total } @@ -185,7 +188,13 @@ watchDebounced(() => query.keyword, listContests, { /> - + diff --git a/apps/web/src/admin/problem/components/AddFromLibraryModal.vue b/apps/web/src/admin/problem/components/AddFromLibraryModal.vue index 79c20b1..942b8a7 100644 --- a/apps/web/src/admin/problem/components/AddFromLibraryModal.vue +++ b/apps/web/src/admin/problem/components/AddFromLibraryModal.vue @@ -3,6 +3,7 @@ import { getLibraryProblemList } from "admin/api" import Pagination from "shared/components/Pagination.vue" import type { AdminProblemRow } from "utils/types" import AddButton from "./AddButton.vue" +import { useListLoader } from "shared/composables/listLoader" interface Props { show: boolean @@ -42,9 +43,11 @@ const columns: DataTableColumn[] = [ }, ] +const { loading, track } = useListLoader() async function getList() { const offset = (query.page - 1) * query.limit - const res = await getLibraryProblemList(offset, query.limit, query.keyword) + const res = await track(getLibraryProblemList(offset, query.limit, query.keyword)) + if (!res) return total.value = res.total problems.value = res.results } @@ -74,7 +77,7 @@ watchDebounced( @close="$emit('update:show', false)" > - + diff --git a/apps/web/src/admin/problem/components/TagProblemsModal.vue b/apps/web/src/admin/problem/components/TagProblemsModal.vue index 96100e3..c36f41f 100644 --- a/apps/web/src/admin/problem/components/TagProblemsModal.vue +++ b/apps/web/src/admin/problem/components/TagProblemsModal.vue @@ -4,6 +4,7 @@ import Pagination from "shared/components/Pagination.vue" import type { AdminProblemRow } from "utils/types" import { batchTagProblems, getProblemList } from "admin/api" import { useBreakpoints } from "shared/composables/breakpoints" +import { useListLoader } from "shared/composables/listLoader" interface Props { show: boolean @@ -63,10 +64,14 @@ const mobileColumns: DataTableColumn[] = [ const columns = computed(() => (isMobile.value ? mobileColumns : allColumns)) +const { loading, track } = useListLoader() async function listProblems() { if (page.value < 1) page.value = 1 const offset = (page.value - 1) * limit.value - const res = await getProblemList(offset, limit.value, keyword.value, "", undefined, props.tagId) + const res = await track( + getProblemList(offset, limit.value, keyword.value, "", undefined, props.tagId), + ) + if (!res) return problems.value = res.results total.value = res.total } @@ -134,7 +139,7 @@ watchDebounced( clearable /> - + diff --git a/apps/web/src/admin/problem/list.vue b/apps/web/src/admin/problem/list.vue index 24605c7..8487c79 100644 --- a/apps/web/src/admin/problem/list.vue +++ b/apps/web/src/admin/problem/list.vue @@ -16,6 +16,7 @@ import CreateProblemMenu from "./components/CreateProblemMenu.vue" import PageHeader from "admin/components/PageHeader.vue" import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue" import { useBreakpoints } from "shared/composables/breakpoints" +import { useListLoader } from "shared/composables/listLoader" /** * 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、 @@ -265,19 +266,23 @@ const columns = computed[]>(() => { : [{ type: "selection" }, ...cols] }) +const { loading, track } = useListLoader() async function listProblems() { if (query.page < 1) query.page = 1 const offset = (query.page - 1) * query.limit - const res = await getProblemList( - offset, - query.limit, - query.keyword, - query.author, - props.contestID, - undefined, - query.kind, - props.contestID ? "" : query.visible, + const res = await track( + getProblemList( + offset, + query.limit, + query.keyword, + query.author, + props.contestID, + undefined, + query.kind, + props.contestID ? "" : query.visible, + ), ) + if (!res) return total.value = res.total problems.value = res.results counts.value = res.counts @@ -404,6 +409,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible], 取消选择 [] = [ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)) +const { loading, track } = useListLoader() async function listProblemSets() { if (query.page < 1) query.page = 1 const offset = (query.page - 1) * query.limit - const res = await getProblemSetList(offset, query.limit, query.keyword) + const res = await track(getProblemSetList(offset, query.limit, query.keyword)) + if (!res) return total.value = res.total problemSets.value = res.results } @@ -150,7 +153,13 @@ watch(() => [query.page, query.limit], listProblemSets) /> - + diff --git a/apps/web/src/admin/user/list.vue b/apps/web/src/admin/user/list.vue index 3f6685b..7af9146 100644 --- a/apps/web/src/admin/user/list.vue +++ b/apps/web/src/admin/user/list.vue @@ -18,6 +18,7 @@ import { downloadAccountsCsv } from "./accountsCsv" import { classLabel, groupClassOptions } from "utils/classes" import { useConfigStore } from "shared/store/config" import { useBreakpoints } from "shared/composables/breakpoints" +import { useListLoader } from "shared/composables/listLoader" /** * 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、 @@ -203,17 +204,14 @@ const problemPermissionOptions: SelectOption[] = [ // 老师不给「无权限」:比赛题的接口要出题权限,设了就是比赛题目页全报权限不足,后端也会兜成「仅自己」 const teacherPermissionOptions = problemPermissionOptions.slice(1) +const { loading, track } = useListLoader() async function listUsers() { if (query.page < 1) query.page = 1 const offset = (query.page - 1) * query.limit - const res = await getUserList( - offset, - query.limit, - query.type, - query.keyword, - query.orderBy, - query.className, + const res = await track( + getUserList(offset, query.limit, query.type, query.keyword, query.orderBy, query.className), ) + if (!res) return total.value = res.total neverLoggedIn.value = res.neverLoggedIn users.value = res.results @@ -427,6 +425,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className (request: Promise): Promise { + const mine = ++seq + loading.value = true + try { + const result = await request + return mine === seq ? result : undefined + } catch (error) { + if (mine !== seq) return undefined + // 登录过期、没权限这几种全局拦截器已经提示过了 + if (!GLOBALLY_HANDLED_CODES.includes(errorCode(error) ?? "")) + message.error(errorMessage(error, "列表没加载出来,刷新一下再试")) + return undefined + } finally { + if (mine === seq) loading.value = false + } + } + + return { loading, track } +} diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts index 790e632..5684739 100644 --- a/apps/web/src/utils/api.ts +++ b/apps/web/src/utils/api.ts @@ -67,6 +67,13 @@ interface ApiClient { * 导出给不走 axios 的请求用(AI 的流式接口是裸 fetch,见 utils/stream.ts)—— * 同一个错误码从两条路进来,学生看到的结果不该有两个样子。 */ +/** handleGlobalApiError 已经替调用方提示过的错误码,调用方别再弹第二遍 */ +export const GLOBALLY_HANDLED_CODES: readonly string[] = [ + "login-required", + "account-disabled", + "permission-denied", +] + export function handleGlobalApiError(code: string, message?: string) { if (code === "login-required") { storage.remove(STORAGE_KEY.AUTHED)