diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx index 8b7775f..f45fd50 100644 --- a/src/components/business/QRCodeScanner.tsx +++ b/src/components/business/QRCodeScanner.tsx @@ -13,6 +13,7 @@ import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as hrefs from '../../navigation/hrefs'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; +import { blurActiveElement } from '../../infrastructure/platform'; const { width, height } = Dimensions.get('window'); @@ -156,9 +157,8 @@ const QRCodeScannerWeb: React.FC = ({ visible, onClose }) => const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]); useEffect(() => { - if (visible && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (visible) { + blurActiveElement(); } }, [visible]); diff --git a/src/components/business/ReportDialog/ReportDialog.tsx b/src/components/business/ReportDialog/ReportDialog.tsx index bb68cab..7f75478 100644 --- a/src/components/business/ReportDialog/ReportDialog.tsx +++ b/src/components/business/ReportDialog/ReportDialog.tsx @@ -21,6 +21,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useAppColors } from '../../../theme'; import { spacing, borderRadius, fontSizes, shadows } from '../../../theme'; import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService'; +import { blurActiveElement } from '../../../infrastructure/platform'; import Text from '../../common/Text'; export interface ReportDialogProps { @@ -63,9 +64,8 @@ const ReportDialog: React.FC = ({ const targetConfig = TARGET_CONFIG[targetType]; useEffect(() => { - if (visible && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (visible) { + blurActiveElement(); } }, [visible]); diff --git a/src/components/call/IncomingCallModal.tsx b/src/components/call/IncomingCallModal.tsx index d386acc..d9f2d57 100644 --- a/src/components/call/IncomingCallModal.tsx +++ b/src/components/call/IncomingCallModal.tsx @@ -13,6 +13,7 @@ import { } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { callStore } from '../../stores/callStore'; +import { blurActiveElement } from '../../infrastructure/platform'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -26,10 +27,7 @@ const IncomingCallModal: React.FC = () => { useEffect(() => { if (incomingCall) { - if (Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); - } + blurActiveElement(); const pulse = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { diff --git a/src/components/common/AdaptiveLayout.tsx b/src/components/common/AdaptiveLayout.tsx index 2596623..d71dda3 100644 --- a/src/components/common/AdaptiveLayout.tsx +++ b/src/components/common/AdaptiveLayout.tsx @@ -22,6 +22,7 @@ import { useBreakpointGTE, FineBreakpointKey, } from '../../hooks/useResponsive'; +import { blurActiveElement } from '../../infrastructure/platform'; export interface AdaptiveLayoutProps { /** 主内容区 */ @@ -160,9 +161,8 @@ export function AdaptiveLayout({ // 抽屉动画 useEffect(() => { if (isDrawerOpen) { - if (Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (isDrawerOpen) { + blurActiveElement(); } Animated.parallel([ Animated.timing(slideAnim, { diff --git a/src/components/common/AppDialogHost.tsx b/src/components/common/AppDialogHost.tsx index 922b6a2..7e0d1ad 100644 --- a/src/components/common/AppDialogHost.tsx +++ b/src/components/common/AppDialogHost.tsx @@ -6,6 +6,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { bindDialogListener, DialogPayload } from '../../services/dialogService'; import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme'; +import { blurActiveElement } from '../../infrastructure/platform'; function createDialogHostStyles(colors: AppColors) { return StyleSheet.create({ @@ -93,12 +94,6 @@ function createDialogHostStyles(colors: AppColors) { }); } -const blurActiveElementOnWeb = () => { - if (Platform.OS !== 'web') return; - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); -}; - const AppDialogHost: React.FC = () => { const [dialog, setDialog] = useState(null); const colors = useAppColors(); @@ -113,7 +108,7 @@ const AppDialogHost: React.FC = () => { useEffect(() => { if (dialog) { - blurActiveElementOnWeb(); + blurActiveElement(); } }, [dialog]); diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index f66fd87..5bac549 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -33,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as MediaLibrary from 'expo-media-library'; import { File, Paths } from 'expo-file-system'; import { spacing, borderRadius, fontSizes } from '../../theme'; +import { blurActiveElement } from '../../infrastructure/platform'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -127,10 +128,7 @@ export const ImageGallery: React.FC = ({ // 打开/关闭时重置状态 useEffect(() => { if (visible) { - if (Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); - } + blurActiveElement(); setCurrentIndex(initialIndex); setShowControls(true); setError(false); diff --git a/src/components/common/VideoPlayerModal.tsx b/src/components/common/VideoPlayerModal.tsx index d4fd000..c8ccc09 100644 --- a/src/components/common/VideoPlayerModal.tsx +++ b/src/components/common/VideoPlayerModal.tsx @@ -18,6 +18,7 @@ import { VideoView, useVideoPlayer } from 'expo-video'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { spacing, fontSizes, borderRadius } from '../../theme'; +import { blurActiveElement } from '../../infrastructure/platform'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -81,9 +82,8 @@ export const VideoPlayerModal: React.FC = ({ onClose, }) => { useEffect(() => { - if (visible && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (visible) { + blurActiveElement(); } }, [visible]); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0b1fd9b..001741a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -111,3 +111,16 @@ export type { UseDifferentialPostsResult, DiffUpdatesInfo, } from './useDifferentialPosts'; + +// ==================== 平台键盘 Hooks ==================== +export { + usePlatformKeyboard, + useKeyboardAvoidingProps, + useKeyboardListener, +} from './usePlatformKeyboard'; + +export type { + KeyboardEventType, + PlatformKeyboardOptions, + PlatformKeyboardResult, +} from './usePlatformKeyboard'; diff --git a/src/hooks/usePlatformKeyboard.ts b/src/hooks/usePlatformKeyboard.ts new file mode 100644 index 0000000..65bee1a --- /dev/null +++ b/src/hooks/usePlatformKeyboard.ts @@ -0,0 +1,179 @@ +/** + * usePlatformKeyboard - 统一的跨平台键盘行为 Hook + * + * 消除散落在多个文件中的 Platform.OS 键盘事件判断 + * + * 使用前(重复代码): + * ```tsx + * const showSub = Keyboard.addListener( + * Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + * handler + * ); + * ``` + * + * 使用后: + * ```tsx + * const { addListener } = usePlatformKeyboard(); + * const showSub = addListener('show', handler); + * ``` + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { Keyboard, Platform, KeyboardEvent, EmitterSubscription } from 'react-native'; + +export type KeyboardEventType = 'show' | 'hide' | 'change'; + +export interface PlatformKeyboardOptions { + onShow?: (event: KeyboardEvent) => void; + onHide?: (event: KeyboardEvent) => void; + onChange?: (height: number) => void; + enabled?: boolean; +} + +export interface PlatformKeyboardResult { + /** 键盘高度 */ + keyboardHeight: number; + /** 键盘是否可见 */ + isKeyboardVisible: boolean; + /** 平台最佳的 KeyboardAvoidingView behavior */ + keyboardBehavior: 'padding' | 'height' | undefined; + /** 平台最佳的 keyboardVerticalOffset */ + keyboardVerticalOffset: number; +} + +/** + * 获取平台特定的键盘事件名称 + */ +function getKeyboardEventName(type: KeyboardEventType): string { + switch (type) { + case 'show': + return Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + case 'hide': + return Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + case 'change': + return Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow'; + } +} + +/** + * 平台键盘 Hook + * + * 自动处理跨平台键盘事件差异 + */ +export function usePlatformKeyboard( + options: PlatformKeyboardOptions = {} +): PlatformKeyboardResult { + const { onShow, onHide, onChange, enabled = true } = options; + + useEffect(() => { + if (!enabled) return; + + const subscriptions: EmitterSubscription[] = []; + + if (onShow) { + subscriptions.push( + Keyboard.addListener( + getKeyboardEventName('show') as any, + onShow + ) + ); + } + + if (onHide) { + subscriptions.push( + Keyboard.addListener( + getKeyboardEventName('hide') as any, + onHide + ) + ); + } + + if (onChange) { + const handleChange = (e: KeyboardEvent) => { + onChange(e.endCoordinates.height); + }; + subscriptions.push( + Keyboard.addListener( + getKeyboardEventName('show') as any, + handleChange + ) + ); + subscriptions.push( + Keyboard.addListener( + getKeyboardEventName('hide') as any, + () => onChange(0) + ) + ); + } + + return () => { + subscriptions.forEach(sub => sub.remove()); + }; + }, [enabled, onShow, onHide, onChange]); + + return { + keyboardHeight: 0, + isKeyboardVisible: false, + keyboardBehavior: Platform.OS === 'ios' ? 'padding' : undefined, + keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 0, + }; +} + +/** + * 获取平台最佳的 KeyboardAvoidingView props + * + * 用于替代重复的 Platform.OS 三元判断: + * ```tsx + * // 之前 + * behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + * + * // 之后 + * const { keyboardProps } = useKeyboardAvoidingProps(); + * + * ``` + */ +export function useKeyboardAvoidingProps( + options: { offset?: number; defaultBehavior?: 'padding' | 'height' } = {} +): { + keyboardProps: { + behavior: 'padding' | 'height' | undefined; + keyboardVerticalOffset: number; + }; +} { + const { offset = 0, defaultBehavior } = options; + + return { + keyboardProps: { + behavior: defaultBehavior ?? (Platform.OS === 'ios' ? 'padding' : undefined), + keyboardVerticalOffset: Platform.OS === 'ios' ? offset : 0, + }, + }; +} + +/** + * 键盘事件监听 Hook(简化版) + * + * 返回一个平台无关的 addListener 函数 + */ +export function useKeyboardListener() { + const subscriptionsRef = useRef([]); + + useEffect(() => { + return () => { + subscriptionsRef.current.forEach(sub => sub.remove()); + subscriptionsRef.current = []; + }; + }, []); + + const addListener = useCallback( + (type: KeyboardEventType, handler: (event: KeyboardEvent) => void) => { + const eventName = getKeyboardEventName(type); + const sub = Keyboard.addListener(eventName as any, handler); + subscriptionsRef.current.push(sub); + return sub; + }, + [] + ); + + return { addListener }; +} \ No newline at end of file diff --git a/src/infrastructure/platform/domUtils.ts b/src/infrastructure/platform/domUtils.ts new file mode 100644 index 0000000..05e30bb --- /dev/null +++ b/src/infrastructure/platform/domUtils.ts @@ -0,0 +1,83 @@ +/** + * Web Platform DOM Utilities + * + * 提供Web平台特定的DOM操作工具函数 + * 解决跨平台代码中需要访问Web DOM的补丁问题 + */ + +import { Platform, Keyboard } from 'react-native'; + +/** + * Blur当前激活的DOM元素(Web)并收起键盘(移动端) + * + * - Web: blur DOM active element + * - iOS/Android: Keyboard.dismiss() + * + * 替代散落在多个文件中的重复代码 + */ +export function blurActiveElement(): void { + if (Platform.OS === 'web') { + try { + const activeElement = (globalThis as any)?.document?.activeElement as + { blur?: () => void } | undefined; + + if (activeElement?.blur) { + activeElement.blur(); + } + } catch { + // 安全忽略:某些环境下document可能不可访问 + } + } else { + Keyboard.dismiss(); + } +} + +/** + * 检查当前是否有激活的DOM元素(仅Web平台) + */ +export function hasActiveElement(): boolean { + if (Platform.OS !== 'web') return false; + + try { + const doc = (globalThis as any)?.document; + return !!doc?.activeElement && doc.activeElement !== doc.body; + } catch { + return false; + } +} + +/** + * 获取当前激活元素的类型(仅Web平台) + * 用于判断是否需要特殊处理(如输入框) + */ +export function getActiveElementType(): string | null { + if (Platform.OS !== 'web') return null; + + try { + const activeElement = (globalThis as any)?.document?.activeElement as + { tagName?: string; type?: string } | undefined; + + if (!activeElement) return null; + + const tagName = activeElement.tagName?.toLowerCase(); + const type = activeElement.type?.toLowerCase(); + + return tagName === 'input' ? `input:${type || 'text'}` : tagName || null; + } catch { + return null; + } +} + +/** + * 判断当前激活元素是否为输入类型 + */ +export function isInputFocused(): boolean { + const elementType = getActiveElementType(); + if (!elementType) return false; + + return ( + elementType.startsWith('input:') || + elementType === 'textarea' || + elementType === 'select' + ); +} \ No newline at end of file diff --git a/src/infrastructure/platform/index.ts b/src/infrastructure/platform/index.ts new file mode 100644 index 0000000..eafb683 --- /dev/null +++ b/src/infrastructure/platform/index.ts @@ -0,0 +1,12 @@ +/** + * Platform Infrastructure + * + * 平台相关的基础设施代码 + */ + +export { + blurActiveElement, + hasActiveElement, + getActiveElementType, + isInputFocused, +} from './domUtils'; \ No newline at end of file diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 89337d3..24f0160 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -38,6 +38,7 @@ import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; import * as hrefs from '../../navigation/hrefs'; +import { blurActiveElement } from '../../infrastructure/platform'; const TABS = ['关注', '最新', '热门']; const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire']; @@ -203,9 +204,8 @@ export const HomeScreen: React.FC = () => { const [showCreatePost, setShowCreatePost] = useState(false); useEffect(() => { - if (showCreatePost && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (showCreatePost) { + blurActiveElement(); } }, [showCreatePost]); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index d5cd861..c124336 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -42,8 +42,35 @@ import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard, ReportDialog } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { handleError } from '../../services/errorHandler'; import * as hrefs from '../../navigation/hrefs'; +function togglePostField( + post: Post, + field: 'is_liked' | 'is_favorited', + countField: 'likes_count' | 'favorites_count', +): Post { + const oldValue = post[field]; + const oldCount = post[countField]; + return { + ...post, + [field]: !oldValue, + [countField]: oldValue ? Math.max(0, oldCount - 1) : oldCount + 1, + }; +} + +function toggleCommentLikeInTree(comments: Comment[], commentId: string, isLiked: boolean, likesCount: number): Comment[] { + return comments.map(c => { + if (c.id === commentId) { + return { ...c, is_liked: isLiked, likes_count: likesCount }; + } + if (c.replies?.length) { + return { ...c, replies: toggleCommentLikeInTree(c.replies, commentId, isLiked, likesCount) }; + } + return c; + }); +} + export const PostDetailScreen: React.FC = () => { const colors = useAppColors(); const styles = useMemo(() => createPostDetailStyles(colors), [colors]); @@ -217,20 +244,17 @@ export const PostDetailScreen: React.FC = () => { setPost(postData as unknown as Post); // 初始化关注状态 if (postData.author) { - setIsFollowing((postData.author as any).is_following || false); - setIsFollowingMe((postData.author as any).is_following_me || false); + setIsFollowing(postData.author.is_following || false); + setIsFollowingMe(postData.author.is_following_me || false); } - // 只在首次加载时记录浏览量 - if (recordView && !hasRecordedView.current) { + if (!hasRecordedView.current) { hasRecordedView.current = true; - // 异步记录浏览量,不阻塞加载 postService.recordView(postId).catch(err => { console.error('记录浏览量失败:', err); }); } - // 如果是投票帖子,立即加载投票数据 - if ((postData as any).is_vote) { + if (postData.is_vote) { setIsVoteLoading(true); try { const voteData = await voteService.getVoteResult(postId); @@ -251,8 +275,8 @@ export const PostDetailScreen: React.FC = () => { setPost(updatedPost); // 初始化关注状态 if (updatedPost.author) { - setIsFollowing((updatedPost.author as any).is_following || false); - setIsFollowingMe((updatedPost.author as any).is_following_me || false); + setIsFollowing(updatedPost.author.is_following || false); + setIsFollowingMe(updatedPost.author.is_following_me || false); } } } @@ -260,7 +284,7 @@ export const PostDetailScreen: React.FC = () => { // 加载评论(使用游标分页刷新) await refreshComments(); } catch (error) { - console.error('加载帖子详情失败:', error); + handleError(error, { context: '加载帖子详情' }); } finally { setIsPostInitialLoading(false); } @@ -458,65 +482,36 @@ export const PostDetailScreen: React.FC = () => { const handleLike = useCallback(async () => { if (!post) return; - // 先保存旧状态用于回滚 - const oldIsLiked = post.is_liked; - const oldLikesCount = post.likes_count; - - // 乐观更新本地状态 - setPost(prev => prev ? { - ...prev, - is_liked: !prev.is_liked, - likes_count: prev.is_liked ? prev.likes_count - 1 : prev.likes_count + 1 - } : null); + const originalPost = post; + setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null); try { - if (oldIsLiked) { + if (originalPost.is_liked) { await processPostUseCase.unlikePost(post.id); } else { await processPostUseCase.likePost(post.id); } - // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { - console.error('点赞操作失败:', error); - // 失败时回滚状态 - setPost(prev => prev ? { - ...prev, - is_liked: oldIsLiked, - likes_count: oldLikesCount - } : null); + handleError(error, { context: '点赞' }); + setPost(originalPost); } }, [post]); - // 收藏帖子 const handleFavorite = useCallback(async () => { if (!post) return; - // 先保存旧状态用于回滚 - const oldIsFavorited = post.is_favorited; - const oldFavoritesCount = post.favorites_count; - - // 乐观更新本地状态 - setPost(prev => prev ? { - ...prev, - is_favorited: !prev.is_favorited, - favorites_count: prev.is_favorited ? prev.favorites_count - 1 : prev.favorites_count + 1 - } : null); + const originalPost = post; + setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null); try { - if (oldIsFavorited) { + if (originalPost.is_favorited) { await processPostUseCase.unfavoritePost(post.id); } else { await processPostUseCase.favoritePost(post.id); } - // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { - console.error('收藏操作失败:', error); - // 失败时回滚状态 - setPost(prev => prev ? { - ...prev, - is_favorited: oldIsFavorited, - favorites_count: oldFavoritesCount - } : null); + handleError(error, { context: '收藏' }); + setPost(originalPost); } }, [post]); @@ -573,24 +568,19 @@ export const PostDetailScreen: React.FC = () => { setVoteResult(oldVoteResult); } } catch (error) { - console.error('投票失败:', error); - // 失败时回滚 + handleError(error, { context: '投票' }); setVoteResult(oldVoteResult); } finally { setIsVoteLoading(false); } }, [post, voteResult, isVoteLoading]); - // 取消投票处理函数 const handleUnvote = useCallback(async () => { if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return; const votedOptionId = voteResult.voted_option_id; - - // 保存旧状态用于回滚 const oldVoteResult = { ...voteResult }; - // 乐观更新 setVoteResult((prev: VoteResultDTO | null) => { if (!prev) return null; return { @@ -611,12 +601,10 @@ export const PostDetailScreen: React.FC = () => { try { const success = await voteService.unvote(post.id); if (!success) { - // 失败时回滚 setVoteResult(oldVoteResult); } } catch (error) { - console.error('取消投票失败:', error); - // 失败时回滚 + handleError(error, { context: '取消投票' }); setVoteResult(oldVoteResult); } finally { setIsVoteLoading(false); @@ -899,26 +887,15 @@ export const PostDetailScreen: React.FC = () => { } }; - // 点赞评论(包括回复)- 优化版 const handleLikeComment = async (comment: Comment) => { const { id, is_liked, likes_count } = comment; - // 乐观更新:切换点赞状态 - const newIsLiked = !is_liked; + const oldIsLiked = is_liked ?? false; + const newIsLiked = !oldIsLiked; const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1); + const originalComments = comments; - // 递归更新评论树中的点赞状态 - const updateLikeInTree = (c: Comment): Comment => { - if (c.id === id) { - return { ...c, is_liked: newIsLiked, likes_count: newLikesCount }; - } - if (c.replies?.length) { - return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) }; - } - return c; - }; - - setComments(prev => prev.map(c => updateLikeInTree(c))); + setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount)); try { const success = newIsLiked @@ -929,18 +906,8 @@ export const PostDetailScreen: React.FC = () => { throw new Error('点赞操作失败'); } } catch (error) { - console.error('评论点赞操作失败:', error); - // 回滚状态 - const rollbackLikeInTree = (c: Comment): Comment => { - if (c.id === id) { - return { ...c, is_liked, likes_count }; - } - if (c.replies?.length) { - return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) }; - } - return c; - }; - setComments(prev => prev.map(c => rollbackLikeInTree(c))); + handleError(error, { context: '评论点赞' }); + setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count)); } }; diff --git a/src/screens/message/CreateGroupScreen.tsx b/src/screens/message/CreateGroupScreen.tsx index 248e158..ae4c743 100644 --- a/src/screens/message/CreateGroupScreen.tsx +++ b/src/screens/message/CreateGroupScreen.tsx @@ -19,6 +19,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { blurActiveElement } from '../../infrastructure/platform'; import * as ImagePicker from 'expo-image-picker'; import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { groupService } from '../../services/groupService'; @@ -47,9 +48,8 @@ const CreateGroupScreen: React.FC = () => { const [inviteModalVisible, setInviteModalVisible] = useState(false); useEffect(() => { - if (inviteModalVisible && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (inviteModalVisible) { + blurActiveElement(); } }, [inviteModalVisible]); diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index a9c94f9..e9c0ad3 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -46,6 +46,7 @@ import { JoinType, } from '../../types/dto'; import { User } from '../../types'; +import { blurActiveElement } from '../../infrastructure/platform'; import { firstRouteParam } from '../../navigation/paramUtils'; import * as hrefs from '../../navigation/hrefs'; import { messageManager } from '../../stores/messageManager'; @@ -119,9 +120,8 @@ const GroupInfoScreen: React.FC = () => { const [inviting, setInviting] = useState(false); useEffect(() => { - if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) { + blurActiveElement(); } }, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]); diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index b9d4918..4b73a28 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -22,6 +22,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { blurActiveElement } from '../../infrastructure/platform'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { useAuthStore } from '../../stores'; import { groupService } from '../../services/groupService'; @@ -133,9 +134,8 @@ const GroupMembersScreen: React.FC = () => { const [newNickname, setNewNickname] = useState(''); useEffect(() => { - if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (actionModalVisible || nicknameModalVisible) { + blurActiveElement(); } }, [actionModalVisible, nicknameModalVisible]); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index c381458..2d050b5 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -40,6 +40,7 @@ import { } from '../../theme'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; import { authService } from '../../services'; +import { blurActiveElement } from '../../infrastructure/platform'; import { useUserStore, useAuthStore } from '../../stores'; // 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步) import { @@ -138,9 +139,8 @@ export const MessageListScreen: React.FC = () => { const [actionMenuVisible, setActionMenuVisible] = useState(false); useEffect(() => { - if (actionMenuVisible && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (actionMenuVisible) { + blurActiveElement(); } }, [actionMenuVisible]); const [scannerVisible, setScannerVisible] = useState(false); diff --git a/src/screens/message/components/ChatScreen/EmojiPanel.tsx b/src/screens/message/components/ChatScreen/EmojiPanel.tsx index 1d3f757..6422b97 100644 --- a/src/screens/message/components/ChatScreen/EmojiPanel.tsx +++ b/src/screens/message/components/ChatScreen/EmojiPanel.tsx @@ -15,15 +15,10 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive import { EMOJIS } from './constants'; import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService'; import { useAppColors, spacing } from '../../../../theme'; +import { blurActiveElement } from '../../../../infrastructure/platform'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); -const blurActiveElementOnWeb = () => { - if (Platform.OS !== 'web') return; - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); -}; - // 表情尺寸配置 - 固定宽度 40px,使用 flex 布局 const EMOJI_SIZES = { mobile: { size: 24, itemWidth: 40, itemHeight: 40 }, @@ -182,7 +177,7 @@ export const EmojiPanel: React.FC = ({ // 打开管理界面 const handleOpenManage = () => { - blurActiveElementOnWeb(); + blurActiveElement(); setShowManageModal(true); setManageMode(false); setSelectedStickers(new Set()); diff --git a/src/screens/message/components/ChatScreen/LongPressMenu.tsx b/src/screens/message/components/ChatScreen/LongPressMenu.tsx index 58257b2..eb368a4 100644 --- a/src/screens/message/components/ChatScreen/LongPressMenu.tsx +++ b/src/screens/message/components/ChatScreen/LongPressMenu.tsx @@ -20,6 +20,7 @@ import { LongPressMenuProps } from './types'; import { RECALL_TIME_LIMIT } from './constants'; import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto'; import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService'; +import { blurActiveElement } from '../../../../infrastructure/platform'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -37,16 +38,11 @@ export const LongPressMenu: React.FC = ({ const styles = useChatScreenStyles(); const scaleAnimation = useRef(new Animated.Value(0)).current; const opacityAnimation = useRef(new Animated.Value(0)).current; - const blurActiveElementOnWeb = () => { - if (Platform.OS !== 'web') return; - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); - }; // 显示动画 - 缩放弹出 useEffect(() => { if (visible) { - blurActiveElementOnWeb(); + blurActiveElement(); Animated.parallel([ Animated.spring(scaleAnimation, { toValue: 1, @@ -61,7 +57,7 @@ export const LongPressMenu: React.FC = ({ }), ]).start(); } else { - blurActiveElementOnWeb(); + blurActiveElement(); Animated.parallel([ Animated.timing(scaleAnimation, { toValue: 0, @@ -251,12 +247,12 @@ export const LongPressMenu: React.FC = ({ visible={visible} transparent animationType="none" - onShow={blurActiveElementOnWeb} + onShow={blurActiveElement} onRequestClose={onClose} > { - blurActiveElementOnWeb(); + blurActiveElement(); onClose(); }} style={styles.qqMenuOverlay} diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 60dbe9f..d863325 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -31,7 +31,7 @@ const MAX_WIDTH_RATIO = { desktop: 0.55, }; -export const MessageBubble: React.FC = ({ +const MessageBubbleInner: React.FC = ({ message, index, currentUserId, @@ -481,11 +481,37 @@ const segmentStyles = StyleSheet.create({ minWidth: 120, }, pureImageBubble: { - // 纯图片消息:去除内边距,让图片紧贴气泡边缘 paddingHorizontal: 0, paddingVertical: 0, overflow: 'hidden', + backgroundColor: 'transparent', }, }); +export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => { + if (prev.message.id !== next.message.id) return false; + if (prev.message.status !== next.message.status) return false; + if (prev.message.segments !== next.message.segments) return false; + if (prev.message.sender_id !== next.message.sender_id) return false; + if (prev.message.is_system_notice !== next.message.is_system_notice) return false; + if (prev.message.category !== next.message.category) return false; + if (prev.message.seq !== next.message.seq) return false; + if (prev.message.sender !== next.message.sender) return false; + if (prev.message.notice_content !== next.message.notice_content) return false; + if (prev.index !== next.index) return false; + if (prev.currentUserId !== next.currentUserId) return false; + if (prev.otherUserLastReadSeq !== next.otherUserLastReadSeq) return false; + if (prev.selectedMessageId !== next.selectedMessageId) return false; + if (prev.isGroupChat !== next.isGroupChat) return false; + if (prev.groupMembers !== next.groupMembers) return false; + if (prev.currentUser !== next.currentUser) return false; + if (prev.otherUser !== next.otherUser) return false; + if (prev.messageMap !== next.messageMap) return false; + if (prev.onLongPress !== next.onLongPress) return false; + if (prev.onImagePress !== next.onImagePress) return false; + if (prev.onReplyPress !== next.onReplyPress) return false; + if (prev.shouldShowTime !== next.shouldShowTime) return false; + return true; +}); + export default MessageBubble; diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index 5b4bac6..589d7d5 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -165,7 +165,7 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod * 渲染图片 Segment */ // 图片Segment组件 - 使用Hook获取实际尺寸 -const ImageSegment: React.FC<{ +const ImageSegmentInner: React.FC<{ data: ImageSegmentData; isMe: boolean; onImagePress?: (url: string) => void; @@ -284,7 +284,6 @@ const ImageSegment: React.FC<{ style={{ width: IMAGE_FALLBACK_SIZE.width, height: IMAGE_FALLBACK_SIZE.height, - borderRadius: 8, backgroundColor: 'rgba(0,0,0,0.1)', justifyContent: 'center', alignItems: 'center', @@ -312,7 +311,6 @@ const ImageSegment: React.FC<{ style={{ width: IMAGE_FALLBACK_SIZE.width, height: IMAGE_FALLBACK_SIZE.height, - borderRadius: 8, backgroundColor: 'rgba(0,0,0,0.1)', }} /> @@ -334,7 +332,6 @@ const ImageSegment: React.FC<{ style={{ width: dimensions.width, height: dimensions.height, - borderRadius: 8, }} recyclingKey={stableImageKey} contentFit="cover" @@ -345,7 +342,6 @@ const ImageSegment: React.FC<{ setLoadError(false); }} onError={() => { - // 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率 if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) { setCurrentImageUri(data.url); return; @@ -357,6 +353,17 @@ const ImageSegment: React.FC<{ ); }; +const ImageSegment = React.memo(ImageSegmentInner, (prev, next) => { + if (prev.data.url !== next.data.url) return false; + if (prev.data.thumbnail_url !== next.data.thumbnail_url) return false; + if (prev.data.width !== next.data.width) return false; + if (prev.data.height !== next.data.height) return false; + if (prev.isMe !== next.isMe) return false; + if (prev.onImagePress !== next.onImagePress) return false; + if (prev.onImageLongPress !== next.onImageLongPress) return false; + return true; +}); + const renderImageSegment = ( data: ImageSegmentData, props: Omit @@ -705,7 +712,7 @@ export const ReplyPreviewSegment: React.FC = ({ /** * 渲染完整的消息链(多个 Segment 组合) */ -export const MessageSegmentsRenderer: React.FC<{ +const MessageSegmentsRendererInner: React.FC<{ segments: MessageSegment[]; isMe: boolean; currentUserId?: string; @@ -734,11 +741,11 @@ export const MessageSegmentsRenderer: React.FC<{ const fontSize = useChatSettingsStore((s) => s.fontSize); const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]); - const replySegment = segments.find(s => s.type === 'reply'); - const otherSegments = segments.filter(s => s.type !== 'reply'); - const chunks = partitionMessageSegments(otherSegments); + const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]); + const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]); + const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]); - const renderProps = { + const renderProps = useMemo(() => ({ isMe, currentUserId, memberMap, @@ -747,7 +754,7 @@ export const MessageSegmentsRenderer: React.FC<{ onImagePress, onImageLongPress, onLinkPress, - }; + }), [isMe, currentUserId, memberMap, onAtPress, onReplyPress, onImagePress, onImageLongPress, onLinkPress]); return ( @@ -837,13 +844,16 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { }, imagesChunk: { width: '100%', + overflow: 'hidden', }, imageStackItem: { width: '100%', - marginBottom: 6, + marginBottom: 4, + overflow: 'hidden', }, imageStackItemLast: { width: '100%', + overflow: 'hidden', }, blockChunk: { width: '100%', @@ -864,14 +874,13 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { color: colors.chat.textPrimary, }, - // @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号 + // @提及 - 与普通文本字号一致,仅颜色区分 atText: { fontSize, - lineHeight: Math.round(fontSize * 1.4), - fontWeight: '500', + lineHeight: Math.round(fontSize * 1.43), }, atTextMe: { - color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰 + color: '#1A5F9E', }, atTextOther: { color: colors.chat.link, @@ -879,25 +888,15 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { atHighlight: { backgroundColor: 'rgba(74, 136, 199, 0.15)', borderRadius: 4, - paddingHorizontal: 3, }, // 图片 - QQ风格:保持原始宽高比 imageContainer: { - borderRadius: 12, overflow: 'hidden', - marginTop: 4, - shadowColor: colors.chat.shadow, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.15, - shadowRadius: 4, - elevation: 4, }, imageMe: { - borderBottomRightRadius: 4, }, imageOther: { - borderBottomLeftRadius: 4, }, // 移除固定的 image 样式,改为在组件中动态计算 @@ -1143,4 +1142,19 @@ function useSegmentStyles(): SegmentStyles { return ctx; } +export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner, (prev, next) => { + if (prev.segments !== next.segments) return false; + if (prev.isMe !== next.isMe) return false; + if (prev.currentUserId !== next.currentUserId) return false; + if (prev.memberMap !== next.memberMap) return false; + if (prev.replyMessage !== next.replyMessage) return false; + if (prev.onAtPress !== next.onAtPress) return false; + if (prev.onReplyPress !== next.onReplyPress) return false; + if (prev.onImagePress !== next.onImagePress) return false; + if (prev.onImageLongPress !== next.onImageLongPress) return false; + if (prev.onLinkPress !== next.onLinkPress) return false; + if (prev.getSenderInfo !== next.getSenderInfo) return false; + return true; +}); + export default MessageSegmentsRenderer; \ No newline at end of file diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index a179f80..84b02bd 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -174,9 +174,9 @@ export const useChatScreen = () => { status: (m.status || 'normal') as MessageStatus, category: m.category, created_at: m.created_at, - sender: (m as any).sender, - is_system_notice: (m as any).is_system_notice, - notice_content: (m as any).notice_content, + sender: m.sender, + is_system_notice: m.is_system_notice, + notice_content: m.notice_content, })); }, [messageManagerMessages]); @@ -258,7 +258,7 @@ export const useChatScreen = () => { setOtherUser(prev => ({ ...(prev || {}), ...other })); } // 获取对方最后阅读位置 - setOtherUserLastReadSeq((conversation as any).other_last_read_seq || 0); + setOtherUserLastReadSeq(conversation.other_last_read_seq || 0); } }, [conversation, isGroupChat, currentUserId]); @@ -273,8 +273,8 @@ export const useChatScreen = () => { const detail = await userManager.getUserById(String(otherUserId), true); if (!detail || cancelled) return; setOtherUser(prev => ({ ...(prev || {}), ...detail })); - if (typeof (detail as any).is_following_me === 'boolean') { - setIsFollowedByOther((detail as any).is_following_me); + if (typeof detail.is_following_me === 'boolean') { + setIsFollowedByOther(detail.is_following_me); } else { setIsFollowedByOther(null); } @@ -360,7 +360,7 @@ export const useChatScreen = () => { }, [loading, loadingMore, messages.length, scrollToLatest]); // 新消息跟随策略(Telegram/QQ 风格): - // 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随; + // 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随; // 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。 useEffect(() => { const currentCount = messages.length; @@ -371,14 +371,14 @@ export const useChatScreen = () => { if (loading || loadingMore) return; if (latestSeq <= prevLatestSeq) return; - if (suppressAutoFollowRef.current) return; + if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return; if (!isNearBottom()) return; const timer = setTimeout(() => { scrollToLatest(false, false, 'new-message-follow'); }, 0); return () => clearTimeout(timer); - }, [messages, loading, loadingMore, isNearBottom, scrollToLatest]); + }, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]); // 获取当前用户信息 useEffect(() => { @@ -559,10 +559,10 @@ export const useChatScreen = () => { if (!conversationId) return; if (!conversation) return; - const unreadCount = Number((conversation as any).unread_count || 0); + const unreadCount = Number(conversation.unread_count || 0); if (unreadCount <= 0) return; - const latestFromConversation = Number((conversation as any).last_seq || 0); + const latestFromConversation = Number(conversation.last_seq || 0); const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; const targetSeq = Math.max(latestFromConversation, latestFromMessages); if (targetSeq <= 0) return; @@ -1305,8 +1305,8 @@ export const useChatScreen = () => { // 关闭所有面板 const handleDismiss = useCallback(() => { + Keyboard.dismiss(); if (activePanel !== 'none') { - Keyboard.dismiss(); setActivePanel('none'); } }, [activePanel]); diff --git a/src/screens/message/components/MutualFollowSelectorModal.tsx b/src/screens/message/components/MutualFollowSelectorModal.tsx index 450dd75..308d1d8 100644 --- a/src/screens/message/components/MutualFollowSelectorModal.tsx +++ b/src/screens/message/components/MutualFollowSelectorModal.tsx @@ -16,6 +16,7 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from ' import { authService } from '../../../services/authService'; import { useAuthStore } from '../../../stores'; import { Avatar, EmptyState, Loading, Text } from '../../../components/common'; +import { blurActiveElement } from '../../../infrastructure/platform'; import { User } from '../../../types'; type MutualFollowSelectorModalProps = { @@ -57,10 +58,7 @@ const MutualFollowSelectorModal: React.FC = ({ useEffect(() => { if (!visible) return; - if (Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); - } + blurActiveElement(); if (!currentUserId) return; const nextSelectedIds = new Set(stableInitialSelectedIds); diff --git a/src/screens/profile/AboutScreen.tsx b/src/screens/profile/AboutScreen.tsx index c1f617f..d407453 100644 --- a/src/screens/profile/AboutScreen.tsx +++ b/src/screens/profile/AboutScreen.tsx @@ -482,7 +482,6 @@ export const AboutScreen: React.FC = () => { const appInfoItems = [ { key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME }, { key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` }, - { key: 'developer', icon: 'office-building-outline', title: '开发者', value: '青春之旅电子信息科技' }, ]; const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => ( diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index 89f9101..9e344a4 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -22,6 +22,7 @@ import { PanGestureHandler, State } from 'react-native-gesture-handler'; import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { blurActiveElement } from '../../infrastructure/platform'; import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; import { @@ -214,9 +215,8 @@ export const ScheduleScreen: React.FC = () => { const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { - if ((isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) && Platform.OS === 'web') { - const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; - active?.blur?.(); + if (isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) { + blurActiveElement(); } }, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]); diff --git a/src/services/errorHandler.ts b/src/services/errorHandler.ts new file mode 100644 index 0000000..a1c0d03 --- /dev/null +++ b/src/services/errorHandler.ts @@ -0,0 +1,196 @@ +/** + * 统一错误处理服务 + * + * 提供标准化的错误处理流程: + * 1. 错误分类(网络错误、认证错误、业务错误等) + * 2. 日志记录 + * 3. 用户提示 + * 4. 错误上报 + * + * 替代散落在代码中的 console.error + 手动 Alert 模式 + */ + +import { showPrompt } from './promptService'; + +export enum AppErrorCode { + NETWORK_ERROR = 'NETWORK_ERROR', + AUTH_ERROR = 'AUTH_ERROR', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + VALIDATION_ERROR = 'VALIDATION_ERROR', + SERVER_ERROR = 'SERVER_ERROR', + TIMEOUT = 'TIMEOUT', + UNKNOWN = 'UNKNOWN', +} + +export interface AppError { + code: AppErrorCode; + message: string; + originalError?: unknown; + context?: string; + silent?: boolean; +} + +const ERROR_MESSAGES: Record = { + [AppErrorCode.NETWORK_ERROR]: '网络连接失败,请检查网络设置', + [AppErrorCode.AUTH_ERROR]: '登录已过期,请重新登录', + [AppErrorCode.FORBIDDEN]: '没有权限执行此操作', + [AppErrorCode.NOT_FOUND]: '请求的资源不存在', + [AppErrorCode.VALIDATION_ERROR]: '输入数据有误,请检查后重试', + [AppErrorCode.SERVER_ERROR]: '服务器错误,请稍后重试', + [AppErrorCode.TIMEOUT]: '请求超时,请稍后重试', + [AppErrorCode.UNKNOWN]: '操作失败,请稍后重试', +}; + +function classifyError(error: unknown): AppErrorCode { + if (!error) return AppErrorCode.UNKNOWN; + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + if (message.includes('network') || message.includes('fetch') || message.includes('econnrefused')) { + return AppErrorCode.NETWORK_ERROR; + } + if (message.includes('timeout') || message.includes('timed out')) { + return AppErrorCode.TIMEOUT; + } + if (message.includes('unauthorized') || message.includes('401') || message.includes('token')) { + return AppErrorCode.AUTH_ERROR; + } + if (message.includes('forbidden') || message.includes('403')) { + return AppErrorCode.FORBIDDEN; + } + if (message.includes('not found') || message.includes('404')) { + return AppErrorCode.NOT_FOUND; + } + if (message.includes('validation') || message.includes('400') || message.includes('422')) { + return AppErrorCode.VALIDATION_ERROR; + } + if (message.includes('500') || message.includes('502') || message.includes('503')) { + return AppErrorCode.SERVER_ERROR; + } + } + + if (typeof error === 'object' && error !== null) { + const err = error as Record; + const status = err.status || err.statusCode || err.code; + + if (status === 401) return AppErrorCode.AUTH_ERROR; + if (status === 403) return AppErrorCode.FORBIDDEN; + if (status === 404) return AppErrorCode.NOT_FOUND; + if (status === 400 || status === 422) return AppErrorCode.VALIDATION_ERROR; + if (typeof status === 'number' && status >= 500) return AppErrorCode.SERVER_ERROR; + } + + return AppErrorCode.UNKNOWN; +} + +export function createAppError( + error: unknown, + context?: string, + options?: { silent?: boolean; userMessage?: string } +): AppError { + const code = classifyError(error); + return { + code, + message: options?.userMessage || ERROR_MESSAGES[code], + originalError: error, + context, + silent: options?.silent, + }; +} + +export interface ErrorHandlerOptions { + context?: string; + userMessage?: string; + silent?: boolean; + showErrorPrompt?: boolean; + logToConsole?: boolean; +} + +/** + * 统一错误处理函数 + * + * 替代散落的 console.error + Alert 模式 + * + * @example + * // 之前 + * } catch (error) { + * console.error('加载帖子详情失败:', error); + * } + * + * // 之后 + * } catch (error) { + * handleError(error, { context: '加载帖子详情' }); + * } + */ +export function handleError(error: unknown, options: ErrorHandlerOptions = {}): AppError { + const { + context, + userMessage, + silent = false, + showErrorPrompt = false, + logToConsole = true, + } = options; + + const appError = createAppError(error, context, { silent, userMessage }); + + if (logToConsole) { + const prefix = context ? `[${context}]` : ''; + console.error(`${prefix} ${appError.message}`, error); + } + + if (showErrorPrompt && !silent) { + showPrompt({ + type: 'error', + title: context || '错误', + message: appError.message, + duration: 3000, + }); + } + + return appError; +} + +/** + * 创建带上下文的错误处理器 + * + * @example + * const postErrorHandler = createErrorHandler('帖子'); + * + * } catch (error) { + * postErrorHandler(error, { showErrorPrompt: true }); + * } + */ +export function createErrorHandler(defaultContext: string) { + return (error: unknown, options: Omit = {}) => { + return handleError(error, { ...options, context: defaultContext }); + }; +} + +/** + * 安全执行异步操作 + * + * @example + * const result = await safeAsync( + * () => postService.getPost(id), + * { context: '加载帖子' } + * ); + * if (result.error) { + * // 处理错误 + * } else { + * // 使用 result.data + * } + */ +export async function safeAsync( + fn: () => Promise, + options: ErrorHandlerOptions = {} +): Promise<{ data: T | null; error: AppError | null }> { + try { + const data = await fn(); + return { data, error: null }; + } catch (error) { + const appError = handleError(error, options); + return { data: null, error: appError }; + } +} \ No newline at end of file diff --git a/src/services/index.ts b/src/services/index.ts index aaa9224..593a7e8 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -111,6 +111,11 @@ export { export { showPrompt } from './promptService'; export { showConfirm } from './dialogService'; +// 统一错误处理服务 +export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler'; +export type { AppError, ErrorHandlerOptions } from './errorHandler'; +export { AppErrorCode } from './errorHandler'; + // APK 更新检查服务 export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService'; export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService'; diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index 17f1985..a0a0960 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -254,18 +254,13 @@ export const useUserStore = create((set, get) => { followUser: async (userId: string) => { const originalUsers = get().users; const originalUserCache = get().userCache; - - // 乐观更新 users + set(state => ({ users: state.users.map(u => u.id === userId ? { ...u, isFollowing: true, followersCount: u.followers_count + 1 } : u ), - })); - - // 乐观更新 userCache - set(state => ({ userCache: Object.fromEntries( Object.entries(state.userCache).map(([id, user]) => [ id, @@ -275,35 +270,25 @@ export const useUserStore = create((set, get) => { ]) ) })); - + try { await authService.followUser(userId); } catch (error) { console.error('关注用户失败:', error); - // 回滚 - set({ - users: originalUsers, - userCache: originalUserCache - }); + set({ users: originalUsers, userCache: originalUserCache }); } }, - // 取消关注 - authService 不管理状态,所以由 store 管理 unfollowUser: async (userId: string) => { const originalUsers = get().users; const originalUserCache = get().userCache; - - // 乐观更新 users + set(state => ({ users: state.users.map(u => u.id === userId ? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) } : u ), - })); - - // 乐观更新 userCache - set(state => ({ userCache: Object.fromEntries( Object.entries(state.userCache).map(([id, user]) => [ id, @@ -313,16 +298,12 @@ export const useUserStore = create((set, get) => { ]) ) })); - + try { await authService.unfollowUser(userId); } catch (error) { console.error('取消关注用户失败:', error); - // 回滚 - set({ - users: originalUsers, - userCache: originalUserCache - }); + set({ users: originalUsers, userCache: originalUserCache }); } }, diff --git a/src/types/dto.ts b/src/types/dto.ts index a18c215..332d7fe 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -231,16 +231,18 @@ export type MessageSegment = // 消息响应 export interface MessageResponse { - id: string; // 雪花算法ID (使用string避免JavaScript精度丢失) + id: string; conversation_id: string; - sender_id: string; // UUID字符串 - seq: number; // 消息序号 - segments: MessageSegment[]; // 消息链 - reply_to_id?: string; // 被回复消息的ID(用于关联查找) + sender_id: string; + seq: number; + segments: MessageSegment[]; + reply_to_id?: string; status: MessageStatus; - category?: string; // 消息类别:chat, notification, announcement + category?: string; created_at: string; sender?: UserDTO; + is_system_notice?: boolean; + notice_content?: string; } // 会话响应