Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion web/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
"active_users": "{{count}} active users",
"active_users_label": "Active users: {{count}}",
"image_suffix": "PIC",
"image_response_time": "Image response: {{time}}",
"image_response_time": "Image: {{time}}",
"time_range": "Time Range:",
"range_today": "Today",
"range_7d": "7 Days",
Expand Down Expand Up @@ -697,6 +697,7 @@
"cache_ratio": "Cache Ratio",
"cache_cumulative_ratio": "Cumulative Cache Ratio",
"auto_update": "Auto update",
"auto_update_off": "Auto update off",
"granularity_hour": "Hourly",
"granularity_day": "Daily"
},
Expand Down
3 changes: 2 additions & 1 deletion web/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
"active_users": "{{count}} 活跃用户",
"active_users_label": "活跃用户: {{count}}",
"image_suffix": "PIC",
"image_response_time": "图片响应时间: {{time}}",
"image_response_time": "图片: {{time}}",
"time_range": "时间范围:",
"range_today": "今天",
"range_7d": "近 7 天",
Expand Down Expand Up @@ -700,6 +700,7 @@
"cache_ratio": "缓存比例",
"cache_cumulative_ratio": "缓存累计比例",
"auto_update": "自动更新",
"auto_update_off": "关闭自动更新",
"granularity_hour": "按小时",
"granularity_day": "按天"
},
Expand Down
71 changes: 20 additions & 51 deletions web/src/pages/admin/AccountsPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { keepPreviousData, useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertDialog, Button, Dropdown, EmptyState, Input, Label, ListBox, Select, Spinner, TextField as HeroTextField } from '@heroui/react';
import { AlertDialog, Button, EmptyState, Input, Label, ListBox, Select, Spinner, TextField as HeroTextField } from '@heroui/react';
import {
Plus,
RefreshCw,
ChevronDown,
Search,
Download,
Upload,
Expand All @@ -24,11 +22,13 @@ import {
import { useCrudMutation } from '../../shared/hooks/useCrudMutation';
import { useDebouncedValue } from '../../shared/hooks/useDebouncedValue';
import { usePagination } from '../../shared/hooks/usePagination';
import { ADMIN_AUTO_REFRESH_OPTIONS, usePersistentAutoRefresh } from '../../shared/hooks/usePersistentAutoRefresh';
import { queryKeys } from '../../shared/queryKeys';
import { PAGE_SIZE_OPTIONS, FETCH_ALL_PARAMS } from '../../shared/constants';
import { getTotalPages } from '../../shared/utils/pagination';
import { TablePaginationFooter } from '../../shared/components/TablePaginationFooter';
import { DialogTriggerShim } from '../../shared/components/DialogTriggerShim';
import { AutoRefreshControl } from '../../shared/components/AutoRefreshControl';
import { CreateAccountModal } from './accounts/CreateAccountModal';
import { EditAccountModal } from './accounts/EditAccountModal';
import { AccountTypeFilterSelect } from './accounts/AccountTypeFilterSelect';
Expand All @@ -52,7 +52,6 @@ import {
AccountSelectionStore,
AccountTableRow,
AccountsTableLoadingRow,
AutoRefreshCountdownLabel,
TableSelectionCheckbox,
UNGROUPED_GROUP_FILTER,
columnAlignClass,
Expand All @@ -66,6 +65,8 @@ import {
type AccountUsageWindowCache,
} from './accounts/AccountPageSupport';

const ACCOUNT_AUTO_REFRESH_STORAGE_KEY = 'airgate.admin.accounts.auto_refresh';

export default function AccountsPageContent() {
const { t } = useTranslation();
const queryClient = useQueryClient();
Expand Down Expand Up @@ -131,10 +132,9 @@ export default function AccountsPageContent() {
const [proxyFilter, setProxyFilter] = useState('');

// 自动刷新
const AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30];
const [autoRefresh, setAutoRefresh] = useState(0); // 秒,0=关闭
const [autoRefresh, setAutoRefresh] = usePersistentAutoRefresh(ACCOUNT_AUTO_REFRESH_STORAGE_KEY, 0, ADMIN_AUTO_REFRESH_OPTIONS); // 秒,0=关闭
const refreshAccounts = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.accounts() });
queryClient.invalidateQueries({ queryKey: queryKeys.accounts() }, { cancelRefetch: false });
}, [queryClient]);
const autoRefreshLabel = t('accounts.auto_refresh');
const autoRefreshOffLabel = t('accounts.auto_refresh_off');
Expand Down Expand Up @@ -175,7 +175,7 @@ export default function AccountsPageContent() {
}, [groupFilter, keyword, page, pageSize, platformFilter, proxyFilter, selectionStore, stateFilter, typeFilter]);

