diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 3aebc61..6b1e19a 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -18,6 +18,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { PostCardProps, PostCardAction } from './types'; import { ImageGridItem, SmartImage } from '../../common'; import Text from '../../common/Text'; +import HighlightText from '../../common/HighlightText'; import Avatar from '../../common/Avatar'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme'; import { getPreviewImageUrl } from '../../../utils/imageHelper'; @@ -84,6 +85,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea if (prev.isAuthor !== next.isAuthor) return false; if (prev.style !== next.style) return false; if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false; + if (prev.highlightKeyword !== next.highlightKeyword) return false; const a = prev.post; const b = next.post; @@ -374,6 +376,10 @@ function createPostCardStyles(colors: AppColors) { fontSize: fontSizes.sm, marginLeft: 4, }, + highlight: { + backgroundColor: `${colors.warning.main}40`, + color: colors.text.primary, + }, }); } @@ -391,6 +397,7 @@ const PostCardInner: React.FC = (normalizedProps) => { isAuthor = false, index, style, + highlightKeyword, } = normalizedProps; const colors = useAppColors(); @@ -556,7 +563,7 @@ const PostCardInner: React.FC = (normalizedProps) => { ) : ( - {contentPreview} + {highlightKeyword ? : contentPreview} )} @@ -570,7 +577,7 @@ const PostCardInner: React.FC = (normalizedProps) => { {!!post.title && ( - {post.title} + {highlightKeyword ? : post.title} )} @@ -636,7 +643,7 @@ const PostCardInner: React.FC = (normalizedProps) => { {!!post.title && ( - {post.title} + {highlightKeyword ? : post.title} )} @@ -646,7 +653,7 @@ const PostCardInner: React.FC = (normalizedProps) => { style={styles.content} numberOfLines={isExpanded ? undefined : contentNumberOfLines} > - {content} + {highlightKeyword ? : content} {shouldTruncate && ( setIsExpanded((prev) => !prev)} style={styles.expandBtn}> diff --git a/src/components/business/PostCard/types.ts b/src/components/business/PostCard/types.ts index f367321..003f3a3 100644 --- a/src/components/business/PostCard/types.ts +++ b/src/components/business/PostCard/types.ts @@ -118,6 +118,9 @@ export interface PostCardProps { /** 索引(用于虚拟化列表优化) */ index?: number; + /** 搜索高亮关键词 */ + highlightKeyword?: string; + /** 自定义样式 */ style?: StyleProp; } diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx index f45fd50..852a379 100644 --- a/src/components/business/QRCodeScanner.tsx +++ b/src/components/business/QRCodeScanner.tsx @@ -195,18 +195,23 @@ const QRCodeScannerNative: React.FC = ({ visible, onClose }) // 动态导入 expo-camera,避免 Web 端加载 const [cameraModule, setCameraModule] = useState(null); - const [permission, setPermission] = useState(null); + const [permissionStatus, setPermissionStatus] = useState<'undetermined' | 'granted' | 'denied'>('undetermined'); const [scanned, setScanned] = useState(false); useEffect(() => { if (visible && Platform.OS !== 'web') { import('expo-camera').then((module) => { setCameraModule(module); - const [perm, requestPerm] = module.useCameraPermissions(); - setPermission(perm); - if (!perm?.granted) { - requestPerm(); - } + module.Camera.getCameraPermissionsAsync().then((result) => { + setPermissionStatus(result.granted ? 'granted' : result.canAskAgain ? 'undetermined' : 'denied'); + if (!result.granted && result.canAskAgain) { + module.Camera.requestCameraPermissionsAsync().then((reqResult) => { + setPermissionStatus(reqResult.granted ? 'granted' : 'denied'); + }); + } + }); + }).catch((err) => { + console.warn('Failed to load expo-camera:', err); }); } }, [visible]); @@ -237,7 +242,7 @@ const QRCodeScannerNative: React.FC = ({ visible, onClose }) } }; - if (!cameraModule || !permission?.granted) { + if (!cameraModule || permissionStatus !== 'granted') { return ( @@ -251,9 +256,15 @@ const QRCodeScannerNative: React.FC = ({ visible, onClose }) 需要相机权限才能扫码 - cameraModule?.useCameraPermissions()[1]()} + { + if (cameraModule) { + cameraModule.Camera.requestCameraPermissionsAsync().then((result) => { + setPermissionStatus(result.granted ? 'granted' : 'denied'); + }); + } + }} > 授予权限 diff --git a/src/components/common/HighlightText.tsx b/src/components/common/HighlightText.tsx new file mode 100644 index 0000000..21263ce --- /dev/null +++ b/src/components/common/HighlightText.tsx @@ -0,0 +1,54 @@ +import React, { useMemo } from 'react'; +import { Text, TextStyle, StyleProp } from 'react-native'; + +interface HighlightTextProps { + text: string; + keyword: string; + style?: StyleProp; +} + +const HighlightText: React.FC = ({ text, keyword, style }) => { + const parts = useMemo(() => { + if (!text || !keyword) return [{ text, highlight: false }]; + + const result: Array<{ text: string; highlight: boolean }> = []; + const lowerText = text.toLowerCase(); + const lowerKeyword = keyword.toLowerCase(); + let lastIndex = 0; + + let searchFrom = 0; + while (searchFrom < lowerText.length) { + const index = lowerText.indexOf(lowerKeyword, searchFrom); + if (index === -1) break; + + if (index > lastIndex) { + result.push({ text: text.substring(lastIndex, index), highlight: false }); + } + result.push({ text: text.substring(index, index + keyword.length), highlight: true }); + lastIndex = index + keyword.length; + searchFrom = lastIndex; + } + + if (lastIndex < text.length) { + result.push({ text: text.substring(lastIndex), highlight: false }); + } + + return result.length > 0 ? result : [{ text, highlight: false }]; + }, [text, keyword]); + + return ( + + {parts.map((part, i) => + part.highlight ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} + + ); +}; + +export default HighlightText; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 169018e..5b0bbc6 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -7,6 +7,7 @@ export { default as Button } from './Button'; export { default as Card } from './Card'; export { default as Input } from './Input'; export { default as Loading } from './Loading'; +export { default as HighlightText } from './HighlightText'; export { default as Text } from './Text'; export { default as EmptyState } from './EmptyState'; export { default as Divider } from './Divider'; diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 272ae18..f553304 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -4,7 +4,7 @@ * 支持响应式布局,宽屏下显示更大的搜索结果区域 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { View, FlatList, @@ -80,7 +80,8 @@ export const SearchScreen: React.FC = ({ onBack }) => { // 保存当前搜索关键词,用于Tab切换时重新搜索 const [currentKeyword, setCurrentKeyword] = useState(''); - // 使用游标分页进行帖子搜索 + const searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]); + const { list: searchResults, isLoading, @@ -100,20 +101,25 @@ export const SearchScreen: React.FC = ({ onBack }) => { }); return response; }, - { pageSize: DEFAULT_PAGE_SIZE }, - { query: '' } + { pageSize: DEFAULT_PAGE_SIZE, autoLoad: false }, + searchExtraParams ); // 用户搜索结果(保持原有分页方式) const [userResults, setUserResults] = useState([]); const [userLoading, setUserLoading] = useState(false); + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + const resetRef = useRef(reset); + resetRef.current = reset; + // 当搜索词变化时重置 useEffect(() => { if (currentKeyword) { - refresh(); + refreshRef.current(); } else { - reset(); + resetRef.current(); } }, [currentKeyword]); @@ -132,11 +138,7 @@ export const SearchScreen: React.FC = ({ onBack }) => { try { const searchType = getSearchType(); - if (searchType === 'posts') { - // 帖子搜索由 useCursorPagination 处理,这里只需触发刷新 - refresh(); - } else if (searchType === 'users') { - // 用户搜索保持原有方式 + if (searchType === 'users') { setUserLoading(true); const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20); setUserResults(usersResponse.list || []); @@ -259,6 +261,7 @@ export const SearchScreen: React.FC = ({ onBack }) => { onAction={(action) => handlePostAction(post, action)} variant="list" features={isMobile ? 'compact' : 'full'} + highlightKeyword={currentKeyword} /> ))} @@ -281,6 +284,7 @@ export const SearchScreen: React.FC = ({ onBack }) => { onAction={(action) => handlePostAction(item, action)} variant="list" features="compact" + highlightKeyword={currentKeyword} /> )} keyExtractor={item => item.id} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index cf7dfcb..70c1531 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -578,6 +578,7 @@ export const ChatScreen: React.FC = (props) => { onInsertEmoji={handleInsertEmoji} onInsertSticker={handleSendSticker} onClose={closePanel} + onFocusInput={() => textInputRef.current?.focus()} /> )} diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index b297ffa..43db9c3 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -20,7 +20,6 @@ import { RefreshControl, ActivityIndicator, Animated, - TextInput, Keyboard, BackHandler, Platform, @@ -60,6 +59,7 @@ import { ConversationListRow } from './components/ConversationListRow'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; // 导入扫码组件 +import { SearchBar, TabBar } from '../../components/business'; import { QRCodeScanner } from '../../components/business/QRCodeScanner'; // 系统通知会话特殊ID @@ -673,49 +673,40 @@ export const MessageListScreen: React.FC = () => { return null; }; - // 渲染搜索模式UI + // 渲染搜索模式UI(扁平化现代化设计) const renderSearchMode = () => ( - - - + + performSearch(searchText)} + placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'} + autoFocus + /> + + + 取消 + + + + {/* Tab切换 - 现代化风格 */} + + setActiveSearchTab(index === 0 ? 'chat' : 'user')} + variant="modern" /> - {searchText.length > 0 && ( - setSearchText('')}> - - - )} - - - setActiveSearchTab('chat')} - > - - 聊天记录 - - - setActiveSearchTab('user')} - > - - 用户 - - - - + + {/* 搜索结果 */} { } return `user-${item.user?.id || index}`; }} - contentContainerStyle={[styles.searchResultsContent, { paddingBottom: listBottomInset }]} + contentContainerStyle={[ + styles.searchResultsContent, + { paddingBottom: listBottomInset }, + ]} showsVerticalScrollIndicator={false} scrollEnabled={true} keyboardShouldPersistTaps="handled" @@ -757,14 +751,18 @@ export const MessageListScreen: React.FC = () => { - - - - 搜索 + + {}} + onSubmit={() => {}} + placeholder="搜索" + /> @@ -1026,19 +1024,7 @@ function createMessageListStyles(colors: AppColors) { paddingBottom: spacing.sm, backgroundColor: colors.background.default, }, - searchBox: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.chat.surfaceInput, - borderRadius: 10, - paddingHorizontal: spacing.sm, - paddingVertical: 10, - }, - searchPlaceholder: { - fontSize: 14, - color: colors.text.hint, - marginLeft: spacing.xs, - }, + listContent: { flexGrow: 1, backgroundColor: colors.background.default, @@ -1062,60 +1048,49 @@ function createMessageListStyles(colors: AppColors) { }, searchModeContainer: { flex: 1, - backgroundColor: colors.background.default, + backgroundColor: colors.background.paper, }, - searchInputContainer: { + searchHeader: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.chat.surfaceInput, - borderRadius: 10, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: `${colors.divider}70`, paddingHorizontal: spacing.md, - marginHorizontal: spacing.md, - marginTop: spacing.md, - height: 40, + paddingBottom: spacing.sm, }, - searchInput: { + searchShell: { flex: 1, - fontSize: 15, - color: colors.text.primary, - marginLeft: spacing.md, }, - searchTabs: { - flexDirection: 'row', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - gap: spacing.sm, + cancelButton: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + marginLeft: spacing.sm, }, - searchTab: { - flex: 1, - paddingVertical: spacing.sm, - alignItems: 'center', - backgroundColor: colors.chat.surfaceInput, - borderRadius: 8, + cancelText: { + fontSize: fontSizes.md, + fontWeight: '600', + color: colors.primary.main, }, - searchTabActive: { - backgroundColor: colors.primary.main, - }, - searchTabText: { - fontSize: 14, - color: colors.text.secondary, - fontWeight: '500', - }, - searchTabTextActive: { - color: colors.primary.contrast, + searchTabContainer: { + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: `${colors.divider}50`, }, searchResultsContent: { flexGrow: 1, paddingHorizontal: spacing.md, + paddingTop: spacing.sm, }, searchResultItem: { flexDirection: 'row', alignItems: 'center', - paddingVertical: spacing.md, + padding: spacing.md, backgroundColor: colors.background.paper, - borderRadius: 10, + borderRadius: borderRadius.lg, marginBottom: spacing.sm, - paddingHorizontal: spacing.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: `${colors.divider}50`, }, searchResultContent: { flex: 1, diff --git a/src/screens/message/components/ChatScreen/EmojiPanel.tsx b/src/screens/message/components/ChatScreen/EmojiPanel.tsx index 0ffd5a8..dd53cdd 100644 --- a/src/screens/message/components/ChatScreen/EmojiPanel.tsx +++ b/src/screens/message/components/ChatScreen/EmojiPanel.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView, Platform } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, Platform } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; @@ -17,7 +17,7 @@ import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUr import { useAppColors, spacing } from '../../../../theme'; import { blurActiveElement } from '../../../../infrastructure/platform'; -const { height: SCREEN_HEIGHT } = Dimensions.get('window'); +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); // 表情尺寸配置 - 固定宽度 40px,使用 flex 布局 const EMOJI_SIZES = { @@ -35,12 +35,14 @@ interface EmojiPanelProps { onInsertEmoji: (emoji: string) => void; onInsertSticker: (stickerUrl: string) => void; onClose: () => void; + onFocusInput?: () => void; } export const EmojiPanel: React.FC = ({ onInsertEmoji, onInsertSticker, onClose, + onFocusInput, }) => { const colors = useAppColors(); const baseStyles = useChatScreenStyles(); @@ -88,6 +90,25 @@ export const EmojiPanel: React.FC = ({ }); }, [emojiSize, baseStyles]); + // 每行显示的 emoji 数量(基于 40px 宽度 + 20px margin) + const emojisPerRow = useMemo(() => { + const itemTotalWidth = 40 + 20; // width + margin*2 + const containerWidth = SCREEN_WIDTH - spacing.sm * 2; + return Math.floor(containerWidth / itemTotalWidth); + }, []); + + // 将 EMOJIS 分组成行数据,用于 FlatList 虚拟化渲染 + const emojiRows = useMemo(() => { + const rows: { id: string; emojis: string[] }[] = []; + for (let i = 0; i < EMOJIS.length; i += emojisPerRow) { + rows.push({ + id: `emoji-row-${i}`, + emojis: EMOJIS.slice(i, i + emojisPerRow), + }); + } + return rows; + }, [emojisPerRow]); + // 加载自定义表情 const loadStickers = useCallback(async () => { setLoadingStickers(true); @@ -110,24 +131,55 @@ export const EmojiPanel: React.FC = ({ - // 渲染 Emoji 面板(使用 flex 布局,每个表情固定 40px 宽度) - const renderEmojiPanel = () => ( - - {EMOJIS.map((emoji, index) => ( + // 处理 emoji 点击:插入后自动聚焦输入框 + const handleEmojiPress = useCallback((emoji: string) => { + onInsertEmoji(emoji); + // 使用 requestAnimationFrame 确保在 UI 更新后聚焦,减少卡顿感 + if (Platform.OS === 'web') { + onFocusInput?.(); + } else { + requestAnimationFrame(() => { + onFocusInput?.(); + }); + } + }, [onInsertEmoji, onFocusInput]); + + // 渲染单行 emoji + const renderEmojiRow = useCallback(({ item }: { item: { id: string; emojis: string[] } }) => ( + + {item.emojis.map((emoji, index) => ( onInsertEmoji(emoji)} + onPress={() => handleEmojiPress(emoji)} activeOpacity={0.7} > {emoji} ))} - + + ), [styles.emojiItem, styles.emojiText, handleEmojiPress]); + + // 渲染 Emoji 面板(使用 FlatList 虚拟化,只渲染可见行) + const renderEmojiPanel = () => ( + item.id} + renderItem={renderEmojiRow} + contentContainerStyle={styles.emojiContainer} + showsVerticalScrollIndicator={true} + keyboardShouldPersistTaps="handled" + initialNumToRender={12} + maxToRenderPerBatch={12} + windowSize={5} + removeClippedSubviews={true} + getItemLayout={(_data, index) => ({ + length: 60, + offset: 60 * index, + index, + })} + /> ); // 添加表情(从相册选择) @@ -296,6 +348,7 @@ export const EmojiPanel: React.FC = ({ return ( item.type === 'manage' ? 'manage-button' : item.sticker.id}