2026-04-26 17:13:43 +08:00
|
|
|
|
import React, { memo } from 'react';
|
|
|
|
|
|
import { View, TouchableOpacity, Image, StyleSheet, Pressable } from 'react-native';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { useAppColors, spacing, borderRadius, fontSizes, type AppColors } from '../../../theme';
|
|
|
|
|
|
import { Text } from '../../common';
|
|
|
|
|
|
import type { TradeItemDTO } from '../../../types/trade';
|
|
|
|
|
|
import { TRADE_CATEGORY_MAP, TRADE_CONDITION_MAP, TRADE_TYPE_MAP } from '../../../types/trade';
|
|
|
|
|
|
|
|
|
|
|
|
export interface TradeCardProps {
|
|
|
|
|
|
item: TradeItemDTO;
|
|
|
|
|
|
variant?: 'list' | 'grid';
|
|
|
|
|
|
onPress?: (id: string) => void;
|
|
|
|
|
|
onFavorite?: (id: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPrice(price?: number | null): string {
|
|
|
|
|
|
if (price == null) return '面议';
|
|
|
|
|
|
return `¥${price.toFixed(price % 1 === 0 ? 0 : 2)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createTradeCardStyles(colors: AppColors) {
|
|
|
|
|
|
return StyleSheet.create({
|
|
|
|
|
|
gridCard: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.xl,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
// subtle shadow for depth
|
|
|
|
|
|
shadowColor: '#000',
|
|
|
|
|
|
shadowOffset: { width: 0, height: 1 },
|
|
|
|
|
|
shadowOpacity: 0.06,
|
|
|
|
|
|
shadowRadius: 3,
|
|
|
|
|
|
elevation: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
listCard: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
padding: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
imageContainer: {
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
aspectRatio: 1,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
2026-04-27 01:02:39 +08:00
|
|
|
|
minHeight: 140,
|
2026-04-26 17:13:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
listImageContainer: {
|
|
|
|
|
|
width: 120,
|
|
|
|
|
|
height: 120,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
},
|
|
|
|
|
|
image: {
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
resizeMode: 'cover',
|
|
|
|
|
|
},
|
|
|
|
|
|
// 包邮/标签 badge 放在图片左下角,黄色背景
|
|
|
|
|
|
shippingBadge: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 6,
|
|
|
|
|
|
left: 6,
|
|
|
|
|
|
paddingHorizontal: 5,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
backgroundColor: '#FFE066',
|
|
|
|
|
|
},
|
|
|
|
|
|
shippingBadgeText: {
|
|
|
|
|
|
color: '#5C4B00',
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
},
|
|
|
|
|
|
typeBadge: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 6,
|
|
|
|
|
|
left: 6,
|
|
|
|
|
|
paddingHorizontal: 6,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
},
|
|
|
|
|
|
sellBadge: {
|
|
|
|
|
|
backgroundColor: `${colors.success.main}CC`,
|
|
|
|
|
|
},
|
|
|
|
|
|
buyBadge: {
|
|
|
|
|
|
backgroundColor: `${colors.primary.main}CC`,
|
|
|
|
|
|
},
|
|
|
|
|
|
typeBadgeText: {
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
statusBadge: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 6,
|
|
|
|
|
|
right: 6,
|
|
|
|
|
|
paddingHorizontal: 6,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
backgroundColor: `${colors.warning.main}BB`,
|
|
|
|
|
|
},
|
|
|
|
|
|
statusBadgeText: {
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
gridContent: {
|
2026-04-27 01:02:39 +08:00
|
|
|
|
paddingHorizontal: spacing.sm,
|
|
|
|
|
|
paddingTop: spacing.xs,
|
|
|
|
|
|
paddingBottom: spacing.sm,
|
2026-04-26 17:13:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
titleRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
gap: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
title: {
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
lineHeight: 20,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
priceInline: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
color: '#FF4D4F',
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
priceRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'baseline',
|
|
|
|
|
|
gap: 4,
|
|
|
|
|
|
marginTop: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
price: {
|
|
|
|
|
|
fontSize: fontSizes['2xl'],
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
color: '#FF4D4F',
|
|
|
|
|
|
},
|
|
|
|
|
|
originalPrice: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
textDecorationLine: 'line-through',
|
|
|
|
|
|
},
|
|
|
|
|
|
negotiable: {
|
|
|
|
|
|
fontSize: fontSizes.lg,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
},
|
|
|
|
|
|
wantCount: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
marginLeft: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
tagsRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
gap: 4,
|
2026-04-27 01:02:39 +08:00
|
|
|
|
marginTop: 2,
|
2026-04-26 17:13:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
tag: {
|
|
|
|
|
|
paddingHorizontal: 5,
|
|
|
|
|
|
paddingVertical: 1,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
backgroundColor: `${colors.primary.main}10`,
|
|
|
|
|
|
},
|
|
|
|
|
|
tagText: {
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
conditionTag: {
|
|
|
|
|
|
backgroundColor: `${colors.success.main}10`,
|
|
|
|
|
|
},
|
|
|
|
|
|
conditionTagText: {
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
color: colors.success.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
// 促销标签样式(如"24小时发货")
|
|
|
|
|
|
promoTag: {
|
|
|
|
|
|
paddingHorizontal: 5,
|
|
|
|
|
|
paddingVertical: 1,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
backgroundColor: '#FFF2F0',
|
|
|
|
|
|
borderWidth: 0.5,
|
|
|
|
|
|
borderColor: '#FFCCC7',
|
|
|
|
|
|
},
|
|
|
|
|
|
promoTagText: {
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
color: '#FF4D4F',
|
|
|
|
|
|
},
|
|
|
|
|
|
bottomRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
2026-04-27 01:02:39 +08:00
|
|
|
|
marginTop: spacing.xs,
|
2026-04-26 17:13:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
authorRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: spacing.xs,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
avatar: {
|
|
|
|
|
|
width: 18,
|
|
|
|
|
|
height: 18,
|
|
|
|
|
|
borderRadius: 9,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
authorName: {
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
flexShrink: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
statsRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
statItem: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
statText: {
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
},
|
|
|
|
|
|
favButton: {
|
|
|
|
|
|
padding: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
listContent: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
},
|
|
|
|
|
|
listLayout: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
gap: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
imagePlaceholder: {
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const TradeCardBase: React.FC<TradeCardProps> = ({ item, variant = 'list', onPress, onFavorite }) => {
|
|
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = createTradeCardStyles(colors);
|
|
|
|
|
|
|
|
|
|
|
|
const firstImage = item.images?.[0];
|
|
|
|
|
|
const isSell = item.trade_type === 'sell';
|
|
|
|
|
|
const isActive = item.status === 'active';
|
|
|
|
|
|
const conditionLabel = item.condition ? TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] : null;
|
|
|
|
|
|
const categoryLabel = TRADE_CATEGORY_MAP[item.category] || item.category;
|
|
|
|
|
|
|
|
|
|
|
|
const renderImage = (containerStyle: any, imageStyle: any) => (
|
|
|
|
|
|
<View style={containerStyle}>
|
|
|
|
|
|
{firstImage ? (
|
|
|
|
|
|
<Image
|
|
|
|
|
|
source={{ uri: firstImage.preview_url || firstImage.url }}
|
|
|
|
|
|
style={imageStyle}
|
|
|
|
|
|
defaultSource={undefined}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<View style={[imageStyle, styles.imagePlaceholder]}>
|
|
|
|
|
|
<MaterialCommunityIcons name="image-outline" size={32} color={colors.text.hint} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* 包邮标签 */}
|
|
|
|
|
|
{item.is_free_shipping && (
|
|
|
|
|
|
<View style={styles.shippingBadge}>
|
|
|
|
|
|
<Text style={styles.shippingBadgeText}>包邮</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<View style={[styles.typeBadge, isSell ? styles.sellBadge : styles.buyBadge]}>
|
|
|
|
|
|
<Text style={styles.typeBadgeText}>{TRADE_TYPE_MAP[item.trade_type]}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
{!isActive && (
|
|
|
|
|
|
<View style={styles.statusBadge}>
|
|
|
|
|
|
<Text style={styles.statusBadgeText}>
|
|
|
|
|
|
{item.status === 'sold' ? '已售' : item.status === 'bought' ? '已收' : item.status === 'offline' ? '下架' : ''}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const renderTitleRow = () => (
|
|
|
|
|
|
<View style={styles.titleRow}>
|
|
|
|
|
|
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
|
|
|
|
|
<Text style={styles.priceInline}>
|
|
|
|
|
|
{item.price != null ? formatPrice(item.price) : '面议'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const renderTags = () => {
|
|
|
|
|
|
if (!conditionLabel && !isSell && !item.is_quick_ship) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.tagsRow}>
|
|
|
|
|
|
{conditionLabel && isSell && (
|
|
|
|
|
|
<View style={[styles.tag, styles.conditionTag]}>
|
|
|
|
|
|
<Text style={styles.conditionTagText}>{conditionLabel}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{item.is_quick_ship && (
|
|
|
|
|
|
<View style={styles.promoTag}>
|
|
|
|
|
|
<Text style={styles.promoTagText}>24小时发货</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderBottom = () => (
|
|
|
|
|
|
<View style={styles.bottomRow}>
|
|
|
|
|
|
<View style={styles.authorRow}>
|
|
|
|
|
|
{item.author?.avatar ? (
|
|
|
|
|
|
<Image source={{ uri: item.author.avatar }} style={styles.avatar} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<View style={[styles.avatar, { backgroundColor: colors.primary.main + '33' }]} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Text style={styles.authorName} numberOfLines={1}>{item.author?.nickname || '匿名'}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
<View style={styles.statsRow}>
|
|
|
|
|
|
{(item.good_reputation || 0) > 0 && (
|
|
|
|
|
|
<View style={[styles.promoTag, { backgroundColor: '#FFF7E6', borderColor: '#FFD591' }]}>
|
|
|
|
|
|
<Text style={[styles.promoTagText, { color: '#FA8C16' }]}>回头客好评过百</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<View style={styles.tag}>
|
|
|
|
|
|
<Text style={styles.tagText}>{categoryLabel}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (variant === 'grid') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.gridCard}
|
|
|
|
|
|
onPress={() => onPress?.(item.id)}
|
|
|
|
|
|
activeOpacity={0.7}
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderImage(styles.imageContainer, styles.image)}
|
|
|
|
|
|
<View style={styles.gridContent}>
|
|
|
|
|
|
{renderTitleRow()}
|
|
|
|
|
|
{renderTags()}
|
|
|
|
|
|
{renderBottom()}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 01:02:39 +08:00
|
|
|
|
// grid variant is used in masonry layout; list variant below is unused but kept for compatibility
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 17:13:43 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.listCard}
|
|
|
|
|
|
onPress={() => onPress?.(item.id)}
|
|
|
|
|
|
activeOpacity={0.7}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.listLayout}>
|
|
|
|
|
|
{renderImage(styles.listImageContainer, styles.image)}
|
|
|
|
|
|
<View style={styles.listContent}>
|
|
|
|
|
|
{renderTitleRow()}
|
|
|
|
|
|
{renderTags()}
|
|
|
|
|
|
{renderBottom()}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const TradeCard = memo(TradeCardBase);
|
|
|
|
|
|
|
|
|
|
|
|
export default TradeCard;
|