feat: refine expo admin mobile flows

This commit is contained in:
xuhongbin
2026-03-08 20:53:15 +08:00
parent c70ca1641a
commit 434bbf258a
21 changed files with 3128 additions and 6381 deletions

View File

@@ -1,264 +1,223 @@
import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import * as Clipboard from 'expo-clipboard';
import { Copy, Search, UserRound } from 'lucide-react-native';
import { useQuery } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useCallback, useMemo, useState } from 'react';
import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
import { useMemo, useState } from 'react';
import { FlatList, Pressable, RefreshControl, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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 { queryClient } from '@/src/lib/query-client';
import { getUser, listUserApiKeys, listUsers } from '@/src/services/admin';
import { adminConfigState } from '@/src/store/admin-config';
import type { AdminApiKey, AdminUser, UserUsageSummary } from '@/src/types/admin';
import type { AdminUser } from '@/src/types/admin';
const { useSnapshot } = require('valtio/react');
type UserSupplement = {
usage?: UserUsageSummary;
apiKeys: AdminApiKey[];
const colors = {
page: '#f4efe4',
card: '#fbf8f2',
mutedCard: '#f1ece2',
primary: '#1d5f55',
text: '#16181a',
subtext: '#6f665c',
dangerBg: '#fbf1eb',
danger: '#c25d35',
accentBg: '#efe4cf',
accentText: '#8c5a22',
};
function getUserTitle(user: AdminUser) {
return user.username?.trim() || user.email;
}
type SortOrder = 'desc' | 'asc';
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) {
function formatBalance(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 (
<View style={{ flex: 1, minWidth: 0, backgroundColor, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 12 }}>
<Text style={{ fontSize: 11, color: colors.subtext }}>{title}</Text>
<Text numberOfLines={1} style={{ marginTop: 6, fontSize: tone === 'accent' ? 20 : 16, fontWeight: '800', color: valueColor }}>
{value}
</Text>
</View>
);
}
function UserCard({ user }: { user: AdminUser }) {
const isAdmin = user.role?.trim().toLowerCase() === 'admin';
const statusLabel = `${isAdmin ? 'admin · ' : ''}${user.status || 'active'}`;
return (
<View style={{ backgroundColor: colors.card, borderRadius: 18, padding: 14 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text numberOfLines={1} style={{ fontSize: 16, fontWeight: '800', color: colors.text }}>{user.email}</Text>
<Text style={{ marginTop: 4, fontSize: 12, color: colors.subtext }}>使 {formatActivityTime(user.last_used_at || user.updated_at || user.created_at)}</Text>
</View>
<View style={{ alignSelf: 'flex-start', backgroundColor: user.status === 'inactive' ? '#cfc5b7' : colors.primary, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
<Text style={{ fontSize: 10, fontWeight: '700', color: '#fff' }}>{statusLabel}</Text>
</View>
</View>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<MetricTile title="金额" value={formatBalance(Number(user.balance ?? 0))} tone="accent" />
<MetricTile title="名称" value={getUserNameLabel(user)} />
</View>
</View>
);
}
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<number | null>(null);
const keyword = useDebouncedValue(searchText.trim(), 300);
const queryClient = useQueryClient();
const hasAccount = Boolean(config.baseUrl.trim());
const [searchText, setSearchText] = useState('');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const debouncedSearchText = useDebouncedValue(searchText, 250);
const usersQuery = useQuery({
queryKey: ['users', keyword],
queryFn: () => listUsers(keyword),
queryKey: ['users', debouncedSearchText],
queryFn: () => listUsers(debouncedSearchText),
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)]);
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]);
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<Record<number, UserSupplement | undefined>>((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 (
<Pressable
className="px-1"
onPress={() => {
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}`);
}}
>
<ListCard title={getUserTitle(user)} meta={user.email} badge={user.status || 'active'} icon={UserRound}>
<View className="gap-2">
<View className="rounded-[14px] bg-[#f7f2e9] px-3 py-2.5">
<View className="flex-row items-center justify-between">
<Text className="text-[11px] text-[#6f665c]"></Text>
<Text className="text-sm font-semibold text-[#16181a]">${Number(user.balance ?? 0).toFixed(2)}</Text>
</View>
</View>
<View className="rounded-[14px] bg-[#f7f2e9] px-3 py-2">
<View className="mb-1.5 flex-row items-center justify-between">
<Text className="text-[11px] text-[#6f665c]">Keys</Text>
<Text className="text-[10px] text-[#8a8072]">{keyItems.length} </Text>
</View>
<View className="gap-0">
{keyItems.map((apiKey, index) => (
<View
key={apiKey.id}
style={{ paddingVertical: 4 }}
>
{(() => {
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 (
<>
<View className="flex-row items-center gap-2">
<Text numberOfLines={1} className="flex-1 text-[11px] font-semibold text-[#16181a]">
{apiKey.name}
</Text>
<Text numberOfLines={1} className="text-[10px] text-[#6f665c]">
{isUnlimited ? `${formatQuotaValue(used)} / 无限` : `${formatQuotaValue(used)} / ${formatQuotaValue(quota)}`}
</Text>
<Pressable
className="rounded-full bg-[#e7dfcf] p-1.5"
onPress={(event) => {
event.stopPropagation();
void copyKey(apiKey.id, apiKey.key);
}}
>
<Copy color="#4e463e" size={11} />
</Pressable>
</View>
<View className="h-1.5 overflow-hidden rounded-full bg-[#ddd2c0]" style={{ marginTop: 3 }}>
<View
className={
isUnlimited
? 'h-full rounded-full bg-[#7d7468]'
: used / Math.max(quota, 1) >= 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 }}
/>
</View>
{copiedKeyId === apiKey.id ? <Text className="text-[10px] text-[#1d5f55]" style={{ paddingTop: 3 }}></Text> : null}
</>
);
})()}
</View>
))}
{keyItems.length === 0 ? (
<View className="py-[10px]">
<Text className="text-[11px] text-[#6f665c]"> token </Text>
</View>
) : null}
</View>
</View>
</View>
</ListCard>
</Pressable>
);
},
[copiedKeyId, queryClient, sortedItems, supplementsByUserId]
);
const emptyState = useMemo(
() => (
<ListCard
title={hasAccount ? '暂无匹配用户' : '未连接服务器'}
meta={hasAccount ? errorMessage || '调整搜索词后再试。' : '请先前往服务器标签连接 Sub2API。'}
icon={UserRound}
/>
),
[errorMessage, hasAccount]
);
const errorMessage = getErrorMessage(usersQuery.error);
return (
<ScreenShell
title="用户管理"
subtitle=""
titleAside={<Text className="text-[11px] text-[#a2988a]"> {sortedItems.length}</Text>}
variant="minimal"
scroll={false}
bottomInsetClassName="pb-12"
>
<View className="flex-1">
<View className="rounded-[16px] bg-[#fbf8f2] px-2.5 py-2.5">
<View className="flex-row items-center gap-2">
<View className="flex-1 flex-row items-center rounded-[14px] bg-[#f1ece2] px-3 py-2.5">
<Search color="#7d7468" size={18} />
<TextInput
value={searchText}
onChangeText={setSearchText}
placeholder="搜索邮箱或用户名"
placeholderTextColor="#9b9081"
className="ml-3 flex-1 text-base text-[#16181a]"
/>
</View>
<Pressable
className={sortOrder === 'desc' ? 'rounded-[14px] bg-[#1d5f55] px-3 py-2.5' : 'rounded-[14px] bg-[#e7dfcf] px-3 py-2.5'}
onPress={() => setSortOrder('desc')}
>
<Text className={sortOrder === 'desc' ? 'text-[11px] font-semibold text-white' : 'text-[11px] font-semibold text-[#4e463e]'}>
</Text>
</Pressable>
<Pressable
className={sortOrder === 'asc' ? 'rounded-[14px] bg-[#1d5f55] px-3 py-2.5' : 'rounded-[14px] bg-[#e7dfcf] px-3 py-2.5'}
onPress={() => setSortOrder('asc')}
>
<Text className={sortOrder === 'asc' ? 'text-[11px] font-semibold text-white' : 'text-[11px] font-semibold text-[#4e463e]'}>
</Text>
</Pressable>
</View>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.page }}>
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 14 }}>
<View style={{ marginBottom: 10 }}>
<Text style={{ fontSize: 28, fontWeight: '700', color: colors.text }}></Text>
<Text style={{ marginTop: 4, fontSize: 12, color: '#8a8072' }}></Text>
</View>
<View className="mt-2.5 flex-1">
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
<View style={{ flex: 1, backgroundColor: colors.card, borderRadius: 16, padding: 10 }}>
<TextInput
value={searchText}
onChangeText={setSearchText}
placeholder="搜索邮箱、用户名或备注"
placeholderTextColor="#9b9081"
style={{ backgroundColor: colors.mutedCard, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, fontSize: 15, color: colors.text }}
/>
</View>
<Pressable
onPress={() => setSortOrder((value) => (value === 'desc' ? 'asc' : 'desc'))}
style={{ backgroundColor: colors.card, borderRadius: 16, paddingHorizontal: 14, paddingVertical: 14, minWidth: 92, alignItems: 'center' }}
>
<Text style={{ fontSize: 11, color: colors.subtext }}></Text>
<Text style={{ marginTop: 4, fontSize: 13, fontWeight: '700', color: colors.text }}>{sortOrder === 'desc' ? '倒序' : '正序'}</Text>
</Pressable>
</View>
{!hasAccount ? (
<View style={{ marginTop: 10, backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}></Text>
<Text style={{ marginTop: 8, fontSize: 14, lineHeight: 22, color: colors.subtext }}></Text>
<Pressable
style={{ marginTop: 14, alignSelf: 'flex-start', backgroundColor: colors.primary, borderRadius: 14, paddingHorizontal: 16, paddingVertical: 12 }}
onPress={() => router.push('/settings')}
>
<Text style={{ color: '#fff', fontSize: 13, fontWeight: '700' }}></Text>
</Pressable>
</View>
) : usersQuery.isLoading ? (
<View style={{ marginTop: 10, backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}></Text>
<Text style={{ marginTop: 8, fontSize: 14, lineHeight: 22, color: colors.subtext }}></Text>
</View>
) : usersQuery.error ? (
<View style={{ marginTop: 10, backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}></Text>
<View style={{ marginTop: 12, borderRadius: 14, backgroundColor: colors.dangerBg, paddingHorizontal: 14, paddingVertical: 12 }}>
<Text style={{ color: colors.danger, fontSize: 14, lineHeight: 20 }}>{errorMessage}</Text>
</View>
</View>
) : (
<FlatList
data={sortedItems}
renderItem={renderItem}
style={{ marginTop: 10, flex: 1 }}
data={users}
keyExtractor={(item) => `${item.id}`}
showsVerticalScrollIndicator={false}
ListHeaderComponent={() => <View className="h-2" />}
ListEmptyComponent={emptyState}
ListFooterComponent={() => <View className="h-4" />}
ItemSeparatorComponent={() => <View className="h-3" />}
keyboardShouldPersistTaps="handled"
removeClippedSubviews
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={5}
refreshControl={<RefreshControl refreshing={usersQuery.isRefetching} onRefresh={() => void usersQuery.refetch()} tintColor="#1d5f55" />}
contentContainerStyle={{ paddingBottom: 8, gap: 12, flexGrow: users.length === 0 ? 1 : 0 }}
ListEmptyComponent={
<View style={{ backgroundColor: colors.card, borderRadius: 18, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}></Text>
<Text style={{ marginTop: 8, fontSize: 14, lineHeight: 22, color: colors.subtext }}></Text>
</View>
}
renderItem={({ item }) => (
<Pressable
onPress={() => {
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}`);
}}
>
<UserCard user={item} />
</Pressable>
)}
/>
</View>
)}
</View>
</ScreenShell>
</SafeAreaView>
);
}