feat(usage): add shared usage stats store

This commit is contained in:
Supra4E8C
2026-02-22 03:35:35 +08:00
parent 028568c3ae
commit b15c70fd6b
7 changed files with 204 additions and 81 deletions
@@ -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<KeyStats>(EMPTY_STATS);
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
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 };
};
+15 -22
View File
@@ -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<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(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,
@@ -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<void>;
refreshKeyStats: () => Promise<void>;
};
export function useAuthFilesStats(): UseAuthFilesStatsResult {
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
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 };
}
+4 -1
View File
@@ -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 } });
+11 -6
View File
@@ -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(() => {
+1
View File
@@ -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';
+141
View File
@@ -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<string, unknown>;
type UsageStatsState = {
usage: UsageStatsSnapshot | null;
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
error: string | null;
lastRefreshedAt: number | null;
scopeKey: string;
loadUsageStats: (options?: LoadUsageStatsOptions) => Promise<void>;
clearUsageStats: () => void;
};
const createEmptyKeyStats = (): KeyStats => ({ bySource: {}, byAuthIndex: {} });
let usageRequestToken = 0;
let inFlightUsageRequest: { id: number; scopeKey: string; promise: Promise<void> } | 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<UsageStatsState>((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: ''
});
}
}));