diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index bee897d..c5f1318 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -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); } diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 078efbf..722a3a7 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -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 = ({ 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 = ({ 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 = ({ onBack }) => { return ( = ({ onBack }) => { /> )} keyExtractor={item => item.id} - contentContainerStyle={{ paddingBottom: responsivePadding }} + contentContainerStyle={{ paddingBottom: listBottomInset }} showsVerticalScrollIndicator={false} refreshControl={ = ({ onBack }) => { return ( = ({ onBack }) => { )} keyExtractor={item => item.id} - contentContainerStyle={{ paddingVertical: responsiveGap }} + contentContainerStyle={{ paddingVertical: responsiveGap, paddingBottom: listBottomInset }} showsVerticalScrollIndicator={false} ListFooterComponent={userLoading ? : null} /> diff --git a/src/services/notification/jpushService.ts b/src/services/notification/jpushService.ts index 3413d60..a42b7d8 100644 --- a/src/services/notification/jpushService.ts +++ b/src/services/notification/jpushService.ts @@ -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); } diff --git a/src/services/notification/systemNotificationService.ts b/src/services/notification/systemNotificationService.ts index 1b6dfc6..bf79f37 100644 --- a/src/services/notification/systemNotificationService.ts +++ b/src/services/notification/systemNotificationService.ts @@ -208,9 +208,6 @@ class SystemNotificationService { async clearAllNotifications(): Promise { jpushService.clearAllNotifications(); - if (Platform.OS === 'web' && 'Notification' in window) { - // No programmatic way to clear browser notifications - } } async clearBadge(): Promise { diff --git a/src/stores/auth/authStore.ts b/src/stores/auth/authStore.ts index 70f4041..b6391c3 100644 --- a/src/stores/auth/authStore.ts +++ b/src/stores/auth/authStore.ts @@ -62,32 +62,61 @@ async function cacheUser(user: User): Promise { } // ── 将 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; }