后台八个列表(题目、比赛、用户、公告、题单、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:
1 parent
4dfd14f95b
commit
d4aadc7722
10 files changed
+122
-27
No files matched your search
@@ -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
|
||||
// 第一次进来、或者翻页 / 搜索之后选中的那份不在这一页了:默认看第一份
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user