feat: 添加日历功能和进度条组件
- 在项目中引入 dayjs 库以处理日期 - 新增 PlanCard 和 ProgressBar 组件,分别用于展示训练计划和进度条 - 更新首页以显示推荐的训练计划 - 优化个人中心页面的底部留白处理 - 本地化界面文本为中文
This commit is contained in:
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import { Text, TouchableOpacity, View } from 'react-native';
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -51,8 +52,8 @@ export default function TabLayout() {
|
|||||||
},
|
},
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 20,
|
bottom: TAB_BAR_BOTTOM_OFFSET,
|
||||||
height: 68,
|
height: TAB_BAR_HEIGHT,
|
||||||
borderRadius: 34,
|
borderRadius: 34,
|
||||||
backgroundColor: '#192126',
|
backgroundColor: '#192126',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -69,7 +70,7 @@ export default function TabLayout() {
|
|||||||
},
|
},
|
||||||
tabBarItemStyle: {
|
tabBarItemStyle: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
height: 68,
|
height: TAB_BAR_HEIGHT,
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
|
|||||||
@@ -1,110 +1,362 @@
|
|||||||
import { Image } from 'expo-image';
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { Collapsible } from '@/components/Collapsible';
|
export default function ExploreScreen() {
|
||||||
import { ExternalLink } from '@/components/ExternalLink';
|
// 使用 dayjs:当月日期与默认选中“今天”
|
||||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
const days = getMonthDaysZh();
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomPadding = useMemo(() => {
|
||||||
|
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||||
|
}, [tabBarHeight, insets?.bottom]);
|
||||||
|
|
||||||
|
const monthTitle = getMonthTitleZh();
|
||||||
|
|
||||||
|
// 日期条自动滚动到选中项
|
||||||
|
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
|
||||||
|
const [scrollWidth, setScrollWidth] = useState(0);
|
||||||
|
const DAY_PILL_WIDTH = 68;
|
||||||
|
const DAY_PILL_SPACING = 12;
|
||||||
|
|
||||||
|
const scrollToIndex = (index: number, animated = true) => {
|
||||||
|
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
|
||||||
|
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
|
||||||
|
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollWidth > 0) {
|
||||||
|
scrollToIndex(selectedIndex, false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [scrollWidth]);
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<View style={styles.container}>
|
||||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
<SafeAreaView style={styles.safeArea}>
|
||||||
headerImage={
|
<ScrollView
|
||||||
<IconSymbol
|
style={styles.scrollView}
|
||||||
size={310}
|
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||||
color="#808080"
|
showsVerticalScrollIndicator={false}
|
||||||
name="chevron.left.forwardslash.chevron.right"
|
>
|
||||||
style={styles.headerImage}
|
{/* 标题与日期选择 */}
|
||||||
/>
|
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||||
}>
|
<ScrollView
|
||||||
<ThemedView style={styles.titleContainer}>
|
horizontal
|
||||||
<ThemedText type="title">Explore</ThemedText>
|
showsHorizontalScrollIndicator={false}
|
||||||
</ThemedView>
|
contentContainerStyle={styles.daysContainer}
|
||||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
ref={daysScrollRef}
|
||||||
<Collapsible title="File-based routing">
|
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
||||||
<ThemedText>
|
>
|
||||||
This app has two screens:{' '}
|
{days.map((d, i) => {
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
const selected = i === selectedIndex;
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
return (
|
||||||
</ThemedText>
|
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
|
||||||
<ThemedText>
|
<TouchableOpacity
|
||||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
|
||||||
sets up the tab navigator.
|
onPress={() => {
|
||||||
</ThemedText>
|
setSelectedIndex(i);
|
||||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
scrollToIndex(i);
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
}}
|
||||||
</ExternalLink>
|
activeOpacity={0.8}
|
||||||
</Collapsible>
|
>
|
||||||
<Collapsible title="Android, iOS, and web support">
|
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
|
||||||
<ThemedText>
|
<Text style={[styles.dayDate, selected && styles.dayDateSelected]}>{d.dayOfMonth}</Text>
|
||||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
</TouchableOpacity>
|
||||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
{selected && <View style={styles.selectedDot} />}
|
||||||
</ThemedText>
|
</View>
|
||||||
</Collapsible>
|
);
|
||||||
<Collapsible title="Images">
|
|
||||||
<ThemedText>
|
|
||||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
|
||||||
different screen densities
|
|
||||||
</ThemedText>
|
|
||||||
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} />
|
|
||||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Custom fonts">
|
|
||||||
<ThemedText>
|
|
||||||
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '}
|
|
||||||
<ThemedText style={{ fontFamily: 'SpaceMono' }}>
|
|
||||||
custom fonts such as this one.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Light and dark mode components">
|
|
||||||
<ThemedText>
|
|
||||||
This template has light and dark mode support. The{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
|
||||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Animations">
|
|
||||||
<ThemedText>
|
|
||||||
This template includes an example of an animated component. The{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
|
||||||
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '}
|
|
||||||
library to create a waving hand animation.
|
|
||||||
</ThemedText>
|
|
||||||
{Platform.select({
|
|
||||||
ios: (
|
|
||||||
<ThemedText>
|
|
||||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
|
||||||
component provides a parallax effect for the header image.
|
|
||||||
</ThemedText>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Collapsible>
|
</ScrollView>
|
||||||
</ParallaxScrollView>
|
|
||||||
|
{/* 今日报告 标题 */}
|
||||||
|
<Text style={styles.sectionTitle}>今日报告</Text>
|
||||||
|
|
||||||
|
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
||||||
|
<Text style={styles.cardTitleSecondary}>训练时间</Text>
|
||||||
|
<View style={styles.trainingContent}>
|
||||||
|
<View style={styles.trainingRingTrack} />
|
||||||
|
<View style={styles.trainingRingProgress} />
|
||||||
|
<Text style={styles.trainingPercent}>80%</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricsRight}>
|
||||||
|
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
|
||||||
|
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
||||||
|
<Text style={styles.caloriesValue}>645 千卡</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
|
||||||
|
<View style={styles.cardHeaderRow}>
|
||||||
|
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
|
||||||
|
<Text style={styles.cardTitle}>步数</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.stepsValue}>999/2000</Text>
|
||||||
|
<ProgressBar progress={0.5} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primary = Colors.light.primary;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
headerImage: {
|
container: {
|
||||||
color: '#808080',
|
flex: 1,
|
||||||
bottom: -90,
|
backgroundColor: '#F6F7F8',
|
||||||
left: -35,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
},
|
||||||
titleContainer: {
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
daysContainer: {
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
dayItemWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 68,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
dayPill: {
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dayPillNormal: {
|
||||||
|
backgroundColor: '#C8F852',
|
||||||
|
},
|
||||||
|
dayPillSelected: {
|
||||||
|
backgroundColor: '#192126',
|
||||||
|
},
|
||||||
|
dayLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
dayLabelSelected: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
dayDate: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
dayDateSelected: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
selectedDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#192126',
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 4,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
metricsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 8,
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#0F1418',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 18,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
metricsLeft: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#EEE9FF',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 18,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
metricsRight: {
|
||||||
|
width: 160,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
metricsRightCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
caloriesCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
trainingCard: {
|
||||||
|
backgroundColor: '#EEE9FF',
|
||||||
|
},
|
||||||
|
|
||||||
|
cardTitleSecondary: {
|
||||||
|
color: '#9AA3AE',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
caloriesValue: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
trainingContent: {
|
||||||
|
marginTop: 8,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
trainingRingTrack: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 60,
|
||||||
|
borderWidth: 12,
|
||||||
|
borderColor: '#E2D9FD',
|
||||||
|
},
|
||||||
|
trainingRingProgress: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 60,
|
||||||
|
borderWidth: 12,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
borderTopColor: '#8B74F3',
|
||||||
|
borderRightColor: '#8B74F3',
|
||||||
|
transform: [{ rotateZ: '45deg' }],
|
||||||
|
},
|
||||||
|
trainingPercent: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#8B74F3',
|
||||||
|
},
|
||||||
|
cyclingHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
cyclingIconBadge: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: primary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
cyclingTitle: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
mapArea: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: 14,
|
||||||
|
height: 180,
|
||||||
|
padding: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
mapTile: {
|
||||||
|
width: '25%',
|
||||||
|
height: '25%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
|
routeLine: {
|
||||||
|
position: 'absolute',
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: primary,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
cardHeaderRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
iconSquare: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
heartCard: {
|
||||||
|
backgroundColor: '#FFE5E5',
|
||||||
|
},
|
||||||
|
waveContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 70,
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
waveBar: {
|
||||||
|
width: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#E54D4D',
|
||||||
|
},
|
||||||
|
heartValue: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
color: '#5B5B5B',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
stepsCard: {
|
||||||
|
backgroundColor: '#FFE4B8',
|
||||||
|
},
|
||||||
|
stepsValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#7A6A42',
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
|
import { PlanCard } from '@/components/PlanCard';
|
||||||
import { SearchBox } from '@/components/SearchBox';
|
import { SearchBox } from '@/components/SearchBox';
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
import { WorkoutCard } from '@/components/WorkoutCard';
|
import { WorkoutCard } from '@/components/WorkoutCard';
|
||||||
|
import { getChineseGreeting } from '@/utils/date';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
const workoutData = [
|
const workoutData = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: '体态评估',
|
title: 'AI体态评估',
|
||||||
duration: 50,
|
duration: 5,
|
||||||
imageSource: require('@/assets/images/react-logo.png'),
|
imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: 'Hand\nTraining',
|
title: '认证教练',
|
||||||
calories: 600,
|
|
||||||
duration: 40,
|
|
||||||
imageSource: require('@/assets/images/react-logo.png'),
|
imageSource: require('@/assets/images/react-logo.png'),
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Core\nWorkout',
|
|
||||||
calories: 450,
|
|
||||||
duration: 35,
|
|
||||||
imageSource: require('@/assets/images/react-logo.png'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
@@ -35,16 +28,16 @@ export default function HomeScreen() {
|
|||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<ThemedText style={styles.greeting}>Good Morning 🔥</ThemedText>
|
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
|
||||||
<ThemedText style={styles.userName}>Pramuditya Uzumaki</ThemedText>
|
<ThemedText style={styles.userName}>新学员,欢迎你</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Search Box */}
|
{/* Search Box */}
|
||||||
<SearchBox placeholder="Search" />
|
<SearchBox placeholder="搜索" />
|
||||||
|
|
||||||
{/* Popular Workouts Section */}
|
{/* Popular Workouts Section */}
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<ThemedText style={styles.sectionTitle}>Popular Workouts</ThemedText>
|
<ThemedText style={styles.sectionTitle}>热门活动</ThemedText>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -56,7 +49,6 @@ export default function HomeScreen() {
|
|||||||
<WorkoutCard
|
<WorkoutCard
|
||||||
key={workout.id}
|
key={workout.id}
|
||||||
title={workout.title}
|
title={workout.title}
|
||||||
calories={workout.calories}
|
|
||||||
duration={workout.duration}
|
duration={workout.duration}
|
||||||
imageSource={workout.imageSource}
|
imageSource={workout.imageSource}
|
||||||
onPress={() => console.log(`Pressed ${workout.title}`)}
|
onPress={() => console.log(`Pressed ${workout.title}`)}
|
||||||
@@ -65,6 +57,30 @@ export default function HomeScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Today Plan Section */}
|
||||||
|
<View style={styles.sectionContainer}>
|
||||||
|
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.planList}>
|
||||||
|
<PlanCard
|
||||||
|
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png'}
|
||||||
|
title="体态评估"
|
||||||
|
subtitle="评估你的体态,制定训练计划"
|
||||||
|
level="初学者"
|
||||||
|
progress={0}
|
||||||
|
/>
|
||||||
|
<PlanCard
|
||||||
|
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
||||||
|
title="30日训练打卡"
|
||||||
|
subtitle="坚持30天,养成训练习惯"
|
||||||
|
level="初学者"
|
||||||
|
progress={0.75}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Add some spacing at the bottom */}
|
{/* Add some spacing at the bottom */}
|
||||||
<View style={styles.bottomSpacing} />
|
<View style={styles.bottomSpacing} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -109,6 +125,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
},
|
},
|
||||||
|
planList: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
workoutScroll: {
|
workoutScroll: {
|
||||||
paddingLeft: 24,
|
paddingLeft: 24,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import React, { useState } from 'react';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -12,18 +14,19 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
export default function PersonalScreen() {
|
export default function PersonalScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const tabBarHeight = useBottomTabBarHeight();
|
||||||
|
const bottomPadding = useMemo(() => {
|
||||||
|
// 统一的页面底部留白:TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
|
||||||
|
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||||
|
}, [tabBarHeight, insets?.bottom]);
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
|
|
||||||
const ProfileHeader = () => (
|
|
||||||
<View style={styles.headerContainer}>
|
|
||||||
{/* 标题 */}
|
|
||||||
<Text style={styles.headerTitle}>Profile</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const UserInfoSection = () => (
|
const UserInfoSection = () => (
|
||||||
<View style={styles.userInfoCard}>
|
<View style={styles.userInfoCard}>
|
||||||
@@ -198,8 +201,11 @@ export default function PersonalScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
<ProfileHeader />
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
<UserInfoSection />
|
<UserInfoSection />
|
||||||
<StatsSection />
|
<StatsSection />
|
||||||
<MenuSection title="Account" items={accountItems} />
|
<MenuSection title="Account" items={accountItems} />
|
||||||
@@ -207,7 +213,7 @@ export default function PersonalScreen() {
|
|||||||
<MenuSection title="Other" items={otherItems} />
|
<MenuSection title="Other" items={otherItems} />
|
||||||
|
|
||||||
{/* 底部浮动按钮 */}
|
{/* 底部浮动按钮 */}
|
||||||
<View style={styles.floatingButtonContainer}>
|
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
|
||||||
<TouchableOpacity style={dynamicStyles.floatingButton}>
|
<TouchableOpacity style={dynamicStyles.floatingButton}>
|
||||||
<Ionicons name="search" size={24} color="#192126" />
|
<Ionicons name="search" size={24} color="#192126" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -231,21 +237,6 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
backgroundColor: '#F5F5F5',
|
backgroundColor: '#F5F5F5',
|
||||||
},
|
},
|
||||||
// 头部导航
|
|
||||||
headerContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingTop: 10,
|
|
||||||
paddingBottom: 20,
|
|
||||||
},
|
|
||||||
|
|
||||||
headerTitle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#000',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 用户信息区域
|
// 用户信息区域
|
||||||
userInfoCard: {
|
userInfoCard: {
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
|||||||
92
components/PlanCard.tsx
Normal file
92
components/PlanCard.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
|
import React from 'react';
|
||||||
|
import { Image, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
type Level = '初学者' | '中级' | '高级';
|
||||||
|
|
||||||
|
type PlanCardProps = {
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
level: Level;
|
||||||
|
progress: number; // 0 - 1
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlanCard({ image, title, subtitle, level, progress }: PlanCardProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Image source={{ uri: image }} style={styles.image} />
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.badgeContainer}>
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{level}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||||
|
|
||||||
|
<View style={styles.progressWrapper}>
|
||||||
|
<ProgressBar progress={progress} showLabel={true} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 18,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 22,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: 18,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
badgeContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: -10,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
backgroundColor: '#192126',
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
borderBottomLeftRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#B1B6BD',
|
||||||
|
},
|
||||||
|
progressWrapper: {
|
||||||
|
marginTop: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
87
components/ProgressBar.tsx
Normal file
87
components/ProgressBar.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Animated, Easing, LayoutChangeEvent, StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
type ProgressBarProps = {
|
||||||
|
progress: number; // 0 - 1
|
||||||
|
height?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
trackColor?: string;
|
||||||
|
fillColor?: string;
|
||||||
|
animated?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProgressBar({
|
||||||
|
progress,
|
||||||
|
height = 18,
|
||||||
|
style,
|
||||||
|
trackColor = '#EDEDED',
|
||||||
|
fillColor = '#BBF246',
|
||||||
|
animated = true,
|
||||||
|
showLabel = true,
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
const [trackWidth, setTrackWidth] = useState(0);
|
||||||
|
const animatedValue = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const clamped = useMemo(() => {
|
||||||
|
if (Number.isNaN(progress)) return 0;
|
||||||
|
return Math.min(1, Math.max(0, progress));
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animated) {
|
||||||
|
animatedValue.setValue(clamped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Animated.timing(animatedValue, {
|
||||||
|
toValue: clamped,
|
||||||
|
duration: 650,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, [clamped, animated, animatedValue]);
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => {
|
||||||
|
setTrackWidth(e.nativeEvent.layout.width);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillWidth = Animated.multiply(animatedValue, trackWidth || 1);
|
||||||
|
const percent = Math.round(clamped * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { height }, style]} onLayout={onLayout}>
|
||||||
|
<View style={[styles.track, { backgroundColor: trackColor }]} />
|
||||||
|
<Animated.View style={[styles.fill, { width: fillWidth, backgroundColor: fillColor }]}>
|
||||||
|
{showLabel && (
|
||||||
|
<Text style={styles.label}>{percent}%</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 10,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -4,8 +4,8 @@ import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react
|
|||||||
|
|
||||||
interface WorkoutCardProps {
|
interface WorkoutCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
calories: number;
|
calories?: number;
|
||||||
duration: number;
|
duration?: number;
|
||||||
imageSource: any;
|
imageSource: any;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ export function WorkoutCard({ title, calories, duration, imageSource, onPress }:
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.container} onPress={onPress}>
|
<TouchableOpacity style={styles.container} onPress={onPress}>
|
||||||
<ImageBackground
|
<ImageBackground
|
||||||
source={imageSource}
|
source={{ uri: imageSource }}
|
||||||
style={styles.backgroundImage}
|
style={styles.backgroundImage}
|
||||||
imageStyle={styles.imageStyle}
|
imageStyle={styles.imageStyle}
|
||||||
>
|
>
|
||||||
@@ -31,10 +31,12 @@ export function WorkoutCard({ title, calories, duration, imageSource, onPress }:
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{duration !== undefined && (
|
||||||
<View style={styles.statItem}>
|
<View style={styles.statItem}>
|
||||||
<Ionicons name="time-outline" size={16} color="#fff" />
|
<Ionicons name="time-outline" size={16} color="#fff" />
|
||||||
<Text style={styles.statText}>{duration} Min</Text>
|
<Text style={styles.statText}>{duration} Min</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
10
constants/TabBar.ts
Normal file
10
constants/TabBar.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const TAB_BAR_HEIGHT = 68;
|
||||||
|
export const TAB_BAR_BOTTOM_OFFSET = 20;
|
||||||
|
|
||||||
|
// 为需要避让底部 TabBar 的页面提供一个统一的底部内边距计算
|
||||||
|
export const getTabBarBottomPadding = (measuredTabBarHeight?: number) => {
|
||||||
|
const height = Math.max(measuredTabBarHeight ?? 0, TAB_BAR_HEIGHT);
|
||||||
|
return height + TAB_BAR_BOTTOM_OFFSET;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
@@ -5242,6 +5243,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.13",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/dayjs/-/dayjs-1.11.13.tgz",
|
||||||
|
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
@@ -41,9 +42,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@types/react": "~19.0.10",
|
"@types/react": "~19.0.10",
|
||||||
"typescript": "~5.8.3",
|
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~9.2.0"
|
"eslint-config-expo": "~9.2.0",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
59
utils/date.ts
Normal file
59
utils/date.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回基于当前时间的中文问候语:早上好 / 下午好 / 晚上好
|
||||||
|
* - 早上:05:00 - 11:59
|
||||||
|
* - 下午:12:00 - 17:59
|
||||||
|
* - 晚上:18:00 - 04:59
|
||||||
|
*/
|
||||||
|
export function getChineseGreeting(now: Date = new Date()): string {
|
||||||
|
const hour = now.getHours();
|
||||||
|
|
||||||
|
if (hour >= 5 && hour < 12) {
|
||||||
|
return '早上好';
|
||||||
|
}
|
||||||
|
if (hour >= 12 && hour < 18) {
|
||||||
|
return '下午好';
|
||||||
|
}
|
||||||
|
return '晚上好';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取中文月份标题,例如:2025年8月 */
|
||||||
|
export function getMonthTitleZh(date: Dayjs = dayjs()): string {
|
||||||
|
return date.format('YYYY年M月');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthDay = {
|
||||||
|
/** 中文星期:日/一/二/三/四/五/六 */
|
||||||
|
weekdayZh: string;
|
||||||
|
/** 月内第几日(1-31) */
|
||||||
|
dayOfMonth: number;
|
||||||
|
/** 对应的 dayjs 对象 */
|
||||||
|
date: Dayjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 获取某月的所有日期(中文星期+日号) */
|
||||||
|
export function getMonthDaysZh(date: Dayjs = dayjs()): MonthDay[] {
|
||||||
|
const year = date.year();
|
||||||
|
const monthIndex = date.month();
|
||||||
|
const daysInMonth = date.daysInMonth();
|
||||||
|
const zhWeek = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
|
const d = dayjs(new Date(year, monthIndex, i + 1));
|
||||||
|
return {
|
||||||
|
weekdayZh: zhWeek[d.day()],
|
||||||
|
dayOfMonth: i + 1,
|
||||||
|
date: d,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取“今天”在当月的索引(0 基) */
|
||||||
|
export function getTodayIndexInMonth(date: Dayjs = dayjs()): number {
|
||||||
|
return date.date() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user