From 30c493c88f0a87173de822e9766ae3200fcc2873 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 21 Apr 2026 12:31:51 +0800 Subject: [PATCH] feat(search): add keyword highlighting to PostCard and improve QRCodeScanner Add HighlightText component for search result highlighting in PostCard. Refactor QRCodeScanner to use async permission methods with better error handling. Improve SearchScreen with stable function refs and pass highlightKeyword to PostCard. Modernize MessageListScreen search UI with SearchBar and TabBar components. Add emoji virtualization to EmojiPanel and auto-focus input after inserting emoji. BREAKING CHANGE: EmojiPanel onFocusInput prop added for focus management --- src/components/business/PostCard/PostCard.tsx | 15 +- src/components/business/PostCard/types.ts | 3 + src/components/business/QRCodeScanner.tsx | 31 ++-- src/components/common/HighlightText.tsx | 54 ++++++ src/components/common/index.ts | 1 + src/screens/home/SearchScreen.tsx | 26 +-- src/screens/message/ChatScreen.tsx | 1 + src/screens/message/MessageListScreen.tsx | 167 ++++++++---------- .../components/ChatScreen/EmojiPanel.tsx | 79 +++++++-- 9 files changed, 243 insertions(+), 134 deletions(-) create mode 100644 src/components/common/HighlightText.tsx 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}