Skip to content

Commit d540a3e

Browse files
authored
feat: 添加 CooldownsProvider 上下文减少重复 API 请求 (#139)
- 创建 CooldownsProvider 共享 cooldowns 数据 - 更新 ClientTypeRoutesContent 包装 CooldownsProvider - 更新 provider-row 使用 useCooldownsContext - 更新 dialog 组件使用 useCooldownsContext 解决 Routes 页面多个组件各自调用 /api/admin/cooldowns 导致重复请求的问题
1 parent 2a567c4 commit d540a3e

File tree

5 files changed

+204
-14
lines changed

5 files changed

+204
-14
lines changed

web/src/components/cooldown-details-dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
Activity,
1818
} from 'lucide-react';
1919
import type { Cooldown } from '@/lib/transport/types';
20-
import { useCooldowns } from '@/hooks/use-cooldowns';
20+
import { useCooldownsContext } from '@/contexts/cooldowns-context';
2121

2222
interface CooldownDetailsDialogProps {
2323
cooldown: Cooldown | null;
@@ -93,7 +93,7 @@ export function CooldownDetailsDialog({
9393
const { t, i18n } = useTranslation();
9494
const REASON_INFO = getReasonInfo(t);
9595
// 获取 formatRemaining 函数用于实时倒计时
96-
const { formatRemaining } = useCooldowns();
96+
const { formatRemaining } = useCooldownsContext();
9797

9898
// 计算初始倒计时值
9999
const getInitialCountdown = useCallback(() => {

web/src/components/provider-details-dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from 'lucide-react';
2424
import type { Cooldown, ProviderStats, ClientType } from '@/lib/transport/types';
2525
import type { ProviderConfigItem } from '@/pages/client-routes/types';
26-
import { useCooldowns } from '@/hooks/use-cooldowns';
26+
import { useCooldownsContext } from '@/contexts/cooldowns-context';
2727
import { Button, Switch } from '@/components/ui';
2828
import { getProviderColor, type ProviderType } from '@/lib/theme';
2929
import { cn } from '@/lib/utils';
@@ -145,7 +145,7 @@ export function ProviderDetailsDialog({
145145
}: ProviderDetailsDialogProps) {
146146
const { t, i18n } = useTranslation();
147147
const REASON_INFO = getReasonInfo(t);
148-
const { formatRemaining } = useCooldowns();
148+
const { formatRemaining } = useCooldownsContext();
149149

150150
// 计算初始倒计时值
151151
const getInitialCountdown = useCallback(() => {

web/src/components/routes/ClientTypeRoutesContent.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,27 @@ import {
4444
import type { ProviderConfigItem } from '@/pages/client-routes/types';
4545
import { Button } from '../ui';
4646
import { AntigravityQuotasProvider } from '@/contexts/antigravity-quotas-context';
47+
import { CooldownsProvider } from '@/contexts/cooldowns-context';
4748

4849
interface ClientTypeRoutesContentProps {
4950
clientType: ClientType;
5051
projectID: number; // 0 for global routes
5152
searchQuery?: string; // Optional search query from parent
5253
}
5354

54-
export function ClientTypeRoutesContent({
55+
// Wrapper component that provides the AntigravityQuotasProvider and CooldownsProvider
56+
export function ClientTypeRoutesContent(props: ClientTypeRoutesContentProps) {
57+
return (
58+
<AntigravityQuotasProvider>
59+
<CooldownsProvider>
60+
<ClientTypeRoutesContentInner {...props} />
61+
</CooldownsProvider>
62+
</AntigravityQuotasProvider>
63+
);
64+
}
65+
66+
// Inner component that can access the contexts
67+
function ClientTypeRoutesContentInner({
5568
clientType,
5669
projectID,
5770
searchQuery = '',
@@ -237,10 +250,9 @@ export function ClientTypeRoutesContent({
237250
}
238251

239252
return (
240-
<AntigravityQuotasProvider>
241-
<div className="flex flex-col h-full px-6">
242-
<div className="flex-1 overflow-y-auto px-lg py-6">
243-
<div className="mx-auto max-w-[1400px] space-y-6">
253+
<div className="flex flex-col h-full px-6">
254+
<div className="flex-1 overflow-y-auto px-lg py-6">
255+
<div className="mx-auto max-w-[1400px] space-y-6">
244256
{/* Routes List */}
245257
{items.length > 0 ? (
246258
<DndContext
@@ -366,9 +378,8 @@ export function ClientTypeRoutesContent({
366378
</div>
367379
</div>
368380
)}
369-
</div>
370381
</div>
371382
</div>
372-
</AntigravityQuotasProvider>
383+
</div>
373384
);
374385
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Cooldowns Context
3+
* 提供共享的 Cooldowns 数据,减少重复请求
4+
*/
5+
6+
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
7+
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
8+
import { getTransport } from '@/lib/transport';
9+
import type { Cooldown } from '@/lib/transport';
10+
11+
interface CooldownsContextValue {
12+
cooldowns: Cooldown[];
13+
isLoading: boolean;
14+
getCooldownForProvider: (providerId: number, clientType?: string) => Cooldown | undefined;
15+
isProviderInCooldown: (providerId: number, clientType?: string) => boolean;
16+
getRemainingSeconds: (cooldown: Cooldown) => number;
17+
formatRemaining: (cooldown: Cooldown) => string;
18+
clearCooldown: (providerId: number) => void;
19+
isClearingCooldown: boolean;
20+
}
21+
22+
const CooldownsContext = createContext<CooldownsContextValue | null>(null);
23+
24+
interface CooldownsProviderProps {
25+
children: ReactNode;
26+
}
27+
28+
export function CooldownsProvider({ children }: CooldownsProviderProps) {
29+
const queryClient = useQueryClient();
30+
// Force re-render counter to trigger updates when cooldowns expire
31+
const [refreshKey, setRefreshKey] = useState(0);
32+
33+
const {
34+
data: cooldowns = [],
35+
isLoading,
36+
} = useQuery({
37+
queryKey: ['cooldowns'],
38+
queryFn: () => getTransport().getCooldowns(),
39+
staleTime: 5000,
40+
});
41+
42+
// Subscribe to cooldown_update WebSocket event
43+
useEffect(() => {
44+
const transport = getTransport();
45+
const unsubscribe = transport.subscribe('cooldown_update', () => {
46+
queryClient.invalidateQueries({ queryKey: ['cooldowns'] });
47+
});
48+
49+
return () => {
50+
unsubscribe();
51+
};
52+
}, [queryClient]);
53+
54+
// Mutation for clearing cooldown
55+
const clearCooldownMutation = useMutation({
56+
mutationFn: (providerId: number) => getTransport().clearCooldown(providerId),
57+
onSuccess: () => {
58+
queryClient.invalidateQueries({ queryKey: ['cooldowns'] });
59+
},
60+
});
61+
62+
// Setup timeouts for each cooldown to force re-render when they expire
63+
useEffect(() => {
64+
if (cooldowns.length === 0) {
65+
return;
66+
}
67+
68+
const timeouts: number[] = [];
69+
70+
cooldowns.forEach((cooldown) => {
71+
const until = new Date(cooldown.untilTime).getTime();
72+
const now = Date.now();
73+
const delay = until - now;
74+
75+
if (delay > 0) {
76+
const timeout = setTimeout(() => {
77+
setRefreshKey((prev) => prev + 1);
78+
}, delay + 100);
79+
timeouts.push(timeout);
80+
}
81+
});
82+
83+
return () => {
84+
timeouts.forEach((timeout) => clearTimeout(timeout));
85+
};
86+
}, [cooldowns]);
87+
88+
const getCooldownForProvider = useCallback((providerId: number, clientType?: string) => {
89+
return cooldowns.find((cd: Cooldown) => {
90+
const matchesProvider = cd.providerID === providerId;
91+
const matchesClientType =
92+
cd.clientType === '' ||
93+
cd.clientType === 'all' ||
94+
(clientType && cd.clientType === clientType);
95+
96+
if (!matchesProvider || !matchesClientType) {
97+
return false;
98+
}
99+
100+
const untilTime =
101+
cd.untilTime || ((cd as unknown as Record<string, unknown>).until as string);
102+
if (!untilTime) {
103+
return false;
104+
}
105+
const until = new Date(untilTime).getTime();
106+
const now = Date.now();
107+
return until > now;
108+
});
109+
// eslint-disable-next-line react-hooks/exhaustive-deps
110+
}, [cooldowns, refreshKey]);
111+
112+
const isProviderInCooldown = useCallback((providerId: number, clientType?: string) => {
113+
return !!getCooldownForProvider(providerId, clientType);
114+
}, [getCooldownForProvider]);
115+
116+
const getRemainingSeconds = useCallback((cooldown: Cooldown) => {
117+
const untilTime =
118+
cooldown.untilTime || ((cooldown as unknown as Record<string, unknown>).until as string);
119+
if (!untilTime) return 0;
120+
121+
const until = new Date(untilTime);
122+
const now = new Date();
123+
const diff = until.getTime() - now.getTime();
124+
return Math.max(0, Math.floor(diff / 1000));
125+
}, []);
126+
127+
const formatRemaining = useCallback((cooldown: Cooldown) => {
128+
const seconds = getRemainingSeconds(cooldown);
129+
130+
if (Number.isNaN(seconds) || seconds === 0) return 'Expired';
131+
132+
const hours = Math.floor(seconds / 3600);
133+
const minutes = Math.floor((seconds % 3600) / 60);
134+
const secs = seconds % 60;
135+
136+
if (hours > 0) {
137+
return `${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`;
138+
} else if (minutes > 0) {
139+
return `${String(minutes).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`;
140+
} else {
141+
return `${String(secs).padStart(2, '0')}s`;
142+
}
143+
}, [getRemainingSeconds]);
144+
145+
const clearCooldown = useCallback((providerId: number) => {
146+
clearCooldownMutation.mutate(providerId);
147+
}, [clearCooldownMutation]);
148+
149+
return (
150+
<CooldownsContext.Provider
151+
value={{
152+
cooldowns,
153+
isLoading,
154+
getCooldownForProvider,
155+
isProviderInCooldown,
156+
getRemainingSeconds,
157+
formatRemaining,
158+
clearCooldown,
159+
isClearingCooldown: clearCooldownMutation.isPending,
160+
}}
161+
>
162+
{children}
163+
</CooldownsContext.Provider>
164+
);
165+
}
166+
167+
export function useCooldownsContext() {
168+
const context = useContext(CooldownsContext);
169+
if (!context) {
170+
throw new Error('useCooldownsContext must be used within CooldownsProvider');
171+
}
172+
return context;
173+
}
174+
175+
// Optional hook that doesn't throw when used outside provider
176+
export function useCooldownFromContext(providerId: number, clientType?: string): Cooldown | undefined {
177+
const context = useContext(CooldownsContext);
178+
return context?.getCooldownForProvider(providerId, clientType);
179+
}

web/src/pages/client-routes/components/provider-row.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { cn } from '@/lib/utils';
99
import type { ClientType, ProviderStats, AntigravityQuotaData } from '@/lib/transport';
1010
import type { ProviderConfigItem } from '../types';
1111
import { useAntigravityQuotaFromContext } from '@/contexts/antigravity-quotas-context';
12-
import { useCooldowns } from '@/hooks/use-cooldowns';
12+
import { useCooldownsContext } from '@/contexts/cooldowns-context';
1313
import { ProviderDetailsDialog } from '@/components/provider-details-dialog';
1414
import { useState, useEffect } from 'react';
1515
import { useTranslation } from 'react-i18next';
@@ -60,7 +60,7 @@ export function SortableProviderRow({
6060
onDelete,
6161
}: SortableProviderRowProps) {
6262
const [showDetailsDialog, setShowDetailsDialog] = useState(false);
63-
const { getCooldownForProvider, clearCooldown, isClearingCooldown } = useCooldowns();
63+
const { getCooldownForProvider, clearCooldown, isClearingCooldown } = useCooldownsContext();
6464
const cooldown = getCooldownForProvider(item.provider.id, clientType);
6565

6666
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -206,7 +206,7 @@ export function ProviderRowContent({
206206
const claudeInfo = isAntigravity ? getClaudeQuotaInfo(quota) : null;
207207

208208
// 获取 cooldown 状态
209-
const { getCooldownForProvider, formatRemaining, getRemainingSeconds } = useCooldowns();
209+
const { getCooldownForProvider, formatRemaining, getRemainingSeconds } = useCooldownsContext();
210210
const cooldown = getCooldownForProvider(provider.id, clientType);
211211
const isInCooldown = isInCooldownProp ?? !!cooldown;
212212

0 commit comments

Comments
 (0)