feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
This commit is contained in:
@@ -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}>
|
||||
|
||||
96
components/challenges/ChallengeRankingItem.tsx
Normal file
96
components/challenges/ChallengeRankingItem.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user