feat: 更新心情记录功能和界面
- 调整启动画面中的图片宽度,提升视觉效果 - 移除引导页面相关组件,简化应用结构 - 新增心情统计页面,支持用户查看和分析心情数据 - 优化心情卡片组件,增强用户交互体验 - 更新登录页面标题,提升品牌一致性 - 新增心情日历和编辑功能,支持用户记录和管理心情
This commit is contained in:
2
app.json
2
app.json
@@ -38,7 +38,7 @@
|
|||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/Sealife.jpeg",
|
"image": "./assets/images/Sealife.jpeg",
|
||||||
"imageWidth": 200,
|
"imageWidth": 40,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||||
import { BMICard } from '@/components/BMICard';
|
import { BMICard } from '@/components/BMICard';
|
||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
import { MoodModal } from '@/components/MoodModal';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import { ProgressBar } from '@/components/ProgressBar';
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
import { WeightHistoryCard } from '@/components/WeightHistoryCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||||
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
@@ -163,29 +165,45 @@ export default function ExploreScreen() {
|
|||||||
const [isNutritionLoading, setIsNutritionLoading] = useState(false);
|
const [isNutritionLoading, setIsNutritionLoading] = useState(false);
|
||||||
|
|
||||||
// 心情相关状态
|
// 心情相关状态
|
||||||
const [moodModalVisible, setMoodModalVisible] = useState(false);
|
const dispatch = useAppDispatch();
|
||||||
const [moodRecords, setMoodRecords] = useState<Array<{ mood: string, date: string, time: string }>>([]);
|
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<string | null>(null);
|
const latestRequestKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
// 心情保存处理函数
|
// 加载心情数据
|
||||||
const handleMoodSave = (mood: string, time: string) => {
|
const loadMoodData = async (targetDate?: Date) => {
|
||||||
const today = new Date();
|
if (!isLoggedIn) return;
|
||||||
const dateString = `${today.getFullYear()}年${today.getMonth() + 1}月${today.getDate()}日`;
|
|
||||||
|
|
||||||
const newRecord = {
|
try {
|
||||||
mood,
|
setIsMoodLoading(true);
|
||||||
date: dateString,
|
|
||||||
time
|
|
||||||
};
|
|
||||||
|
|
||||||
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) => {
|
const loadHealthData = async (targetDate?: Date) => {
|
||||||
try {
|
try {
|
||||||
console.log('=== 开始HealthKit初始化流程 ===');
|
console.log('=== 开始HealthKit初始化流程 ===');
|
||||||
@@ -290,6 +308,7 @@ export default function ExploreScreen() {
|
|||||||
loadHealthData(currentDate);
|
loadHealthData(currentDate);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadNutritionData(currentDate);
|
loadNutritionData(currentDate);
|
||||||
|
loadMoodData(currentDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedIndex])
|
}, [selectedIndex])
|
||||||
@@ -303,6 +322,7 @@ export default function ExploreScreen() {
|
|||||||
loadHealthData(target);
|
loadHealthData(target);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadNutritionData(target);
|
loadNutritionData(target);
|
||||||
|
loadMoodData(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -424,23 +444,11 @@ export default function ExploreScreen() {
|
|||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
{/* 心情卡片 */}
|
{/* 心情卡片 */}
|
||||||
<FloatingCard style={[styles.masonryCard, styles.moodCard]} delay={1500}>
|
<FloatingCard style={[styles.masonryCard, styles.moodCard]} delay={1500}>
|
||||||
<TouchableOpacity onPress={() => setMoodModalVisible(true)} style={styles.moodCardContent}>
|
<MoodCard
|
||||||
<View style={styles.cardHeaderRow}>
|
moodCheckin={currentMoodCheckin}
|
||||||
<View style={styles.moodIconContainer}>
|
onPress={() => router.push('/mood/calendar')}
|
||||||
<Text style={styles.moodIcon}>😊</Text>
|
isLoading={isMoodLoading}
|
||||||
</View>
|
/>
|
||||||
<Text style={styles.cardTitle}>心情</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.moodSubtitle}>记录你的每日心情</Text>
|
|
||||||
{moodRecords.length > 0 ? (
|
|
||||||
<View style={styles.moodPreview}>
|
|
||||||
<Text style={styles.moodPreviewText}>今日:{moodRecords[0].mood}</Text>
|
|
||||||
<Text style={styles.moodPreviewTime}>{moodRecords[0].time}</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.moodEmptyText}>点击记录心情</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -484,13 +492,6 @@ export default function ExploreScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
||||||
{/* 心情弹窗 */}
|
|
||||||
<MoodModal
|
|
||||||
visible={moodModalVisible}
|
|
||||||
onClose={() => setMoodModalVisible(false)}
|
|
||||||
onSave={handleMoodSave}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -866,46 +867,4 @@ const styles = StyleSheet.create({
|
|||||||
moodCard: {
|
moodCard: {
|
||||||
backgroundColor: '#F0FDF4',
|
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export default function RootLayout() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ThemeProvider value={DefaultTheme}>
|
<ThemeProvider value={DefaultTheme}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="onboarding" />
|
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useAppDispatch } from '@/hooks/redux';
|
|||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { login } from '@/store/userSlice';
|
import { login } from '@/store/userSlice';
|
||||||
import Toast from 'react-native-toast-message';
|
import Toast from 'react-native-toast-message';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -238,8 +237,8 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
<View style={styles.headerWrap}>
|
<View style={styles.headerWrap}>
|
||||||
<ThemedText style={[styles.title, { color: color.text }]}>普拉提助手</ThemedText>
|
<ThemedText style={[styles.title, { color: color.text }]}>Sealife</ThemedText>
|
||||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录普拉提星球</ThemedText>
|
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Sealife</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Apple 登录 */}
|
{/* Apple 登录 */}
|
||||||
|
|||||||
287
app/mood-statistics.tsx
Normal file
287
app/mood-statistics.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={backgroundGradientColors}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Text style={styles.loginPrompt}>请先登录查看心情统计</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={backgroundGradientColors}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||||
|
<Text style={styles.title}>心情统计</Text>
|
||||||
|
|
||||||
|
{loading.history || loading.statistics ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colorTokens.primary} />
|
||||||
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 统计概览 */}
|
||||||
|
{statistics && (
|
||||||
|
<View style={styles.statsOverview}>
|
||||||
|
<Text style={styles.sectionTitle}>统计概览</Text>
|
||||||
|
<View style={styles.statsGrid}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{statistics.totalCheckins}</Text>
|
||||||
|
<Text style={styles.statLabel}>总打卡次数</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{statistics.averageIntensity.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.statLabel}>平均强度</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>
|
||||||
|
{statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statLabel}>最常见心情</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 心情历史记录 */}
|
||||||
|
<MoodHistoryCard
|
||||||
|
moodCheckins={moodCheckins}
|
||||||
|
title="最近30天心情记录"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 心情分布 */}
|
||||||
|
{statistics && (
|
||||||
|
<View style={styles.distributionContainer}>
|
||||||
|
<Text style={styles.sectionTitle}>心情分布</Text>
|
||||||
|
<View style={styles.distributionList}>
|
||||||
|
{Object.entries(statistics.moodDistribution)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([moodType, count]) => (
|
||||||
|
<View key={moodType} style={styles.distributionItem}>
|
||||||
|
<Text style={styles.moodType}>{moodType}</Text>
|
||||||
|
<View style={styles.countContainer}>
|
||||||
|
<Text style={styles.count}>{count}</Text>
|
||||||
|
<Text style={styles.percentage}>
|
||||||
|
({((count / statistics.totalCheckins) * 100).toFixed(1)}%)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
586
app/mood/calendar.tsx
Normal file
586
app/mood/calendar.tsx
Normal file
@@ -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<number | null>(null);
|
||||||
|
const [selectedDateMood, setSelectedDateMood] = useState<any>(null);
|
||||||
|
const [moodRecords, setMoodRecords] = useState<Record<string, any[]>>({});
|
||||||
|
|
||||||
|
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<string, any[]> = {};
|
||||||
|
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 (
|
||||||
|
<View style={[styles.moodIconContainer, { backgroundColor: mood?.color }]}>
|
||||||
|
<View style={styles.moodIcon}>
|
||||||
|
<Text style={styles.moodEmoji}>{mood?.emoji || '😊'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.defaultMoodIcon}>
|
||||||
|
<Text style={styles.defaultMoodEmoji}>😊</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用统一的渐变背景色
|
||||||
|
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={backgroundGradientColors}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<Text style={styles.backButton}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>心情日历</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{/* 日历视图 */}
|
||||||
|
<View style={styles.calendar}>
|
||||||
|
{/* 月份导航 */}
|
||||||
|
<View style={styles.monthNavigation}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.navButton}
|
||||||
|
onPress={goToPreviousMonth}
|
||||||
|
>
|
||||||
|
<Text style={styles.navButtonText}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.monthTitle}>{year}年{monthNames[month - 1]}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.navButton}
|
||||||
|
onPress={goToNextMonth}
|
||||||
|
>
|
||||||
|
<Text style={styles.navButtonText}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.weekHeader}>
|
||||||
|
{weekDays.map((day, index) => (
|
||||||
|
<Text key={index} style={styles.weekDay}>{day}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{calendar.map((week, weekIndex) => (
|
||||||
|
<View key={weekIndex} style={styles.weekRow}>
|
||||||
|
{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 (
|
||||||
|
<View key={dayIndex} style={styles.dayContainer}>
|
||||||
|
{day && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dayButton,
|
||||||
|
isSelected && styles.dayButtonSelected,
|
||||||
|
isToday && styles.dayButtonToday
|
||||||
|
]}
|
||||||
|
onPress={() => !isFutureDate && day && onSelectDate(day)}
|
||||||
|
disabled={isFutureDate}
|
||||||
|
>
|
||||||
|
<View style={styles.dayContent}>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayNumber,
|
||||||
|
isSelected && styles.dayNumberSelected,
|
||||||
|
isToday && styles.dayNumberToday,
|
||||||
|
isFutureDate && styles.dayNumberDisabled
|
||||||
|
]}>
|
||||||
|
{day.toString().padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
{renderMoodIcon(day, isSelected)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 选中日期的记录 */}
|
||||||
|
<View style={styles.selectedDateSection}>
|
||||||
|
<View style={styles.selectedDateHeader}>
|
||||||
|
<Text style={styles.selectedDateTitle}>
|
||||||
|
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addMoodButton}
|
||||||
|
onPress={openMoodEdit}
|
||||||
|
>
|
||||||
|
<Text style={styles.addMoodButtonText}>记录</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedDay ? (
|
||||||
|
selectedDateMood ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.moodRecord}
|
||||||
|
onPress={openMoodEdit}
|
||||||
|
>
|
||||||
|
<View style={styles.recordIcon}>
|
||||||
|
<View style={styles.moodIcon}>
|
||||||
|
<Text style={styles.moodEmoji}>
|
||||||
|
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.emoji || '😊'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.recordContent}>
|
||||||
|
<Text style={styles.recordMood}>
|
||||||
|
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.recordIntensity}>强度: {selectedDateMood.intensity}</Text>
|
||||||
|
{selectedDateMood.description && (
|
||||||
|
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
<Text style={styles.recordTime}>
|
||||||
|
{dayjs(selectedDateMood.createdAt).format('HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRecord}>
|
||||||
|
<Text style={styles.emptyRecordText}>暂无心情记录</Text>
|
||||||
|
<Text style={styles.emptyRecordSubtext}>点击右上角"记录"按钮添加心情</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRecord}>
|
||||||
|
<Text style={styles.emptyRecordText}>请先选择一个日期</Text>
|
||||||
|
<Text style={styles.emptyRecordSubtext}>点击日历中的日期,然后点击"记录"按钮添加心情</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
444
app/mood/edit.tsx
Normal file
444
app/mood/edit.tsx
Normal file
@@ -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<MoodType | ''>('');
|
||||||
|
const [intensity, setIntensity] = useState(5);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [existingMood, setExistingMood] = useState<any>(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 (
|
||||||
|
<View style={styles.intensityContainer}>
|
||||||
|
<Text style={styles.intensityLabel}>心情强度: {intensity}</Text>
|
||||||
|
<View style={styles.intensitySlider}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={level}
|
||||||
|
style={[
|
||||||
|
styles.intensityDot,
|
||||||
|
intensity >= level && styles.intensityDotActive
|
||||||
|
]}
|
||||||
|
onPress={() => setIntensity(level)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.intensityLabels}>
|
||||||
|
<Text style={styles.intensityLabelText}>轻微</Text>
|
||||||
|
<Text style={styles.intensityLabelText}>强烈</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用统一的渐变背景色
|
||||||
|
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={backgroundGradientColors}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()}>
|
||||||
|
<Text style={styles.backButton}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>
|
||||||
|
{existingMood ? '编辑心情' : '记录心情'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{/* 日期显示 */}
|
||||||
|
<View style={styles.dateSection}>
|
||||||
|
<Text style={styles.dateTitle}>
|
||||||
|
{dayjs(selectedDate).format('YYYY年M月D日')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 心情选择 */}
|
||||||
|
<View style={styles.moodSection}>
|
||||||
|
<Text style={styles.sectionTitle}>选择心情</Text>
|
||||||
|
<View style={styles.moodOptions}>
|
||||||
|
{moodOptions.map((mood, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.moodOption,
|
||||||
|
selectedMood === mood.type && styles.selectedMoodOption
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedMood(mood.type)}
|
||||||
|
>
|
||||||
|
<Text style={styles.moodEmoji}>{mood.emoji}</Text>
|
||||||
|
<Text style={styles.moodLabel}>{mood.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 心情强度选择 */}
|
||||||
|
{selectedMood && (
|
||||||
|
<View style={styles.intensitySection}>
|
||||||
|
<Text style={styles.sectionTitle}>心情强度</Text>
|
||||||
|
{renderIntensitySlider()}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 心情描述 */}
|
||||||
|
{selectedMood && (
|
||||||
|
<View style={styles.descriptionSection}>
|
||||||
|
<Text style={styles.sectionTitle}>心情描述(可选)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.descriptionInput}
|
||||||
|
placeholder="描述一下你的心情..."
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
maxLength={200}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
<Text style={styles.characterCount}>{description.length}/200</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
{existingMood && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.deleteButton, isDeleting && styles.disabledButton]}
|
||||||
|
onPress={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Text style={styles.deleteButtonText}>
|
||||||
|
{isDeleting ? '删除中...' : '删除记录'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, (!selectedMood || isLoading) && styles.disabledButton]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!selectedMood || isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
|
|
||||||
export default function OnboardingLayout() {
|
|
||||||
return (
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="personal-info" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
|
||||||
<StatusBar
|
|
||||||
barStyle={'dark-content'}
|
|
||||||
backgroundColor={backgroundColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 跳过按钮 */}
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
|
||||||
<ThemedText style={[styles.skipText, { color: textColor }]}>
|
|
||||||
跳过
|
|
||||||
</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
|
||||||
<View style={styles.contentContainer}>
|
|
||||||
{/* Logo 或插图区域 */}
|
|
||||||
<View style={styles.imageContainer}>
|
|
||||||
<View style={[styles.logoPlaceholder, { backgroundColor: primaryColor }]}>
|
|
||||||
<Text style={styles.logoText}>🧘♀️</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 标题和描述 */}
|
|
||||||
<View style={styles.textContainer}>
|
|
||||||
<ThemedText type="title" style={styles.title}>
|
|
||||||
欢迎来到数字普拉提
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={[styles.subtitle, { color: textColor + '90' }]}>
|
|
||||||
让我们一起开始您的健康之旅{'\n'}
|
|
||||||
个性化的普拉提体验正等着您
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 特色功能点 */}
|
|
||||||
<View style={styles.featuresContainer}>
|
|
||||||
{[
|
|
||||||
{ icon: '📊', title: '个性化训练', desc: '根据您的身体状况定制训练计划' },
|
|
||||||
{ icon: '🤖', title: 'AI 姿态分析', desc: '实时纠正您的动作姿态' },
|
|
||||||
{ icon: '📈', title: '进度追踪', desc: '记录您的每一次进步' },
|
|
||||||
].map((feature, index) => (
|
|
||||||
<View key={index} style={styles.featureItem}>
|
|
||||||
<Text style={styles.featureIcon}>{feature.icon}</Text>
|
|
||||||
<View style={styles.featureTextContainer}>
|
|
||||||
<ThemedText style={[styles.featureTitle, { color: textColor }]}>
|
|
||||||
{feature.title}
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={[styles.featureDesc, { color: textColor + '70' }]}>
|
|
||||||
{feature.desc}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<View style={styles.buttonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.getStartedButton, { backgroundColor: primaryColor }]}
|
|
||||||
onPress={handleGetStarted}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={styles.getStartedButtonText}>开始体验</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.laterButton} onPress={handleSkip}>
|
|
||||||
<ThemedText style={[styles.laterButtonText, { color: textColor + '70' }]}>
|
|
||||||
稍后再说
|
|
||||||
</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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<PersonalInfo>({
|
|
||||||
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 = () => (
|
|
||||||
<View style={styles.optionsContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.genderOption,
|
|
||||||
{ borderColor: primaryColor },
|
|
||||||
personalInfo.gender === 'female' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
|
|
||||||
]}
|
|
||||||
onPress={() => handleGenderSelect('female')}
|
|
||||||
>
|
|
||||||
<Text style={styles.genderIcon}>👩</Text>
|
|
||||||
<ThemedText style={[styles.genderText, { color: textColor }]}>女性</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.genderOption,
|
|
||||||
{ borderColor: primaryColor },
|
|
||||||
personalInfo.gender === 'male' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
|
|
||||||
]}
|
|
||||||
onPress={() => handleGenderSelect('male')}
|
|
||||||
>
|
|
||||||
<Text style={styles.genderIcon}>👨</Text>
|
|
||||||
<ThemedText style={[styles.genderText, { color: textColor }]}>男性</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderNumberInput = (
|
|
||||||
field: 'age' | 'height' | 'weight',
|
|
||||||
placeholder: string,
|
|
||||||
unit: string
|
|
||||||
) => (
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<View style={[styles.inputWrapper, { borderColor: iconColor + '30' }]}>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.numberInput, { color: textColor }]}
|
|
||||||
value={personalInfo[field]}
|
|
||||||
onChangeText={(value) => handleInputChange(field, value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={iconColor}
|
|
||||||
keyboardType="numeric"
|
|
||||||
maxLength={field === 'age' ? 3 : 4}
|
|
||||||
/>
|
|
||||||
<ThemedText style={[styles.unitText, { color: iconColor }]}>{unit}</ThemedText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
|
||||||
<StatusBar
|
|
||||||
barStyle={'dark-content'}
|
|
||||||
backgroundColor={backgroundColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 顶部导航 */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
{currentStep > 0 && (
|
|
||||||
<TouchableOpacity style={styles.backButton} onPress={handlePrevious}>
|
|
||||||
<ThemedText style={[styles.backText, { color: textColor }]}>‹ 返回</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
|
||||||
<ThemedText style={[styles.skipText, { color: iconColor }]}>跳过</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
|
||||||
<View style={styles.progressContainer}>
|
|
||||||
<View style={[styles.progressBackground, { backgroundColor: iconColor + '20' }]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.progressBar,
|
|
||||||
{
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
width: `${((currentStep + 1) / steps.length) * 100}%`
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<ThemedText style={[styles.progressText, { color: iconColor }]}>
|
|
||||||
{currentStep + 1} / {steps.length}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
style={styles.content}
|
|
||||||
contentContainerStyle={styles.contentContainer}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<View style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title" style={styles.title}>
|
|
||||||
{steps[currentStep].title}
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={[styles.subtitle, { color: textColor + '80' }]}>
|
|
||||||
{steps[currentStep].subtitle}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
{renderStepContent()}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<View style={styles.buttonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.nextButton,
|
|
||||||
{ backgroundColor: isStepCompleted() ? primaryColor : iconColor + '30' }
|
|
||||||
]}
|
|
||||||
onPress={handleNext}
|
|
||||||
disabled={!isStepCompleted()}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.nextButtonText,
|
|
||||||
{ color: isStepCompleted() ? '#192126' : iconColor }
|
|
||||||
]}>
|
|
||||||
{currentStep === steps.length - 1 ? '完成' : '下一步'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,76 +1,111 @@
|
|||||||
|
import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { ThemedText } from './ThemedText';
|
|
||||||
import { ThemedView } from './ThemedView';
|
|
||||||
|
|
||||||
interface MoodCardProps {
|
interface MoodCardProps {
|
||||||
|
moodCheckin: MoodCheckin | null;
|
||||||
onPress: () => void;
|
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 (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} disabled={isLoading}>
|
||||||
<View style={styles.header}>
|
<View style={styles.cardHeaderRow}>
|
||||||
<ThemedText style={styles.title}>心情</ThemedText>
|
<View style={styles.moodIconContainer}>
|
||||||
<ThemedText style={styles.subtitle}>记录你的每日心情</ThemedText>
|
{moodCheckin ? (
|
||||||
|
<Text style={styles.moodIcon}>
|
||||||
|
{moodConfig?.emoji || '😊'}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.moodIcon}>😊</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardTitle}>心情</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.content} onPress={onPress}>
|
<Text style={styles.moodSubtitle}>记录你的每日心情</Text>
|
||||||
<View style={styles.moodIcon}>
|
|
||||||
<Text style={styles.emoji}>😊</Text>
|
{isLoading ? (
|
||||||
|
<View style={styles.moodPreview}>
|
||||||
|
<Text style={styles.moodLoadingText}>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
<ThemedText style={styles.moodText}>点击记录今日心情</ThemedText>
|
) : moodCheckin ? (
|
||||||
</TouchableOpacity>
|
<View style={styles.moodPreview}>
|
||||||
</ThemedView>
|
<Text style={styles.moodPreviewText}>
|
||||||
|
{moodConfig?.label || moodCheckin.moodType}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.moodPreviewTime}>
|
||||||
|
{dayjs(moodCheckin.createdAt).format('HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.moodEmptyText}>点击记录心情</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
moodCardContent: {
|
||||||
backgroundColor: '#fff',
|
width: '100%',
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 3.84,
|
|
||||||
elevation: 5,
|
|
||||||
},
|
},
|
||||||
header: {
|
cardHeaderRow: {
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
moodIconContainer: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#DCFCE7',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 10,
|
||||||
},
|
},
|
||||||
moodIcon: {
|
moodIcon: {
|
||||||
width: 40,
|
fontSize: 14,
|
||||||
height: 40,
|
},
|
||||||
borderRadius: 20,
|
cardTitle: {
|
||||||
backgroundColor: '#f0f0f0',
|
fontSize: 14,
|
||||||
justifyContent: 'center',
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
moodSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
moodPreview: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: 12,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
emoji: {
|
moodPreviewText: {
|
||||||
fontSize: 20,
|
fontSize: 14,
|
||||||
|
color: '#059669',
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
moodText: {
|
moodPreviewTime: {
|
||||||
fontSize: 16,
|
fontSize: 12,
|
||||||
flex: 1,
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
moodEmptyText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
moodLoadingText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
199
components/MoodHistoryCard.tsx
Normal file
199
components/MoodHistoryCard.tsx
Normal file
@@ -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<string, number>,
|
||||||
|
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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
|
||||||
|
{moodCheckins.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>暂无心情记录</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{moodStats.total}</Text>
|
||||||
|
<Text style={styles.statLabel}>总记录</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{moodStats.averageIntensity}</Text>
|
||||||
|
<Text style={styles.statLabel}>平均强度</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{moodStats.mostFrequentMood}</Text>
|
||||||
|
<Text style={styles.statLabel}>最常见</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 最近记录 */}
|
||||||
|
<View style={styles.recentContainer}>
|
||||||
|
<Text style={styles.sectionTitle}>最近记录</Text>
|
||||||
|
{recentMoods.map((checkin, index) => {
|
||||||
|
const moodConfig = getMoodConfig(checkin.moodType);
|
||||||
|
return (
|
||||||
|
<View key={checkin.id} style={styles.moodItem}>
|
||||||
|
<View style={styles.moodInfo}>
|
||||||
|
<Text style={styles.moodEmoji}>{moodConfig?.emoji}</Text>
|
||||||
|
<View style={styles.moodDetails}>
|
||||||
|
<Text style={styles.moodLabel}>{moodConfig?.label}</Text>
|
||||||
|
<Text style={styles.moodDate}>
|
||||||
|
{dayjs(checkin.createdAt).format('MM月DD日 HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.moodIntensity}>
|
||||||
|
<Text style={styles.intensityText}>强度 {checkin.intensity}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string>('');
|
|
||||||
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 (
|
|
||||||
<View style={[styles.moodIconContainer, { backgroundColor: mood?.color }]}>
|
|
||||||
<View style={styles.bearIcon}>
|
|
||||||
<Text style={styles.bearEmoji}>🐻</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.defaultMoodIcon}>
|
|
||||||
<Text style={styles.defaultMoodEmoji}>😊</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
animationType="slide"
|
|
||||||
presentationStyle="pageSheet"
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<SafeAreaView style={styles.container}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<TouchableOpacity onPress={onClose}>
|
|
||||||
<Text style={styles.backButton}>←</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>{year}年{monthNames[month - 1]}</Text>
|
|
||||||
<TouchableOpacity>
|
|
||||||
<Text style={styles.nextButton}>→</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView style={styles.content}>
|
|
||||||
{/* 日历视图 */}
|
|
||||||
<View style={styles.calendar}>
|
|
||||||
<View style={styles.weekHeader}>
|
|
||||||
{weekDays.map((day, index) => (
|
|
||||||
<Text key={index} style={styles.weekDay}>{day}</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{calendar.map((week, weekIndex) => (
|
|
||||||
<View key={weekIndex} style={styles.weekRow}>
|
|
||||||
{week.map((day, dayIndex) => (
|
|
||||||
<View key={dayIndex} style={styles.dayContainer}>
|
|
||||||
{day && (
|
|
||||||
<>
|
|
||||||
<Text style={[
|
|
||||||
styles.dayNumber,
|
|
||||||
day === today && styles.todayNumber
|
|
||||||
]}>
|
|
||||||
{day.toString().padStart(2, '0')}
|
|
||||||
</Text>
|
|
||||||
{renderMoodIcon(day, day === today)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 心情选择 */}
|
|
||||||
<View style={styles.moodSection}>
|
|
||||||
<Text style={styles.sectionTitle}>选择今日心情</Text>
|
|
||||||
<View style={styles.moodOptions}>
|
|
||||||
{moodOptions.map((mood, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={index}
|
|
||||||
style={[
|
|
||||||
styles.moodOption,
|
|
||||||
selectedMood === mood.label && styles.selectedMoodOption
|
|
||||||
]}
|
|
||||||
onPress={() => setSelectedMood(mood.label)}
|
|
||||||
>
|
|
||||||
<Text style={styles.moodEmoji}>{mood.emoji}</Text>
|
|
||||||
<Text style={styles.moodLabel}>{mood.label}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 近期记录 */}
|
|
||||||
<View style={styles.recentSection}>
|
|
||||||
<Text style={styles.sectionTitle}>近期记录</Text>
|
|
||||||
<Text style={styles.recentDate}>{year}年{month}月{today}日</Text>
|
|
||||||
|
|
||||||
{selectedMood && (
|
|
||||||
<View style={styles.recentRecord}>
|
|
||||||
<View style={styles.recordIcon}>
|
|
||||||
<View style={styles.bearIcon}>
|
|
||||||
<Text style={styles.bearEmoji}>🐻</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.recordMood}>{selectedMood}</Text>
|
|
||||||
<View style={styles.spacer} />
|
|
||||||
<Text style={styles.recordTime}>
|
|
||||||
{new Date().getHours()}:{new Date().getMinutes().toString().padStart(2, '0')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 保存按钮 */}
|
|
||||||
<View style={styles.footer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.saveButton, !selectedMood && styles.disabledButton]}
|
|
||||||
onPress={handleSave}
|
|
||||||
disabled={!selectedMood}
|
|
||||||
>
|
|
||||||
<Text style={styles.saveButtonText}>保存心情</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 添加按钮 */}
|
|
||||||
<TouchableOpacity style={styles.addButton} onPress={handleSave}>
|
|
||||||
<Text style={styles.addButtonText}>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</SafeAreaView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -40,7 +40,7 @@ export default function PrivacyConsentModal({
|
|||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>欢迎来到普拉提助手</Text>
|
<Text style={styles.title}>欢迎来到Sealife</Text>
|
||||||
|
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
<Text style={styles.description}>
|
<Text style={styles.description}>
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ export const ROUTES = {
|
|||||||
LEGAL_USER_AGREEMENT: '/legal/user-agreement',
|
LEGAL_USER_AGREEMENT: '/legal/user-agreement',
|
||||||
LEGAL_PRIVACY_POLICY: '/legal/privacy-policy',
|
LEGAL_PRIVACY_POLICY: '/legal/privacy-policy',
|
||||||
|
|
||||||
// 引导页路由
|
|
||||||
ONBOARDING: '/onboarding',
|
|
||||||
ONBOARDING_PERSONAL_INFO: '/onboarding/personal-info',
|
|
||||||
|
|
||||||
// 营养相关路由
|
// 营养相关路由
|
||||||
NUTRITION_RECORDS: '/nutrition/records',
|
NUTRITION_RECORDS: '/nutrition/records',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
196
docs/mood-checkin-implementation.md
Normal file
196
docs/mood-checkin-implementation.md
Normal file
@@ -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
|
||||||
|
<MoodModal
|
||||||
|
visible={moodModalVisible}
|
||||||
|
onClose={() => 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. **兼容性**: 确保在不同设备和系统版本上的兼容性
|
||||||
127
docs/mood-checkin-test.md
Normal file
127
docs/mood-checkin-test.md
Normal file
@@ -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. 用户体验没有受到影响
|
||||||
205
docs/mood-modal-optimization.md
Normal file
205
docs/mood-modal-optimization.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# 心情打卡弹窗优化功能说明
|
||||||
|
|
||||||
|
## 优化内容
|
||||||
|
|
||||||
|
### 1. 月份导航功能
|
||||||
|
- ✅ 添加了左右箭头按钮,支持月份切换
|
||||||
|
- ✅ 月份标题居中显示,格式为"2025年8月"
|
||||||
|
- ✅ 左箭头为深色,右箭头为浅灰色(符合UI设计)
|
||||||
|
- ✅ 点击箭头可以切换到上个月或下个月
|
||||||
|
|
||||||
|
### 2. 日期选择优化
|
||||||
|
- ✅ 支持选择任意月份的任意日期
|
||||||
|
- ✅ 选中日期有高亮显示(绿色背景)
|
||||||
|
- ✅ 今天日期有特殊标识(绿色边框)
|
||||||
|
- ✅ 未来日期禁用选择(灰色显示)
|
||||||
|
- ✅ 选择日期后自动加载对应的心情记录
|
||||||
|
|
||||||
|
### 3. 交互逻辑优化
|
||||||
|
- ✅ 月份切换时重置选中日期
|
||||||
|
- ✅ 选择日期时自动加载该日期的心情数据
|
||||||
|
- ✅ 如果该日期已有心情记录,自动填充并支持更新
|
||||||
|
- ✅ 保存时必须同时选择日期和心情
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 1. 状态管理
|
||||||
|
```typescript
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [selectedDay, setSelectedDay] = useState<number | null>(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
|
||||||
|
{/* 月份导航 */}
|
||||||
|
<View style={styles.monthNavigation}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.navButton}
|
||||||
|
onPress={goToPreviousMonth}
|
||||||
|
>
|
||||||
|
<Text style={styles.navButtonText}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.monthTitle}>{year}年{monthNames[month - 1]}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.navButton}
|
||||||
|
onPress={goToNextMonth}
|
||||||
|
>
|
||||||
|
<Text style={styles.navButtonText}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 样式设计
|
||||||
|
|
||||||
|
### 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. **数据统计**: 在日历上显示心情统计信息
|
||||||
152
docs/mood-redux-migration.md
Normal file
152
docs/mood-redux-migration.md
Normal file
@@ -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 <Loading />;
|
||||||
|
|
||||||
|
return <div>{latestRecord?.moodType}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **类型安全**: 所有 API 调用都有完整的 TypeScript 类型定义
|
||||||
|
2. **错误处理**: Redux 提供了统一的错误处理机制
|
||||||
|
3. **加载状态**: 每个操作都有对应的加载状态管理
|
||||||
|
4. **数据缓存**: Redux 会自动缓存已加载的数据,避免重复请求
|
||||||
|
|
||||||
|
## 后续优化建议
|
||||||
|
|
||||||
|
1. **数据持久化**: 考虑使用 Redux Persist 进行数据持久化
|
||||||
|
2. **离线支持**: 添加离线数据同步功能
|
||||||
|
3. **实时更新**: 考虑添加 WebSocket 支持实时数据更新
|
||||||
|
4. **性能监控**: 添加性能监控和优化指标
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
1. **单元测试**: 为 Redux actions 和 reducers 编写单元测试
|
||||||
|
2. **集成测试**: 测试页面组件与 Redux 的集成
|
||||||
|
3. **端到端测试**: 测试完整的心情管理流程
|
||||||
|
4. **性能测试**: 测试大量数据下的性能表现
|
||||||
167
hooks/useMoodData.ts
Normal file
167
hooks/useMoodData.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
141
services/moodCheckins.ts
Normal file
141
services/moodCheckins.ts
Normal file
@@ -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<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建心情打卡请求
|
||||||
|
export type CreateMoodCheckinDto = {
|
||||||
|
moodType: MoodType;
|
||||||
|
intensity: number; // 1-10
|
||||||
|
description?: string;
|
||||||
|
checkinDate?: string; // YYYY-MM-DD,默认今天
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新心情打卡请求
|
||||||
|
export type UpdateMoodCheckinDto = {
|
||||||
|
id: string;
|
||||||
|
moodType?: MoodType;
|
||||||
|
intensity?: number;
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除心情打卡请求
|
||||||
|
export type DeleteMoodCheckinDto = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 心情统计数据
|
||||||
|
export type MoodStatistics = {
|
||||||
|
totalCheckins: number;
|
||||||
|
averageIntensity: number;
|
||||||
|
moodDistribution: Record<MoodType, number>;
|
||||||
|
mostFrequentMood: MoodType;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建心情打卡
|
||||||
|
export async function createMoodCheckin(dto: CreateMoodCheckinDto): Promise<MoodCheckin> {
|
||||||
|
return await api.post('/api/mood-checkins', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新心情打卡
|
||||||
|
export async function updateMoodCheckin(dto: UpdateMoodCheckinDto): Promise<MoodCheckin> {
|
||||||
|
return await api.put('/api/mood-checkins', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除心情打卡
|
||||||
|
export async function deleteMoodCheckin(dto: DeleteMoodCheckinDto): Promise<boolean> {
|
||||||
|
return await api.delete('/api/mood-checkins', { body: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取每日心情记录
|
||||||
|
export async function getDailyMoodCheckins(date?: string): Promise<MoodCheckin[]> {
|
||||||
|
const path = date ? `/api/mood-checkins/daily?date=${encodeURIComponent(date)}` : '/api/mood-checkins/daily';
|
||||||
|
const data = await api.get<MoodCheckin[]>(path);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取心情历史记录
|
||||||
|
export async function getMoodCheckinsHistory(params: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
moodType?: MoodType;
|
||||||
|
}): Promise<MoodCheckin[]> {
|
||||||
|
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<MoodCheckin[]>(path);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取心情统计数据
|
||||||
|
export async function getMoodStatistics(params: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}): Promise<MoodStatistics> {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
startDate: params.startDate,
|
||||||
|
endDate: params.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = `/api/mood-checkins/statistics?${queryParams.toString()}`;
|
||||||
|
return await api.get<MoodStatistics>(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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
|||||||
import challengeReducer from './challengeSlice';
|
import challengeReducer from './challengeSlice';
|
||||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||||
|
import moodReducer from './moodSlice';
|
||||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
import trainingPlanReducer from './trainingPlanSlice';
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
@@ -40,6 +41,7 @@ export const store = configureStore({
|
|||||||
user: userReducer,
|
user: userReducer,
|
||||||
challenge: challengeReducer,
|
challenge: challengeReducer,
|
||||||
checkin: checkinReducer,
|
checkin: checkinReducer,
|
||||||
|
mood: moodReducer,
|
||||||
trainingPlan: trainingPlanReducer,
|
trainingPlan: trainingPlanReducer,
|
||||||
scheduleExercise: scheduleExerciseReducer,
|
scheduleExercise: scheduleExerciseReducer,
|
||||||
exerciseLibrary: exerciseLibraryReducer,
|
exerciseLibrary: exerciseLibraryReducer,
|
||||||
|
|||||||
324
store/moodSlice.ts
Normal file
324
store/moodSlice.ts
Normal file
@@ -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<string, MoodCheckin[]>;
|
||||||
|
// 当前选中的日期
|
||||||
|
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<MoodType, number>;
|
||||||
|
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<string>) => {
|
||||||
|
state.selectedDate = action.payload;
|
||||||
|
// 如果该日期没有记录,设置为 null
|
||||||
|
const records = state.moodRecords[action.payload];
|
||||||
|
state.selectedMoodRecord = records && records.length > 0 ? records[0] : null;
|
||||||
|
},
|
||||||
|
// 设置选中的心情记录
|
||||||
|
setSelectedMoodRecord: (state, action: PayloadAction<MoodCheckin | null>) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -41,7 +41,7 @@ export type UserState = {
|
|||||||
activityHistory: ActivityHistoryItem[];
|
activityHistory: ActivityHistoryItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
|
export const DEFAULT_MEMBER_NAME = '小海豹';
|
||||||
|
|
||||||
const initialState: UserState = {
|
const initialState: UserState = {
|
||||||
token: null,
|
token: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user