feat: add dedicated create-user and create-account admin flows

This commit is contained in:
xuhongbin
2026-03-09 19:06:02 +08:00
parent 58393a6730
commit 293b62b444
13 changed files with 1576 additions and 57 deletions

View File

@@ -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 (
<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={{ flex: 1 }}>
<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 style={{ flex: 1, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 11, color: colors.subtext }}>使</Text>
@@ -243,6 +243,7 @@ export default function UserDetailScreen() {
const [amount, setAmount] = useState('10');
const [notes, setNotes] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [searchText, setSearchText] = useState('');
const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
const [rangeKey, setRangeKey] = useState<RangeKey>('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 (
<>
<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>
</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>
) : null}

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