Files
frontend/src/screens/trade/TradeDetailScreen.tsx
lan 3196972596
All checks were successful
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / ota-ios (push) Successful in 1m39s
Frontend CI / build-and-push-web (push) Successful in 3m30s
Frontend CI / build-android-apk (push) Successful in 1h1m8s
refactor: restructure core architecture and responsive system
This commit implements a major architectural refactor to improve modularity, type safety, and maintainability across the codebase.

Key changes include:
- **Responsive System Refactor**: Migrated from a monolithic `useResponsive` hook to a set of specialized, granular hooks (`useBreakpoint`, `useOrientation`, `usePlatform`, etc.) located in `src/presentation/hooks/responsive`. This reduces unnecessary re-renders and improves developer experience.
- **Core Service Restructuring**: Introduced a new directory structure for services, including dedicated folders for `datasources`, `mappers`, and domain-specific types (`message`, `post`).
- **Data Layer Improvements**:
    - Centralized JSON parsing logic in `src/database/core/jsonUtils.ts`.
    - Cleaned up repository implementations by removing redundant local utility functions.
    - Refactored `MessageMapper` to use the new centralized JSON utility.
- **Type System Cleanup**:
    - Decomposed the large `src/types/dto.ts` into a modular `src/types/dto/` directory.
    - Simplified `src/types/index.ts` and introduced backward-compatible aliases for core entities.
- **Utility Consolidation**:
    - Created a centralized `src/utils/formatTime.ts` to replace fragmented date formatting logic across various screens and components.
    - Removed deprecated responsive utility files in favor of the new hook-based system.
- **Service Logic Refinement**: Refactored `ApiClient` and `WebSocketService` to use a centralized `showVerificationModal` service, removing duplicated state management for verification prompts.
2026-05-05 19:07:33 +08:00

674 lines
20 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, { useState, useEffect, useCallback, useMemo } 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, ImageGrid, ImageGallery } 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';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
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,
},
// 降价标签
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.lg,
},
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: {
fontWeight: '800',
color: colors.text.primary,
letterSpacing: -0.3,
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: {
color: colors.text.primary,
letterSpacing: 0.2,
marginBottom: spacing.lg,
},
// 底部操作栏
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 [manageModalVisible, setManageModalVisible] = useState(false);
const [showImageModal, setShowImageModal] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const isOwner = item?.user_id === currentUser?.id;
// 响应式 hook与帖子同步
const {
width,
isMobile,
isTablet,
isDesktop,
isWideScreen,
} = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 28, '3xl': 32, '4xl': 40 });
const responsiveGap = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32 });
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]);
// 响应式图片间距和圆角(与帖子同步)
const imageGap = isDesktop ? 12 : isTablet ? 10 : 8;
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.lg;
const images = item?.images || [];
// 转换图片格式给 ImageGrid 使用
const tradeImages = useMemo(() => {
return images.map(img => ({
id: img.id,
url: img.url || img.preview_url_large || img.preview_url || '',
thumbnail_url: img.preview_url || img.preview_url_large || img.url || '',
width: img.width,
height: img.height,
}));
}, [images]);
const handleImagePress = useCallback((allImages: any[], index: number) => {
setSelectedImageIndex(index);
setShowImageModal(true);
}, []);
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>
);
}
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, { paddingHorizontal: responsivePadding, paddingTop: responsivePadding }]}>
{/* 卖家信息头部 */}
{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,
{
fontSize: isDesktop ? fontSizes['3xl'] : isTablet ? fontSizes['2xl'] : fontSizes['2xl'],
lineHeight: isDesktop ? 40 : isTablet ? 36 : 32,
},
]}
numberOfLines={2}
>
{item.title}
</Text>
</View>
{/* 商品描述 - 与帖子正文同步 */}
{item.content && (
<Text
style={[
styles.contentText,
{
fontSize: isDesktop ? fontSizes.xl : isTablet ? fontSizes.lg : fontSizes.lg,
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28,
},
]}
>
{item.content}
</Text>
)}
</View>
{/* 图片 - 使用 ImageGrid 与帖子同步 */}
{tradeImages.length > 0 && (
<View style={{ paddingHorizontal: responsivePadding, paddingBottom: responsivePadding }}>
<ImageGrid
images={tradeImages}
mode="auto"
gap={imageGap}
borderRadius={imageBorderRadius}
maxDisplayCount={isWideScreen ? 20 : 100}
showMoreOverlay={false}
onImagePress={handleImagePress}
/>
</View>
)}
{/* 内容区右下角属性文字(仅出售商品显示) */}
{item.trade_type === 'sell' && (
<View style={{ paddingHorizontal: responsivePadding, paddingBottom: responsivePadding }}>
<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>
{/* 图片预览 */}
<ImageGallery
visible={showImageModal}
images={tradeImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || '',
}))}
initialIndex={selectedImageIndex}
onClose={() => setShowImageModal(false)}
enableSave
/>
</SafeAreaView>
);
}