Files
sub2api-mobile/app/(tabs)/settings.tsx
2026-03-08 20:53:15 +08:00

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { router } from 'expo-router';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { Pressable, RefreshControl, ScrollView, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useState } from 'react';
import { z } from 'zod';
import { getAdminSettings, getDashboardStats } from '@/src/services/admin';
import { queryClient } from '@/src/lib/query-client';
import { adminConfigState, removeAdminAccount, saveAdminConfig, switchAdminAccount, type AdminAccountProfile } from '@/src/store/admin-config';
const { useSnapshot } = require('valtio/react');
const schema = z
.object({
baseUrl: z.string().min(1, '请输入服务器地址'),
adminApiKey: z.string(),
})
.refine((values) => values.adminApiKey.trim().length > 0, {
path: ['adminApiKey'],
message: '请输入 Admin Key',
});
type FormValues = z.infer<typeof schema>;
type ConnectionState = 'idle' | 'checking' | 'success' | 'error';
const colors = {
page: '#f4efe4',
card: '#fbf8f2',
mutedCard: '#f1ece2',
primary: '#1d5f55',
text: '#16181a',
subtext: '#6f665c',
border: '#e7dfcf',
dangerBg: '#fbf1eb',
danger: '#c25d35',
successBg: '#e6f4ee',
success: '#1d5f55',
};
function getConnectionErrorMessage(error: unknown) {
if (error instanceof Error && error.message) {
switch (error.message) {
case 'BASE_URL_REQUIRED':
return '请先填写服务器地址。';
case 'ADMIN_API_KEY_REQUIRED':
return '请先填写 Admin Key。';
case 'INVALID_SERVER_RESPONSE':
return '当前地址返回的数据不正确,请确认它是可用的管理接口。';
default:
return error.message;
}
}
return '连接失败请检查服务器地址、Admin Key 和网络连通性。';
}
function ServerCard({
account,
active,
onSelect,
onDelete,
}: {
account: AdminAccountProfile;
active: boolean;
onSelect: () => Promise<void>;
onDelete: () => Promise<void>;
}) {
return (
<Pressable
onPress={onSelect}
style={{
backgroundColor: active ? '#e6f4ee' : colors.card,
borderRadius: 18,
padding: 16,
borderWidth: 1,
borderColor: active ? colors.success : colors.border,
}}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: '700', color: colors.text }}>{account.label}</Text>
<Text style={{ marginTop: 6, fontSize: 13, lineHeight: 20, color: colors.subtext }}>{account.baseUrl}</Text>
<Text style={{ marginTop: 8, fontSize: 11, color: '#8a8072' }}> {new Date(account.updatedAt).toLocaleString()}</Text>
</View>
{active ? (
<View style={{ backgroundColor: colors.success, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }}>
<Text style={{ color: '#fff', fontSize: 11, fontWeight: '700' }}>使</Text>
</View>
) : null}
</View>
<View style={{ flexDirection: 'row', gap: 10, marginTop: 14 }}>
<Pressable onPress={onSelect} style={{ flex: 1, backgroundColor: active ? '#d7eee4' : colors.primary, borderRadius: 14, paddingVertical: 11, alignItems: 'center' }}>
<Text style={{ color: active ? colors.success : '#fff', fontSize: 13, fontWeight: '700' }}>{active ? '已选中' : '切换到此服务器'}</Text>
</Pressable>
<Pressable onPress={onDelete} style={{ backgroundColor: colors.border, borderRadius: 14, paddingHorizontal: 16, justifyContent: 'center' }}>
<Text style={{ color: '#7a3d31', fontSize: 13, fontWeight: '700' }}></Text>
</Pressable>
</View>
</Pressable>
);
}
export default function SettingsScreen() {
const config = useSnapshot(adminConfigState);
const [showForm, setShowForm] = useState(config.accounts.length === 0);
const [connectionState, setConnectionState] = useState<ConnectionState>('idle');
const [connectionMessage, setConnectionMessage] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const { control, handleSubmit, formState, reset } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
baseUrl: '',
adminApiKey: '',
},
});
async function verifyAndEnter(successMessage: string) {
setConnectionState('checking');
setConnectionMessage('正在检测当前服务是否可用...');
try {
queryClient.clear();
await queryClient.fetchQuery({ queryKey: ['admin-settings'], queryFn: getAdminSettings });
await queryClient.prefetchQuery({ queryKey: ['monitor-stats'], queryFn: getDashboardStats });
setConnectionState('success');
setConnectionMessage(successMessage);
router.replace('/monitor');
} catch (error) {
setConnectionState('error');
setConnectionMessage(getConnectionErrorMessage(error));
}
}
async function handleAdd(values: FormValues) {
await saveAdminConfig(values);
reset({ baseUrl: '', adminApiKey: '' });
setShowForm(false);
await verifyAndEnter('服务器已添加并切换成功。');
}
async function handleSelect(account: AdminAccountProfile) {
await switchAdminAccount(account.id);
await verifyAndEnter(`已切换到 ${account.label}`);
}
async function handleDelete(account: AdminAccountProfile) {
await removeAdminAccount(account.id);
queryClient.clear();
}
async function handleRefresh() {
if (!config.baseUrl.trim()) {
return;
}
setIsRefreshing(true);
setConnectionState('idle');
setConnectionMessage('');
try {
await Promise.all([
queryClient.fetchQuery({ queryKey: ['admin-settings'], queryFn: getAdminSettings }),
queryClient.prefetchQuery({ queryKey: ['monitor-stats'], queryFn: getDashboardStats }),
]);
} catch (error) {
setConnectionState('error');
setConnectionMessage(getConnectionErrorMessage(error));
} finally {
setIsRefreshing(false);
}
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.page }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 110, gap: 14 }}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={() => void handleRefresh()} tintColor="#1d5f55" />}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 28, fontWeight: '700', color: colors.text }}></Text>
<Text style={{ marginTop: 6, fontSize: 13, color: '#8a8072' }}></Text>
</View>
<Pressable
onPress={() => {
setShowForm((value) => !value);
setConnectionState('idle');
setConnectionMessage('');
}}
style={{ backgroundColor: colors.primary, borderRadius: 999, width: 42, height: 42, alignItems: 'center', justifyContent: 'center' }}
>
<Text style={{ color: '#fff', fontSize: 24, lineHeight: 24 }}>+</Text>
</Pressable>
</View>
{showForm ? (
<View style={{ backgroundColor: colors.card, borderRadius: 18, padding: 16, gap: 14 }}>
<Text style={{ fontSize: 18, fontWeight: '700', color: colors.text }}></Text>
<View>
<Text style={{ marginBottom: 8, fontSize: 12, color: colors.subtext }}></Text>
<Controller
control={control}
name="baseUrl"
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
placeholder="例如https://api.example.com"
placeholderTextColor="#9b9081"
autoCapitalize="none"
autoCorrect={false}
style={{ backgroundColor: colors.mutedCard, borderRadius: 16, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: colors.text }}
/>
)}
/>
</View>
<View>
<Text style={{ marginBottom: 8, fontSize: 12, color: colors.subtext }}>Admin Key</Text>
<Controller
control={control}
name="adminApiKey"
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
placeholder="admin-xxxxxxxx"
placeholderTextColor="#9b9081"
autoCapitalize="none"
autoCorrect={false}
style={{ backgroundColor: colors.mutedCard, borderRadius: 16, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: colors.text }}
/>
)}
/>
</View>
{formState.errors.baseUrl || formState.errors.adminApiKey ? (
<View style={{ borderRadius: 14, backgroundColor: colors.dangerBg, paddingHorizontal: 14, paddingVertical: 12 }}>
<Text style={{ color: colors.danger, fontSize: 14 }}>{formState.errors.baseUrl?.message || formState.errors.adminApiKey?.message}</Text>
</View>
) : null}
{connectionMessage ? (
<View style={{ borderRadius: 14, backgroundColor: connectionState === 'success' ? colors.successBg : colors.dangerBg, paddingHorizontal: 14, paddingVertical: 12 }}>
<Text style={{ color: connectionState === 'success' ? colors.success : colors.danger, fontSize: 14 }}>{connectionMessage}</Text>
</View>
) : null}
<View style={{ flexDirection: 'row', gap: 12 }}>
<Pressable
onPress={handleSubmit(handleAdd)}
disabled={connectionState === 'checking'}
style={{ flex: 1, backgroundColor: connectionState === 'checking' ? '#7ca89f' : colors.primary, borderRadius: 16, paddingVertical: 14, alignItems: 'center' }}
>
<Text style={{ color: '#fff', fontSize: 14, fontWeight: '700' }}>{connectionState === 'checking' ? '检测中...' : '保存并使用'}</Text>
</Pressable>
<Pressable
onPress={() => {
setShowForm(false);
setConnectionState('idle');
setConnectionMessage('');
reset({ baseUrl: '', adminApiKey: '' });
}}
style={{ flex: 1, backgroundColor: colors.border, borderRadius: 16, paddingVertical: 14, alignItems: 'center' }}
>
<Text style={{ color: '#4e463e', fontSize: 14, fontWeight: '700' }}></Text>
</Pressable>
</View>
</View>
) : null}
<View style={{ gap: 12 }}>
{config.accounts.map((account: AdminAccountProfile) => (
<ServerCard
key={account.id}
account={account}
active={account.id === config.activeAccountId}
onSelect={() => handleSelect(account)}
onDelete={() => handleDelete(account)}
/>
))}
{config.accounts.length === 0 ? (
<View style={{ backgroundColor: colors.card, borderRadius: 18, padding: 18 }}>
<Text style={{ fontSize: 15, fontWeight: '700', color: colors.text }}></Text>
<Text style={{ marginTop: 8, fontSize: 13, lineHeight: 21, color: colors.subtext }}> + </Text>
</View>
) : null}
</View>
</ScrollView>
</SafeAreaView>
);
}