import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import * as Clipboard from 'expo-clipboard'; import { Copy, Search, UserRound } from 'lucide-react-native'; import { router } from 'expo-router'; import { useCallback, useMemo, useState } from 'react'; import { FlatList, Pressable, Text, TextInput, View } from 'react-native'; import { ListCard } from '@/src/components/list-card'; import { ScreenShell } from '@/src/components/screen-shell'; import { useDebouncedValue } from '@/src/hooks/use-debounced-value'; import { useScreenInteractive } from '@/src/hooks/use-screen-interactive'; import { getUser, getUserUsage, listUserApiKeys, listUsers } from '@/src/services/admin'; import { adminConfigState } from '@/src/store/admin-config'; import type { AdminApiKey, AdminUser, UserUsageSummary } from '@/src/types/admin'; const { useSnapshot } = require('valtio/react'); type UserSupplement = { usage?: UserUsageSummary; apiKeys: AdminApiKey[]; }; function getUserTitle(user: AdminUser) { return user.username?.trim() || user.email; } function getUserSortValue(user: AdminUser) { const raw = user.updated_at || user.created_at || ''; const value = raw ? new Date(raw).getTime() : 0; return Number.isNaN(value) ? 0 : value; } function formatQuotaValue(value: number) { return `$${value.toFixed(2)}`; } export default function UsersScreen() { useScreenInteractive('users_interactive'); const config = useSnapshot(adminConfigState); const [searchText, setSearchText] = useState(''); const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc'); const [copiedKeyId, setCopiedKeyId] = useState(null); const keyword = useDebouncedValue(searchText.trim(), 300); const queryClient = useQueryClient(); const hasAccount = Boolean(config.baseUrl.trim()); const usersQuery = useQuery({ queryKey: ['users', keyword], queryFn: () => listUsers(keyword), enabled: hasAccount, }); const items = usersQuery.data?.items ?? []; const userDetailQueries = useQueries({ queries: items.map((user) => ({ queryKey: ['user-list-supplement', user.id], queryFn: async () => { const [usage, apiKeysData] = await Promise.all([getUserUsage(user.id), listUserApiKeys(user.id)]); return { usage, apiKeys: apiKeysData.items ?? [], } satisfies UserSupplement; }, enabled: hasAccount, staleTime: 60_000, })), }); const errorMessage = usersQuery.error instanceof Error ? usersQuery.error.message : ''; const supplementsByUserId = useMemo( () => items.reduce>((result, user, index) => { result[user.id] = userDetailQueries[index]?.data; return result; }, {}), [items, userDetailQueries] ); const sortedItems = useMemo( () => [...items].sort((left, right) => { const delta = getUserSortValue(right) - getUserSortValue(left); return sortOrder === 'desc' ? delta : -delta; }), [items, sortOrder] ); async function copyKey(keyId: number, value: string) { await Clipboard.setStringAsync(value); setCopiedKeyId(keyId); setTimeout(() => setCopiedKeyId((current) => (current === keyId ? null : current)), 1600); } const renderItem = useCallback( ({ item: user }: { item: (typeof sortedItems)[number] }) => { const keyItems = (supplementsByUserId[user.id]?.apiKeys ?? []).slice(0, 3); return ( { void queryClient.prefetchQuery({ queryKey: ['user', user.id], queryFn: () => getUser(user.id) }); void queryClient.prefetchQuery({ queryKey: ['user-usage', user.id], queryFn: () => getUserUsage(user.id) }); void queryClient.prefetchQuery({ queryKey: ['user-api-keys', user.id], queryFn: () => listUserApiKeys(user.id) }); router.push(`/users/${user.id}`); }} > 余额 ${Number(user.balance ?? 0).toFixed(2)} Keys {keyItems.length} 个 {keyItems.map((apiKey, index) => ( {(() => { const quota = Number(apiKey.quota ?? 0); const used = Number(apiKey.quota_used ?? 0); const isUnlimited = quota <= 0; const progressWidth = isUnlimited ? '16%' : (`${Math.max(Math.min((used / quota) * 100, 100), 6)}%` as `${number}%`); return ( <> {apiKey.name} {isUnlimited ? `${formatQuotaValue(used)} / 无限` : `${formatQuotaValue(used)} / ${formatQuotaValue(quota)}`} { event.stopPropagation(); void copyKey(apiKey.id, apiKey.key); }} > = 0.85 ? 'h-full rounded-full bg-[#c25d35]' : used / Math.max(quota, 1) >= 0.6 ? 'h-full rounded-full bg-[#d38b36]' : 'h-full rounded-full bg-[#1d5f55]' } style={{ width: progressWidth }} /> {copiedKeyId === apiKey.id ? 已复制 : null} ); })()} ))} {keyItems.length === 0 ? ( 当前用户还没有可展示的 token 额度信息。 ) : null} ); }, [copiedKeyId, queryClient, sortedItems, supplementsByUserId] ); const emptyState = useMemo( () => ( ), [errorMessage, hasAccount] ); return ( 搜索结果 {sortedItems.length}} variant="minimal" scroll={false} bottomInsetClassName="pb-12" > setSortOrder('desc')} > 最新 setSortOrder('asc')} > 最早 `${item.id}`} showsVerticalScrollIndicator={false} ListHeaderComponent={() => } ListEmptyComponent={emptyState} ListFooterComponent={() => } ItemSeparatorComponent={() => } keyboardShouldPersistTaps="handled" removeClippedSubviews initialNumToRender={8} maxToRenderPerBatch={8} windowSize={5} /> ); }