diff --git a/apps/console/package.json b/apps/console/package.json index faf6e12d3f..16c093d4d0 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -39,6 +39,7 @@ "@ulticode/i18n-completeness": "workspace:*", "@ulticode/locale-preference": "workspace:*", "@ulticode/markdown-utils": "workspace:*", + "@ulticode/request-state": "workspace:*", "@ulticode/sidebar-menu": "workspace:*", "@ulticode/submission-status": "workspace:*", "@ulticode/theme": "workspace:*", diff --git a/apps/console/src/api/__tests__/problem.spec.ts b/apps/console/src/api/__tests__/problem.spec.ts index da8874eb56..e82bf8d877 100644 --- a/apps/console/src/api/__tests__/problem.spec.ts +++ b/apps/console/src/api/__tests__/problem.spec.ts @@ -84,7 +84,8 @@ describe("fetchProblems", () => { totalPages: 4, }); - const result = await fetchProblems({ search: "two sum" }, 2, 1); + const signal = new AbortController().signal; + const result = await fetchProblems({ search: "two sum" }, 2, 1, signal); expect(result.items[0]?.title).toBe("Two Sum"); expect(result.total).toBe(4); @@ -93,6 +94,7 @@ describe("fetchProblems", () => { expect(result.totalPages).toBe(4); expect(apiGet).toHaveBeenCalledWith( "/problems?page=2&pageSize=1&search=two+sum", + { signal }, ); }); }); diff --git a/apps/console/src/api/problem.ts b/apps/console/src/api/problem.ts index 7efff40916..752377dbb3 100644 --- a/apps/console/src/api/problem.ts +++ b/apps/console/src/api/problem.ts @@ -29,6 +29,7 @@ export async function fetchProblems( filters: ProblemFilters = {}, page: number = 1, pageSize: number = 50, + signal?: AbortSignal, ): Promise> { const params = new URLSearchParams(); params.append("page", String(page)); @@ -44,7 +45,7 @@ export async function fetchProblems( if (filters.sortBy) params.append("sortBy", filters.sortBy); if (filters.sortOrder) params.append("sortOrder", filters.sortOrder); - const response = await apiGet(`/problems?${params.toString()}`); + const response = await apiGet(`/problems?${params.toString()}`, { signal }); const pageResult = readPage(response); return { ...pageResult, diff --git a/apps/console/src/components/problem/composables/useProblemExplorer.ts b/apps/console/src/components/problem/composables/useProblemExplorer.ts index e5f100ad46..997971a4f7 100644 --- a/apps/console/src/components/problem/composables/useProblemExplorer.ts +++ b/apps/console/src/components/problem/composables/useProblemExplorer.ts @@ -5,6 +5,7 @@ import type { Component } from "vue"; import type { ProblemExplorerProps } from "../type"; import { CheckCircle2, FileEdit } from "lucide-vue-next"; import { fetchProblems } from "@/api/problem"; +import { createValueRequest } from "@ulticode/request-state"; import { toast } from "vue-sonner"; import { useRouter } from "vue-router"; import { PROBLEM_CATEGORIES } from "@/constants/problem-categories"; @@ -33,11 +34,15 @@ export function useProblemExplorer(props: ProblemExplorerProps) { const page = ref(1); const total = ref(0); const totalPages = ref(1); - const isLoading = ref(false); + const problemRequest = createValueRequest({ + errorMessage: "Failed to load problems", + onError: (error) => { + console.error("Failed to load problems", error); + toast.error(t("problem.explorer.failedToLoad")); + }, + }); + const isLoading = problemRequest.loading; const searchDebounceTimer = ref | null>(null); - // Monotonic request token: a fresh search/filter bumps it so an in-flight - // response that was superseded can detect it is stale and discard itself. - const latestRequestToken = ref(0); const selectedCategory = ref(props.initialCategory || "all"); @@ -68,46 +73,28 @@ export function useProblemExplorer(props: ProblemExplorerProps) { }; const loadProblems = async (append = false) => { - // Supplied mode: the caller passed its own problem list, so there is no - // fetch to perform and the watchers/clearFilters/onMounted stay no-op. if (props.problems !== undefined) return; - // Serialize pagination (scroll-driven load-more can fan); fresh - // searches/filters go through and supersede via latestRequestToken. if (append && isLoading.value) return; - const myToken = ++latestRequestToken.value; - isLoading.value = true; - try { - const currentPage = append ? page.value : 1; - const result = await fetchProblems( - buildFilters(), - currentPage, - PROBLEMS_PER_PAGE, - ); - // A newer search/filter superseded this request — discard the stale - // response so it cannot overwrite newer state. - if (myToken !== latestRequestToken.value) return; - if (append) { - fallbackProblems.value = [...fallbackProblems.value, ...result.items]; - } else { - fallbackProblems.value = result.items; - page.value = 1; - } - total.value = result.total; - totalPages.value = result.totalPages; - } catch (error) { - if (myToken !== latestRequestToken.value) return; - console.error("Failed to load problems", error); - toast.error(t("problem.explorer.failedToLoad")); - if (!append) { + const currentPage = append ? page.value : 1; + const result = await problemRequest.run((signal) => + fetchProblems(buildFilters(), currentPage, PROBLEMS_PER_PAGE, signal), + ); + if (!result) { + if (problemRequest.error.value && !append) { fallbackProblems.value = []; total.value = 0; totalPages.value = 1; } - } finally { - if (myToken === latestRequestToken.value) { - isLoading.value = false; - } + return; + } + if (append) { + fallbackProblems.value = [...fallbackProblems.value, ...result.items]; + } else { + fallbackProblems.value = result.items; + page.value = 1; } + total.value = result.total; + totalPages.value = result.totalPages; }; onMounted(() => { diff --git a/apps/console/src/composables/useSearch.ts b/apps/console/src/composables/useSearch.ts index 4b65ab1dfa..0bdeebdd47 100644 --- a/apps/console/src/composables/useSearch.ts +++ b/apps/console/src/composables/useSearch.ts @@ -1,5 +1,6 @@ import { onScopeDispose, ref, type Ref } from "vue"; import { searchApi } from "@/api/search"; +import { createValueRequest } from "@ulticode/request-state"; import type { SearchResult } from "@/types/search"; export interface UseSearchOptions { @@ -29,32 +30,24 @@ export function useSearch(options: UseSearchOptions = {}): UseSearchReturn { const query = ref(""); const results = ref([]); - const loading = ref(false); - const error = ref(null); + const request = createValueRequest({ errorMessage: "Search failed" }); + const { loading, error } = request; const total = ref(0); const selectedIndex = ref(0); const isOpen = ref(false); let debounceTimer: ReturnType | null = null; - let activeController: AbortController | null = null; - let latestRequest = 0; - function cancelPendingSearch(): void { - latestRequest += 1; + request.cancel(); if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } - - activeController?.abort(); - activeController = null; - loading.value = false; } const search = (q: string): void => { cancelPendingSearch(); - const requestId = latestRequest; query.value = q; error.value = null; @@ -68,42 +61,12 @@ export function useSearch(options: UseSearchOptions = {}): UseSearchReturn { debounceTimer = setTimeout(() => { debounceTimer = null; - if (requestId !== latestRequest) { - return; - } - - const controller = new AbortController(); - activeController = controller; - loading.value = true; - - void searchApi - .search({ query: q, limit }, controller.signal) - .then((response) => { - if (requestId !== latestRequest || controller.signal.aborted) { - return; - } - - results.value = response.results; - total.value = response.total; - selectedIndex.value = 0; - }) - .catch((err: unknown) => { - if (requestId !== latestRequest || controller.signal.aborted) { - return; - } - - error.value = err instanceof Error ? err.message : "Search failed"; - results.value = []; - total.value = 0; - }) - .finally(() => { - if (requestId === latestRequest) { - loading.value = false; - if (activeController === controller) { - activeController = null; - } - } - }); + void request.run((signal) => searchApi.search({ query: q, limit }, signal)).then((response) => { + if (!response) return; + results.value = response.results; + total.value = response.total; + selectedIndex.value = 0; + }); }, debounceMs); }; diff --git a/apps/console/src/stores/__tests__/contestBrowse.spec.ts b/apps/console/src/stores/__tests__/contestBrowse.spec.ts index f0a6f51b2b..c5843314ac 100644 --- a/apps/console/src/stores/__tests__/contestBrowse.spec.ts +++ b/apps/console/src/stores/__tests__/contestBrowse.spec.ts @@ -82,12 +82,25 @@ describe("useContestBrowseStore loading state", () => { expect(store.loadingContests).toBe(false); }); - it("does not write past-load failures into shared browse error", async () => { - vi.mocked(fetchPastContests).mockRejectedValue(new Error("past failed")); + it("keeps past-only pagination out of page-wide loading", async () => { + type ContestPage = PaginatedResult; + + const past = deferred(); + vi.mocked(fetchPastContests).mockReturnValue(past.promise); + const store = useContestBrowseStore(); - store.error = "ongoing failed"; + const pastLoad = store.loadPastContests(1, 10); + expect(store.loadingContests).toBe(false); + past.resolve({ items: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }); + await pastLoad; + expect(store.loadingContests).toBe(false); + }); + + it("exposes past-load failures from request-state", async () => { + vi.mocked(fetchPastContests).mockRejectedValue(new Error("past failed")); + const store = useContestBrowseStore(); await expect(store.loadPastContests()).rejects.toThrow("past failed"); - expect(store.error).toBe("ongoing failed"); + expect(store.error).toBe("past failed"); }); }); diff --git a/apps/console/src/stores/achievement.ts b/apps/console/src/stores/achievement.ts index 5c035e68ad..ccb2794cd9 100644 --- a/apps/console/src/stores/achievement.ts +++ b/apps/console/src/stores/achievement.ts @@ -3,12 +3,15 @@ import { ref, computed } from "vue"; import type { Achievement, AchievementProgress } from "@/types/achievement"; import { achievementApi } from "@/api/achievement"; import { getSocketManager, NotificationEvent } from "@/lib/socket"; +import { createValueRequest } from "@ulticode/request-state"; export const useAchievementStore = defineStore("achievement", () => { const achievements = ref([]); const userAchievements = ref([]); const totalPoints = ref(0); - const loading = ref(false); + const allRequest = createValueRequest({ errorMessage: "Failed to load achievements", rethrow: true }); + const userRequest = createValueRequest({ errorMessage: "Failed to load user achievements", rethrow: true }); + const loading = computed(() => allRequest.loading.value || userRequest.loading.value); const initialized = ref(false); const error = ref(null); @@ -42,39 +45,39 @@ export const useAchievementStore = defineStore("achievement", () => { // Actions async function fetchAll(params?: { category?: string }) { - loading.value = true; error.value = null; + allRequest.error.value = null; + userRequest.error.value = null; try { - const result = await achievementApi.getAll(params); + const result = await allRequest.run(() => achievementApi.getAll(params)); + if (!result) return null; achievements.value = result.items; return result; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load achievements"; + error.value = allRequest.error.value; throw err; - } finally { - loading.value = false; } } async function fetchUserAchievements() { - loading.value = true; error.value = null; + allRequest.error.value = null; + userRequest.error.value = null; try { - const result = await achievementApi.getUserAchievements(); + const result = await userRequest.run(() => achievementApi.getUserAchievements()); + if (!result) return null; userAchievements.value = result; return result; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load user achievements"; + error.value = userRequest.error.value; throw err; - } finally { - loading.value = false; } } async function fetchUserPoints() { error.value = null; + allRequest.error.value = null; + userRequest.error.value = null; try { const result = await achievementApi.getUserPoints(); totalPoints.value = result.points; @@ -90,6 +93,8 @@ export const useAchievementStore = defineStore("achievement", () => { if (initialized.value) return; error.value = null; + allRequest.error.value = null; + userRequest.error.value = null; try { await Promise.all([fetchUserAchievements(), fetchUserPoints()]); initialized.value = true; @@ -134,6 +139,8 @@ export const useAchievementStore = defineStore("achievement", () => { function clearError() { error.value = null; + allRequest.error.value = null; + userRequest.error.value = null; } return { diff --git a/apps/console/src/stores/bookmark.ts b/apps/console/src/stores/bookmark.ts index 0f7011b820..f76d953b10 100644 --- a/apps/console/src/stores/bookmark.ts +++ b/apps/console/src/stores/bookmark.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; +import { createValueRequest } from "@ulticode/request-state"; import type { AddBookmarkInput, BookmarkFolder, @@ -28,10 +29,14 @@ export const useBookmarkStore = defineStore("bookmark", () => { const selectedFolderId = ref(null); const selectedFolderDetails = ref(null); const isLoading = ref(false); - const isLoadingDetails = ref(false); + const detailRequest = createValueRequest({ + errorMessage: "Failed to load folder details", + rethrow: true, + }); + const isLoadingDetails = detailRequest.loading; const isLoaded = ref(false); - const error = ref(null); - let detailRequestId = 0; + const operationError = ref(null); + const error = computed(() => operationError.value ?? detailRequest.error.value); const defaultFolder = computed(() => folders.value.find((f) => f.isDefault)); @@ -51,7 +56,8 @@ export const useBookmarkStore = defineStore("bookmark", () => { if (isLoaded.value && !force) return; isLoading.value = true; - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { folders.value = await fetchFolders(); isLoaded.value = true; @@ -62,7 +68,7 @@ export const useBookmarkStore = defineStore("bookmark", () => { resetSelection(); } } catch (err) { - error.value = getErrorMessage(err, "Failed to load folders"); + operationError.value = getErrorMessage(err, "Failed to load folders"); throw err; } finally { isLoading.value = false; @@ -82,24 +88,14 @@ export const useBookmarkStore = defineStore("bookmark", () => { } async function loadFolderDetails(id: string) { - const requestId = ++detailRequestId; - isLoadingDetails.value = true; - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { - const details = await fetchFolder(id); - if (requestId === detailRequestId) { - selectedFolderDetails.value = details; - } + const details = await detailRequest.run(() => fetchFolder(id)); + if (details) selectedFolderDetails.value = details; } catch (err) { - if (requestId === detailRequestId) { - selectedFolderDetails.value = null; - error.value = getErrorMessage(err, "Failed to load folder details"); - } + selectedFolderDetails.value = null; throw err; - } finally { - if (requestId === detailRequestId) { - isLoadingDetails.value = false; - } } } @@ -117,23 +113,25 @@ export const useBookmarkStore = defineStore("bookmark", () => { } function resetSelection() { - detailRequestId += 1; + detailRequest.cancel(); selectedFolderId.value = null; selectedFolderDetails.value = null; isLoadingDetails.value = false; - error.value = null; + operationError.value = null; + detailRequest.error.value = null; } async function createFolder( data: CreateFolderInput, ): Promise { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { const newFolder = await apiCreateFolder(data); folders.value.push(newFolder); return newFolder; } catch (err) { - error.value = getErrorMessage(err, "Failed to create folder"); + operationError.value = getErrorMessage(err, "Failed to create folder"); throw err; } } @@ -142,7 +140,8 @@ export const useBookmarkStore = defineStore("bookmark", () => { id: string, data: UpdateFolderInput, ): Promise { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { const updated = await apiUpdateFolder(id, data); const index = folders.value.findIndex((f) => f.id === id); @@ -158,13 +157,14 @@ export const useBookmarkStore = defineStore("bookmark", () => { } return updated; } catch (err) { - error.value = getErrorMessage(err, "Failed to update folder"); + operationError.value = getErrorMessage(err, "Failed to update folder"); throw err; } } async function removeFolder(id: string): Promise { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { await apiDeleteFolder(id); folders.value = folders.value.filter((f) => f.id !== id); @@ -172,13 +172,14 @@ export const useBookmarkStore = defineStore("bookmark", () => { resetSelection(); } } catch (err) { - error.value = getErrorMessage(err, "Failed to delete folder"); + operationError.value = getErrorMessage(err, "Failed to delete folder"); throw err; } } async function reorderFolders(ids: string[]): Promise { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { await apiReorderFolders(ids); ids.forEach((id, index) => { @@ -193,7 +194,7 @@ export const useBookmarkStore = defineStore("bookmark", () => { return a.sortOrder - b.sortOrder; }); } catch (err) { - error.value = getErrorMessage(err, "Failed to reorder folders"); + operationError.value = getErrorMessage(err, "Failed to reorder folders"); throw err; } } @@ -242,7 +243,8 @@ export const useBookmarkStore = defineStore("bookmark", () => { data: AddBookmarkInput, ): Promise { return runFolderMutation(folderId, async () => { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { const item = await apiAddBookmark(folderId, data); const details = selectedFolderDetails.value; @@ -256,7 +258,7 @@ export const useBookmarkStore = defineStore("bookmark", () => { } return item; } catch (err) { - error.value = getErrorMessage(err, "Failed to add bookmark"); + operationError.value = getErrorMessage(err, "Failed to add bookmark"); throw err; } }); @@ -267,7 +269,8 @@ export const useBookmarkStore = defineStore("bookmark", () => { bookmarkId: string, ): Promise { return runFolderMutation(folderId, async () => { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { await apiRemoveBookmark(folderId, bookmarkId); const details = selectedFolderDetails.value; @@ -278,7 +281,7 @@ export const useBookmarkStore = defineStore("bookmark", () => { updateItemCount(folderId, -1); } } catch (err) { - error.value = getErrorMessage(err, "Failed to remove bookmark"); + operationError.value = getErrorMessage(err, "Failed to remove bookmark"); throw err; } }); @@ -290,7 +293,8 @@ export const useBookmarkStore = defineStore("bookmark", () => { targetId: string, ): Promise { return runFolderMutation(folderId, async () => { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; try { await apiRemoveBookmarkByTarget(folderId, targetType, targetId); const details = selectedFolderDetails.value; @@ -304,14 +308,15 @@ export const useBookmarkStore = defineStore("bookmark", () => { updateItemCount(folderId, -1); } } catch (err) { - error.value = getErrorMessage(err, "Failed to remove bookmark"); + operationError.value = getErrorMessage(err, "Failed to remove bookmark"); throw err; } }); } function clearError() { - error.value = null; + operationError.value = null; + detailRequest.error.value = null; } function reset() { @@ -319,7 +324,8 @@ export const useBookmarkStore = defineStore("bookmark", () => { folders.value = []; isLoaded.value = false; isLoading.value = false; - error.value = null; + operationError.value = null; + detailRequest.error.value = null; } return { diff --git a/apps/console/src/stores/contest/rankingStore.ts b/apps/console/src/stores/contest/rankingStore.ts index 61ad53bacc..99afd76502 100644 --- a/apps/console/src/stores/contest/rankingStore.ts +++ b/apps/console/src/stores/contest/rankingStore.ts @@ -1,5 +1,6 @@ // console/src/stores/contest/rankingStore.ts import { defineStore } from "pinia"; +import { createValueRequest } from "@ulticode/request-state"; import { ref, computed } from "vue"; import type { RankingEntry } from "@/types/contest"; import { getRanking } from "@/api/contest"; @@ -13,10 +14,14 @@ export const useRankingStore = defineStore("ranking", () => { const rankings = ref([]); /** Loading state */ - const loading = ref(false); + const rankingRequest = createValueRequest({ + errorMessage: "Failed to load ranking", + rethrow: true, + }); + const loading = rankingRequest.loading; /** Error message */ - const error = ref(null); + const error = rankingRequest.error; /** Whether the ranking is frozen (during final minutes of contest) */ const frozen = ref(false); @@ -46,17 +51,11 @@ export const useRankingStore = defineStore("ranking", () => { slug: string, options?: { page?: number; limit?: number; includeVirtual?: boolean }, ): Promise { - loading.value = true; - error.value = null; try { - const result = await getRanking(slug, options); - rankings.value = result.items; + const result = await rankingRequest.run(() => getRanking(slug, options)); + if (result) rankings.value = result.items; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load ranking"; throw err; - } finally { - loading.value = false; } } @@ -64,9 +63,10 @@ export const useRankingStore = defineStore("ranking", () => { * Clear ranking data */ function clearRanking(): void { + rankingRequest.cancel(); + rankingRequest.error.value = null; rankings.value = []; frozen.value = false; - error.value = null; } /** @@ -87,16 +87,16 @@ export const useRankingStore = defineStore("ranking", () => { * Clear error state */ function clearError(): void { - error.value = null; + rankingRequest.error.value = null; } /** * Reset store to initial state */ function $reset(): void { + rankingRequest.cancel(); + rankingRequest.error.value = null; rankings.value = []; - loading.value = false; - error.value = null; frozen.value = false; } diff --git a/apps/console/src/stores/contestBrowse.ts b/apps/console/src/stores/contestBrowse.ts index fbdf0855aa..f96218b19e 100644 --- a/apps/console/src/stores/contestBrowse.ts +++ b/apps/console/src/stores/contestBrowse.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; -import { ref } from "vue"; +import { computed, ref } from "vue"; +import { createValueRequest } from "@ulticode/request-state"; import type { ContestListItem } from "@/types/contest"; import { fetchUpcomingContests, @@ -28,7 +29,18 @@ export const useContestBrowseStore = defineStore("contestBrowse", () => { const pastContests = ref([]); const pastContestsTotal = ref(0); - const loadingContests = ref(false); + const contestsRequest = createValueRequest({ + errorMessage: "Failed to load contests", + rethrow: true, + }); + const pastContestsRequest = createValueRequest({ + errorMessage: "Failed to load contests", + rethrow: true, + }); + // Page-wide loading covers the ongoing/upcoming lists only; past-tab + // pagination reports through usePastContestsPager's own tab-level loading + // so page turns keep the finished-tab skeleton and pager UI. + const loadingContests = computed(() => contestsRequest.loading.value); const error = ref(null); // ========================================================================= @@ -36,32 +48,43 @@ export const useContestBrowseStore = defineStore("contestBrowse", () => { // ========================================================================= async function loadContests() { - loadingContests.value = true; error.value = null; + contestsRequest.error.value = null; + pastContestsRequest.error.value = null; try { - const [upcoming, running] = await Promise.all([ + const lists = await contestsRequest.run(() => Promise.all([ fetchUpcomingContests(), fetchRunningContests(), - ]); + ])); + if (!lists) return; + const [upcoming, running] = lists; upcomingContests.value = upcoming.items; runningContests.value = running.items; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load contests"; + error.value = contestsRequest.error.value; throw err; - } finally { - loadingContests.value = false; } } async function loadPastContests(page: number = 1, pageSize: number = 10) { - const result = await fetchPastContests(page, pageSize); - pastContests.value = result.items; - pastContestsTotal.value = result.total; + error.value = null; + contestsRequest.error.value = null; + pastContestsRequest.error.value = null; + try { + const result = await pastContestsRequest.run(() => fetchPastContests(page, pageSize)); + if (!result) return; + pastContests.value = result.items; + pastContestsTotal.value = result.total; + } catch (err) { + error.value = pastContestsRequest.error.value; + throw err; + } } function clearError() { error.value = null; + contestsRequest.error.value = null; + pastContestsRequest.error.value = null; } return { diff --git a/apps/console/src/stores/contestDetail.ts b/apps/console/src/stores/contestDetail.ts index 2d7db4f842..1c65ccf4cb 100644 --- a/apps/console/src/stores/contestDetail.ts +++ b/apps/console/src/stores/contestDetail.ts @@ -1,4 +1,5 @@ import { defineStore } from "pinia"; +import { createValueRequest } from "@ulticode/request-state"; import { computed, ref } from "vue"; import type { ContestDetail, @@ -52,7 +53,11 @@ export const useContestDetailStore = defineStore("contestDetail", () => { const userParticipation = ref>(new Map()); - const loading = ref(false); + const detailRequest = createValueRequest({ + errorMessage: "Failed to load contest details", + rethrow: true, + }); + const loading = detailRequest.loading; const error = ref(null); // ========================================================================= @@ -72,16 +77,14 @@ export const useContestDetailStore = defineStore("contestDetail", () => { // ========================================================================= async function loadContestDetail(contestId: string) { - loading.value = true; error.value = null; + detailRequest.error.value = null; try { - currentContest.value = await fetchContestDetail(contestId); + const contest = await detailRequest.run(() => fetchContestDetail(contestId)); + if (contest) currentContest.value = contest; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load contest details"; + error.value = detailRequest.error.value; throw err; - } finally { - loading.value = false; } } @@ -109,6 +112,7 @@ export const useContestDetailStore = defineStore("contestDetail", () => { async function registerForContest(contestId: string) { error.value = null; + detailRequest.error.value = null; try { await apiRegister(contestId); const status = await fetchParticipationStatus(contestId); @@ -129,6 +133,7 @@ export const useContestDetailStore = defineStore("contestDetail", () => { async function unregisterFromContest(contestId: string) { error.value = null; + detailRequest.error.value = null; try { await apiUnregister(contestId); const status = await fetchParticipationStatus(contestId); @@ -177,6 +182,7 @@ export const useContestDetailStore = defineStore("contestDetail", () => { function clearError() { error.value = null; + detailRequest.error.value = null; } return { diff --git a/apps/console/src/stores/contestRanking.ts b/apps/console/src/stores/contestRanking.ts index eb614cbdea..43fcb92aba 100644 --- a/apps/console/src/stores/contestRanking.ts +++ b/apps/console/src/stores/contestRanking.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; -import { ref } from "vue"; +import { createValueRequest } from "@ulticode/request-state"; +import { computed, ref } from "vue"; import type { ContestListItem, GlobalRankingEntry, UserContestHistory } from "@/types/contest"; import { fetchGlobalRankings, @@ -30,14 +31,19 @@ export const useContestRankingStore = defineStore("contestRanking", () => { // ========================================================================= const globalRankings = ref([]); - const loadingRankings = ref(false); + const rankingsRequest = createValueRequest({ + errorMessage: "Failed to load rankings", + rethrow: true, + }); + const loadingRankings = rankingsRequest.loading; const registeredContests = ref([]); const participatedContests = ref([]); const virtualContests = ref([]); const contestHistory = ref([]); - const error = ref(null); + const operationError = ref(null); + const error = computed(() => operationError.value ?? rankingsRequest.error.value); // ========================================================================= // ACTIONS — GLOBAL RANKINGS @@ -48,21 +54,17 @@ export const useContestRankingStore = defineStore("contestRanking", () => { limit?: number; country?: string; }) { - loadingRankings.value = true; - error.value = null; + operationError.value = null; + rankingsRequest.error.value = null; try { - const result = await fetchGlobalRankings({ + const result = await rankingsRequest.run(() => fetchGlobalRankings({ page: options?.page ?? 1, limit: options?.limit ?? 10, country: options?.country, - }); - globalRankings.value = result.items; + })); + if (result) globalRankings.value = result.items; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load rankings"; throw err; - } finally { - loadingRankings.value = false; } } @@ -73,7 +75,8 @@ export const useContestRankingStore = defineStore("contestRanking", () => { async function loadUserContests( type?: "registered" | "participated" | "virtual", ) { - error.value = null; + operationError.value = null; + rankingsRequest.error.value = null; try { if (type) { const result = await apiFetchUserContests(type); @@ -91,25 +94,27 @@ export const useContestRankingStore = defineStore("contestRanking", () => { virtualContests.value = virtual.items; } } catch (err) { - error.value = + operationError.value = err instanceof Error ? err.message : "Failed to load user contests"; throw err; } } async function loadContestHistory() { - error.value = null; + operationError.value = null; + rankingsRequest.error.value = null; try { contestHistory.value = await fetchUserContestHistory(); } catch (err) { - error.value = + operationError.value = err instanceof Error ? err.message : "Failed to load contest history"; throw err; } } function clearError() { - error.value = null; + operationError.value = null; + rankingsRequest.error.value = null; } return { diff --git a/apps/console/src/stores/userStats.ts b/apps/console/src/stores/userStats.ts index 638a566a00..8edf4f0ae0 100644 --- a/apps/console/src/stores/userStats.ts +++ b/apps/console/src/stores/userStats.ts @@ -3,11 +3,14 @@ import { ref, computed } from "vue"; import type { UserStats, UserSkills } from "@/types/userStats"; import { fetchUserStats, fetchUserSkills } from "@/api/user"; import { useAuthStore } from "./auth"; +import { createValueRequest } from "@ulticode/request-state"; export const useUserStatsStore = defineStore("userStats", () => { const stats = ref(null); const skills = ref(null); - const loading = ref(false); + const statsRequest = createValueRequest({ errorMessage: "Failed to load stats", rethrow: true }); + const skillsRequest = createValueRequest({ errorMessage: "Failed to load skills", rethrow: true }); + const loading = computed(() => statsRequest.loading.value || skillsRequest.loading.value); const lastFetch = ref(0); const cacheTTL = 5 * 60 * 1000; // 5 分钟 const error = ref(null); @@ -69,51 +72,44 @@ export const useUserStatsStore = defineStore("userStats", () => { async function fetchStats(forceRefresh = false) { const authStore = useAuthStore(); if (!authStore.userId) return null; - - if (!forceRefresh && isCacheValid.value && stats.value) { - return stats.value; - } - - loading.value = true; + if (!forceRefresh && isCacheValid.value && stats.value) return stats.value; error.value = null; + statsRequest.error.value = null; + skillsRequest.error.value = null; try { - const result = await fetchUserStats(authStore.userId); + const result = await statsRequest.run(() => fetchUserStats(authStore.userId!)); + if (!result) return null; stats.value = result; lastFetch.value = Date.now(); return result; } catch (err) { - error.value = err instanceof Error ? err.message : "Failed to load stats"; + error.value = statsRequest.error.value; throw err; - } finally { - loading.value = false; } } async function fetchSkills(forceRefresh = false) { const authStore = useAuthStore(); if (!authStore.userId) return null; - - if (!forceRefresh && skills.value) { - return skills.value; - } - - loading.value = true; + if (!forceRefresh && skills.value) return skills.value; error.value = null; + statsRequest.error.value = null; + skillsRequest.error.value = null; try { - const result = await fetchUserSkills(authStore.userId); + const result = await skillsRequest.run(() => fetchUserSkills(authStore.userId!)); + if (!result) return null; skills.value = result; return result; } catch (err) { - error.value = - err instanceof Error ? err.message : "Failed to load skills"; + error.value = skillsRequest.error.value; throw err; - } finally { - loading.value = false; } } async function initialize() { error.value = null; + statsRequest.error.value = null; + skillsRequest.error.value = null; try { await Promise.all([fetchStats(), fetchSkills()]); } catch (err) { @@ -128,6 +124,8 @@ export const useUserStatsStore = defineStore("userStats", () => { function clearError() { error.value = null; + statsRequest.error.value = null; + skillsRequest.error.value = null; } return { diff --git a/apps/console/src/views/contest/components/MyContests.vue b/apps/console/src/views/contest/components/MyContests.vue index 63f59b73e2..a0a3c0e1f1 100644 --- a/apps/console/src/views/contest/components/MyContests.vue +++ b/apps/console/src/views/contest/components/MyContests.vue @@ -1,5 +1,6 @@