fix(后台列表): 加载失败有提示、慢的时候表格转圈、旧请求晚回来不再盖掉新结果
Deploy / deploy (push) Canceled after 0s

后台八个列表(题目、比赛、用户、公告、题单、AI 报告、从题库加题、标签下的题)原来是
`const res = await getXxx(); rows.value = res.results`:接口 500 或断网时什么都不提示,
表格停在旧数据上;关键词防抖和翻页一交错,先发的晚回来会把新结果盖掉。

shared/composables/listLoader.ts 的 track() 把请求包一层:带请求序号、出错按后端文案提示
(登录过期、没权限这几种全局拦截器已经提示过,不弹第二遍)、表格挂 loading。

实跑:六个列表各 10 行正常;拦掉题目列表的接口再翻页,弹「网络异常,请检查网络后重试」
(原来没有任何反应)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-16 15:07:10 +08:00
1 parent 4dfd14f95b
commit d4aadc7722
10 files changed
+122 -27

No files matched your search

+4 -1
View File
@@ -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<AdminAiReport | null>(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
// 第一次进来、或者翻页 / 搜索之后选中的那份不在这一页了:默认看第一份
+11 -2
View File
@@ -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 })
</n-button>
</template>
</PageHeader>
<n-data-table striped class="ann-table" :columns="columns" :data="announcements" />
<n-data-table
:loading="loading"
striped
class="ann-table"
:columns="columns"
:data="announcements"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
+11 -2
View File
@@ -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<AdminContestListItem>[] = [
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, {
/>
</template>
</PageHeader>
<n-data-table striped class="contest-table" :columns="columns" :data="contests" />
<n-data-table
:loading="loading"
striped
class="contest-table"
:columns="columns"
:data="contests"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
@@ -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<AdminProblemRow>[] = [
},
]
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)"
>
<n-input class="search" v-model:value="query.keyword" clearable placeholder="搜索标题或编号" />
<n-data-table striped :columns="columns" :data="problems" />
<n-data-table :loading="loading" striped :columns="columns" :data="problems" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</n-modal>
</template>
@@ -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<AdminProblemRow>[] = [
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
/>
</n-flex>
<n-data-table striped :columns="columns" :data="problems" />
<n-data-table :loading="loading" striped :columns="columns" :data="problems" />
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
</n-flex>
</n-modal>
+15 -9
View File
@@ -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<DataTableColumn<AdminProblemRow>[]>(() => {
: [{ 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],
<n-button size="small" quaternary @click="selectedRowKeys = []">取消选择</n-button>
</div>
<n-data-table
:loading="loading"
striped
class="problem-table"
:columns="columns"
+11 -2
View File
@@ -7,6 +7,7 @@ import type { AdminProblemSet } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api"
import { NButton, NSwitch, NTag, useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useListLoader } from "shared/composables/listLoader"
/**
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
@@ -115,10 +116,12 @@ const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
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)
/>
</template>
</PageHeader>
<n-data-table striped class="set-table" :columns="columns" :data="problemSets" />
<n-data-table
:loading="loading"
striped
class="set-table"
:columns="columns"
:data="problemSets"
/>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
+6 -7
View File
@@ -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
</template>
</PageHeader>
<n-data-table
:loading="loading"
striped
class="user-table"
:data="users"
@@ -0,0 +1,45 @@
import { ref } from "vue"
import { createDiscreteApi } from "naive-ui"
import { errorCode, errorMessage, GLOBALLY_HANDLED_CODES } from "utils/api"
const { message } = createDiscreteApi(["message"])
/**
* 列表页的取数:转圈、出错提示、旧请求晚回来不盖新结果。
*
* 原来后台列表各写各的 `const res = await getXxx(); rows.value = res.results`:
* - 没有 try:接口 500 或断网时什么都不提示,表格停在旧数据上,老师以为筛选没生效;
* - 没有请求序号:关键词防抖和翻页一交错,先发的那个晚回来,会把新结果盖掉;
* - 没有 loading:慢的时候看不出是在加载还是就这么多。
*
* 用法是把请求包一层,其余照旧:
*
* const res = await track(getContestList(offset, query.limit, query.keyword))
* if (!res) return
* contests.value = res.results
*
* 被后发的请求顶掉、或者出错(已经提示过)时 `track` 给 undefined,调用方直接 return。
*/
export function useListLoader() {
const loading = ref(false)
let seq = 0
async function track<R>(request: Promise<R>): Promise<R | undefined> {
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 }
}
+7
View File
@@ -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)