refactor(ui): redesign SearchBar with compact mode and add home screen tabs
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m13s
Frontend CI / build-android-apk (push) Failing after 9m39s
Frontend CI / ota-android (push) Successful in 10m31s

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:
lafay
2026-04-26 17:13:43 +08:00
parent 5815f1a5f7
commit 6cbd2092ac
17 changed files with 2341 additions and 64 deletions

View File

@@ -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
View 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} />;
}

View 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}
/>
);
}

View File

@@ -128,7 +128,7 @@ function createCommentItemStyles(colors: AppColors) {
actionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.lg,
marginRight: spacing.sm,
paddingVertical: 4,
paddingRight: spacing.xs,
},

View File

@@ -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>

View 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;

View File

@@ -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';

View File

@@ -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)}`;
}

View File

@@ -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}

View 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>
);
}

View File

@@ -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,

View 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>
);
}

View 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>
);
}

View File

@@ -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 {

View 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();

View File

@@ -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
View 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: '明显使用痕迹' },
];