feat: 完善目标管理功能及相关组件
- 新增创建目标弹窗,支持用户输入目标信息并提交 - 实现目标数据的转换,支持将目标转换为待办事项和时间轴事件 - 优化目标页面,集成Redux状态管理,处理目标的创建、完成和错误提示 - 更新时间轴组件,支持动态显示目标安排 - 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
@@ -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<TimeTabType>('day');
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [todos, setTodos] = useState<TodoItem[]>(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() {
|
||||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||
今日
|
||||
</Text>
|
||||
<Text style={[styles.pageSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{dayjs().format('YYYY年M月D日 dddd')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 今日待办事项卡片 */}
|
||||
@@ -323,7 +368,6 @@ export default function GoalsScreen() {
|
||||
isFutureDate && styles.dayDateDisabled
|
||||
]}>{d.dayOfMonth}</Text>
|
||||
</TouchableOpacity>
|
||||
{selected && <View style={[styles.selectedDot, { backgroundColor: colorTokens.primary }]} />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@@ -339,6 +383,14 @@ export default function GoalsScreen() {
|
||||
onEventPress={handleEventPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 创建目标弹窗 */}
|
||||
<CreateGoalModal
|
||||
visible={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateGoal}
|
||||
loading={createLoading}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user