feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互

- 重构挑战列表为横向轮播,支持多进行中的挑战
- 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard
- ChallengeProgressCard 支持小时级剩余时间显示
- 新增 ChallengeRankingItem 组件展示榜单项
- 排行榜支持分页加载、下拉刷新与错误重试
- 挑战卡片新增已结束角标与渐变遮罩
- 加入/退出挑战时展示庆祝动画与错误提示
- 统一背景渐变色与卡片阴影细节
This commit is contained in:
richarjiang
2025-09-30 11:33:24 +08:00
parent d32a822604
commit b0602b0a99
8 changed files with 871 additions and 159 deletions

View File

@@ -1,3 +1,5 @@
import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -12,17 +14,19 @@ import {
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Animated,
FlatList,
ScrollView, ScrollView,
StatusBar,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
useWindowDimensions
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
const AVATAR_SIZE = 36; const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132; const CARD_IMAGE_WIDTH = 132;
@@ -33,21 +37,37 @@ const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
expired: '已结束', expired: '已结束',
}; };
const CAROUSEL_ITEM_SPACING = 16;
const MIN_CAROUSEL_CARD_WIDTH = 280;
const DOT_BASE_SIZE = 6;
export default function ChallengesScreen() { export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const insets = useSafeAreaInsets();
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards); const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus); const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError); const listError = useAppSelector(selectChallengesListError);
const ongoingChallenge = useMemo( const ongoingChallenges = useMemo(() => {
() => const now = dayjs();
challenges.find( return challenges.filter((challenge) => {
(challenge) => challenge.status === 'ongoing' && challenge.isJoined && challenge.progress if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
), return false;
[challenges] }
);
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 progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6'; const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
@@ -113,11 +133,11 @@ export default function ChallengesScreen() {
return ( return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}> <View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} /> <LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView <ScrollView
contentContainerStyle={styles.scrollContent} contentContainerStyle={[styles.scrollContent, {
paddingTop: insets.top,
}]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
bounces bounces
> >
@@ -138,33 +158,20 @@ export default function ChallengesScreen() {
</TouchableOpacity> */} </TouchableOpacity> */}
</View> </View>
{ongoingChallenge ? ( {ongoingChallenges.length ? (
<TouchableOpacity <OngoingChallengesCarousel
activeOpacity={0.92} challenges={ongoingChallenges}
onPress={() => colorTokens={colorTokens}
router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } })
}
>
<ChallengeProgressCard
title={ongoingChallenge.title}
endAt={ongoingChallenge.endAt}
progress={ongoingChallenge.progress}
style={styles.progressCardWrapper}
backgroundColors={[colorTokens.card, colorTokens.card]}
titleColor={colorTokens.text}
subtitleColor={colorTokens.textSecondary}
metaColor={colorTokens.primary}
metaSuffixColor={colorTokens.textSecondary}
accentColor={colorTokens.primary}
trackColor={progressTrackColor} trackColor={progressTrackColor}
inactiveColor={progressInactiveColor} inactiveColor={progressInactiveColor}
onPress={(challenge) =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/> />
</TouchableOpacity>
) : null} ) : null}
<View style={styles.cardsContainer}>{renderChallenges()}</View> <View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView> </ScrollView>
</SafeAreaView>
</View> </View>
); );
} }
@@ -192,31 +199,210 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
}, },
]} ]}
> >
<View style={styles.cardInner}>
<View style={styles.cardMedia}>
<Image <Image
source={{ uri: challenge.image }} source={{ uri: challenge.image }}
style={styles.cardImage} style={styles.cardImage}
cachePolicy={'memory-disk'} cachePolicy={'memory-disk'}
/> />
<>
<LinearGradient
pointerEvents="none"
colors={['rgba(17, 21, 32, 0.05)', 'rgba(13, 17, 28, 0.4)']}
style={styles.cardImageOverlay}
/>
<View style={styles.expiredBadge}>
<Text style={styles.expiredBadgeText}>{statusLabel}</Text>
</View>
</>
</View>
<View style={styles.cardContent}> <View style={styles.cardContent}>
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}> <Text
style={[styles.cardTitle, { color: textColor }]}
numberOfLines={1}
>
{challenge.title} {challenge.title}
</Text> </Text>
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text> <Text
<Text style={[styles.cardParticipants, { color: mutedColor }]}> style={[styles.cardDate, { color: mutedColor }]}
>
{challenge.dateRange}
</Text>
<Text
style={[styles.cardParticipants, { color: mutedColor }]}
>
{challenge.participantsLabel} {challenge.participantsLabel}
{' · '}
{statusLabel}
{challenge.isJoined ? ' · 已加入' : ''} {challenge.isJoined ? ' · 已加入' : ''}
</Text> </Text>
{challenge.avatars.length ? ( {challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} /> <AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null} ) : null}
</View> </View>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
} }
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<FlatList<ChallengeCardViewModel> | 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 (
<Animated.View
style={[
styles.carouselCard,
{
width: cardWidth,
transform: [{ scale }, { translateY }],
},
]}
>
<TouchableOpacity
activeOpacity={0.92}
style={styles.carouselTouchable}
onPress={() => onPress(item)}
>
<ChallengeProgressCard
title={item.title}
endAt={item.endAt}
progress={item.progress}
style={styles.carouselProgressCard}
backgroundColors={[colorTokens.card, colorTokens.card]}
titleColor={colorTokens.text}
subtitleColor={colorTokens.textSecondary}
metaColor={colorTokens.primary}
metaSuffixColor={colorTokens.textSecondary}
accentColor={colorTokens.primary}
trackColor={trackColor}
inactiveColor={inactiveColor}
/>
</TouchableOpacity>
</Animated.View>
);
},
[cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
);
return (
<View style={styles.carouselContainer}>
<Animated.FlatList
ref={listRef}
data={challenges}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
bounces
decelerationRate="fast"
snapToAlignment="start"
snapToInterval={snapInterval}
ItemSeparatorComponent={() => <View style={{ width: CAROUSEL_ITEM_SPACING }} />}
onScroll={onScroll}
scrollEventThrottle={16}
overScrollMode="never"
renderItem={renderItem}
/>
{challenges.length > 1 ? (
<View style={styles.carouselIndicators}>
{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 (
<Animated.View
key={challenge.id}
style={[
styles.carouselDot,
{
opacity: dotOpacity,
backgroundColor: colorTokens.primary,
transform: [{ scaleX }],
},
]}
/>
);
})}
</View>
) : null}
</View>
);
}
type AvatarStackProps = { type AvatarStackProps = {
avatars: string[]; avatars: string[];
borderColor: string; borderColor: string;
@@ -251,7 +437,7 @@ const styles = StyleSheet.create({
}, },
scrollContent: { scrollContent: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingBottom: 32, paddingBottom: 120,
}, },
headerRow: { headerRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -289,9 +475,31 @@ const styles = StyleSheet.create({
cardsContainer: { cardsContainer: {
gap: 18, gap: 18,
}, },
progressCardWrapper: { carouselContainer: {
marginBottom: 24, 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: { stateContainer: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -316,20 +524,29 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
}, },
card: { card: {
flexDirection: 'row',
borderRadius: 28, borderRadius: 28,
padding: 18, padding: 18,
alignItems: 'center',
shadowOffset: { width: 0, height: 16 }, shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.18, shadowOpacity: 0.18,
shadowRadius: 24, shadowRadius: 24,
elevation: 6, elevation: 6,
position: 'relative',
overflow: 'hidden',
},
cardInner: {
flexDirection: 'row',
alignItems: 'center',
}, },
cardImage: { cardImage: {
width: CARD_IMAGE_WIDTH, width: CARD_IMAGE_WIDTH,
height: CARD_IMAGE_HEIGHT, height: CARD_IMAGE_HEIGHT,
borderRadius: 22, borderRadius: 22,
}, },
cardMedia: {
borderRadius: 22,
overflow: 'hidden',
position: 'relative',
},
cardContent: { cardContent: {
flex: 1, flex: 1,
marginLeft: 16, marginLeft: 16,
@@ -339,6 +556,7 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
marginBottom: 4, marginBottom: 4,
}, },
cardDate: { cardDate: {
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
@@ -348,6 +566,35 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '500', 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: { cardProgress: {
marginTop: 8, marginTop: 8,
fontSize: 13, fontSize: 13,

View File

@@ -1,4 +1,5 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -9,6 +10,7 @@ import {
joinChallenge, joinChallenge,
leaveChallenge, leaveChallenge,
reportChallengeProgress, reportChallengeProgress,
fetchChallengeRankings,
selectChallengeById, selectChallengeById,
selectChallengeDetailError, selectChallengeDetailError,
selectChallengeDetailStatus, selectChallengeDetailStatus,
@@ -16,7 +18,8 @@ import {
selectJoinStatus, selectJoinStatus,
selectLeaveError, selectLeaveError,
selectLeaveStatus, selectLeaveStatus,
selectProgressStatus selectProgressStatus,
selectChallengeRankingList
} from '@/store/challengesSlice'; } from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -107,6 +110,9 @@ export default function ChallengeDetailScreen() {
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); 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(() => { useEffect(() => {
const getData = async (id: string) => { const getData = async (id: string) => {
try { try {
@@ -121,6 +127,12 @@ export default function ChallengeDetailScreen() {
}, [dispatch, id]); }, [dispatch, id]);
useEffect(() => {
if (id && !rankingList) {
void dispatch(fetchChallengeRankings({ id }));
}
}, [dispatch, id, rankingList]);
const [showCelebration, setShowCelebration] = useState(false); const [showCelebration, setShowCelebration] = useState(false);
@@ -139,13 +151,23 @@ export default function ChallengeDetailScreen() {
const progress = challenge?.progress; 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( const participantAvatars = useMemo(
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6), () => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
[rankingData], [rankingData],
); );
const handleViewAllRanking = () => {
if (!id) {
return;
}
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
};
const dateRangeLabel = useMemo( const dateRangeLabel = useMemo(
() => () =>
buildDateRangeLabel({ buildDateRangeLabel({
@@ -439,7 +461,7 @@ export default function ChallengeDetailScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}></Text>
<TouchableOpacity> <TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
<Text style={styles.sectionAction}></Text> <Text style={styles.sectionAction}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -451,23 +473,7 @@ export default function ChallengeDetailScreen() {
<View style={styles.rankingCard}> <View style={styles.rankingCard}>
{rankingData.length ? ( {rankingData.length ? (
rankingData.map((item, index) => ( rankingData.map((item, index) => (
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}> <ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
{item.avatar ? (
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} cachePolicy={'memory-disk'} />
) : (
<View style={styles.rankingAvatarPlaceholder}>
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
</View>
)}
<View style={styles.rankingInfo}>
<Text style={styles.rankingName}>{item.name}</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
)) ))
) : ( ) : (
<View style={styles.emptyRanking}> <View style={styles.emptyRanking}>
@@ -718,63 +724,6 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 10 },
elevation: 6, 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: { emptyRanking: {
paddingVertical: 40, paddingVertical: 40,
alignItems: 'center', alignItems: 'center',

View File

@@ -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 (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
if (detailStatus === 'loading' && !challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
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<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 160;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
};
if (!challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
</Text>
</View>
</View>
);
}
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
const subtitle = challenge.rankingDescription ?? challenge.summary;
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colorTokens.primary}
/>
}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View style={styles.pageHeader}>
<Text style={styles.challengeTitle}>{challenge.title}</Text>
{subtitle ? <Text style={styles.challengeSubtitle}>{subtitle}</Text> : null}
{challenge.progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={challenge.progress}
style={styles.progressCardWrapper}
/>
) : null}
</View>
<View style={styles.rankingCard}>
{showInitialRankingLoading ? (
<View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
) : rankingData.length ? (
rankingData.map((item, index) => (
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
))
) : rankingError ? (
<View style={styles.emptyRanking}>
<Text style={styles.rankingErrorText}>{rankingError}</Text>
</View>
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
{isLoadingMore ? (
<View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text>
</View>
) : null}
{rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text>
</View>
) : null}
</View>
</ScrollView>
</View>
);
}
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',
},
});

View File

@@ -20,6 +20,11 @@ type ChallengeProgressCardProps = {
inactiveColor?: string; inactiveColor?: string;
}; };
type RemainingTime = {
value: number;
unit: '天' | '小时';
};
const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff']; const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff'];
const DEFAULT_TITLE_COLOR = '#1c1f3a'; const DEFAULT_TITLE_COLOR = '#1c1f3a';
const DEFAULT_SUBTITLE_COLOR = '#707baf'; const DEFAULT_SUBTITLE_COLOR = '#707baf';
@@ -38,11 +43,22 @@ const clampSegments = (target: number, completed: number) => {
return { segmentsCount, completedSegments }; return { segmentsCount, completedSegments };
}; };
const calculateRemainingDays = (endAt?: string) => { const calculateRemainingTime = (endAt?: string): RemainingTime => {
if (!endAt) return 0; if (!endAt) return { value: 0, unit: '天' };
const endDate = dayjs(endAt); const endDate = dayjs(endAt);
if (!endDate.isValid()) return 0; if (!endDate.isValid()) return { value: 0, unit: '天' };
return Math.max(0, endDate.diff(dayjs(), 'd'));
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<ChallengeProgressCardProps> = ({ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
@@ -96,7 +112,7 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
}); });
}, [segments?.completedSegments, segments?.segmentsCount]); }, [segments?.completedSegments, segments?.segmentsCount]);
const remainingDays = useMemo(() => calculateRemainingDays(endAt), [endAt]); const remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]);
if (!hasValidProgress || !progress || !segments) { if (!hasValidProgress || !progress || !segments) {
return null; return null;
@@ -111,7 +127,9 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
{title} {title}
</Text> </Text>
</View> </View>
<Text style={[styles.remaining, { color: subtitleColor }]}> {remainingDays} </Text> <Text style={[styles.remaining, { color: subtitleColor }]}>
{remainingTime.value} {remainingTime.unit}
</Text>
</View> </View>
<View style={styles.metaRow}> <View style={styles.metaRow}>

View File

@@ -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 (
<View style={[styles.rankingRow, showDivider && styles.rankingRowDivider]}>
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
{item.avatar ? (
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} cachePolicy="memory-disk" />
) : (
<View style={styles.rankingAvatarPlaceholder}>
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
</View>
)}
<View style={styles.rankingInfo}>
<Text style={styles.rankingName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
);
}
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',
},
});

View File

@@ -148,8 +148,8 @@ export const Colors = {
ornamentAccent: palette.success[100], ornamentAccent: palette.success[100],
// 背景渐变色 // 背景渐变色
backgroundGradientStart: palette.purple[25], backgroundGradientStart: palette.purple[100],
backgroundGradientEnd: palette.base.white, backgroundGradientEnd: palette.purple[25],
}, },
dark: { dark: {
// 基础文本/背景 // 基础文本/背景

View File

@@ -15,6 +15,8 @@ export type RankingItemDto = {
avatar: string | null; avatar: string | null;
metric: string; metric: string;
badge?: string; badge?: string;
todayReportedValue?: number;
todayTargetValue?: number;
}; };
export enum ChallengeType { export enum ChallengeType {
@@ -55,6 +57,13 @@ export type ChallengeDetailDto = ChallengeListItemDto & {
userRank?: number; userRank?: number;
}; };
export type ChallengeRankingsDto = {
total: number;
page: number;
pageSize: number;
items: RankingItemDto[];
};
export async function listChallenges(): Promise<ChallengeListItemDto[]> { export async function listChallenges(): Promise<ChallengeListItemDto[]> {
return api.get<ChallengeListItemDto[]>('/challenges'); return api.get<ChallengeListItemDto[]>('/challenges');
} }
@@ -75,3 +84,19 @@ export async function reportChallengeProgress(id: string, value?: number): Promi
const body = value != null ? { value } : undefined; const body = value != null ? { value } : undefined;
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body); return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
} }
export async function getChallengeRankings(
id: string,
params?: { page?: number; pageSize?: number }
): Promise<ChallengeRankingsDto> {
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<ChallengeRankingsDto>(url);
}

View File

@@ -5,6 +5,7 @@ import {
type ChallengeStatus, type ChallengeStatus,
type RankingItemDto, type RankingItemDto,
getChallengeDetail, getChallengeDetail,
getChallengeRankings,
joinChallenge as joinChallengeApi, joinChallenge as joinChallengeApi,
leaveChallenge as leaveChallengeApi, leaveChallenge as leaveChallengeApi,
listChallenges, listChallenges,
@@ -26,6 +27,14 @@ export type ChallengeEntity = ChallengeSummary & {
userRank?: number; userRank?: number;
}; };
type ChallengeRankingList = {
items: RankingItem[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
};
type ChallengesState = { type ChallengesState = {
entities: Record<string, ChallengeEntity>; entities: Record<string, ChallengeEntity>;
order: string[]; order: string[];
@@ -39,6 +48,10 @@ type ChallengesState = {
leaveError: Record<string, string | undefined>; leaveError: Record<string, string | undefined>;
progressStatus: Record<string, AsyncStatus>; progressStatus: Record<string, AsyncStatus>;
progressError: Record<string, string | undefined>; progressError: Record<string, string | undefined>;
rankingList: Record<string, ChallengeRankingList | undefined>;
rankingStatus: Record<string, AsyncStatus>;
rankingLoadMoreStatus: Record<string, AsyncStatus>;
rankingError: Record<string, string | undefined>;
}; };
const initialState: ChallengesState = { const initialState: ChallengesState = {
@@ -54,6 +67,10 @@ const initialState: ChallengesState = {
leaveError: {}, leaveError: {},
progressStatus: {}, progressStatus: {},
progressError: {}, progressError: {},
rankingList: {},
rankingStatus: {},
rankingLoadMoreStatus: {},
rankingError: {},
}; };
const toErrorMessage = (error: unknown): string => { 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({ const challengesSlice = createSlice({
name: 'challenges', name: 'challenges',
initialState, initialState,
@@ -249,6 +279,49 @@ const challengesSlice = createSlice({
const id = action.meta.arg.id; const id = action.meta.arg.id;
state.progressStatus[id] = 'failed'; state.progressStatus[id] = 'failed';
state.progressError[id] = action.payload ?? toErrorMessage(action.error); 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) => export const selectProgressError = (id: string) =>
createSelector([selectChallengesState], (state) => state.progressError[id]); 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]);