feat: 完善目标管理功能及相关组件

- 新增创建目标弹窗,支持用户输入目标信息并提交
- 实现目标数据的转换,支持将目标转换为待办事项和时间轴事件
- 优化目标页面,集成Redux状态管理,处理目标的创建、完成和错误提示
- 更新时间轴组件,支持动态显示目标安排
- 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
richarjiang
2025-08-22 12:05:27 +08:00
parent 136c800084
commit 231620d778
11 changed files with 1811 additions and 169 deletions

View File

@@ -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',
},
});