From 3177500f74b005ed32512d3f79a7a24d05eac352 Mon Sep 17 00:00:00 2001 From: xuhongbin Date: Wed, 11 Mar 2026 20:47:50 +0800 Subject: [PATCH] feat: streamline account overview list workflow --- app/(tabs)/accounts.tsx | 233 +------------------ app/(tabs)/monitor.tsx | 51 ++-- app/_layout.tsx | 14 +- app/accounts/[id].tsx | 131 ----------- app/accounts/overview.tsx | 5 + src/components/list-card.tsx | 28 ++- src/components/screen-shell.tsx | 9 +- src/screens/accounts-list-screen.tsx | 335 +++++++++++++++++++++++++++ 8 files changed, 419 insertions(+), 387 deletions(-) delete mode 100644 app/accounts/[id].tsx create mode 100644 app/accounts/overview.tsx create mode 100644 src/screens/accounts-list-screen.tsx diff --git a/app/(tabs)/accounts.tsx b/app/(tabs)/accounts.tsx index ee89bf6..c3564f5 100644 --- a/app/(tabs)/accounts.tsx +++ b/app/(tabs)/accounts.tsx @@ -1,232 +1,5 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { KeyRound, Search, ShieldCheck, ShieldOff } from 'lucide-react-native'; -import { router } from 'expo-router'; -import { useCallback, useMemo, useState } from 'react'; -import { FlatList, Pressable, RefreshControl, Text, TextInput, View } from 'react-native'; +import { AccountsListScreen } from '@/src/screens/accounts-list-screen'; -import { ListCard } from '@/src/components/list-card'; -import { ScreenShell } from '@/src/components/screen-shell'; -import { useDebouncedValue } from '@/src/hooks/use-debounced-value'; -import { getAccount, getAccountTodayStats, getDashboardTrend, listAccounts, setAccountSchedulable, testAccount } from '@/src/services/admin'; -import type { AdminAccount } from '@/src/types/admin'; - -function getDateRange() { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 6); - - const toDate = (value: Date) => value.toISOString().slice(0, 10); - - return { - start_date: toDate(start), - end_date: toDate(end), - }; -} - -function formatTime(value?: string | null) { - if (!value) return '--'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '--'; - return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; -} - -function getAccountError(account: AdminAccount) { - return Boolean(account.status === 'error' || account.error_message); -} - -export default function AccountsScreen() { - const [searchText, setSearchText] = useState(''); - const [filter, setFilter] = useState<'all' | 'schedulable' | 'paused' | 'error'>('all'); - const keyword = useDebouncedValue(searchText.trim(), 300); - const queryClient = useQueryClient(); - const range = getDateRange(); - - const accountsQuery = useQuery({ - queryKey: ['accounts', keyword], - queryFn: () => listAccounts(keyword), - }); - - const toggleMutation = useMutation({ - mutationFn: ({ accountId, schedulable }: { accountId: number; schedulable: boolean }) => - setAccountSchedulable(accountId, schedulable), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['accounts'] }), - }); - - const items = accountsQuery.data?.items ?? []; - const filteredItems = useMemo(() => { - return items.filter((account) => { - if (filter === 'schedulable') return account.schedulable !== false && !getAccountError(account); - if (filter === 'paused') return account.schedulable === false && !getAccountError(account); - if (filter === 'error') return getAccountError(account); - return true; - }); - }, [filter, items]); - const errorMessage = accountsQuery.error instanceof Error ? accountsQuery.error.message : ''; - - const summary = useMemo(() => { - const total = items.length; - const errors = items.filter(getAccountError).length; - const paused = items.filter((item) => item.schedulable === false && !getAccountError(item)).length; - const active = items.filter((item) => item.schedulable !== false && !getAccountError(item)).length; - return { total, active, paused, errors }; - }, [items]); - - const listHeader = useMemo( - () => ( - - - - - - - - - {([ - ['all', `全部 ${summary.total}`], - ['schedulable', `可调度 ${summary.active}`], - ['paused', `暂停 ${summary.paused}`], - ['error', `异常 ${summary.errors}`], - ] as const).map(([key, label]) => { - const active = filter === key; - return ( - setFilter(key)} - className={active ? 'rounded-full bg-[#1d5f55] px-3 py-2' : 'rounded-full bg-[#e7dfcf] px-3 py-2'} - > - {label} - - ); - })} - - - - ), - [filter, summary.active, summary.errors, summary.paused, summary.total] - ); - - const renderItem = useCallback( - ({ item: account }: { item: (typeof filteredItems)[number] }) => { - const isError = getAccountError(account); - const statusText = isError ? '异常' : account.schedulable ? '可调度' : '暂停调度'; - const groupsText = account.groups?.map((group) => group.name).filter(Boolean).slice(0, 3).join(' · '); - - return ( - { - void queryClient.prefetchQuery({ queryKey: ['account', account.id], queryFn: () => getAccount(account.id) }); - void queryClient.prefetchQuery({ queryKey: ['account-today-stats', account.id], queryFn: () => getAccountTodayStats(account.id) }); - void queryClient.prefetchQuery({ - queryKey: ['account-trend', account.id, range.start_date, range.end_date], - queryFn: () => getDashboardTrend({ ...range, granularity: 'day', account_id: account.id }), - }); - router.push(`/accounts/${account.id}`); - }} - > - - - - - {account.schedulable && !isError ? : } - {statusText} - - 最近使用 {formatTime(account.last_used_at || account.updated_at)} - - - - - 并发 - {account.current_concurrency ?? 0} / {account.concurrency ?? 0} - - - 倍率 - {(account.rate_multiplier ?? 1).toFixed(2)}x - - - - {groupsText ? 分组 {groupsText} : null} - {account.error_message ? 异常信息:{account.error_message} : null} - - - { - event.stopPropagation(); - testAccount(account.id).catch(() => undefined); - }} - > - 测试 - - { - event.stopPropagation(); - toggleMutation.mutate({ - accountId: account.id, - schedulable: !account.schedulable, - }); - }} - > - {account.schedulable ? '暂停' : '恢复'} - - - - - - ); - }, - [filteredItems, queryClient, range.end_date, range.start_date, toggleMutation] - ); - - const emptyState = useMemo( - () => , - [errorMessage] - ); - - return ( - - 更接近网页后台的账号视图。 - router.push('/accounts/create')} - className="h-8 w-8 items-center justify-center rounded-[10px] bg-[#1d5f55]" - > - + - - - )} - variant="minimal" - scroll={false} - > - `${item.id}`} - showsVerticalScrollIndicator={false} - refreshControl={ void accountsQuery.refetch()} tintColor="#1d5f55" />} - ListHeaderComponent={listHeader} - ListEmptyComponent={emptyState} - ItemSeparatorComponent={() => } - keyboardShouldPersistTaps="handled" - removeClippedSubviews - initialNumToRender={8} - maxToRenderPerBatch={8} - windowSize={5} - /> - - ); +export default function AccountsRouteScreen() { + return ; } diff --git a/app/(tabs)/monitor.tsx b/app/(tabs)/monitor.tsx index 602f743..05ca887 100644 --- a/app/(tabs)/monitor.tsx +++ b/app/(tabs)/monitor.tsx @@ -325,26 +325,39 @@ export default function MonitorScreen() { detail={`TPM ${formatNumber(stats?.tpm)}`} /> -
- - - 总数 - {formatNumber(totalAccounts)} +
router.push('/accounts/overview')} + > + 账号清单 + + )} + > + router.push('/accounts/overview')}> + + + 总数 + {formatNumber(totalAccounts)} + + + 健康 + {formatNumber(healthyAccounts)} + + + 异常 + {formatNumber(errorAccounts)} + + + 限流 + {formatNumber(currentPageLimitedAccounts)} + - - 健康 - {formatNumber(healthyAccounts)} - - - 异常 - {formatNumber(errorAccounts)} - - - 限流 - {formatNumber(currentPageLimitedAccounts)} - - - 总数 / 健康 / 异常优先使用后端聚合字段;限流与繁忙基于当前页账号列表。 + 总数 / 健康 / 异常优先使用后端聚合字段;限流与繁忙基于当前页账号列表。点击进入账号清单。 +
{throughputPoints.length > 1 ? ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 1530d1c..ccaf030 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -89,7 +89,19 @@ export default function RootLayout() { headerShadowVisible: false, }} /> - + )} diff --git a/app/accounts/[id].tsx b/app/accounts/[id].tsx deleted file mode 100644 index bc7a9c0..0000000 --- a/app/accounts/[id].tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ChevronLeft, ShieldCheck, TestTubeDiagonal } from 'lucide-react-native'; -import { router, useLocalSearchParams } from 'expo-router'; -import { Pressable, Text, View } from 'react-native'; - -import { DetailRow } from '@/src/components/detail-row'; -import { LineTrendChart } from '@/src/components/line-trend-chart'; -import { ListCard } from '@/src/components/list-card'; -import { formatDisplayTime, formatTokenValue } from '@/src/lib/formatters'; -import { ScreenShell } from '@/src/components/screen-shell'; -import { getAccount, getAccountTodayStats, getDashboardTrend, refreshAccount, setAccountSchedulable, testAccount } from '@/src/services/admin'; - -function getDateRange() { - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 6); - - const toDate = (value: Date) => value.toISOString().slice(0, 10); - - return { - start_date: toDate(start), - end_date: toDate(end), - }; -} - -export default function AccountDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const accountId = Number(id); - const queryClient = useQueryClient(); - const range = getDateRange(); - - const accountQuery = useQuery({ - queryKey: ['account', accountId], - queryFn: () => getAccount(accountId), - enabled: Number.isFinite(accountId), - }); - - const todayStatsQuery = useQuery({ - queryKey: ['account-today-stats', accountId], - queryFn: () => getAccountTodayStats(accountId), - enabled: Number.isFinite(accountId), - }); - - const trendQuery = useQuery({ - queryKey: ['account-trend', accountId, range.start_date, range.end_date], - queryFn: () => getDashboardTrend({ ...range, granularity: 'day', account_id: accountId }), - enabled: Number.isFinite(accountId), - }); - - const refreshMutation = useMutation({ - mutationFn: () => refreshAccount(accountId), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['account', accountId] }), - }); - - const schedulableMutation = useMutation({ - mutationFn: (schedulable: boolean) => setAccountSchedulable(accountId, schedulable), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['account', accountId] }); - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - }, - }); - - const account = accountQuery.data; - const todayStats = todayStatsQuery.data; - const trendPoints = (trendQuery.data?.trend ?? []).map((item) => ({ - label: item.date.slice(5), - value: item.total_tokens, - })); - const isRefreshing = accountQuery.isRefetching || todayStatsQuery.isRefetching || trendQuery.isRefetching; - - function handleRefresh() { - void Promise.all([accountQuery.refetch(), todayStatsQuery.refetch(), trendQuery.refetch()]); - } - - return ( - router.back()}> - - - } - > - - - - - - - - - - - - - - - {trendPoints.length > 1 ? ( - - ) : null} - - - - testAccount(accountId).catch(() => undefined)}> - 测试账号 - - refreshMutation.mutate()}> - {refreshMutation.isPending ? '刷新中...' : '刷新凭据'} - - - schedulableMutation.mutate(!account?.schedulable)} - > - - {schedulableMutation.isPending ? '提交中...' : account?.schedulable ? '暂停调度' : '恢复调度'} - - - - - ); -} diff --git a/app/accounts/overview.tsx b/app/accounts/overview.tsx new file mode 100644 index 0000000..5e13fa4 --- /dev/null +++ b/app/accounts/overview.tsx @@ -0,0 +1,5 @@ +import { AccountsListScreen } from '@/src/screens/accounts-list-screen'; + +export default function AccountOverviewListScreen() { + return ; +} diff --git a/src/components/list-card.tsx b/src/components/list-card.tsx index 26b0f7e..19feb8c 100644 --- a/src/components/list-card.tsx +++ b/src/components/list-card.tsx @@ -6,11 +6,33 @@ type ListCardProps = { title: string; meta?: string; badge?: string; + badgeTone?: 'default' | 'success' | 'muted' | 'danger'; children?: ReactNode; icon?: LucideIcon; }; -export function ListCard({ title, meta, badge, children, icon: Icon }: ListCardProps) { +const badgeClassMap: Record, { wrap: string; text: string }> = { + default: { + wrap: 'rounded-full bg-[#e7dfcf] px-2.5 py-1', + text: 'text-[10px] font-semibold uppercase tracking-[1px] text-[#5d564d]', + }, + success: { + wrap: 'rounded-full bg-[#e6f4ee] px-2.5 py-1', + text: 'text-[10px] font-semibold uppercase tracking-[1px] text-[#1d5f55]', + }, + muted: { + wrap: 'rounded-full bg-[#ece7dc] px-2.5 py-1', + text: 'text-[10px] font-semibold uppercase tracking-[1px] text-[#7d7468]', + }, + danger: { + wrap: 'rounded-full bg-[#f7e1d6] px-2.5 py-1', + text: 'text-[10px] font-semibold uppercase tracking-[1px] text-[#a4512b]', + }, +}; + +export function ListCard({ title, meta, badge, badgeTone = 'default', children, icon: Icon }: ListCardProps) { + const badgeClass = badgeClassMap[badgeTone]; + return ( @@ -22,8 +44,8 @@ export function ListCard({ title, meta, badge, children, icon: Icon }: ListCardP {meta ? {meta} : null} {badge ? ( - - {badge} + + {badge} ) : null} diff --git a/src/components/screen-shell.tsx b/src/components/screen-shell.tsx index 2f381c9..3cac295 100644 --- a/src/components/screen-shell.tsx +++ b/src/components/screen-shell.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren, ReactNode } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; +import type { Edge } from 'react-native-safe-area-context'; import { RefreshControl, ScrollView, Text, View } from 'react-native'; type ScreenShellProps = PropsWithChildren<{ @@ -14,6 +15,7 @@ type ScreenShellProps = PropsWithChildren<{ contentGapClassName?: string; refreshing?: boolean; onRefresh?: () => void | Promise; + safeAreaEdges?: Edge[]; }>; function ScreenHeader({ @@ -32,7 +34,7 @@ function ScreenHeader({ {titleAside} {subtitle ? ( - + {subtitle} ) : null} @@ -70,10 +72,11 @@ export function ScreenShell({ contentGapClassName = 'mt-4 gap-4', refreshing = false, onRefresh, + safeAreaEdges = ['top', 'bottom'], }: ScreenShellProps) { if (!scroll) { return ( - + {children} @@ -83,7 +86,7 @@ export function ScreenShell({ } return ( - + ('all'); + const [usageSort, setUsageSort] = useState('usage-desc'); + const [testingAccountId, setTestingAccountId] = useState(null); + const [testFeedbackByAccountId, setTestFeedbackByAccountId] = useState>({}); + const [togglingAccountId, setTogglingAccountId] = useState(null); + const keyword = useDebouncedValue(searchText.trim(), 300); + const queryClient = useQueryClient(); + + const accountsQuery = useQuery({ + queryKey: ['accounts', keyword], + queryFn: () => listAccounts(keyword), + }); + + const toggleMutation = useMutation({ + mutationFn: ({ accountId, schedulable }: { accountId: number; schedulable: boolean }) => + setAccountSchedulable(accountId, schedulable), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['accounts'] }), + }); + + const testMutation = useMutation({ + mutationFn: (accountId: number) => testAccount(accountId), + }); + + const items = accountsQuery.data?.items ?? []; + const accountCostQueries = useQueries({ + queries: items.map((account) => ({ + queryKey: ['account-today-stats', account.id], + queryFn: () => getAccountTodayStats(account.id), + staleTime: 60_000, + })), + }); + + const todayByAccountId = useMemo(() => { + const next = new Map(); + items.forEach((account, index) => { + const result = accountCostQueries[index]?.data; + const fromStatsCost = typeof result?.cost === 'number' && Number.isFinite(result.cost) ? result.cost : undefined; + const fromExtra = typeof account.extra?.today_cost === 'number' ? account.extra.today_cost : undefined; + const cost = fromStatsCost ?? fromExtra ?? 0; + const requests = typeof result?.requests === 'number' && Number.isFinite(result.requests) ? result.requests : 0; + const tokens = typeof result?.tokens === 'number' && Number.isFinite(result.tokens) ? result.tokens : 0; + next.set(account.id, { requests, tokens, cost }); + }); + return next; + }, [accountCostQueries, items]); + + const filteredItems = useMemo(() => { + const statusMatched = items.filter((account) => { + const visualStatus = getAccountVisualStatus(account); + if (filter === 'all') return true; + if (filter === 'active') return visualStatus.filterKey === 'active'; + if (filter === 'paused') return visualStatus.filterKey === 'paused'; + if (filter === 'error') return visualStatus.filterKey === 'error'; + return true; + }); + + const sorted = [...statusMatched].sort((left, right) => { + const requestsLeft = todayByAccountId.get(left.id)?.requests ?? 0; + const requestsRight = todayByAccountId.get(right.id)?.requests ?? 0; + if (requestsLeft === requestsRight) { + const tokensLeft = todayByAccountId.get(left.id)?.tokens ?? 0; + const tokensRight = todayByAccountId.get(right.id)?.tokens ?? 0; + return tokensLeft - tokensRight; + } + if (usageSort === 'usage-asc') return requestsLeft - requestsRight; + return requestsRight - requestsLeft; + }); + + return sorted; + }, [filter, items, todayByAccountId, usageSort]); + const errorMessage = accountsQuery.error instanceof Error ? accountsQuery.error.message : ''; + + const summary = useMemo(() => { + const total = items.length; + const errors = items.filter((item) => getAccountVisualStatus(item).filterKey === 'error').length; + const paused = items.filter((item) => getAccountVisualStatus(item).filterKey === 'paused').length; + const active = items.filter((item) => getAccountVisualStatus(item).filterKey === 'active').length; + return { total, active, paused, errors }; + }, [items]); + + const listHeader = useMemo( + () => ( + + + + + + + + + {([ + ['all', `全部 ${summary.total}`], + ['active', `正常 ${summary.active}`], + ['paused', `暂停 ${summary.paused}`], + ['error', `异常 ${summary.errors}`], + ] as const).map(([key, label]) => { + const active = filter === key; + return ( + setFilter(key)} + className={active ? 'rounded-full bg-[#1d5f55] px-3 py-2' : 'rounded-full bg-[#e7dfcf] px-3 py-2'} + > + {label} + + ); + })} + + + + {([ + ['usage-desc', '请求高→低'], + ['usage-asc', '请求低→高'], + ] as const).map(([key, label]) => { + const active = usageSort === key; + return ( + setUsageSort(key)} + className={active ? 'rounded-full bg-[#4e463e] px-3 py-3' : 'rounded-full bg-[#e7dfcf] px-3 py-3'} + > + {label} + + ); + })} + + + + ), + [filter, summary.active, summary.errors, summary.paused, summary.total, usageSort] + ); + + const renderItem = useCallback( + ({ item: account }: { item: (typeof filteredItems)[number] }) => { + const isError = getAccountError(account); + const visualStatus = getAccountVisualStatus(account); + const statusText = visualStatus.label; + const groupsText = account.groups?.map((group) => group.name).filter(Boolean).slice(0, 3).join(' · '); + const todayStats = todayByAccountId.get(account.id) ?? { requests: 0, tokens: 0, cost: 0 }; + const nextSchedulable = visualStatus.filterKey === 'paused'; + const toggleLabel = nextSchedulable ? '恢复' : '暂停'; + const testFeedback = testFeedbackByAccountId[account.id]; + const isTogglingCurrent = togglingAccountId === account.id && toggleMutation.isPending; + const isTestingCurrent = testingAccountId === account.id && testMutation.isPending; + + return ( + + + + + + {account.schedulable && !isError ? : } + 状态:{statusText} + + 最近使用 {formatTime(account.last_used_at || account.updated_at)} + + + + + 请求次数 + {todayStats.requests} + + + 消费金额 + ${todayStats.cost.toFixed(2)} + + + token消耗 + {formatTokenValue(todayStats.tokens)} + + + + 优先级 {account.priority ?? 0} · 倍率 {(account.rate_multiplier ?? 1).toFixed(2)}x + + {groupsText ? 分组 {groupsText} : null} + {account.error_message ? 异常信息:{account.error_message} : null} + + + { + event.stopPropagation(); + setTestingAccountId(account.id); + testMutation.mutate(account.id, { + onSuccess: () => { + setTestFeedbackByAccountId((current) => ({ ...current, [account.id]: '测试成功' })); + }, + onError: (error) => { + const message = error instanceof Error && error.message ? error.message : '测试失败'; + setTestFeedbackByAccountId((current) => ({ ...current, [account.id]: message })); + }, + onSettled: () => { + setTestingAccountId((current) => (current === account.id ? null : current)); + }, + }); + }} + > + {isTestingCurrent ? '测试中...' : '测试'} + + { + event.stopPropagation(); + setTogglingAccountId(account.id); + toggleMutation.mutate({ + accountId: account.id, + schedulable: nextSchedulable, + }, { + onSettled: () => { + setTogglingAccountId((current) => (current === account.id ? null : current)); + }, + }); + }} + > + {isTogglingCurrent ? '处理中...' : toggleLabel} + + + + {testFeedback ? 测试结果:{testFeedback} : null} + + + + ); + }, + [testFeedbackByAccountId, testMutation, testingAccountId, todayByAccountId, toggleMutation, togglingAccountId] + ); + + const emptyState = useMemo( + () => , + [errorMessage] + ); + + return ( + 更接近网页后台的账号视图。 + )} + variant="minimal" + scroll={false} + safeAreaEdges={safeAreaEdges} + bottomInsetClassName="pb-6" + contentGapClassName="mt-2 gap-2" + > + `${item.id}`} + showsVerticalScrollIndicator={false} + refreshControl={ void accountsQuery.refetch()} tintColor="#1d5f55" />} + ListHeaderComponent={listHeader} + ListEmptyComponent={emptyState} + ItemSeparatorComponent={() => } + keyboardShouldPersistTaps="handled" + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={5} + /> + + ); +}