Files
frontend/src/screens/home/SearchScreen.tsx
lafay 1ffbb63753
Some checks failed
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Failing after 33m45s
Frontend CI / build-and-push-web (push) Failing after 46s
fix: 统一修复所有屏幕的安全区域问题
- 有 header 的屏幕使用 edges={['bottom']}(React Navigation 自动处理顶部)
- 没有 header 的屏幕使用 edges={['top', 'bottom']}(需要自己处理顶部)
- 修复了以下屏幕:
  - HomeScreen: edges={['bottom']} → ['top', 'bottom']
  - MessageListScreen: edges={['top']} → ['top', 'bottom']
  - ScheduleScreen: edges={['top']} → ['top', 'bottom']
  - ProfileScreen: edges={['top', 'bottom']} ✓ 已正确
  - PostDetailScreen: 空状态添加 edges={['bottom']}
  - UserScreen: 空状态添加 edges={['bottom']}
  - NotificationsScreen: edges={[]} → ['bottom']
  - SearchScreen: edges={['left', 'right']} → ['bottom']
  - BlockedUsersScreen: 加载状态添加 edges={['bottom']}
  - GroupInfoScreen: edges={['bottom']} ✓ 已正确
  - PrivateChatInfoScreen: 加载状态添加 edges={['bottom']}
2026-03-18 10:58:41 +08:00

577 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 搜索页 SearchScreen响应式版本
* 胡萝卜BBS - 搜索帖子、用户
* 支持响应式布局,宽屏下显示更大的搜索结果区域
*/
import React, { useState, useCallback } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
ScrollView,
} 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 } from '../../components/common';
import { HomeStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
const TABS = ['帖子', '用户'];
type SearchType = 'posts' | 'users';
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 [searchResults, setSearchResults] = useState<{
posts: Post[];
users: User[];
}>({
posts: [],
users: [],
});
const [hasSearched, setHasSearched] = useState(false);
// 保存当前搜索关键词用于Tab切换时重新搜索
const [currentKeyword, setCurrentKeyword] = useState('');
// 执行搜索 - 根据当前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') {
// 搜索帖子
const postsResponse = await postService.searchPosts(trimmedKeyword, 1, 20);
setSearchResults(prev => ({
...prev,
posts: postsResponse.list
}));
} else if (searchType === 'users') {
// 搜索用户
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
setSearchResults(prev => ({
...prev,
users: usersResponse.list
}));
}
} catch (error) {
console.error('搜索失败:', error);
}
setHasSearched(true);
}, [addSearchHistory, activeIndex]);
// 处理搜索提交
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.posts;
if (posts.length === 0) {
return (
<EmptyState
title="未找到相关帖子"
description="试试其他关键词吧"
icon="file-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 }}
>
{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>
</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}
/>
);
};
// 渲染用户搜索结果
const renderUserResults = () => {
const users = searchResults.users;
if (users.length === 0) {
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>
</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}
/>
);
};
// 渲染搜索结果
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>
<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,
// 移除阴影效果
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,
paddingTop: spacing.xs,
paddingBottom: spacing.xs,
},
suggestionsContainer: {
flex: 1,
},
section: {
marginTop: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
sectionTitle: {
fontWeight: '600',
color: colors.text.primary,
},
tagContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
tag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
},
tagText: {
marginLeft: spacing.xs,
},
// 移动端用户列表样式
userItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
},
userInfo: {
flex: 1,
marginLeft: spacing.md,
},
userName: {
fontWeight: '600',
color: colors.text.primary,
},
followingBadge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.primary.light + '30',
alignItems: 'center',
justifyContent: 'center',
},
// 桌面端用户卡片样式
userCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
minHeight: 180,
},
userCardInfo: {
alignItems: 'center',
marginTop: spacing.md,
},
userCardName: {
fontWeight: '600',
color: colors.text.primary,
marginBottom: spacing.xs,
},
});