From 9796c614ed4ea6331576b64248647eb8b0c71029 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 12 Aug 2025 09:16:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E8=BF=9B=E5=BA=A6=E6=9D=A1=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在项目中引入 dayjs 库以处理日期 - 新增 PlanCard 和 ProgressBar 组件,分别用于展示训练计划和进度条 - 更新首页以显示推荐的训练计划 - 优化个人中心页面的底部留白处理 - 本地化界面文本为中文 --- app/(tabs)/_layout.tsx | 7 +- app/(tabs)/explore.tsx | 450 +++++++++++++++++++++++++++++-------- app/(tabs)/index.tsx | 57 +++-- app/(tabs)/personal.tsx | 49 ++-- components/PlanCard.tsx | 92 ++++++++ components/ProgressBar.tsx | 87 +++++++ components/WorkoutCard.tsx | 16 +- constants/TabBar.ts | 10 + package-lock.json | 7 + package.json | 5 +- utils/date.ts | 59 +++++ 11 files changed, 680 insertions(+), 159 deletions(-) create mode 100644 components/PlanCard.tsx create mode 100644 components/ProgressBar.tsx create mode 100644 constants/TabBar.ts create mode 100644 utils/date.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 189f693..14744cf 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { IconSymbol } from '@/components/ui/IconSymbol'; +import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar'; export default function TabLayout() { const pathname = usePathname(); @@ -51,8 +52,8 @@ export default function TabLayout() { }, tabBarStyle: { position: 'absolute', - bottom: 20, - height: 68, + bottom: TAB_BAR_BOTTOM_OFFSET, + height: TAB_BAR_HEIGHT, borderRadius: 34, backgroundColor: '#192126', shadowColor: '#000', @@ -69,7 +70,7 @@ export default function TabLayout() { }, tabBarItemStyle: { backgroundColor: 'transparent', - height: 68, + height: TAB_BAR_HEIGHT, marginTop: 0, marginBottom: 0, paddingTop: 0, diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index d4fbcaa..9dadf87 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -1,110 +1,362 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; +import { ProgressBar } from '@/components/ProgressBar'; +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'; -import { ExternalLink } from '@/components/ExternalLink'; -import ParallaxScrollView from '@/components/ParallaxScrollView'; -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { IconSymbol } from '@/components/ui/IconSymbol'; +export default function ExploreScreen() { + // 使用 dayjs:当月日期与默认选中“今天” + const days = getMonthDaysZh(); + const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); + const tabBarHeight = useBottomTabBarHeight(); + const insets = useSafeAreaInsets(); + const bottomPadding = useMemo(() => { + return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); + }, [tabBarHeight, insets?.bottom]); + + const monthTitle = getMonthTitleZh(); + + // 日期条自动滚动到选中项 + const daysScrollRef = useRef(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 ( - - }> - - Explore - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - Open app/_layout.tsx to see how to load{' '} - - custom fonts such as this one. - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful react-native-reanimated{' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - + + + + {/* 标题与日期选择 */} + {monthTitle} + setScrollWidth(e.nativeEvent.layout.width)} + > + {days.map((d, i) => { + const selected = i === selectedIndex; + return ( + + { + setSelectedIndex(i); + scrollToIndex(i); + }} + activeOpacity={0.8} + > + {d.weekdayZh} + {d.dayOfMonth} + + {selected && } + + ); + })} + + + {/* 今日报告 标题 */} + 今日报告 + + {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */} + + + 训练时间 + + + + 80% + + + + + 消耗卡路里 + 645 千卡 + + + + + 步数 + + 999/2000 + + + + + + + ); } +const primary = Colors.light.primary; + const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', + container: { + flex: 1, + backgroundColor: '#F6F7F8', }, - 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', - 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, + } }); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9ad8d4f..0415a44 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,31 +1,24 @@ +import { PlanCard } from '@/components/PlanCard'; import { SearchBox } from '@/components/SearchBox'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; import { WorkoutCard } from '@/components/WorkoutCard'; +import { getChineseGreeting } from '@/utils/date'; import React from 'react'; import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native'; const workoutData = [ { id: 1, - title: '体态评估', - duration: 50, - imageSource: require('@/assets/images/react-logo.png'), + title: 'AI体态评估', + duration: 5, + imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png', }, { id: 2, - title: 'Hand\nTraining', - calories: 600, - duration: 40, + title: '认证教练', 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() { @@ -35,16 +28,16 @@ export default function HomeScreen() { {/* Header Section */} - Good Morning 🔥 - Pramuditya Uzumaki + {getChineseGreeting()} 🔥 + 新学员,欢迎你 {/* Search Box */} - + {/* Popular Workouts Section */} - Popular Workouts + 热门活动 console.log(`Pressed ${workout.title}`)} @@ -65,6 +57,30 @@ export default function HomeScreen() { + {/* Today Plan Section */} + + 为你推荐 + + + + + + + + + {/* Add some spacing at the bottom */} @@ -109,6 +125,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, marginBottom: 18, }, + planList: { + paddingHorizontal: 24, + }, workoutScroll: { paddingLeft: 24, }, diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 39431e8..d36f2e8 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,7 +1,9 @@ import { Colors } from '@/constants/Colors'; +import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useColorScheme } from '@/hooks/useColorScheme'; 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 { SafeAreaView, ScrollView, @@ -12,18 +14,19 @@ import { TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; - const ProfileHeader = () => ( - - {/* 标题 */} - Profile - - ); const UserInfoSection = () => ( @@ -47,10 +50,10 @@ export default function PersonalScreen() { Lose a Fat Program - {/* 编辑按钮 */} - - Edit - + {/* 编辑按钮 */} + + Edit + ); @@ -198,8 +201,11 @@ export default function PersonalScreen() { - - + @@ -207,7 +213,7 @@ export default function PersonalScreen() { {/* 底部浮动按钮 */} - + @@ -231,21 +237,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, backgroundColor: '#F5F5F5', }, - // 头部导航 - headerContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingTop: 10, - paddingBottom: 20, - }, - - headerTitle: { - fontSize: 20, - fontWeight: 'bold', - color: '#000', - }, - // 用户信息区域 userInfoCard: { borderRadius: 16, diff --git a/components/PlanCard.tsx b/components/PlanCard.tsx new file mode 100644 index 0000000..1d208aa --- /dev/null +++ b/components/PlanCard.tsx @@ -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 ( + + + + + + {level} + + + + {title} + {subtitle} + + + + + + + ); +} + +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, + }, +}); + + diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx new file mode 100644 index 0000000..ab9346e --- /dev/null +++ b/components/ProgressBar.tsx @@ -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 ( + + + + {showLabel && ( + {percent}% + )} + + + ); +} + +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, + }, +}); + + diff --git a/components/WorkoutCard.tsx b/components/WorkoutCard.tsx index 36db43d..539c3f9 100644 --- a/components/WorkoutCard.tsx +++ b/components/WorkoutCard.tsx @@ -4,8 +4,8 @@ import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react interface WorkoutCardProps { title: string; - calories: number; - duration: number; + calories?: number; + duration?: number; imageSource: any; onPress?: () => void; } @@ -14,7 +14,7 @@ export function WorkoutCard({ title, calories, duration, imageSource, onPress }: return ( @@ -31,10 +31,12 @@ export function WorkoutCard({ title, calories, duration, imageSource, onPress }: )} - - - {duration} Min - + {duration !== undefined && ( + + + {duration} Min + + )} diff --git a/constants/TabBar.ts b/constants/TabBar.ts new file mode 100644 index 0000000..134f962 --- /dev/null +++ b/constants/TabBar.ts @@ -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; +}; + + diff --git a/package-lock.json b/package-lock.json index 04b257a..445324d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "dayjs": "^1.11.13", "expo": "~53.0.20", "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", @@ -5242,6 +5243,12 @@ "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": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/package.json b/package.json index bb6002f..b5d97a2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "dayjs": "^1.11.13", "expo": "~53.0.20", "expo-blur": "~14.1.5", "expo-constants": "~17.1.7", @@ -41,9 +42,9 @@ "devDependencies": { "@babel/core": "^7.25.2", "@types/react": "~19.0.10", - "typescript": "~5.8.3", "eslint": "^9.25.0", - "eslint-config-expo": "~9.2.0" + "eslint-config-expo": "~9.2.0", + "typescript": "~5.8.3" }, "private": true } diff --git a/utils/date.ts b/utils/date.ts new file mode 100644 index 0000000..afad423 --- /dev/null +++ b/utils/date.ts @@ -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; +} + +