- 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
383 lines
10 KiB
TypeScript
383 lines
10 KiB
TypeScript
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;
|