diff --git a/app/(tabs)/goals.tsx b/app/(tabs)/goals.tsx index e1b84e8..2c2d912 100644 --- a/app/(tabs)/goals.tsx +++ b/app/(tabs)/goals.tsx @@ -1,107 +1,132 @@ +import CreateGoalModal from '@/components/CreateGoalModal'; import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector'; import { TimelineSchedule } from '@/components/TimelineSchedule'; import { TodoItem } from '@/components/TodoCard'; import { TodoCarousel } from '@/components/TodoCarousel'; import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { clearErrors, completeGoal, createGoal, fetchGoals } from '@/store/goalsSlice'; +import { CreateGoalRequest, GoalListItem } from '@/types/goals'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; +import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; import isBetween from 'dayjs/plugin/isBetween'; import { LinearGradient } from 'expo-linear-gradient'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; dayjs.extend(isBetween); -// 模拟数据 -const mockTodos: TodoItem[] = [ - { - id: '1', - title: '每日健身训练', - description: '完成30分钟普拉提训练', - time: dayjs().hour(8).minute(0).toISOString(), - category: 'workout', - priority: 'high', - isCompleted: false, - }, - { - id: '2', - title: '支付信用卡账单', - description: '本月信用卡账单到期', - time: dayjs().hour(10).minute(0).toISOString(), - category: 'finance', - priority: 'medium', - isCompleted: false, - }, - { - id: '3', - title: '参加瑜伽课程', - description: '晚上瑜伽课程预约', - time: dayjs().hour(19).minute(0).toISOString(), - category: 'personal', - priority: 'low', - isCompleted: true, - }, -]; +// 将目标转换为TodoItem的辅助函数 +const goalToTodoItem = (goal: GoalListItem): TodoItem => { + return { + id: goal.id, + title: goal.title, + description: goal.description || `${goal.frequency}次/${getRepeatTypeLabel(goal.repeatType)}`, + time: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString() || '', -const mockTimelineEvents = [ - { - id: '1', - title: '每日健身训练', - startTime: dayjs().hour(8).minute(0).toISOString(), - endTime: dayjs().hour(8).minute(30).toISOString(), - category: 'workout' as const, - isCompleted: false, - }, - { - id: '2', - title: '支付信用卡账单', - startTime: dayjs().hour(10).minute(0).toISOString(), - endTime: dayjs().hour(10).minute(15).toISOString(), - category: 'finance' as const, - isCompleted: false, - }, - { - id: '3', - title: '团队会议', - startTime: dayjs().hour(10).minute(0).toISOString(), - endTime: dayjs().hour(11).minute(0).toISOString(), - category: 'work' as const, - isCompleted: false, - }, - { - id: '4', - title: '午餐时间', - startTime: dayjs().hour(12).minute(0).toISOString(), - endTime: dayjs().hour(13).minute(0).toISOString(), - category: 'personal' as const, - isCompleted: true, - }, - { - id: '5', - title: '健康检查', - startTime: dayjs().hour(14).minute(30).toISOString(), - endTime: dayjs().hour(15).minute(30).toISOString(), - category: 'health' as const, - isCompleted: false, - }, - { - id: '6', - title: '参加瑜伽课程', - startTime: dayjs().hour(19).minute(0).toISOString(), - endTime: dayjs().hour(20).minute(0).toISOString(), - category: 'personal' as const, - isCompleted: true, - }, -]; + category: getCategoryFromGoal(goal.category), + priority: getPriorityFromGoal(goal.priority), + isCompleted: goal.status === 'completed', + }; +}; + +// 获取重复类型标签 +const getRepeatTypeLabel = (repeatType: string): string => { + switch (repeatType) { + case 'daily': return '每日'; + case 'weekly': return '每周'; + case 'monthly': return '每月'; + default: return '自定义'; + } +}; + +// 从目标分类获取TodoItem分类 +const getCategoryFromGoal = (category?: string): TodoItem['category'] => { + if (!category) return 'personal'; + if (category.includes('运动') || category.includes('健身')) return 'workout'; + if (category.includes('工作')) return 'work'; + if (category.includes('健康')) return 'health'; + if (category.includes('财务')) return 'finance'; + return 'personal'; +}; + +// 从目标优先级获取TodoItem优先级 +const getPriorityFromGoal = (priority: number): TodoItem['priority'] => { + if (priority >= 8) return 'high'; + if (priority >= 5) return 'medium'; + return 'low'; +}; + +// 将目标转换为时间轴事件的辅助函数 +const goalToTimelineEvent = (goal: GoalListItem) => { + + return { + id: goal.id, + title: goal.title, + startTime: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString(), + endTime: goal.endTime ? dayjs().startOf('day').add(goal.endTime, 'minute').toISOString() : undefined, + category: getCategoryFromGoal(goal.category), + isCompleted: goal.status === 'completed', + }; +}; export default function GoalsScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; + const dispatch = useAppDispatch(); + + // Redux状态 + const { + goals, + goalsLoading, + goalsError, + createLoading, + createError + } = useAppSelector((state) => state.goals); const [selectedTab, setSelectedTab] = useState('day'); const [selectedDate, setSelectedDate] = useState(new Date()); - const [todos, setTodos] = useState(mockTodos); + const [showCreateModal, setShowCreateModal] = useState(false); + + // 页面聚焦时重新加载数据 + useFocusEffect( + useCallback(() => { + console.log('useFocusEffect'); + // 只在需要时刷新数据,比如从后台返回或从其他页面返回 + dispatch(fetchGoals({ + status: 'active', + page: 1, + pageSize: 200, + })); + }, [dispatch]) + ); + + // 处理错误提示 + useEffect(() => { + console.log('goalsError', goalsError); + console.log('createError', createError); + if (goalsError) { + Alert.alert('错误', goalsError); + dispatch(clearErrors()); + } + if (createError) { + Alert.alert('创建失败', createError); + dispatch(clearErrors()); + } + }, [goalsError, createError, dispatch]); + + // 创建目标处理函数 + const handleCreateGoal = async (goalData: CreateGoalRequest) => { + try { + await dispatch(createGoal(goalData)).unwrap(); + setShowCreateModal(false); + Alert.alert('成功', '目标创建成功!'); + } catch (error) { + // 错误已在useEffect中处理 + } + }; // tab切换处理函数 const handleTabChange = (tab: TimeTabType) => { @@ -181,51 +206,68 @@ export default function GoalsScreen() { } }; - // 上半部分待办卡片始终只显示当日数据 + // 将目标转换为TodoItem数据 const todayTodos = useMemo(() => { const today = dayjs(); - return todos.filter(todo => - dayjs(todo.time).isSame(today, 'day') + const activeGoals = goals.filter(goal => + goal.status === 'active' && + (goal.repeatType === 'daily' || + (goal.repeatType === 'weekly' && today.day() !== 0) || + (goal.repeatType === 'monthly' && today.date() <= 28)) ); - }, [todos]); + return activeGoals.map(goalToTodoItem); + }, [goals]); - // 下半部分时间轴根据选择的时间范围和日期过滤数据 + // 将目标转换为时间轴事件数据 const filteredTimelineEvents = useMemo(() => { const selected = dayjs(selectedDate); + let filteredGoals: GoalListItem[] = []; switch (selectedTab) { case 'day': - return mockTimelineEvents.filter(event => - dayjs(event.startTime).isSame(selected, 'day') - ); + filteredGoals = goals.filter(goal => { + if (goal.status !== 'active') return false; + if (goal.repeatType === 'daily') return true; + if (goal.repeatType === 'weekly') return selected.day() !== 0; + if (goal.repeatType === 'monthly') return selected.date() <= 28; + return false; + }); + break; case 'week': - return mockTimelineEvents.filter(event => - dayjs(event.startTime).isSame(selected, 'week') + filteredGoals = goals.filter(goal => + goal.status === 'active' && + (goal.repeatType === 'daily' || goal.repeatType === 'weekly') ); + break; case 'month': - return mockTimelineEvents.filter(event => - dayjs(event.startTime).isSame(selected, 'month') - ); + filteredGoals = goals.filter(goal => goal.status === 'active'); + break; default: - return mockTimelineEvents; + filteredGoals = goals.filter(goal => goal.status === 'active'); } - }, [selectedTab, selectedDate]); + return filteredGoals.map(goalToTimelineEvent); + }, [selectedTab, selectedDate, goals]); + console.log('filteredTimelineEvents', filteredTimelineEvents); const handleTodoPress = (item: TodoItem) => { - console.log('Todo pressed:', item.title); - // 这里可以导航到详情页面或展示编辑模态框 + console.log('Goal pressed:', item.title); + // 这里可以导航到目标详情页面 }; - const handleToggleComplete = (item: TodoItem) => { - setTodos(prevTodos => - prevTodos.map(todo => - todo.id === item.id - ? { ...todo, isCompleted: !todo.isCompleted } - : todo - ) - ); + const handleToggleComplete = async (item: TodoItem) => { + try { + await dispatch(completeGoal({ + goalId: item.id, + completionData: { + completionCount: 1, + notes: '通过待办卡片完成' + } + })).unwrap(); + } catch (error) { + Alert.alert('错误', '记录完成失败'); + } }; const handleEventPress = (event: any) => { @@ -255,9 +297,12 @@ export default function GoalsScreen() { 今日 - - {dayjs().format('YYYY年M月D日 dddd')} - + setShowCreateModal(true)} + > + + + {/* 今日待办事项卡片 */} @@ -323,7 +368,6 @@ export default function GoalsScreen() { isFutureDate && styles.dayDateDisabled ]}>{d.dayOfMonth} - {selected && } ); })} @@ -339,6 +383,14 @@ export default function GoalsScreen() { onEventPress={handleEventPress} /> + + {/* 创建目标弹窗 */} + setShowCreateModal(false)} + onSubmit={handleCreateGoal} + loading={createLoading} + /> ); @@ -359,6 +411,9 @@ const styles = StyleSheet.create({ flex: 1, }, header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16, @@ -368,6 +423,25 @@ const styles = StyleSheet.create({ fontWeight: '800', marginBottom: 4, }, + addButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#6366F1', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + addButtonText: { + color: '#FFFFFF', + fontSize: 24, + fontWeight: '600', + lineHeight: 24, + }, pageSubtitle: { fontSize: 16, fontWeight: '500', @@ -377,7 +451,6 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255, 255, 255, 0.95)', borderTopLeftRadius: 24, borderTopRightRadius: 24, - marginTop: 8, overflow: 'hidden', }, // 日期选择器样式 (参考 statistics.tsx) @@ -399,8 +472,8 @@ const styles = StyleSheet.create({ marginRight: 8, }, dayPill: { - width: 48, - height: 72, + width: 40, + height: 60, borderRadius: 24, alignItems: 'center', justifyContent: 'center', @@ -421,7 +494,7 @@ const styles = StyleSheet.create({ opacity: 0.4, }, dayLabel: { - fontSize: 12, + fontSize: 11, fontWeight: '700', color: 'gray', marginBottom: 2, @@ -432,7 +505,7 @@ const styles = StyleSheet.create({ dayLabelDisabled: { }, dayDate: { - fontSize: 14, + fontSize: 12, fontWeight: '800', color: 'gray', }, @@ -441,12 +514,4 @@ const styles = StyleSheet.create({ }, dayDateDisabled: { }, - selectedDot: { - width: 5, - height: 5, - borderRadius: 2.5, - marginTop: 6, - marginBottom: 2, - alignSelf: 'center', - }, }); diff --git a/components/CreateGoalModal.tsx b/components/CreateGoalModal.tsx new file mode 100644 index 0000000..3e48c9d --- /dev/null +++ b/components/CreateGoalModal.tsx @@ -0,0 +1,523 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import React, { useState } from 'react'; +import { + Alert, + Modal, + Platform, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +interface CreateGoalModalProps { + visible: boolean; + onClose: () => void; + onSubmit: (goalData: CreateGoalRequest) => void; + loading?: boolean; +} + +const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [ + { value: 'daily', label: '每日' }, + { value: 'weekly', label: '每周' }, + { value: 'monthly', label: '每月' }, + { value: 'custom', label: '自定义' }, +]; + +const FREQUENCY_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +export const CreateGoalModal: React.FC = ({ + visible, + onClose, + onSubmit, + loading = false, +}) => { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + // 表单状态 + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [repeatType, setRepeatType] = useState('daily'); + const [frequency, setFrequency] = useState(1); + const [hasReminder, setHasReminder] = useState(false); + const [reminderTime, setReminderTime] = useState('20:00'); + const [category, setCategory] = useState(''); + const [priority, setPriority] = useState(5); + const [showTimePicker, setShowTimePicker] = useState(false); + const [tempSelectedTime, setTempSelectedTime] = useState(null); + + // 重置表单 + const resetForm = () => { + setTitle(''); + setDescription(''); + setRepeatType('daily'); + setFrequency(1); + setHasReminder(false); + setReminderTime('19:00'); + setCategory(''); + setPriority(5); + }; + + // 处理关闭 + const handleClose = () => { + if (!loading) { + resetForm(); + onClose(); + } + }; + + // 处理提交 + const handleSubmit = () => { + if (!title.trim()) { + Alert.alert('提示', '请输入目标标题'); + return; + } + + // 计算startTime:从reminderTime中获取小时和分钟,转换为当天的分钟数 + let startTime: number | undefined; + if (reminderTime) { + const [hours, minutes] = reminderTime.split(':').map(Number); + startTime = hours * 60 + minutes; + } + + + + const goalData: CreateGoalRequest = { + title: title.trim(), + description: description.trim() || undefined, + repeatType, + frequency, + category: category.trim() || undefined, + priority, + hasReminder, + reminderTime: hasReminder ? reminderTime : undefined, + reminderSettings: hasReminder ? { + enabled: true, + weekdays: [1, 2, 3, 4, 5, 6, 0], // 默认每天 + } : undefined, + startTime, + }; + + console.log('goalData', goalData); + + onSubmit(goalData); + }; + + // 时间选择器 + const handleTimeChange = (event: any, selectedDate?: Date) => { + if (Platform.OS === 'android') { + // Android: 用户点击系统确认按钮后自动关闭 + if (event.type === 'set' && selectedDate) { + const hours = selectedDate.getHours().toString().padStart(2, '0'); + const minutes = selectedDate.getMinutes().toString().padStart(2, '0'); + setReminderTime(`${hours}:${minutes}`); + } + setShowTimePicker(false); + } else { + // iOS: 只在用户点击自定义确认按钮时更新 + if (selectedDate) { + setTempSelectedTime(selectedDate); + } + } + }; + + const handleConfirmTime = () => { + setShowTimePicker(false); + if (tempSelectedTime) { + const hours = tempSelectedTime.getHours().toString().padStart(2, '0'); + const minutes = tempSelectedTime.getMinutes().toString().padStart(2, '0'); + setReminderTime(`${hours}:${minutes}`); + } + setTempSelectedTime(null); + }; + + const handleCancelTime = () => { + setShowTimePicker(false); + setTempSelectedTime(null); + }; + + const showTimePickerModal = () => { + setShowTimePicker(true); + }; + + // 获取当前时间对应的Date对象 + const getCurrentTimeDate = () => { + const [hours, minutes] = reminderTime.split(':').map(Number); + const date = new Date(); + date.setHours(hours, minutes, 0, 0); + return date; + }; + + return ( + + + {/* 头部 */} + + + + ← + + + + 创建新目标 + + + + + + {/* 目标标题输入 */} + + + + 图标 + + + + {/* 装饰图案 */} + + + + + + {/* 目标重复周期 */} + + + + 🔄 + + + 目标重复周期 + + + + {REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label} + + + › + + + + + + {/* 频率设置 */} + + + + 📊 + + + 频率 + + + + {frequency} + + + › + + + + + + {/* 提醒设置 */} + + + + 🔔 + + + 提醒 + + + + + + {/* 时间设置 */} + + + + + + + 时间 + + + + {reminderTime} + + + › + + + + + + {/* 时间选择器弹窗 */} + setShowTimePicker(false)} + > + + + + {Platform.OS === 'ios' && ( + + + 取消 + + + 确定 + + + )} + + + + {/* 描述输入(可选) */} + + + + + + {/* 保存按钮 */} + + + + {loading ? '保存中...' : '保存'} + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 20, + }, + cancelButton: { + fontSize: 24, + fontWeight: '600', + }, + title: { + fontSize: 18, + fontWeight: '600', + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + section: { + marginBottom: 24, + }, + iconTitleContainer: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + marginBottom: 16, + }, + iconPlaceholder: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', + marginRight: 16, + }, + iconText: { + fontSize: 12, + color: '#9CA3AF', + fontWeight: '500', + }, + titleInput: { + flex: 1, + fontSize: 16, + fontWeight: '500', + minHeight: 60, + textAlignVertical: 'top', + }, + decorationContainer: { + alignItems: 'flex-end', + paddingRight: 20, + }, + decoration: { + width: 80, + height: 60, + backgroundColor: '#E0E7FF', + borderRadius: 40, + opacity: 0.6, + }, + optionCard: { + borderRadius: 16, + marginBottom: 12, + overflow: 'hidden', + }, + optionHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + }, + optionIcon: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#F3F4F6', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + optionIconText: { + fontSize: 16, + }, + optionLabel: { + flex: 1, + fontSize: 16, + fontWeight: '500', + }, + optionValue: { + flexDirection: 'row', + alignItems: 'center', + }, + optionValueText: { + fontSize: 16, + fontWeight: '500', + marginRight: 8, + }, + chevron: { + fontSize: 20, + fontWeight: '300', + }, + descriptionInput: { + padding: 16, + fontSize: 16, + minHeight: 80, + textAlignVertical: 'top', + }, + footer: { + padding: 20, + paddingBottom: 40, + }, + saveButton: { + backgroundColor: '#6366F1', + borderRadius: 16, + paddingVertical: 16, + alignItems: 'center', + }, + saveButtonText: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '600', + }, + modalBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.35)', + }, + modalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 8, + gap: 12, + }, + modalBtn: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 10, + backgroundColor: '#F1F5F9', + }, + modalBtnPrimary: { + backgroundColor: '#6366F1', + }, + modalBtnText: { + color: '#334155', + fontWeight: '700', + }, + modalBtnTextPrimary: { + color: '#FFFFFF', + fontWeight: '700', + }, +}); + +export default CreateGoalModal; \ No newline at end of file diff --git a/components/TimeTabSelector.tsx b/components/TimeTabSelector.tsx index e501d1a..a39e8eb 100644 --- a/components/TimeTabSelector.tsx +++ b/components/TimeTabSelector.tsx @@ -73,7 +73,7 @@ const styles = StyleSheet.create({ }, tabContainer: { flexDirection: 'row', - borderRadius: 12, + borderRadius: 20, padding: 4, shadowColor: '#000', shadowOffset: { @@ -87,7 +87,7 @@ const styles = StyleSheet.create({ tab: { paddingVertical: 12, paddingHorizontal: 32, - borderRadius: 16, + borderRadius: 20, justifyContent: 'center', alignItems: 'center', }, diff --git a/components/TimelineSchedule.tsx b/components/TimelineSchedule.tsx index 1e8d72c..c7a930f 100644 --- a/components/TimelineSchedule.tsx +++ b/components/TimelineSchedule.tsx @@ -44,7 +44,8 @@ const getEventStyle = (event: TimelineEvent) => { const endMinutes = endTime.hour() * 60 + endTime.minute(); const durationMinutes = endMinutes - startMinutes; - const top = (startMinutes / 60) * HOUR_HEIGHT; + // 计算top位置时需要加上时间标签的paddingTop偏移(8px) + const top = (startMinutes / 60) * HOUR_HEIGHT + 8; const height = Math.max((durationMinutes / 60) * HOUR_HEIGHT, 30); // 最小高度30 return { top, height }; @@ -93,9 +94,13 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin const categoryColor = getCategoryColor(event.category); // 计算水平偏移和宽度,用于处理重叠事件 - const eventWidth = (screenWidth - TIME_LABEL_WIDTH - 40) / Math.max(groupSize, 1); + const availableWidth = screenWidth - TIME_LABEL_WIDTH - 48; // 减少一些边距 + const eventWidth = availableWidth / Math.max(groupSize, 1); const leftOffset = index * eventWidth; + // 判断是否应该显示时间段 - 当卡片高度小于50或宽度小于80时隐藏时间段 + const shouldShowTimeRange = height >= 50 && eventWidth >= 80; + return ( {event.title} - - {dayjs(event.startTime).format('HH:mm')} - {event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`} - + {shouldShowTimeRange && ( + + {dayjs(event.startTime).format('HH:mm')} + {event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`} + + )} {event.isCompleted && ( @@ -146,22 +153,23 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin return ( - {/* 日期标题 */} - - - {dayjs(selectedDate).format('YYYY年M月D日 dddd')} - - - {events.length} 项任务 - - - {/* 时间轴 */} + {/* 日期标题 */} + + + {dayjs(selectedDate).format('YYYY年M月D日 dddd')} + + + {events.length} 个目标 + + + + {/* 时间标签 */} @@ -220,7 +228,8 @@ function CurrentTimeLine() { const now = dayjs(); const currentMinutes = now.hour() * 60 + now.minute(); - const top = (currentMinutes / 60) * HOUR_HEIGHT; + // 当前时间线也需要加上时间标签的paddingTop偏移(8px) + const top = (currentMinutes / 60) * HOUR_HEIGHT + 8; return ( {item.category} - {item.priority && ( - - )} + {/* 主要内容 */} @@ -117,11 +115,11 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) { - + {timeFormatted} @@ -159,13 +157,6 @@ const styles = StyleSheet.create({ marginHorizontal: 8, borderRadius: 20, padding: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.1, - shadowRadius: 8, elevation: 6, position: 'relative', }, diff --git a/components/TodoCarousel.tsx b/components/TodoCarousel.tsx index 8f4a6e0..0c63530 100644 --- a/components/TodoCarousel.tsx +++ b/components/TodoCarousel.tsx @@ -52,7 +52,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou {/* 底部指示器 */} - + {/* {todos.map((_, index) => ( ))} - + */} ); } @@ -71,7 +71,6 @@ const styles = StyleSheet.create({ container: { }, scrollView: { - marginBottom: 12, }, scrollContent: { paddingHorizontal: 20, diff --git a/docs/goals-implementation-summary.md b/docs/goals-implementation-summary.md new file mode 100644 index 0000000..7ca2382 --- /dev/null +++ b/docs/goals-implementation-summary.md @@ -0,0 +1,112 @@ +# 目标管理功能实现总结 + +## 已完成的功能 + +### 1. 数据结构定义 (`types/goals.ts`) +- ✅ 完整的目标数据模型 (Goal, GoalCompletion) +- ✅ API请求/响应类型定义 +- ✅ 重复类型、状态、优先级等枚举 +- ✅ 分页和统计数据类型 + +### 2. API服务层 (`services/goalsApi.ts`) +- ✅ 完整的CRUD操作 +- ✅ 目标完成记录管理 +- ✅ 批量操作支持 +- ✅ 统计信息获取 +- ✅ 查询参数和分页支持 + +### 3. Redux状态管理 (`store/goalsSlice.ts`) +- ✅ 完整的异步操作 (createAsyncThunk) +- ✅ 乐观更新支持 +- ✅ 错误处理和加载状态 +- ✅ 分页数据管理 +- ✅ 筛选和搜索状态 + +### 4. 创建目标弹窗 (`components/CreateGoalModal.tsx`) +- ✅ 符合设计稿的UI界面 +- ✅ 表单验证和状态管理 +- ✅ 重复周期、频率、提醒设置 +- ✅ 加载状态和错误处理 + +### 5. 目标页面集成 (`app/(tabs)/goals.tsx`) +- ✅ Redux状态集成 +- ✅ 目标数据转换为TodoItem和时间轴事件 +- ✅ 创建目标按钮和弹窗 +- ✅ 完成目标功能 +- ✅ 错误提示和加载状态 + +## 核心功能特性 + +### 数据流程 +1. **创建目标**: 用户填写表单 → 调用API → 更新Redux状态 → 刷新UI +2. **获取目标**: 页面加载 → 调用API → 更新Redux状态 → 渲染列表 +3. **完成目标**: 用户点击完成 → 调用API → 乐观更新 → 记录完成 + +### 数据转换 +- 目标数据转换为TodoItem格式,用于待办卡片显示 +- 目标数据转换为时间轴事件格式,用于时间轴显示 +- 支持不同重复类型的筛选和显示逻辑 + +### 状态管理 +- 使用Redux Toolkit进行状态管理 +- 支持乐观更新,提升用户体验 +- 完整的错误处理和加载状态 +- 分页数据管理和无限滚动支持 + +## API接口对应 + +| 功能 | API接口 | 实现状态 | +|------|---------|----------| +| 创建目标 | POST /goals | ✅ | +| 获取目标列表 | GET /goals | ✅ | +| 获取目标详情 | GET /goals/{id} | ✅ | +| 更新目标 | PUT /goals/{id} | ✅ | +| 删除目标 | DELETE /goals/{id} | ✅ | +| 记录完成 | POST /goals/{id}/complete | ✅ | +| 获取完成记录 | GET /goals/{id}/completions | ✅ | +| 获取统计信息 | GET /goals/stats/overview | ✅ | +| 批量操作 | POST /goals/batch | ✅ | + +## 使用方式 + +### 创建目标 +1. 点击页面右上角的"+"按钮 +2. 填写目标标题(必填) +3. 选择重复周期(每日/每周/每月/自定义) +4. 设置频率 +5. 配置提醒(可选) +6. 点击保存 + +### 完成目标 +1. 在待办卡片中点击完成按钮 +2. 系统自动记录完成时间和次数 +3. 更新目标进度 + +### 查看目标 +- 今日待办:显示当日需要完成的目标 +- 时间轴:根据选择的时间范围显示目标安排 + +## 技术特点 + +1. **类型安全**: 完整的TypeScript类型定义 +2. **状态管理**: Redux Toolkit + RTK Query模式 +3. **乐观更新**: 提升用户体验 +4. **错误处理**: 完整的错误提示和恢复机制 +5. **响应式设计**: 适配不同屏幕尺寸 +6. **可扩展性**: 模块化设计,易于扩展新功能 + +## 后续优化建议 + +1. **缓存策略**: 实现本地缓存,减少网络请求 +2. **离线支持**: 支持离线创建和完成目标 +3. **推送通知**: 集成推送服务,实现目标提醒 +4. **数据同步**: 实现多设备数据同步 +5. **统计分析**: 添加更详细的目标完成统计和分析 +6. **社交功能**: 支持目标分享和好友互动 + +## 测试建议 + +1. **单元测试**: 测试Redux reducers和API服务 +2. **集成测试**: 测试完整的数据流程 +3. **UI测试**: 测试组件交互和状态变化 +4. **端到端测试**: 测试完整的用户流程 \ No newline at end of file diff --git a/services/goalsApi.ts b/services/goalsApi.ts new file mode 100644 index 0000000..a900fd2 --- /dev/null +++ b/services/goalsApi.ts @@ -0,0 +1,154 @@ +import { + ApiResponse, + BatchGoalOperationRequest, + BatchGoalOperationResult, + CompleteGoalRequest, + CreateGoalRequest, + GetGoalCompletionsQuery, + GetGoalsQuery, + Goal, + GoalCompletion, + GoalDetailResponse, + GoalListItem, + GoalStats, + PaginatedResponse, + UpdateGoalRequest, +} from '@/types/goals'; +import { api } from './api'; + +// 目标管理API服务 + +/** + * 创建目标 + */ +export const createGoal = async (goalData: CreateGoalRequest): Promise => { + return api.post('/goals', goalData); +}; + +/** + * 获取目标列表 + */ +export const getGoals = async (query: GetGoalsQuery = {}): Promise> => { + const searchParams = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + + const queryString = searchParams.toString(); + const path = queryString ? `/goals?${queryString}` : '/goals'; + + return api.get>(path); +}; + +/** + * 获取目标详情 + */ +export const getGoalById = async (goalId: string): Promise> => { + return api.get>(`/goals/${goalId}`); +}; + +/** + * 更新目标 + */ +export const updateGoal = async (goalId: string, goalData: UpdateGoalRequest): Promise> => { + return api.put>(`/goals/${goalId}`, goalData); +}; + +/** + * 删除目标 + */ +export const deleteGoal = async (goalId: string): Promise> => { + return api.delete>(`/goals/${goalId}`); +}; + +/** + * 记录目标完成 + */ +export const completeGoal = async (goalId: string, completionData: CompleteGoalRequest = {}): Promise> => { + return api.post>(`/goals/${goalId}/complete`, completionData); +}; + +/** + * 获取目标完成记录 + */ +export const getGoalCompletions = async ( + goalId: string, + query: GetGoalCompletionsQuery = {} +): Promise>> => { + const searchParams = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + + const queryString = searchParams.toString(); + const path = queryString ? `/goals/${goalId}/completions?${queryString}` : `/goals/${goalId}/completions`; + + return api.get>>(path); +}; + +/** + * 获取目标统计信息 + */ +export const getGoalStats = async (): Promise> => { + return api.get>('/goals/stats/overview'); +}; + +/** + * 批量操作目标 + */ +export const batchOperateGoals = async (operationData: BatchGoalOperationRequest): Promise> => { + return api.post>('/goals/batch', operationData); +}; + +/** + * 暂停目标 + */ +export const pauseGoal = async (goalId: string): Promise> => { + return updateGoal(goalId, { status: 'paused' }); +}; + +/** + * 恢复目标 + */ +export const resumeGoal = async (goalId: string): Promise> => { + return updateGoal(goalId, { status: 'active' }); +}; + +/** + * 完成目标 + */ +export const markGoalCompleted = async (goalId: string): Promise> => { + return updateGoal(goalId, { status: 'completed' }); +}; + +/** + * 取消目标 + */ +export const cancelGoal = async (goalId: string): Promise> => { + return updateGoal(goalId, { status: 'cancelled' }); +}; + +// 导出所有API方法 +export const goalsApi = { + createGoal, + getGoals, + getGoalById, + updateGoal, + deleteGoal, + completeGoal, + getGoalCompletions, + getGoalStats, + batchOperateGoals, + pauseGoal, + resumeGoal, + markGoalCompleted, + cancelGoal, +}; + +export default goalsApi; \ No newline at end of file diff --git a/store/goalsSlice.ts b/store/goalsSlice.ts new file mode 100644 index 0000000..dc12058 --- /dev/null +++ b/store/goalsSlice.ts @@ -0,0 +1,608 @@ +import { goalsApi } from '@/services/goalsApi'; +import { + BatchGoalOperationRequest, + CompleteGoalRequest, + CreateGoalRequest, + GetGoalCompletionsQuery, + GetGoalsQuery, + GoalCompletion, + GoalDetailResponse, + GoalListItem, + GoalStats, + GoalStatus, + UpdateGoalRequest +} from '@/types/goals'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +// 目标管理状态类型 +export interface GoalsState { + // 目标列表 + goals: GoalListItem[]; + goalsLoading: boolean; + goalsError: string | null; + goalsPagination: { + page: number; + pageSize: number; + total: number; + hasMore: boolean; + }; + + // 当前查看的目标详情 + currentGoal: GoalDetailResponse | null; + currentGoalLoading: boolean; + currentGoalError: string | null; + + // 目标完成记录 + completions: GoalCompletion[]; + completionsLoading: boolean; + completionsError: string | null; + completionsPagination: { + page: number; + pageSize: number; + total: number; + hasMore: boolean; + }; + + // 目标统计 + stats: GoalStats | null; + statsLoading: boolean; + statsError: string | null; + + // 创建/更新目标 + createLoading: boolean; + createError: string | null; + updateLoading: boolean; + updateError: string | null; + + // 批量操作 + batchLoading: boolean; + batchError: string | null; + + // 筛选和搜索 + filters: GetGoalsQuery; +} + +const initialState: GoalsState = { + goals: [], + goalsLoading: false, + goalsError: null, + goalsPagination: { + page: 1, + pageSize: 20, + total: 0, + hasMore: false, + }, + + currentGoal: null, + currentGoalLoading: false, + currentGoalError: null, + + completions: [], + completionsLoading: false, + completionsError: null, + completionsPagination: { + page: 1, + pageSize: 20, + total: 0, + hasMore: false, + }, + + stats: null, + statsLoading: false, + statsError: null, + + createLoading: false, + createError: null, + updateLoading: false, + updateError: null, + + batchLoading: false, + batchError: null, + + filters: { + page: 1, + pageSize: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + }, +}; + +// 异步操作 + +/** + * 获取目标列表 + */ +export const fetchGoals = createAsyncThunk( + 'goals/fetchGoals', + async (query: GetGoalsQuery = {}, { rejectWithValue }) => { + try { + const response = await goalsApi.getGoals(query); + + console.log('fetchGoals response', response); + return { query, response }; + } catch (error: any) { + return rejectWithValue(error.message || '获取目标列表失败'); + } + } +); + +/** + * 加载更多目标 + */ +export const loadMoreGoals = createAsyncThunk( + 'goals/loadMoreGoals', + async (_, { getState, rejectWithValue }) => { + try { + const state = getState() as { goals: GoalsState }; + const { filters, goalsPagination } = state.goals; + + if (!goalsPagination.hasMore) { + return { goals: [], pagination: goalsPagination }; + } + + const query = { + ...filters, + page: goalsPagination.page + 1, + }; + + const response = await goalsApi.getGoals(query); + + console.log('response', response); + + return { query, response }; + } catch (error: any) { + return rejectWithValue(error.message || '加载更多目标失败'); + } + } +); + +/** + * 获取目标详情 + */ +export const fetchGoalDetail = createAsyncThunk( + 'goals/fetchGoalDetail', + async (goalId: string, { rejectWithValue }) => { + try { + const response = await goalsApi.getGoalById(goalId); + return response.data; + } catch (error: any) { + return rejectWithValue(error.message || '获取目标详情失败'); + } + } +); + +/** + * 创建目标 + */ +export const createGoal = createAsyncThunk( + 'goals/createGoal', + async (goalData: CreateGoalRequest, { rejectWithValue }) => { + try { + const response = await goalsApi.createGoal(goalData); + console.log('createGoal response', response); + return response; + } catch (error: any) { + return rejectWithValue(error.message || '创建目标失败'); + } + } +); + +/** + * 更新目标 + */ +export const updateGoal = createAsyncThunk( + 'goals/updateGoal', + async ({ goalId, goalData }: { goalId: string; goalData: UpdateGoalRequest }, { rejectWithValue }) => { + try { + const response = await goalsApi.updateGoal(goalId, goalData); + return response.data; + } catch (error: any) { + return rejectWithValue(error.message || '更新目标失败'); + } + } +); + +/** + * 删除目标 + */ +export const deleteGoal = createAsyncThunk( + 'goals/deleteGoal', + async (goalId: string, { rejectWithValue }) => { + try { + await goalsApi.deleteGoal(goalId); + return goalId; + } catch (error: any) { + return rejectWithValue(error.message || '删除目标失败'); + } + } +); + +/** + * 记录目标完成 + */ +export const completeGoal = createAsyncThunk( + 'goals/completeGoal', + async ({ goalId, completionData }: { goalId: string; completionData?: CompleteGoalRequest }, { rejectWithValue }) => { + try { + const response = await goalsApi.completeGoal(goalId, completionData); + return { goalId, completion: response.data }; + } catch (error: any) { + return rejectWithValue(error.message || '记录目标完成失败'); + } + } +); + +/** + * 获取目标完成记录 + */ +export const fetchGoalCompletions = createAsyncThunk( + 'goals/fetchGoalCompletions', + async ({ goalId, query }: { goalId: string; query?: GetGoalCompletionsQuery }, { rejectWithValue }) => { + try { + const response = await goalsApi.getGoalCompletions(goalId, query); + return { query, response: response.data }; + } catch (error: any) { + return rejectWithValue(error.message || '获取完成记录失败'); + } + } +); + +/** + * 获取目标统计 + */ +export const fetchGoalStats = createAsyncThunk( + 'goals/fetchGoalStats', + async (_, { rejectWithValue }) => { + try { + const response = await goalsApi.getGoalStats(); + return response.data; + } catch (error: any) { + return rejectWithValue(error.message || '获取目标统计失败'); + } + } +); + +/** + * 批量操作目标 + */ +export const batchOperateGoals = createAsyncThunk( + 'goals/batchOperateGoals', + async (operationData: BatchGoalOperationRequest, { rejectWithValue }) => { + try { + const response = await goalsApi.batchOperateGoals(operationData); + return { operation: operationData, results: response.data }; + } catch (error: any) { + return rejectWithValue(error.message || '批量操作失败'); + } + } +); + +// Redux Slice +const goalsSlice = createSlice({ + name: 'goals', + initialState, + reducers: { + // 设置筛选条件 + setFilters: (state, action: PayloadAction>) => { + state.filters = { ...state.filters, ...action.payload }; + }, + + // 重置筛选条件 + resetFilters: (state) => { + state.filters = { + page: 1, + pageSize: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + }; + }, + + // 清除错误 + clearErrors: (state) => { + state.goalsError = null; + state.currentGoalError = null; + state.completionsError = null; + state.statsError = null; + state.createError = null; + state.updateError = null; + state.batchError = null; + }, + + // 清除当前目标详情 + clearCurrentGoal: (state) => { + state.currentGoal = null; + state.currentGoalError = null; + }, + + // 本地更新目标状态(用于乐观更新) + updateGoalStatus: (state, action: PayloadAction<{ goalId: string; status: GoalStatus }>) => { + const { goalId, status } = action.payload; + + // 更新目标列表中的状态 + const goalIndex = state.goals.findIndex(goal => goal.id === goalId); + if (goalIndex !== -1) { + state.goals[goalIndex].status = status; + } + + // 更新当前目标详情中的状态 + if (state.currentGoal && state.currentGoal.id === goalId) { + state.currentGoal.status = status; + } + }, + + // 本地增加完成次数(用于乐观更新) + incrementGoalCompletion: (state, action: PayloadAction<{ goalId: string; count?: number }>) => { + const { goalId, count = 1 } = action.payload; + + // 更新目标列表中的完成次数 + const goalIndex = state.goals.findIndex(goal => goal.id === goalId); + if (goalIndex !== -1) { + state.goals[goalIndex].completedCount += count; + // 重新计算进度百分比 + if (state.goals[goalIndex].targetCount && state.goals[goalIndex].targetCount > 0) { + state.goals[goalIndex].progressPercentage = Math.round( + (state.goals[goalIndex].completedCount / state.goals[goalIndex].targetCount) * 100 + ); + } + } + + // 更新当前目标详情中的完成次数 + if (state.currentGoal && state.currentGoal.id === goalId) { + state.currentGoal.completedCount += count; + if (state.currentGoal.targetCount && state.currentGoal.targetCount > 0) { + state.currentGoal.progressPercentage = Math.round( + (state.currentGoal.completedCount / state.currentGoal.targetCount) * 100 + ); + } + } + }, + }, + extraReducers: (builder) => { + builder + // 获取目标列表 + .addCase(fetchGoals.pending, (state) => { + state.goalsLoading = true; + state.goalsError = null; + }) + .addCase(fetchGoals.fulfilled, (state, action) => { + state.goalsLoading = false; + const { query, response } = action.payload; + + // 如果是第一页,替换数据;否则追加数据 + if (query.page === 1) { + state.goals = response.list; + } else { + state.goals = [...state.goals, ...response.list]; + } + + state.goalsPagination = { + page: response.page, + pageSize: response.pageSize, + total: response.total, + hasMore: response.page * response.pageSize < response.total, + }; + }) + .addCase(fetchGoals.rejected, (state, action) => { + state.goalsLoading = false; + state.goalsError = action.payload as string; + }) + + // 加载更多目标 + .addCase(loadMoreGoals.pending, (state) => { + state.goalsLoading = true; + }) + .addCase(loadMoreGoals.fulfilled, (state, action) => { + state.goalsLoading = false; + const { response } = action.payload; + + if (!response) { + return; + } + + state.goals = [...state.goals, ...response.list]; + state.goalsPagination = { + page: response.page, + pageSize: response.pageSize, + total: response.total, + hasMore: response.page * response.pageSize < response.total, + }; + }) + .addCase(loadMoreGoals.rejected, (state, action) => { + state.goalsLoading = false; + state.goalsError = action.payload as string; + }) + + // 获取目标详情 + .addCase(fetchGoalDetail.pending, (state) => { + state.currentGoalLoading = true; + state.currentGoalError = null; + }) + .addCase(fetchGoalDetail.fulfilled, (state, action) => { + state.currentGoalLoading = false; + state.currentGoal = action.payload; + }) + .addCase(fetchGoalDetail.rejected, (state, action) => { + state.currentGoalLoading = false; + state.currentGoalError = action.payload as string; + }) + + // 创建目标 + .addCase(createGoal.pending, (state) => { + state.createLoading = true; + state.createError = null; + }) + .addCase(createGoal.fulfilled, (state, action) => { + state.createLoading = false; + // 将新目标添加到列表开头 + const newGoal: GoalListItem = { + ...action.payload, + progressPercentage: action.payload.targetCount && action.payload.targetCount > 0 + ? Math.round((action.payload.completedCount / action.payload.targetCount) * 100) + : 0, + }; + state.goals.unshift(newGoal); + state.goalsPagination.total += 1; + }) + .addCase(createGoal.rejected, (state, action) => { + state.createLoading = false; + state.createError = action.payload as string; + }) + + // 更新目标 + .addCase(updateGoal.pending, (state) => { + state.updateLoading = true; + state.updateError = null; + }) + .addCase(updateGoal.fulfilled, (state, action) => { + state.updateLoading = false; + const updatedGoal = action.payload; + + // 计算进度百分比 + const progressPercentage = updatedGoal.targetCount && updatedGoal.targetCount > 0 + ? Math.round((updatedGoal.completedCount / updatedGoal.targetCount) * 100) + : 0; + + // 更新目标列表中的目标 + const goalIndex = state.goals.findIndex(goal => goal.id === updatedGoal.id); + if (goalIndex !== -1) { + state.goals[goalIndex] = { + ...state.goals[goalIndex], + ...updatedGoal, + progressPercentage, + }; + } + + // 更新当前目标详情 + if (state.currentGoal && state.currentGoal.id === updatedGoal.id) { + state.currentGoal = { + ...state.currentGoal, + ...updatedGoal, + progressPercentage, + }; + } + }) + .addCase(updateGoal.rejected, (state, action) => { + state.updateLoading = false; + state.updateError = action.payload as string; + }) + + // 删除目标 + .addCase(deleteGoal.fulfilled, (state, action) => { + const goalId = action.payload; + + // 从目标列表中移除 + state.goals = state.goals.filter(goal => goal.id !== goalId); + state.goalsPagination.total -= 1; + + // 如果删除的是当前查看的目标,清除详情 + if (state.currentGoal && state.currentGoal.id === goalId) { + state.currentGoal = null; + } + }) + + // 记录目标完成 + .addCase(completeGoal.fulfilled, (state, action) => { + const { goalId, completion } = action.payload; + + // 增加完成次数 + goalsSlice.caseReducers.incrementGoalCompletion(state, { + type: 'goals/incrementGoalCompletion', + payload: { goalId, count: completion.completionCount }, + }); + + // 将完成记录添加到列表开头 + state.completions.unshift(completion); + }) + + // 获取完成记录 + .addCase(fetchGoalCompletions.pending, (state) => { + state.completionsLoading = true; + state.completionsError = null; + }) + .addCase(fetchGoalCompletions.fulfilled, (state, action) => { + state.completionsLoading = false; + const { query, response } = action.payload; + + // 如果是第一页,替换数据;否则追加数据 + if (query?.page === 1) { + state.completions = response.list; + } else { + state.completions = [...state.completions, ...response.list]; + } + + state.completionsPagination = { + page: response.page, + pageSize: response.pageSize, + total: response.total, + hasMore: response.page * response.pageSize < response.total, + }; + }) + .addCase(fetchGoalCompletions.rejected, (state, action) => { + state.completionsLoading = false; + state.completionsError = action.payload as string; + }) + + // 获取目标统计 + .addCase(fetchGoalStats.pending, (state) => { + state.statsLoading = true; + state.statsError = null; + }) + .addCase(fetchGoalStats.fulfilled, (state, action) => { + state.statsLoading = false; + state.stats = action.payload; + }) + .addCase(fetchGoalStats.rejected, (state, action) => { + state.statsLoading = false; + state.statsError = action.payload as string; + }) + + // 批量操作 + .addCase(batchOperateGoals.pending, (state) => { + state.batchLoading = true; + state.batchError = null; + }) + .addCase(batchOperateGoals.fulfilled, (state, action) => { + state.batchLoading = false; + const { operation, results } = action.payload; + + // 根据操作类型更新状态 + results.forEach(result => { + if (result.success) { + const goalIndex = state.goals.findIndex(goal => goal.id === result.goalId); + if (goalIndex !== -1) { + switch (operation.action) { + case 'pause': + state.goals[goalIndex].status = 'paused'; + break; + case 'resume': + state.goals[goalIndex].status = 'active'; + break; + case 'complete': + state.goals[goalIndex].status = 'completed'; + break; + case 'delete': + state.goals = state.goals.filter(goal => goal.id !== result.goalId); + state.goalsPagination.total -= 1; + break; + } + } + } + }); + }) + .addCase(batchOperateGoals.rejected, (state, action) => { + state.batchLoading = false; + state.batchError = action.payload as string; + }); + }, +}); + +export const { + setFilters, + resetFilters, + clearErrors, + clearCurrentGoal, + updateGoalStatus, + incrementGoalCompletion, +} = goalsSlice.actions; + +export default goalsSlice.reducer; \ No newline at end of file diff --git a/store/index.ts b/store/index.ts index bc06217..7e9e711 100644 --- a/store/index.ts +++ b/store/index.ts @@ -2,6 +2,7 @@ import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import challengeReducer from './challengeSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import exerciseLibraryReducer from './exerciseLibrarySlice'; +import goalsReducer from './goalsSlice'; import moodReducer from './moodSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice'; import trainingPlanReducer from './trainingPlanSlice'; @@ -41,6 +42,7 @@ export const store = configureStore({ user: userReducer, challenge: challengeReducer, checkin: checkinReducer, + goals: goalsReducer, mood: moodReducer, trainingPlan: trainingPlanReducer, scheduleExercise: scheduleExerciseReducer, diff --git a/types/goals.ts b/types/goals.ts new file mode 100644 index 0000000..2d18e20 --- /dev/null +++ b/types/goals.ts @@ -0,0 +1,180 @@ +// 目标管理相关类型定义 + +export type RepeatType = 'daily' | 'weekly' | 'monthly' | 'custom'; + +export type GoalStatus = 'active' | 'paused' | 'completed' | 'cancelled'; + +export type GoalPriority = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + +// 自定义重复规则 +export interface CustomRepeatRule { + type: 'weekly' | 'monthly'; + weekdays?: number[]; // 0-6,0为周日 + monthDays?: number[]; // 1-31 + interval?: number; // 间隔周数或月数 +} + +// 提醒设置 +export interface ReminderSettings { + enabled: boolean; + weekdays?: number[]; // 0-6,0为周日 + sound?: string; + vibration?: boolean; +} + +// 目标数据结构 +export interface Goal { + id: string; + userId: string; + title: string; + description?: string; + repeatType: RepeatType; + frequency: number; + customRepeatRule?: CustomRepeatRule; + startDate: string; // ISO date string + endDate?: string; // ISO date string + startTime: number; // HH:mm format + endTime: number; // HH:mm format + status: GoalStatus; + completedCount: number; + targetCount?: number; + category?: string; + priority: GoalPriority; + hasReminder: boolean; + reminderTime?: string; // HH:mm format + reminderSettings?: ReminderSettings; + createdAt?: string; + updatedAt?: string; +} + +// 目标完成记录 +export interface GoalCompletion { + id: string; + goalId: string; + userId: string; + completedAt: string; // ISO datetime string + completionCount: number; + notes?: string; + metadata?: Record; +} + +// 创建目标的请求数据 +export interface CreateGoalRequest { + title: string; + description?: string; + repeatType: RepeatType; + frequency: number; + customRepeatRule?: CustomRepeatRule; + startDate?: string; + endDate?: string; + startTime?: number; // 单位:分钟 + endTime?: number; // 单位:分钟 + targetCount?: number; + category?: string; + priority: GoalPriority; + hasReminder: boolean; + reminderTime?: string; + reminderSettings?: ReminderSettings; +} + +// 更新目标的请求数据 +export interface UpdateGoalRequest { + title?: string; + description?: string; + repeatType?: RepeatType; + frequency?: number; + customRepeatRule?: CustomRepeatRule; + startDate?: string; + endDate?: string; + targetCount?: number; + category?: string; + priority?: GoalPriority; + hasReminder?: boolean; + reminderTime?: string; + reminderSettings?: ReminderSettings; + status?: GoalStatus; +} + +// 记录目标完成的请求数据 +export interface CompleteGoalRequest { + completionCount?: number; + notes?: string; + completedAt?: string; +} + +// 获取目标列表的查询参数 +export interface GetGoalsQuery { + page?: number; + pageSize?: number; + status?: GoalStatus; + repeatType?: RepeatType; + category?: string; + search?: string; + startDate?: string; + endDate?: string; + sortBy?: 'createdAt' | 'updatedAt' | 'priority' | 'title' | 'startDate'; + sortOrder?: 'asc' | 'desc'; +} + +// 获取目标完成记录的查询参数 +export interface GetGoalCompletionsQuery { + page?: number; + pageSize?: number; + startDate?: string; + endDate?: string; +} + +// 批量操作目标的请求数据 +export interface BatchGoalOperationRequest { + goalIds: string[]; + action: 'pause' | 'resume' | 'complete' | 'delete'; +} + +// 批量操作结果 +export interface BatchGoalOperationResult { + goalId: string; + success: boolean; + error?: string; +} + +// 目标统计信息 +export interface GoalStats { + total: number; + active: number; + completed: number; + paused: number; + cancelled: number; + byCategory: Record; + byRepeatType: Record; + totalCompletions: number; + thisWeekCompletions: number; + thisMonthCompletions: number; +} + +// API响应格式 +export interface ApiResponse { + code: number; + message: string; + data: T; +} + +// 分页响应格式 +export interface PaginatedResponse { + page: number; + pageSize: number; + total: number; + list: T[]; +} + +// 目标详情响应(包含完成记录) +export interface GoalDetailResponse extends Goal { + progressPercentage: number; + daysRemaining?: number; + completions: GoalCompletion[]; +} + +// 目标列表项响应 +export interface GoalListItem extends Goal { + progressPercentage: number; + daysRemaining?: number; +} \ No newline at end of file