diff --git a/web/src/components/layout/app-providers.tsx b/web/src/components/layout/app-providers.tsx index 96f5f1996..034e26855 100644 --- a/web/src/components/layout/app-providers.tsx +++ b/web/src/components/layout/app-providers.tsx @@ -1,24 +1,15 @@ import type { ReactNode } from "react"; import { useEffect } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { App, ConfigProvider } from "antd"; import zhCN from "antd/locale/zh_CN"; import { AuthSessionHydrator } from "@/components/auth/auth-session-hydrator"; import { ClientRootInit } from "@/components/layout/client-root-init"; import { getAntThemeConfig } from "@/lib/app-theme"; +import { appQueryClient } from "@/lib/query-client"; import { useThemeStore } from "@/stores/use-theme-store"; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: false, - refetchOnWindowFocus: false, - }, - }, -}); - export function AppProviders({ children }: { children: ReactNode }) { const theme = useThemeStore((state) => state.theme); const dark = theme === "dark"; @@ -31,7 +22,7 @@ export function AppProviders({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/web/src/hooks/use-wallet-balance.ts b/web/src/hooks/use-wallet-balance.ts index 5f40b8adc..ef1c608e9 100644 --- a/web/src/hooks/use-wallet-balance.ts +++ b/web/src/hooks/use-wallet-balance.ts @@ -1,32 +1,52 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getWallet } from "@/services/api/wallet"; const WALLET_REFRESH_INTERVAL_MS = 30_000; +type WalletBalanceSnapshot = { + userId: string; + availableMicrocredits: number | null; + refreshing: boolean; +}; + export function useWalletBalance(userId?: string, enabled = true) { - const [availableMicrocredits, setAvailableMicrocredits] = useState(null); - const [refreshing, setRefreshing] = useState(false); + const activeUserId = enabled ? userId || "" : ""; + const requestSequence = useRef(0); + const [snapshot, setSnapshot] = useState({ userId: "", availableMicrocredits: null, refreshing: false }); const refresh = useCallback(async () => { - if (!enabled || !userId) { - setAvailableMicrocredits(null); + const requestedUserId = activeUserId; + const sequence = ++requestSequence.current; + if (!requestedUserId) { + setSnapshot({ userId: "", availableMicrocredits: null, refreshing: false }); return; } - setRefreshing(true); + setSnapshot((current) => ({ + userId: requestedUserId, + availableMicrocredits: current.userId === requestedUserId ? current.availableMicrocredits : null, + refreshing: true, + })); try { const wallet = await getWallet(1, 1); - setAvailableMicrocredits(wallet.account.availableMicrocredits); - } catch { - // 顶栏余额是只读辅助信息,读取失败时保留上次成功值,避免短暂网络错误造成闪烁。 - } finally { - setRefreshing(false); + if (sequence !== requestSequence.current) return; + if (wallet.account.userId !== requestedUserId) throw new Error("积分账户与当前用户不一致"); + setSnapshot({ userId: requestedUserId, availableMicrocredits: wallet.account.availableMicrocredits, refreshing: false }); + } catch (error) { + if (sequence !== requestSequence.current) return; + console.warn("积分余额刷新失败", error); + setSnapshot((current) => ({ + userId: requestedUserId, + availableMicrocredits: current.userId === requestedUserId ? current.availableMicrocredits : null, + refreshing: false, + })); } - }, [enabled, userId]); + }, [activeUserId]); useEffect(() => { - if (!enabled || !userId) { - setAvailableMicrocredits(null); + if (!activeUserId) { + requestSequence.current += 1; + setSnapshot({ userId: "", availableMicrocredits: null, refreshing: false }); return; } void refresh(); @@ -39,12 +59,18 @@ export function useWalletBalance(userId?: string, enabled = true) { window.addEventListener("wallet:updated", handleFocus); document.addEventListener("visibilitychange", handleVisibility); return () => { + requestSequence.current += 1; window.clearInterval(timer); window.removeEventListener("focus", handleFocus); window.removeEventListener("wallet:updated", handleFocus); document.removeEventListener("visibilitychange", handleVisibility); }; - }, [enabled, refresh, userId]); + }, [activeUserId, refresh]); - return { availableMicrocredits, refreshing, refresh }; + const snapshotMatchesUser = snapshot.userId === activeUserId; + return { + availableMicrocredits: snapshotMatchesUser ? snapshot.availableMicrocredits : null, + refreshing: snapshotMatchesUser ? snapshot.refreshing : Boolean(activeUserId), + refresh, + }; } diff --git a/web/src/lib/query-client.ts b/web/src/lib/query-client.ts new file mode 100644 index 000000000..4fa4eb098 --- /dev/null +++ b/web/src/lib/query-client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const appQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 950fca272..de998eaf3 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -1,5 +1,6 @@ import { getSystemChannels, type AuthSessionPayload } from "@/services/api/auth"; import { localForageStorage } from "@/lib/localforage-storage"; +import { appQueryClient } from "@/lib/query-client"; import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope"; import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { ASSET_STORE_KEY, useAssetStore } from "@/stores/use-asset-store"; @@ -8,8 +9,12 @@ import { useUserStore } from "@/stores/use-user-store"; import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData } from "@/services/user-data-sync"; export async function applyUserSession(payload: AuthSessionPayload) { + const previousUserId = useUserStore.getState().user?.id || ""; + const nextUserId = payload.user?.id || ""; useUserStore.getState().setHydrated(false); try { + // Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。 + if (previousUserId !== nextUserId) appQueryClient.clear(); resetRemoteUserDataSync(); await flushCanvasStorePersistence(); setActiveUserScope(payload.user?.id);