import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as Clipboard from 'expo-clipboard';
import { Stack, useLocalSearchParams } from 'expo-router';
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { LineTrendChart } from '@/src/components/line-trend-chart';
import { getDashboardSnapshot, getUsageStats, getUser, listUserApiKeys, updateUserBalance } from '@/src/services/admin';
import type { AdminApiKey, BalanceOperation } from '@/src/types/admin';
const colors = {
page: '#f4efe4',
card: '#fbf8f2',
text: '#16181a',
subtext: '#6f665c',
border: '#e7dfcf',
primary: '#1d5f55',
dark: '#1b1d1f',
errorBg: '#f7e1d6',
errorText: '#a4512b',
muted: '#f7f1e6',
};
type RangeKey = '24h' | '7d' | '30d';
const RANGE_OPTIONS: Array<{ key: RangeKey; label: string }> = [
{ key: '24h', label: '24H' },
{ key: '7d', label: '7D' },
{ key: '30d', label: '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' as const) : ('day' as const),
};
}
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 '加载失败,请稍后重试。';
}
function formatMoney(value?: number | null) {
return `$${Number(value ?? 0).toFixed(2)}`;
}
function formatUsageCost(stats?: { total_account_cost?: number | null; total_actual_cost?: number | null; total_cost?: number | null }) {
const value = Number(stats?.total_account_cost ?? stats?.total_actual_cost ?? stats?.total_cost ?? 0);
return `$${value.toFixed(4)}`;
}
function formatTokenValue(value?: number | null) {
const number = Number(value ?? 0);
if (number >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(2)}B`;
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(2)}M`;
if (number >= 1_000) return `${(number / 1_000).toFixed(2)}K`;
return new Intl.NumberFormat('en-US').format(number);
}
function formatQuota(quotaUsed?: number | null, quota?: number | null) {
const used = Number(quotaUsed ?? 0);
const limit = Number(quota ?? 0);
if (limit <= 0) {
return '∞';
}
return `${used} / ${limit}`;
}
function formatTime(value?: string | null) {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hours = `${date.getHours()}`.padStart(2, '0');
const minutes = `${date.getMinutes()}`.padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
{title}
{children}
);
}
function GridField({ label, value }: { label: string; value: string }) {
return (
{label}
{value}
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
{label}
{value}
);
}
function StatusBadge({ text }: { text: string }) {
const normalized = text.toLowerCase();
const backgroundColor = normalized === 'active' ? '#dff4ea' : normalized === 'inactive' ? '#ece5da' : '#f7e1d6';
const color = normalized === 'active' ? '#17663f' : normalized === 'inactive' ? '#6f665c' : '#a4512b';
return (
{text}
);
}
function CopyInlineButton({ copied, onPress }: { copied: boolean; onPress: () => void }) {
return (
{copied ? '已复制' : '复制'}
);
}
function KeyItem({ item, copied, onCopy }: { item: AdminApiKey; copied: boolean; onCopy: () => void }) {
return (
{item.name || `Key #${item.id}`}
{item.group?.name || '未分组'}
{item.key || '--'}
已用额度
{formatQuota(item.quota_used, item.quota)}
最后使用时间
{formatTime(item.last_used_at || item.updated_at || item.created_at)}
);
}
export default function UserDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const userId = Number(id);
const queryClient = useQueryClient();
const [operation, setOperation] = useState('add');
const [amount, setAmount] = useState('10');
const [notes, setNotes] = useState('');
const [formError, setFormError] = useState(null);
const [searchText, setSearchText] = useState('');
const [copiedKeyId, setCopiedKeyId] = useState(null);
const [rangeKey, setRangeKey] = useState('7d');
const range = getDateRange(rangeKey);
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
enabled: Number.isFinite(userId),
});
const apiKeysQuery = useQuery({
queryKey: ['user-api-keys', userId],
queryFn: () => listUserApiKeys(userId),
enabled: Number.isFinite(userId),
});
const usageStatsQuery = useQuery({
queryKey: ['usage-stats', 'user', userId, rangeKey, range.start_date, range.end_date],
queryFn: () => getUsageStats({ ...range, user_id: userId }),
enabled: Number.isFinite(userId),
});
const usageSnapshotQuery = useQuery({
queryKey: ['usage-snapshot', 'user', userId, rangeKey, range.start_date, range.end_date, range.granularity],
queryFn: () =>
getDashboardSnapshot({
...range,
user_id: userId,
include_stats: false,
include_trend: true,
include_model_stats: false,
include_group_stats: false,
include_users_trend: false,
}),
enabled: Number.isFinite(userId),
});
;
;
const balanceMutation = useMutation({
mutationFn: (payload: { amount: number; notes?: string; operation: BalanceOperation }) =>
updateUserBalance(userId, {
balance: payload.amount,
notes: payload.notes,
operation: payload.operation,
}),
onSuccess: () => {
setFormError(null);
setAmount('10');
setNotes('');
queryClient.invalidateQueries({ queryKey: ['user', userId] });
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => setFormError(getErrorMessage(error)),
});
const user = userQuery.data;
const apiKeys = apiKeysQuery.data?.items ?? [];
const filteredApiKeys = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
return apiKeys.filter((item) => {
const haystack = [item.name, item.key, item.group?.name].filter(Boolean).join(' ').toLowerCase();
return keyword ? haystack.includes(keyword) : true;
});
}, [apiKeys, searchText]);
const trendPoints = (usageSnapshotQuery.data?.trend ?? []).map((item) => ({
label: rangeKey === '24h' ? item.date.slice(11, 13) : item.date.slice(5, 10),
value: item.total_tokens,
}));
function submitBalance() {
const numericAmount = Number(amount);
if (!amount.trim()) {
setFormError('请输入金额。');
return;
}
if (!Number.isFinite(numericAmount) || numericAmount < 0) {
setFormError('金额格式不正确。');
return;
}
balanceMutation.mutate({
amount: numericAmount,
notes: notes.trim() || undefined,
operation,
});
}
async function copyKey(item: AdminApiKey) {
await Clipboard.setStringAsync(item.key || '');
setCopiedKeyId(item.id);
setTimeout(() => {
setCopiedKeyId((current) => (current === item.id ? null : current));
}, 1500);
}
return (
<>
{userQuery.isLoading ? (
) : null}
{userQuery.error ? (
用户信息加载失败
{getErrorMessage(userQuery.error)}
) : null}
{user ? (
邮箱
{user.email || '--'}
最后使用时间
{formatTime(user.last_used_at || user.updated_at || user.created_at)}
) : null}
{RANGE_OPTIONS.map((item) => {
const active = item.key === rangeKey;
return (
setRangeKey(item.key)}
style={{
backgroundColor: active ? colors.primary : colors.muted,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
borderWidth: 1,
borderColor: active ? colors.primary : colors.border,
}}
>
{item.label}
);
})}
{usageStatsQuery.data ? (
输入 {formatTokenValue(usageStatsQuery.data.total_input_tokens)} · 输出 {formatTokenValue(usageStatsQuery.data.total_output_tokens)}
) : null}
{usageStatsQuery.isLoading ? 正在加载用量统计... : null}
{usageStatsQuery.error ? (
用量统计加载失败
{getErrorMessage(usageStatsQuery.error)}
) : null}
{!usageSnapshotQuery.isLoading && trendPoints.length > 1 ? (
formatTokenValue(value)}
compact
/>
) : null}
{usageSnapshotQuery.isLoading ? 正在加载趋势图... : null}
{usageSnapshotQuery.error ? (
趋势加载失败
{getErrorMessage(usageSnapshotQuery.error)}
) : null}
{apiKeysQuery.isLoading ? 正在加载 API Keys... : null}
{apiKeysQuery.error ? (
API Keys 加载失败
{getErrorMessage(apiKeysQuery.error)}
) : null}
{!apiKeysQuery.isLoading && !apiKeysQuery.error ? (
filteredApiKeys.length > 0 ? (
{filteredApiKeys.map((item) => (
copyKey(item)} />
))}
) : (
当前筛选条件下没有 Key。
)
) : null}
{([
{ label: '充值', value: 'add' },
{ label: '扣减', value: 'subtract' },
{ label: '设为', value: 'set' },
] as const).map((item) => {
const active = operation === item.value;
return (
setOperation(item.value)}
style={{
flex: 1,
backgroundColor: active ? colors.primary : colors.muted,
borderRadius: 12,
paddingVertical: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: active ? colors.primary : colors.border,
}}
>
{item.label}
);
})}
{formError ? (
{formError}
) : null}
{balanceMutation.isPending ? '提交中...' : '确认提交'}
>
);
}