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 { 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 (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
bounces
>
<View style={styles.headerRow}>
<View>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text>
</View>
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
<ScrollView
contentContainerStyle={[styles.scrollContent, {
paddingTop: insets.top,
}]}
showsVerticalScrollIndicator={false}
bounces
>
<View style={styles.headerRow}>
<View>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text>
</View>
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
<LinearGradient
colors={[colorTokens.primary, colorTokens.accentPurple]}
start={{ x: 0, y: 0 }}
@@ -136,35 +156,22 @@ export default function ChallengesScreen() {
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
</LinearGradient>
</TouchableOpacity> */}
</View>
</View>
{ongoingChallenge ? (
<TouchableOpacity
activeOpacity={0.92}
onPress={() =>
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}
inactiveColor={progressInactiveColor}
/>
</TouchableOpacity>
) : null}
{ongoingChallenges.length ? (
<OngoingChallengesCarousel
challenges={ongoingChallenges}
colorTokens={colorTokens}
trackColor={progressTrackColor}
inactiveColor={progressInactiveColor}
onPress={(challenge) =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
) : null}
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
</SafeAreaView>
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
</View>
);
}
@@ -192,28 +199,207 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
},
]}
>
<Image
source={{ uri: challenge.image }}
style={styles.cardImage}
cachePolicy={'memory-disk'}
<View style={styles.cardInner}>
<View style={styles.cardMedia}>
<Image
source={{ uri: challenge.image }}
style={styles.cardImage}
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}>
<Text
style={[styles.cardTitle, { color: textColor }]}
numberOfLines={1}
>
{challenge.title}
</Text>
<Text
style={[styles.cardDate, { color: mutedColor }]}
>
{challenge.dateRange}
</Text>
<Text
style={[styles.cardParticipants, { color: mutedColor }]}
>
{challenge.participantsLabel}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
</View>
</View>
</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}
/>
<View style={styles.cardContent}>
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
{challenge.title}
</Text>
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
<Text style={[styles.cardParticipants, { color: mutedColor }]}>
{challenge.participantsLabel}
{' · '}
{statusLabel}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
</View>
</TouchableOpacity>
{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>
);
}
@@ -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,

View File

@@ -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() {
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity>
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
<Text style={styles.sectionAction}></Text>
</TouchableOpacity>
</View>
@@ -451,23 +473,7 @@ export default function ChallengeDetailScreen() {
<View style={styles.rankingCard}>
{rankingData.length ? (
rankingData.map((item, index) => (
<View key={item.id} style={[styles.rankingRow, index > 0 && 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}>{item.name}</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
))
) : (
<View style={styles.emptyRanking}>
@@ -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',

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;
};
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<ChallengeProgressCardProps> = ({
@@ -96,7 +112,7 @@ export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
});
}, [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<ChallengeProgressCardProps> = ({
{title}
</Text>
</View>
<Text style={[styles.remaining, { color: subtitleColor }]}> {remainingDays} </Text>
<Text style={[styles.remaining, { color: subtitleColor }]}>
{remainingTime.value} {remainingTime.unit}
</Text>
</View>
<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],
// 背景渐变色
backgroundGradientStart: palette.purple[25],
backgroundGradientEnd: palette.base.white,
backgroundGradientStart: palette.purple[100],
backgroundGradientEnd: palette.purple[25],
},
dark: {
// 基础文本/背景

View File

@@ -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<ChallengeListItemDto[]> {
return api.get<ChallengeListItemDto[]>('/challenges');
}
@@ -75,3 +84,19 @@ export async function reportChallengeProgress(id: string, value?: number): Promi
const body = value != null ? { value } : undefined;
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 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<string, ChallengeEntity>;
order: string[];
@@ -39,6 +48,10 @@ type ChallengesState = {
leaveError: Record<string, string | undefined>;
progressStatus: Record<string, AsyncStatus>;
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 = {
@@ -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]);