From b0602b0a99ef3f4ed795e8110980cef356e74661 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 30 Sep 2025 11:33:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E6=96=B0=E5=A2=9E=E6=8C=91?= =?UTF-8?q?=E6=88=98=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=8E=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E6=A6=9C=E5=8F=8A=E8=BD=AE=E6=92=AD=E5=8D=A1=E7=89=87=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节 --- app/(tabs)/challenges.tsx | 395 ++++++++++++++---- app/challenges/{[id].tsx => [id]/index.tsx} | 103 ++--- app/challenges/[id]/leaderboard.tsx | 292 +++++++++++++ .../challenges/ChallengeProgressCard.tsx | 30 +- .../challenges/ChallengeRankingItem.tsx | 96 +++++ constants/Colors.ts | 4 +- services/challengesApi.ts | 25 ++ store/challengesSlice.ts | 85 ++++ 8 files changed, 871 insertions(+), 159 deletions(-) rename app/challenges/{[id].tsx => [id]/index.tsx} (91%) create mode 100644 app/challenges/[id]/leaderboard.tsx create mode 100644 components/challenges/ChallengeRankingItem.tsx diff --git a/app/(tabs)/challenges.tsx b/app/(tabs)/challenges.tsx index f338b2b..6c81f82 100644 --- a/app/(tabs)/challenges.tsx +++ b/app/(tabs)/challenges.tsx @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; + import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -12,17 +14,19 @@ import { import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useRouter } from 'expo-router'; -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { ActivityIndicator, + Animated, + FlatList, ScrollView, - StatusBar, StyleSheet, Text, TouchableOpacity, View, + useWindowDimensions } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; const AVATAR_SIZE = 36; const CARD_IMAGE_WIDTH = 132; @@ -33,21 +37,37 @@ const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = { expired: '已结束', }; +const CAROUSEL_ITEM_SPACING = 16; +const MIN_CAROUSEL_CARD_WIDTH = 280; +const DOT_BASE_SIZE = 6; + export default function ChallengesScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const insets = useSafeAreaInsets(); + const colorTokens = Colors[theme]; const router = useRouter(); const dispatch = useAppDispatch(); const challenges = useAppSelector(selectChallengeCards); const listStatus = useAppSelector(selectChallengesListStatus); const listError = useAppSelector(selectChallengesListError); - const ongoingChallenge = useMemo( - () => - challenges.find( - (challenge) => challenge.status === 'ongoing' && challenge.isJoined && challenge.progress - ), - [challenges] - ); + const ongoingChallenges = useMemo(() => { + const now = dayjs(); + return challenges.filter((challenge) => { + if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) { + return false; + } + + if (challenge.endAt) { + const endDate = dayjs(challenge.endAt); + if (endDate.isValid() && endDate.isBefore(now)) { + return false; + } + } + + return true; + }); + }, [challenges]); const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa'; const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6'; @@ -113,20 +133,20 @@ export default function ChallengesScreen() { return ( - - - - - - 挑战 - 参与精选活动,保持每日动力 - - {/* + + + + 挑战 + 参与精选活动,保持每日动力 + + {/* */} - + - {ongoingChallenge ? ( - - router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } }) - } - > - - - ) : null} + {ongoingChallenges.length ? ( + + router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) + } + /> + ) : null} - {renderChallenges()} - - + {renderChallenges()} + ); } @@ -192,28 +199,207 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }, ]} > - + + + + <> + + + {statusLabel} + + + + + + + {challenge.title} + + + {challenge.dateRange} + + + {challenge.participantsLabel} + {challenge.isJoined ? ' · 已加入' : ''} + + {challenge.avatars.length ? ( + + ) : null} + + + + + ); +} + +type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark']; + +type OngoingChallengesCarouselProps = { + challenges: ChallengeCardViewModel[]; + colorTokens: ThemeColorTokens; + trackColor: string; + inactiveColor: string; + onPress: (challenge: ChallengeCardViewModel) => void; +}; + +function OngoingChallengesCarousel({ + challenges, + colorTokens, + trackColor, + inactiveColor, + onPress, +}: OngoingChallengesCarouselProps) { + const { width } = useWindowDimensions(); + const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH); + const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING; + const scrollX = useRef(new Animated.Value(0)).current; + const listRef = useRef | null>(null); + + useEffect(() => { + scrollX.setValue(0); + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + }, [scrollX, challenges.length]); + + const onScroll = useMemo( + () => + Animated.event( + [ + { + nativeEvent: { + contentOffset: { x: scrollX }, + }, + }, + ], + { useNativeDriver: true } + ), + [scrollX] + ); + + const renderItem = useCallback( + ({ item, index }: { item: ChallengeCardViewModel; index: number }) => { + const inputRange = [ + (index - 1) * snapInterval, + index * snapInterval, + (index + 1) * snapInterval, + ]; + const scale = scrollX.interpolate({ + inputRange, + outputRange: [0.94, 1, 0.94], + extrapolate: 'clamp', + }); + const translateY = scrollX.interpolate({ + inputRange, + outputRange: [10, 0, 10], + extrapolate: 'clamp', + }); + + return ( + + onPress(item)} + > + + + + ); + }, + [cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor] + ); + + return ( + + item.id} + horizontal + showsHorizontalScrollIndicator={false} + bounces + decelerationRate="fast" + snapToAlignment="start" + snapToInterval={snapInterval} + + ItemSeparatorComponent={() => } + onScroll={onScroll} + scrollEventThrottle={16} + overScrollMode="never" + renderItem={renderItem} /> - - - {challenge.title} - - {challenge.dateRange} - - {challenge.participantsLabel} - {' · '} - {statusLabel} - {challenge.isJoined ? ' · 已加入' : ''} - - {challenge.avatars.length ? ( - - ) : null} - - + {challenges.length > 1 ? ( + + {challenges.map((challenge, index) => { + const inputRange = [ + (index - 1) * snapInterval, + index * snapInterval, + (index + 1) * snapInterval, + ]; + const scaleX = scrollX.interpolate({ + inputRange, + outputRange: [1, 2.6, 1], + extrapolate: 'clamp', + }); + const dotOpacity = scrollX.interpolate({ + inputRange, + outputRange: [0.35, 1, 0.35], + extrapolate: 'clamp', + }); + + return ( + + ); + })} + + ) : null} + ); } @@ -251,7 +437,7 @@ const styles = StyleSheet.create({ }, scrollContent: { paddingHorizontal: 20, - paddingBottom: 32, + paddingBottom: 120, }, headerRow: { flexDirection: 'row', @@ -289,9 +475,31 @@ const styles = StyleSheet.create({ cardsContainer: { gap: 18, }, - progressCardWrapper: { + carouselContainer: { marginBottom: 24, }, + carouselCard: { + width: '100%', + }, + carouselTouchable: { + flex: 1, + }, + carouselProgressCard: { + width: '100%', + }, + carouselIndicators: { + marginTop: 18, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + carouselDot: { + width: DOT_BASE_SIZE, + height: DOT_BASE_SIZE, + borderRadius: DOT_BASE_SIZE / 2, + marginHorizontal: 4, + backgroundColor: 'transparent', + }, stateContainer: { alignItems: 'center', justifyContent: 'center', @@ -316,20 +524,29 @@ const styles = StyleSheet.create({ fontWeight: '600', }, card: { - flexDirection: 'row', borderRadius: 28, padding: 18, - alignItems: 'center', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.18, shadowRadius: 24, elevation: 6, + position: 'relative', + overflow: 'hidden', + }, + cardInner: { + flexDirection: 'row', + alignItems: 'center', }, cardImage: { width: CARD_IMAGE_WIDTH, height: CARD_IMAGE_HEIGHT, borderRadius: 22, }, + cardMedia: { + borderRadius: 22, + overflow: 'hidden', + position: 'relative', + }, cardContent: { flex: 1, marginLeft: 16, @@ -339,6 +556,7 @@ const styles = StyleSheet.create({ fontWeight: '700', marginBottom: 4, }, + cardDate: { fontSize: 13, fontWeight: '500', @@ -348,6 +566,35 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '500', }, + cardExpired: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(148, 163, 184, 0.22)', + }, + cardExpiredText: { + opacity: 0.7, + }, + cardDimOverlay: { + ...StyleSheet.absoluteFillObject, + borderRadius: 28, + }, + cardImageOverlay: { + ...StyleSheet.absoluteFillObject, + }, + expiredBadge: { + position: 'absolute', + left: 12, + bottom: 12, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + backgroundColor: 'rgba(12, 16, 28, 0.45)', + }, + expiredBadgeText: { + fontSize: 12, + fontWeight: '600', + color: '#f7f9ff', + letterSpacing: 0.3, + }, cardProgress: { marginTop: 8, fontSize: 13, diff --git a/app/challenges/[id].tsx b/app/challenges/[id]/index.tsx similarity index 91% rename from app/challenges/[id].tsx rename to app/challenges/[id]/index.tsx index bbccb07..3006aa9 100644 --- a/app/challenges/[id].tsx +++ b/app/challenges/[id]/index.tsx @@ -1,4 +1,5 @@ import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; +import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -9,6 +10,7 @@ import { joinChallenge, leaveChallenge, reportChallengeProgress, + fetchChallengeRankings, selectChallengeById, selectChallengeDetailError, selectChallengeDetailStatus, @@ -16,7 +18,8 @@ import { selectJoinStatus, selectLeaveError, selectLeaveStatus, - selectProgressStatus + selectProgressStatus, + selectChallengeRankingList } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; import { Ionicons } from '@expo/vector-icons'; @@ -107,6 +110,9 @@ export default function ChallengeDetailScreen() { const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); + const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]); + const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined)); + useEffect(() => { const getData = async (id: string) => { try { @@ -121,6 +127,12 @@ export default function ChallengeDetailScreen() { }, [dispatch, id]); + useEffect(() => { + if (id && !rankingList) { + void dispatch(fetchChallengeRankings({ id })); + } + }, [dispatch, id, rankingList]); + const [showCelebration, setShowCelebration] = useState(false); @@ -139,13 +151,23 @@ export default function ChallengeDetailScreen() { const progress = challenge?.progress; - const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]); + const rankingData = useMemo(() => { + const source = rankingList?.items ?? challenge?.rankings ?? []; + return source.slice(0, 10); + }, [challenge?.rankings, rankingList?.items]); const participantAvatars = useMemo( () => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6), [rankingData], ); + const handleViewAllRanking = () => { + if (!id) { + return; + } + router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } }); + }; + const dateRangeLabel = useMemo( () => buildDateRangeLabel({ @@ -439,7 +461,7 @@ export default function ChallengeDetailScreen() { 排行榜 - + 查看全部 @@ -451,23 +473,7 @@ export default function ChallengeDetailScreen() { {rankingData.length ? ( rankingData.map((item, index) => ( - 0 && styles.rankingRowDivider]}> - - {index + 1} - - {item.avatar ? ( - - ) : ( - - - - )} - - {item.name} - {item.metric} - - {item.badge ? {item.badge} : null} - + 0} /> )) ) : ( @@ -718,63 +724,6 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 10 }, elevation: 6, }, - rankingRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 18, - }, - rankingRowDivider: { - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: '#E5E7FF', - }, - rankingOrderCircle: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#EEF0FF', - marginRight: 12, - }, - rankingOrder: { - fontSize: 15, - fontWeight: '700', - color: '#4F5BD5', - }, - rankingAvatar: { - width: 44, - height: 44, - borderRadius: 22, - marginRight: 14, - }, - rankingAvatarPlaceholder: { - width: 44, - height: 44, - borderRadius: 22, - marginRight: 14, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#EEF0FF', - }, - rankingInfo: { - flex: 1, - }, - rankingName: { - fontSize: 15, - fontWeight: '700', - color: '#1c1f3a', - }, - rankingMetric: { - marginTop: 4, - fontSize: 13, - color: '#6f7ba7', - }, - rankingBadge: { - fontSize: 12, - color: '#A67CFF', - fontWeight: '700', - }, emptyRanking: { paddingVertical: 40, alignItems: 'center', diff --git a/app/challenges/[id]/leaderboard.tsx b/app/challenges/[id]/leaderboard.tsx new file mode 100644 index 0000000..6a22772 --- /dev/null +++ b/app/challenges/[id]/leaderboard.tsx @@ -0,0 +1,292 @@ +import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; +import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { + fetchChallengeDetail, + fetchChallengeRankings, + selectChallengeById, + selectChallengeDetailError, + selectChallengeDetailStatus, + selectChallengeRankingError, + selectChallengeRankingList, + selectChallengeRankingLoadMoreStatus, + selectChallengeRankingStatus, +} from '@/store/challengesSlice'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useEffect, useMemo } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function ChallengeLeaderboardScreen() { + const { id } = useLocalSearchParams<{ id?: string }>(); + const router = useRouter(); + const dispatch = useAppDispatch(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const insets = useSafeAreaInsets(); + + const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]); + const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); + + const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]); + const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle')); + const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]); + const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined)); + + const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]); + const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined)); + const rankingStatusSelector = useMemo(() => (id ? selectChallengeRankingStatus(id) : undefined), [id]); + const rankingStatus = useAppSelector((state) => (rankingStatusSelector ? rankingStatusSelector(state) : 'idle')); + const rankingLoadMoreStatusSelector = useMemo( + () => (id ? selectChallengeRankingLoadMoreStatus(id) : undefined), + [id] + ); + const rankingLoadMoreStatus = useAppSelector((state) => + rankingLoadMoreStatusSelector ? rankingLoadMoreStatusSelector(state) : 'idle' + ); + const rankingErrorSelector = useMemo(() => (id ? selectChallengeRankingError(id) : undefined), [id]); + const rankingError = useAppSelector((state) => (rankingErrorSelector ? rankingErrorSelector(state) : undefined)); + + useEffect(() => { + if (id) { + void dispatch(fetchChallengeDetail(id)); + } + }, [dispatch, id]); + + useEffect(() => { + if (id && !rankingList) { + void dispatch(fetchChallengeRankings({ id })); + } + }, [dispatch, id, rankingList]); + + if (!id) { + return ( + + router.back()} withSafeTop /> + + 未找到该挑战。 + + + ); + } + + if (detailStatus === 'loading' && !challenge) { + return ( + + router.back()} withSafeTop /> + + + 加载榜单中… + + + ); + } + + const hasMore = rankingList?.hasMore ?? false; + const isRefreshing = rankingStatus === 'loading'; + const isLoadingMore = rankingLoadMoreStatus === 'loading'; + const defaultPageSize = rankingList?.pageSize ?? 20; + const showInitialRankingLoading = isRefreshing && (!rankingList || rankingList.items.length === 0); + + const handleRefresh = () => { + if (!id) { + return; + } + void dispatch(fetchChallengeRankings({ id, page: 1, pageSize: defaultPageSize })); + }; + + const handleLoadMore = () => { + if (!id || !rankingList || !hasMore || isLoadingMore || rankingStatus === 'loading') { + return; + } + void dispatch( + fetchChallengeRankings({ id, page: rankingList.page + 1, pageSize: rankingList.pageSize }) + ); + }; + + const handleScroll = (event: NativeSyntheticEvent) => { + const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; + const paddingToBottom = 160; + if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) { + handleLoadMore(); + } + }; + + if (!challenge) { + return ( + + router.back()} withSafeTop /> + + + {detailError ?? '暂时无法加载榜单,请稍后再试。'} + + + + ); + } + + const rankingData = rankingList?.items ?? challenge.rankings ?? []; + const subtitle = challenge.rankingDescription ?? challenge.summary; + + return ( + + router.back()} withSafeTop /> + + } + onScroll={handleScroll} + scrollEventThrottle={16} + > + + {challenge.title} + {subtitle ? {subtitle} : null} + {challenge.progress ? ( + + ) : null} + + + + {showInitialRankingLoading ? ( + + + 加载榜单中… + + ) : rankingData.length ? ( + rankingData.map((item, index) => ( + 0} /> + )) + ) : rankingError ? ( + + {rankingError} + + ) : ( + + 榜单即将开启,快来抢占席位。 + + )} + {isLoadingMore ? ( + + + 加载更多… + + ) : null} + {rankingLoadMoreStatus === 'failed' ? ( + + 加载更多失败,请下拉刷新重试 + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + scrollView: { + flex: 1, + backgroundColor: 'transparent', + }, + pageHeader: { + paddingHorizontal: 24, + paddingTop: 24, + }, + challengeTitle: { + fontSize: 22, + fontWeight: '800', + color: '#1c1f3a', + }, + challengeSubtitle: { + marginTop: 8, + fontSize: 14, + color: '#6f7ba7', + lineHeight: 20, + }, + progressCardWrapper: { + marginTop: 20, + }, + rankingCard: { + marginTop: 24, + marginHorizontal: 24, + borderRadius: 24, + backgroundColor: '#ffffff', + paddingVertical: 10, + shadowColor: 'rgba(30, 41, 59, 0.12)', + shadowOpacity: 0.16, + shadowRadius: 18, + shadowOffset: { width: 0, height: 10 }, + elevation: 6, + }, + emptyRanking: { + paddingVertical: 40, + alignItems: 'center', + justifyContent: 'center', + }, + emptyRankingText: { + fontSize: 14, + color: '#6f7ba7', + }, + rankingLoading: { + paddingVertical: 32, + alignItems: 'center', + justifyContent: 'center', + }, + rankingErrorText: { + fontSize: 14, + color: '#eb5757', + }, + loadMoreIndicator: { + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + loadMoreErrorText: { + fontSize: 13, + color: '#eb5757', + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + marginTop: 16, + fontSize: 14, + }, + missingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + }, + missingText: { + fontSize: 14, + textAlign: 'center', + }, +}); diff --git a/components/challenges/ChallengeProgressCard.tsx b/components/challenges/ChallengeProgressCard.tsx index 68d69b4..6a6623f 100644 --- a/components/challenges/ChallengeProgressCard.tsx +++ b/components/challenges/ChallengeProgressCard.tsx @@ -20,6 +20,11 @@ type ChallengeProgressCardProps = { inactiveColor?: string; }; +type RemainingTime = { + value: number; + unit: '天' | '小时'; +}; + const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff']; const DEFAULT_TITLE_COLOR = '#1c1f3a'; const DEFAULT_SUBTITLE_COLOR = '#707baf'; @@ -38,11 +43,22 @@ const clampSegments = (target: number, completed: number) => { return { segmentsCount, completedSegments }; }; -const calculateRemainingDays = (endAt?: string) => { - if (!endAt) return 0; +const calculateRemainingTime = (endAt?: string): RemainingTime => { + if (!endAt) return { value: 0, unit: '天' }; const endDate = dayjs(endAt); - if (!endDate.isValid()) return 0; - return Math.max(0, endDate.diff(dayjs(), 'd')); + if (!endDate.isValid()) return { value: 0, unit: '天' }; + + const diffMilliseconds = endDate.diff(dayjs()); + if (diffMilliseconds <= 0) { + return { value: 0, unit: '天' }; + } + + const diffHours = diffMilliseconds / (60 * 60 * 1000); + if (diffHours < 24) { + return { value: Math.max(1, Math.floor(diffHours)), unit: '小时' }; + } + + return { value: Math.floor(diffHours / 24), unit: '天' }; }; export const ChallengeProgressCard: React.FC = ({ @@ -96,7 +112,7 @@ export const ChallengeProgressCard: React.FC = ({ }); }, [segments?.completedSegments, segments?.segmentsCount]); - const remainingDays = useMemo(() => calculateRemainingDays(endAt), [endAt]); + const remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]); if (!hasValidProgress || !progress || !segments) { return null; @@ -111,7 +127,9 @@ export const ChallengeProgressCard: React.FC = ({ {title} - 挑战剩余 {remainingDays} 天 + + 挑战剩余 {remainingTime.value} {remainingTime.unit} + diff --git a/components/challenges/ChallengeRankingItem.tsx b/components/challenges/ChallengeRankingItem.tsx new file mode 100644 index 0000000..58c22ca --- /dev/null +++ b/components/challenges/ChallengeRankingItem.tsx @@ -0,0 +1,96 @@ +import type { RankingItem } from '@/store/challengesSlice'; +import { Ionicons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +type ChallengeRankingItemProps = { + item: RankingItem; + index: number; + showDivider?: boolean; +}; + +export function ChallengeRankingItem({ item, index, showDivider = false }: ChallengeRankingItemProps) { + return ( + + + {index + 1} + + {item.avatar ? ( + + ) : ( + + + + )} + + + {item.name} + + {item.metric} + + {item.badge ? {item.badge} : null} + + ); +} + +const styles = StyleSheet.create({ + rankingRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 18, + }, + rankingRowDivider: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#E5E7FF', + }, + rankingOrderCircle: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#EEF0FF', + marginRight: 12, + }, + rankingOrder: { + fontSize: 15, + fontWeight: '700', + color: '#4F5BD5', + }, + rankingAvatar: { + width: 44, + height: 44, + borderRadius: 22, + marginRight: 14, + backgroundColor: '#EEF0FF', + }, + rankingAvatarPlaceholder: { + width: 44, + height: 44, + borderRadius: 22, + marginRight: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#EEF0FF', + }, + rankingInfo: { + flex: 1, + }, + rankingName: { + fontSize: 15, + fontWeight: '700', + color: '#1c1f3a', + }, + rankingMetric: { + marginTop: 4, + fontSize: 13, + color: '#6f7ba7', + }, + rankingBadge: { + fontSize: 12, + color: '#A67CFF', + fontWeight: '700', + }, +}); diff --git a/constants/Colors.ts b/constants/Colors.ts index 0a409e3..dd53956 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -148,8 +148,8 @@ export const Colors = { ornamentAccent: palette.success[100], // 背景渐变色 - backgroundGradientStart: palette.purple[25], - backgroundGradientEnd: palette.base.white, + backgroundGradientStart: palette.purple[100], + backgroundGradientEnd: palette.purple[25], }, dark: { // 基础文本/背景 diff --git a/services/challengesApi.ts b/services/challengesApi.ts index 5be96a7..130c645 100644 --- a/services/challengesApi.ts +++ b/services/challengesApi.ts @@ -15,6 +15,8 @@ export type RankingItemDto = { avatar: string | null; metric: string; badge?: string; + todayReportedValue?: number; + todayTargetValue?: number; }; export enum ChallengeType { @@ -55,6 +57,13 @@ export type ChallengeDetailDto = ChallengeListItemDto & { userRank?: number; }; +export type ChallengeRankingsDto = { + total: number; + page: number; + pageSize: number; + items: RankingItemDto[]; +}; + export async function listChallenges(): Promise { return api.get('/challenges'); } @@ -75,3 +84,19 @@ export async function reportChallengeProgress(id: string, value?: number): Promi const body = value != null ? { value } : undefined; return api.post(`/challenges/${encodeURIComponent(id)}/progress`, body); } + +export async function getChallengeRankings( + id: string, + params?: { page?: number; pageSize?: number } +): Promise { + const searchParams = new URLSearchParams(); + if (params?.page) { + searchParams.append('page', String(params.page)); + } + if (params?.pageSize) { + searchParams.append('pageSize', String(params.pageSize)); + } + const query = searchParams.toString(); + const url = `/challenges/${encodeURIComponent(id)}/rankings${query ? `?${query}` : ''}`; + return api.get(url); +} diff --git a/store/challengesSlice.ts b/store/challengesSlice.ts index cc1be6f..29b91d7 100644 --- a/store/challengesSlice.ts +++ b/store/challengesSlice.ts @@ -5,6 +5,7 @@ import { type ChallengeStatus, type RankingItemDto, getChallengeDetail, + getChallengeRankings, joinChallenge as joinChallengeApi, leaveChallenge as leaveChallengeApi, listChallenges, @@ -26,6 +27,14 @@ export type ChallengeEntity = ChallengeSummary & { userRank?: number; }; +type ChallengeRankingList = { + items: RankingItem[]; + total: number; + page: number; + pageSize: number; + hasMore: boolean; +}; + type ChallengesState = { entities: Record; order: string[]; @@ -39,6 +48,10 @@ type ChallengesState = { leaveError: Record; progressStatus: Record; progressError: Record; + rankingList: Record; + rankingStatus: Record; + rankingLoadMoreStatus: Record; + rankingError: Record; }; const initialState: ChallengesState = { @@ -54,6 +67,10 @@ const initialState: ChallengesState = { leaveError: {}, progressStatus: {}, progressError: {}, + rankingList: {}, + rankingStatus: {}, + rankingLoadMoreStatus: {}, + rankingError: {}, }; const toErrorMessage = (error: unknown): string => { @@ -128,6 +145,19 @@ export const reportChallengeProgress = createAsyncThunk< } }); +export const fetchChallengeRankings = createAsyncThunk< + { id: string; total: number; page: number; pageSize: number; items: RankingItem[] }, + { id: string; page?: number; pageSize?: number }, + { rejectValue: string } +>('challenges/fetchRankings', async ({ id, page = 1, pageSize = 20 }, { rejectWithValue }) => { + try { + const data = await getChallengeRankings(id, { page, pageSize }); + return { id, ...data }; + } catch (error) { + return rejectWithValue(toErrorMessage(error)); + } +}); + const challengesSlice = createSlice({ name: 'challenges', initialState, @@ -249,6 +279,49 @@ const challengesSlice = createSlice({ const id = action.meta.arg.id; state.progressStatus[id] = 'failed'; state.progressError[id] = action.payload ?? toErrorMessage(action.error); + }) + .addCase(fetchChallengeRankings.pending, (state, action) => { + const { id, page = 1 } = action.meta.arg; + if (page <= 1) { + state.rankingStatus[id] = 'loading'; + state.rankingError[id] = undefined; + state.rankingLoadMoreStatus[id] = 'idle'; + } else { + state.rankingLoadMoreStatus[id] = 'loading'; + } + }) + .addCase(fetchChallengeRankings.fulfilled, (state, action) => { + const { id, items, page, pageSize, total } = action.payload; + const existing = state.rankingList[id]; + let merged: RankingItem[]; + if (!existing || page <= 1) { + merged = [...items]; + } else { + const map = new Map(existing.items.map((item) => [item.id, item] as const)); + items.forEach((item) => { + map.set(item.id, item); + }); + merged = Array.from(map.values()); + } + const hasMore = merged.length < total; + state.rankingList[id] = { items: merged, total, page, pageSize, hasMore }; + if (page <= 1) { + state.rankingStatus[id] = 'succeeded'; + state.rankingError[id] = undefined; + } else { + state.rankingLoadMoreStatus[id] = 'succeeded'; + } + }) + .addCase(fetchChallengeRankings.rejected, (state, action) => { + const { id, page = 1 } = action.meta.arg; + const message = action.payload ?? toErrorMessage(action.error); + if (page <= 1) { + state.rankingStatus[id] = 'failed'; + state.rankingError[id] = message; + } else { + state.rankingLoadMoreStatus[id] = 'failed'; + state.rankingError[id] = message; + } }); }, }); @@ -369,3 +442,15 @@ export const selectProgressStatus = (id: string) => export const selectProgressError = (id: string) => createSelector([selectChallengesState], (state) => state.progressError[id]); + +export const selectChallengeRankingList = (id: string) => + createSelector([selectChallengesState], (state) => state.rankingList[id]); + +export const selectChallengeRankingStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.rankingStatus[id] ?? 'idle'); + +export const selectChallengeRankingLoadMoreStatus = (id: string) => + createSelector([selectChallengesState], (state) => state.rankingLoadMoreStatus[id] ?? 'idle'); + +export const selectChallengeRankingError = (id: string) => + createSelector([selectChallengesState], (state) => state.rankingError[id]);