diff --git a/app/_layout.tsx b/app/_layout.tsx index b1d55b9..431e2bc 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import { PaperProvider } from 'react-native-paper'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Notifications from 'expo-notifications'; import * as SystemUI from 'expo-system-ui'; +import { useFonts } from 'expo-font'; import { registerNotificationPresentationHandler } from '@/services/notification'; import { api } from '@/services/core'; @@ -267,6 +268,16 @@ function ThemedProviders({ children }: { children: React.ReactNode }) { } export default function RootLayout() { + const [fontsLoaded] = useFonts({}); + + if (!fontsLoaded) { + return ( + + + + ); + } + return ( diff --git a/app/trade/[tradeId].tsx b/app/trade/[tradeId].tsx new file mode 100644 index 0000000..e630392 --- /dev/null +++ b/app/trade/[tradeId].tsx @@ -0,0 +1,7 @@ +import { TradeDetailScreen } from '../../src/screens/trade/TradeDetailScreen'; +import { useLocalSearchParams } from 'expo-router'; + +export default function TradeDetailRoute() { + const { tradeId } = useLocalSearchParams<{ tradeId: string }>(); + return ; +} \ No newline at end of file diff --git a/app/trade/edit/[tradeId].tsx b/app/trade/edit/[tradeId].tsx new file mode 100644 index 0000000..60de998 --- /dev/null +++ b/app/trade/edit/[tradeId].tsx @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { CreateTradeScreen } from '../../../src/screens/trade/CreateTradeScreen'; +import { tradeService } from '../../../src/services/trade/tradeService'; +import { Loading } from '../../../src/components/common'; +import { View, Alert } from 'react-native'; +import type { TradeItemDetailDTO } from '../../../src/types/trade'; + +export default function EditTradeRoute() { + const { tradeId } = useLocalSearchParams<{ tradeId: string }>(); + const router = useRouter(); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!tradeId) return; + tradeService.getTradeItem(tradeId) + .then(data => setItem(data)) + .catch(() => Alert.alert('错误', '加载商品信息失败')) + .finally(() => setLoading(false)); + }, [tradeId]); + + const handleClose = useCallback(() => { + router.back(); + }, [router]); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + if (loading) { + return ; + } + + if (!item) { + return null; + } + + return ( + + ); +} diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index 79be8a6..b8fb632 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -128,7 +128,7 @@ function createCommentItemStyles(colors: AppColors) { actionButton: { flexDirection: 'row', alignItems: 'center', - marginRight: spacing.lg, + marginRight: spacing.sm, paddingVertical: 4, paddingRight: spacing.xs, }, diff --git a/src/components/business/SearchBar.tsx b/src/components/business/SearchBar.tsx index 0d66df7..2ccc101 100644 --- a/src/components/business/SearchBar.tsx +++ b/src/components/business/SearchBar.tsx @@ -16,52 +16,44 @@ interface SearchBarProps { onFocus?: () => void; onBlur?: () => void; autoFocus?: boolean; + compact?: boolean; } -function createSearchBarStyles(colors: AppColors) { +function createSearchBarStyles(colors: AppColors, compact: boolean) { return StyleSheet.create({ container: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.background.paper, + backgroundColor: 'transparent', borderRadius: borderRadius.full, - paddingHorizontal: spacing.xs, - height: 46, - borderWidth: 1, + paddingHorizontal: compact ? spacing.sm : spacing.xs, + height: compact ? 38 : 46, + borderWidth: compact ? 1 : 1, borderColor: colors.divider, - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, }, containerFocused: { borderColor: colors.primary.main, - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, + backgroundColor: 'transparent', }, searchIconWrap: { - width: 30, - height: 30, - marginLeft: spacing.xs, - marginRight: spacing.xs, + width: compact ? 22 : 30, + height: compact ? 22 : 30, + marginLeft: compact ? 2 : spacing.xs, + marginRight: compact ? 6 : spacing.xs, borderRadius: borderRadius.full, - backgroundColor: `${colors.text.secondary}12`, + backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center', }, searchIconWrapFocused: { - backgroundColor: `${colors.primary.main}1A`, + backgroundColor: 'transparent', }, input: { flex: 1, - fontSize: fontSizes.md, + fontSize: compact ? fontSizes.md : fontSizes.md, color: colors.text.primary, - paddingVertical: spacing.sm + 1, - paddingHorizontal: spacing.xs, + paddingVertical: compact ? 0 : spacing.sm + 1, + paddingHorizontal: compact ? 2 : spacing.xs, }, clearButton: { width: 24, @@ -83,9 +75,10 @@ const SearchBar: React.FC = ({ onFocus, onBlur, autoFocus = false, + compact = false, }) => { const colors = useAppColors(); - const styles = useMemo(() => createSearchBarStyles(colors), [colors]); + const styles = useMemo(() => createSearchBarStyles(colors, compact), [colors, compact]); const [isFocused, setIsFocused] = useState(false); const handleFocus = () => { @@ -103,7 +96,7 @@ const SearchBar: React.FC = ({ diff --git a/src/components/business/TradeCard/TradeCard.tsx b/src/components/business/TradeCard/TradeCard.tsx new file mode 100644 index 0000000..ca9da18 --- /dev/null +++ b/src/components/business/TradeCard/TradeCard.tsx @@ -0,0 +1,377 @@ +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 = ({ 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) => ( + + {firstImage ? ( + + ) : ( + + + + )} + {/* 包邮标签 */} + {item.is_free_shipping && ( + + 包邮 + + )} + + {TRADE_TYPE_MAP[item.trade_type]} + + {!isActive && ( + + + {item.status === 'sold' ? '已售' : item.status === 'bought' ? '已收' : item.status === 'offline' ? '下架' : ''} + + + )} + + ); + + const renderTitleRow = () => ( + + {item.title} + + {item.price != null ? formatPrice(item.price) : '面议'} + + + ); + + const renderTags = () => { + if (!conditionLabel && !isSell && !item.is_quick_ship) return null; + return ( + + {conditionLabel && isSell && ( + + {conditionLabel} + + )} + {item.is_quick_ship && ( + + 24小时发货 + + )} + + ); + }; + + const renderBottom = () => ( + + + {item.author?.avatar ? ( + + ) : ( + + )} + {item.author?.nickname || '匿名'} + + + {(item.good_reputation || 0) > 0 && ( + + 回头客好评过百 + + )} + + {categoryLabel} + + + + ); + + if (variant === 'grid') { + return ( + onPress?.(item.id)} + activeOpacity={0.7} + > + {renderImage(styles.imageContainer, styles.image)} + + {renderTitleRow()} + {renderTags()} + {renderBottom()} + + + ); + } + + return ( + onPress?.(item.id)} + activeOpacity={0.7} + > + + {renderImage(styles.listImageContainer, styles.image)} + + {renderTitleRow()} + {renderTags()} + {renderBottom()} + + + + ); +}; + +export const TradeCard = memo(TradeCardBase); + +export default TradeCard; diff --git a/src/components/business/index.ts b/src/components/business/index.ts index 4988e02..749640e 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -26,3 +26,4 @@ export { default as PostContentRenderer } from './PostContentRenderer'; export { default as PostMentionInput } from './PostMentionInput'; export { default as ReportDialog } from './ReportDialog'; export { default as ShareSheet } from './ShareSheet'; +export { TradeCard } from './TradeCard/TradeCard'; diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts index 4df90ad..c960498 100644 --- a/src/navigation/hrefs.ts +++ b/src/navigation/hrefs.ts @@ -218,3 +218,7 @@ export function hrefProfilePrivacySettings(): string { export function hrefProfileDeletion(): string { return '/profile/account-deletion'; } + +export function hrefEditTrade(tradeId: string): string { + return `/trade/edit/${encodeURIComponent(tradeId)}`; +} diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 369dd33..1213ecc 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -37,12 +37,22 @@ import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { postSyncService } from '@/services/post'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; +import { MarketView } from './MarketView'; +import { CreateTradeScreen } from '../trade/CreateTradeScreen'; +import type { TradeCategory } from '../../types/trade'; +import { ALL_TRADE_CATEGORIES } from '../../types/trade'; import * as hrefs from '../../navigation/hrefs'; import { blurActiveElement } from '../../infrastructure/platform'; const SORT_OPTIONS = ['关注', '推荐', '最新']; const SORT_ICONS = ['account-heart', 'fire', 'clock-outline']; const SORT_TYPES: PostType[] = ['follow', 'hot', 'latest']; + +const MARKET_FILTER_OPTIONS = [ + { key: 'all' as const, label: '全部', icon: 'view-grid-outline' }, + { key: 'sell' as const, label: '出售', icon: 'tag-outline' }, + { key: 'buy' as const, label: '收购', icon: 'hand-coin-outline' }, +]; const DEFAULT_PAGE_SIZE = 20; const MOBILE_TAB_BAR_HEIGHT = 64; const MOBILE_TAB_FLOATING_MARGIN = 12; @@ -56,7 +66,7 @@ type ViewMode = 'list' | 'grid'; type PostType = 'follow' | 'hot' | 'latest'; type LatestCapsule = { id: string; name: string }; -function createHomeStyles(colors: AppColors) { +function createHomeStyles(colors: AppColors, responsivePadding: number) { return StyleSheet.create({ container: { flex: 1, @@ -66,13 +76,45 @@ function createHomeStyles(colors: AppColors) { backgroundColor: colors.background.paper, }, searchWrapper: { - paddingTop: spacing.lg, + paddingTop: spacing.sm, paddingBottom: spacing.xs, - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, + }, + homeTabSwitcher: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: spacing.xs, + marginBottom: spacing.xs, + paddingHorizontal: responsivePadding + 4, + }, + homeTabSwitcherLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 20, + }, + homeTabSwitcherRight: { + flex: 1, + marginLeft: 16, + }, + homeTabItem: { + paddingVertical: spacing.sm, + alignItems: 'center', + justifyContent: 'center', + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + homeTabItemActive: { + borderBottomColor: colors.text.primary, + }, + homeTabText: { + fontSize: 20, + fontWeight: '400', + color: colors.text.hint, + }, + homeTabTextActive: { + fontSize: 20, + fontWeight: '700', + color: colors.text.primary, }, homeTabBar: { marginTop: spacing.sm, @@ -89,7 +131,6 @@ function createHomeStyles(colors: AppColors) { alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.xs, - backgroundColor: colors.background.paper, }, sortBarCapsuleScroll: { flex: 1, @@ -182,21 +223,22 @@ export const HomeScreen: React.FC = () => { const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus); const colors = useAppColors(); const resolvedScheme = useResolvedColorScheme(); - const styles = useMemo(() => createHomeStyles(colors), [colors]); - const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content'; // 使用响应式 hook - const { - width, - isMobile, - isTablet, - isDesktop, + const { + width, + isMobile, + isTablet, + isDesktop, isWideScreen, } = useResponsive(); - + // 响应式间距 const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 }); const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + + const styles = useMemo(() => createHomeStyles(colors, responsivePadding), [colors, responsivePadding]); + const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content'; const [sortIndex, setSortIndex] = useState(0); const [viewMode, setViewMode] = useState('list'); @@ -214,6 +256,14 @@ export const HomeScreen: React.FC = () => { // 发帖弹窗状态 const [showCreatePost, setShowCreatePost] = useState(false); + // 广场/市集 Tab + const [homeTab, setHomeTab] = useState<'square' | 'market'>('square'); + const [showCreateTrade, setShowCreateTrade] = useState(false); + + // 市集筛选状态 + const [marketFilterType, setMarketFilterType] = useState<'all' | 'sell' | 'buy'>('all'); + const [marketCategory, setMarketCategory] = useState('all'); + // 分享面板状态 const [showShareSheet, setShowShareSheet] = useState(false); const [sharePost, setSharePost] = useState(null); @@ -530,6 +580,13 @@ export const HomeScreen: React.FC = () => { setSortIndex(nextIndex); }, [sortIndex]); + const cycleMarketFilter = useCallback(() => { + setMarketFilterType(prev => { + const idx = MARKET_FILTER_OPTIONS.findIndex(f => f.key === prev); + return MARKET_FILTER_OPTIONS[(idx + 1) % MARKET_FILTER_OPTIONS.length].key; + }); + }, []); + useEffect(() => { const loadChannels = async () => { const list = await channelService.list(); @@ -856,18 +913,42 @@ export const HomeScreen: React.FC = () => { {/* 顶部Header */} - {/* 搜索栏 */} - - {}} - onSubmit={handleSearchPress} - onFocus={handleSearchPress} - placeholder="搜索帖子、用户" - /> + {/* 广场/市集切换 */} + + + setHomeTab('square')} + > + + 广场 + + + setHomeTab('market')} + > + + 市集 + + + + + {}} + onSubmit={handleSearchPress} + onFocus={handleSearchPress} + placeholder="搜索" + compact + /> + - - {/* 排序筛选 + 频道 */} + + {/* 排序筛选 + 频道(仅广场模式显示) */} + {homeTab === 'square' && ( { + )} + + {/* 市集分类 + 筛选(仅市集模式显示) */} + {homeTab === 'market' && ( + + ( + setMarketCategory(item.key as TradeCategory | 'all')} + > + + {item.label} + + + )} + keyExtractor={(item: { key: string; label: string }) => item.key} + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.sortBarCapsuleContent} + style={styles.sortBarCapsuleScroll} + /> + + f.key === marketFilterType)?.icon || 'view-grid-outline') as any} + size={18} + color={colors.text.secondary} + /> + + {MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.label || '全部'} + + + + )} - {/* 帖子列表 */} + {/* 内容区域 */} + {homeTab === 'square' ? ( {isInitialLoading ? ( @@ -906,8 +1034,17 @@ export const HomeScreen: React.FC = () => { renderResponsiveGrid() )} + ) : ( + setShowCreateTrade(true)} + filterType={marketFilterType} + onFilterTypeChange={setMarketFilterType} + selectedCategory={marketCategory} + onSelectedCategoryChange={setMarketCategory} + /> + )} - {/* 漂浮发帖按钮 */} + {/* 漂浮发帖/发布按钮 */} { isWideScreen && styles.floatingButtonWide, floatingButtonBottom !== undefined && { bottom: floatingButtonBottom }, ]} - onPress={handleCreatePost} + onPress={homeTab === 'market' ? () => { + if (!isAuthenticated) { router.push('/login'); return; } + if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; } + setShowCreateTrade(true); + } : handleCreatePost} activeOpacity={0.8} > @@ -945,6 +1086,18 @@ export const HomeScreen: React.FC = () => { /> + {/* 发布交易弹窗 */} + setShowCreateTrade(false)} + > + setShowCreateTrade(false)} + /> + + {/* 分享面板 */} void; + filterType?: TradeFilterType; + onFilterTypeChange?: (type: TradeFilterType) => void; + selectedCategory?: TradeCategory | 'all'; + onSelectedCategoryChange?: (cat: TradeCategory | 'all') => void; +} + +export function MarketView({ + onCreateTrade, + filterType: filterTypeProp, + onFilterTypeChange, + selectedCategory: selectedCategoryProp, + onSelectedCategoryChange, +}: MarketViewProps) { + const colors = useAppColors(); + const router = useRouter(); + const isAuth = useIsAuthenticated(); + const { width, isMobile } = useResponsive(); + + const gap = useResponsiveSpacing({ xs: 6, sm: 8, md: 10, lg: 12, xl: 16 }); + const padding = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20 }); + const styles = useMemo(() => createMarketStyles(colors, gap, padding), [colors, gap, padding]); + + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [cursor, setCursor] = useState(); + const [filterTypeLocal, setFilterTypeLocal] = useState('all'); + const [selectedCategoryLocal, setSelectedCategoryLocal] = useState('all'); + + const filterType = filterTypeProp ?? filterTypeLocal; + const selectedCategory = selectedCategoryProp ?? selectedCategoryLocal; + const setFilterType = onFilterTypeChange ?? setFilterTypeLocal; + const setSelectedCategory = onSelectedCategoryChange ?? setSelectedCategoryLocal; + + const flashListRef = useRef(null); + + // 网格列数:移动端2列,平板3列,桌面4列 + const numColumns = useMemo(() => { + if (width >= 1200) return 4; + if (width >= 768) return 3; + return 2; + }, [width]); + + // 计算卡片宽度(用于固定宽高比) + const cardWidth = useMemo(() => { + const totalGap = gap * (numColumns - 1); + return Math.floor((width - padding * 2 - totalGap) / numColumns); + }, [width, padding, gap, numColumns]); + + const fetchItems = useCallback(async (isRefresh = false) => { + try { + if (isRefresh) { + setIsRefreshing(true); + } else if (!isLoading) { + return; + } + + const tradeType = filterType === 'all' ? undefined : (filterType as TradeType); + const category = selectedCategory === 'all' ? undefined : (selectedCategory as TradeCategory); + + const res = await tradeService.getTradeItems({ + trade_type: tradeType, + category, + page_size: PAGE_SIZE, + }); + + setItems(res.list || []); + setCursor(res.next_cursor); + setHasMore(res.has_more); + } catch (e) { + console.error('Failed to fetch trade items:', e); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [filterType, selectedCategory]); + + useEffect(() => { + setIsLoading(true); + setCursor(undefined); + fetchItems(true); + }, [filterType, selectedCategory]); + + const loadMore = useCallback(async () => { + if (isLoadingMore || !hasMore || !cursor) return; + setIsLoadingMore(true); + try { + const tradeType = filterType === 'all' ? undefined : (filterType as TradeType); + const category = selectedCategory === 'all' ? undefined : (selectedCategory as TradeCategory); + const res = await tradeService.getTradeItems({ + trade_type: tradeType, + category, + cursor, + page_size: PAGE_SIZE, + }); + setItems(prev => [...prev, ...(res.list || [])]); + setCursor(res.next_cursor); + setHasMore(res.has_more); + } catch (e) { + console.error('Failed to load more trade items:', e); + } finally { + setIsLoadingMore(false); + } + }, [isLoadingMore, hasMore, cursor, filterType, selectedCategory]); + + const handlePressItem = useCallback((id: string) => { + router.push(`/trade/${id}` as any); + }, [router]); + + const handleFavorite = useCallback(async (id: string) => { + if (!isAuth) return; + try { + const item = items.find(i => i.id === id); + if (!item) return; + if (item.is_favorited) { + await tradeService.unfavorite(id); + } else { + await tradeService.favorite(id); + } + setItems(prev => + prev.map(i => + i.id === id + ? { ...i, is_favorited: !i.is_favorited, favorites_count: i.favorites_count + (i.is_favorited ? -1 : 1) } + : i + ) + ); + } catch (e) { + console.error('Favorite failed:', e); + } + }, [items, isAuth]); + + const renderItem = useCallback>(({ item }) => ( + + + + ), [handlePressItem, handleFavorite, cardWidth, gap]); + + const keyExtractor = useCallback((item: TradeItemDTO) => item.id, []); + + if (isLoading) { + return ; + } + + return ( + + fetchItems(true)} + colors={[colors.primary.main]} + tintColor={colors.primary.main} + /> + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListEmptyComponent={ + + } + ListFooterComponent={isLoadingMore ? : null} + drawDistance={250} + /> + + ); +} diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 9503da4..c3ca3a3 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -1472,7 +1472,7 @@ export const PostDetailScreen: React.FC = () => { @@ -1483,7 +1483,7 @@ export const PostDetailScreen: React.FC = () => { @@ -1492,7 +1492,7 @@ export const PostDetailScreen: React.FC = () => { - + {formatNumber(post?.shares_count || 0)} @@ -2153,7 +2153,7 @@ function createPostDetailStyles(colors: AppColors) { borderRadius: 20, paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 2, - minHeight: 40, + minHeight: 44, justifyContent: 'center', }, inputTriggerText: { @@ -2163,14 +2163,14 @@ function createPostDetailStyles(colors: AppColors) { bottomComposerRow: { flexDirection: 'row', alignItems: 'center', - gap: spacing.xs + 2, + gap: spacing.xs, }, bottomActionItem: { alignItems: 'center', justifyContent: 'center', paddingVertical: 2, - paddingHorizontal: spacing.sm + 1, - marginLeft: spacing.xs, + paddingHorizontal: spacing.sm, + marginLeft: 0, }, bottomActionCount: { marginTop: 1, diff --git a/src/screens/trade/CreateTradeScreen.tsx b/src/screens/trade/CreateTradeScreen.tsx new file mode 100644 index 0000000..34ae0fd --- /dev/null +++ b/src/screens/trade/CreateTradeScreen.tsx @@ -0,0 +1,640 @@ +/** + * CreateTradeScreen 发布/编辑商品(现代化扁平风格) + * 设计风格: + * - 纯白背景,扁平化设计 + * - 灰色填充输入框,圆角14px + * - 橙色圆角按钮 + * - 清晰的标签 + 输入框分组 + * - 与登录、注册、创建群聊保持一致 + */ + +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { + View, + TextInput, + ScrollView, + Alert, + TouchableOpacity, + Image, + StyleSheet, + Pressable, + KeyboardAvoidingView, + Platform, + Animated, + ActivityIndicator, +} from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import { Loading } from '../../components/common'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useAppColors, type AppColors } from '../../theme'; +import { Text } from '../../components/common'; +import { tradeService } from '../../services/trade/tradeService'; +import { uploadService } from '../../services'; +import type { TradeType, TradeCategory, TradeCondition, TradeItemDetailDTO } from '../../types/trade'; +import { ALL_TRADE_CATEGORIES, ALL_TRADE_CONDITIONS } from '../../types/trade'; + +interface CreateTradeScreenProps { + onClose: () => void; + onSuccess?: () => void; + editItem?: TradeItemDetailDTO; +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + keyboardView: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + paddingHorizontal: 28, + paddingTop: 20, + paddingBottom: 40, + }, + content: { + flex: 1, + maxWidth: 400, + width: '100%', + alignSelf: 'center', + }, + + // 标题区域 + titleSection: { + marginBottom: 32, + marginTop: 12, + }, + title: { + fontSize: 28, + fontWeight: '700', + color: colors.text.primary, + lineHeight: 36, + }, + underline: { + width: 40, + height: 4, + backgroundColor: colors.primary.main, + borderRadius: 2, + marginTop: 12, + marginBottom: 12, + }, + subtitle: { + fontSize: 15, + color: colors.text.secondary, + }, + + // 输入框区域(参考登录注册风格) + inputContainer: { + marginBottom: 24, + }, + inputLabel: { + fontSize: 14, + fontWeight: '500', + color: colors.text.secondary, + marginBottom: 10, + }, + requiredMark: { + color: colors.error.main, + fontWeight: '600', + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.default, + borderRadius: 14, + paddingHorizontal: 18, + minHeight: 56, + borderWidth: 1, + borderColor: 'transparent', + }, + input: { + flex: 1, + fontSize: 16, + color: colors.text.primary, + minHeight: 56, + }, + textAreaWrapper: { + paddingVertical: 14, + alignItems: 'flex-start', + }, + textArea: { + minHeight: 100, + lineHeight: 22, + textAlignVertical: 'top', + }, + checkIcon: { + marginLeft: 8, + }, + charCount: { + textAlign: 'right', + marginTop: 6, + fontSize: 12, + color: colors.text.hint, + fontWeight: '500', + }, + + // 交易类型选择 + typeRow: { + flexDirection: 'row', + gap: 12, + }, + typeButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 14, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.divider, + backgroundColor: colors.background.default, + }, + typeButtonActive: { + borderColor: colors.primary.main, + backgroundColor: `${colors.primary.main}12`, + }, + typeButtonText: { + fontSize: 16, + color: colors.text.secondary, + }, + typeButtonTextActive: { + color: colors.primary.main, + fontWeight: '600', + }, + + // 分类/成色选项(紧凑横向滚动) + optionRow: { + flexDirection: 'row', + gap: 8, + }, + optionChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: colors.background.default, + borderWidth: 1, + borderColor: colors.divider, + }, + optionChipActive: { + borderColor: colors.primary.main, + backgroundColor: `${colors.primary.main}12`, + }, + optionChipText: { + fontSize: 13, + color: colors.text.secondary, + }, + optionChipTextActive: { + color: colors.primary.main, + fontWeight: '600', + }, + + // 价格输入 + priceInputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.default, + borderRadius: 14, + paddingHorizontal: 18, + minHeight: 56, + borderWidth: 1, + borderColor: 'transparent', + }, + pricePrefix: { + fontSize: 18, + color: colors.text.hint, + marginRight: 4, + }, + priceField: { + flex: 1, + fontSize: 16, + color: colors.text.primary, + minHeight: 56, + }, + + // 图片上传 + imageSection: { + marginBottom: 24, + }, + imageGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + imageItem: { + width: 100, + height: 100, + borderRadius: 14, + overflow: 'hidden', + position: 'relative', + }, + imagePreview: { + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + removeImageBtn: { + position: 'absolute', + top: 6, + right: 6, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.5)', + alignItems: 'center', + justifyContent: 'center', + }, + addImageBtn: { + width: 100, + height: 100, + borderRadius: 14, + borderWidth: 2, + borderColor: colors.divider, + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background.default, + }, + uploadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.3)', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 14, + }, + + // 提交按钮 + submitBtn: { + height: 56, + borderRadius: 14, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + marginBottom: 16, + }, + submitBtnDisabled: { + opacity: 0.6, + }, + submitBtnText: { + fontSize: 17, + fontWeight: '600', + color: colors.text.inverse, + }, + + // 顶部关闭按钮 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + }, + backIconButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text.primary, + }, + headerRight: { + width: 40, + }, + }); +} + +export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeScreenProps) { + const colors = useAppColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const isEdit = !!editItem; + + const [tradeType, setTradeType] = useState(editItem?.trade_type || 'sell'); + const [category, setCategory] = useState(editItem?.category || 'digital'); + const [title, setTitle] = useState(editItem?.title || ''); + const [content, setContent] = useState(editItem?.content || ''); + const [price, setPrice] = useState(editItem?.price != null ? String(editItem.price) : ''); + const [condition, setCondition] = useState( + editItem?.condition as TradeCondition | undefined + ); + const [images, setImages] = useState<{ uri: string; uploading: boolean }[]>( + editItem?.images?.map(img => ({ uri: img.url, uploading: false })) || [] + ); + const [submitting, setSubmitting] = useState(false); + + // 动画值 + const fadeAnim = useRef(new Animated.Value(0)).current; + const slideAnim = useRef(new Animated.Value(20)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]).start(); + }, []); + + const handleSubmit = useCallback(async () => { + if (!title.trim()) { + Alert.alert('提示', '请输入标题'); + return; + } + if (images.some(img => img.uploading)) { + Alert.alert('提示', '请等待图片上传完成'); + return; + } + + setSubmitting(true); + try { + const payload = { + trade_type: tradeType, + category, + title: title.trim(), + content: content.trim() || title.trim(), + images: images.map(img => img.uri), + price: price ? parseFloat(price) : null, + condition, + }; + + if (isEdit && editItem) { + await tradeService.updateTradeItem(editItem.id, payload); + } else { + await tradeService.createTradeItem(payload); + } + onSuccess?.(); + onClose(); + } catch (e: any) { + Alert.alert(isEdit ? '更新失败' : '发布失败', e?.message || '请稍后重试'); + } finally { + setSubmitting(false); + } + }, [title, content, tradeType, category, price, condition, images, onClose, onSuccess, isEdit, editItem]); + + const handlePickImage = useCallback(async () => { + if (images.length >= 9) { + Alert.alert('提示', '最多上传9张图片'); + return; + } + + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + Alert.alert('权限不足', '需要访问相册权限来选择图片'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: 'images', + allowsMultipleSelection: true, + selectionLimit: 9 - images.length, + quality: 1, + }); + + if (!result.canceled && result.assets) { + const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + setImages(prev => [...prev, ...newImages]); + + for (let i = 0; i < newImages.length; i++) { + const asset = result.assets[i]; + const uploadResult = await uploadService.uploadImage({ + uri: asset.uri, + type: asset.mimeType || 'image/jpeg', + }); + + if (uploadResult) { + setImages(prev => { + const updated = [...prev]; + const index = prev.findIndex(img => img.uri === asset.uri); + if (index !== -1) { + updated[index] = { uri: uploadResult.url, uploading: false }; + } + return updated; + }); + } else { + setImages(prev => prev.filter(img => img.uri !== asset.uri)); + } + } + } + }, [images.length]); + + const removeImage = useCallback((uri: string) => { + setImages(prev => prev.filter(img => img.uri !== uri)); + }, []); + + const canSubmit = title.trim().length > 0 && !submitting && !images.some(i => i.uploading); + + return ( + + {/* 顶部导航 */} + + + + + {isEdit ? '编辑商品' : '发布商品'} + + + + + + + {/* 标题区域 */} + + {isEdit ? '编辑商品' : '发布商品'} + + 填写商品信息,让更多人看到 + + + {/* 交易类型 */} + + 交易类型 + + setTradeType('sell')} + > + + 出售 + + setTradeType('buy')} + > + + 收购 + + + + + {/* 分类 */} + + 分类 + + + {ALL_TRADE_CATEGORIES.map(cat => ( + setCategory(cat.key)} + > + + {cat.label} + + + ))} + + + + + {/* 标题 */} + + 标题 * + + + {title.length > 0 && ( + + )} + + {title.length}/100 + + + {/* 描述 */} + + 描述 + + + + {content.length}/2000 + + + {/* 图片 */} + + 图片 ({images.length}/9) + + {images.map((img) => ( + + + {img.uploading && ( + + + + )} + removeImage(img.uri)}> + + + + ))} + {images.length < 9 && ( + + + 添加图片 + + )} + + + + {/* 价格 */} + + 价格(留空为面议) + + ¥ + + + + + {/* 成色 */} + {tradeType === 'sell' && ( + + 成色 + + + {ALL_TRADE_CONDITIONS.map(c => ( + setCondition(condition === c.key ? undefined : c.key)} + > + + {c.label} + + + ))} + + + + )} + + {/* 提交按钮 */} + + {submitting ? ( + + ) : ( + {isEdit ? '保存' : '发布'} + )} + + + + + + ); +} diff --git a/src/screens/trade/TradeDetailScreen.tsx b/src/screens/trade/TradeDetailScreen.tsx new file mode 100644 index 0000000..9a26ce9 --- /dev/null +++ b/src/screens/trade/TradeDetailScreen.tsx @@ -0,0 +1,623 @@ +import React, { useState, useEffect, useCallback } 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 } 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'; + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + 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, + }, + imageContainer: { + width: '100%', + aspectRatio: 1, + backgroundColor: colors.background.paper, + position: 'relative', + }, + image: { + width: '100%', + height: '100%', + resizeMode: 'contain', + }, + // 降价标签 + 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', + }, + + titleRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + marginBottom: spacing.md, + }, + 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: { + fontSize: fontSizes['3xl'], + fontWeight: '600', + color: colors.text.primary, + lineHeight: 34, + 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: { + fontSize: fontSizes.lg, + color: colors.text.primary, + lineHeight: 28, + marginBottom: spacing.md, + }, + // 底部操作栏 + 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', + }, + }); +} + +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(null); + const [loading, setLoading] = useState(true); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [manageModalVisible, setManageModalVisible] = useState(false); + + const isOwner = item?.user_id === currentUser?.id; + + + + 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]); + + if (loading) { + return ; + } + + if (!item) { + return ( + + + 商品不存在或已删除 + + + ); + } + + const images = item.images || []; + const currentImage = images[currentImageIndex]; + + return ( + + + + router.back()} /> + + 商品详情 + + + + + + {/* 卖家信息头部 */} + {item.author && ( + + + {item.author.avatar ? ( + + ) : ( + + + + )} + + + + {item.author.nickname && item.author.nickname.length > 10 + ? `${item.author.nickname.slice(0, 10)}...` + : item.author.nickname || '匿名'} + + {(item.author?.credit_score || 0) > 80 && ( + + 卖家信用优秀 + + )} + + + {item.price != null ? ( + + 价格:¥{item.price % 1 === 0 ? item.price.toFixed(0) : item.price.toFixed(2)} + + ) : ( + 价格:面议 + )} + + + + )} + + {/* 标题 */} + + {item.title} + {item.condition && item.trade_type === 'sell' && ( + + + + {TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition} + + + + )} + + + {/* 商品属性 */} + + + 品牌 + {item.brand || '其他'} + + + 成色 + + {TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition || '未知'} + + + + 功能状态 + {item.function_status || '正常使用'} + + + + {/* 商品描述 */} + {item.content && ( + {item.content} + )} + + + + + {/* 图片放在文字下方 */} + {images.length > 0 && ( + + {images.map((img, idx) => ( + + + + + ))} + + )} + + + {/* 底部操作栏 */} + + {isOwner ? ( + /* 卖家管理按钮 */ + <> + + setManageModalVisible(true)}> + + 管理 + + + ) : ( + <> + {!isOwner && item.status === 'active' && ( + <> + + + + 聊一聊 + + + )} + {!isOwner && item.status !== 'active' && ( + + + + {item.status === 'sold' ? '已售出' : item.status === 'bought' ? '已收得' : '已下架'} + + + + )} + + )} + + + {/* 管理弹窗 */} + setManageModalVisible(false)} + > + setManageModalVisible(false)}> + + + 编辑 + + + 删除 + + setManageModalVisible(false)}> + 取消 + + + + + + ); +} \ No newline at end of file diff --git a/src/services/index.ts b/src/services/index.ts index c61edb6..9345074 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -40,6 +40,8 @@ export type { SubmitVerificationResponse } from './auth'; export { postService, commentService, voteService, channelService, reportService } from './post'; export type { ChannelItem } from './post'; +export { tradeService } from './trade/tradeService'; + export { messageService } from './message'; export { groupService } from './message'; export type { diff --git a/src/services/trade/tradeService.ts b/src/services/trade/tradeService.ts new file mode 100644 index 0000000..35b9245 --- /dev/null +++ b/src/services/trade/tradeService.ts @@ -0,0 +1,72 @@ +import { api } from '../core/api'; +import type { + TradeItemDTO, + TradeItemDetailDTO, + CreateTradeItemInput, + UpdateTradeItemInput, + TradeCursorPageResponse, + TradeType, + TradeCategory, + TradeItemStatus, +} from '@/types/trade'; + +class TradeService { + async getTradeItems(params?: { + trade_type?: TradeType; + category?: TradeCategory; + status?: TradeItemStatus; + keyword?: string; + cursor?: string; + page_size?: number; + }): Promise { + const queryParams: Record = {}; + if (params?.trade_type) queryParams.trade_type = params.trade_type; + if (params?.category) queryParams.category = params.category; + if (params?.status) queryParams.status = params.status; + if (params?.keyword) queryParams.keyword = params.keyword; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.page_size) queryParams.page_size = String(params.page_size); + + const res = await api.get('/trade', queryParams); + return res.data; + } + + async getTradeItem(id: string): Promise { + const res = await api.get(`/trade/${id}`); + return res.data; + } + + async createTradeItem(data: CreateTradeItemInput): Promise { + const res = await api.post('/trade', data); + return res.data; + } + + async updateTradeItem(id: string, data: UpdateTradeItemInput): Promise { + const res = await api.put(`/trade/${id}`, data); + return res.data; + } + + async deleteTradeItem(id: string): Promise { + await api.delete(`/trade/${id}`); + } + + async updateTradeItemStatus(id: string, status: TradeItemStatus): Promise { + await api.put(`/trade/${id}/status`, { status }); + } + + async recordView(id: string): Promise { + await api.post(`/trade/${id}/view`, {}); + } + + async favorite(id: string): Promise<{ favorited: boolean }> { + const res = await api.post<{ favorited: boolean }>(`/trade/${id}/favorite`, {}); + return res.data; + } + + async unfavorite(id: string): Promise<{ favorited: boolean }> { + const res = await api.delete<{ favorited: boolean }>(`/trade/${id}/favorite`); + return res.data; + } +} + +export const tradeService = new TradeService(); \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 6611650..76e1a80 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ export * from './dto'; export * from './schedule'; export * from './material'; +export * from './trade'; // 兼容旧类型(用于内部组件) import type { diff --git a/src/types/trade.ts b/src/types/trade.ts new file mode 100644 index 0000000..f3be2e0 --- /dev/null +++ b/src/types/trade.ts @@ -0,0 +1,124 @@ +export type TradeType = 'sell' | 'buy'; +export type TradeCategory = 'digital' | 'book' | 'clothing' | 'daily' | 'cosmetics' | 'sport' | 'ticket' | 'other'; +export type TradeItemStatus = 'active' | 'sold' | 'bought' | 'offline' | 'deleted'; +export type TradeCondition = 'brand_new' | 'like_new' | 'lightly_used' | 'well_used'; + +export interface TradeImageDTO { + id: string; + url: string; + thumbnail_url: string; + preview_url: string; + preview_url_large: string; + width: number; + height: number; +} + +export interface TradeItemDTO { + id: string; + user_id: string; + trade_type: TradeType; + category: TradeCategory; + title: string; + content: string; + segments?: any[]; + images: TradeImageDTO[]; + price?: number | null; + condition?: string; + status: TradeItemStatus; + views_count: number; + favorites_count: number; + wants_count?: number; + comments_count?: number; + is_favorited: boolean; + is_free_shipping?: boolean; + is_quick_ship?: boolean; + is_negotiable?: boolean; + is_self_pickup?: boolean; + good_reputation?: number; + brand?: string; + function_status?: string; + promo_text?: string; + author: any | null; + created_at: string; + updated_at: string; +} + +export type TradeItemDetailDTO = TradeItemDTO; + +export interface CreateTradeItemInput { + trade_type: TradeType; + category: TradeCategory; + title: string; + content?: string; + segments?: any[]; + images?: string[]; + price?: number | null; + condition?: TradeCondition; +} + +export interface UpdateTradeItemInput { + trade_type?: TradeType; + category?: TradeCategory; + title?: string; + content?: string; + segments?: any[]; + images?: string[]; + price?: number | null; + condition?: TradeCondition; +} + +export interface TradeCursorPageResponse { + list: TradeItemDTO[]; + next_cursor?: string; + prev_cursor?: string; + has_more: boolean; +} + +export const TRADE_CATEGORY_MAP: Record = { + digital: '数码电子', + book: '书籍教材', + clothing: '服饰鞋包', + daily: '日用百货', + cosmetics: '美妆护肤', + sport: '运动户外', + ticket: '票券卡券', + other: '其他', +}; + +export const TRADE_CONDITION_MAP: Record = { + brand_new: '全新', + like_new: '几乎全新', + lightly_used: '轻微使用痕迹', + well_used: '明显使用痕迹', +}; + +export const TRADE_TYPE_MAP: Record = { + sell: '出售', + buy: '收购', +}; + +export const TRADE_STATUS_MAP: Record = { + active: '在售', + sold: '已售出', + bought: '已收得', + offline: '已下架', + deleted: '已删除', +}; + +export const ALL_TRADE_CATEGORIES: { key: TradeCategory; label: string }[] = [ + { key: 'digital', label: '数码电子' }, + { key: 'book', label: '书籍教材' }, + { key: 'clothing', label: '服饰鞋包' }, + { key: 'daily', label: '日用百货' }, + { key: 'cosmetics', label: '美妆护肤' }, + { key: 'sport', label: '运动户外' }, + { key: 'ticket', label: '票券卡券' }, + { key: 'other', label: '其他' }, +]; + +export const ALL_TRADE_CONDITIONS: { key: TradeCondition; label: string }[] = [ + { key: 'brand_new', label: '全新' }, + { key: 'like_new', label: '几乎全新' }, + { key: 'lightly_used', label: '轻微使用痕迹' }, + { key: 'well_used', label: '明显使用痕迹' }, +]; \ No newline at end of file