From b15c70fd6b98b868f89df8d0d35dd1edfe1f830c Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 22 Feb 2026 03:35:35 +0800 Subject: [PATCH] feat(usage): add shared usage stats store --- .../providers/hooks/useProviderStats.ts | 47 +++--- src/components/usage/hooks/useUsageData.ts | 37 ++--- .../authFiles/hooks/useAuthFilesStats.ts | 37 ++--- src/pages/AiProvidersPage.tsx | 5 +- src/pages/AuthFilesPage.tsx | 17 ++- src/stores/index.ts | 1 + src/stores/useUsageStatsStore.ts | 141 ++++++++++++++++++ 7 files changed, 204 insertions(+), 81 deletions(-) create mode 100644 src/stores/useUsageStatsStore.ts diff --git a/src/components/providers/hooks/useProviderStats.ts b/src/components/providers/hooks/useProviderStats.ts index 4955529..7169b0b 100644 --- a/src/components/providers/hooks/useProviderStats.ts +++ b/src/components/providers/hooks/useProviderStats.ts @@ -1,37 +1,26 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback } from 'react'; import { useInterval } from '@/hooks/useInterval'; -import { usageApi } from '@/services/api'; -import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage'; - -const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} }; +import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; export const useProviderStats = () => { - const [keyStats, setKeyStats] = useState(EMPTY_STATS); - const [usageDetails, setUsageDetails] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const loadingRef = useRef(false); + const keyStats = useUsageStatsStore((state) => state.keyStats); + const usageDetails = useUsageStatsStore((state) => state.usageDetails); + const isLoading = useUsageStatsStore((state) => state.loading); + const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); - // 加载 key 统计和 usage 明细(API 层已有60秒超时) + // 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。 const loadKeyStats = useCallback(async () => { - if (loadingRef.current) return; - loadingRef.current = true; - setIsLoading(true); - try { - const usageResponse = await usageApi.getUsage(); - const usageData = usageResponse?.usage ?? usageResponse; - const stats = await usageApi.getKeyStats(usageData); - setKeyStats(stats); - setUsageDetails(collectUsageDetails(usageData)); - } catch { - // 静默失败 - } finally { - loadingRef.current = false; - setIsLoading(false); - } - }, []); + await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }); + }, [loadUsageStats]); - // 定时刷新状态数据(每240秒) - useInterval(loadKeyStats, 240_000); + // 定时器触发时强制刷新共享 usage。 + const refreshKeyStats = useCallback(async () => { + await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); + }, [loadUsageStats]); - return { keyStats, usageDetails, loadKeyStats, isLoading }; + useInterval(() => { + void refreshKeyStats(); + }, 240_000); + + return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading }; }; diff --git a/src/components/usage/hooks/useUsageData.ts b/src/components/usage/hooks/useUsageData.ts index a51b712..e68cc0b 100644 --- a/src/components/usage/hooks/useUsageData.ts +++ b/src/components/usage/hooks/useUsageData.ts @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNotificationStore } from '@/stores'; +import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores'; import { usageApi } from '@/services/api/usage'; import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage'; @@ -32,36 +32,25 @@ export interface UseUsageDataReturn { export function useUsageData(): UseUsageDataReturn { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); + const usageSnapshot = useUsageStatsStore((state) => state.usage); + const loading = useUsageStatsStore((state) => state.loading); + const storeError = useUsageStatsStore((state) => state.error); + const lastRefreshedAtTs = useUsageStatsStore((state) => state.lastRefreshedAt); + const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); - const [usage, setUsage] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); const [modelPrices, setModelPrices] = useState>({}); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); - const [lastRefreshedAt, setLastRefreshedAt] = useState(null); const importInputRef = useRef(null); const loadUsage = useCallback(async () => { - setLoading(true); - setError(''); - try { - const data = await usageApi.getUsage(); - const payload = (data?.usage ?? data) as unknown; - setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null); - setLastRefreshedAt(new Date()); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('usage_stats.loading_error'); - setError(message); - } finally { - setLoading(false); - } - }, [t]); + await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); + }, [loadUsageStats]); useEffect(() => { - loadUsage(); + void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }); setModelPrices(loadModelPrices()); - }, [loadUsage]); + }, [loadUsageStats]); const handleExport = async () => { setExporting(true); @@ -122,7 +111,7 @@ export function useUsageData(): UseUsageDataReturn { }), 'success' ); - await loadUsage(); + await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; showNotification( @@ -139,6 +128,10 @@ export function useUsageData(): UseUsageDataReturn { saveModelPrices(prices); }, []); + const usage = usageSnapshot as UsagePayload | null; + const error = storeError || ''; + const lastRefreshedAt = lastRefreshedAtTs ? new Date(lastRefreshedAtTs) : null; + return { usage, loading, diff --git a/src/features/authFiles/hooks/useAuthFilesStats.ts b/src/features/authFiles/hooks/useAuthFilesStats.ts index 64c6c27..b1622ab 100644 --- a/src/features/authFiles/hooks/useAuthFilesStats.ts +++ b/src/features/authFiles/hooks/useAuthFilesStats.ts @@ -1,35 +1,26 @@ -import { useCallback, useRef, useState } from 'react'; -import { usageApi } from '@/services/api'; -import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage'; +import { useCallback } from 'react'; +import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; +import type { KeyStats, UsageDetail } from '@/utils/usage'; export type UseAuthFilesStatsResult = { keyStats: KeyStats; usageDetails: UsageDetail[]; loadKeyStats: () => Promise; + refreshKeyStats: () => Promise; }; export function useAuthFilesStats(): UseAuthFilesStatsResult { - const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); - const [usageDetails, setUsageDetails] = useState([]); - const loadingKeyStatsRef = useRef(false); + const keyStats = useUsageStatsStore((state) => state.keyStats); + const usageDetails = useUsageStatsStore((state) => state.usageDetails); + const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); const loadKeyStats = useCallback(async () => { - if (loadingKeyStatsRef.current) return; - loadingKeyStatsRef.current = true; - try { - const usageResponse = await usageApi.getUsage(); - const usageData = usageResponse?.usage ?? usageResponse; - const stats = await usageApi.getKeyStats(usageData); - setKeyStats(stats); - const details = collectUsageDetails(usageData); - setUsageDetails(details); - } catch { - // 静默失败 - } finally { - loadingKeyStatsRef.current = false; - } - }, []); + await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }); + }, [loadUsageStats]); - return { keyStats, usageDetails, loadKeyStats }; + const refreshKeyStats = useCallback(async () => { + await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); + }, [loadUsageStats]); + + return { keyStats, usageDetails, loadKeyStats, refreshKeyStats }; } - diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 8dfba35..c7d8e6e 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -15,6 +15,7 @@ import { withDisableAllModelsRule, withoutDisableAllModelsRule, } from '@/components/providers/utils'; +import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { ampcodeApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; @@ -58,7 +59,7 @@ export function AiProvidersPage() { const disableControls = connectionStatus !== 'connected'; const isSwitching = Boolean(configSwitchingKey); - const { keyStats, usageDetails, loadKeyStats } = useProviderStats(); + const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats(); const getErrorMessage = (err: unknown) => { if (err instanceof Error) return err.message; @@ -129,6 +130,8 @@ export function AiProvidersPage() { config?.openaiCompatibility, ]); + useHeaderRefresh(refreshKeyStats); + const openEditor = useCallback( (path: string) => { navigate(path, { state: { fromAiProviders: true } }); diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 3c1b80f..a9214ce 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -63,7 +63,7 @@ export function AuthFilesPage() { const previousSelectionCountRef = useRef(0); const selectionCountRef = useRef(0); - const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats(); + const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useAuthFilesStats(); const { files, selectedFiles, @@ -87,7 +87,7 @@ export function AuthFilesPage() { deselectAll, batchSetStatus, batchDelete - } = useAuthFilesData({ refreshKeyStats: loadKeyStats }); + } = useAuthFilesData({ refreshKeyStats }); const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails); @@ -130,7 +130,7 @@ export function AuthFilesPage() { } = useAuthFilesPrefixProxyEditor({ disableControls: connectionStatus !== 'connected', loadFiles, - loadKeyStats + loadKeyStats: refreshKeyStats }); const disableControls = connectionStatus !== 'connected'; @@ -204,8 +204,8 @@ export function AuthFilesPage() { }; const handleHeaderRefresh = useCallback(async () => { - await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelAlias()]); - }, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]); + await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]); + }, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]); useHeaderRefresh(handleHeaderRefresh); @@ -217,7 +217,12 @@ export function AuthFilesPage() { loadModelAlias(); }, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]); - useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null); + useInterval( + () => { + void refreshKeyStats(); + }, + isCurrentLayer ? 240_000 : null + ); useInterval(() => setNowMs(Date.now()), isCurrentLayer ? 60_000 : null); const existingTypes = useMemo(() => { diff --git a/src/stores/index.ts b/src/stores/index.ts index 6c287ca..893ec83 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -11,3 +11,4 @@ export { useModelsStore } from './useModelsStore'; export { useQuotaStore } from './useQuotaStore'; export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore'; export { useClaudeEditDraftStore } from './useClaudeEditDraftStore'; +export { useUsageStatsStore, USAGE_STATS_STALE_TIME_MS } from './useUsageStatsStore'; diff --git a/src/stores/useUsageStatsStore.ts b/src/stores/useUsageStatsStore.ts new file mode 100644 index 0000000..1b1c86a --- /dev/null +++ b/src/stores/useUsageStatsStore.ts @@ -0,0 +1,141 @@ +import { create } from 'zustand'; +import { usageApi } from '@/services/api'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { collectUsageDetails, computeKeyStats, type KeyStats, type UsageDetail } from '@/utils/usage'; +import i18n from '@/i18n'; + +export const USAGE_STATS_STALE_TIME_MS = 240_000; + +export type LoadUsageStatsOptions = { + force?: boolean; + staleTimeMs?: number; +}; + +type UsageStatsSnapshot = Record; + +type UsageStatsState = { + usage: UsageStatsSnapshot | null; + keyStats: KeyStats; + usageDetails: UsageDetail[]; + loading: boolean; + error: string | null; + lastRefreshedAt: number | null; + scopeKey: string; + loadUsageStats: (options?: LoadUsageStatsOptions) => Promise; + clearUsageStats: () => void; +}; + +const createEmptyKeyStats = (): KeyStats => ({ bySource: {}, byAuthIndex: {} }); + +let usageRequestToken = 0; +let inFlightUsageRequest: { id: number; scopeKey: string; promise: Promise } | null = null; + +const getErrorMessage = (error: unknown) => + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : i18n.t('usage_stats.loading_error'); + +export const useUsageStatsStore = create((set, get) => ({ + usage: null, + keyStats: createEmptyKeyStats(), + usageDetails: [], + loading: false, + error: null, + lastRefreshedAt: null, + scopeKey: '', + + loadUsageStats: async (options = {}) => { + const force = options.force === true; + const staleTimeMs = options.staleTimeMs ?? USAGE_STATS_STALE_TIME_MS; + const { apiBase = '', managementKey = '' } = useAuthStore.getState(); + const scopeKey = `${apiBase}::${managementKey}`; + const state = get(); + const scopeChanged = state.scopeKey !== scopeKey; + + // 先复用同源 in-flight 请求,避免多个页面同时发起重复 /usage。 + if (inFlightUsageRequest && inFlightUsageRequest.scopeKey === scopeKey) { + await inFlightUsageRequest.promise; + return; + } + + // 连接目标变化时,旧请求结果必须失效。 + if (inFlightUsageRequest && inFlightUsageRequest.scopeKey !== scopeKey) { + usageRequestToken += 1; + inFlightUsageRequest = null; + } + + const fresh = + !scopeChanged && + state.lastRefreshedAt !== null && + Date.now() - state.lastRefreshedAt < staleTimeMs; + + if (!force && fresh) { + return; + } + + if (scopeChanged) { + set({ + usage: null, + keyStats: createEmptyKeyStats(), + usageDetails: [], + error: null, + lastRefreshedAt: null, + scopeKey + }); + } + + const requestId = (usageRequestToken += 1); + set({ loading: true, error: null, scopeKey }); + + const requestPromise = (async () => { + try { + const usageResponse = await usageApi.getUsage(); + const rawUsage = usageResponse?.usage ?? usageResponse; + const usage = + rawUsage && typeof rawUsage === 'object' ? (rawUsage as UsageStatsSnapshot) : null; + + if (requestId !== usageRequestToken) return; + + set({ + usage, + keyStats: computeKeyStats(usage), + usageDetails: collectUsageDetails(usage), + loading: false, + error: null, + lastRefreshedAt: Date.now(), + scopeKey + }); + } catch (error: unknown) { + if (requestId !== usageRequestToken) return; + set({ + loading: false, + error: getErrorMessage(error), + scopeKey + }); + } finally { + if (inFlightUsageRequest?.id === requestId) { + inFlightUsageRequest = null; + } + } + })(); + + inFlightUsageRequest = { id: requestId, scopeKey, promise: requestPromise }; + await requestPromise; + }, + + clearUsageStats: () => { + usageRequestToken += 1; + inFlightUsageRequest = null; + set({ + usage: null, + keyStats: createEmptyKeyStats(), + usageDetails: [], + loading: false, + error: null, + lastRefreshedAt: null, + scopeKey: '' + }); + } +}));