Files
frontend/src/components/business/TradeCard/TradeCard.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

378 lines
10 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, { 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,
},
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: {
padding: spacing.sm,
},
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,
marginTop: spacing.xs,
},
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',
marginTop: spacing.sm,
},
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>
);
}
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;