// 查询账号列表
const { data, isLoading } = useQuery({
const { data, isLoading, isFetching: isAccountsFetching } = useQuery({
queryKey: queryKeys.accounts(page, pageSize, debouncedKeyword, platformFilter, stateFilter, typeFilter, groupFilter, proxyFilter),
queryFn: () =>
accountsApi.list({
Expand All @@ -189,6 +189,7 @@ export default function AccountsPageContent() {
ungrouped: groupFilter === UNGROUPED_GROUP_FILTER ? true : undefined,
proxy_id: proxyFilter ? Number(proxyFilter) : undefined,
}),
meta: { globalLoading: false },
placeholderData: keepPreviousData,
});
const rows = data?.list ?? [];
Expand Down Expand Up @@ -750,49 +751,17 @@ export default function AccountsPageContent() {
</div>

<div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:ml-auto xl:justify-end">
<Button
isIconOnly
aria-label={t('common.refresh')}
size="sm"
variant="ghost"
className="h-8 w-8 min-w-8"
onPress={refreshAccounts}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Dropdown>
<Dropdown.Trigger
className={`ag-account-auto-refresh-trigger button button--sm ${autoRefresh ? 'button--secondary' : 'button--ghost'} h-8 min-w-[7.5rem] whitespace-nowrap px-3`}
>
<AutoRefreshCountdownLabel
autoRefresh={autoRefresh}
label={autoRefreshLabel}
offLabel={autoRefreshOffLabel}
onRefresh={refreshAccounts}
/>
<ChevronDown className="h-3 w-3 shrink-0" />
</Dropdown.Trigger>
<Dropdown.Popover placement="bottom end">
<Dropdown.Menu
aria-label={t('accounts.auto_refresh')}
selectedKeys={new Set([`auto_${autoRefresh}`])}
selectionMode="single"
onAction={(key) => {
const action = String(key);
setAutoRefresh(Number(action.replace('auto_', '')));
}}
>
{AUTO_REFRESH_OPTIONS.map((sec) => (
<Dropdown.Item key={sec} id={`auto_${sec}`} textValue={sec === 0 ? t('accounts.auto_refresh_off') : `${t('accounts.auto_refresh')}${sec}s`}>
<span className="flex items-center justify-between gap-6">
<span>{sec === 0 ? t('accounts.auto_refresh_off') : `${t('accounts.auto_refresh')}${sec}s`}</span>
{autoRefresh === sec ? <span className="text-primary">✓</span> : null}
</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
<AutoRefreshControl
value={autoRefresh}
options={ADMIN_AUTO_REFRESH_OPTIONS}
label={autoRefreshLabel}
offLabel={autoRefreshOffLabel}
ariaLabel={t('accounts.auto_refresh')}
refreshAriaLabel={t('common.refresh')}
onChange={setAutoRefresh}
onRefresh={refreshAccounts}
isRefreshing={isAccountsFetching}
/>
<Button
variant="secondary"
onPress={() => importInputRef.current?.click()}
Expand Down
86 changes: 40 additions & 46 deletions web/src/pages/admin/UsagePage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { lazy, Suspense, useCallback, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { Button, Card, ComboBox, Input, ListBox, Select, Tabs } from '@heroui/react';
import { Card, ComboBox, Input, ListBox, Select, Tabs } from '@heroui/react';
import { usageApi } from '../../shared/api/usage';
import { usersApi } from '../../shared/api/users';
import { apikeysApi } from '../../shared/api/apikeys';
import { usePagination } from '../../shared/hooks/usePagination';
import { usePersistentBoolean } from '../../shared/hooks/usePersistentBoolean';
import { usePlatforms } from '../../shared/hooks/usePlatforms';
import { useDebouncedValue } from '../../shared/hooks/useDebouncedValue';
import { useDeferredActivation } from '../../shared/hooks/useDeferredActivation';
import { Activity, Coins, Hash, DollarSign, Search, RefreshCw } from 'lucide-react';
import { Activity, Coins, Hash, DollarSign, Search } from 'lucide-react';
import { useUsageColumns, fmtNum, type UsageColumnConfig } from '../../shared/columns/usageColumns';
import type { APIKeyResp, UsageLogResp, UsageQuery, UsageTrendBucket } from '../../shared/types';
import { CompactDataTable } from '../../shared/components/CompactDataTable';
Expand All @@ -19,7 +18,8 @@ import { UsageDateRangeFilter } from '../../shared/components/UsageDateRangeFilt
import { UsageModelFilterInput } from '../../shared/components/UsageModelFilterInput';
import { PIE_CHART_COLORS } from '../../shared/constants';
import { CostValue } from '../../shared/components/CostValue';
import { NativeSwitch } from '../../shared/components/NativeSwitch';
import { AutoRefreshControl } from '../../shared/components/AutoRefreshControl';
import { ADMIN_AUTO_REFRESH_OPTIONS, usePersistentAutoRefresh } from '../../shared/hooks/usePersistentAutoRefresh';

const UsagePieChart = lazy(() =>
import('./usage/UsageCharts').then((m) => ({ default: m.UsagePieChart })),
Expand Down Expand Up @@ -105,7 +105,6 @@ const groupByHeaderKeys: Record<string, string> = {
};

const ADMIN_USAGE_STATS_GROUP_BY = 'model,group,account,user';
const USAGE_AUTO_UPDATE_INTERVAL_MS = 3_000;
const USAGE_PAGE_ACTIVATION_DELAY_MS = 180;
const ADMIN_USAGE_AUTO_UPDATE_STORAGE_KEY = 'airgate.admin.usage.auto_update';

Expand Down Expand Up @@ -393,10 +392,12 @@ export default function UsagePage() {
const [filters, setFilters] = useState<Partial<UsageQuery>>({});
const [statsGroupBy, setStatsGroupBy] = useState<string>('model');
const [granularity, setGranularity] = useState<string>('hour');
const [autoRefresh, setAutoRefresh] = usePersistentBoolean(ADMIN_USAGE_AUTO_UPDATE_STORAGE_KEY, false);
const [autoRefresh, setAutoRefresh] = usePersistentAutoRefresh(ADMIN_USAGE_AUTO_UPDATE_STORAGE_KEY, 0, ADMIN_AUTO_REFRESH_OPTIONS);
const { platforms, platformName } = usePlatforms();
const pageActive = useDeferredActivation(USAGE_PAGE_ACTIVATION_DELAY_MS);
const autoRefreshInterval = autoRefresh ? USAGE_AUTO_UPDATE_INTERVAL_MS : false;
const autoRefreshEnabled = autoRefresh > 0;
const autoRefreshLabel = `${t('usage.auto_update')} `;
const autoRefreshOffLabel = t('usage.auto_update_off', '关闭自动更新');

const handleModelChange = useCallback((model: string) => {
const nextModel = model || undefined;
Expand Down Expand Up @@ -488,11 +489,10 @@ export default function UsagePage() {
} = useQuery({
queryKey: ['admin-usage', queryParams],
queryFn: ({ signal }) => usageApi.adminList(queryParams, { signal }),
meta: { globalLoading: false },
enabled: pageActive,
refetchInterval: autoRefreshInterval,
refetchIntervalInBackground: false,
refetchOnReconnect: autoRefresh,
refetchOnWindowFocus: autoRefresh,
refetchOnReconnect: autoRefreshEnabled,
refetchOnWindowFocus: autoRefreshEnabled,
placeholderData: keepPreviousData,
});

Expand All @@ -508,11 +508,10 @@ export default function UsagePage() {
user_id: filters.user_id ? Number(filters.user_id) : undefined,
api_key_id: filters.api_key_id ? Number(filters.api_key_id) : undefined,
}, { signal }),
meta: { globalLoading: false },
enabled: pageActive,
refetchInterval: autoRefreshInterval,
refetchIntervalInBackground: false,
refetchOnReconnect: autoRefresh,
refetchOnWindowFocus: autoRefresh,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});

Expand All @@ -529,29 +528,27 @@ export default function UsagePage() {
user_id: filters.user_id ? Number(filters.user_id) : undefined,
api_key_id: filters.api_key_id ? Number(filters.api_key_id) : undefined,
}, { signal }),
meta: { globalLoading: false },
enabled: pageActive,
refetchInterval: autoRefreshInterval,
refetchIntervalInBackground: false,
refetchOnReconnect: autoRefresh,
refetchOnWindowFocus: autoRefresh,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});

const isRefreshing = pageActive && (isUsageFetching || isStatsFetching || isTrendFetching);
const isUsageTableRefreshing = pageActive && isUsageFetching;

function handleManualRefresh() {
const handleManualRefresh = useCallback(() => {
if (!pageActive) return;
void refetchUsage();
void refetchStats();
void refetchTrend();
}
void refetchUsage({ cancelRefetch: false });
void refetchStats({ cancelRefetch: false });
void refetchTrend({ cancelRefetch: false });
}, [pageActive, refetchStats, refetchTrend, refetchUsage]);

function handleAutoRefreshChange(enabled: boolean) {
setAutoRefresh(enabled);
if (enabled && pageActive) {
handleManualRefresh();
}
}
const handleAutoRefresh = useCallback(() => {
if (!pageActive) return;
void refetchUsage({ cancelRefetch: false });
}, [pageActive, refetchUsage]);

function updateFilter(key: keyof UsageQuery, value: string) {
const nextValue = (key === 'user_id' || key === 'api_key_id')
Expand Down Expand Up @@ -914,22 +911,19 @@ export default function UsagePage() {
</ComboBox.Popover>
</ComboBox>
</div>
<Button
isIconOnly
aria-label={t('common.refresh', 'Refresh')}
isDisabled={!pageActive || isRefreshing}
size="sm"
variant="ghost"
onPress={handleManualRefresh}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
<NativeSwitch
<AutoRefreshControl
value={autoRefresh}
options={ADMIN_AUTO_REFRESH_OPTIONS}
label={autoRefreshLabel}
offLabel={autoRefreshOffLabel}
ariaLabel={t('usage.auto_update')}
className="shrink-0"
isSelected={autoRefresh}
label={<span className="text-sm text-text-secondary">{t('usage.auto_update')}</span>}
onChange={handleAutoRefreshChange}
refreshAriaLabel={t('common.refresh', 'Refresh')}
onChange={setAutoRefresh}
onAutoRefresh={handleAutoRefresh}
onRefresh={handleManualRefresh}
isAutoRefreshing={isUsageTableRefreshing}
isRefreshing={isRefreshing}
isDisabled={!pageActive}
/>
</div>

Expand All @@ -940,7 +934,7 @@ export default function UsagePage() {
dataVersion={pageActive ? dataUpdatedAt : undefined}
emptyDescription={t('usage.empty_description', '调整筛选条件后重试')}
emptyTitle={t('common.no_data')}
highlightNewRows={pageActive && autoRefresh && page === 1}
highlightNewRows={pageActive && autoRefreshEnabled && page === 1}
highlightResetKey={JSON.stringify({ ...filters, page, pageSize })}
isLoading={!pageActive || isLoading}
page={page}
Expand Down
50 changes: 0 additions & 50 deletions web/src/pages/admin/accounts/AccountPageSupport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -602,56 +602,6 @@ export function AccountsTableLoadingRow({ colSpan, minHeight = 220 }: { colSpan:
);
}

export const AutoRefreshCountdownLabel = memo(function AutoRefreshCountdownLabel({
autoRefresh,
label,
offLabel,
onRefresh,
}: {
autoRefresh: number;
label: string;
offLabel: string;
onRefresh: () => void;
}) {
const labelRef = useRef<HTMLSpanElement>(null);

useEffect(() => {
const renderCountdown = (seconds: number) => {
if (labelRef.current) {
labelRef.current.textContent = autoRefresh ? `${label}${seconds}s` : offLabel;
}
};

if (!autoRefresh) {
renderCountdown(0);
return undefined;
}

let remaining = autoRefresh;
renderCountdown(remaining);
const timer = window.setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
onRefresh();
remaining = autoRefresh;
}
renderCountdown(remaining);
}, 1000);
return () => window.clearInterval(timer);
}, [autoRefresh, label, offLabel, onRefresh]);

return (
<span ref={labelRef}>
{autoRefresh ? `${label}${autoRefresh}s` : offLabel}
</span>
);
}, (prev, next) => (
prev.autoRefresh === next.autoRefresh
&& prev.label === next.label
&& prev.offLabel === next.offLabel
&& prev.onRefresh === next.onRefresh
));

// formatCountdown 把剩余毫秒格式化成 "Xd Yh"/"Xh Ym"/"Ym" 样式,
// 与 sub2api 的"限流中 10h 16m 自动恢复"徽标一致。
function formatCountdown(ms: number): string {
Expand Down
Loading
Loading