Files
frontend/src/screens/trade/TradeDetailScreen.tsx
lafay 6cbd2092ac
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(ui): redesign SearchBar with compact mode and add home screen tabs
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.
2026-04-26 17:13:43 +08:00

623 lines
18 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.
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>
);
}