Files
frontend/src/screens/home/SearchScreen.tsx
lafay 437dab3b6e
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m51s
Frontend CI / ota-android (push) Successful in 11m16s
Frontend CI / build-android-apk (push) Failing after 12m54s
Rebrand the entire application from "胡萝卜社区" (Carrot BBS) to "威友" (WithYou), including:
- Update app name, package name (skin.carrot.bbs → cn.qczlit.withyou), and bundle identifier
- Update API endpoints (bbs.littlelan.cn → withyou.littlelan.cn)
- Update URL scheme (carrotbbs:// → withyou://)
- Rename database from carrot_bbs to with_you
- Update CI/CD pipeline image names and artifact naming
- Add Android signing configuration for release builds
- Update all UI text and comments throughout the codebase
- Refactor registerStore to use in-memory state instead of AsyncStorage persistence
- Improve WebRTC track handling and call stream management
- Add metro platform resolution for native platform support

BREAKING app package, database, and URLs are no longer valid
2026-04-22 16:54:51 +08:00

667 lines
19 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响应式版本
* 威友 - 搜索帖子、用户
* 支持响应式布局,宽屏下显示更大的搜索结果区域
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
ScrollView,
RefreshControl,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Post, User } from '../../types';
import { useUserStore } from '../../stores';
import { postService, authService } from '../../services';
import { postSyncService } from '@/services/post';
import { PostCard, TabBar, SearchBar } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard';
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
const TABS = ['帖子', '用户'];
const DEFAULT_PAGE_SIZE = 20;
type SearchType = 'posts' | 'users';
interface SearchScreenProps {
onBack?: () => void;
}
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchScreenStyles(colors), [colors]);
const router = useRouter();
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 searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
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, autoLoad: false },
searchExtraParams
);
// 用户搜索结果(保持原有分页方式)
const [userResults, setUserResults] = useState<User[]>([]);
const [userLoading, setUserLoading] = useState(false);
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
const resetRef = useRef(reset);
resetRef.current = reset;
// 当搜索词变化时重置
useEffect(() => {
if (currentKeyword) {
refreshRef.current();
} else {
resetRef.current();
}
}, [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 === '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) => {
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
};
// 跳转到用户主页
const handleUserPress = (userId: string) => {
router.push(hrefs.hrefUserProfile(userId));
};
// 统一处理 PostCard 的操作(搜索页不支持删除)
const handlePostAction = (post: Post, action: PostCardAction) => {
switch (action.type) {
case 'press':
handlePostPress(post.id);
break;
case 'userPress':
if (post.author?.id) {
handleUserPress(post.author.id);
}
break;
case 'like':
postSyncService.likePost(post.id);
break;
case 'unlike':
postSyncService.unlikePost(post.id);
break;
case 'comment':
handlePostPress(post.id, true);
break;
case 'bookmark':
postSyncService.favoritePost(post.id);
break;
case 'unbookmark':
postSyncService.unfavoritePost(post.id);
break;
case 'share':
// 搜索页暂不处理分享
break;
case 'imagePress':
case 'delete':
break;
}
};
// 获取当前搜索类型
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}
onAction={(action) => handlePostAction(post, action)}
variant="list"
features={isMobile ? 'compact' : 'full'}
highlightKeyword={currentKeyword}
/>
))}
</ResponsiveGrid>
{isLoading && (
<View style={styles.loadingMore}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
// 移动端使用列表布局
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<PostCard
post={item}
onAction={(action) => handlePostAction(item, action)}
variant="list"
features="compact"
highlightKeyword={currentKeyword}
/>
)}
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={['top', 'bottom']}>
{/* 搜索输入框 */}
<View
style={[
styles.searchHeader,
{
paddingTop: spacing.sm,
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() : router.back())}
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>
);
};
function createSearchScreenStyles(colors: AppColors) {
return 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,
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',
},
});
}