import { useQueries, useQuery } from '@tanstack/react-query'; import { router } from 'expo-router'; import { useMemo, useState } from 'react'; import { FlatList, Pressable, RefreshControl, Text, TextInput, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDebouncedValue } from '@/src/hooks/use-debounced-value'; import { formatCompactNumber, formatTokenValue } from '@/src/lib/formatters'; import { queryClient } from '@/src/lib/query-client'; import { getUser, getUsageStats, listUserApiKeys, listUsers } from '@/src/services/admin'; import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config'; import type { AdminUser, UsageStats } from '@/src/types/admin'; const { useSnapshot } = require('valtio/react'); const colors = { page: '#f4efe4', card: '#fbf8f2', mutedCard: '#f1ece2', primary: '#1d5f55', text: '#16181a', subtext: '#6f665c', dangerBg: '#fbf1eb', danger: '#c25d35', accentBg: '#efe4cf', accentText: '#8c5a22', }; type SortOrder = 'desc' | 'asc'; type RangeKey = '24h' | '7d' | '30d'; function getDateRange(rangeKey: RangeKey) { const end = new Date(); const start = new Date(); if (rangeKey === '24h') { start.setHours(end.getHours() - 23, 0, 0, 0); } else if (rangeKey === '30d') { start.setDate(end.getDate() - 29); } else { start.setDate(end.getDate() - 6); } const toDate = (value: Date) => value.toISOString().slice(0, 10); return { start_date: toDate(start), end_date: toDate(end), granularity: rangeKey === '24h' ? ('hour' ) : ('day' ), }; } function formatCost(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '$0.00'; return `$${value.toFixed(2)}`; } function formatActivityTime(value?: string) { if (!value) return '时间未知'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '时间未知'; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; } function toTimeValue(value?: string | null) { if (!value) return 0; const time = new Date(value).getTime(); return Number.isNaN(time) ? 0 : time; } function getTimeValue(user: AdminUser) { return toTimeValue(user.last_used_at) || toTimeValue(user.updated_at) || toTimeValue(user.created_at) || user.id || 0; } function getUserNameLabel(user: AdminUser) { if (user.username?.trim()) return user.username.trim(); if (user.notes?.trim()) return user.notes.trim(); return user.email.split('@')[0] || '未命名'; } function getErrorMessage(error: unknown) { if (error instanceof Error && error.message) { switch (error.message) { case 'BASE_URL_REQUIRED': return '请先到服务器页填写服务地址。'; case 'ADMIN_API_KEY_REQUIRED': return '请先到服务器页填写 Admin Token。'; default: return error.message; } } return '当前无法加载页面数据,请检查服务地址、Token 和网络。'; } function MetricTile({ title, value, tone = 'default' }: { title: string; value: string; tone?: 'default' | 'accent' }) { const backgroundColor = tone === 'accent' ? colors.accentBg : colors.mutedCard; const valueColor = tone === 'accent' ? colors.accentText : colors.text; return ( {title} {value} ); } function UserCard({ user, usage }: { user: AdminUser; usage?: UsageStats }) { const isAdmin = user.role?.trim().toLowerCase() === 'admin'; const userNameLabel = getUserNameLabel(user); const statusLabel = `${isAdmin ? 'admin · ' : ''}${user.status || 'active'} · ${userNameLabel}`; const totalCost = Number(usage?.total_account_cost ?? usage?.total_actual_cost ?? usage?.total_cost ?? 0); const totalTokens = Number(usage?.total_tokens ?? 0); const totalRequests = Number(usage?.total_requests ?? 0); return ( {user.email} 最近使用 {formatActivityTime(user.last_used_at || user.updated_at || user.created_at)} {statusLabel} ); } export default function UsersScreen() { const config = useSnapshot(adminConfigState); const hasAccount = hasAuthenticatedAdminSession(config); const [searchText, setSearchText] = useState(''); const [sortOrder, setSortOrder] = useState('desc'); const debouncedSearchText = useDebouncedValue(searchText, 250); const usersQuery = useQuery({ queryKey: ['users', debouncedSearchText], queryFn: () => listUsers(debouncedSearchText), enabled: hasAccount, }); const usageRange = useMemo(() => getDateRange('7d'), []); const users = useMemo(() => { const items = [...(usersQuery.data?.items ?? [])]; items.sort((left, right) => { const value = getTimeValue(left) - getTimeValue(right); return sortOrder === 'desc' ? -value : value; }); return items; }, [sortOrder, usersQuery.data?.items]); const usageQueries = useQueries({ queries: users.map((user) => ({ queryKey: ['usage-stats', 'user', user.id, '7d', usageRange.start_date, usageRange.end_date], queryFn: () => getUsageStats({ ...usageRange, user_id: user.id }), enabled: hasAccount, staleTime: 60_000, })), }); const usageByUserId = useMemo( () => new Map(users.map((user, index) => [user.id, usageQueries[index]?.data] as const)), [users, usageQueries] ); const errorMessage = getErrorMessage(usersQuery.error); return ( 用户 查看用户列表并进入详情页管理账号。 setSortOrder((value) => (value === 'desc' ? 'asc' : 'desc'))} style={{ backgroundColor: colors.card, borderRadius: 16, paddingHorizontal: 14, paddingVertical: 14, minWidth: 92, alignItems: 'center' }} > 时间 {sortOrder === 'desc' ? '倒序' : '正序'} {!hasAccount ? ( 未连接服务器 请先到“服务器”页完成连接,再查看用户列表。 router.push('/settings')} > 去配置服务器 ) : usersQuery.isLoading ? ( 正在加载用户 已连接服务器,正在拉取用户列表。 ) : usersQuery.error ? ( 加载失败 {errorMessage} ) : ( `${item.id}`} showsVerticalScrollIndicator={false} refreshControl={ void usersQuery.refetch()} tintColor="#1d5f55" />} contentContainerStyle={{ paddingBottom: 8, gap: 12, flexGrow: users.length === 0 ? 1 : 0 }} ListEmptyComponent={ 暂无用户 当前搜索条件下没有匹配结果,可以修改关键词后重试。 } renderItem={({ item }) => ( { void queryClient.prefetchQuery({ queryKey: ['user', item.id], queryFn: () => getUser(item.id) }); void queryClient.prefetchQuery({ queryKey: ['user-api-keys', item.id], queryFn: () => listUserApiKeys(item.id) }); router.push(`/users/${item.id}`); }} > )} /> )} ); }