feat: 添加用户活动热力图组件
在个人页面新增活动热力图展示组件,并实现浮动动画效果优化统计卡片交互体验。
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { DEFAULT_MEMBER_NAME, fetchMyProfile } from '@/store/userSlice';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Alert, Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
|
||||
@@ -40,6 +40,7 @@ export default function PersonalScreen() {
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
dispatch(fetchMyProfile());
|
||||
dispatch(fetchActivityHistory())
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
@@ -68,36 +69,6 @@ export default function PersonalScreen() {
|
||||
// 颜色令牌
|
||||
const colorTokens = colors;
|
||||
|
||||
const handleResetOnboarding = () => {
|
||||
Alert.alert(
|
||||
'重置引导',
|
||||
'确定要重置引导流程吗?下次启动应用时将重新显示引导页面。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']);
|
||||
Alert.alert('成功', '引导状态已重置,请重启应用查看效果。');
|
||||
} catch (error) {
|
||||
console.error('重置引导状态失败:', error);
|
||||
Alert.alert('错误', '重置失败,请稍后重试。');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const UserInfoSection = () => (
|
||||
<View style={[styles.userInfoCard, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={styles.userInfoContainer}>
|
||||
@@ -304,6 +275,7 @@ export default function PersonalScreen() {
|
||||
>
|
||||
<UserInfoSection />
|
||||
<StatsSection />
|
||||
<ActivityHeatMap />
|
||||
{menuSections.map((section, index) => (
|
||||
<MenuSection key={index} title={section.title} items={section.items} />
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,7 @@ import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -28,6 +29,56 @@ import {
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// 浮动动画组件
|
||||
const FloatingCard = ({ children, delay = 0, style }: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
style?: any;
|
||||
}) => {
|
||||
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const startAnimation = () => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: 1,
|
||||
duration: 3000,
|
||||
delay: delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: 0,
|
||||
duration: 3000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
};
|
||||
|
||||
startAnimation();
|
||||
}, [floatAnim, delay]);
|
||||
|
||||
const translateY = floatAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-2, -6],
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
transform: [{ translateY }],
|
||||
marginBottom: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -309,14 +360,15 @@ export default function ExploreScreen() {
|
||||
<View style={styles.masonryContainer}>
|
||||
{/* 左列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
<StressMeter
|
||||
value={hrvValue}
|
||||
updateTime={hrvUpdateTime}
|
||||
style={styles.masonryCard}
|
||||
hrvValue={hrvValue}
|
||||
/>
|
||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||
<StressMeter
|
||||
value={hrvValue}
|
||||
updateTime={hrvUpdateTime}
|
||||
hrvValue={hrvValue}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
<View style={[styles.masonryCard, styles.caloriesCard]}>
|
||||
<FloatingCard style={[styles.masonryCard, styles.caloriesCard]} delay={500}>
|
||||
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
||||
{activeCalories != null ? (
|
||||
<AnimatedNumber
|
||||
@@ -328,9 +380,9 @@ export default function ExploreScreen() {
|
||||
) : (
|
||||
<Text style={styles.caloriesValue}>——</Text>
|
||||
)}
|
||||
</View>
|
||||
</FloatingCard>
|
||||
|
||||
<View style={[styles.masonryCard, styles.stepsCard]}>
|
||||
<FloatingCard style={[styles.masonryCard, styles.stepsCard]} delay={1000}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Text style={styles.cardTitle}>步数</Text>
|
||||
</View>
|
||||
@@ -351,31 +403,33 @@ export default function ExploreScreen() {
|
||||
fillColor="#FFC365"
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
{/* 右列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
<BMICard
|
||||
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
|
||||
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
||||
style={styles.masonryCardNoBg}
|
||||
// compact={true}
|
||||
/>
|
||||
<FloatingCard style={styles.masonryCard} delay={250}>
|
||||
<BMICard
|
||||
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
|
||||
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
||||
style={styles.bmiCardOverride}
|
||||
// compact={true}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
<FitnessRingsCard
|
||||
activeCalories={fitnessRingsData.activeCalories}
|
||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||||
standHours={fitnessRingsData.standHours}
|
||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||||
resetToken={animToken}
|
||||
style={styles.masonryCard}
|
||||
/>
|
||||
<FloatingCard style={styles.masonryCard} delay={750}>
|
||||
<FitnessRingsCard
|
||||
activeCalories={fitnessRingsData.activeCalories}
|
||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||||
standHours={fitnessRingsData.standHours}
|
||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
<View style={[styles.masonryCard, styles.sleepCard]}>
|
||||
<FloatingCard style={[styles.masonryCard, styles.sleepCard]} delay={1250}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
</View>
|
||||
@@ -386,7 +440,7 @@ export default function ExploreScreen() {
|
||||
) : (
|
||||
<Text style={styles.sleepValue}>——</Text>
|
||||
)}
|
||||
</View>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -713,11 +767,10 @@ const styles = StyleSheet.create({
|
||||
masonryContainer: {
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
marginHorizontal: 3,
|
||||
},
|
||||
masonryCard: {
|
||||
width: '100%',
|
||||
@@ -727,26 +780,15 @@ const styles = StyleSheet.create({
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginBottom: 8,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
masonryCardNoBg: {
|
||||
width: '100%',
|
||||
bmiCardOverride: {
|
||||
margin: -16, // 抵消 masonryCard 的 padding
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginBottom: 8,
|
||||
},
|
||||
compactStepsCard: {
|
||||
minHeight: 100,
|
||||
|
||||
329
components/ActivityHeatMap.tsx
Normal file
329
components/ActivityHeatMap.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Dimensions, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
|
||||
const ActivityHeatMap = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
|
||||
const activityData = useAppSelector(stat => stat.user.activityHistory);
|
||||
|
||||
// 获取屏幕宽度,计算适合的网格大小
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
const containerPadding = 40; // 卡片的左右padding
|
||||
const availableWidth = screenWidth - containerPadding;
|
||||
|
||||
// 计算小方块的尺寸(显示最近26周,约6个月)
|
||||
const weeksToShow = 26;
|
||||
const gapBetweenWeeks = 4; // 周与周之间的间距
|
||||
const totalGaps = (weeksToShow - 1) * gapBetweenWeeks;
|
||||
const cellWidth = (availableWidth - totalGaps) / weeksToShow;
|
||||
const cellSize = Math.max(Math.floor(cellWidth), 8); // 确保最小尺寸
|
||||
|
||||
// 生成最近6个月的活动数据
|
||||
const generateActivityData = useMemo(() => {
|
||||
const data: { date: string; level: number }[] = [];
|
||||
|
||||
|
||||
const today = dayjs();
|
||||
const startDate = today.subtract(weeksToShow * 7, 'day');
|
||||
|
||||
// 创建服务端数据的日期映射,便于快速查找
|
||||
const activityMap = new Map<string, number>();
|
||||
if (activityData && activityData.length > 0) {
|
||||
activityData.forEach(item => {
|
||||
activityMap.set(item.date, item.level);
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i <= weeksToShow * 7; i++) {
|
||||
const currentDate = startDate.add(i, 'day');
|
||||
const dateString = currentDate.format('YYYY-MM-DD');
|
||||
|
||||
// 如果服务端有对应日期的数据,使用服务端的 level;否则为 0
|
||||
const level = activityMap.get(dateString) || 0;
|
||||
|
||||
data.push({
|
||||
date: dateString,
|
||||
level,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [activityData, weeksToShow]);
|
||||
|
||||
console.log('generateActivityData', generateActivityData);
|
||||
|
||||
|
||||
// 根据活跃度计算颜色
|
||||
const getActivityColor = (level: number): string => {
|
||||
// 由于useColorScheme总是返回'light',我们直接使用浅色主题的颜色
|
||||
switch (level) {
|
||||
case 0:
|
||||
return colors.separator; // 使用主题分隔线色
|
||||
case 1:
|
||||
case 2:
|
||||
return 'rgba(135,206,235,0.4)';
|
||||
case 3:
|
||||
return 'rgba(135,206,235,0.65)';
|
||||
case 4:
|
||||
default:
|
||||
return colors.primary;
|
||||
}
|
||||
};
|
||||
|
||||
// 将数据按周组织
|
||||
const organizeDataByWeeks = useMemo(() => {
|
||||
const weeks: { date: string; level: number }[][] = [];
|
||||
|
||||
for (let week = 0; week < weeksToShow; week++) {
|
||||
const weekData: { date: string; level: number }[] = [];
|
||||
for (let day = 1; day <= 7; day++) {
|
||||
const dataIndex = week * 7 + day;
|
||||
if (dataIndex < generateActivityData.length) {
|
||||
weekData.push(generateActivityData[dataIndex] as { date: string; level: number });
|
||||
}
|
||||
}
|
||||
weeks.push(weekData);
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}, [generateActivityData, weeksToShow]);
|
||||
|
||||
console.log('organizeDataByWeeks', organizeDataByWeeks);
|
||||
|
||||
|
||||
// 获取月份标签(简化的月份标签系统)
|
||||
const getMonthLabels = useMemo(() => {
|
||||
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
|
||||
// 简单策略:均匀分布4-5个月份标签
|
||||
const totalWeeks = weeksToShow;
|
||||
const labelPositions: { label: string; position: number }[] = [];
|
||||
|
||||
// 在25%、50%、75%位置放置标签
|
||||
const positions = [
|
||||
Math.floor(totalWeeks * 0.1),
|
||||
Math.floor(totalWeeks * 0.35),
|
||||
Math.floor(totalWeeks * 0.65),
|
||||
Math.floor(totalWeeks * 0.9)
|
||||
];
|
||||
|
||||
positions.forEach((weekIndex) => {
|
||||
if (weekIndex < organizeDataByWeeks.length && organizeDataByWeeks[weekIndex].length > 0) {
|
||||
const firstDay = dayjs(organizeDataByWeeks[weekIndex][0].date);
|
||||
const month = firstDay.month();
|
||||
labelPositions.push({
|
||||
label: monthNames[month],
|
||||
position: weekIndex
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return labelPositions;
|
||||
}, [organizeDataByWeeks, weeksToShow]);
|
||||
|
||||
// 计算活动统计
|
||||
const activityStats = useMemo(() => {
|
||||
const totalDays = generateActivityData.length;
|
||||
const activeDays = generateActivityData.filter(d => d.level > 0).length;
|
||||
const totalActivity = generateActivityData.reduce((sum, d) => sum + d.level, 0);
|
||||
|
||||
return {
|
||||
totalDays,
|
||||
activeDays,
|
||||
totalActivity,
|
||||
activeRate: Math.round((activeDays / totalDays) * 100)
|
||||
};
|
||||
}, [generateActivityData]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
{/* 标题和统计 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||
最近6个月活跃 {activityStats.activeDays} 天
|
||||
</Text>
|
||||
<View style={[styles.statsBadge, { backgroundColor: colors.ornamentPrimary }]}>
|
||||
<Text style={[styles.statsText, { color: colors.primary }]}>
|
||||
{activityStats.activeRate}%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
{/* 活动热力图 */}
|
||||
<View style={styles.heatMapContainer}>
|
||||
{/* 热力图网格 */}
|
||||
<View style={styles.gridContainer}>
|
||||
{organizeDataByWeeks.map((week, weekIndex) => (
|
||||
<View key={weekIndex} style={[styles.week, { width: cellSize }]}>
|
||||
{week.map((day, dayIndex) => (
|
||||
<View
|
||||
key={day.date}
|
||||
style={[
|
||||
styles.cell,
|
||||
{
|
||||
backgroundColor: getActivityColor(day.level),
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
borderRadius: Math.max(cellSize * 0.15, 1.5),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 月份标签 */}
|
||||
<View style={styles.monthLabelsContainer}>
|
||||
{getMonthLabels.map((monthData, index) => {
|
||||
// 计算标签位置:基于周的位置,但要确保标签不重叠
|
||||
const basePosition = (monthData.position / (weeksToShow - 1)) * 100;
|
||||
// 限制位置范围,避免标签超出边界
|
||||
const leftPercentage = Math.max(0, Math.min(90, basePosition));
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={`${monthData.label}-${index}`}
|
||||
style={[
|
||||
styles.monthLabel,
|
||||
{
|
||||
color: colors.textMuted,
|
||||
position: 'absolute',
|
||||
left: `${leftPercentage}%`
|
||||
}
|
||||
]}
|
||||
>
|
||||
{monthData.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 图例 */}
|
||||
<View style={styles.legend}>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>少</Text>
|
||||
<View style={styles.legendColors}>
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<View
|
||||
key={level}
|
||||
style={[
|
||||
styles.legendCell,
|
||||
{
|
||||
backgroundColor: getActivityColor(level),
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>多</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: 'rgba(135,206,235,0.25)',
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(135,206,235,0.08)',
|
||||
},
|
||||
header: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statsBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statsText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
heatMapContainer: {
|
||||
marginBottom: 18,
|
||||
},
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 2,
|
||||
gap: 2, // 统一的周间距
|
||||
},
|
||||
week: {
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cell: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
monthLabelsContainer: {
|
||||
position: 'relative',
|
||||
height: 16,
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
monthLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
transform: [{ translateX: -12 }],
|
||||
minWidth: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
legend: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 10,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
},
|
||||
legendColors: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
legendCell: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2.5,
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default ActivityHeatMap;
|
||||
@@ -20,6 +20,17 @@ export type UserProfile = {
|
||||
maxUsageCount?: number;
|
||||
};
|
||||
|
||||
export type WeightHistoryItem = {
|
||||
weight: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ActivityHistoryItem = {
|
||||
date: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export type UserState = {
|
||||
token: string | null;
|
||||
profile: UserProfile;
|
||||
@@ -27,12 +38,7 @@ export type UserState = {
|
||||
error: string | null;
|
||||
privacyAgreed: boolean;
|
||||
weightHistory: WeightHistoryItem[];
|
||||
};
|
||||
|
||||
export type WeightHistoryItem = {
|
||||
weight: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
activityHistory: ActivityHistoryItem[];
|
||||
};
|
||||
|
||||
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
|
||||
@@ -49,6 +55,7 @@ const initialState: UserState = {
|
||||
error: null,
|
||||
privacyAgreed: false,
|
||||
weightHistory: [],
|
||||
activityHistory: [],
|
||||
};
|
||||
|
||||
export type LoginPayload = Record<string, any> & {
|
||||
@@ -176,6 +183,16 @@ export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', as
|
||||
}
|
||||
});
|
||||
|
||||
// 获取用户活动历史记录
|
||||
export const fetchActivityHistory = createAsyncThunk('user/fetchActivityHistory', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data: ActivityHistoryItem[] = await api.get('/api/users/activity-history');
|
||||
console.log('fetchActivityHistory', data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
return rejectWithValue(err?.message ?? '获取用户活动历史记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
const userSlice = createSlice({
|
||||
name: 'user',
|
||||
@@ -240,11 +257,15 @@ const userSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchWeightHistory.rejected, (state, action) => {
|
||||
state.error = (action.payload as string) ?? '获取用户体重历史记录失败';
|
||||
})
|
||||
.addCase(fetchActivityHistory.fulfilled, (state, action) => {
|
||||
state.activityHistory = action.payload;
|
||||
})
|
||||
.addCase(fetchActivityHistory.rejected, (state, action) => {
|
||||
state.error = (action.payload as string) ?? '获取用户活动历史记录失败';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user