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

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