From 72e75b602ee4b3b27edcb63a36def34d27c55c25 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 21 Aug 2025 17:59:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=BF=83=E6=83=85?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=E5=92=8C=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整启动画面中的图片宽度,提升视觉效果 - 移除引导页面相关组件,简化应用结构 - 新增心情统计页面,支持用户查看和分析心情数据 - 优化心情卡片组件,增强用户交互体验 - 更新登录页面标题,提升品牌一致性 - 新增心情日历和编辑功能,支持用户记录和管理心情 --- app.json | 2 +- app/(tabs)/statistics.tsx | 121 ++---- app/_layout.tsx | 1 - app/auth/login.tsx | 5 +- app/mood-statistics.tsx | 287 ++++++++++++++ app/mood/calendar.tsx | 586 ++++++++++++++++++++++++++++ app/mood/edit.tsx | 444 +++++++++++++++++++++ app/onboarding/_layout.tsx | 10 - app/onboarding/index.tsx | 234 ----------- app/onboarding/personal-info.tsx | 426 -------------------- components/MoodCard.tsx | 137 ++++--- components/MoodHistoryCard.tsx | 199 ++++++++++ components/MoodModal.tsx | 422 -------------------- components/PrivacyConsentModal.tsx | 8 +- constants/Routes.ts | 4 - docs/mood-checkin-implementation.md | 196 ++++++++++ docs/mood-checkin-test.md | 127 ++++++ docs/mood-modal-optimization.md | 205 ++++++++++ docs/mood-redux-migration.md | 152 ++++++++ hooks/useMoodData.ts | 167 ++++++++ services/moodCheckins.ts | 141 +++++++ store/index.ts | 2 + store/moodSlice.ts | 324 +++++++++++++++ store/userSlice.ts | 2 +- 24 files changed, 2964 insertions(+), 1238 deletions(-) create mode 100644 app/mood-statistics.tsx create mode 100644 app/mood/calendar.tsx create mode 100644 app/mood/edit.tsx delete mode 100644 app/onboarding/_layout.tsx delete mode 100644 app/onboarding/index.tsx delete mode 100644 app/onboarding/personal-info.tsx create mode 100644 components/MoodHistoryCard.tsx delete mode 100644 components/MoodModal.tsx create mode 100644 docs/mood-checkin-implementation.md create mode 100644 docs/mood-checkin-test.md create mode 100644 docs/mood-modal-optimization.md create mode 100644 docs/mood-redux-migration.md create mode 100644 hooks/useMoodData.ts create mode 100644 services/moodCheckins.ts create mode 100644 store/moodSlice.ts diff --git a/app.json b/app.json index fa5fa0f..bbcbfef 100644 --- a/app.json +++ b/app.json @@ -38,7 +38,7 @@ "expo-splash-screen", { "image": "./assets/images/Sealife.jpeg", - "imageWidth": 200, + "imageWidth": 40, "resizeMode": "contain", "backgroundColor": "#ffffff" } diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index ea3016f..144aecb 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -1,23 +1,25 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; import { BMICard } from '@/components/BMICard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard'; -import { MoodModal } from '@/components/MoodModal'; +import { MoodCard } from '@/components/MoodCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { ProgressBar } from '@/components/ProgressBar'; import { StressMeter } from '@/components/StressMeter'; import { WeightHistoryCard } from '@/components/WeightHistoryCard'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; -import { useAppSelector } from '@/hooks/redux'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords'; +import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; +import { router } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Animated, @@ -163,29 +165,45 @@ export default function ExploreScreen() { const [isNutritionLoading, setIsNutritionLoading] = useState(false); // 心情相关状态 - const [moodModalVisible, setMoodModalVisible] = useState(false); - const [moodRecords, setMoodRecords] = useState>([]); + const dispatch = useAppDispatch(); + const [isMoodLoading, setIsMoodLoading] = useState(false); + + // 从 Redux 获取当前日期的心情记录 + const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate( + days[selectedIndex]?.date?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD') + )); // 记录最近一次请求的"日期键",避免旧请求覆盖新结果 const latestRequestKeyRef = useRef(null); const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; - // 心情保存处理函数 - const handleMoodSave = (mood: string, time: string) => { - const today = new Date(); - const dateString = `${today.getFullYear()}年${today.getMonth() + 1}月${today.getDate()}日`; + // 加载心情数据 + const loadMoodData = async (targetDate?: Date) => { + if (!isLoggedIn) return; - const newRecord = { - mood, - date: dateString, - time - }; + try { + setIsMoodLoading(true); - setMoodRecords(prev => [newRecord, ...prev]); - setMoodModalVisible(false); + // 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期 + let derivedDate: Date; + if (targetDate) { + derivedDate = targetDate; + } else { + derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); + } + + const dateString = dayjs(derivedDate).format('YYYY-MM-DD'); + await dispatch(fetchDailyMoodCheckins(dateString)); + } catch (error) { + console.error('加载心情数据失败:', error); + } finally { + setIsMoodLoading(false); + } }; + + const loadHealthData = async (targetDate?: Date) => { try { console.log('=== 开始HealthKit初始化流程 ==='); @@ -290,6 +308,7 @@ export default function ExploreScreen() { loadHealthData(currentDate); if (isLoggedIn) { loadNutritionData(currentDate); + loadMoodData(currentDate); } } }, [selectedIndex]) @@ -303,6 +322,7 @@ export default function ExploreScreen() { loadHealthData(target); if (isLoggedIn) { loadNutritionData(target); + loadMoodData(target); } } }; @@ -424,23 +444,11 @@ export default function ExploreScreen() { {/* 心情卡片 */} - setMoodModalVisible(true)} style={styles.moodCardContent}> - - - 😊 - - 心情 - - 记录你的每日心情 - {moodRecords.length > 0 ? ( - - 今日:{moodRecords[0].mood} - {moodRecords[0].time} - - ) : ( - 点击记录心情 - )} - + router.push('/mood/calendar')} + isLoading={isMoodLoading} + /> @@ -484,13 +492,6 @@ export default function ExploreScreen() { - - {/* 心情弹窗 */} - setMoodModalVisible(false)} - onSave={handleMoodSave} - /> ); } @@ -866,46 +867,4 @@ const styles = StyleSheet.create({ moodCard: { backgroundColor: '#F0FDF4', }, - moodCardContent: { - width: '100%', - }, - moodIconContainer: { - width: 24, - height: 24, - borderRadius: 8, - backgroundColor: '#DCFCE7', - alignItems: 'center', - justifyContent: 'center', - marginRight: 10, - }, - moodIcon: { - fontSize: 14, - }, - moodSubtitle: { - fontSize: 12, - color: '#6B7280', - marginTop: 4, - marginBottom: 8, - }, - moodPreview: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: 4, - }, - moodPreviewText: { - fontSize: 14, - color: '#059669', - fontWeight: '600', - }, - moodPreviewTime: { - fontSize: 12, - color: '#6B7280', - }, - moodEmptyText: { - fontSize: 12, - color: '#9CA3AF', - fontStyle: 'italic', - marginTop: 4, - }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index be2d090..1212616 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -79,7 +79,6 @@ export default function RootLayout() { - diff --git a/app/auth/login.tsx b/app/auth/login.tsx index edf32f9..beb41e3 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -14,7 +14,6 @@ import { useAppDispatch } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { login } from '@/store/userSlice'; import Toast from 'react-native-toast-message'; -import { ROUTES } from '@/constants/Routes'; export default function LoginScreen() { const router = useRouter(); @@ -238,8 +237,8 @@ export default function LoginScreen() { - 普拉提助手 - 欢迎登录普拉提星球 + Sealife + 欢迎登录Sealife {/* Apple 登录 */} diff --git a/app/mood-statistics.tsx b/app/mood-statistics.tsx new file mode 100644 index 0000000..81f0d77 --- /dev/null +++ b/app/mood-statistics.tsx @@ -0,0 +1,287 @@ +import { MoodHistoryCard } from '@/components/MoodHistoryCard'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { + fetchMoodHistory, + fetchMoodStatistics, + selectMoodLoading, + selectMoodRecords, + selectMoodStatistics +} from '@/store/moodSlice'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useEffect } from 'react'; +import { + ActivityIndicator, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; + +export default function MoodStatisticsScreen() { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const { isLoggedIn } = useAuthGuard(); + const dispatch = useAppDispatch(); + + // 从 Redux 获取数据 + const moodRecords = useAppSelector(selectMoodRecords); + const statistics = useAppSelector(selectMoodStatistics); + const loading = useAppSelector(selectMoodLoading); + + // 获取最近30天的心情数据 + const loadMoodData = async () => { + if (!isLoggedIn) return; + + try { + const endDate = dayjs().format('YYYY-MM-DD'); + const startDate = dayjs().subtract(30, 'days').format('YYYY-MM-DD'); + + // 并行加载历史记录和统计数据 + await Promise.all([ + dispatch(fetchMoodHistory({ startDate, endDate })), + dispatch(fetchMoodStatistics({ startDate, endDate })) + ]); + } catch (error) { + console.error('加载心情数据失败:', error); + } + }; + + useEffect(() => { + loadMoodData(); + }, [isLoggedIn, dispatch]); + + // 将 moodRecords 转换为数组格式 + const moodCheckins = Object.values(moodRecords).flat(); + + // 使用统一的渐变背景色 + const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const; + + if (!isLoggedIn) { + return ( + + + + + 请先登录查看心情统计 + + + + ); + } + + return ( + + + + + 心情统计 + + {loading.history || loading.statistics ? ( + + + 加载中... + + ) : ( + <> + {/* 统计概览 */} + {statistics && ( + + 统计概览 + + + {statistics.totalCheckins} + 总打卡次数 + + + {statistics.averageIntensity.toFixed(1)} + 平均强度 + + + + {statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0} + + 最常见心情 + + + + )} + + {/* 心情历史记录 */} + + + {/* 心情分布 */} + {statistics && ( + + 心情分布 + + {Object.entries(statistics.moodDistribution) + .sort(([, a], [, b]) => b - a) + .map(([moodType, count]) => ( + + {moodType} + + {count} + + ({((count / statistics.totalCheckins) * 100).toFixed(1)}%) + + + + ))} + + + )} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + safeArea: { + flex: 1, + }, + scrollView: { + flex: 1, + paddingHorizontal: 20, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loginPrompt: { + fontSize: 16, + color: '#666', + textAlign: 'center', + }, + title: { + fontSize: 28, + fontWeight: '800', + color: '#192126', + marginTop: 24, + marginBottom: 24, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + }, + loadingText: { + fontSize: 16, + color: '#666', + marginTop: 16, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + color: '#192126', + marginBottom: 16, + }, + statsOverview: { + marginBottom: 24, + }, + statsGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + statCard: { + flex: 1, + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + statNumber: { + fontSize: 24, + fontWeight: '800', + color: '#192126', + marginBottom: 8, + }, + statLabel: { + fontSize: 14, + color: '#6B7280', + textAlign: 'center', + }, + distributionContainer: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 24, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + distributionList: { + gap: 12, + }, + distributionItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + }, + moodType: { + fontSize: 16, + fontWeight: '500', + color: '#192126', + }, + countContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + count: { + fontSize: 16, + fontWeight: '600', + color: '#192126', + }, + percentage: { + fontSize: 14, + color: '#6B7280', + }, +}); diff --git a/app/mood/calendar.tsx b/app/mood/calendar.tsx new file mode 100644 index 0000000..ab96df6 --- /dev/null +++ b/app/mood/calendar.tsx @@ -0,0 +1,586 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { useMoodData } from '@/hooks/useMoodData'; +import { getMoodOptions } from '@/services/moodCheckins'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Dimensions, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +const { width } = Dimensions.get('window'); + +// 心情日历数据生成函数 +const generateCalendarData = (targetDate: Date) => { + const year = targetDate.getFullYear(); + const month = targetDate.getMonth(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const firstDayOfWeek = new Date(year, month, 1).getDay(); + + const calendar = []; + const weeks = []; + + // 添加空白日期 + for (let i = 0; i < firstDayOfWeek; i++) { + weeks.push(null); + } + + // 添加实际日期 + for (let day = 1; day <= daysInMonth; day++) { + weeks.push(day); + } + + // 按周分组 + for (let i = 0; i < weeks.length; i += 7) { + calendar.push(weeks.slice(i, i + 7)); + } + + return { calendar, today: new Date().getDate(), month: month + 1, year }; +}; + +export default function MoodCalendarScreen() { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const params = useLocalSearchParams(); + const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData(); + + const { selectedDate } = params; + const initialDate = selectedDate ? dayjs(selectedDate as string).toDate() : new Date(); + + const [currentMonth, setCurrentMonth] = useState(initialDate); + const [selectedDay, setSelectedDay] = useState(null); + const [selectedDateMood, setSelectedDateMood] = useState(null); + const [moodRecords, setMoodRecords] = useState>({}); + + const moodOptions = getMoodOptions(); + const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + + // 生成当前月份的日历数据 + const { calendar, today, month, year } = generateCalendarData(currentMonth); + + // 初始化选中日期 + useEffect(() => { + if (selectedDate) { + const date = dayjs(selectedDate as string); + setCurrentMonth(date.toDate()); + setSelectedDay(date.date()); + const dateString = date.format('YYYY-MM-DD'); + loadDailyMoodCheckins(dateString); + } else { + const today = new Date(); + setCurrentMonth(today); + setSelectedDay(today.getDate()); + const dateString = dayjs().format('YYYY-MM-DD'); + loadDailyMoodCheckins(dateString); + } + loadMonthMoodData(currentMonth); + }, [selectedDate]); + + // 加载整个月份的心情数据 + const loadMonthMoodData = async (targetMonth: Date) => { + try { + const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD'); + const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD'); + + const historyData = await fetchMoodHistoryRecords({ startDate, endDate }); + + // 将历史记录按日期分组 + const monthData: Record = {}; + historyData.forEach(checkin => { + const date = checkin.checkinDate; + if (!monthData[date]) { + monthData[date] = []; + } + monthData[date].push(checkin); + }); + + setMoodRecords(monthData); + } catch (error) { + console.error('加载月份心情数据失败:', error); + } + }; + + // 加载选中日期的心情记录 + const loadDailyMoodCheckins = async (dateString: string) => { + try { + const checkins = await fetchMoodRecords(dateString); + if (checkins.length > 0) { + setSelectedDateMood(checkins[0]); // 取最新的记录 + } else { + setSelectedDateMood(null); + } + } catch (error) { + console.error('加载心情记录失败:', error); + setSelectedDateMood(null); + } + }; + + // 月份切换函数 + const goToPreviousMonth = () => { + const newMonth = new Date(currentMonth); + newMonth.setMonth(newMonth.getMonth() - 1); + setCurrentMonth(newMonth); + setSelectedDay(null); + loadMonthMoodData(newMonth); + }; + + const goToNextMonth = () => { + const newMonth = new Date(currentMonth); + newMonth.setMonth(newMonth.getMonth() + 1); + setCurrentMonth(newMonth); + setSelectedDay(null); + loadMonthMoodData(newMonth); + }; + + // 日期选择函数 + const onSelectDate = (day: number) => { + setSelectedDay(day); + const selectedDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD'); + loadDailyMoodCheckins(selectedDateString); + }; + + // 跳转到心情编辑页面 + const openMoodEdit = () => { + const selectedDateString = selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'); + const moodId = selectedDateMood?.id; + + router.push({ + pathname: '/mood/edit', + params: { + date: selectedDateString, + ...(moodId && { moodId }) + } + }); + }; + + const renderMoodIcon = (day: number | null, isSelected: boolean) => { + if (!day) return null; + + // 检查该日期是否有心情记录 + const dayDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD'); + const dayRecords = moodRecords[dayDateString] || []; + const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null; + + if (moodRecord) { + const mood = moodOptions.find(m => m.type === moodRecord.moodType); + return ( + + + {mood?.emoji || '😊'} + + + ); + } + + return ( + + 😊 + + ); + }; + + // 使用统一的渐变背景色 + const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const; + + return ( + + + + + router.back()}> + + + 心情日历 + + + + + {/* 日历视图 */} + + {/* 月份导航 */} + + + + + {year}年{monthNames[month - 1]} + + + + + + + {weekDays.map((day, index) => ( + {day} + ))} + + + {calendar.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const isSelected = day === selectedDay; + const isToday = day === today && month === new Date().getMonth() + 1 && year === new Date().getFullYear(); + const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day')); + + return ( + + {day && ( + !isFutureDate && day && onSelectDate(day)} + disabled={isFutureDate} + > + + + {day.toString().padStart(2, '0')} + + {renderMoodIcon(day, isSelected)} + + + )} + + ); + })} + + ))} + + + {/* 选中日期的记录 */} + + + + {selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'} + + + 记录 + + + + {selectedDay ? ( + selectedDateMood ? ( + + + + + {moodOptions.find(m => m.type === selectedDateMood.moodType)?.emoji || '😊'} + + + + + + {moodOptions.find(m => m.type === selectedDateMood.moodType)?.label} + + 强度: {selectedDateMood.intensity} + {selectedDateMood.description && ( + {selectedDateMood.description} + )} + + + + {dayjs(selectedDateMood.createdAt).format('HH:mm')} + + + ) : ( + + 暂无心情记录 + 点击右上角"记录"按钮添加心情 + + ) + ) : ( + + 请先选择一个日期 + 点击日历中的日期,然后点击"记录"按钮添加心情 + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + safeArea: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + }, + backButton: { + fontSize: 24, + color: '#666', + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#333', + flex: 1, + textAlign: 'center', + }, + headerSpacer: { + width: 24, + }, + content: { + flex: 1, + }, + calendar: { + backgroundColor: '#fff', + margin: 16, + borderRadius: 16, + padding: 16, + }, + monthNavigation: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + navButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#f8f9fa', + justifyContent: 'center', + alignItems: 'center', + }, + navButtonText: { + fontSize: 20, + color: '#333', + fontWeight: '600', + }, + monthTitle: { + fontSize: 18, + fontWeight: '700', + color: '#192126', + }, + weekHeader: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 16, + }, + weekDay: { + fontSize: 14, + color: '#666', + textAlign: 'center', + width: (width - 96) / 7, + }, + weekRow: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 16, + }, + dayContainer: { + width: (width - 96) / 7, + alignItems: 'center', + }, + dayButton: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 8, + }, + dayButtonSelected: { + backgroundColor: '#4CAF50', + }, + dayButtonToday: { + borderWidth: 2, + borderColor: '#4CAF50', + }, + dayContent: { + position: 'relative', + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + dayNumber: { + fontSize: 14, + color: '#999', + fontWeight: '500', + position: 'absolute', + top: 2, + zIndex: 1, + }, + dayNumberSelected: { + color: '#FFFFFF', + fontWeight: '600', + }, + dayNumberToday: { + color: '#4CAF50', + fontWeight: '600', + }, + dayNumberDisabled: { + color: '#ccc', + }, + moodIconContainer: { + position: 'absolute', + bottom: 2, + width: 20, + height: 20, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + moodIcon: { + width: 16, + height: 16, + borderRadius: 8, + backgroundColor: 'rgba(255,255,255,0.9)', + justifyContent: 'center', + alignItems: 'center', + }, + moodEmoji: { + fontSize: 12, + }, + defaultMoodIcon: { + position: 'absolute', + bottom: 2, + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 1, + borderColor: '#ddd', + borderStyle: 'dashed', + justifyContent: 'center', + alignItems: 'center', + }, + defaultMoodEmoji: { + fontSize: 10, + opacity: 0.3, + }, + selectedDateSection: { + backgroundColor: '#fff', + margin: 16, + marginTop: 0, + borderRadius: 16, + padding: 16, + }, + selectedDateHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + selectedDateTitle: { + fontSize: 20, + fontWeight: '700', + color: '#192126', + }, + addMoodButton: { + paddingHorizontal: 16, + height: 32, + borderRadius: 16, + backgroundColor: '#4CAF50', + justifyContent: 'center', + alignItems: 'center', + }, + addMoodButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + moodRecord: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 12, + }, + recordIcon: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#4CAF50', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + recordContent: { + flex: 1, + }, + recordMood: { + fontSize: 16, + color: '#333', + fontWeight: '500', + }, + recordIntensity: { + fontSize: 14, + color: '#666', + marginTop: 2, + }, + recordDescription: { + fontSize: 14, + color: '#666', + marginTop: 4, + fontStyle: 'italic', + }, + spacer: { + flex: 1, + }, + recordTime: { + fontSize: 14, + color: '#999', + }, + emptyRecord: { + alignItems: 'center', + paddingVertical: 20, + }, + emptyRecordText: { + fontSize: 16, + color: '#666', + marginBottom: 8, + }, + emptyRecordSubtext: { + fontSize: 12, + color: '#999', + }, + +}); diff --git a/app/mood/edit.tsx b/app/mood/edit.tsx new file mode 100644 index 0000000..2548bce --- /dev/null +++ b/app/mood/edit.tsx @@ -0,0 +1,444 @@ +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { getMoodOptions, MoodType } from '@/services/moodCheckins'; +import { + createMoodRecord, + deleteMoodRecord, + fetchDailyMoodCheckins, + selectMoodRecordsByDate, + updateMoodRecord +} from '@/store/moodSlice'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Alert, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +export default function MoodEditScreen() { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + const params = useLocalSearchParams(); + const dispatch = useAppDispatch(); + + const { date, moodId } = params; + const selectedDate = date as string || dayjs().format('YYYY-MM-DD'); + + const [selectedMood, setSelectedMood] = useState(''); + const [intensity, setIntensity] = useState(5); + const [description, setDescription] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [existingMood, setExistingMood] = useState(null); + + const moodOptions = getMoodOptions(); + + // 从 Redux 获取数据 + const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate)); + const loading = useAppSelector(state => state.mood.loading); + + // 初始化数据 + useEffect(() => { + // 加载当前日期的心情记录 + dispatch(fetchDailyMoodCheckins(selectedDate)); + }, [selectedDate, dispatch]); + + // 当 moodRecords 更新时,查找现有记录 + useEffect(() => { + if (moodId && moodRecords.length > 0) { + const mood = moodRecords.find((c: any) => c.id === moodId) || moodRecords[0]; + setExistingMood(mood); + setSelectedMood(mood.moodType); + setIntensity(mood.intensity); + setDescription(mood.description || ''); + } + }, [moodId, moodRecords]); + + const handleSave = async () => { + if (!selectedMood) { + Alert.alert('提示', '请选择心情'); + return; + } + + try { + setIsLoading(true); + + if (existingMood) { + // 更新现有记录 + await dispatch(updateMoodRecord({ + id: existingMood.id, + moodType: selectedMood, + intensity, + description: description.trim() || undefined, + })).unwrap(); + } else { + // 创建新记录 + await dispatch(createMoodRecord({ + moodType: selectedMood, + intensity, + description: description.trim() || undefined, + checkinDate: selectedDate, + })).unwrap(); + } + + Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [ + { text: '确定', onPress: () => router.back() } + ]); + } catch (error) { + console.error('保存心情失败:', error); + Alert.alert('错误', '保存心情失败,请重试'); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async () => { + if (!existingMood) return; + + Alert.alert( + '确认删除', + '确定要删除这条心情记录吗?', + [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + setIsDeleting(true); + await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap(); + + Alert.alert('成功', '心情记录已删除', [ + { text: '确定', onPress: () => router.back() } + ]); + } catch (error) { + console.error('删除心情失败:', error); + Alert.alert('错误', '删除心情失败,请重试'); + } finally { + setIsDeleting(false); + } + }, + }, + ] + ); + }; + + const renderIntensitySlider = () => { + return ( + + 心情强度: {intensity} + + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => ( + = level && styles.intensityDotActive + ]} + onPress={() => setIntensity(level)} + /> + ))} + + + 轻微 + 强烈 + + + ); + }; + + // 使用统一的渐变背景色 + const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const; + + return ( + + + + + router.back()}> + + + + {existingMood ? '编辑心情' : '记录心情'} + + + + + + {/* 日期显示 */} + + + {dayjs(selectedDate).format('YYYY年M月D日')} + + + + {/* 心情选择 */} + + 选择心情 + + {moodOptions.map((mood, index) => ( + setSelectedMood(mood.type)} + > + {mood.emoji} + {mood.label} + + ))} + + + + {/* 心情强度选择 */} + {selectedMood && ( + + 心情强度 + {renderIntensitySlider()} + + )} + + {/* 心情描述 */} + {selectedMood && ( + + 心情描述(可选) + + {description.length}/200 + + )} + + + {/* 底部按钮 */} + + {existingMood && ( + + + {isDeleting ? '删除中...' : '删除记录'} + + + )} + + + {isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + safeArea: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + }, + backButton: { + fontSize: 24, + color: '#666', + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#333', + flex: 1, + textAlign: 'center', + }, + headerSpacer: { + width: 24, + }, + content: { + flex: 1, + }, + dateSection: { + backgroundColor: '#fff', + margin: 16, + borderRadius: 16, + padding: 16, + alignItems: 'center', + }, + dateTitle: { + fontSize: 24, + fontWeight: '700', + color: '#192126', + }, + moodSection: { + backgroundColor: '#fff', + margin: 16, + marginTop: 0, + borderRadius: 16, + padding: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginBottom: 16, + }, + moodOptions: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + moodOption: { + width: '30%', + alignItems: 'center', + paddingVertical: 16, + marginBottom: 16, + borderRadius: 12, + backgroundColor: '#f8f8f8', + }, + selectedMoodOption: { + backgroundColor: '#e8f5e8', + borderWidth: 2, + borderColor: '#4CAF50', + }, + moodEmoji: { + fontSize: 24, + marginBottom: 8, + }, + moodLabel: { + fontSize: 14, + color: '#333', + }, + intensitySection: { + backgroundColor: '#fff', + margin: 16, + marginTop: 0, + borderRadius: 16, + padding: 16, + }, + intensityContainer: { + alignItems: 'center', + }, + intensityLabel: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 12, + }, + intensitySlider: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + marginBottom: 8, + }, + intensityDot: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: '#ddd', + }, + intensityDotActive: { + backgroundColor: '#4CAF50', + }, + intensityLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + }, + intensityLabelText: { + fontSize: 12, + color: '#666', + }, + descriptionSection: { + backgroundColor: '#fff', + margin: 16, + marginTop: 0, + borderRadius: 16, + padding: 16, + }, + descriptionInput: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + fontSize: 16, + minHeight: 80, + textAlignVertical: 'top', + }, + characterCount: { + fontSize: 12, + color: '#999', + textAlign: 'right', + marginTop: 4, + }, + footer: { + padding: 16, + backgroundColor: '#fff', + }, + saveButton: { + backgroundColor: '#4CAF50', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + marginTop: 8, + }, + deleteButton: { + backgroundColor: '#F44336', + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + }, + disabledButton: { + backgroundColor: '#ccc', + }, + saveButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + deleteButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/app/onboarding/_layout.tsx b/app/onboarding/_layout.tsx deleted file mode 100644 index 74ab6f6..0000000 --- a/app/onboarding/_layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Stack } from 'expo-router'; - -export default function OnboardingLayout() { - return ( - - - - - ); -} diff --git a/app/onboarding/index.tsx b/app/onboarding/index.tsx deleted file mode 100644 index 2d5971a..0000000 --- a/app/onboarding/index.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { ROUTES } from '@/constants/Routes'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { useThemeColor } from '@/hooks/useThemeColor'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { router } from 'expo-router'; -import React from 'react'; -import { - Dimensions, - StatusBar, - StyleSheet, - Text, - TouchableOpacity, - View -} from 'react-native'; - -const { width, height } = Dimensions.get('window'); - -export default function WelcomeScreen() { - const colorScheme = useColorScheme(); - const backgroundColor = useThemeColor({}, 'background'); - const primaryColor = useThemeColor({}, 'primary'); - const textColor = useThemeColor({}, 'text'); - - const handleGetStarted = () => { - router.push(ROUTES.ONBOARDING_PERSONAL_INFO); - }; - - const handleSkip = async () => { - try { - await AsyncStorage.setItem('@onboarding_completed', 'true'); - router.replace(ROUTES.TAB_COACH); - } catch (error) { - console.error('保存引导状态失败:', error); - router.replace(ROUTES.TAB_COACH); - } - }; - - return ( - - - - {/* 跳过按钮 */} - - - 跳过 - - - - {/* 主要内容区域 */} - - {/* Logo 或插图区域 */} - - - 🧘‍♀️ - - - - {/* 标题和描述 */} - - - 欢迎来到数字普拉提 - - - 让我们一起开始您的健康之旅{'\n'} - 个性化的普拉提体验正等着您 - - - - {/* 特色功能点 */} - - {[ - { icon: '📊', title: '个性化训练', desc: '根据您的身体状况定制训练计划' }, - { icon: '🤖', title: 'AI 姿态分析', desc: '实时纠正您的动作姿态' }, - { icon: '📈', title: '进度追踪', desc: '记录您的每一次进步' }, - ].map((feature, index) => ( - - {feature.icon} - - - {feature.title} - - - {feature.desc} - - - - ))} - - - - {/* 底部按钮 */} - - - 开始体验 - - - - - 稍后再说 - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingTop: StatusBar.currentHeight || 44, - }, - skipButton: { - position: 'absolute', - top: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 60, - right: 20, - zIndex: 10, - padding: 8, - }, - skipText: { - fontSize: 16, - fontWeight: '500', - }, - contentContainer: { - flex: 1, - paddingHorizontal: 24, - justifyContent: 'center', - }, - imageContainer: { - alignItems: 'center', - marginBottom: 40, - }, - logoPlaceholder: { - width: 120, - height: 120, - borderRadius: 60, - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 8, - }, - logoText: { - fontSize: 48, - }, - textContainer: { - alignItems: 'center', - marginBottom: 48, - }, - title: { - textAlign: 'center', - marginBottom: 16, - fontWeight: '700', - }, - subtitle: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - paddingHorizontal: 12, - }, - featuresContainer: { - marginBottom: 40, - }, - featureItem: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 24, - paddingHorizontal: 8, - }, - featureIcon: { - fontSize: 32, - marginRight: 16, - width: 40, - textAlign: 'center', - }, - featureTextContainer: { - flex: 1, - }, - featureTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 4, - }, - featureDesc: { - fontSize: 14, - lineHeight: 20, - }, - buttonContainer: { - paddingHorizontal: 24, - paddingBottom: 48, - }, - getStartedButton: { - height: 56, - borderRadius: 16, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 4, - }, - getStartedButtonText: { - color: '#192126', - fontSize: 18, - fontWeight: '600', - }, - laterButton: { - height: 48, - justifyContent: 'center', - alignItems: 'center', - }, - laterButtonText: { - fontSize: 16, - fontWeight: '500', - }, -}); diff --git a/app/onboarding/personal-info.tsx b/app/onboarding/personal-info.tsx deleted file mode 100644 index 9b5e726..0000000 --- a/app/onboarding/personal-info.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { useColorScheme } from '@/hooks/useColorScheme'; -import { useThemeColor } from '@/hooks/useThemeColor'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { router } from 'expo-router'; -import React, { useState } from 'react'; -import { - Alert, - Dimensions, - ScrollView, - StatusBar, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; - -const { width } = Dimensions.get('window'); - -interface PersonalInfo { - gender: 'male' | 'female' | ''; - age: string; - height: string; - weight: string; -} - -export default function PersonalInfoScreen() { - const colorScheme = useColorScheme(); - const backgroundColor = useThemeColor({}, 'background'); - const primaryColor = useThemeColor({}, 'primary'); - const textColor = useThemeColor({}, 'text'); - const iconColor = useThemeColor({}, 'icon'); - - const [personalInfo, setPersonalInfo] = useState({ - gender: '', - age: '', - height: '', - weight: '', - }); - - const [currentStep, setCurrentStep] = useState(0); - - const steps = [ - { - title: '请选择您的性别', - subtitle: '这将帮助我们为您制定更合适的训练计划', - type: 'gender' as const, - }, - { - title: '请输入您的年龄', - subtitle: '年龄信息有助于调整训练强度', - type: 'age' as const, - }, - { - title: '请输入您的身高', - subtitle: '身高信息用于计算身体比例', - type: 'height' as const, - }, - { - title: '请输入您的体重', - subtitle: '体重信息用于个性化训练方案', - type: 'weight' as const, - }, - ]; - - const handleGenderSelect = (gender: 'male' | 'female') => { - setPersonalInfo(prev => ({ ...prev, gender })); - }; - - const handleInputChange = (field: keyof PersonalInfo, value: string) => { - setPersonalInfo(prev => ({ ...prev, [field]: value })); - }; - - const handleNext = () => { - const currentStepType = steps[currentStep].type; - - // 验证当前步骤是否已填写 - if (currentStepType === 'gender' && !personalInfo.gender) { - Alert.alert('提示', '请选择您的性别'); - return; - } - if (currentStepType === 'age' && !personalInfo.age) { - Alert.alert('提示', '请输入您的年龄'); - return; - } - if (currentStepType === 'height' && !personalInfo.height) { - Alert.alert('提示', '请输入您的身高'); - return; - } - if (currentStepType === 'weight' && !personalInfo.weight) { - Alert.alert('提示', '请输入您的体重'); - return; - } - - if (currentStep < steps.length - 1) { - setCurrentStep(currentStep + 1); - } else { - handleComplete(); - } - }; - - const handlePrevious = () => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); - } - }; - - const handleSkip = async () => { - try { - await AsyncStorage.setItem('@onboarding_completed', 'true'); - router.replace('/(tabs)'); - } catch (error) { - console.error('保存引导状态失败:', error); - router.replace('/(tabs)'); - } - }; - - const handleComplete = async () => { - try { - // 保存用户信息和引导完成状态 - await AsyncStorage.multiSet([ - ['@onboarding_completed', 'true'], - ['@user_personal_info', JSON.stringify(personalInfo)], - ]); - console.log('用户信息:', personalInfo); - router.replace('/(tabs)'); - } catch (error) { - console.error('保存用户信息失败:', error); - router.replace('/(tabs)'); - } - }; - - const renderGenderSelection = () => ( - - handleGenderSelect('female')} - > - 👩 - 女性 - - - handleGenderSelect('male')} - > - 👨 - 男性 - - - ); - - const renderNumberInput = ( - field: 'age' | 'height' | 'weight', - placeholder: string, - unit: string - ) => ( - - - handleInputChange(field, value)} - placeholder={placeholder} - placeholderTextColor={iconColor} - keyboardType="numeric" - maxLength={field === 'age' ? 3 : 4} - /> - {unit} - - - ); - - const renderStepContent = () => { - const step = steps[currentStep]; - switch (step.type) { - case 'gender': - return renderGenderSelection(); - case 'age': - return renderNumberInput('age', '请输入年龄', '岁'); - case 'height': - return renderNumberInput('height', '请输入身高', 'cm'); - case 'weight': - return renderNumberInput('weight', '请输入体重', 'kg'); - default: - return null; - } - }; - - const isStepCompleted = () => { - const currentStepType = steps[currentStep].type; - switch (currentStepType) { - case 'gender': - return !!personalInfo.gender; - case 'age': - return !!personalInfo.age; - case 'height': - return !!personalInfo.height; - case 'weight': - return !!personalInfo.weight; - default: - return false; - } - }; - - return ( - - - - {/* 顶部导航 */} - - {currentStep > 0 && ( - - ‹ 返回 - - )} - - - 跳过 - - - - {/* 进度条 */} - - - - - - {currentStep + 1} / {steps.length} - - - - - {/* 标题区域 */} - - - {steps[currentStep].title} - - - {steps[currentStep].subtitle} - - - - {/* 内容区域 */} - {renderStepContent()} - - - {/* 底部按钮 */} - - - - {currentStep === steps.length - 1 ? '完成' : '下一步'} - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingTop: StatusBar.currentHeight || 44, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - }, - backButton: { - padding: 8, - }, - backText: { - fontSize: 16, - fontWeight: '500', - }, - skipButton: { - padding: 8, - }, - skipText: { - fontSize: 16, - fontWeight: '500', - }, - progressContainer: { - paddingHorizontal: 20, - marginBottom: 32, - }, - progressBackground: { - height: 4, - borderRadius: 2, - marginBottom: 8, - }, - progressBar: { - height: '100%', - borderRadius: 2, - }, - progressText: { - fontSize: 12, - textAlign: 'right', - }, - content: { - flex: 1, - }, - contentContainer: { - paddingHorizontal: 24, - paddingBottom: 24, - }, - titleContainer: { - marginBottom: 48, - }, - title: { - textAlign: 'center', - marginBottom: 16, - fontWeight: '700', - }, - subtitle: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - }, - optionsContainer: { - flexDirection: 'row', - justifyContent: 'space-around', - paddingHorizontal: 20, - }, - genderOption: { - width: width * 0.35, - height: 120, - borderRadius: 16, - borderWidth: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 16, - }, - genderIcon: { - fontSize: 48, - marginBottom: 8, - }, - genderText: { - fontSize: 16, - fontWeight: '600', - }, - inputContainer: { - alignItems: 'center', - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderRadius: 12, - paddingHorizontal: 16, - width: width * 0.6, - height: 56, - }, - numberInput: { - flex: 1, - fontSize: 18, - fontWeight: '600', - textAlign: 'center', - }, - unitText: { - fontSize: 16, - fontWeight: '500', - marginLeft: 8, - }, - buttonContainer: { - paddingHorizontal: 24, - paddingBottom: 48, - }, - nextButton: { - height: 56, - borderRadius: 16, - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 4, - }, - nextButtonText: { - fontSize: 18, - fontWeight: '600', - }, -}); diff --git a/components/MoodCard.tsx b/components/MoodCard.tsx index a537c27..7e9f533 100644 --- a/components/MoodCard.tsx +++ b/components/MoodCard.tsx @@ -1,76 +1,111 @@ +import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins'; +import dayjs from 'dayjs'; import React from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { ThemedText } from './ThemedText'; -import { ThemedView } from './ThemedView'; interface MoodCardProps { + moodCheckin: MoodCheckin | null; onPress: () => void; + isLoading?: boolean; } -export function MoodCard({ onPress }: MoodCardProps) { +export function MoodCard({ moodCheckin, onPress, isLoading = false }: MoodCardProps) { + const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null; + return ( - - - 心情 - 记录你的每日心情 + + + + {moodCheckin ? ( + + {moodConfig?.emoji || '😊'} + + ) : ( + 😊 + )} + + 心情 - - - 😊 + 记录你的每日心情 + + {isLoading ? ( + + 加载中... - 点击记录今日心情 - - + ) : moodCheckin ? ( + + + {moodConfig?.label || moodCheckin.moodType} + + + {dayjs(moodCheckin.createdAt).format('HH:mm')} + + + ) : ( + 点击记录心情 + )} + ); } const styles = StyleSheet.create({ - container: { - backgroundColor: '#fff', - borderRadius: 16, - padding: 16, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 3.84, - elevation: 5, + moodCardContent: { + width: '100%', }, - header: { - marginBottom: 12, - }, - title: { - fontSize: 18, - fontWeight: '600', - marginBottom: 4, - }, - subtitle: { - fontSize: 14, - opacity: 0.6, - }, - content: { + cardHeaderRow: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 8, + marginBottom: 12, + }, + moodIconContainer: { + width: 24, + height: 24, + borderRadius: 8, + backgroundColor: '#DCFCE7', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, }, moodIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#f0f0f0', - justifyContent: 'center', + fontSize: 14, + }, + cardTitle: { + fontSize: 14, + fontWeight: '800', + color: '#192126', + }, + moodSubtitle: { + fontSize: 12, + color: '#6B7280', + marginTop: 4, + marginBottom: 8, + }, + moodPreview: { + flexDirection: 'row', + justifyContent: 'space-between', alignItems: 'center', - marginRight: 12, + marginTop: 4, }, - emoji: { - fontSize: 20, + moodPreviewText: { + fontSize: 14, + color: '#059669', + fontWeight: '600', }, - moodText: { - fontSize: 16, - flex: 1, + moodPreviewTime: { + fontSize: 12, + color: '#6B7280', + }, + moodEmptyText: { + fontSize: 12, + color: '#9CA3AF', + fontStyle: 'italic', + marginTop: 4, + }, + moodLoadingText: { + fontSize: 12, + color: '#9CA3AF', + fontStyle: 'italic', + marginTop: 4, }, }); \ No newline at end of file diff --git a/components/MoodHistoryCard.tsx b/components/MoodHistoryCard.tsx new file mode 100644 index 0000000..02d7100 --- /dev/null +++ b/components/MoodHistoryCard.tsx @@ -0,0 +1,199 @@ +import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins'; +import dayjs from 'dayjs'; +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +interface MoodHistoryCardProps { + moodCheckins: MoodCheckin[]; + title?: string; +} + +export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHistoryCardProps) { + // 计算心情统计 + const moodStats = React.useMemo(() => { + const stats = { + total: moodCheckins.length, + averageIntensity: 0, + moodDistribution: {} as Record, + mostFrequentMood: '', + }; + + if (moodCheckins.length === 0) return stats; + + // 计算平均强度 + const totalIntensity = moodCheckins.reduce((sum, checkin) => sum + checkin.intensity, 0); + stats.averageIntensity = Math.round(totalIntensity / moodCheckins.length); + + // 计算心情分布 + moodCheckins.forEach(checkin => { + const moodLabel = getMoodConfig(checkin.moodType)?.label || checkin.moodType; + stats.moodDistribution[moodLabel] = (stats.moodDistribution[moodLabel] || 0) + 1; + }); + + // 找出最频繁的心情 + const sortedMoods = Object.entries(stats.moodDistribution) + .sort(([, a], [, b]) => b - a); + stats.mostFrequentMood = sortedMoods[0]?.[0] || ''; + + return stats; + }, [moodCheckins]); + + // 获取最近的心情记录 + const recentMoods = moodCheckins + .sort((a, b) => dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()) + .slice(0, 5); + + return ( + + {title} + + {moodCheckins.length === 0 ? ( + + 暂无心情记录 + + ) : ( + <> + {/* 统计信息 */} + + + {moodStats.total} + 总记录 + + + {moodStats.averageIntensity} + 平均强度 + + + {moodStats.mostFrequentMood} + 最常见 + + + + {/* 最近记录 */} + + 最近记录 + {recentMoods.map((checkin, index) => { + const moodConfig = getMoodConfig(checkin.moodType); + return ( + + + {moodConfig?.emoji} + + {moodConfig?.label} + + {dayjs(checkin.createdAt).format('MM月DD日 HH:mm')} + + + + + 强度 {checkin.intensity} + + + ); + })} + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: '#192126', + marginBottom: 16, + }, + emptyState: { + alignItems: 'center', + paddingVertical: 32, + }, + emptyText: { + fontSize: 14, + color: '#9CA3AF', + fontStyle: 'italic', + }, + statsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: 20, + paddingVertical: 16, + backgroundColor: '#F8F9FA', + borderRadius: 12, + }, + statItem: { + alignItems: 'center', + }, + statValue: { + fontSize: 20, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + statLabel: { + fontSize: 12, + color: '#6B7280', + }, + recentContainer: { + marginTop: 8, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#192126', + marginBottom: 12, + }, + moodItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + moodInfo: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + moodEmoji: { + fontSize: 20, + marginRight: 12, + }, + moodDetails: { + flex: 1, + }, + moodLabel: { + fontSize: 14, + fontWeight: '500', + color: '#192126', + marginBottom: 2, + }, + moodDate: { + fontSize: 12, + color: '#6B7280', + }, + moodIntensity: { + alignItems: 'flex-end', + }, + intensityText: { + fontSize: 12, + color: '#6B7280', + fontWeight: '500', + }, +}); diff --git a/components/MoodModal.tsx b/components/MoodModal.tsx deleted file mode 100644 index 351373b..0000000 --- a/components/MoodModal.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import React, { useState } from 'react'; -import { - Dimensions, - Modal, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; - -const { width, height } = Dimensions.get('window'); - -interface MoodModalProps { - visible: boolean; - onClose: () => void; - onSave: (mood: string, date: string) => void; -} - -// 心情日历数据 -const generateCalendarData = () => { - const today = new Date(); - const year = today.getFullYear(); - const month = today.getMonth(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const firstDayOfWeek = new Date(year, month, 1).getDay(); - - const calendar = []; - const weeks = []; - - // 添加空白日期 - for (let i = 0; i < firstDayOfWeek; i++) { - weeks.push(null); - } - - // 添加实际日期 - for (let day = 1; day <= daysInMonth; day++) { - weeks.push(day); - } - - // 按周分组 - for (let i = 0; i < weeks.length; i += 7) { - calendar.push(weeks.slice(i, i + 7)); - } - - return { calendar, today: today.getDate(), month: month + 1, year }; -}; - -const moodOptions = [ - { emoji: '😊', label: '开心', color: '#4CAF50' }, - { emoji: '😢', label: '难过', color: '#2196F3' }, - { emoji: '😰', label: '焦虑', color: '#FF9800' }, - { emoji: '😴', label: '疲惫', color: '#9C27B0' }, - { emoji: '😡', label: '愤怒', color: '#F44336' }, - { emoji: '😐', label: '平静', color: '#607D8B' }, -]; - -export function MoodModal({ visible, onClose, onSave }: MoodModalProps) { - const [selectedMood, setSelectedMood] = useState(''); - const { calendar, today, month, year } = generateCalendarData(); - - const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; - const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; - - const handleSave = () => { - if (selectedMood) { - const now = new Date(); - const timeString = `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`; - onSave(selectedMood, timeString); - onClose(); - setSelectedMood(''); - } - }; - - const renderMoodIcon = (day: number | null, isToday: boolean) => { - if (!day) return null; - - if (isToday && selectedMood) { - const mood = moodOptions.find(m => m.label === selectedMood); - return ( - - - 🐻 - - - ); - } - - return ( - - 😊 - - ); - }; - - return ( - - - - - - - {year}年{monthNames[month - 1]} - - - - - - - {/* 日历视图 */} - - - {weekDays.map((day, index) => ( - {day} - ))} - - - {calendar.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - {day && ( - <> - - {day.toString().padStart(2, '0')} - - {renderMoodIcon(day, day === today)} - - )} - - ))} - - ))} - - - {/* 心情选择 */} - - 选择今日心情 - - {moodOptions.map((mood, index) => ( - setSelectedMood(mood.label)} - > - {mood.emoji} - {mood.label} - - ))} - - - - {/* 近期记录 */} - - 近期记录 - {year}年{month}月{today}日 - - {selectedMood && ( - - - - 🐻 - - - {selectedMood} - - - {new Date().getHours()}:{new Date().getMinutes().toString().padStart(2, '0')} - - - )} - - - - {/* 保存按钮 */} - - - 保存心情 - - - - {/* 添加按钮 */} - - + - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f5f5f5', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - backgroundColor: '#fff', - }, - backButton: { - fontSize: 24, - color: '#666', - }, - headerTitle: { - fontSize: 20, - fontWeight: '600', - color: '#333', - }, - nextButton: { - fontSize: 24, - color: '#666', - }, - content: { - flex: 1, - }, - calendar: { - backgroundColor: '#fff', - margin: 16, - borderRadius: 16, - padding: 16, - }, - weekHeader: { - flexDirection: 'row', - justifyContent: 'space-around', - marginBottom: 16, - }, - weekDay: { - fontSize: 14, - color: '#666', - textAlign: 'center', - width: (width - 64) / 7, - }, - weekRow: { - flexDirection: 'row', - justifyContent: 'space-around', - marginBottom: 16, - }, - dayContainer: { - width: (width - 64) / 7, - alignItems: 'center', - }, - dayNumber: { - fontSize: 14, - color: '#999', - marginBottom: 8, - }, - todayNumber: { - color: '#333', - fontWeight: '600', - }, - moodIconContainer: { - width: 40, - height: 40, - borderRadius: 20, - justifyContent: 'center', - alignItems: 'center', - }, - bearIcon: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(255,255,255,0.9)', - justifyContent: 'center', - alignItems: 'center', - }, - bearEmoji: { - fontSize: 12, - }, - defaultMoodIcon: { - width: 40, - height: 40, - borderRadius: 20, - borderWidth: 1, - borderColor: '#ddd', - borderStyle: 'dashed', - justifyContent: 'center', - alignItems: 'center', - }, - defaultMoodEmoji: { - fontSize: 16, - opacity: 0.3, - }, - moodSection: { - backgroundColor: '#fff', - margin: 16, - marginTop: 0, - borderRadius: 16, - padding: 16, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - color: '#333', - marginBottom: 16, - }, - moodOptions: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'space-between', - }, - moodOption: { - width: (width - 80) / 3, - alignItems: 'center', - paddingVertical: 16, - marginBottom: 16, - borderRadius: 12, - backgroundColor: '#f8f8f8', - }, - selectedMoodOption: { - backgroundColor: '#e8f5e8', - borderWidth: 2, - borderColor: '#4CAF50', - }, - moodEmoji: { - fontSize: 24, - marginBottom: 8, - }, - moodLabel: { - fontSize: 14, - color: '#333', - }, - recentSection: { - backgroundColor: '#fff', - margin: 16, - marginTop: 0, - borderRadius: 16, - padding: 16, - }, - recentDate: { - fontSize: 14, - color: '#999', - marginBottom: 16, - }, - recentRecord: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - }, - recordIcon: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: '#4CAF50', - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - recordMood: { - fontSize: 16, - color: '#333', - fontWeight: '500', - }, - spacer: { - flex: 1, - }, - recordTime: { - fontSize: 14, - color: '#999', - }, - footer: { - padding: 16, - backgroundColor: '#fff', - }, - saveButton: { - backgroundColor: '#4CAF50', - borderRadius: 12, - paddingVertical: 16, - alignItems: 'center', - }, - disabledButton: { - backgroundColor: '#ccc', - }, - saveButtonText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - addButton: { - position: 'absolute', - bottom: 100, - right: 20, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: '#00C853', - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, - }, - addButtonText: { - color: '#fff', - fontSize: 24, - fontWeight: '300', - }, -}); \ No newline at end of file diff --git a/components/PrivacyConsentModal.tsx b/components/PrivacyConsentModal.tsx index 75202a2..b426178 100644 --- a/components/PrivacyConsentModal.tsx +++ b/components/PrivacyConsentModal.tsx @@ -40,8 +40,8 @@ export default function PrivacyConsentModal({ > - 欢迎来到普拉提助手 - + 欢迎来到Sealife + 点击"同意并继续"代表您已阅读并理解 @@ -69,11 +69,11 @@ export default function PrivacyConsentModal({ - + 同意并继续 - + 不同意并退出 diff --git a/constants/Routes.ts b/constants/Routes.ts index 37c8c5f..3b9139d 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -33,10 +33,6 @@ export const ROUTES = { LEGAL_USER_AGREEMENT: '/legal/user-agreement', LEGAL_PRIVACY_POLICY: '/legal/privacy-policy', - // 引导页路由 - ONBOARDING: '/onboarding', - ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info', - // 营养相关路由 NUTRITION_RECORDS: '/nutrition/records', } as const; diff --git a/docs/mood-checkin-implementation.md b/docs/mood-checkin-implementation.md new file mode 100644 index 0000000..8d22009 --- /dev/null +++ b/docs/mood-checkin-implementation.md @@ -0,0 +1,196 @@ +# 心情打卡功能实现文档 + +## 功能概述 + +心情打卡功能允许用户记录每日的情绪状态,包括10种基本情绪类型,并可以添加强度评分和详细描述。该功能已完全集成到现有的健康数据统计页面中。 + +## 实现的功能 + +### 1. 核心功能 +- ✅ 创建心情打卡记录 +- ✅ 更新已有心情记录 +- ✅ 删除心情记录(软删除) +- ✅ 查看每日心情记录 +- ✅ 查看历史心情记录 +- ✅ 心情统计分析 + +### 2. 心情类型 +支持10种心情类型,每种都有对应的emoji和颜色: + +| 心情类型 | 英文标识 | 中文标签 | Emoji | 颜色 | +|---------|---------|---------|-------|------| +| 开心 | happy | 开心 | 😊 | #4CAF50 | +| 心动 | excited | 心动 | 💓 | #E91E63 | +| 兴奋 | thrilled | 兴奋 | 🤩 | #FF9800 | +| 平静 | calm | 平静 | 😌 | #2196F3 | +| 焦虑 | anxious | 焦虑 | 😰 | #FF9800 | +| 难过 | sad | 难过 | 😢 | #2196F3 | +| 孤独 | lonely | 孤独 | 🥺 | #9C27B0 | +| 委屈 | wronged | 委屈 | 😔 | #607D8B | +| 生气 | angry | 生气 | 😡 | #F44336 | +| 心累 | tired | 心累 | 😴 | #9C27B0 | + +### 3. 数据字段 +- `moodType`: 心情类型(必填) +- `intensity`: 心情强度 1-10(必填,默认5) +- `description`: 心情描述(可选,最多200字符) +- `checkinDate`: 打卡日期(可选,默认当天) +- `metadata`: 扩展数据(可选) + +## 文件结构 + +### 1. API服务层 +``` +services/moodCheckins.ts +``` +- 定义心情类型和数据结构 +- 提供完整的API调用方法 +- 包含心情配置和工具函数 + +### 2. 组件层 +``` +components/MoodModal.tsx # 心情打卡弹窗 +components/MoodCard.tsx # 心情卡片展示 +components/MoodHistoryCard.tsx # 心情历史记录卡片 +``` + +### 3. 页面层 +``` +app/(tabs)/statistics.tsx # 主统计页面(集成心情卡片) +app/mood-statistics.tsx # 心情统计详情页面 +``` + +## API接口 + +### 创建心情打卡 +```typescript +POST /api/mood-checkins +{ + "moodType": "happy", + "intensity": 8, + "description": "今天工作顺利,心情很好", + "checkinDate": "2025-01-21" +} +``` + +### 获取每日心情 +```typescript +GET /api/mood-checkins/daily?date=2025-01-21 +``` + +### 获取心情历史 +```typescript +GET /api/mood-checkins/history?startDate=2025-01-01&endDate=2025-01-31 +``` + +### 获取心情统计 +```typescript +GET /api/mood-checkins/statistics?startDate=2025-01-01&endDate=2025-01-31 +``` + +## 使用方式 + +### 1. 在统计页面使用 +心情卡片已集成到主统计页面中,用户可以选择日期查看对应的心情记录: + +```typescript +// 加载指定日期的心情数据 +const loadMoodData = async (targetDate?: Date) => { + const dateString = dayjs(targetDate).format('YYYY-MM-DD'); + const checkins = await getDailyMoodCheckins(dateString); + setCurrentMoodCheckin(checkins[0] || null); +}; +``` + +### 2. 打开心情弹窗 +点击心情卡片可以打开心情打卡弹窗: + +```typescript + setMoodModalVisible(false)} + onSave={handleMoodSave} + selectedDate={days[selectedIndex]?.date?.format('YYYY-MM-DD')} +/> +``` + +### 3. 查看心情统计 +访问心情统计页面查看详细分析: + +```typescript +// 加载最近30天的心情数据 +const loadMoodData = async () => { + const endDate = dayjs().format('YYYY-MM-DD'); + const startDate = dayjs().subtract(30, 'days').format('YYYY-MM-DD'); + + const [historyData, statsData] = await Promise.all([ + getMoodCheckinsHistory({ startDate, endDate }), + getMoodStatistics({ startDate, endDate }) + ]); +}; +``` + +## 用户体验特性 + +### 1. 智能日期选择 +- 支持选择任意日期进行心情打卡 +- 自动加载选中日期的心情记录 +- 如果已有记录,自动填充并支持更新 + +### 2. 直观的心情选择 +- 使用emoji和颜色区分不同心情 +- 支持心情强度1-10的滑动选择 +- 可选的心情描述输入 + +### 3. 实时数据同步 +- 保存后立即更新界面显示 +- 支持离线缓存和网络同步 +- 错误处理和重试机制 + +### 4. 统计分析 +- 总打卡次数统计 +- 平均心情强度计算 +- 心情类型分布分析 +- 最频繁心情识别 + +## 技术实现细节 + +### 1. 状态管理 +使用React Hooks管理组件状态: +- `currentMoodCheckin`: 当前选中日期的情绪记录 +- `isMoodLoading`: 加载状态 +- `moodModalVisible`: 弹窗显示状态 + +### 2. 数据加载策略 +- 页面聚焦时自动加载当前日期数据 +- 日期切换时重新加载对应数据 +- 支持并行加载多个数据源 + +### 3. 错误处理 +- API调用失败时的友好提示 +- 网络异常时的重试机制 +- 数据加载失败时的降级显示 + +### 4. 性能优化 +- 使用React.memo优化组件渲染 +- 合理的数据缓存策略 +- 避免不必要的API调用 + +## 扩展功能建议 + +1. **心情趋势图表**: 添加折线图显示心情变化趋势 +2. **心情提醒**: 定时提醒用户进行心情打卡 +3. **心情分享**: 允许用户分享心情状态到社交平台 +4. **AI心情建议**: 基于心情状态提供改善建议 +5. **数据导出**: 支持心情数据导出为CSV或PDF +6. **心情标签**: 支持自定义心情标签和分类 +7. **心情日记**: 结合文字日记功能 +8. **心情目标**: 设置心情改善目标并跟踪进度 + +## 注意事项 + +1. **数据隐私**: 心情数据属于敏感信息,需要严格的隐私保护 +2. **用户体验**: 心情打卡应该是轻松愉快的体验,避免过于复杂的操作 +3. **数据准确性**: 确保心情数据的准确性和一致性 +4. **性能考虑**: 大量历史数据的加载和展示需要考虑性能优化 +5. **兼容性**: 确保在不同设备和系统版本上的兼容性 diff --git a/docs/mood-checkin-test.md b/docs/mood-checkin-test.md new file mode 100644 index 0000000..4a77444 --- /dev/null +++ b/docs/mood-checkin-test.md @@ -0,0 +1,127 @@ +# 心情打卡功能测试指南 + +## 功能测试清单 + +### 1. 基础功能测试 + +#### 1.1 心情卡片显示 +- [ ] 在统计页面可以看到心情卡片 +- [ ] 心情卡片显示正确的emoji和标题 +- [ ] 未记录心情时显示"点击记录心情" +- [ ] 已记录心情时显示心情类型和时间 + +#### 1.2 心情弹窗功能 +- [ ] 点击心情卡片可以打开心情弹窗 +- [ ] 弹窗显示日历视图 +- [ ] 弹窗显示10种心情选项 +- [ ] 心情选项有正确的emoji和颜色 + +#### 1.3 心情选择功能 +- [ ] 可以选择任意心情类型 +- [ ] 选择心情后显示心情强度选择器 +- [ ] 强度选择器支持1-10的滑动选择 +- [ ] 选择心情后显示描述输入框 + +#### 1.4 心情保存功能 +- [ ] 选择心情后可以保存 +- [ ] 保存后弹窗关闭 +- [ ] 保存后心情卡片更新显示 +- [ ] 保存失败时显示错误提示 + +### 2. 日期相关测试 + +#### 2.1 日期选择 +- [ ] 可以选择任意日期进行心情打卡 +- [ ] 选择不同日期时加载对应的心情记录 +- [ ] 未来日期不能进行心情打卡 + +#### 2.2 历史记录 +- [ ] 已有心情记录的日期显示正确的心情 +- [ ] 点击已有记录的日期可以更新心情 +- [ ] 历史记录显示正确的时间格式 + +### 3. 数据同步测试 + +#### 3.1 API调用 +- [ ] 创建心情打卡时调用正确的API +- [ ] 获取每日心情时调用正确的API +- [ ] API调用失败时显示错误信息 + +#### 3.2 数据更新 +- [ ] 保存心情后立即更新界面 +- [ ] 切换日期时重新加载数据 +- [ ] 网络异常时有适当的错误处理 + +### 4. 用户体验测试 + +#### 4.1 界面响应 +- [ ] 所有按钮点击有适当的反馈 +- [ ] 加载状态显示正确 +- [ ] 错误状态显示友好 + +#### 4.2 输入验证 +- [ ] 心情类型为必选项 +- [ ] 强度范围为1-10 +- [ ] 描述最多200字符 + +## 测试步骤 + +### 步骤1: 基础功能测试 +1. 打开应用,进入统计页面 +2. 查看心情卡片是否正确显示 +3. 点击心情卡片,确认弹窗打开 +4. 选择一种心情,确认强度选择器出现 +5. 调整强度,确认描述输入框出现 +6. 输入描述,点击保存 + +### 步骤2: 日期功能测试 +1. 在统计页面选择不同日期 +2. 确认心情卡片显示对应日期的心情 +3. 选择未来日期,确认不能打卡 +4. 选择已有记录的日期,确认可以更新 + +### 步骤3: 数据同步测试 +1. 断开网络连接 +2. 尝试保存心情,确认错误提示 +3. 恢复网络连接 +4. 重新保存心情,确认成功 + +### 步骤4: 边界情况测试 +1. 不选择心情直接保存 +2. 输入超长描述 +3. 快速切换日期 +4. 同时打开多个弹窗 + +## 预期结果 + +### 成功情况 +- 心情打卡功能正常工作 +- 数据正确保存和显示 +- 用户体验流畅 +- 错误处理得当 + +### 失败情况 +- 功能无法使用 +- 数据丢失或错误 +- 界面卡顿或崩溃 +- 错误信息不友好 + +## 问题记录 + +如果在测试过程中发现问题,请记录以下信息: + +1. **问题描述**: 详细描述问题现象 +2. **复现步骤**: 如何重现问题 +3. **预期行为**: 应该发生什么 +4. **实际行为**: 实际发生了什么 +5. **环境信息**: 设备、系统版本等 +6. **严重程度**: 高/中/低 + +## 修复验证 + +修复问题后,需要重新执行相关测试用例,确保: + +1. 问题已解决 +2. 没有引入新问题 +3. 相关功能仍然正常 +4. 用户体验没有受到影响 diff --git a/docs/mood-modal-optimization.md b/docs/mood-modal-optimization.md new file mode 100644 index 0000000..684c48d --- /dev/null +++ b/docs/mood-modal-optimization.md @@ -0,0 +1,205 @@ +# 心情打卡弹窗优化功能说明 + +## 优化内容 + +### 1. 月份导航功能 +- ✅ 添加了左右箭头按钮,支持月份切换 +- ✅ 月份标题居中显示,格式为"2025年8月" +- ✅ 左箭头为深色,右箭头为浅灰色(符合UI设计) +- ✅ 点击箭头可以切换到上个月或下个月 + +### 2. 日期选择优化 +- ✅ 支持选择任意月份的任意日期 +- ✅ 选中日期有高亮显示(绿色背景) +- ✅ 今天日期有特殊标识(绿色边框) +- ✅ 未来日期禁用选择(灰色显示) +- ✅ 选择日期后自动加载对应的心情记录 + +### 3. 交互逻辑优化 +- ✅ 月份切换时重置选中日期 +- ✅ 选择日期时自动加载该日期的心情数据 +- ✅ 如果该日期已有心情记录,自动填充并支持更新 +- ✅ 保存时必须同时选择日期和心情 + +## 技术实现 + +### 1. 状态管理 +```typescript +const [currentMonth, setCurrentMonth] = useState(new Date()); +const [selectedDay, setSelectedDay] = useState(null); +``` + +### 2. 月份切换函数 +```typescript +const goToPreviousMonth = () => { + const newMonth = new Date(currentMonth); + newMonth.setMonth(newMonth.getMonth() - 1); + setCurrentMonth(newMonth); + setSelectedDay(null); +}; + +const goToNextMonth = () => { + const newMonth = new Date(currentMonth); + newMonth.setMonth(newMonth.getMonth() + 1); + setCurrentMonth(newMonth); + setSelectedDay(null); +}; +``` + +### 3. 日期选择函数 +```typescript +const onSelectDate = (day: number) => { + setSelectedDay(day); + const selectedDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD'); + loadDailyMoodCheckins(selectedDateString); +}; +``` + +### 4. UI组件结构 +```jsx +{/* 月份导航 */} + + + + + {year}年{monthNames[month - 1]} + + + + +``` + +## 样式设计 + +### 1. 月份导航样式 +```css +monthNavigation: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, +}, +navButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#f8f9fa', + justifyContent: 'center', + alignItems: 'center', +}, +monthTitle: { + fontSize: 18, + fontWeight: '700', + color: '#192126', +}, +``` + +### 2. 日期按钮样式 +```css +dayButton: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 8, +}, +dayButtonSelected: { + backgroundColor: '#4CAF50', +}, +dayButtonToday: { + borderWidth: 2, + borderColor: '#4CAF50', +}, +``` + +## 用户体验改进 + +### 1. 视觉反馈 +- 选中日期有明显的绿色背景 +- 今天日期有绿色边框标识 +- 未来日期显示为灰色且不可点击 +- 月份切换按钮有适当的视觉反馈 + +### 2. 交互逻辑 +- 月份切换时自动重置选中状态 +- 选择日期时立即加载对应数据 +- 保存前验证日期和心情都已选择 +- 错误提示更加友好 + +### 3. 数据同步 +- 选择日期后自动调用API加载数据 +- 如果已有记录,自动填充表单 +- 保存成功后更新界面显示 +- 支持更新已有记录 + +## 测试要点 + +### 1. 月份导航测试 +- [ ] 点击左箭头可以切换到上个月 +- [ ] 点击右箭头可以切换到下个月 +- [ ] 月份标题正确显示 +- [ ] 切换月份时重置选中日期 + +### 2. 日期选择测试 +- [ ] 可以选择任意有效日期 +- [ ] 选中日期有高亮显示 +- [ ] 今天日期有特殊标识 +- [ ] 未来日期不可选择 + +### 3. 数据加载测试 +- [ ] 选择日期后自动加载心情数据 +- [ ] 已有记录时自动填充表单 +- [ ] 无记录时显示空白表单 +- [ ] 加载失败时有错误提示 + +### 4. 保存功能测试 +- [ ] 必须选择日期和心情才能保存 +- [ ] 保存成功后更新界面 +- [ ] 保存失败时显示错误信息 +- [ ] 支持更新已有记录 + +## 兼容性说明 + +### 1. 向后兼容 +- 保持了原有的API接口 +- 保持了原有的回调函数 +- 保持了原有的样式主题 +- 新增功能不影响现有功能 + +### 2. 数据格式 +- 日期格式统一使用YYYY-MM-DD +- 心情数据格式保持不变 +- API调用方式保持不变 +- 错误处理方式保持一致 + +## 性能优化 + +### 1. 状态管理 +- 使用useState管理本地状态 +- 避免不必要的重新渲染 +- 合理使用useEffect依赖 + +### 2. 数据加载 +- 按需加载日期数据 +- 缓存已加载的数据 +- 避免重复API调用 + +### 3. 界面渲染 +- 优化日历渲染逻辑 +- 减少不必要的样式计算 +- 使用适当的组件拆分 + +## 后续优化建议 + +1. **动画效果**: 添加月份切换的过渡动画 +2. **手势支持**: 支持左右滑动手势切换月份 +3. **快速导航**: 添加年份快速选择功能 +4. **批量操作**: 支持批量设置心情记录 +5. **数据统计**: 在日历上显示心情统计信息 diff --git a/docs/mood-redux-migration.md b/docs/mood-redux-migration.md new file mode 100644 index 0000000..73f156f --- /dev/null +++ b/docs/mood-redux-migration.md @@ -0,0 +1,152 @@ +# 心情管理 Redux 迁移总结 + +## 概述 + +本次迁移将心情管理相关的数据从本地状态管理迁移到 Redux 中进行统一管理,确保多个页面之间的数据状态同步。 + +## 迁移内容 + +### 1. 创建 Redux Slice + +**文件**: `store/moodSlice.ts` + +- 创建了完整的心情状态管理 +- 包含异步 actions 用于 API 调用 +- 提供选择器用于数据获取 +- 支持心情记录的增删改查操作 + +#### 主要功能: +- `fetchDailyMoodCheckins`: 获取指定日期的心情记录 +- `fetchMoodHistory`: 获取心情历史记录 +- `fetchMoodStatistics`: 获取心情统计数据 +- `createMoodRecord`: 创建心情记录 +- `updateMoodRecord`: 更新心情记录 +- `deleteMoodRecord`: 删除心情记录 + +### 2. 创建自定义 Hook + +**文件**: `hooks/useMoodData.ts` + +- 提供了简化的心情数据访问接口 +- 包含类型安全的参数定义 +- 支持多种使用场景 + +#### 主要 Hook: +- `useMoodData()`: 通用心情数据管理 +- `useMoodRecords(date)`: 获取指定日期的心情记录 +- `useTodayMood()`: 获取今天的心情记录 + +### 3. 更新 Store 配置 + +**文件**: `store/index.ts` + +- 将 `moodReducer` 添加到 Redux store 中 +- 确保心情状态在整个应用中可用 + +### 4. 迁移的页面 + +#### 心情日历页面 (`app/mood/calendar.tsx`) +- 使用 `useMoodData` hook 替代本地状态 +- 通过 Redux 管理月份心情数据和选中日期记录 +- 保持原有的 UI 交互逻辑 + +#### 心情编辑页面 (`app/mood/edit.tsx`) +- 使用 Redux actions 进行数据操作 +- 通过 `useMoodRecords` 获取当前日期的记录 +- 支持创建、更新、删除操作 + +#### 心情统计页面 (`app/mood-statistics.tsx`) +- 使用 Redux 管理历史数据和统计数据 +- 通过 `useMoodData` 获取统计数据 +- 保持原有的统计展示逻辑 + +#### 统计页面 (`app/(tabs)/statistics.tsx`) +- 使用 `selectLatestMoodRecordByDate` 获取当前日期的心情记录 +- 通过 Redux 管理心情数据的加载状态 + +## 数据流 + +### 之前的数据流 +``` +页面组件 → 本地状态 → API 调用 → 更新本地状态 +``` + +### 现在的数据流 +``` +页面组件 → Redux Actions → API 调用 → Redux Store → 页面组件 +``` + +## 优势 + +### 1. 数据同步 +- 多个页面共享同一份心情数据 +- 避免数据不一致的问题 +- 减少重复的 API 调用 + +### 2. 状态管理 +- 统一的状态管理逻辑 +- 更好的错误处理和加载状态 +- 支持数据缓存和优化 + +### 3. 开发体验 +- 类型安全的 API 接口 +- 简化的数据访问方式 +- 更好的代码组织和维护性 + +### 4. 性能优化 +- 避免重复的数据请求 +- 支持数据预加载 +- 更好的内存管理 + +## 使用示例 + +### 基本使用 +```typescript +import { useMoodData } from '@/hooks/useMoodData'; + +function MyComponent() { + const { createMood, updateMood, deleteMood } = useMoodData(); + + const handleCreate = async () => { + await createMood({ + moodType: 'happy', + intensity: 8, + description: '今天很开心' + }); + }; +} +``` + +### 获取特定日期的记录 +```typescript +import { useMoodRecords } from '@/hooks/useMoodData'; + +function MyComponent() { + const { records, latestRecord, loading } = useMoodRecords('2024-01-01'); + + if (loading) return ; + + return
{latestRecord?.moodType}
; +} +``` + +## 注意事项 + +1. **类型安全**: 所有 API 调用都有完整的 TypeScript 类型定义 +2. **错误处理**: Redux 提供了统一的错误处理机制 +3. **加载状态**: 每个操作都有对应的加载状态管理 +4. **数据缓存**: Redux 会自动缓存已加载的数据,避免重复请求 + +## 后续优化建议 + +1. **数据持久化**: 考虑使用 Redux Persist 进行数据持久化 +2. **离线支持**: 添加离线数据同步功能 +3. **实时更新**: 考虑添加 WebSocket 支持实时数据更新 +4. **性能监控**: 添加性能监控和优化指标 + +## 测试建议 + +1. **单元测试**: 为 Redux actions 和 reducers 编写单元测试 +2. **集成测试**: 测试页面组件与 Redux 的集成 +3. **端到端测试**: 测试完整的心情管理流程 +4. **性能测试**: 测试大量数据下的性能表现 diff --git a/hooks/useMoodData.ts b/hooks/useMoodData.ts new file mode 100644 index 0000000..77d1ac0 --- /dev/null +++ b/hooks/useMoodData.ts @@ -0,0 +1,167 @@ +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { MoodType } from '@/services/moodCheckins'; +import { + createMoodRecord, + deleteMoodRecord, + fetchDailyMoodCheckins, + fetchMoodHistory, + fetchMoodStatistics, + selectLatestMoodRecordByDate, + selectMoodLoading, + selectMoodRecordsByDate, + selectMoodStatistics, + updateMoodRecord +} from '@/store/moodSlice'; +import dayjs from 'dayjs'; + +// 创建心情记录参数 +export interface CreateMoodParams { + moodType: MoodType; + intensity: number; + description?: string; + checkinDate?: string; +} + +// 更新心情记录参数 +export interface UpdateMoodParams { + id: string; + moodType?: MoodType; + intensity?: number; + description?: string; +} + +// 获取心情历史参数 +export interface GetMoodHistoryParams { + startDate: string; + endDate: string; + moodType?: MoodType; +} + +// 获取心情统计参数 +export interface GetMoodStatisticsParams { + startDate: string; + endDate: string; +} + +/** + * 心情数据管理 Hook + */ +export function useMoodData() { + const dispatch = useAppDispatch(); + const loading = useAppSelector(selectMoodLoading); + const statistics = useAppSelector(selectMoodStatistics); + + /** + * 获取指定日期的心情记录 + */ + const getMoodRecordsByDate = (date: string) => { + return useAppSelector(selectMoodRecordsByDate(date)); + }; + + /** + * 获取指定日期的最新心情记录 + */ + const getLatestMoodRecordByDate = (date: string) => { + return useAppSelector(selectLatestMoodRecordByDate(date)); + }; + + /** + * 获取今天的心情记录 + */ + const getTodayMoodRecord = () => { + const today = dayjs().format('YYYY-MM-DD'); + return useAppSelector(selectLatestMoodRecordByDate(today)); + }; + + /** + * 获取指定日期的心情记录(异步) + */ + const fetchMoodRecords = async (date: string) => { + const result = await dispatch(fetchDailyMoodCheckins(date)).unwrap(); + return result.checkins || []; + }; + + /** + * 获取心情历史记录 + */ + const fetchMoodHistoryRecords = async (params: GetMoodHistoryParams) => { + const result = await dispatch(fetchMoodHistory(params)).unwrap(); + return result.checkins || []; + }; + + /** + * 获取心情统计数据 + */ + const fetchMoodStatisticsData = async (params: GetMoodStatisticsParams) => { + return await dispatch(fetchMoodStatistics(params)).unwrap(); + }; + + /** + * 创建心情记录 + */ + const createMood = async (params: CreateMoodParams) => { + return await dispatch(createMoodRecord(params)).unwrap(); + }; + + /** + * 更新心情记录 + */ + const updateMood = async (params: UpdateMoodParams) => { + return await dispatch(updateMoodRecord(params)).unwrap(); + }; + + /** + * 删除心情记录 + */ + const deleteMood = async (id: string) => { + return await dispatch(deleteMoodRecord({ id })).unwrap(); + }; + + return { + // 状态 + loading, + statistics, + + // 选择器 + getMoodRecordsByDate, + getLatestMoodRecordByDate, + getTodayMoodRecord, + + // 异步操作 + fetchMoodRecords, + fetchMoodHistoryRecords, + fetchMoodStatisticsData, + createMood, + updateMood, + deleteMood, + }; +} + +/** + * 获取指定日期心情记录的 Hook + */ +export function useMoodRecords(date: string) { + const records = useAppSelector(selectMoodRecordsByDate(date)); + const latestRecord = useAppSelector(selectLatestMoodRecordByDate(date)); + const loading = useAppSelector(selectMoodLoading); + const dispatch = useAppDispatch(); + + const fetchRecords = async () => { + return await dispatch(fetchDailyMoodCheckins(date)).unwrap(); + }; + + return { + records, + latestRecord, + loading: loading.daily, + fetchRecords, + }; +} + +/** + * 获取今天心情记录的 Hook + */ +export function useTodayMood() { + const today = dayjs().format('YYYY-MM-DD'); + return useMoodRecords(today); +} diff --git a/services/moodCheckins.ts b/services/moodCheckins.ts new file mode 100644 index 0000000..c751576 --- /dev/null +++ b/services/moodCheckins.ts @@ -0,0 +1,141 @@ +import { api } from './api'; + +// 心情类型定义 +export type MoodType = + | 'happy' // 开心 + | 'excited' // 心动 + | 'thrilled' // 兴奋 + | 'calm' // 平静 + | 'anxious' // 焦虑 + | 'sad' // 难过 + | 'lonely' // 孤独 + | 'wronged' // 委屈 + | 'angry' // 生气 + | 'tired'; // 心累 + +// 心情打卡记录类型 +export type MoodCheckin = { + id: string; + userId: string; + moodType: MoodType; + intensity: number; // 1-10 + description?: string; + checkinDate: string; // YYYY-MM-DD + createdAt: string; // ISO + updatedAt: string; // ISO + metadata?: Record; +}; + +// 创建心情打卡请求 +export type CreateMoodCheckinDto = { + moodType: MoodType; + intensity: number; // 1-10 + description?: string; + checkinDate?: string; // YYYY-MM-DD,默认今天 + metadata?: Record; +}; + +// 更新心情打卡请求 +export type UpdateMoodCheckinDto = { + id: string; + moodType?: MoodType; + intensity?: number; + description?: string; + metadata?: Record; +}; + +// 删除心情打卡请求 +export type DeleteMoodCheckinDto = { + id: string; +}; + +// 心情统计数据 +export type MoodStatistics = { + totalCheckins: number; + averageIntensity: number; + moodDistribution: Record; + mostFrequentMood: MoodType; +}; + +// 创建心情打卡 +export async function createMoodCheckin(dto: CreateMoodCheckinDto): Promise { + return await api.post('/api/mood-checkins', dto); +} + +// 更新心情打卡 +export async function updateMoodCheckin(dto: UpdateMoodCheckinDto): Promise { + return await api.put('/api/mood-checkins', dto); +} + +// 删除心情打卡 +export async function deleteMoodCheckin(dto: DeleteMoodCheckinDto): Promise { + return await api.delete('/api/mood-checkins', { body: dto }); +} + +// 获取每日心情记录 +export async function getDailyMoodCheckins(date?: string): Promise { + const path = date ? `/api/mood-checkins/daily?date=${encodeURIComponent(date)}` : '/api/mood-checkins/daily'; + const data = await api.get(path); + return Array.isArray(data) ? data : []; +} + +// 获取心情历史记录 +export async function getMoodCheckinsHistory(params: { + startDate: string; + endDate: string; + moodType?: MoodType; +}): Promise { + const queryParams = new URLSearchParams({ + startDate: params.startDate, + endDate: params.endDate, + }); + + if (params.moodType) { + queryParams.append('moodType', params.moodType); + } + + const path = `/api/mood-checkins/history?${queryParams.toString()}`; + const data = await api.get(path); + return Array.isArray(data) ? data : []; +} + +// 获取心情统计数据 +export async function getMoodStatistics(params: { + startDate: string; + endDate: string; +}): Promise { + const queryParams = new URLSearchParams({ + startDate: params.startDate, + endDate: params.endDate, + }); + + const path = `/api/mood-checkins/statistics?${queryParams.toString()}`; + return await api.get(path); +} + +// 心情类型配置 +export const MOOD_CONFIG = { + happy: { emoji: '😊', label: '开心', color: '#4CAF50' }, + excited: { emoji: '💓', label: '心动', color: '#E91E63' }, + thrilled: { emoji: '🤩', label: '兴奋', color: '#FF9800' }, + calm: { emoji: '😌', label: '平静', color: '#2196F3' }, + anxious: { emoji: '😰', label: '焦虑', color: '#FF9800' }, + sad: { emoji: '😢', label: '难过', color: '#2196F3' }, + lonely: { emoji: '🥺', label: '孤独', color: '#9C27B0' }, + wronged: { emoji: '😔', label: '委屈', color: '#607D8B' }, + angry: { emoji: '😡', label: '生气', color: '#F44336' }, + tired: { emoji: '😴', label: '心累', color: '#9C27B0' }, +} as const; + +// 获取心情配置 +export function getMoodConfig(moodType: MoodType) { + return MOOD_CONFIG[moodType]; +} + +// 获取所有心情选项 +export function getMoodOptions() { + return Object.entries(MOOD_CONFIG).map(([type, config]) => ({ + type: type as MoodType, + ...config, + })); +} diff --git a/store/index.ts b/store/index.ts index fd95f56..bc06217 100644 --- a/store/index.ts +++ b/store/index.ts @@ -2,6 +2,7 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import challengeReducer from './challengeSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import exerciseLibraryReducer from './exerciseLibrarySlice'; +import moodReducer from './moodSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice'; import trainingPlanReducer from './trainingPlanSlice'; import userReducer from './userSlice'; @@ -40,6 +41,7 @@ export const store = configureStore({ user: userReducer, challenge: challengeReducer, checkin: checkinReducer, + mood: moodReducer, trainingPlan: trainingPlanReducer, scheduleExercise: scheduleExerciseReducer, exerciseLibrary: exerciseLibraryReducer, diff --git a/store/moodSlice.ts b/store/moodSlice.ts new file mode 100644 index 0000000..2082e41 --- /dev/null +++ b/store/moodSlice.ts @@ -0,0 +1,324 @@ +import { + createMoodCheckin, + CreateMoodCheckinDto, + deleteMoodCheckin, + DeleteMoodCheckinDto, + getDailyMoodCheckins, + getMoodCheckinsHistory, + getMoodStatistics, + MoodCheckin, + MoodType, + updateMoodCheckin, + UpdateMoodCheckinDto +} from '@/services/moodCheckins'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import dayjs from 'dayjs'; + +// 状态接口 +interface MoodState { + // 按日期存储的心情记录 + moodRecords: Record; + // 当前选中的日期 + selectedDate: string; + // 当前选中的心情记录 + selectedMoodRecord: MoodCheckin | null; + // 加载状态 + loading: { + daily: boolean; + history: boolean; + statistics: boolean; + create: boolean; + update: boolean; + delete: boolean; + }; + // 错误信息 + error: string | null; + // 统计数据 + statistics: { + totalCheckins: number; + averageIntensity: number; + moodDistribution: Record; + mostFrequentMood: MoodType | null; + } | null; +} + +// 初始状态 +const initialState: MoodState = { + moodRecords: {}, + selectedDate: dayjs().format('YYYY-MM-DD'), + selectedMoodRecord: null, + loading: { + daily: false, + history: false, + statistics: false, + create: false, + update: false, + delete: false, + }, + error: null, + statistics: null, +}; + +// 异步 actions +export const fetchDailyMoodCheckins = createAsyncThunk( + 'mood/fetchDailyMoodCheckins', + async (date: string) => { + const checkins = await getDailyMoodCheckins(date); + return { date, checkins }; + } +); + +export const fetchMoodHistory = createAsyncThunk( + 'mood/fetchMoodHistory', + async (params: { startDate: string; endDate: string; moodType?: MoodType }) => { + const checkins = await getMoodCheckinsHistory(params); + return { params, checkins }; + } +); + +export const fetchMoodStatistics = createAsyncThunk( + 'mood/fetchMoodStatistics', + async (params: { startDate: string; endDate: string }) => { + const statistics = await getMoodStatistics(params); + return statistics; + } +); + +export const createMoodRecord = createAsyncThunk( + 'mood/createMoodRecord', + async (dto: CreateMoodCheckinDto) => { + const newRecord = await createMoodCheckin(dto); + return newRecord; + } +); + +export const updateMoodRecord = createAsyncThunk( + 'mood/updateMoodRecord', + async (dto: UpdateMoodCheckinDto) => { + const updatedRecord = await updateMoodCheckin(dto); + return updatedRecord; + } +); + +export const deleteMoodRecord = createAsyncThunk( + 'mood/deleteMoodRecord', + async (dto: DeleteMoodCheckinDto) => { + await deleteMoodCheckin(dto); + return dto.id; + } +); + +// 创建 slice +const moodSlice = createSlice({ + name: 'mood', + initialState, + reducers: { + // 设置选中的日期 + setSelectedDate: (state, action: PayloadAction) => { + state.selectedDate = action.payload; + // 如果该日期没有记录,设置为 null + const records = state.moodRecords[action.payload]; + state.selectedMoodRecord = records && records.length > 0 ? records[0] : null; + }, + // 设置选中的心情记录 + setSelectedMoodRecord: (state, action: PayloadAction) => { + state.selectedMoodRecord = action.payload; + }, + // 清除错误 + clearError: (state) => { + state.error = null; + }, + // 清除统计数据 + clearStatistics: (state) => { + state.statistics = null; + }, + // 清除所有数据 + clearMoodData: (state) => { + state.moodRecords = {}; + state.selectedMoodRecord = null; + state.statistics = null; + state.error = null; + }, + }, + extraReducers: (builder) => { + // fetchDailyMoodCheckins + builder + .addCase(fetchDailyMoodCheckins.pending, (state) => { + state.loading.daily = true; + state.error = null; + }) + .addCase(fetchDailyMoodCheckins.fulfilled, (state, action) => { + state.loading.daily = false; + const { date, checkins } = action.payload; + state.moodRecords[date] = checkins; + + // 如果是当前选中的日期,更新选中的记录 + if (date === state.selectedDate) { + state.selectedMoodRecord = checkins.length > 0 ? checkins[0] : null; + } + }) + .addCase(fetchDailyMoodCheckins.rejected, (state, action) => { + state.loading.daily = false; + state.error = action.error.message || '获取心情记录失败'; + }); + + // fetchMoodHistory + builder + .addCase(fetchMoodHistory.pending, (state) => { + state.loading.history = true; + state.error = null; + }) + .addCase(fetchMoodHistory.fulfilled, (state, action) => { + state.loading.history = false; + const { checkins } = action.payload; + + // 将历史记录按日期分组存储 + checkins.forEach(checkin => { + const date = checkin.checkinDate; + if (!state.moodRecords[date]) { + state.moodRecords[date] = []; + } + + // 检查是否已存在相同 ID 的记录 + const existingIndex = state.moodRecords[date].findIndex(r => r.id === checkin.id); + if (existingIndex >= 0) { + state.moodRecords[date][existingIndex] = checkin; + } else { + state.moodRecords[date].push(checkin); + } + }); + }) + .addCase(fetchMoodHistory.rejected, (state, action) => { + state.loading.history = false; + state.error = action.error.message || '获取心情历史失败'; + }); + + // fetchMoodStatistics + builder + .addCase(fetchMoodStatistics.pending, (state) => { + state.loading.statistics = true; + state.error = null; + }) + .addCase(fetchMoodStatistics.fulfilled, (state, action) => { + state.loading.statistics = false; + state.statistics = action.payload; + }) + .addCase(fetchMoodStatistics.rejected, (state, action) => { + state.loading.statistics = false; + state.error = action.error.message || '获取心情统计失败'; + }); + + // createMoodRecord + builder + .addCase(createMoodRecord.pending, (state) => { + state.loading.create = true; + state.error = null; + }) + .addCase(createMoodRecord.fulfilled, (state, action) => { + state.loading.create = false; + const newRecord = action.payload; + const date = newRecord.checkinDate; + + // 添加到对应日期的记录中 + if (!state.moodRecords[date]) { + state.moodRecords[date] = []; + } + state.moodRecords[date].unshift(newRecord); // 添加到开头 + + // 如果是当前选中的日期,更新选中的记录 + if (date === state.selectedDate) { + state.selectedMoodRecord = newRecord; + } + }) + .addCase(createMoodRecord.rejected, (state, action) => { + state.loading.create = false; + state.error = action.error.message || '创建心情记录失败'; + }); + + // updateMoodRecord + builder + .addCase(updateMoodRecord.pending, (state) => { + state.loading.update = true; + state.error = null; + }) + .addCase(updateMoodRecord.fulfilled, (state, action) => { + state.loading.update = false; + const updatedRecord = action.payload; + const date = updatedRecord.checkinDate; + + // 更新对应日期的记录 + if (state.moodRecords[date]) { + const index = state.moodRecords[date].findIndex(r => r.id === updatedRecord.id); + if (index >= 0) { + state.moodRecords[date][index] = updatedRecord; + } + } + + // 如果是当前选中的记录,更新选中的记录 + if (state.selectedMoodRecord?.id === updatedRecord.id) { + state.selectedMoodRecord = updatedRecord; + } + }) + .addCase(updateMoodRecord.rejected, (state, action) => { + state.loading.update = false; + state.error = action.error.message || '更新心情记录失败'; + }); + + // deleteMoodRecord + builder + .addCase(deleteMoodRecord.pending, (state) => { + state.loading.delete = true; + state.error = null; + }) + .addCase(deleteMoodRecord.fulfilled, (state, action) => { + state.loading.delete = false; + const deletedId = action.payload; + + // 从所有日期的记录中删除 + Object.keys(state.moodRecords).forEach(date => { + state.moodRecords[date] = state.moodRecords[date].filter(r => r.id !== deletedId); + }); + + // 如果是当前选中的记录被删除,清空选中的记录 + if (state.selectedMoodRecord?.id === deletedId) { + state.selectedMoodRecord = null; + } + }) + .addCase(deleteMoodRecord.rejected, (state, action) => { + state.loading.delete = false; + state.error = action.error.message || '删除心情记录失败'; + }); + }, +}); + +// 导出 actions +export const { + setSelectedDate, + setSelectedMoodRecord, + clearError, + clearStatistics, + clearMoodData, +} = moodSlice.actions; + +// 导出 reducer +export default moodSlice.reducer; + +// 导出选择器 +export const selectMoodState = (state: { mood: MoodState }) => state.mood; +export const selectMoodRecords = (state: { mood: MoodState }) => state.mood.moodRecords; +export const selectSelectedDate = (state: { mood: MoodState }) => state.mood.selectedDate; +export const selectSelectedMoodRecord = (state: { mood: MoodState }) => state.mood.selectedMoodRecord; +export const selectMoodLoading = (state: { mood: MoodState }) => state.mood.loading; +export const selectMoodError = (state: { mood: MoodState }) => state.mood.error; +export const selectMoodStatistics = (state: { mood: MoodState }) => state.mood.statistics; + +// 获取指定日期的心情记录 +export const selectMoodRecordsByDate = (date: string) => (state: { mood: MoodState }) => { + return state.mood.moodRecords[date] || []; +}; + +// 获取指定日期的最新心情记录 +export const selectLatestMoodRecordByDate = (date: string) => (state: { mood: MoodState }) => { + const records = state.mood.moodRecords[date] || []; + return records.length > 0 ? records[0] : null; +}; diff --git a/store/userSlice.ts b/store/userSlice.ts index 0d21229..991932d 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -41,7 +41,7 @@ export type UserState = { activityHistory: ActivityHistoryItem[]; }; -export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; +export const DEFAULT_MEMBER_NAME = '小海豹'; const initialState: UserState = { token: null,