feat(auth): enhance login error handling with detailed error code mapping
- Add backend error_code support (INVALID_CREDENTIALS, USER_BANNED, etc.) - Improve network error detection with code=0 fallback - Add granular HTTP status code handling (400, 401, 403, 429, 5xx) - Update LoginScreen to display authStore error or generic message - Include notification service improvements: - Keep JPush connection alive in background via setKeepLongConnInBackground - Add manual sound/vibration for local notifications via preferences - Fix SearchScreen layout by accounting for floating tab bar and safe area
This commit is contained in:
@@ -107,12 +107,11 @@ export const LoginScreen: React.FC = () => {
|
||||
try {
|
||||
const success = await login({ username, password });
|
||||
if (!success) {
|
||||
if (!useAuthStore.getState().error) {
|
||||
showPrompt({ title: '登录失败', message: '请检查用户名和密码', type: 'error' });
|
||||
}
|
||||
const errorMsg = useAuthStore.getState().error || '登录失败,请稍后重试';
|
||||
showPrompt({ title: '登录失败', message: errorMsg, type: 'error' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '登录失败', message: error.message || '请检查用户名和密码', type: 'error' });
|
||||
showPrompt({ title: '登录失败', message: error.message || '登录失败,请稍后重试', type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
@@ -30,6 +30,8 @@ import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
|
||||
const TABS = ['帖子', '用户'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
// 与 TabsLayout 中的常量保持一致:tabBarHeight 56 + 上下浮动间距 12*2
|
||||
const FLOATING_TAB_BAR_HEIGHT = 56 + 12 * 2;
|
||||
|
||||
type SearchType = 'posts' | 'users';
|
||||
|
||||
@@ -41,6 +43,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchScreenStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
||||
|
||||
// 使用响应式 hook
|
||||
@@ -73,6 +76,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
'4xl': 1200,
|
||||
});
|
||||
|
||||
// 底部预留空间:悬浮 TabBar 高度 + 底部安全区 + 间距
|
||||
const listBottomInset = FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
@@ -238,7 +244,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
return (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||
contentContainerStyle={{ paddingBottom: listBottomInset }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
@@ -287,7 +293,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||
contentContainerStyle={{ paddingBottom: listBottomInset }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -323,7 +329,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
return (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||
contentContainerStyle={{ paddingBottom: listBottomInset }}
|
||||
>
|
||||
<ResponsiveGrid
|
||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
||||
@@ -412,7 +418,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={{ paddingVertical: responsiveGap }}
|
||||
contentContainerStyle={{ paddingVertical: responsiveGap, paddingBottom: listBottomInset }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListFooterComponent={userLoading ? <Loading size="sm" /> : null}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform, NativeModules, AppState } from 'react-native';
|
||||
import { Platform, NativeModules, AppState, Vibration } from 'react-native';
|
||||
import { pushService } from './pushService';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { getNotificationPreferencesSync } from './notificationPreferences';
|
||||
@@ -112,6 +112,9 @@ class JPushService {
|
||||
// Register listeners only once (idempotent guard)
|
||||
this._registerListeners();
|
||||
|
||||
// 退后台保持极光长连接,确保推送实时到达
|
||||
this._setBackgroundKeepLongConn(true);
|
||||
|
||||
// 初始化 SDK
|
||||
JPush!.init({
|
||||
appKey: jpushAppKey,
|
||||
@@ -165,12 +168,12 @@ class JPushService {
|
||||
console.warn('[JPush] setChannelAndSound failed:', error);
|
||||
}
|
||||
|
||||
// Ensure notification channel has sound & vibration enabled via expo-notifications
|
||||
try {
|
||||
Notifications.setNotificationChannelAsync('jpush_notification', {
|
||||
name: '推送通知',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
sound: 'default',
|
||||
enableLights: true,
|
||||
showBadge: true,
|
||||
});
|
||||
@@ -179,6 +182,30 @@ class JPushService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置退后台是否保持极光长连接
|
||||
* @see https://docs.jiguang.cn/jpush/client/Android/android_api#退后台是否保持极光长连接-api
|
||||
*/
|
||||
private _setBackgroundKeepLongConn(keep: boolean): void {
|
||||
if (!JPush) return;
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
const jpushModule = NativeModules.JPushModule;
|
||||
if (jpushModule?.setKeepLongConnInBackground) {
|
||||
jpushModule.setKeepLongConnInBackground(keep);
|
||||
console.log('[JPush] setKeepLongConnInBackground:', keep);
|
||||
} else {
|
||||
console.warn('[JPush] setKeepLongConnInBackground not available in native module');
|
||||
}
|
||||
} else if (Platform.OS === 'ios') {
|
||||
JPush.setBackgroundEnable(keep);
|
||||
console.log('[JPush] setBackgroundEnable:', keep);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[JPush] setBackgroundKeepLongConn failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private _fetchRegistrationID(): void {
|
||||
if (!JPush) return;
|
||||
JPush.getRegistrationID((result: any) => {
|
||||
@@ -329,6 +356,21 @@ class JPushService {
|
||||
} else {
|
||||
JPush.addLocalNotification(params);
|
||||
}
|
||||
|
||||
// JPush local notification does not support sound/vibration directly.
|
||||
// Trigger them manually based on user preferences.
|
||||
const prefs = getNotificationPreferencesSync();
|
||||
if (prefs.soundEnabled) {
|
||||
// Play system default notification sound via a silent expo-notification
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: { title: '', body: '', sound: true, vibrate: [] },
|
||||
trigger: null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (prefs.vibrationEnabled) {
|
||||
// Use React Native Vibration API for device-level vibration
|
||||
Vibration.vibrate([0, 250, 250, 250]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[JPush] addLocalNotification failed:', error);
|
||||
}
|
||||
|
||||
@@ -208,9 +208,6 @@ class SystemNotificationService {
|
||||
|
||||
async clearAllNotifications(): Promise<void> {
|
||||
jpushService.clearAllNotifications();
|
||||
if (Platform.OS === 'web' && 'Notification' in window) {
|
||||
// No programmatic way to clear browser notifications
|
||||
}
|
||||
}
|
||||
|
||||
async clearBadge(): Promise<void> {
|
||||
|
||||
@@ -62,32 +62,61 @@ async function cacheUser(user: User): Promise<void> {
|
||||
}
|
||||
|
||||
// ── 将 API 错误转换为用户可读的中文提示 ──
|
||||
// 优先使用后端返回的 error_code 精确匹配,兜底使用 HTTP 状态码和 message
|
||||
function resolveLoginError(error: any): string {
|
||||
const code: number = error?.code ?? 0;
|
||||
const msg: string = error?.message ?? '';
|
||||
const msg: string = String(error?.message ?? '');
|
||||
const errorCode: string = String(error?.errorCode ?? '').toUpperCase();
|
||||
|
||||
// 网络层错误(无法连接服务器)
|
||||
// 网络层错误(无法连接服务器)—— code 为 0 通常是请求未发出
|
||||
if (
|
||||
code === 0 ||
|
||||
msg.includes('Network request failed') ||
|
||||
msg.includes('网络请求失败') ||
|
||||
msg.includes('Failed to fetch') ||
|
||||
code === 500
|
||||
msg.includes('Failed to fetch')
|
||||
) {
|
||||
return '无法连接到服务器,请检查网络连接后重试';
|
||||
}
|
||||
|
||||
// 账号或密码错误
|
||||
if (code === 401 || msg.includes('密码') || msg.includes('用户名') || msg.includes('incorrect')) {
|
||||
// 优先使用后端 error_code 精确匹配
|
||||
switch (errorCode) {
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return '用户名或密码错误,请重新输入';
|
||||
case 'USER_BANNED':
|
||||
return '账号已被封禁,请联系管理员';
|
||||
case 'USER_BLOCKED':
|
||||
return '该账号存在异常,暂时无法登录';
|
||||
case 'USER_NOT_FOUND':
|
||||
return '用户不存在,请检查输入';
|
||||
case 'BAD_REQUEST':
|
||||
return '请求参数有误,请检查输入';
|
||||
case 'UNAUTHORIZED':
|
||||
return '登录已过期,请重新登录';
|
||||
case 'FORBIDDEN':
|
||||
return '没有访问权限,请联系管理员';
|
||||
case 'INTERNAL_ERROR':
|
||||
return '服务器内部错误,请稍后重试';
|
||||
case 'TOO_MANY_REQUESTS':
|
||||
case 'RATE_LIMITED':
|
||||
return '登录尝试过于频繁,请稍后再试';
|
||||
}
|
||||
|
||||
// 兜底:按 HTTP 状态码分类
|
||||
if (code === 401 || code === 400) {
|
||||
return '用户名或密码错误,请重新输入';
|
||||
}
|
||||
|
||||
// 账号被禁用/封禁
|
||||
if (code === 403 || msg.includes('禁止') || msg.includes('封禁') || msg.includes('forbidden')) {
|
||||
if (code === 403) {
|
||||
return '账号已被禁用,请联系管理员';
|
||||
}
|
||||
if (code === 429) {
|
||||
return '登录尝试过于频繁,请稍后再试';
|
||||
}
|
||||
if (code >= 500) {
|
||||
return '服务器开小差了,请稍后重试';
|
||||
}
|
||||
|
||||
// 服务端明确返回了 message,直接用
|
||||
if (msg && msg !== '登录失败') {
|
||||
// 最后兜底:如果后端返回了有意义的 message,直接用
|
||||
if (msg && msg !== '登录失败' && msg !== '请求失败') {
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user