refactor(ui): redesign SearchBar with compact mode and add home screen tabs
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m13s
Frontend CI / build-android-apk (push) Failing after 9m39s
Frontend CI / ota-android (push) Successful in 10m31s

Refactor SearchBar component to support compact mode with transparent styling,
reduced padding, and smaller icons. Add home screen tab navigation for switching
between home feed and market view with animated tab indicator. Also adjust
PostDetailScreen action button sizing and CommentItem margins for better spacing.
This commit is contained in:
lafay
2026-04-26 17:13:43 +08:00
parent 5815f1a5f7
commit 6cbd2092ac
17 changed files with 2341 additions and 64 deletions

View File

@@ -37,12 +37,22 @@ import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
import { postSyncService } from '@/services/post';
import { SearchScreen } from './SearchScreen';
import { CreatePostScreen } from '../create/CreatePostScreen';
import { MarketView } from './MarketView';
import { CreateTradeScreen } from '../trade/CreateTradeScreen';
import type { TradeCategory } from '../../types/trade';
import { ALL_TRADE_CATEGORIES } from '../../types/trade';
import * as hrefs from '../../navigation/hrefs';
import { blurActiveElement } from '../../infrastructure/platform';
const SORT_OPTIONS = ['关注', '推荐', '最新'];
const SORT_ICONS = ['account-heart', 'fire', 'clock-outline'];
const SORT_TYPES: PostType[] = ['follow', 'hot', 'latest'];
const MARKET_FILTER_OPTIONS = [
{ key: 'all' as const, label: '全部', icon: 'view-grid-outline' },
{ key: 'sell' as const, label: '出售', icon: 'tag-outline' },
{ key: 'buy' as const, label: '收购', icon: 'hand-coin-outline' },
];
const DEFAULT_PAGE_SIZE = 20;
const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12;
@@ -56,7 +66,7 @@ type ViewMode = 'list' | 'grid';
type PostType = 'follow' | 'hot' | 'latest';
type LatestCapsule = { id: string; name: string };
function createHomeStyles(colors: AppColors) {
function createHomeStyles(colors: AppColors, responsivePadding: number) {
return StyleSheet.create({
container: {
flex: 1,
@@ -66,13 +76,45 @@ function createHomeStyles(colors: AppColors) {
backgroundColor: colors.background.paper,
},
searchWrapper: {
paddingTop: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
homeTabSwitcher: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.xs,
marginBottom: spacing.xs,
paddingHorizontal: responsivePadding + 4,
},
homeTabSwitcherLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
homeTabSwitcherRight: {
flex: 1,
marginLeft: 16,
},
homeTabItem: {
paddingVertical: spacing.sm,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
homeTabItemActive: {
borderBottomColor: colors.text.primary,
},
homeTabText: {
fontSize: 20,
fontWeight: '400',
color: colors.text.hint,
},
homeTabTextActive: {
fontSize: 20,
fontWeight: '700',
color: colors.text.primary,
},
homeTabBar: {
marginTop: spacing.sm,
@@ -89,7 +131,6 @@ function createHomeStyles(colors: AppColors) {
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: colors.background.paper,
},
sortBarCapsuleScroll: {
flex: 1,
@@ -182,21 +223,22 @@ export const HomeScreen: React.FC = () => {
const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus);
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
const styles = useMemo(() => createHomeStyles(colors), [colors]);
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
// 使用响应式 hook
const {
width,
isMobile,
isTablet,
isDesktop,
const {
width,
isMobile,
isTablet,
isDesktop,
isWideScreen,
} = useResponsive();
// 响应式间距
const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 });
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const styles = useMemo(() => createHomeStyles(colors, responsivePadding), [colors, responsivePadding]);
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
const [sortIndex, setSortIndex] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>('list');
@@ -214,6 +256,14 @@ export const HomeScreen: React.FC = () => {
// 发帖弹窗状态
const [showCreatePost, setShowCreatePost] = useState(false);
// 广场/市集 Tab
const [homeTab, setHomeTab] = useState<'square' | 'market'>('square');
const [showCreateTrade, setShowCreateTrade] = useState(false);
// 市集筛选状态
const [marketFilterType, setMarketFilterType] = useState<'all' | 'sell' | 'buy'>('all');
const [marketCategory, setMarketCategory] = useState<TradeCategory | 'all'>('all');
// 分享面板状态
const [showShareSheet, setShowShareSheet] = useState(false);
const [sharePost, setSharePost] = useState<Post | null>(null);
@@ -530,6 +580,13 @@ export const HomeScreen: React.FC = () => {
setSortIndex(nextIndex);
}, [sortIndex]);
const cycleMarketFilter = useCallback(() => {
setMarketFilterType(prev => {
const idx = MARKET_FILTER_OPTIONS.findIndex(f => f.key === prev);
return MARKET_FILTER_OPTIONS[(idx + 1) % MARKET_FILTER_OPTIONS.length].key;
});
}, []);
useEffect(() => {
const loadChannels = async () => {
const list = await channelService.list();
@@ -856,18 +913,42 @@ export const HomeScreen: React.FC = () => {
{/* 顶部Header */}
<View style={styles.header}>
{/* 搜索栏 */}
<View style={[styles.searchWrapper, { paddingHorizontal: responsivePadding }]}>
<SearchBar
value=""
onChangeText={() => {}}
onSubmit={handleSearchPress}
onFocus={handleSearchPress}
placeholder="搜索帖子、用户"
/>
{/* 广场/市集切换 */}
<View style={styles.homeTabSwitcher}>
<View style={styles.homeTabSwitcherLeft}>
<TouchableOpacity
activeOpacity={0.7}
style={[styles.homeTabItem, homeTab === 'square' && styles.homeTabItemActive]}
onPress={() => setHomeTab('square')}
>
<Text style={homeTab === 'square' ? styles.homeTabTextActive : styles.homeTabText}>
广
</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
style={[styles.homeTabItem, homeTab === 'market' && styles.homeTabItemActive]}
onPress={() => setHomeTab('market')}
>
<Text style={homeTab === 'market' ? styles.homeTabTextActive : styles.homeTabText}>
</Text>
</TouchableOpacity>
</View>
<View style={styles.homeTabSwitcherRight}>
<SearchBar
value=""
onChangeText={() => {}}
onSubmit={handleSearchPress}
onFocus={handleSearchPress}
placeholder="搜索"
compact
/>
</View>
</View>
{/* 排序筛选 + 频道 */}
{/* 排序筛选 + 频道(仅广场模式显示) */}
{homeTab === 'square' && (
<View style={styles.sortBar}>
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
<MaterialCommunityIcons
@@ -894,9 +975,56 @@ export const HomeScreen: React.FC = () => {
</Text>
</TouchableOpacity>
</View>
)}
{/* 市集分类 + 筛选(仅市集模式显示) */}
{homeTab === 'market' && (
<View style={styles.sortBar}>
<FlashList
horizontal
data={[{ key: 'all', label: '全部' }, ...ALL_TRADE_CATEGORIES]}
renderItem={({ item }: { item: { key: string; label: string } }) => (
<TouchableOpacity
activeOpacity={0.7}
style={[
styles.capsuleItem,
marketCategory === item.key && { backgroundColor: `${colors.primary.main}18` },
]}
onPress={() => setMarketCategory(item.key as TradeCategory | 'all')}
>
<Text style={[
styles.capsuleText,
marketCategory === item.key ? styles.capsuleTextActive : {},
]}>
{item.label}
</Text>
</TouchableOpacity>
)}
keyExtractor={(item: { key: string; label: string }) => item.key}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.sortBarCapsuleContent}
style={styles.sortBarCapsuleScroll}
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={cycleMarketFilter}
style={styles.sortTrigger}
>
<MaterialCommunityIcons
name={(MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.icon || 'view-grid-outline') as any}
size={18}
color={colors.text.secondary}
/>
<Text style={styles.sortTriggerText}>
{MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.label || '全部'}
</Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 帖子列表 */}
{/* 内容区域 */}
{homeTab === 'square' ? (
<View style={styles.contentContainer}>
{isInitialLoading ? (
<Loading fullScreen />
@@ -906,8 +1034,17 @@ export const HomeScreen: React.FC = () => {
renderResponsiveGrid()
)}
</View>
) : (
<MarketView
onCreateTrade={() => setShowCreateTrade(true)}
filterType={marketFilterType}
onFilterTypeChange={setMarketFilterType}
selectedCategory={marketCategory}
onSelectedCategoryChange={setMarketCategory}
/>
)}
{/* 漂浮发帖按钮 */}
{/* 漂浮发帖/发布按钮 */}
<TouchableOpacity
style={[
styles.floatingButton,
@@ -915,7 +1052,11 @@ export const HomeScreen: React.FC = () => {
isWideScreen && styles.floatingButtonWide,
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
]}
onPress={handleCreatePost}
onPress={homeTab === 'market' ? () => {
if (!isAuthenticated) { router.push('/login'); return; }
if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; }
setShowCreateTrade(true);
} : handleCreatePost}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
@@ -945,6 +1086,18 @@ export const HomeScreen: React.FC = () => {
/>
</Modal>
{/* 发布交易弹窗 */}
<Modal
visible={showCreateTrade}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowCreateTrade(false)}
>
<CreateTradeScreen
onClose={() => setShowCreateTrade(false)}
/>
</Modal>
{/* 分享面板 */}
<ShareSheet
visible={showShareSheet}

View File

@@ -0,0 +1,223 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { View, StyleSheet, RefreshControl, Pressable } from 'react-native';
import { FlashList, FlashListRef } from '@shopify/flash-list';
import type { ListRenderItem } from '@shopify/flash-list';
import { useAppColors, type AppColors } from '../../theme';
import { Text, Loading, EmptyState } from '../../components/common';
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
import { tradeService } from '../../services/trade/tradeService';
import { useIsAuthenticated } from '../../stores/auth';
import type {
TradeItemDTO,
TradeCategory,
TradeType,
} from '../../types/trade';
import { useRouter } from 'expo-router';
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
type TradeFilterType = 'all' | 'sell' | 'buy';
const PAGE_SIZE = 20;
function createMarketStyles(colors: AppColors, gap: number, padding: number) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
listContent: {
paddingHorizontal: padding,
paddingTop: gap,
paddingBottom: 80,
},
columnWrapper: {
justifyContent: 'space-between',
},
});
}
interface MarketViewProps {
onCreateTrade: () => void;
filterType?: TradeFilterType;
onFilterTypeChange?: (type: TradeFilterType) => void;
selectedCategory?: TradeCategory | 'all';
onSelectedCategoryChange?: (cat: TradeCategory | 'all') => void;
}
export function MarketView({
onCreateTrade,
filterType: filterTypeProp,
onFilterTypeChange,
selectedCategory: selectedCategoryProp,
onSelectedCategoryChange,
}: MarketViewProps) {
const colors = useAppColors();
const router = useRouter();
const isAuth = useIsAuthenticated();
const { width, isMobile } = useResponsive();
const gap = useResponsiveSpacing({ xs: 6, sm: 8, md: 10, lg: 12, xl: 16 });
const padding = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20 });
const styles = useMemo(() => createMarketStyles(colors, gap, padding), [colors, gap, padding]);
const [items, setItems] = useState<TradeItemDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [cursor, setCursor] = useState<string | undefined>();
const [filterTypeLocal, setFilterTypeLocal] = useState<TradeFilterType>('all');
const [selectedCategoryLocal, setSelectedCategoryLocal] = useState<TradeCategory | 'all'>('all');
const filterType = filterTypeProp ?? filterTypeLocal;
const selectedCategory = selectedCategoryProp ?? selectedCategoryLocal;
const setFilterType = onFilterTypeChange ?? setFilterTypeLocal;
const setSelectedCategory = onSelectedCategoryChange ?? setSelectedCategoryLocal;
const flashListRef = useRef<any>(null);
// 网格列数移动端2列平板3列桌面4列
const numColumns = useMemo(() => {
if (width >= 1200) return 4;
if (width >= 768) return 3;
return 2;
}, [width]);
// 计算卡片宽度(用于固定宽高比)
const cardWidth = useMemo(() => {
const totalGap = gap * (numColumns - 1);
return Math.floor((width - padding * 2 - totalGap) / numColumns);
}, [width, padding, gap, numColumns]);
const fetchItems = useCallback(async (isRefresh = false) => {
try {
if (isRefresh) {
setIsRefreshing(true);
} else if (!isLoading) {
return;
}
const tradeType = filterType === 'all' ? undefined : (filterType as TradeType);
const category = selectedCategory === 'all' ? undefined : (selectedCategory as TradeCategory);
const res = await tradeService.getTradeItems({
trade_type: tradeType,
category,
page_size: PAGE_SIZE,
});
setItems(res.list || []);
setCursor(res.next_cursor);
setHasMore(res.has_more);
} catch (e) {
console.error('Failed to fetch trade items:', e);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [filterType, selectedCategory]);
useEffect(() => {
setIsLoading(true);
setCursor(undefined);
fetchItems(true);
}, [filterType, selectedCategory]);
const loadMore = useCallback(async () => {
if (isLoadingMore || !hasMore || !cursor) return;
setIsLoadingMore(true);
try {
const tradeType = filterType === 'all' ? undefined : (filterType as TradeType);
const category = selectedCategory === 'all' ? undefined : (selectedCategory as TradeCategory);
const res = await tradeService.getTradeItems({
trade_type: tradeType,
category,
cursor,
page_size: PAGE_SIZE,
});
setItems(prev => [...prev, ...(res.list || [])]);
setCursor(res.next_cursor);
setHasMore(res.has_more);
} catch (e) {
console.error('Failed to load more trade items:', e);
} finally {
setIsLoadingMore(false);
}
}, [isLoadingMore, hasMore, cursor, filterType, selectedCategory]);
const handlePressItem = useCallback((id: string) => {
router.push(`/trade/${id}` as any);
}, [router]);
const handleFavorite = useCallback(async (id: string) => {
if (!isAuth) return;
try {
const item = items.find(i => i.id === id);
if (!item) return;
if (item.is_favorited) {
await tradeService.unfavorite(id);
} else {
await tradeService.favorite(id);
}
setItems(prev =>
prev.map(i =>
i.id === id
? { ...i, is_favorited: !i.is_favorited, favorites_count: i.favorites_count + (i.is_favorited ? -1 : 1) }
: i
)
);
} catch (e) {
console.error('Favorite failed:', e);
}
}, [items, isAuth]);
const renderItem = useCallback<ListRenderItem<TradeItemDTO>>(({ item }) => (
<View style={{ width: cardWidth, marginBottom: gap }}>
<TradeCard
item={item}
variant="grid"
onPress={handlePressItem}
onFavorite={handleFavorite}
/>
</View>
), [handlePressItem, handleFavorite, cardWidth, gap]);
const keyExtractor = useCallback((item: TradeItemDTO) => item.id, []);
if (isLoading) {
return <Loading fullScreen />;
}
return (
<View style={styles.container}>
<FlashList
ref={flashListRef as any}
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={numColumns}
key={`market_${numColumns}`}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={() => fetchItems(true)}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={
<EmptyState
title="暂无商品"
description="还没有人发布商品,快来发布第一条吧"
/>
}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
drawDistance={250}
/>
</View>
);
}

View File

@@ -1472,7 +1472,7 @@ export const PostDetailScreen: React.FC = () => {
<TouchableOpacity style={styles.bottomActionItem} onPress={handleLike}>
<MaterialCommunityIcons
name={post?.is_liked ? 'thumb-up' : 'thumb-up-outline'}
size={isDesktop ? 23 : 21}
size={isDesktop ? 26 : 24}
color={post?.is_liked ? colors.primary.main : colors.text.secondary}
/>
<Text variant="caption" style={styles.bottomActionCount}>
@@ -1483,7 +1483,7 @@ export const PostDetailScreen: React.FC = () => {
<TouchableOpacity style={styles.bottomActionItem} onPress={handleFavorite}>
<MaterialCommunityIcons
name={post?.is_favorited ? 'star' : 'star-outline'}
size={isDesktop ? 23 : 21}
size={isDesktop ? 26 : 24}
color={post?.is_favorited ? colors.warning.main : colors.text.secondary}
/>
<Text variant="caption" style={styles.bottomActionCount}>
@@ -1492,7 +1492,7 @@ export const PostDetailScreen: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity style={styles.bottomActionItem} onPress={handleShare}>
<MaterialCommunityIcons name="share-outline" size={isDesktop ? 23 : 21} color={colors.text.secondary} />
<MaterialCommunityIcons name="share-outline" size={isDesktop ? 26 : 24} color={colors.text.secondary} />
<Text variant="caption" style={styles.bottomActionCount}>
{formatNumber(post?.shares_count || 0)}
</Text>
@@ -2153,7 +2153,7 @@ function createPostDetailStyles(colors: AppColors) {
borderRadius: 20,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
minHeight: 40,
minHeight: 44,
justifyContent: 'center',
},
inputTriggerText: {
@@ -2163,14 +2163,14 @@ function createPostDetailStyles(colors: AppColors) {
bottomComposerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs + 2,
gap: spacing.xs,
},
bottomActionItem: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 2,
paddingHorizontal: spacing.sm + 1,
marginLeft: spacing.xs,
paddingHorizontal: spacing.sm,
marginLeft: 0,
},
bottomActionCount: {
marginTop: 1,