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}
+ />
+
+ );
+}