Files
frontend/src/screens/trade/TradeDetailScreen.tsx

622 lines
18 KiB
TypeScript
Raw Normal View History

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',
},
priceSymbol: {
fontSize: fontSizes.md,
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',
},
contentAttrs: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
gap: spacing.md,
flexWrap: 'wrap',
},
contentAttrText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
},
});
}
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}>
<Text style={styles.priceSymbol}>¥</Text>{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>
</View>
{/* 商品描述 */}
{item.content && (
<Text style={styles.contentText}>{item.content}</Text>
)}
</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>
)}
{/* 内容区右下角属性文字(仅出售商品显示) */}
{item.trade_type === 'sell' && (
<View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.md }}>
<View style={styles.contentAttrs}>
<Text style={styles.contentAttrText}>{item.brand || '其他'}</Text>
<Text style={styles.contentAttrText}>
{TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition || '未知'}
</Text>
<Text style={styles.contentAttrText}>{item.function_status || '正常使用'}</Text>
</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>
);
}