Files
frontend/src/components/business/TradeCard/TradeCard.tsx
lan f5f9c3a619
Some checks failed
Frontend CI / ota-android (push) Successful in 1m10s
Frontend CI / ota-ios (push) Successful in 1m8s
Frontend CI / build-and-push-web (push) Successful in 6m1s
Frontend CI / build-android-apk (push) Has been cancelled
ci: add iOS OTA publishing and refactor gesture handling
- Add `ota-ios` job to GitHub Actions workflow to support iOS OTA updates
- Refactor `ScheduleScreen` to use `GestureDetector` and `Gesture` from `react-native-gesture-handler` instead of `PanGestureHandler`
- Update `TradeCard` styles and layout spacing in `HomeScreen` and `MarketView`
- Refactor `MarketView` to use updated responsive spacing values
2026-05-05 17:46:31 +08:00

383 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.lg,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
listCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
padding: spacing.md,
},
imageContainer: {
position: 'relative',
aspectRatio: 1,
backgroundColor: colors.background.default,
minHeight: 140,
},
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: {
paddingHorizontal: spacing.sm,
paddingTop: spacing.xs,
paddingBottom: 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: 2,
},
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.xs,
},
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>
);
}
// grid variant is used in masonry layout; list variant below is unused but kept for compatibility
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;