From 293b62b444c7394e951242ebf13bf7516057dc10 Mon Sep 17 00:00:00 2001 From: xuhongbin Date: Mon, 9 Mar 2026 19:06:02 +0800 Subject: [PATCH] feat: add dedicated create-user and create-account admin flows --- app/(tabs)/accounts.tsx | 12 +- app/(tabs)/monitor.tsx | 41 +-- app/(tabs)/settings.tsx | 36 ++- app/(tabs)/users.tsx | 23 +- app/_layout.tsx | 39 +++ app/accounts/create.tsx | 425 ++++++++++++++++++++++++++++++ app/login.tsx | 48 ++-- app/users/[id].tsx | 73 ++++- app/users/create-account.tsx | 497 +++++++++++++++++++++++++++++++++++ app/users/create-user.tsx | 368 ++++++++++++++++++++++++++ src/lib/admin-fetch.ts | 20 +- src/services/admin.ts | 23 ++ src/types/admin.ts | 28 ++ 13 files changed, 1576 insertions(+), 57 deletions(-) create mode 100644 app/accounts/create.tsx create mode 100644 app/users/create-account.tsx create mode 100644 app/users/create-user.tsx diff --git a/app/(tabs)/accounts.tsx b/app/(tabs)/accounts.tsx index daa6471..ee89bf6 100644 --- a/app/(tabs)/accounts.tsx +++ b/app/(tabs)/accounts.tsx @@ -198,7 +198,17 @@ export default function AccountsScreen() { 更接近网页后台的账号视图。} + titleAside={( + + 更接近网页后台的账号视图。 + router.push('/accounts/create')} + className="h-8 w-8 items-center justify-center rounded-[10px] bg-[#1d5f55]" + > + + + + + )} variant="minimal" scroll={false} > diff --git a/app/(tabs)/monitor.tsx b/app/(tabs)/monitor.tsx index e681caf..602f743 100644 --- a/app/(tabs)/monitor.tsx +++ b/app/(tabs)/monitor.tsx @@ -8,7 +8,7 @@ import { BarChartCard } from '@/src/components/bar-chart-card'; import { formatTokenValue } from '@/src/lib/formatters'; import { DonutChartCard } from '@/src/components/donut-chart-card'; 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'; const { useSnapshot } = require('valtio/react'); @@ -172,23 +172,37 @@ export default function MonitorScreen() { const [rangeKey, setRangeKey] = useState('7d'); const range = useMemo(() => getDateRange(rangeKey), [rangeKey]); - const statsQuery = useQuery({ queryKey: ['monitor-stats'], queryFn: getDashboardStats, enabled: hasAccount }); - const settingsQuery = useQuery({ queryKey: ['admin-settings'], queryFn: getAdminSettings, enabled: hasAccount }); - const accountPageSize = Math.max(statsQuery.data?.total_accounts ?? 20, 20); - const accountsQuery = useQuery({ - queryKey: ['monitor-accounts', accountPageSize], - queryFn: () => listAllAccounts(''), + const statsQuery = useQuery({ + queryKey: ['monitor-stats'], + queryFn: getDashboardStats, 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({ queryKey: ['monitor-trend', rangeKey, range.start_date, range.end_date, range.granularity], queryFn: () => getDashboardTrend(range), enabled: hasAccount, + staleTime: 60_000, + placeholderData: (previousData) => previousData, }); const modelsQuery = useQuery({ queryKey: ['monitor-models', rangeKey, range.start_date, range.end_date], queryFn: () => getDashboardModels(range), enabled: hasAccount, + staleTime: 60_000, + placeholderData: (previousData) => previousData, }); function refetchAll() { @@ -381,22 +395,11 @@ export default function MonitorScreen() { formatValue={formatCompactNumber} /> -
+
{latestTrendPoints.length === 0 ? ( 当前时间范围没有趋势数据。 ) : ( - {throughputPoints.length > 1 ? ( - - ) : null} - {latestTrendPoints.map((point) => ( diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 781b522..0a7f15d 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -109,6 +109,7 @@ export default function SettingsScreen() { const [connectionState, setConnectionState] = useState('idle'); const [connectionMessage, setConnectionMessage] = useState(''); const [isRefreshing, setIsRefreshing] = useState(false); + const [showAdminKey, setShowAdminKey] = useState(false); const { control, handleSubmit, formState, reset } = useForm({ resolver: zodResolver(schema), defaultValues: { @@ -227,15 +228,32 @@ export default function SettingsScreen() { control={control} name="adminApiKey" render={({ field: { onChange, value } }) => ( - + + + setShowAdminKey((value) => !value)} + style={{ backgroundColor: colors.border, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10 }} + > + {showAdminKey ? '隐藏' : '显示'} + + )} /> diff --git a/app/(tabs)/users.tsx b/app/(tabs)/users.tsx index 372025a..9f41b60 100644 --- a/app/(tabs)/users.tsx +++ b/app/(tabs)/users.tsx @@ -130,7 +130,7 @@ function UserCard({ user, usage }: { user: AdminUser; usage?: UsageStats }) { {user.email} 最近使用 {formatActivityTime(user.last_used_at || user.updated_at || user.created_at)} - + {statusLabel} @@ -187,9 +187,24 @@ export default function UsersScreen() { return ( - - 用户 - 查看用户列表并进入详情页管理账号。 + + + 用户 + 查看用户列表并进入详情页管理账号。 + + router.push('/users/create-user')} + style={{ + width: 40, + height: 40, + borderRadius: 12, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + }} + > + + + diff --git a/app/_layout.tsx b/app/_layout.tsx index a13d097..1530d1c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -50,6 +50,45 @@ export default function RootLayout() { headerShadowVisible: false, }} /> + + + )} diff --git a/app/accounts/create.tsx b/app/accounts/create.tsx new file mode 100644 index 0000000..d7d05a5 --- /dev/null +++ b/app/accounts/create.tsx @@ -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; + +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); + 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('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(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 ( + <> + + + + + 基础信息 + + 账号名称 + + + 平台 + + {PLATFORM_OPTIONS.map((item) => { + const active = platform === item; + return ( + setPlatform(item)} + style={{ + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + borderColor: active ? colors.primary : colors.border, + backgroundColor: active ? colors.primary : colors.muted, + }} + > + {item} + + ); + })} + + + 类型 + + {ACCOUNT_TYPE_OPTIONS.map((item) => { + const active = type === item; + return ( + setType(item)} + style={{ + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + borderColor: active ? colors.primary : colors.border, + backgroundColor: active ? colors.primary : colors.muted, + }} + > + {item} + + ); + })} + + + 备注(可选) + + + + + 请求体字段 + + 该页面直接创建 /admin/accounts,credentials 必填,extra 可选。 + + + credentials(JSON 对象) + + + extra(可选,JSON 对象) + + + + + 可选参数 + + proxy_id + + + concurrency + + + priority + + + rate_multiplier + + + group_ids(逗号分隔) + + + + {formError ? ( + + {formError} + + ) : null} + + { + setFormError(null); + createMutation.mutate(); + }} + disabled={!canSubmit || createMutation.isPending} + style={{ + backgroundColor: !canSubmit || createMutation.isPending ? '#8a8072' : colors.dark, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + }} + > + {createMutation.isPending ? '提交中...' : '创建账号'} + + + + + ); +} diff --git a/app/login.tsx b/app/login.tsx index 81beb2a..cec3612 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -66,6 +66,7 @@ export default function LoginScreen() { }); const [connectionState, setConnectionState] = useState('idle'); const [connectionMessage, setConnectionMessage] = useState(''); + const [showAdminKey, setShowAdminKey] = useState(false); if (hasAccount) { return ; @@ -114,21 +115,38 @@ export default function LoginScreen() { control={control} name="adminApiKey" render={({ field: { onChange, value } }) => ( - { - if (connectionState !== 'idle') { - setConnectionState('idle'); - setConnectionMessage(''); - } - onChange(text); - }} - placeholder="admin-xxxxxxxx" - placeholderTextColor="#9b9081" - autoCapitalize="none" - autoCorrect={false} - style={{ backgroundColor: colors.mutedCard, borderRadius: 16, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: colors.text }} - /> + + { + if (connectionState !== 'idle') { + setConnectionState('idle'); + setConnectionMessage(''); + } + onChange(text); + }} + placeholder="admin-xxxxxxxx" + placeholderTextColor="#9b9081" + autoCapitalize="none" + autoCorrect={false} + secureTextEntry={!showAdminKey} + style={{ + flex: 1, + backgroundColor: colors.mutedCard, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + color: colors.text, + }} + /> + setShowAdminKey((value) => !value)} + style={{ backgroundColor: colors.border, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10 }} + > + {showAdminKey ? '隐藏' : '显示'} + + )} /> diff --git a/app/users/[id].tsx b/app/users/[id].tsx index 5e1dc42..f1d2b80 100644 --- a/app/users/[id].tsx +++ b/app/users/[id].tsx @@ -2,11 +2,11 @@ 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 { Alert, 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 { getDashboardSnapshot, getUsageStats, getUser, listUserApiKeys, updateUserBalance, updateUserStatus } from '@/src/services/admin'; import type { AdminApiKey, BalanceOperation } from '@/src/types/admin'; 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 limit = Number(quota ?? 0); @@ -92,7 +92,7 @@ function formatQuota(quotaUsed?: number | null, quota?: number | null) { return '∞'; } - return `${used} / ${limit}`; + return `${used}`; } function formatTime(value?: string | null) { @@ -168,8 +168,8 @@ function MetricCard({ label, value }: { label: string; value: string }) { 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'; + const backgroundColor = normalized === 'active' ? '#dff4ea' : normalized === 'inactive' || normalized === 'disabled' ? '#ece5da' : '#f7e1d6'; + const color = normalized === 'active' ? '#17663f' : normalized === 'inactive' || normalized === 'disabled' ? '#6f665c' : '#a4512b'; return ( @@ -223,7 +223,7 @@ function KeyItem({ item, copied, onCopy }: { item: AdminApiKey; copied: boolean; 已用额度 - {formatQuota(item.quota_used, item.quota)} + {formatQuotaUsage(item.quota_used, item.quota)} 最后使用时间 @@ -243,6 +243,7 @@ export default function UserDetailScreen() { const [amount, setAmount] = useState('10'); const [notes, setNotes] = useState(''); const [formError, setFormError] = useState(null); + const [statusError, setStatusError] = useState(null); const [searchText, setSearchText] = useState(''); const [copiedKeyId, setCopiedKeyId] = useState(null); const [rangeKey, setRangeKey] = useState('7d'); @@ -300,6 +301,16 @@ export default function UserDetailScreen() { 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 apiKeys = apiKeysQuery.data?.items ?? []; @@ -344,6 +355,24 @@ export default function UserDetailScreen() { }, 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 ( <> @@ -392,6 +421,36 @@ export default function UserDetailScreen() { {formatTime(user.last_used_at || user.updated_at || user.created_at)} + + + + 用户状态 + + + + + {statusMutation.isPending ? '处理中...' : user.status === 'disabled' ? '启用用户' : '禁用用户'} + + + + + {user.role?.toLowerCase() === 'admin' ? 管理员用户不支持禁用。 : null} + + {statusError ? ( + + {statusError} + + ) : null}
) : null} diff --git a/app/users/create-account.tsx b/app/users/create-account.tsx new file mode 100644 index 0000000..79f3420 --- /dev/null +++ b/app/users/create-account.tsx @@ -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 ( + + {title} + {children} + + ); +} + +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; + const parsed = JSON.parse(raw) as Record; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const entries = Object.entries(parsed as Record); + 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('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(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 ( + <> + + + +
+ 账号名称 + + + 平台 + + {PLATFORM_OPTIONS.map((item) => { + const active = platform === item; + return ( + setPlatform(item)} + style={{ + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + borderColor: active ? colors.primary : colors.border, + backgroundColor: active ? colors.primary : colors.muted, + }} + > + {item} + + ); + })} + + + 账号类型 + + {(['apikey', 'oauth'] as const).map((item) => { + const active = accountType === item; + return ( + 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, + }} + > + {item.toUpperCase()} + + ); + })} + + + 备注(可选) + +
+ +
+ {accountType === 'apikey' ? ( + <> + Base URL + + + API Key + + + ) : ( + <> + Access Token + + + Refresh Token(可选) + + + Client ID(可选) + + + )} + + 额外凭证 JSON(可选) + +
+ +
+ 并发 concurrency + + + 优先级 priority + + + 倍率 rate_multiplier + + + 代理 ID proxy_id + + + 分组 IDs(逗号分隔) + +
+ + {formError ? ( + + {formError} + + ) : null} + + { + setFormError(null); + createMutation.mutate(); + }} + disabled={!canSubmit || createMutation.isPending} + style={{ + backgroundColor: !canSubmit || createMutation.isPending ? '#8a8072' : colors.dark, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + }} + > + {createMutation.isPending ? '提交中...' : '创建账号'} + +
+
+ + ); +} diff --git a/app/users/create-user.tsx b/app/users/create-user.tsx new file mode 100644 index 0000000..d09a084 --- /dev/null +++ b/app/users/create-user.tsx @@ -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 { + 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); + + 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; +} + +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(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 ( + <> + + + + + 基础信息 + + 邮箱 + + + 密码 + + + 用户名(可选) + + + 备注(可选) + + + + + 权限与状态 + + 角色 + + {(['user', 'admin'] as const).map((item) => { + const active = role === item; + return ( + 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, + }} + > + {item} + + ); + })} + + + 状态 + + {(['active', 'disabled'] as const).map((item) => { + const active = status === item; + return ( + 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, + }} + > + {item} + + ); + })} + + + + + 高级参数(可选) + + 余额 balance + + + 并发 concurrency + + + extra(可选,JSON 对象) + + + + {formError ? ( + + {formError} + + ) : null} + + { + setFormError(null); + createMutation.mutate(); + }} + disabled={!canSubmit || createMutation.isPending} + style={{ + backgroundColor: !canSubmit || createMutation.isPending ? '#8a8072' : colors.dark, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + }} + > + {createMutation.isPending ? '提交中...' : '创建用户'} + + + + + ); +} diff --git a/src/lib/admin-fetch.ts b/src/lib/admin-fetch.ts index 7e2ed0b..c6547e7 100644 --- a/src/lib/admin-fetch.ts +++ b/src/lib/admin-fetch.ts @@ -1,6 +1,21 @@ import { adminConfigState } from '@/src/store/admin-config'; 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( path: string, init: RequestInit = {}, @@ -27,15 +42,16 @@ export async function adminFetch( headers.set('Idempotency-Key', options.idempotencyKey); } - const response = await fetch(`${baseUrl}${path}`, { + const response = await fetch(buildRequestUrl(baseUrl, path), { ...init, headers, }); let json: ApiEnvelope; + const rawText = await response.text(); try { - json = (await response.json()) as ApiEnvelope; + json = JSON.parse(rawText) as ApiEnvelope; } catch { throw new Error('INVALID_SERVER_RESPONSE'); } diff --git a/src/services/admin.ts b/src/services/admin.ts index 3255257..8438858 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -11,6 +11,8 @@ import type { DashboardSnapshot, DashboardStats, DashboardTrend, + CreateAccountRequest, + CreateUserRequest, PaginatedData, UsageStats, UserUsageSummary, @@ -95,6 +97,13 @@ export function getUser(userId: number) { return adminFetch(`/api/v1/admin/users/${userId}`); } +export function createUser(body: CreateUserRequest) { + return adminFetch('/api/v1/admin/users', { + method: 'POST', + body: JSON.stringify(body), + }); +} + export function getUserUsage(userId: number, period: 'day' | 'week' | 'month' = 'month') { return adminFetch(`/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(`/api/v1/admin/users/${userId}`, { + method: 'PUT', + body: JSON.stringify({ status }), + }); +} + export function listGroups(search = '') { return adminFetch>( `/api/v1/admin/groups${buildQuery({ page: 1, page_size: 20, search: search.trim() })}` @@ -139,6 +155,13 @@ export function getAccount(accountId: number) { return adminFetch(`/api/v1/admin/accounts/${accountId}`); } +export function createAccount(body: CreateAccountRequest) { + return adminFetch('/api/v1/admin/accounts', { + method: 'POST', + body: JSON.stringify(body), + }); +} + export function getAccountTodayStats(accountId: number) { return adminFetch(`/api/v1/admin/accounts/${accountId}/today-stats`); } diff --git a/src/types/admin.ts b/src/types/admin.ts index ae2d703..618682f 100644 --- a/src/types/admin.ts +++ b/src/types/admin.ts @@ -197,3 +197,31 @@ export type AdminAccount = { groups?: AdminGroup[]; extra?: Record; }; + +export type AccountType = 'apikey' | 'oauth' | 'setup-token' | 'upstream'; + +export type CreateAccountRequest = { + name: string; + platform: string; + type: AccountType; + credentials: Record; + extra?: Record; + 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; +};