mirror of
https://gitee.com/wanwujie/sub2api-mobile
synced 2026-04-18 22:04:46 +08:00
feat: add dedicated create-user and create-account admin flows
This commit is contained in:
@@ -198,7 +198,17 @@ export default function AccountsScreen() {
|
|||||||
<ScreenShell
|
<ScreenShell
|
||||||
title="账号管理"
|
title="账号管理"
|
||||||
subtitle="看单账号状态、并发、最近使用和异常信息。"
|
subtitle="看单账号状态、并发、最近使用和异常信息。"
|
||||||
titleAside={<Text className="text-[11px] text-[#a2988a]">更接近网页后台的账号视图。</Text>}
|
titleAside={(
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Text className="text-[11px] text-[#a2988a]">更接近网页后台的账号视图。</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/accounts/create')}
|
||||||
|
className="h-8 w-8 items-center justify-center rounded-[10px] bg-[#1d5f55]"
|
||||||
|
>
|
||||||
|
<Text className="text-xl leading-5 text-white">+</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
scroll={false}
|
scroll={false}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { BarChartCard } from '@/src/components/bar-chart-card';
|
|||||||
import { formatTokenValue } from '@/src/lib/formatters';
|
import { formatTokenValue } from '@/src/lib/formatters';
|
||||||
import { DonutChartCard } from '@/src/components/donut-chart-card';
|
import { DonutChartCard } from '@/src/components/donut-chart-card';
|
||||||
import { LineTrendChart } from '@/src/components/line-trend-chart';
|
import { LineTrendChart } from '@/src/components/line-trend-chart';
|
||||||
import { getAdminSettings, getDashboardModels, getDashboardStats, getDashboardTrend, listAllAccounts } from '@/src/services/admin';
|
import { getAdminSettings, getDashboardModels, getDashboardStats, getDashboardTrend, listAccounts } from '@/src/services/admin';
|
||||||
import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config';
|
import { adminConfigState, hasAuthenticatedAdminSession } from '@/src/store/admin-config';
|
||||||
|
|
||||||
const { useSnapshot } = require('valtio/react');
|
const { useSnapshot } = require('valtio/react');
|
||||||
@@ -172,23 +172,37 @@ export default function MonitorScreen() {
|
|||||||
const [rangeKey, setRangeKey] = useState<RangeKey>('7d');
|
const [rangeKey, setRangeKey] = useState<RangeKey>('7d');
|
||||||
const range = useMemo(() => getDateRange(rangeKey), [rangeKey]);
|
const range = useMemo(() => getDateRange(rangeKey), [rangeKey]);
|
||||||
|
|
||||||
const statsQuery = useQuery({ queryKey: ['monitor-stats'], queryFn: getDashboardStats, enabled: hasAccount });
|
const statsQuery = useQuery({
|
||||||
const settingsQuery = useQuery({ queryKey: ['admin-settings'], queryFn: getAdminSettings, enabled: hasAccount });
|
queryKey: ['monitor-stats'],
|
||||||
const accountPageSize = Math.max(statsQuery.data?.total_accounts ?? 20, 20);
|
queryFn: getDashboardStats,
|
||||||
const accountsQuery = useQuery({
|
|
||||||
queryKey: ['monitor-accounts', accountPageSize],
|
|
||||||
queryFn: () => listAllAccounts(''),
|
|
||||||
enabled: hasAccount,
|
enabled: hasAccount,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: ['admin-settings'],
|
||||||
|
queryFn: getAdminSettings,
|
||||||
|
enabled: hasAccount,
|
||||||
|
staleTime: 120_000,
|
||||||
|
});
|
||||||
|
const accountsQuery = useQuery({
|
||||||
|
queryKey: ['monitor-accounts'],
|
||||||
|
queryFn: () => listAccounts(''),
|
||||||
|
enabled: hasAccount,
|
||||||
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
const trendQuery = useQuery({
|
const trendQuery = useQuery({
|
||||||
queryKey: ['monitor-trend', rangeKey, range.start_date, range.end_date, range.granularity],
|
queryKey: ['monitor-trend', rangeKey, range.start_date, range.end_date, range.granularity],
|
||||||
queryFn: () => getDashboardTrend(range),
|
queryFn: () => getDashboardTrend(range),
|
||||||
enabled: hasAccount,
|
enabled: hasAccount,
|
||||||
|
staleTime: 60_000,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
const modelsQuery = useQuery({
|
const modelsQuery = useQuery({
|
||||||
queryKey: ['monitor-models', rangeKey, range.start_date, range.end_date],
|
queryKey: ['monitor-models', rangeKey, range.start_date, range.end_date],
|
||||||
queryFn: () => getDashboardModels(range),
|
queryFn: () => getDashboardModels(range),
|
||||||
enabled: hasAccount,
|
enabled: hasAccount,
|
||||||
|
staleTime: 60_000,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
@@ -381,22 +395,11 @@ export default function MonitorScreen() {
|
|||||||
formatValue={formatCompactNumber}
|
formatValue={formatCompactNumber}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section title="趋势摘要" subtitle="图表 + 最近几个统计点的请求、Token 和成本变化">
|
<Section title="趋势摘要" subtitle="最近几个统计点的请求、Token 和成本变化">
|
||||||
{latestTrendPoints.length === 0 ? (
|
{latestTrendPoints.length === 0 ? (
|
||||||
<Text style={{ fontSize: 14, color: colors.subtext }}>当前时间范围没有趋势数据。</Text>
|
<Text style={{ fontSize: 14, color: colors.subtext }}>当前时间范围没有趋势数据。</Text>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: 12 }}>
|
<View style={{ gap: 12 }}>
|
||||||
{throughputPoints.length > 1 ? (
|
|
||||||
<LineTrendChart
|
|
||||||
title="摘要 Token 趋势"
|
|
||||||
subtitle="最近统计点的 Token 变化"
|
|
||||||
points={throughputPoints.slice(-6)}
|
|
||||||
color="#a34d2d"
|
|
||||||
formatValue={formatTokenDisplay}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
{latestTrendPoints.map((point) => (
|
{latestTrendPoints.map((point) => (
|
||||||
<View key={point.date} style={{ backgroundColor: colors.mutedCard, borderRadius: 14, padding: 12 }}>
|
<View key={point.date} style={{ backgroundColor: colors.mutedCard, borderRadius: 14, padding: 12 }}>
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export default function SettingsScreen() {
|
|||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
|
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
|
||||||
const [connectionMessage, setConnectionMessage] = useState('');
|
const [connectionMessage, setConnectionMessage] = useState('');
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [showAdminKey, setShowAdminKey] = useState(false);
|
||||||
const { control, handleSubmit, formState, reset } = useForm<FormValues>({
|
const { control, handleSubmit, formState, reset } = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -227,15 +228,32 @@ export default function SettingsScreen() {
|
|||||||
control={control}
|
control={control}
|
||||||
name="adminApiKey"
|
name="adminApiKey"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<TextInput
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
value={value}
|
<TextInput
|
||||||
onChangeText={onChange}
|
value={value}
|
||||||
placeholder="admin-xxxxxxxx"
|
onChangeText={onChange}
|
||||||
placeholderTextColor="#9b9081"
|
placeholder="admin-xxxxxxxx"
|
||||||
autoCapitalize="none"
|
placeholderTextColor="#9b9081"
|
||||||
autoCorrect={false}
|
autoCapitalize="none"
|
||||||
style={{ backgroundColor: colors.mutedCard, borderRadius: 16, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: colors.text }}
|
autoCorrect={false}
|
||||||
/>
|
secureTextEntry={!showAdminKey}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.mutedCard,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAdminKey((value) => !value)}
|
||||||
|
style={{ backgroundColor: colors.border, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: '#4e463e' }}>{showAdminKey ? '隐藏' : '显示'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function UserCard({ user, usage }: { user: AdminUser; usage?: UsageStats }) {
|
|||||||
<Text numberOfLines={1} style={{ fontSize: 16, fontWeight: '800', color: colors.text }}>{user.email}</Text>
|
<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>
|
<Text style={{ marginTop: 4, fontSize: 12, color: colors.subtext }}>最近使用 {formatActivityTime(user.last_used_at || user.updated_at || user.created_at)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ alignSelf: 'flex-start', backgroundColor: user.status === 'inactive' ? '#cfc5b7' : colors.primary, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
|
<View style={{ alignSelf: 'flex-start', backgroundColor: user.status === 'inactive' || user.status === 'disabled' ? '#cfc5b7' : colors.primary, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
|
||||||
<Text style={{ fontSize: 10, fontWeight: '700', color: '#fff' }}>{statusLabel}</Text>
|
<Text style={{ fontSize: 10, fontWeight: '700', color: '#fff' }}>{statusLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -187,9 +187,24 @@ export default function UsersScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.page }}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.page }}>
|
||||||
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 14 }}>
|
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 14 }}>
|
||||||
<View style={{ marginBottom: 10 }}>
|
<View style={{ marginBottom: 10, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||||
<Text style={{ fontSize: 28, fontWeight: '700', color: colors.text }}>用户</Text>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ marginTop: 4, fontSize: 12, color: '#8a8072' }}>查看用户列表并进入详情页管理账号。</Text>
|
<Text style={{ fontSize: 28, fontWeight: '700', color: colors.text }}>用户</Text>
|
||||||
|
<Text style={{ marginTop: 4, fontSize: 12, color: '#8a8072' }}>查看用户列表并进入详情页管理账号。</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/users/create-user')}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 24, lineHeight: 24, fontWeight: '500' }}>+</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -50,6 +50,45 @@ export default function RootLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="users/create-account"
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
presentation: 'card',
|
||||||
|
headerShown: true,
|
||||||
|
title: '添加账号',
|
||||||
|
headerBackTitle: '返回',
|
||||||
|
headerTintColor: '#16181a',
|
||||||
|
headerStyle: { backgroundColor: '#f4efe4' },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="users/create-user"
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
presentation: 'card',
|
||||||
|
headerShown: true,
|
||||||
|
title: '添加用户',
|
||||||
|
headerBackTitle: '返回',
|
||||||
|
headerTintColor: '#16181a',
|
||||||
|
headerStyle: { backgroundColor: '#f4efe4' },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="accounts/create"
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
presentation: 'card',
|
||||||
|
headerShown: true,
|
||||||
|
title: '添加账号',
|
||||||
|
headerBackTitle: '返回',
|
||||||
|
headerTintColor: '#16181a',
|
||||||
|
headerStyle: { backgroundColor: '#f4efe4' },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen name="accounts/[id]" options={{ presentation: 'card' }} />
|
<Stack.Screen name="accounts/[id]" options={{ presentation: 'card' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
425
app/accounts/create.tsx
Normal file
425
app/accounts/create.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Stack, router } 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 { createAccount } from '@/src/services/admin';
|
||||||
|
import type { AccountType, CreateAccountRequest } 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_OPTIONS = ['anthropic', 'openai', 'gemini', 'sora', 'antigravity'];
|
||||||
|
const ACCOUNT_TYPE_OPTIONS: AccountType[] = ['apikey', 'oauth', 'setup-token', 'upstream'];
|
||||||
|
type JsonScalar = string | number | boolean | null | undefined;
|
||||||
|
type JsonRecord = Record<string, JsonScalar>;
|
||||||
|
|
||||||
|
function toNumber(raw: string) {
|
||||||
|
if (!raw.trim()) return undefined;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGroupIds(raw: string) {
|
||||||
|
const values = raw
|
||||||
|
.split(',')
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
return values.length > 0 ? values : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectInput(raw: string, fieldLabel: string): JsonRecord | undefined {
|
||||||
|
if (!raw.trim()) return undefined;
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${fieldLabel} 必须是 JSON 对象。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(parsed as Record<string, unknown>);
|
||||||
|
for (const [, value] of entries) {
|
||||||
|
const valueType = typeof value;
|
||||||
|
if (
|
||||||
|
value !== null
|
||||||
|
&& value !== undefined
|
||||||
|
&& valueType !== 'string'
|
||||||
|
&& valueType !== 'number'
|
||||||
|
&& valueType !== 'boolean'
|
||||||
|
) {
|
||||||
|
throw new Error(`${fieldLabel} 仅支持 string / number / boolean / null。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
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。';
|
||||||
|
case 'INVALID_SERVER_RESPONSE':
|
||||||
|
return '服务返回格式异常,请确认后端接口可用并检查网关日志。';
|
||||||
|
case 'REQUEST_FAILED':
|
||||||
|
return '请求失败,请检查服务地址、Token 和网络连通性。';
|
||||||
|
default:
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '创建账号失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateAdminAccountScreen() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [platform, setPlatform] = useState('anthropic');
|
||||||
|
const [type, setType] = useState<AccountType>('apikey');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [credentialsJson, setCredentialsJson] = useState('{\n "base_url": "",\n "api_key": ""\n}');
|
||||||
|
const [extraJson, setExtraJson] = useState('');
|
||||||
|
const [proxyId, setProxyId] = useState('');
|
||||||
|
const [concurrency, setConcurrency] = useState('');
|
||||||
|
const [priority, setPriority] = useState('');
|
||||||
|
const [rateMultiplier, setRateMultiplier] = useState('');
|
||||||
|
const [groupIds, setGroupIds] = useState('');
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => Boolean(name.trim() && credentialsJson.trim()), [credentialsJson, name]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const credentials = parseObjectInput(credentialsJson, 'credentials');
|
||||||
|
if (!credentials) {
|
||||||
|
throw new Error('credentials 不能为空。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra = parseObjectInput(extraJson, 'extra');
|
||||||
|
|
||||||
|
const payload: CreateAccountRequest = {
|
||||||
|
name: name.trim(),
|
||||||
|
platform,
|
||||||
|
type,
|
||||||
|
credentials,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
proxy_id: toNumber(proxyId),
|
||||||
|
concurrency: toNumber(concurrency),
|
||||||
|
priority: toNumber(priority),
|
||||||
|
rate_multiplier: toNumber(rateMultiplier),
|
||||||
|
group_ids: toGroupIds(groupIds),
|
||||||
|
extra,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createAccount(payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormError(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
router.replace('/(tabs)/accounts');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFormError(getErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: '添加账号 (/admin/accounts)' }} />
|
||||||
|
<SafeAreaView edges={['bottom']} style={{ flex: 1, backgroundColor: colors.page }}>
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>基础信息</Text>
|
||||||
|
|
||||||
|
<Text style={{ marginTop: 12, marginBottom: 6, fontSize: 12, color: colors.subtext }}>账号名称</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="例如:openai-main"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>平台</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
||||||
|
{PLATFORM_OPTIONS.map((item) => {
|
||||||
|
const active = platform === item;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item}
|
||||||
|
onPress={() => setPlatform(item)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontSize: 12, fontWeight: '700' }}>{item}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>类型</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
||||||
|
{ACCOUNT_TYPE_OPTIONS.map((item) => {
|
||||||
|
const active = type === item;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item}
|
||||||
|
onPress={() => setType(item)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontSize: 12, fontWeight: '700' }}>{item}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>备注(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="例如:主线路账号"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>请求体字段</Text>
|
||||||
|
<Text style={{ marginTop: 6, fontSize: 12, color: colors.subtext }}>
|
||||||
|
该页面直接创建 /admin/accounts,credentials 必填,extra 可选。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{ marginTop: 12, marginBottom: 6, fontSize: 12, color: colors.subtext }}>credentials(JSON 对象)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={credentialsJson}
|
||||||
|
onChangeText={setCredentialsJson}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder='例如:{"base_url":"https://api.example.com","api_key":"sk-..."}'
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
minHeight: 120,
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>extra(可选,JSON 对象)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={extraJson}
|
||||||
|
onChangeText={setExtraJson}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder='例如:{"window_cost_limit":50}'
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
minHeight: 96,
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>可选参数</Text>
|
||||||
|
|
||||||
|
<Text style={{ marginTop: 12, marginBottom: 6, fontSize: 12, color: colors.subtext }}>proxy_id</Text>
|
||||||
|
<TextInput
|
||||||
|
value={proxyId}
|
||||||
|
onChangeText={setProxyId}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:3"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>concurrency</Text>
|
||||||
|
<TextInput
|
||||||
|
value={concurrency}
|
||||||
|
onChangeText={setConcurrency}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:10"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>priority</Text>
|
||||||
|
<TextInput
|
||||||
|
value={priority}
|
||||||
|
onChangeText={setPriority}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:0"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>rate_multiplier</Text>
|
||||||
|
<TextInput
|
||||||
|
value={rateMultiplier}
|
||||||
|
onChangeText={setRateMultiplier}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="例如:1"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>group_ids(逗号分隔)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={groupIds}
|
||||||
|
onChangeText={setGroupIds}
|
||||||
|
placeholder="例如:1,2,5"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<View style={{ backgroundColor: colors.errorBg, borderRadius: 12, padding: 12, marginBottom: 12 }}>
|
||||||
|
<Text style={{ color: colors.errorText }}>{formError}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setFormError(null);
|
||||||
|
createMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={!canSubmit || createMutation.isPending}
|
||||||
|
style={{
|
||||||
|
backgroundColor: !canSubmit || createMutation.isPending ? '#8a8072' : colors.dark,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>{createMutation.isPending ? '提交中...' : '创建账号'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ export default function LoginScreen() {
|
|||||||
});
|
});
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
|
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
|
||||||
const [connectionMessage, setConnectionMessage] = useState('');
|
const [connectionMessage, setConnectionMessage] = useState('');
|
||||||
|
const [showAdminKey, setShowAdminKey] = useState(false);
|
||||||
|
|
||||||
if (hasAccount) {
|
if (hasAccount) {
|
||||||
return <Redirect href="/monitor" />;
|
return <Redirect href="/monitor" />;
|
||||||
@@ -114,21 +115,38 @@ export default function LoginScreen() {
|
|||||||
control={control}
|
control={control}
|
||||||
name="adminApiKey"
|
name="adminApiKey"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<TextInput
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
value={value}
|
<TextInput
|
||||||
onChangeText={(text) => {
|
value={value}
|
||||||
if (connectionState !== 'idle') {
|
onChangeText={(text) => {
|
||||||
setConnectionState('idle');
|
if (connectionState !== 'idle') {
|
||||||
setConnectionMessage('');
|
setConnectionState('idle');
|
||||||
}
|
setConnectionMessage('');
|
||||||
onChange(text);
|
}
|
||||||
}}
|
onChange(text);
|
||||||
placeholder="admin-xxxxxxxx"
|
}}
|
||||||
placeholderTextColor="#9b9081"
|
placeholder="admin-xxxxxxxx"
|
||||||
autoCapitalize="none"
|
placeholderTextColor="#9b9081"
|
||||||
autoCorrect={false}
|
autoCapitalize="none"
|
||||||
style={{ backgroundColor: colors.mutedCard, borderRadius: 16, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: colors.text }}
|
autoCorrect={false}
|
||||||
/>
|
secureTextEntry={!showAdminKey}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.mutedCard,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAdminKey((value) => !value)}
|
||||||
|
style={{ backgroundColor: colors.border, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: '#4e463e' }}>{showAdminKey ? '隐藏' : '显示'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
import { Alert, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { LineTrendChart } from '@/src/components/line-trend-chart';
|
import { LineTrendChart } from '@/src/components/line-trend-chart';
|
||||||
import { getDashboardSnapshot, getUsageStats, getUser, listUserApiKeys, updateUserBalance } from '@/src/services/admin';
|
import { getDashboardSnapshot, getUsageStats, getUser, listUserApiKeys, updateUserBalance, updateUserStatus } from '@/src/services/admin';
|
||||||
import type { AdminApiKey, BalanceOperation } from '@/src/types/admin';
|
import type { AdminApiKey, BalanceOperation } from '@/src/types/admin';
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -84,7 +84,7 @@ function formatTokenValue(value?: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatQuota(quotaUsed?: number | null, quota?: number | null) {
|
function formatQuotaUsage(quotaUsed?: number | null, quota?: number | null) {
|
||||||
const used = Number(quotaUsed ?? 0);
|
const used = Number(quotaUsed ?? 0);
|
||||||
const limit = Number(quota ?? 0);
|
const limit = Number(quota ?? 0);
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ function formatQuota(quotaUsed?: number | null, quota?: number | null) {
|
|||||||
return '∞';
|
return '∞';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${used} / ${limit}`;
|
return `${used}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
function formatTime(value?: string | null) {
|
||||||
@@ -168,8 +168,8 @@ function MetricCard({ label, value }: { label: string; value: string }) {
|
|||||||
|
|
||||||
function StatusBadge({ text }: { text: string }) {
|
function StatusBadge({ text }: { text: string }) {
|
||||||
const normalized = text.toLowerCase();
|
const normalized = text.toLowerCase();
|
||||||
const backgroundColor = normalized === 'active' ? '#dff4ea' : normalized === 'inactive' ? '#ece5da' : '#f7e1d6';
|
const backgroundColor = normalized === 'active' ? '#dff4ea' : normalized === 'inactive' || normalized === 'disabled' ? '#ece5da' : '#f7e1d6';
|
||||||
const color = normalized === 'active' ? '#17663f' : normalized === 'inactive' ? '#6f665c' : '#a4512b';
|
const color = normalized === 'active' ? '#17663f' : normalized === 'inactive' || normalized === 'disabled' ? '#6f665c' : '#a4512b';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ backgroundColor, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
|
<View style={{ backgroundColor, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
|
||||||
@@ -223,7 +223,7 @@ function KeyItem({ item, copied, onCopy }: { item: AdminApiKey; copied: boolean;
|
|||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={{ fontSize: 11, color: colors.subtext }}>已用额度</Text>
|
<Text style={{ fontSize: 11, color: colors.subtext }}>已用额度</Text>
|
||||||
<Text style={{ marginTop: 4, fontSize: 16, fontWeight: '700', color: colors.text }}>{formatQuota(item.quota_used, item.quota)}</Text>
|
<Text style={{ marginTop: 4, fontSize: 16, fontWeight: '700', color: colors.text }}>{formatQuotaUsage(item.quota_used, item.quota)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, alignItems: 'flex-end' }}>
|
<View style={{ flex: 1, alignItems: 'flex-end' }}>
|
||||||
<Text style={{ fontSize: 11, color: colors.subtext }}>最后使用时间</Text>
|
<Text style={{ fontSize: 11, color: colors.subtext }}>最后使用时间</Text>
|
||||||
@@ -243,6 +243,7 @@ export default function UserDetailScreen() {
|
|||||||
const [amount, setAmount] = useState('10');
|
const [amount, setAmount] = useState('10');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
|
const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
|
||||||
const [rangeKey, setRangeKey] = useState<RangeKey>('7d');
|
const [rangeKey, setRangeKey] = useState<RangeKey>('7d');
|
||||||
@@ -300,6 +301,16 @@ export default function UserDetailScreen() {
|
|||||||
onError: (error) => setFormError(getErrorMessage(error)),
|
onError: (error) => setFormError(getErrorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: 'active' | 'disabled') => updateUserStatus(userId, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
setStatusError(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
onError: (error) => setStatusError(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
const user = userQuery.data;
|
const user = userQuery.data;
|
||||||
const apiKeys = apiKeysQuery.data?.items ?? [];
|
const apiKeys = apiKeysQuery.data?.items ?? [];
|
||||||
|
|
||||||
@@ -344,6 +355,24 @@ export default function UserDetailScreen() {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleUserStatus() {
|
||||||
|
if (!user) return;
|
||||||
|
const nextStatus: 'active' | 'disabled' = user.status === 'disabled' ? 'active' : 'disabled';
|
||||||
|
const actionLabel = nextStatus === 'disabled' ? '禁用' : '启用';
|
||||||
|
|
||||||
|
Alert.alert(`${actionLabel}用户`, `确认要${actionLabel}该用户吗?`, [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '确认',
|
||||||
|
style: nextStatus === 'disabled' ? 'destructive' : 'default',
|
||||||
|
onPress: () => {
|
||||||
|
setStatusError(null);
|
||||||
|
statusMutation.mutate(nextStatus);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: user?.email || '用户详情' }} />
|
<Stack.Screen options={{ title: user?.email || '用户详情' }} />
|
||||||
@@ -392,6 +421,36 @@ export default function UserDetailScreen() {
|
|||||||
<Text style={{ marginTop: 4, fontSize: 13, color: colors.subtext }}>{formatTime(user.last_used_at || user.updated_at || user.created_at)}</Text>
|
<Text style={{ marginTop: 4, fontSize: 13, color: colors.subtext }}>{formatTime(user.last_used_at || user.updated_at || user.created_at)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.subtext }}>用户状态</Text>
|
||||||
|
<StatusBadge text={user.status || 'active'} />
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
disabled={statusMutation.isPending || user.role?.toLowerCase() === 'admin'}
|
||||||
|
onPress={handleToggleUserStatus}
|
||||||
|
style={{
|
||||||
|
backgroundColor: user.status === 'disabled' ? colors.primary : '#8b3f1f',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
opacity: statusMutation.isPending || user.role?.toLowerCase() === 'admin' ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 12, fontWeight: '700' }}>
|
||||||
|
{statusMutation.isPending ? '处理中...' : user.status === 'disabled' ? '启用用户' : '禁用用户'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{user.role?.toLowerCase() === 'admin' ? <Text style={{ marginTop: 8, fontSize: 12, color: colors.subtext }}>管理员用户不支持禁用。</Text> : null}
|
||||||
|
|
||||||
|
{statusError ? (
|
||||||
|
<View style={{ marginTop: 10, backgroundColor: colors.errorBg, borderRadius: 12, padding: 12 }}>
|
||||||
|
<Text style={{ color: colors.errorText }}>{statusError}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
497
app/users/create-account.tsx
Normal file
497
app/users/create-account.tsx
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Stack, router } 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 { createAccount } from '@/src/services/admin';
|
||||||
|
import type { AccountType } 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_OPTIONS = ['anthropic', 'openai', 'gemini', 'sora', 'antigravity'];
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>{title}</Text>
|
||||||
|
<View style={{ marginTop: 12 }}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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。';
|
||||||
|
case 'INVALID_SERVER_RESPONSE':
|
||||||
|
return '服务返回格式异常,请确认后端接口可用并检查网关日志。';
|
||||||
|
case 'REQUEST_FAILED':
|
||||||
|
return '请求失败,请检查服务地址、Token 和网络连通性。';
|
||||||
|
default:
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '创建账号失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberValue(raw: string) {
|
||||||
|
if (!raw.trim()) return undefined;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGroupIds(raw: string) {
|
||||||
|
const values = raw
|
||||||
|
.split(',')
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
|
||||||
|
return values.length > 0 ? values : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw: string) {
|
||||||
|
if (!raw.trim()) return {} as Record<string, string | number | boolean | null | undefined>;
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, string | number | boolean | null | undefined>;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const entries = Object.entries(parsed as Record<string, unknown>);
|
||||||
|
for (const [, value] of entries) {
|
||||||
|
const valueType = typeof value;
|
||||||
|
if (
|
||||||
|
value !== null
|
||||||
|
&& value !== undefined
|
||||||
|
&& valueType !== 'string'
|
||||||
|
&& valueType !== 'number'
|
||||||
|
&& valueType !== 'boolean'
|
||||||
|
) {
|
||||||
|
throw new Error('JSON 仅支持 string / number / boolean / null。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
throw new Error('JSON 需要是对象格式。');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateAccountScreen() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [platform, setPlatform] = useState('anthropic');
|
||||||
|
const [accountType, setAccountType] = useState<AccountType>('apikey');
|
||||||
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [accessToken, setAccessToken] = useState('');
|
||||||
|
const [refreshToken, setRefreshToken] = useState('');
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [concurrency, setConcurrency] = useState('');
|
||||||
|
const [priority, setPriority] = useState('');
|
||||||
|
const [rateMultiplier, setRateMultiplier] = useState('');
|
||||||
|
const [proxyId, setProxyId] = useState('');
|
||||||
|
const [groupIds, setGroupIds] = useState('');
|
||||||
|
const [extraCredentialsJson, setExtraCredentialsJson] = useState('');
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
if (!name.trim()) return false;
|
||||||
|
if (accountType === 'apikey') return Boolean(baseUrl.trim() && apiKey.trim());
|
||||||
|
return Boolean(accessToken.trim());
|
||||||
|
}, [accessToken, accountType, apiKey, baseUrl, name]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const credentialsFromJson = parseJsonObject(extraCredentialsJson);
|
||||||
|
const credentials = accountType === 'apikey'
|
||||||
|
? {
|
||||||
|
...credentialsFromJson,
|
||||||
|
base_url: baseUrl.trim(),
|
||||||
|
api_key: apiKey.trim(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...credentialsFromJson,
|
||||||
|
access_token: accessToken.trim(),
|
||||||
|
refresh_token: refreshToken.trim() || undefined,
|
||||||
|
client_id: clientId.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createAccount({
|
||||||
|
name: name.trim(),
|
||||||
|
platform,
|
||||||
|
type: accountType,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
concurrency: parseNumberValue(concurrency),
|
||||||
|
priority: parseNumberValue(priority),
|
||||||
|
rate_multiplier: parseNumberValue(rateMultiplier),
|
||||||
|
proxy_id: parseNumberValue(proxyId),
|
||||||
|
group_ids: parseGroupIds(groupIds),
|
||||||
|
credentials,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormError(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
router.replace('/(tabs)/accounts');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFormError(getErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: '添加账号' }} />
|
||||||
|
<SafeAreaView edges={['bottom']} style={{ flex: 1, backgroundColor: colors.page }}>
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||||
|
<Section title="基础配置">
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>账号名称</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="例如:openai-main"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>平台</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
|
||||||
|
{PLATFORM_OPTIONS.map((item) => {
|
||||||
|
const active = platform === item;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item}
|
||||||
|
onPress={() => setPlatform(item)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontSize: 12, fontWeight: '700' }}>{item}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>账号类型</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 10 }}>
|
||||||
|
{(['apikey', 'oauth'] as const).map((item) => {
|
||||||
|
const active = accountType === item;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item}
|
||||||
|
onPress={() => setAccountType(item)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontSize: 12, fontWeight: '700' }}>{item.toUpperCase()}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>备注(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="例如:主线路账号"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="凭证信息">
|
||||||
|
{accountType === 'apikey' ? (
|
||||||
|
<>
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>Base URL</Text>
|
||||||
|
<TextInput
|
||||||
|
value={baseUrl}
|
||||||
|
onChangeText={setBaseUrl}
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>API Key</Text>
|
||||||
|
<TextInput
|
||||||
|
value={apiKey}
|
||||||
|
onChangeText={setApiKey}
|
||||||
|
placeholder="sk-..."
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>Access Token</Text>
|
||||||
|
<TextInput
|
||||||
|
value={accessToken}
|
||||||
|
onChangeText={setAccessToken}
|
||||||
|
placeholder="access_token"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>Refresh Token(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={refreshToken}
|
||||||
|
onChangeText={setRefreshToken}
|
||||||
|
placeholder="refresh_token"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>Client ID(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={clientId}
|
||||||
|
onChangeText={setClientId}
|
||||||
|
placeholder="client_id"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>额外凭证 JSON(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={extraCredentialsJson}
|
||||||
|
onChangeText={setExtraCredentialsJson}
|
||||||
|
placeholder='例如:{"project_id":"abc","tier_id":2}'
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
multiline
|
||||||
|
style={{
|
||||||
|
minHeight: 88,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="高级参数(可选)">
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>并发 concurrency</Text>
|
||||||
|
<TextInput
|
||||||
|
value={concurrency}
|
||||||
|
onChangeText={setConcurrency}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:10"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>优先级 priority</Text>
|
||||||
|
<TextInput
|
||||||
|
value={priority}
|
||||||
|
onChangeText={setPriority}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:0"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>倍率 rate_multiplier</Text>
|
||||||
|
<TextInput
|
||||||
|
value={rateMultiplier}
|
||||||
|
onChangeText={setRateMultiplier}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="例如:1"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>代理 ID proxy_id</Text>
|
||||||
|
<TextInput
|
||||||
|
value={proxyId}
|
||||||
|
onChangeText={setProxyId}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:3"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>分组 IDs(逗号分隔)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={groupIds}
|
||||||
|
onChangeText={setGroupIds}
|
||||||
|
placeholder="例如:1,2,5"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<View style={{ backgroundColor: colors.errorBg, borderRadius: 12, padding: 12, marginBottom: 12 }}>
|
||||||
|
<Text style={{ color: colors.errorText }}>{formError}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setFormError(null);
|
||||||
|
createMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={!canSubmit || createMutation.isPending}
|
||||||
|
style={{
|
||||||
|
backgroundColor: !canSubmit || createMutation.isPending ? '#8a8072' : colors.dark,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>{createMutation.isPending ? '提交中...' : '创建账号'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
368
app/users/create-user.tsx
Normal file
368
app/users/create-user.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { Stack, router } 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 { queryClient } from '@/src/lib/query-client';
|
||||||
|
import { createUser } from '@/src/services/admin';
|
||||||
|
import type { CreateUserRequest } 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 JsonValue = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
function parseJsonObject(raw: string, fieldLabel: string): Record<string, JsonValue> {
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${fieldLabel} 必须是 JSON 对象。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(parsed as Record<string, unknown>);
|
||||||
|
|
||||||
|
for (const [, value] of entries) {
|
||||||
|
const valueType = typeof value;
|
||||||
|
if (
|
||||||
|
value !== null
|
||||||
|
&& value !== undefined
|
||||||
|
&& valueType !== 'string'
|
||||||
|
&& valueType !== 'number'
|
||||||
|
&& valueType !== 'boolean'
|
||||||
|
) {
|
||||||
|
throw new Error(`${fieldLabel} 仅支持 string / number / boolean / null。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as Record<string, JsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(raw: string) {
|
||||||
|
if (!raw.trim()) return undefined;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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。';
|
||||||
|
case 'INVALID_SERVER_RESPONSE':
|
||||||
|
return '服务返回格式异常,请确认后端接口可用并检查网关日志。';
|
||||||
|
case 'REQUEST_FAILED':
|
||||||
|
return '请求失败,请检查服务地址、Token 和网络连通性。';
|
||||||
|
default:
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '创建用户失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateUserScreen() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [role, setRole] = useState<'user' | 'admin'>('user');
|
||||||
|
const [status, setStatus] = useState<'active' | 'disabled'>('active');
|
||||||
|
const [balance, setBalance] = useState('');
|
||||||
|
const [concurrency, setConcurrency] = useState('');
|
||||||
|
const [extraJson, setExtraJson] = useState('');
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => Boolean(email.trim() && password.trim()), [email, password]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const extra = parseJsonObject(extraJson, 'extra');
|
||||||
|
const payload: CreateUserRequest = {
|
||||||
|
...extra,
|
||||||
|
email: email.trim(),
|
||||||
|
password: password.trim(),
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
balance: toNumber(balance),
|
||||||
|
concurrency: toNumber(concurrency),
|
||||||
|
};
|
||||||
|
|
||||||
|
return createUser(payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFormError(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
router.replace('/(tabs)/users');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFormError(getErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: '添加用户 (/admin/users)' }} />
|
||||||
|
<SafeAreaView edges={['bottom']} style={{ flex: 1, backgroundColor: colors.page }}>
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>基础信息</Text>
|
||||||
|
|
||||||
|
<Text style={{ marginTop: 12, marginBottom: 6, fontSize: 12, color: colors.subtext }}>邮箱</Text>
|
||||||
|
<TextInput
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="例如:user@example.com"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>密码</Text>
|
||||||
|
<TextInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>用户名(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
placeholder="例如:demo-user"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
autoCapitalize="none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>备注(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="例如:测试用户"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>权限与状态</Text>
|
||||||
|
|
||||||
|
<Text style={{ marginTop: 12, marginBottom: 6, fontSize: 12, color: colors.subtext }}>角色</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 10 }}>
|
||||||
|
{(['user', 'admin'] as const).map((item) => {
|
||||||
|
const active = role === item;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item}
|
||||||
|
onPress={() => setRole(item)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontSize: 12, fontWeight: '700' }}>{item}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>状态</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
{(['active', 'disabled'] as const).map((item) => {
|
||||||
|
const active = status === item;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={item}
|
||||||
|
onPress={() => setStatus(item)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: active ? colors.primary : colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontSize: 12, fontWeight: '700' }}>{item}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}>高级参数(可选)</Text>
|
||||||
|
|
||||||
|
<Text style={{ marginTop: 12, marginBottom: 6, fontSize: 12, color: colors.subtext }}>余额 balance</Text>
|
||||||
|
<TextInput
|
||||||
|
value={balance}
|
||||||
|
onChangeText={setBalance}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder="例如:100"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>并发 concurrency</Text>
|
||||||
|
<TextInput
|
||||||
|
value={concurrency}
|
||||||
|
onChangeText={setConcurrency}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="例如:5"
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ marginBottom: 6, fontSize: 12, color: colors.subtext }}>extra(可选,JSON 对象)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={extraJson}
|
||||||
|
onChangeText={setExtraJson}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder='例如:{"daily_limit":10}'
|
||||||
|
placeholderTextColor="#9a9082"
|
||||||
|
style={{
|
||||||
|
minHeight: 96,
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<View style={{ backgroundColor: colors.errorBg, borderRadius: 12, padding: 12, marginBottom: 12 }}>
|
||||||
|
<Text style={{ color: colors.errorText }}>{formError}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setFormError(null);
|
||||||
|
createMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={!canSubmit || createMutation.isPending}
|
||||||
|
style={{
|
||||||
|
backgroundColor: !canSubmit || createMutation.isPending ? '#8a8072' : colors.dark,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>{createMutation.isPending ? '提交中...' : '创建用户'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
import { adminConfigState } from '@/src/store/admin-config';
|
import { adminConfigState } from '@/src/store/admin-config';
|
||||||
import type { ApiEnvelope } from '@/src/types/admin';
|
import type { ApiEnvelope } from '@/src/types/admin';
|
||||||
|
|
||||||
|
function buildRequestUrl(baseUrl: string, path: string) {
|
||||||
|
const normalizedBase = baseUrl.trim().replace(/\/$/, '');
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const duplicatedPrefixes = ['/api/v1', '/api'];
|
||||||
|
|
||||||
|
for (const prefix of duplicatedPrefixes) {
|
||||||
|
if (normalizedBase.endsWith(prefix) && normalizedPath.startsWith(`${prefix}/`)) {
|
||||||
|
const baseWithoutPrefix = normalizedBase.slice(0, -prefix.length);
|
||||||
|
return `${baseWithoutPrefix}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedBase}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function adminFetch<T>(
|
export async function adminFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
@@ -27,15 +42,16 @@ export async function adminFetch<T>(
|
|||||||
headers.set('Idempotency-Key', options.idempotencyKey);
|
headers.set('Idempotency-Key', options.idempotencyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}${path}`, {
|
const response = await fetch(buildRequestUrl(baseUrl, path), {
|
||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
let json: ApiEnvelope<T>;
|
let json: ApiEnvelope<T>;
|
||||||
|
const rawText = await response.text();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json = (await response.json()) as ApiEnvelope<T>;
|
json = JSON.parse(rawText) as ApiEnvelope<T>;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('INVALID_SERVER_RESPONSE');
|
throw new Error('INVALID_SERVER_RESPONSE');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
DashboardSnapshot,
|
DashboardSnapshot,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
DashboardTrend,
|
DashboardTrend,
|
||||||
|
CreateAccountRequest,
|
||||||
|
CreateUserRequest,
|
||||||
PaginatedData,
|
PaginatedData,
|
||||||
UsageStats,
|
UsageStats,
|
||||||
UserUsageSummary,
|
UserUsageSummary,
|
||||||
@@ -95,6 +97,13 @@ export function getUser(userId: number) {
|
|||||||
return adminFetch<AdminUser>(`/api/v1/admin/users/${userId}`);
|
return adminFetch<AdminUser>(`/api/v1/admin/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createUser(body: CreateUserRequest) {
|
||||||
|
return adminFetch<AdminUser>('/api/v1/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserUsage(userId: number, period: 'day' | 'week' | 'month' = 'month') {
|
export function getUserUsage(userId: number, period: 'day' | 'week' | 'month' = 'month') {
|
||||||
return adminFetch<UserUsageSummary>(`/api/v1/admin/users/${userId}/usage${buildQuery({ period })}`);
|
return adminFetch<UserUsageSummary>(`/api/v1/admin/users/${userId}/usage${buildQuery({ period })}`);
|
||||||
}
|
}
|
||||||
@@ -119,6 +128,13 @@ export function updateUserBalance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateUserStatus(userId: number, status: 'active' | 'disabled') {
|
||||||
|
return adminFetch<AdminUser>(`/api/v1/admin/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listGroups(search = '') {
|
export function listGroups(search = '') {
|
||||||
return adminFetch<PaginatedData<AdminGroup>>(
|
return adminFetch<PaginatedData<AdminGroup>>(
|
||||||
`/api/v1/admin/groups${buildQuery({ page: 1, page_size: 20, search: search.trim() })}`
|
`/api/v1/admin/groups${buildQuery({ page: 1, page_size: 20, search: search.trim() })}`
|
||||||
@@ -139,6 +155,13 @@ export function getAccount(accountId: number) {
|
|||||||
return adminFetch<AdminAccount>(`/api/v1/admin/accounts/${accountId}`);
|
return adminFetch<AdminAccount>(`/api/v1/admin/accounts/${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createAccount(body: CreateAccountRequest) {
|
||||||
|
return adminFetch<AdminAccount>('/api/v1/admin/accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getAccountTodayStats(accountId: number) {
|
export function getAccountTodayStats(accountId: number) {
|
||||||
return adminFetch<AccountTodayStats>(`/api/v1/admin/accounts/${accountId}/today-stats`);
|
return adminFetch<AccountTodayStats>(`/api/v1/admin/accounts/${accountId}/today-stats`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,3 +197,31 @@ export type AdminAccount = {
|
|||||||
groups?: AdminGroup[];
|
groups?: AdminGroup[];
|
||||||
extra?: Record<string, string | number | boolean | null>;
|
extra?: Record<string, string | number | boolean | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccountType = 'apikey' | 'oauth' | 'setup-token' | 'upstream';
|
||||||
|
|
||||||
|
export type CreateAccountRequest = {
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
type: AccountType;
|
||||||
|
credentials: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
extra?: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
notes?: string;
|
||||||
|
proxy_id?: number;
|
||||||
|
concurrency?: number;
|
||||||
|
priority?: number;
|
||||||
|
rate_multiplier?: number;
|
||||||
|
group_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserRequest = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
username?: string;
|
||||||
|
notes?: string;
|
||||||
|
role?: 'user' | 'admin';
|
||||||
|
status?: 'active' | 'disabled';
|
||||||
|
balance?: number;
|
||||||
|
concurrency?: number;
|
||||||
|
[key: string]: string | number | boolean | null | undefined;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user