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.
This commit is contained in:
@@ -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 (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
|
||||
7
app/trade/[tradeId].tsx
Normal file
7
app/trade/[tradeId].tsx
Normal file
@@ -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 <TradeDetailScreen tradeId={tradeId} />;
|
||||
}
|
||||
46
app/trade/edit/[tradeId].tsx
Normal file
46
app/trade/edit/[tradeId].tsx
Normal file
@@ -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<TradeItemDetailDTO | null>(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 <Loading fullScreen />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateTradeScreen
|
||||
onClose={handleClose}
|
||||
onSuccess={handleSuccess}
|
||||
editItem={item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
marginRight: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
|
||||
@@ -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<SearchBarProps> = ({
|
||||
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<SearchBarProps> = ({
|
||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={18}
|
||||
size={compact ? 16 : 18}
|
||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
377
src/components/business/TradeCard/TradeCard.tsx
Normal file
377
src/components/business/TradeCard/TradeCard.tsx
Normal file
@@ -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<TradeCardProps> = ({ item, variant = 'list', onPress, onFavorite }) => {
|
||||
const colors = useAppColors();
|
||||
const styles = createTradeCardStyles(colors);
|
||||
|
||||
const firstImage = item.images?.[0];
|
||||
const isSell = item.trade_type === 'sell';
|
||||
const isActive = item.status === 'active';
|
||||
const conditionLabel = item.condition ? TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] : null;
|
||||
const categoryLabel = TRADE_CATEGORY_MAP[item.category] || item.category;
|
||||
|
||||
const renderImage = (containerStyle: any, imageStyle: any) => (
|
||||
<View style={containerStyle}>
|
||||
{firstImage ? (
|
||||
<Image
|
||||
source={{ uri: firstImage.preview_url || firstImage.url }}
|
||||
style={imageStyle}
|
||||
defaultSource={undefined}
|
||||
/>
|
||||
) : (
|
||||
<View style={[imageStyle, styles.imagePlaceholder]}>
|
||||
<MaterialCommunityIcons name="image-outline" size={32} color={colors.text.hint} />
|
||||
</View>
|
||||
)}
|
||||
{/* 包邮标签 */}
|
||||
{item.is_free_shipping && (
|
||||
<View style={styles.shippingBadge}>
|
||||
<Text style={styles.shippingBadgeText}>包邮</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.typeBadge, isSell ? styles.sellBadge : styles.buyBadge]}>
|
||||
<Text style={styles.typeBadgeText}>{TRADE_TYPE_MAP[item.trade_type]}</Text>
|
||||
</View>
|
||||
{!isActive && (
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusBadgeText}>
|
||||
{item.status === 'sold' ? '已售' : item.status === 'bought' ? '已收' : item.status === 'offline' ? '下架' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderTitleRow = () => (
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
||||
<Text style={styles.priceInline}>
|
||||
{item.price != null ? formatPrice(item.price) : '面议'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderTags = () => {
|
||||
if (!conditionLabel && !isSell && !item.is_quick_ship) return null;
|
||||
return (
|
||||
<View style={styles.tagsRow}>
|
||||
{conditionLabel && isSell && (
|
||||
<View style={[styles.tag, styles.conditionTag]}>
|
||||
<Text style={styles.conditionTagText}>{conditionLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.is_quick_ship && (
|
||||
<View style={styles.promoTag}>
|
||||
<Text style={styles.promoTagText}>24小时发货</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBottom = () => (
|
||||
<View style={styles.bottomRow}>
|
||||
<View style={styles.authorRow}>
|
||||
{item.author?.avatar ? (
|
||||
<Image source={{ uri: item.author.avatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, { backgroundColor: colors.primary.main + '33' }]} />
|
||||
)}
|
||||
<Text style={styles.authorName} numberOfLines={1}>{item.author?.nickname || '匿名'}</Text>
|
||||
</View>
|
||||
<View style={styles.statsRow}>
|
||||
{(item.good_reputation || 0) > 0 && (
|
||||
<View style={[styles.promoTag, { backgroundColor: '#FFF7E6', borderColor: '#FFD591' }]}>
|
||||
<Text style={[styles.promoTagText, { color: '#FA8C16' }]}>回头客好评过百</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.tag}>
|
||||
<Text style={styles.tagText}>{categoryLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (variant === 'grid') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.gridCard}
|
||||
onPress={() => onPress?.(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{renderImage(styles.imageContainer, styles.image)}
|
||||
<View style={styles.gridContent}>
|
||||
{renderTitleRow()}
|
||||
{renderTags()}
|
||||
{renderBottom()}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.listCard}
|
||||
onPress={() => onPress?.(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.listLayout}>
|
||||
{renderImage(styles.listImageContainer, styles.image)}
|
||||
<View style={styles.listContent}>
|
||||
{renderTitleRow()}
|
||||
{renderTags()}
|
||||
{renderBottom()}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const TradeCard = memo(TradeCardBase);
|
||||
|
||||
export default TradeCard;
|
||||
@@ -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';
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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<ViewMode>('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<TradeCategory | 'all'>('all');
|
||||
|
||||
// 分享面板状态
|
||||
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||
const [sharePost, setSharePost] = useState<Post | null>(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 */}
|
||||
<View style={styles.header}>
|
||||
{/* 搜索栏 */}
|
||||
<View style={[styles.searchWrapper, { paddingHorizontal: responsivePadding }]}>
|
||||
<SearchBar
|
||||
value=""
|
||||
onChangeText={() => {}}
|
||||
onSubmit={handleSearchPress}
|
||||
onFocus={handleSearchPress}
|
||||
placeholder="搜索帖子、用户"
|
||||
/>
|
||||
{/* 广场/市集切换 */}
|
||||
<View style={styles.homeTabSwitcher}>
|
||||
<View style={styles.homeTabSwitcherLeft}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={[styles.homeTabItem, homeTab === 'square' && styles.homeTabItemActive]}
|
||||
onPress={() => setHomeTab('square')}
|
||||
>
|
||||
<Text style={homeTab === 'square' ? styles.homeTabTextActive : styles.homeTabText}>
|
||||
广场
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={[styles.homeTabItem, homeTab === 'market' && styles.homeTabItemActive]}
|
||||
onPress={() => setHomeTab('market')}
|
||||
>
|
||||
<Text style={homeTab === 'market' ? styles.homeTabTextActive : styles.homeTabText}>
|
||||
市集
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.homeTabSwitcherRight}>
|
||||
<SearchBar
|
||||
value=""
|
||||
onChangeText={() => {}}
|
||||
onSubmit={handleSearchPress}
|
||||
onFocus={handleSearchPress}
|
||||
placeholder="搜索"
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 排序筛选 + 频道 */}
|
||||
|
||||
{/* 排序筛选 + 频道(仅广场模式显示) */}
|
||||
{homeTab === 'square' && (
|
||||
<View style={styles.sortBar}>
|
||||
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
|
||||
<MaterialCommunityIcons
|
||||
@@ -894,9 +975,56 @@ export const HomeScreen: React.FC = () => {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 市集分类 + 筛选(仅市集模式显示) */}
|
||||
{homeTab === 'market' && (
|
||||
<View style={styles.sortBar}>
|
||||
<FlashList
|
||||
horizontal
|
||||
data={[{ key: 'all', label: '全部' }, ...ALL_TRADE_CATEGORIES]}
|
||||
renderItem={({ item }: { item: { key: string; label: string } }) => (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.capsuleItem,
|
||||
marketCategory === item.key && { backgroundColor: `${colors.primary.main}18` },
|
||||
]}
|
||||
onPress={() => setMarketCategory(item.key as TradeCategory | 'all')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.capsuleText,
|
||||
marketCategory === item.key ? styles.capsuleTextActive : {},
|
||||
]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item: { key: string; label: string }) => item.key}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.sortBarCapsuleContent}
|
||||
style={styles.sortBarCapsuleScroll}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={cycleMarketFilter}
|
||||
style={styles.sortTrigger}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={(MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.icon || 'view-grid-outline') as any}
|
||||
size={18}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
<Text style={styles.sortTriggerText}>
|
||||
{MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.label || '全部'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
{/* 内容区域 */}
|
||||
{homeTab === 'square' ? (
|
||||
<View style={styles.contentContainer}>
|
||||
{isInitialLoading ? (
|
||||
<Loading fullScreen />
|
||||
@@ -906,8 +1034,17 @@ export const HomeScreen: React.FC = () => {
|
||||
renderResponsiveGrid()
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<MarketView
|
||||
onCreateTrade={() => setShowCreateTrade(true)}
|
||||
filterType={marketFilterType}
|
||||
onFilterTypeChange={setMarketFilterType}
|
||||
selectedCategory={marketCategory}
|
||||
onSelectedCategoryChange={setMarketCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 漂浮发帖按钮 */}
|
||||
{/* 漂浮发帖/发布按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.floatingButton,
|
||||
@@ -915,7 +1052,11 @@ export const HomeScreen: React.FC = () => {
|
||||
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}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
|
||||
@@ -945,6 +1086,18 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 发布交易弹窗 */}
|
||||
<Modal
|
||||
visible={showCreateTrade}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setShowCreateTrade(false)}
|
||||
>
|
||||
<CreateTradeScreen
|
||||
onClose={() => setShowCreateTrade(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 分享面板 */}
|
||||
<ShareSheet
|
||||
visible={showShareSheet}
|
||||
|
||||
223
src/screens/home/MarketView.tsx
Normal file
223
src/screens/home/MarketView.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { View, StyleSheet, RefreshControl, Pressable } from 'react-native';
|
||||
import { FlashList, FlashListRef } from '@shopify/flash-list';
|
||||
import type { ListRenderItem } from '@shopify/flash-list';
|
||||
import { useAppColors, type AppColors } from '../../theme';
|
||||
import { Text, Loading, EmptyState } from '../../components/common';
|
||||
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
||||
import { tradeService } from '../../services/trade/tradeService';
|
||||
import { useIsAuthenticated } from '../../stores/auth';
|
||||
import type {
|
||||
TradeItemDTO,
|
||||
TradeCategory,
|
||||
TradeType,
|
||||
} from '../../types/trade';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
|
||||
type TradeFilterType = 'all' | 'sell' | 'buy';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function createMarketStyles(colors: AppColors, gap: number, padding: number) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: padding,
|
||||
paddingTop: gap,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface MarketViewProps {
|
||||
onCreateTrade: () => 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<TradeItemDTO[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [cursor, setCursor] = useState<string | undefined>();
|
||||
const [filterTypeLocal, setFilterTypeLocal] = useState<TradeFilterType>('all');
|
||||
const [selectedCategoryLocal, setSelectedCategoryLocal] = useState<TradeCategory | 'all'>('all');
|
||||
|
||||
const filterType = filterTypeProp ?? filterTypeLocal;
|
||||
const selectedCategory = selectedCategoryProp ?? selectedCategoryLocal;
|
||||
const setFilterType = onFilterTypeChange ?? setFilterTypeLocal;
|
||||
const setSelectedCategory = onSelectedCategoryChange ?? setSelectedCategoryLocal;
|
||||
|
||||
const flashListRef = useRef<any>(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<ListRenderItem<TradeItemDTO>>(({ item }) => (
|
||||
<View style={{ width: cardWidth, marginBottom: gap }}>
|
||||
<TradeCard
|
||||
item={item}
|
||||
variant="grid"
|
||||
onPress={handlePressItem}
|
||||
onFavorite={handleFavorite}
|
||||
/>
|
||||
</View>
|
||||
), [handlePressItem, handleFavorite, cardWidth, gap]);
|
||||
|
||||
const keyExtractor = useCallback((item: TradeItemDTO) => item.id, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading fullScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlashList
|
||||
ref={flashListRef as any}
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={numColumns}
|
||||
key={`market_${numColumns}`}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={() => fetchItems(true)}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="暂无商品"
|
||||
description="还没有人发布商品,快来发布第一条吧"
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
|
||||
drawDistance={250}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1472,7 +1472,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
<TouchableOpacity style={styles.bottomActionItem} onPress={handleLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={post?.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
||||
size={isDesktop ? 23 : 21}
|
||||
size={isDesktop ? 26 : 24}
|
||||
color={post?.is_liked ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
<Text variant="caption" style={styles.bottomActionCount}>
|
||||
@@ -1483,7 +1483,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
<TouchableOpacity style={styles.bottomActionItem} onPress={handleFavorite}>
|
||||
<MaterialCommunityIcons
|
||||
name={post?.is_favorited ? 'star' : 'star-outline'}
|
||||
size={isDesktop ? 23 : 21}
|
||||
size={isDesktop ? 26 : 24}
|
||||
color={post?.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||
/>
|
||||
<Text variant="caption" style={styles.bottomActionCount}>
|
||||
@@ -1492,7 +1492,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.bottomActionItem} onPress={handleShare}>
|
||||
<MaterialCommunityIcons name="share-outline" size={isDesktop ? 23 : 21} color={colors.text.secondary} />
|
||||
<MaterialCommunityIcons name="share-outline" size={isDesktop ? 26 : 24} color={colors.text.secondary} />
|
||||
<Text variant="caption" style={styles.bottomActionCount}>
|
||||
{formatNumber(post?.shares_count || 0)}
|
||||
</Text>
|
||||
@@ -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,
|
||||
|
||||
640
src/screens/trade/CreateTradeScreen.tsx
Normal file
640
src/screens/trade/CreateTradeScreen.tsx
Normal file
@@ -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<TradeType>(editItem?.trade_type || 'sell');
|
||||
const [category, setCategory] = useState<TradeCategory>(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<TradeCondition | undefined>(
|
||||
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 (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
{/* 顶部导航 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.backIconButton} activeOpacity={0.8}>
|
||||
<MaterialCommunityIcons name="close" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{isEdit ? '编辑商品' : '发布商品'}</Text>
|
||||
<View style={styles.headerRight} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.titleSection}>
|
||||
<Text style={styles.title}>{isEdit ? '编辑商品' : '发布商品'}</Text>
|
||||
<View style={styles.underline} />
|
||||
<Text style={styles.subtitle}>填写商品信息,让更多人看到</Text>
|
||||
</View>
|
||||
|
||||
{/* 交易类型 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>交易类型</Text>
|
||||
<View style={styles.typeRow}>
|
||||
<Pressable
|
||||
style={[styles.typeButton, tradeType === 'sell' && styles.typeButtonActive]}
|
||||
onPress={() => setTradeType('sell')}
|
||||
>
|
||||
<MaterialCommunityIcons name="tag-outline" size={18} color={tradeType === 'sell' ? colors.primary.main : colors.text.hint} />
|
||||
<Text style={[styles.typeButtonText, tradeType === 'sell' ? styles.typeButtonTextActive : {}]}>出售</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.typeButton, tradeType === 'buy' && styles.typeButtonActive]}
|
||||
onPress={() => setTradeType('buy')}
|
||||
>
|
||||
<MaterialCommunityIcons name="hand-coin-outline" size={18} color={tradeType === 'buy' ? colors.primary.main : colors.text.hint} />
|
||||
<Text style={[styles.typeButtonText, tradeType === 'buy' ? styles.typeButtonTextActive : {}]}>收购</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 分类 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>分类</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.optionRow}>
|
||||
{ALL_TRADE_CATEGORIES.map(cat => (
|
||||
<Pressable
|
||||
key={cat.key}
|
||||
style={[styles.optionChip, category === cat.key && styles.optionChipActive]}
|
||||
onPress={() => setCategory(cat.key)}
|
||||
>
|
||||
<Text style={[styles.optionChipText, category === cat.key ? styles.optionChipTextActive : {}]}>
|
||||
{cat.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 标题 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>标题 <Text style={styles.requiredMark}>*</Text></Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="描述你的商品"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={100}
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{title.length > 0 && (
|
||||
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.charCount}>{title.length}/100</Text>
|
||||
</View>
|
||||
|
||||
{/* 描述 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>描述</Text>
|
||||
<View style={[styles.inputWrapper, styles.textAreaWrapper]}>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="详细描述商品情况..."
|
||||
placeholderTextColor={colors.text.hint}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
textAlignVertical="top"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.charCount}>{content.length}/2000</Text>
|
||||
</View>
|
||||
|
||||
{/* 图片 */}
|
||||
<View style={styles.imageSection}>
|
||||
<Text style={styles.inputLabel}>图片 ({images.length}/9)</Text>
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img) => (
|
||||
<View key={img.uri} style={styles.imageItem}>
|
||||
<Image source={{ uri: img.uri.startsWith('http') ? img.uri : img.uri }} style={styles.imagePreview} />
|
||||
{img.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
)}
|
||||
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.uri)}>
|
||||
<MaterialCommunityIcons name="close" size={16} color="#fff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
{images.length < 9 && (
|
||||
<Pressable style={styles.addImageBtn} onPress={handlePickImage}>
|
||||
<MaterialCommunityIcons name="plus" size={32} color={colors.text.hint} />
|
||||
<Text style={{ fontSize: 12, color: colors.text.hint, marginTop: 4 }}>添加图片</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 价格 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>价格(留空为面议)</Text>
|
||||
<View style={styles.priceInputWrapper}>
|
||||
<Text style={styles.pricePrefix}>¥</Text>
|
||||
<TextInput
|
||||
style={styles.priceField}
|
||||
value={price}
|
||||
onChangeText={setPrice}
|
||||
placeholder="请输入价格"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
keyboardType="decimal-pad"
|
||||
maxLength={10}
|
||||
cursorColor={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 成色 */}
|
||||
{tradeType === 'sell' && (
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>成色</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.optionRow}>
|
||||
{ALL_TRADE_CONDITIONS.map(c => (
|
||||
<Pressable
|
||||
key={c.key}
|
||||
style={[styles.optionChip, condition === c.key && styles.optionChipActive]}
|
||||
onPress={() => setCondition(condition === c.key ? undefined : c.key)}
|
||||
>
|
||||
<Text style={[styles.optionChipText, condition === c.key ? styles.optionChipTextActive : {}]}>
|
||||
{c.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.submitBtn, !canSubmit && styles.submitBtnDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
) : (
|
||||
<Text style={styles.submitBtnText}>{isEdit ? '保存' : '发布'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
623
src/screens/trade/TradeDetailScreen.tsx
Normal file
623
src/screens/trade/TradeDetailScreen.tsx
Normal file
@@ -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<TradeItemDetailDTO | null>(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 <Loading fullScreen />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: colors.text.hint }}>商品不存在或已删除</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const images = item.images || [];
|
||||
const currentImage = images[currentImageIndex];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<AppBackButton onPress={() => router.back()} />
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>商品详情</Text>
|
||||
<View style={styles.headerRight} />
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
<View style={styles.content}>
|
||||
{/* 卖家信息头部 */}
|
||||
{item.author && (
|
||||
<View>
|
||||
<View style={styles.sellerHeader}>
|
||||
{item.author.avatar ? (
|
||||
<Image source={{ uri: item.author.avatar }} style={styles.sellerAvatar} />
|
||||
) : (
|
||||
<View style={[styles.sellerAvatar, { backgroundColor: colors.primary.main + '33', alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<MaterialCommunityIcons name="account" size={18} color={colors.primary.main} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.sellerInfo}>
|
||||
<View style={styles.sellerNameRow}>
|
||||
<Text style={styles.sellerName} numberOfLines={1}>
|
||||
{item.author.nickname && item.author.nickname.length > 10
|
||||
? `${item.author.nickname.slice(0, 10)}...`
|
||||
: item.author.nickname || '匿名'}
|
||||
</Text>
|
||||
{(item.author?.credit_score || 0) > 80 && (
|
||||
<View style={styles.sellerCreditBadge}>
|
||||
<Text style={styles.sellerCreditText}>卖家信用优秀</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{item.price != null ? (
|
||||
<Text style={styles.priceInHeader}>
|
||||
价格:¥{item.price % 1 === 0 ? item.price.toFixed(0) : item.price.toFixed(2)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.priceInHeader}>价格:面议</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
||||
{item.condition && item.trade_type === 'sell' && (
|
||||
<View style={{ flexShrink: 0, marginTop: 7, marginLeft: 'auto' }}>
|
||||
<View style={styles.conditionBadge}>
|
||||
<Text style={styles.conditionBadgeText} numberOfLines={1}>
|
||||
{TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 商品属性 */}
|
||||
<View style={styles.attrGrid}>
|
||||
<View style={styles.attrItem}>
|
||||
<Text style={styles.attrLabel}>品牌</Text>
|
||||
<Text style={styles.attrValue}>{item.brand || '其他'}</Text>
|
||||
</View>
|
||||
<View style={styles.attrItem}>
|
||||
<Text style={styles.attrLabel}>成色</Text>
|
||||
<Text style={styles.attrValue}>
|
||||
{TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] || item.condition || '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.attrItem}>
|
||||
<Text style={styles.attrLabel}>功能状态</Text>
|
||||
<Text style={styles.attrValue}>{item.function_status || '正常使用'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品描述 */}
|
||||
{item.content && (
|
||||
<Text style={styles.contentText}>{item.content}</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
|
||||
{/* 图片放在文字下方 */}
|
||||
{images.length > 0 && (
|
||||
<View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.md }}>
|
||||
{images.map((img, idx) => (
|
||||
<View key={img.id || idx} style={[styles.imageContainer, { marginBottom: spacing.md, borderRadius: borderRadius.lg, overflow: 'hidden' }]}>
|
||||
<Image
|
||||
source={{ uri: img.url || img.preview_url_large || img.preview_url || '' }}
|
||||
style={styles.image}
|
||||
/>
|
||||
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<View style={styles.bottomBar}>
|
||||
{isOwner ? (
|
||||
/* 卖家管理按钮 */
|
||||
<>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable style={styles.manageButton} onPress={() => setManageModalVisible(true)}>
|
||||
<MaterialCommunityIcons name="cog-outline" size={18} color={colors.text.primary} />
|
||||
<Text style={styles.manageButtonText}>管理</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isOwner && item.status === 'active' && (
|
||||
<>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable style={styles.chatButton} onPress={handleChat}>
|
||||
<MaterialCommunityIcons name="chat-outline" size={18} color={colors.text.inverse} />
|
||||
<Text style={styles.chatButtonText}>聊一聊</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
{!isOwner && item.status !== 'active' && (
|
||||
<View style={{ flex: 1, alignItems: 'flex-end' }}>
|
||||
<View style={[styles.chatButton, styles.chatButtonDisabled]}>
|
||||
<Text style={styles.chatButtonText}>
|
||||
{item.status === 'sold' ? '已售出' : item.status === 'bought' ? '已收得' : '已下架'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 管理弹窗 */}
|
||||
<Modal
|
||||
visible={manageModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setManageModalVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setManageModalVisible(false)}>
|
||||
<View style={styles.modalContent}>
|
||||
<Pressable style={styles.modalItem} onPress={handleEdit}>
|
||||
<Text style={styles.modalItemText}>编辑</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.modalItem, { borderBottomWidth: 0 }]} onPress={handleDelete}>
|
||||
<Text style={[styles.modalItemText, { color: colors.error.main }]}>删除</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.modalCancel} onPress={() => setManageModalVisible(false)}>
|
||||
<Text style={styles.modalCancelText}>取消</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
72
src/services/trade/tradeService.ts
Normal file
72
src/services/trade/tradeService.ts
Normal file
@@ -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<TradeCursorPageResponse> {
|
||||
const queryParams: Record<string, string> = {};
|
||||
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<TradeCursorPageResponse>('/trade', queryParams);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getTradeItem(id: string): Promise<TradeItemDetailDTO> {
|
||||
const res = await api.get<TradeItemDetailDTO>(`/trade/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async createTradeItem(data: CreateTradeItemInput): Promise<TradeItemDetailDTO> {
|
||||
const res = await api.post<TradeItemDetailDTO>('/trade', data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async updateTradeItem(id: string, data: UpdateTradeItemInput): Promise<TradeItemDetailDTO> {
|
||||
const res = await api.put<TradeItemDetailDTO>(`/trade/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async deleteTradeItem(id: string): Promise<void> {
|
||||
await api.delete(`/trade/${id}`);
|
||||
}
|
||||
|
||||
async updateTradeItemStatus(id: string, status: TradeItemStatus): Promise<void> {
|
||||
await api.put(`/trade/${id}/status`, { status });
|
||||
}
|
||||
|
||||
async recordView(id: string): Promise<void> {
|
||||
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();
|
||||
@@ -7,6 +7,7 @@
|
||||
export * from './dto';
|
||||
export * from './schedule';
|
||||
export * from './material';
|
||||
export * from './trade';
|
||||
|
||||
// 兼容旧类型(用于内部组件)
|
||||
import type {
|
||||
|
||||
124
src/types/trade.ts
Normal file
124
src/types/trade.ts
Normal file
@@ -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<TradeCategory, string> = {
|
||||
digital: '数码电子',
|
||||
book: '书籍教材',
|
||||
clothing: '服饰鞋包',
|
||||
daily: '日用百货',
|
||||
cosmetics: '美妆护肤',
|
||||
sport: '运动户外',
|
||||
ticket: '票券卡券',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const TRADE_CONDITION_MAP: Record<TradeCondition, string> = {
|
||||
brand_new: '全新',
|
||||
like_new: '几乎全新',
|
||||
lightly_used: '轻微使用痕迹',
|
||||
well_used: '明显使用痕迹',
|
||||
};
|
||||
|
||||
export const TRADE_TYPE_MAP: Record<TradeType, string> = {
|
||||
sell: '出售',
|
||||
buy: '收购',
|
||||
};
|
||||
|
||||
export const TRADE_STATUS_MAP: Record<TradeItemStatus, string> = {
|
||||
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: '明显使用痕迹' },
|
||||
];
|
||||
Reference in New Issue
Block a user