From 6cbd2092acffc3e313cddd378746a828717071a9 Mon Sep 17 00:00:00 2001
From: lafay <2021211506@stu.hit.edu.cn>
Date: Sun, 26 Apr 2026 17:13:43 +0800
Subject: [PATCH] refactor(ui): redesign SearchBar with compact mode and add
home screen tabs
Refactor SearchBar component to support compact mode with transparent styling,
reduced padding, and smaller icons. Add home screen tab navigation for switching
between home feed and market view with animated tab indicator. Also adjust
PostDetailScreen action button sizing and CommentItem margins for better spacing.
---
app/_layout.tsx | 11 +
app/trade/[tradeId].tsx | 7 +
app/trade/edit/[tradeId].tsx | 46 ++
src/components/business/CommentItem.tsx | 2 +-
src/components/business/SearchBar.tsx | 45 +-
.../business/TradeCard/TradeCard.tsx | 377 +++++++++++
src/components/business/index.ts | 1 +
src/navigation/hrefs.ts | 4 +
src/screens/home/HomeScreen.tsx | 213 +++++-
src/screens/home/MarketView.tsx | 223 ++++++
src/screens/home/PostDetailScreen.tsx | 14 +-
src/screens/trade/CreateTradeScreen.tsx | 640 ++++++++++++++++++
src/screens/trade/TradeDetailScreen.tsx | 623 +++++++++++++++++
src/services/index.ts | 2 +
src/services/trade/tradeService.ts | 72 ++
src/types/index.ts | 1 +
src/types/trade.ts | 124 ++++
17 files changed, 2341 insertions(+), 64 deletions(-)
create mode 100644 app/trade/[tradeId].tsx
create mode 100644 app/trade/edit/[tradeId].tsx
create mode 100644 src/components/business/TradeCard/TradeCard.tsx
create mode 100644 src/screens/home/MarketView.tsx
create mode 100644 src/screens/trade/CreateTradeScreen.tsx
create mode 100644 src/screens/trade/TradeDetailScreen.tsx
create mode 100644 src/services/trade/tradeService.ts
create mode 100644 src/types/trade.ts
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