feat: 支持步数卡片; 优化数据分析各类卡片样式

This commit is contained in:
richarjiang
2025-08-30 17:07:04 +08:00
parent 465d5350f3
commit 741688065d
9 changed files with 462 additions and 103 deletions

View File

@@ -4,9 +4,9 @@ import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import { ProgressBar } from '@/components/ProgressBar';
import HeartRateCard from '@/components/statistic/HeartRateCard'; import HeartRateCard from '@/components/statistic/HeartRateCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter'; import { StressMeter } from '@/components/StressMeter';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard'; import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
@@ -19,13 +19,13 @@ import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/mo
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Animated,
SafeAreaView, SafeAreaView,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@@ -40,53 +40,28 @@ const FloatingCard = ({ children, delay = 0, style }: {
delay?: number; delay?: number;
style?: any; 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 ( return (
<Animated.View <View
style={[ style={[
style, style,
{ {
transform: [{ translateY }],
marginBottom: 8, marginBottom: 8,
}, },
]} ]}
> >
{children} {children}
</Animated.View> </View>
); );
}; };
export default function ExploreScreen() { export default function ExploreScreen() {
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
// 开发调试设置为true来使用mock数据
const useMockData = true; // 改为true来启用mock数据调试
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
// 使用 dayjs当月日期与默认选中"今天" // 使用 dayjs当月日期与默认选中"今天"
@@ -111,15 +86,24 @@ export default function ExploreScreen() {
// 从 Redux 获取指定日期的健康数据 // 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
// 解构健康数据 // 解构健康数据支持mock数据
const stepCount = healthData?.steps ?? null; const mockData = useMockData ? getTestHealthData('mock') : null;
const activeCalories = healthData?.activeEnergyBurned ?? null; const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
const basalMetabolism = healthData?.basalEnergyBurned ?? null; const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
const sleepDuration = healthData?.sleepDuration ?? null; const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
const hrvValue = healthData?.hrv ?? 0; const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
const oxygenSaturation = healthData?.oxygenSaturation ?? null; const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
const heartRate = healthData?.heartRate ?? null; const hrvValue = useMockData ? (mockData?.hrv ?? 0) : (healthData?.hrv ?? 0);
const fitnessRingsData = healthData ? { const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
const heartRate = useMockData ? (mockData?.heartRate ?? null) : (healthData?.heartRate ?? null);
const fitnessRingsData = useMockData ? {
activeCalories: mockData?.activeCalories ?? 0,
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
standHours: mockData?.standHours ?? 0,
standHoursGoal: mockData?.standHoursGoal ?? 12,
} : (healthData ? {
activeCalories: healthData.activeEnergyBurned, activeCalories: healthData.activeEnergyBurned,
activeCaloriesGoal: healthData.activeCaloriesGoal, activeCaloriesGoal: healthData.activeCaloriesGoal,
exerciseMinutes: healthData.exerciseMinutes, exerciseMinutes: healthData.exerciseMinutes,
@@ -133,7 +117,7 @@ export default function ExploreScreen() {
exerciseMinutesGoal: 30, exerciseMinutesGoal: 30,
standHours: 0, standHours: 0,
standHoursGoal: 12, standHoursGoal: 12,
}; });
// HRV更新时间 // HRV更新时间
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date()); const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
@@ -357,38 +341,31 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard} delay={500}> <FloatingCard style={styles.masonryCard} delay={500}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
{activeCalories != null ? ( <View style={{
<AnimatedNumber flexDirection: 'row',
value={activeCalories} alignItems: 'flex-end',
resetToken={animToken} marginTop: 20
style={styles.caloriesValue} }}>
format={(v) => `${Math.round(v)} 千卡`} {activeCalories != null ? (
/> <AnimatedNumber
) : ( value={activeCalories}
<Text style={styles.caloriesValue}></Text> resetToken={animToken}
)} style={styles.caloriesValue}
format={(v) => `${Math.round(v)}`}
/>
) : (
<Text style={styles.caloriesValue}></Text>
)}
<Text style={styles.caloriesUnit}></Text>
</View>
</FloatingCard> </FloatingCard>
<FloatingCard style={styles.masonryCard} delay={1000}> <FloatingCard style={styles.masonryCard}>
<View style={styles.cardHeaderRow}> <StepsCard
<Text style={styles.cardTitle}></Text> stepCount={stepCount}
</View> stepGoal={stepGoal}
{stepCount != null ? ( hourlySteps={hourlySteps}
<AnimatedNumber style={styles.stepsCardOverride}
value={stepCount}
resetToken={animToken}
style={styles.stepsValue}
format={(v) => `${Math.round(v)}/${stepGoal}`}
/>
) : (
<Text style={styles.stepsValue}>/{stepGoal}</Text>
)}
<ProgressBar
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / stepGoal))}
height={14}
trackColor="#FFEBCB"
fillColor="#FFC365"
showLabel={false}
/> />
</FloatingCard> </FloatingCard>
<FloatingCard style={styles.masonryCard} delay={0}> <FloatingCard style={styles.masonryCard} delay={0}>
@@ -414,7 +391,7 @@ export default function ExploreScreen() {
/> />
</FloatingCard> </FloatingCard>
<FloatingCard style={styles.masonryCard} delay={750}> <FloatingCard style={styles.masonryCard}>
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
</View> </View>
@@ -463,8 +440,6 @@ export default function ExploreScreen() {
} }
const primary = Colors.light.primary; const primary = Colors.light.primary;
const lightColors = Colors.light;
const darkColors = Colors.dark;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
@@ -543,8 +518,15 @@ const styles = StyleSheet.create({
caloriesValue: { caloriesValue: {
color: '#192126', color: '#192126',
fontSize: 18, fontSize: 18,
fontWeight: '800', lineHeight: 18,
marginTop: 18, fontWeight: '600',
textAlignVertical: 'bottom'
},
caloriesUnit: {
color: '#515558ff',
fontSize: 12,
marginLeft: 4,
lineHeight: 18,
}, },
trainingContent: { trainingContent: {
marginTop: 8, marginTop: 8,
@@ -737,6 +719,11 @@ const styles = StyleSheet.create({
margin: -16, // 抵消 masonryCard 的 padding margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16, borderRadius: 16,
}, },
stepsCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16,
height: '100%', // 填充整个masonryCard
},
compactStepsCard: { compactStepsCard: {
minHeight: 100, minHeight: 100,
}, },

View File

@@ -15,8 +15,6 @@ export function MoodCard({ moodCheckin, onPress, isLoading = false }: MoodCardPr
return ( return (
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} disabled={isLoading}> <TouchableOpacity onPress={onPress} style={styles.moodCardContent} disabled={isLoading}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
{isLoading ? ( {isLoading ? (
<View style={styles.moodPreview}> <View style={styles.moodPreview}>
<Text style={styles.moodLoadingText}>...</Text> <Text style={styles.moodLoadingText}>...</Text>
@@ -52,7 +50,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginTop: 22, marginTop: 12,
}, },
moodPreviewText: { moodPreviewText: {
fontSize: 14, fontSize: 14,

152
components/StepsCard.tsx Normal file
View File

@@ -0,0 +1,152 @@
import React, { useMemo } from 'react';
import {
StyleSheet,
Text,
View,
ViewStyle
} from 'react-native';
import { HourlyStepData } from '@/utils/health';
// 使用原生View来替代SVG避免导入问题
// import Svg, { Rect } from 'react-native-svg';
interface StepsCardProps {
stepCount: number | null;
stepGoal: number;
hourlySteps: HourlyStepData[];
style?: ViewStyle;
}
const StepsCard: React.FC<StepsCardProps> = ({
stepCount,
stepGoal,
hourlySteps,
style
}) => {
// 计算柱状图数据
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 找到最大步数用于计算高度比例
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 20; // 柱状图最大高度(缩小一半)
return hourlySteps.map(data => ({
...data,
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 获取当前小时
const currentHour = new Date().getHours();
return (
<View style={[styles.container, style]}>
{/* 标题和步数显示 */}
<View style={styles.header}>
<Text style={styles.title}></Text>
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{chartData.map((data, index) => {
// 判断是否是当前小时或者有活动的小时
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
return (
<View
key={`bar-${index}`}
style={[
styles.chartBar,
{
height: data.height || 2, // 最小高度2px
backgroundColor: isCurrent && isActive ? '#FFC365' : '#FFEBCB',
}
]}
/>
);
})}
</View>
</View>
</View>
{/* 步数和目标显示 */}
<View style={styles.statsContainer}>
<Text style={styles.stepCount}>
{stepCount !== null ? stepCount.toLocaleString() : '——'}
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
},
footprintIcons: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
chartContainer: {
flex: 1,
justifyContent: 'center',
},
chartWrapper: {
width: '100%',
alignItems: 'center',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 20,
width: '100%',
maxWidth: 240,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
chartBar: {
width: 4,
borderRadius: 1,
alignSelf: 'flex-end',
},
statsContainer: {
alignItems: 'flex-start',
marginTop: 6
},
stepCount: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
},
});
export default StepsCard;

View File

@@ -154,10 +154,11 @@ const styles = StyleSheet.create({
marginBottom: 12, marginBottom: 12,
}, },
value: { value: {
fontSize: 24, fontSize: 20,
fontWeight: '800', fontWeight: '600',
color: '#192126', color: '#192126',
lineHeight: 32, lineHeight: 20,
marginTop: 2,
}, },
unit: { unit: {
fontSize: 12, fontSize: 12,
@@ -166,10 +167,10 @@ const styles = StyleSheet.create({
marginLeft: 4, marginLeft: 4,
}, },
progressContainer: { progressContainer: {
height: 16, height: 6,
}, },
progressTrack: { progressTrack: {
height: 8, height: 6,
borderRadius: 4, borderRadius: 4,
position: 'relative', position: 'relative',
overflow: 'visible', overflow: 'visible',
@@ -185,9 +186,9 @@ const styles = StyleSheet.create({
}, },
indicator: { indicator: {
position: 'absolute', position: 'absolute',
top: -4, top: -2,
width: 16, width: 10,
height: 16, height: 10,
borderRadius: 8, borderRadius: 8,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
shadowColor: '#000', shadowColor: '#000',

View File

@@ -34,7 +34,6 @@ const HealthDataCard: React.FC<HealthDataCardProps> = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
borderRadius: 16,
shadowColor: '#000', shadowColor: '#000',
paddingHorizontal: 16, paddingHorizontal: 16,
shadowOffset: { shadowOffset: {
@@ -48,11 +47,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
iconContainer: {
marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
},
content: { content: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
@@ -60,7 +54,7 @@ const styles = StyleSheet.create({
title: { title: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
marginBottom: 4, marginBottom: 14,
fontWeight: '800', fontWeight: '800',
}, },
valueContainer: { valueContainer: {
@@ -68,8 +62,8 @@ const styles = StyleSheet.create({
alignItems: 'flex-end', alignItems: 'flex-end',
}, },
value: { value: {
fontSize: 24, fontSize: 20,
fontWeight: '800', fontWeight: '600',
color: '#192126', color: '#192126',
}, },
unit: { unit: {

View File

@@ -1,4 +1,3 @@
import { Ionicons } from '@expo/vector-icons';
import React from 'react'; import React from 'react';
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import HealthDataCard from './HealthDataCard'; import HealthDataCard from './HealthDataCard';
@@ -14,10 +13,6 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
style, style,
oxygenSaturation oxygenSaturation
}) => { }) => {
const oxygenIcon = (
<Ionicons name="water" size={24} color="#3B82F6" />
);
return ( return (
<HealthDataCard <HealthDataCard
title="血氧饱和度" title="血氧饱和度"

View File

@@ -1,5 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppDispatch, RootState } from './index'; import { AppDispatch, RootState } from './index';
import { HourlyStepData } from '@/utils/health';
// 健康数据类型定义 // 健康数据类型定义
export interface FitnessRingsData { export interface FitnessRingsData {
@@ -25,6 +26,7 @@ export interface HealthData {
exerciseMinutesGoal: number; exerciseMinutesGoal: number;
standHours: number; standHours: number;
standHoursGoal: number; standHoursGoal: number;
hourlySteps: HourlyStepData[];
} }
export interface HealthState { export interface HealthState {

View File

@@ -27,6 +27,11 @@ const PERMISSIONS: HealthKitPermissions = {
}, },
}; };
export type HourlyStepData = {
hour: number; // 0-23
steps: number;
};
export type TodayHealthData = { export type TodayHealthData = {
steps: number; steps: number;
activeEnergyBurned: number; // kilocalories activeEnergyBurned: number; // kilocalories
@@ -43,6 +48,8 @@ export type TodayHealthData = {
// 新增血氧饱和度和心率数据 // 新增血氧饱和度和心率数据
oxygenSaturation: number | null; oxygenSaturation: number | null;
heartRate: number | null; heartRate: number | null;
// 每小时步数数据
hourlySteps: HourlyStepData[];
}; };
export async function ensureHealthPermissions(): Promise<boolean> { export async function ensureHealthPermissions(): Promise<boolean> {
@@ -155,6 +162,88 @@ async function fetchStepCount(date: Date): Promise<number> {
}); });
} }
// 获取指定日期每小时步数数据
async function fetchHourlyStepCount(date: Date): Promise<HourlyStepData[]> {
return new Promise((resolve) => {
const startOfDay = dayjs(date).startOf('day');
const endOfDay = dayjs(date).endOf('day');
AppleHealthKit.getStepCount({
startDate: startOfDay.toDate().toISOString(),
endDate: endOfDay.toDate().toISOString(),
includeManuallyAdded: false,
}, (err, res) => {
if (err) {
logError('每小时步数', err);
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
}
if (!res || !Array.isArray(res) || res.length === 0) {
logWarning('每小时步数', '为空');
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
}
logSuccess('每小时步数', res);
// 初始化24小时数据
const hourlyData: HourlyStepData[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
steps: 0
}));
// 如果返回的是累计数据,我们需要获取样本数据
resolve(hourlyData);
});
});
}
// 使用样本数据获取每小时步数
async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
return new Promise((resolve) => {
const startOfDay = dayjs(date).startOf('day');
const endOfDay = dayjs(date).endOf('day');
AppleHealthKit.getSamples(
{
startDate: startOfDay.toDate().toISOString(),
endDate: endOfDay.toDate().toISOString(),
type: 'StepCount',
},
(err: any, res: any[]) => {
if (err) {
logError('每小时步数样本', err);
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
}
if (!res || !Array.isArray(res) || res.length === 0) {
logWarning('每小时步数样本', '为空');
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
}
logSuccess('每小时步数样本', res);
// 初始化24小时数据
const hourlyData: HourlyStepData[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
steps: 0
}));
// 将样本数据按小时分组并累加
res.forEach((sample: any) => {
if (sample && sample.startDate && sample.value) {
const hour = dayjs(sample.startDate).hour();
if (hour >= 0 && hour < 24) {
hourlyData[hour].steps += Math.round(sample.value);
}
}
});
resolve(hourlyData);
}
);
});
}
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> { async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
return new Promise((resolve) => { return new Promise((resolve) => {
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => { AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
@@ -304,7 +393,8 @@ function getDefaultHealthData(): TodayHealthData {
standHours: 0, standHours: 0,
standHoursGoal: 12, standHoursGoal: 12,
oxygenSaturation: null, oxygenSaturation: null,
heartRate: null heartRate: null,
hourlySteps: Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 }))
}; };
} }
@@ -318,6 +408,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
// 并行获取所有健康数据 // 并行获取所有健康数据
const [ const [
steps, steps,
hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
sleepDuration, sleepDuration,
@@ -327,6 +418,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
heartRate heartRate
] = await Promise.all([ ] = await Promise.all([
fetchStepCount(date), fetchStepCount(date),
fetchHourlyStepSamples(date),
fetchActiveEnergyBurned(options), fetchActiveEnergyBurned(options),
fetchBasalEnergyBurned(options), fetchBasalEnergyBurned(options),
fetchSleepDuration(options), fetchSleepDuration(options),
@@ -338,6 +430,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
console.log('指定日期健康数据获取完成:', { console.log('指定日期健康数据获取完成:', {
steps, steps,
hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
sleepDuration, sleepDuration,
@@ -349,6 +442,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
return { return {
steps, steps,
hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
sleepDuration, sleepDuration,

136
utils/mockHealthData.ts Normal file
View File

@@ -0,0 +1,136 @@
import { HourlyStepData, TodayHealthData } from './health';
// Mock的每小时步数数据模拟真实的一天活动模式
export const mockHourlySteps: HourlyStepData[] = [
{ hour: 0, steps: 0 }, // 午夜
{ hour: 1, steps: 0 }, // 凌晨
{ hour: 2, steps: 0 },
{ hour: 3, steps: 0 },
{ hour: 4, steps: 0 },
{ hour: 5, steps: 0 },
{ hour: 6, steps: 120 }, // 早晨起床
{ hour: 7, steps: 450 }, // 晨练/上班准备
{ hour: 8, steps: 680 }, // 上班通勤
{ hour: 9, steps: 320 }, // 工作时间
{ hour: 10, steps: 180 }, // 办公室内活动
{ hour: 11, steps: 280 }, // 会议/活动
{ hour: 12, steps: 520 }, // 午餐时间
{ hour: 13, steps: 150 }, // 午休
{ hour: 14, steps: 240 }, // 下午工作
{ hour: 15, steps: 300 }, // 工作活动
{ hour: 16, steps: 380 }, // 会议/外出
{ hour: 17, steps: 480 }, // 下班通勤
{ hour: 18, steps: 620 }, // 晚餐/活动
{ hour: 19, steps: 350 }, // 晚间活动
{ hour: 20, steps: 280 }, // 散步
{ hour: 21, steps: 150 }, // 休闲时间
{ hour: 22, steps: 80 }, // 准备睡觉
{ hour: 23, steps: 30 }, // 睡前
];
// Mock的完整健康数据
export const mockHealthData: TodayHealthData = {
steps: 6140, // 总步数
hourlySteps: mockHourlySteps,
activeEnergyBurned: 420,
basalEnergyBurned: 1680,
sleepDuration: 480, // 8小时
hrv: 45,
activeCalories: 420,
activeCaloriesGoal: 350,
exerciseMinutes: 32,
exerciseMinutesGoal: 30,
standHours: 8,
standHoursGoal: 12,
oxygenSaturation: 98.2,
heartRate: 72,
};
// 生成随机的每小时步数数据(用于测试不同的数据模式)
export const generateRandomHourlySteps = (): HourlyStepData[] => {
return Array.from({ length: 24 }, (_, hour) => {
let steps = 0;
// 模拟真实的活动模式
if (hour >= 6 && hour <= 22) {
if (hour >= 7 && hour <= 9) {
// 早晨高峰期
steps = Math.floor(Math.random() * 600) + 200;
} else if (hour >= 12 && hour <= 13) {
// 午餐时间
steps = Math.floor(Math.random() * 400) + 300;
} else if (hour >= 17 && hour <= 19) {
// 晚间活跃期
steps = Math.floor(Math.random() * 500) + 250;
} else if (hour >= 6 && hour <= 22) {
// 白天正常活动
steps = Math.floor(Math.random() * 300) + 50;
}
} else {
// 夜间很少活动
steps = Math.floor(Math.random() * 50);
}
return { hour, steps };
});
};
// 不同活动模式的预设数据
export const activityPatterns = {
// 久坐办公族
sedentary: Array.from({ length: 24 }, (_, hour) => ({
hour,
steps: hour >= 7 && hour <= 18 ? Math.floor(Math.random() * 200) + 50 :
hour >= 19 && hour <= 21 ? Math.floor(Math.random() * 300) + 100 :
Math.floor(Math.random() * 20)
})),
// 活跃用户
active: Array.from({ length: 24 }, (_, hour) => ({
hour,
steps: hour >= 6 && hour <= 8 ? Math.floor(Math.random() * 800) + 400 :
hour >= 12 && hour <= 13 ? Math.floor(Math.random() * 600) + 300 :
hour >= 17 && hour <= 20 ? Math.floor(Math.random() * 900) + 500 :
hour >= 9 && hour <= 16 ? Math.floor(Math.random() * 400) + 100 :
Math.floor(Math.random() * 50)
})),
// 健身爱好者
fitness: Array.from({ length: 24 }, (_, hour) => ({
hour,
steps: hour === 6 ? Math.floor(Math.random() * 1200) + 800 : // 晨跑
hour === 18 ? Math.floor(Math.random() * 1000) + 600 : // 晚间锻炼
hour >= 7 && hour <= 17 ? Math.floor(Math.random() * 300) + 100 :
Math.floor(Math.random() * 50)
})),
};
// 用于快速切换测试数据的函数
export const getTestHealthData = (pattern: 'mock' | 'random' | 'sedentary' | 'active' | 'fitness' = 'mock'): TodayHealthData => {
let hourlySteps: HourlyStepData[];
switch (pattern) {
case 'random':
hourlySteps = generateRandomHourlySteps();
break;
case 'sedentary':
hourlySteps = activityPatterns.sedentary;
break;
case 'active':
hourlySteps = activityPatterns.active;
break;
case 'fitness':
hourlySteps = activityPatterns.fitness;
break;
default:
hourlySteps = mockHourlySteps;
}
const totalSteps = hourlySteps.reduce((sum, data) => sum + data.steps, 0);
return {
...mockHealthData,
steps: totalSteps,
hourlySteps,
};
};