Files
frontend/src/screens/home/SearchScreen.tsx

636 lines
18 KiB
TypeScript
Raw Normal View History

/**
* SearchScreen
* BBS -
*
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
ScrollView,
RefreshControl,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { Post, User } from '../../types';
import { useUserStore } from '../../stores';
import { postService, authService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
import { HomeStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
const TABS = ['帖子', '用户'];
const DEFAULT_PAGE_SIZE = 20;
type SearchType = 'posts' | 'users';
2026-03-16 17:47:10 +08:00
interface SearchScreenProps {
onBack?: () => void;
navigation?: NavigationProp;
}
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation: propNavigation }) => {
// 如果传入了 navigation 则使用传入的,否则使用 hook 获取的
const navigation = propNavigation || useNavigation<NavigationProp>();
const insets = useSafeAreaInsets();
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
// 使用响应式 hook
const {
width,
isMobile,
isTablet,
isDesktop,
isWideScreen
} = useResponsive();
// 响应式间距
const responsivePadding = useResponsiveSpacing({
xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 32, '3xl': 40, '4xl': 48
});
const responsiveGap = useResponsiveSpacing({
xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32
});
// 响应式搜索栏最大宽度(使用数字)
const searchBarMaxWidth = useResponsiveValue({
xs: 600,
sm: 600,
md: 600,
lg: 700,
xl: 800,
'2xl': 900,
'3xl': 1000,
'4xl': 1200,
});
const [searchText, setSearchText] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [hasSearched, setHasSearched] = useState(false);
// 保存当前搜索关键词用于Tab切换时重新搜索
const [currentKeyword, setCurrentKeyword] = useState('');
// 使用游标分页进行帖子搜索
const {
list: searchResults,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
reset,
} = useCursorPagination<Post, { query: string }>(
async ({ cursor, pageSize, extraParams }) => {
if (!extraParams?.query) {
return { list: [], next_cursor: null, prev_cursor: null, has_more: false };
}
const response = await postService.searchPostsCursor(extraParams.query, {
cursor,
page_size: pageSize,
});
return response;
},
{ pageSize: DEFAULT_PAGE_SIZE },
{ query: '' }
);
// 用户搜索结果(保持原有分页方式)
const [userResults, setUserResults] = useState<User[]>([]);
const [userLoading, setUserLoading] = useState(false);
// 当搜索词变化时重置
useEffect(() => {
if (currentKeyword) {
refresh();
} else {
reset();
}
}, [currentKeyword]);
// 执行搜索 - 根据当前Tab执行对应类型的搜索
const performSearch = useCallback(async (keyword: string) => {
if (!keyword.trim()) return;
const trimmedKeyword = keyword.trim();
// 保存当前搜索关键词
setCurrentKeyword(trimmedKeyword);
// 添加到搜索历史
addSearchHistory(trimmedKeyword);
try {
const searchType = getSearchType();
if (searchType === 'posts') {
// 帖子搜索由 useCursorPagination 处理,这里只需触发刷新
refresh();
} else if (searchType === 'users') {
// 用户搜索保持原有方式
setUserLoading(true);
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
setUserResults(usersResponse.list || []);
}
} catch (error) {
console.error('搜索失败:', error);
}
setHasSearched(true);
}, [addSearchHistory, activeIndex, refresh]);
// 处理搜索提交
const handleSearch = () => {
performSearch(searchText);
};
// 处理搜索历史点击
const handleHistoryPress = (keyword: string) => {
setSearchText(keyword);
performSearch(keyword);
};
// 清除搜索历史
const handleClearHistory = () => {
clearSearchHistory();
};
// 跳转到帖子详情
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
navigation.navigate('PostDetail', { postId, scrollToComments });
};
// 跳转到用户主页
const handleUserPress = (userId: string) => {
navigation.navigate('UserProfile', { userId });
};
// 获取当前搜索类型
const getSearchType = (): SearchType => {
switch (activeIndex) {
case 0: return 'posts';
case 1: return 'users';
default: return 'posts';
}
};
// 渲染帖子搜索结果(使用响应式网格)
const renderPostResults = () => {
const posts = searchResults;
if (posts.length === 0 && !isLoading) {
return (
<EmptyState
title="未找到相关帖子"
description="试试其他关键词吧"
icon="file-search-outline"
/>
);
}
// 平板/桌面端使用网格布局
if (isTablet || isDesktop || isWideScreen) {
return (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: responsivePadding }}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
<ResponsiveGrid
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
gap={{ xs: 12, sm: 14, md: 16, lg: 20, xl: 24 }}
containerStyle={{ paddingHorizontal: responsivePadding }}
>
{posts.map(post => (
<PostCard
key={post.id}
post={post}
onPress={() => handlePostPress(post.id)}
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
onLike={() => {}}
onComment={() => handlePostPress(post.id, true)}
onBookmark={() => {}}
onShare={() => {}}
compact={isMobile}
/>
))}
</ResponsiveGrid>
{isLoading && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
// 移动端使用列表布局
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<PostCard
post={item}
onPress={() => handlePostPress(item.id)}
onUserPress={() => item.author ? handleUserPress(item.author.id) : () => {}}
onLike={() => {}}
onComment={() => handlePostPress(item.id, true)}
onBookmark={() => {}}
onShare={() => {}}
compact
/>
)}
keyExtractor={item => item.id}
contentContainerStyle={{ paddingBottom: responsivePadding }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListFooterComponent={isLoading ? <Loading size="sm" /> : null}
/>
);
};
// 渲染用户搜索结果
const renderUserResults = () => {
const users = userResults;
if (users.length === 0 && !userLoading) {
return (
<EmptyState
title="未找到相关用户"
description="试试其他关键词吧"
icon="account-search-outline"
/>
);
}
// 平板/桌面端使用网格布局
if (isTablet || isDesktop || isWideScreen) {
return (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: responsivePadding }}
>
<ResponsiveGrid
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
gap={{ xs: 12, sm: 14, md: 16, lg: 20, xl: 24 }}
containerStyle={{ paddingHorizontal: responsivePadding }}
>
{users.map(item => (
<TouchableOpacity
key={item.id}
style={[
styles.userCard,
{ padding: responsiveGap }
]}
onPress={() => handleUserPress(item.id)}
>
<Avatar
source={item.avatar}
size={isDesktop ? 70 : isTablet ? 60 : 50}
name={item.nickname}
/>
<View style={styles.userCardInfo}>
<Text
variant="body"
style={[
styles.userCardName,
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
]}
>
{item.nickname}
</Text>
<Text
variant="caption"
color={colors.text.secondary}
style={{ fontSize: isDesktop ? fontSizes.md : fontSizes.sm }}
>
{item.bio || '@' + item.username}
</Text>
</View>
{item.is_following && (
<View style={styles.followingBadge}>
<MaterialCommunityIcons name="check" size={14} color={colors.primary.main} />
</View>
)}
</TouchableOpacity>
))}
</ResponsiveGrid>
{userLoading && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
// 移动端使用列表布局
return (
<FlatList
data={users}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.userItem,
{
marginHorizontal: responsivePadding,
marginVertical: responsiveGap / 2,
padding: responsiveGap
}
]}
onPress={() => handleUserPress(item.id)}
>
<Avatar
source={item.avatar}
size={50}
name={item.nickname}
/>
<View style={styles.userInfo}>
<Text variant="body" style={styles.userName}>{item.nickname}</Text>
<Text variant="caption" color={colors.text.secondary}>{item.bio || '@' + item.username}</Text>
</View>
{item.is_following && (
<View style={styles.followingBadge}>
<MaterialCommunityIcons name="check" size={14} color={colors.primary.main} />
</View>
)}
</TouchableOpacity>
)}
keyExtractor={item => item.id}
contentContainerStyle={{ paddingVertical: responsiveGap }}
showsVerticalScrollIndicator={false}
ListFooterComponent={userLoading ? <Loading size="sm" /> : null}
/>
);
};
// 渲染搜索结果
const renderSearchResults = () => {
const searchType = getSearchType();
if (searchType === 'posts') {
return renderPostResults();
}
if (searchType === 'users') {
return renderUserResults();
}
return null;
};
// 渲染搜索历史和热门搜索
const renderSearchSuggestions = () => {
if (hasSearched) return null;
return (
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
{/* 搜索历史 */}
{history.length > 0 && (
<View style={[styles.section, { marginTop: responsiveGap }]}>
<View style={styles.sectionHeader}>
<Text
variant="body"
style={[
styles.sectionTitle,
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
]}
>
</Text>
<TouchableOpacity onPress={handleClearHistory}>
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
</TouchableOpacity>
</View>
<View style={styles.tagContainer}>
{history.map((keyword, index) => (
<TouchableOpacity
key={index}
style={[
styles.tag,
{
paddingHorizontal: responsiveGap,
paddingVertical: responsiveGap / 2,
marginRight: responsiveGap / 2,
marginBottom: responsiveGap / 2
}
]}
onPress={() => handleHistoryPress(keyword)}
>
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
<Text
variant="caption"
color={colors.text.secondary}
style={[
styles.tagText,
{ fontSize: isDesktop ? fontSizes.md : fontSizes.sm }
]}
>
{keyword}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
);
};
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{/* 搜索输入框 */}
<View
style={[
styles.searchHeader,
{
paddingTop: Math.max(spacing.sm, insets.top + spacing.xs),
paddingHorizontal: responsivePadding,
paddingBottom: spacing.xs,
},
]}
>
<View style={[styles.searchShell, { maxWidth: searchBarMaxWidth }]}>
<SearchBar
value={searchText}
onChangeText={setSearchText}
onSubmit={handleSearch}
placeholder="搜索帖子、用户"
autoFocus
/>
</View>
2026-03-16 17:47:10 +08:00
<TouchableOpacity
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
onPress={() => onBack ? onBack() : navigation.goBack()}
activeOpacity={0.85}
>
<Text
variant="body"
color={colors.primary.main}
style={styles.cancelText}
>
</Text>
</TouchableOpacity>
</View>
{/* Tab切换 */}
<View style={styles.tabWrapper}>
<TabBar
tabs={TABS}
activeIndex={activeIndex}
onTabChange={(index) => {
setActiveIndex(index);
// 如果已经搜索过切换Tab时重新搜索
if (currentKeyword && hasSearched) {
performSearch(currentKeyword);
}
}}
/>
</View>
{/* 内容区域 */}
{hasSearched ? renderSearchResults() : renderSearchSuggestions()}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
searchHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}70`,
},
searchShell: {
flex: 1,
borderRadius: borderRadius.xl,
backgroundColor: `${colors.primary.main}08`,
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs,
2026-03-16 17:47:10 +08:00
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
cancelButton: {
backgroundColor: `${colors.primary.main}14`,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
cancelText: {
fontSize: fontSizes.md,
fontWeight: '600',
},
tabWrapper: {
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}50`,
},
suggestionsContainer: {
flex: 1,
},
section: {
marginTop: spacing.md,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontWeight: '600',
color: colors.text.primary,
},
tagContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
tag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.divider,
},
tagText: {
marginLeft: spacing.xs,
},
userCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
},
userCardInfo: {
flex: 1,
marginLeft: spacing.md,
},
userCardName: {
fontWeight: '600',
color: colors.text.primary,
},
userItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
},
userInfo: {
flex: 1,
marginLeft: spacing.md,
},
userName: {
fontWeight: '600',
color: colors.text.primary,
},
followingBadge: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: `${colors.primary.main}14`,
alignItems: 'center',
justifyContent: 'center',
},
loadingMore: {
paddingVertical: spacing.md,
alignItems: 'center',
},
});