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

@@ -0,0 +1,623 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, ScrollView, Image, StyleSheet, Pressable, Alert, Modal } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useAppColors, spacing, borderRadius, fontSizes, shadows, type AppColors } from '../../theme';
import { Text, Loading, AppBackButton } from '../../components/common';
import { tradeService } from '../../services/trade/tradeService';
import { messageService } from '../../services/message/messageService';
import { useIsAuthenticated, useCurrentUser } from '../../stores/auth';
import type { TradeItemDetailDTO } from '../../types/trade';
import { TRADE_CONDITION_MAP } from '../../types/trade';
import { hrefChat, hrefEditTrade } from '../../navigation/hrefs';
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
...shadows.sm,
},
headerLeft: {
width: 40,
alignItems: 'flex-start',
},
headerTitle: {
fontSize: fontSizes.xl,
fontWeight: '700',
color: colors.text.primary,
},
headerRight: {
width: 40,
},
imageContainer: {
width: '100%',
aspectRatio: 1,
backgroundColor: colors.background.paper,
position: 'relative',
},
image: {
width: '100%',
height: '100%',
resizeMode: 'contain',
},
// 降价标签
discountBadge: {
position: 'absolute',
bottom: 12,
left: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: borderRadius.lg,
backgroundColor: 'rgba(0,0,0,0.6)',
},
discountBadgeText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
content: {
padding: spacing.md,
backgroundColor: colors.background.paper,
},
// 顶部卖家信息栏
sellerHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
sellerAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background.default,
},
sellerInfo: {
flex: 1,
},
sellerNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
sellerName: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
},
sellerCreditBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
backgroundColor: '#FFE066',
},
sellerCreditText: {
fontSize: 10,
color: '#5C4B00',
fontWeight: '700',
},
sellerMeta: {
fontSize: fontSizes.sm,
color: colors.text.hint,
marginTop: 2,
paddingLeft: 36 + spacing.sm,
},
priceInHeader: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: '#FF4D4F',
},
titleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
marginBottom: spacing.md,
},
conditionBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: borderRadius.sm,
backgroundColor: '#FFE066',
flexShrink: 0,
},
conditionBadgeText: {
fontSize: fontSizes.sm,
color: '#5C4B00',
fontWeight: '600',
},
// 副标题信息行(包邮、自提)
subInfoRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
flexWrap: 'wrap',
},
subInfoItem: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
},
shippingTag: {
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: borderRadius.sm,
borderWidth: 0.5,
borderColor: '#FFCCC7',
backgroundColor: '#FFF2F0',
},
shippingTagText: {
fontSize: fontSizes.xs,
color: '#FF4D4F',
},
// 促销横幅
promoBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: '#FFF5F5',
borderRadius: borderRadius.md,
marginBottom: spacing.md,
},
promoBannerText: {
fontSize: fontSizes.md,
color: '#FF4D4F',
fontWeight: '700',
},
promoBannerSub: {
fontSize: fontSizes.md,
color: '#FF7875',
},
title: {
fontSize: fontSizes['3xl'],
fontWeight: '600',
color: colors.text.primary,
lineHeight: 34,
flex: 1,
},
// 商品属性行
attrGrid: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
marginBottom: spacing.md,
gap: spacing.lg,
},
attrItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
attrLabel: {
fontSize: fontSizes.sm,
color: colors.text.hint,
},
attrValue: {
fontSize: fontSizes.sm,
color: colors.text.primary,
fontWeight: '500',
},
// 描述
contentText: {
fontSize: fontSizes.lg,
color: colors.text.primary,
lineHeight: 28,
marginBottom: spacing.md,
},
// 底部操作栏
bottomBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 4,
backgroundColor: colors.background.paper,
borderTopWidth: 1,
borderTopColor: colors.divider,
gap: spacing.md,
},
bottomAction: {
alignItems: 'center',
justifyContent: 'center',
gap: 2,
},
bottomActionText: {
fontSize: 10,
color: colors.text.secondary,
},
chatButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
height: 36,
paddingHorizontal: spacing.lg,
borderRadius: 14,
backgroundColor: colors.primary.main,
},
chatButtonText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
chatButtonDisabled: {
backgroundColor: colors.text.hint,
},
// 管理按钮
editButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
height: 48,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.divider,
paddingHorizontal: spacing.lg,
backgroundColor: colors.background.paper,
},
editButtonText: {
fontSize: fontSizes.md,
color: colors.text.primary,
fontWeight: '600',
},
statusSection: {
marginBottom: spacing.md,
},
divider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing.md,
},
// 底部弹窗
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.background.paper,
borderTopLeftRadius: borderRadius.xl,
borderTopRightRadius: borderRadius.xl,
paddingBottom: spacing.xl,
},
modalItem: {
paddingVertical: spacing.md,
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
modalItemText: {
fontSize: fontSizes.lg,
color: colors.text.primary,
},
modalCancel: {
paddingVertical: spacing.md,
alignItems: 'center',
marginTop: spacing.sm,
},
modalCancelText: {
fontSize: fontSizes.lg,
color: colors.text.hint,
},
// 管理按钮
manageButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
height: 36,
paddingHorizontal: spacing.lg,
borderRadius: 14,
backgroundColor: 'transparent',
},
manageButtonText: {
fontSize: fontSizes.md,
color: colors.text.primary,
fontWeight: '600',
},
});
}
interface TradeDetailScreenProps {
tradeId: string;
}
export function TradeDetailScreen({ tradeId }: TradeDetailScreenProps) {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const isAuth = useIsAuthenticated();
const currentUser = useCurrentUser();
const [item, setItem] = useState<TradeItemDetailDTO | null>(null);
const [loading, setLoading] = useState(true);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [manageModalVisible, setManageModalVisible] = useState(false);
const isOwner = item?.user_id === currentUser?.id;
const fetchItem = useCallback(async () => {
try {
setLoading(true);
const data = await tradeService.getTradeItem(tradeId);
setItem(data);
} catch (e) {
console.error('Failed to fetch trade item:', e);
Alert.alert('错误', '加载失败');
} finally {
setLoading(false);
}
}, [tradeId]);
useEffect(() => {
fetchItem();
}, [fetchItem]);
useEffect(() => {
if (tradeId) {
tradeService.recordView(tradeId).catch(() => {});
}
}, [tradeId]);
const handleFavorite = useCallback(async () => {
if (!isAuth || !item) return;
try {
if (item.is_favorited) {
await tradeService.unfavorite(item.id);
} else {
await tradeService.favorite(item.id);
}
setItem(prev => prev ? {
...prev,
is_favorited: !prev.is_favorited,
favorites_count: prev.favorites_count + (prev.is_favorited ? -1 : 1),
} : prev);
} catch (e) {
console.error('Favorite failed:', e);
}
}, [isAuth, item]);
const handleDelete = useCallback(() => {
if (!item) return;
setManageModalVisible(false);
Alert.alert('删除确认', '确定要删除这个商品吗?此操作不可恢复。', [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
await tradeService.deleteTradeItem(item.id);
router.back();
} catch (e) {
Alert.alert('删除失败', '请稍后重试');
}
},
},
]);
}, [item, router]);
const handleEdit = useCallback(() => {
setManageModalVisible(false);
router.push(hrefEditTrade(item?.id || '') as any);
}, [router, item?.id]);
const handleChat = useCallback(async () => {
if (!isAuth || !item?.author?.id) {
Alert.alert('提示', '请先登录');
return;
}
try {
const conversation = await messageService.createConversation(item.author.id);
if (conversation) {
router.push(
hrefChat({
conversationId: conversation.id.toString(),
userId: item.author.id,
}) as any
);
}
} catch (error) {
console.error('创建会话失败:', error);
Alert.alert('错误', '无法发起聊天,请稍后重试');
}
}, [isAuth, item?.author?.id, router]);
if (loading) {
return <Loading fullScreen />;
}
if (!item) {
return (
<SafeAreaView style={styles.container}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.text.hint }}></Text>
</View>
</SafeAreaView>
);
}
const images = item.images || [];
const currentImage = images[currentImageIndex];
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<AppBackButton onPress={() => router.back()} />
</View>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerRight} />
</View>
<ScrollView>
<View style={styles.content}>
{/* 卖家信息头部 */}
{item.author && (
<View>
<View style={styles.sellerHeader}>
{item.author.avatar ? (
<Image source={{ uri: item.author.avatar }} style={styles.sellerAvatar} />
) : (
<View style={[styles.sellerAvatar, { backgroundColor: colors.primary.main + '33', alignItems: 'center', justifyContent: 'center' }]}>
<MaterialCommunityIcons name="account" size={18} color={colors.primary.main} />
</View>
)}
<View style={styles.sellerInfo}>
<View style={styles.sellerNameRow}>
<Text style={styles.sellerName} numberOfLines={1}>
{item.author.nickname && item.author.nickname.length > 10
? `${item.author.nickname.slice(0, 10)}...`
: item.author.nickname || '匿名'}
</Text>
{(item.author?.credit_score || 0) > 80 && (
<View style={styles.sellerCreditBadge}>
<Text style={styles.sellerCreditText}></Text>
</View>
)}
</View>
</View>
{item.price != null ? (
<Text style={styles.priceInHeader}>
¥{item.price % 1 === 0 ? item.price.toFixed(0) : item.price.toFixed(2)}
</Text>
) : (
<Text style={styles.priceInHeader}></Text>
)}
</View>
</View>
)}
{/* 标题 */}
<View style={styles.titleRow}>
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
{item.condition && item.trade_type === 'sell' && (
<View style={{ flexShrink: 0, marginTop: 7, marginLeft: 'auto' }}>
<View style={styles.conditionBadge}>
<Text style={styles.conditionBadgeText} numberOfLines={1}>
{TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition}
</Text>
</View>
</View>
)}
</View>
{/* 商品属性 */}
<View style={styles.attrGrid}>
<View style={styles.attrItem}>
<Text style={styles.attrLabel}></Text>
<Text style={styles.attrValue}>{item.brand || '其他'}</Text>
</View>
<View style={styles.attrItem}>
<Text style={styles.attrLabel}></Text>
<Text style={styles.attrValue}>
{TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition || '未知'}
</Text>
</View>
<View style={styles.attrItem}>
<Text style={styles.attrLabel}></Text>
<Text style={styles.attrValue}>{item.function_status || '正常使用'}</Text>
</View>
</View>
{/* 商品描述 */}
{item.content && (
<Text style={styles.contentText}>{item.content}</Text>
)}
<View style={styles.divider} />
</View>
{/* 图片放在文字下方 */}
{images.length > 0 && (
<View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.md }}>
{images.map((img, idx) => (
<View key={img.id || idx} style={[styles.imageContainer, { marginBottom: spacing.md, borderRadius: borderRadius.lg, overflow: 'hidden' }]}>
<Image
source={{ uri: img.url || img.preview_url_large || img.preview_url || '' }}
style={styles.image}
/>
</View>
))}
</View>
)}
</ScrollView>
{/* 底部操作栏 */}
<View style={styles.bottomBar}>
{isOwner ? (
/* 卖家管理按钮 */
<>
<View style={{ flex: 1 }} />
<Pressable style={styles.manageButton} onPress={() => setManageModalVisible(true)}>
<MaterialCommunityIcons name="cog-outline" size={18} color={colors.text.primary} />
<Text style={styles.manageButtonText}></Text>
</Pressable>
</>
) : (
<>
{!isOwner && item.status === 'active' && (
<>
<View style={{ flex: 1 }} />
<Pressable style={styles.chatButton} onPress={handleChat}>
<MaterialCommunityIcons name="chat-outline" size={18} color={colors.text.inverse} />
<Text style={styles.chatButtonText}></Text>
</Pressable>
</>
)}
{!isOwner && item.status !== 'active' && (
<View style={{ flex: 1, alignItems: 'flex-end' }}>
<View style={[styles.chatButton, styles.chatButtonDisabled]}>
<Text style={styles.chatButtonText}>
{item.status === 'sold' ? '已售出' : item.status === 'bought' ? '已收得' : '已下架'}
</Text>
</View>
</View>
)}
</>
)}
</View>
{/* 管理弹窗 */}
<Modal
visible={manageModalVisible}
transparent
animationType="slide"
onRequestClose={() => setManageModalVisible(false)}
>
<Pressable style={styles.modalOverlay} onPress={() => setManageModalVisible(false)}>
<View style={styles.modalContent}>
<Pressable style={styles.modalItem} onPress={handleEdit}>
<Text style={styles.modalItemText}></Text>
</Pressable>
<Pressable style={[styles.modalItem, { borderBottomWidth: 0 }]} onPress={handleDelete}>
<Text style={[styles.modalItemText, { color: colors.error.main }]}></Text>
</Pressable>
<Pressable style={styles.modalCancel} onPress={() => setManageModalVisible(false)}>
<Text style={styles.modalCancelText}></Text>
</Pressable>
</View>
</Pressable>
</Modal>
</SafeAreaView>
);
}