mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(usage): add shared usage stats store
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: ''
|
||||
});
|
||||
}
|
||||
}));
|
||||
Reference in New Issue
Block a user