feat: 新增任务管理功能及相关组件

- 将目标页面改为任务列表,支持任务的创建、完成和跳过功能
- 新增任务卡片和任务进度卡片组件,展示任务状态和进度
- 实现任务数据的获取和管理,集成Redux状态管理
- 更新API服务,支持任务相关的CRUD操作
- 编写任务管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
richarjiang
2025-08-22 17:30:14 +08:00
parent 231620d778
commit 259f10540e
21 changed files with 2756 additions and 608 deletions

View File

@@ -1,121 +1,111 @@
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 { TaskCard } from '@/components/TaskCard';
import { TaskProgressCard } from '@/components/TaskProgressCard';
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 { clearErrors, createGoal } from '@/store/goalsSlice';
import { clearErrors as clearTaskErrors, completeTask, fetchTasks, loadMoreTasks, skipTask } from '@/store/tasksSlice';
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
dayjs.extend(isBetween);
// 将目标转换为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() || '',
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',
};
};
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, FlatList, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export default function GoalsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const router = useRouter();
// Redux状态
const {
goals,
goalsLoading,
goalsError,
tasks,
tasksLoading,
tasksError,
tasksPagination,
completeLoading,
completeError,
skipLoading,
skipError,
} = useAppSelector((state) => state.tasks);
const {
createLoading,
createError
} = useAppSelector((state) => state.goals);
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [showCreateModal, setShowCreateModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect');
// 只在需要时刷新数据,比如从后台返回或从其他页面返回
dispatch(fetchGoals({
status: 'active',
page: 1,
pageSize: 200,
}));
console.log('useFocusEffect - loading tasks');
loadTasks();
}, [dispatch])
);
// 加载任务列表
const loadTasks = async () => {
try {
await dispatch(fetchTasks({
startDate: dayjs().startOf('day').toISOString(),
endDate: dayjs().endOf('day').toISOString(),
})).unwrap();
} catch (error) {
console.error('Failed to load tasks:', error);
}
};
// 下拉刷新
const onRefresh = async () => {
setRefreshing(true);
try {
await loadTasks();
} finally {
setRefreshing(false);
}
};
// 加载更多任务
const handleLoadMoreTasks = async () => {
if (tasksPagination.hasMore && !tasksLoading) {
try {
await dispatch(loadMoreTasks()).unwrap();
} catch (error) {
console.error('Failed to load more tasks:', error);
}
}
};
// 处理错误提示
useEffect(() => {
console.log('goalsError', goalsError);
console.log('tasksError', tasksError);
console.log('createError', createError);
if (goalsError) {
Alert.alert('错误', goalsError);
dispatch(clearErrors());
console.log('completeError', completeError);
console.log('skipError', skipError);
if (tasksError) {
Alert.alert('错误', tasksError);
dispatch(clearTaskErrors());
}
if (createError) {
Alert.alert('创建失败', createError);
dispatch(clearErrors());
}
}, [goalsError, createError, dispatch]);
if (completeError) {
Alert.alert('完成失败', completeError);
dispatch(clearTaskErrors());
}
if (skipError) {
Alert.alert('跳过失败', skipError);
dispatch(clearTaskErrors());
}
}, [tasksError, createError, completeError, skipError, dispatch]);
// 创建目标处理函数
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
@@ -123,156 +113,85 @@ export default function GoalsScreen() {
await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false);
Alert.alert('成功', '目标创建成功!');
// 创建目标后重新加载任务列表
loadTasks();
} catch (error) {
// 错误已在useEffect中处理
}
};
// tab切换处理函数
const handleTabChange = (tab: TimeTabType) => {
setSelectedTab(tab);
// 当切换到周或月模式时,如果当前选择的日期不是今天,则重置为今天
const today = new Date();
const currentDate = selectedDate;
if (tab === 'week' || tab === 'month') {
// 如果当前选择的日期不是今天,重置为今天
if (!dayjs(currentDate).isSame(dayjs(today), 'day')) {
setSelectedDate(today);
setSelectedIndex(getTodayIndexInMonth());
}
} else if (tab === 'day') {
// 天模式下也重置为今天
setSelectedDate(today);
setSelectedIndex(getTodayIndexInMonth());
}
// 任务点击处理
const handleTaskPress = (task: TaskListItem) => {
console.log('Task pressed:', task.title);
// 这里可以导航到任务详情页面
};
// 日期选择器相关状态 (参考 statistics.tsx)
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 48;
const DAY_PILL_SPACING = 8;
const scrollToIndex = (index: number, animated = true) => {
if (!daysScrollRef.current || scrollWidth === 0) return;
const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING;
const baseOffset = index * itemWidth;
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
// 确保不会滚动超出边界
const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth);
const finalOffset = Math.min(centerOffset, maxScrollOffset);
daysScrollRef.current.scrollTo({ x: finalOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// 当选中索引变化时,滚动到对应位置
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// 日期选择处理
const onSelectDate = (index: number) => {
setSelectedIndex(index);
const targetDate = days[index]?.date?.toDate();
if (targetDate) {
setSelectedDate(targetDate);
// 在周模式下,如果用户选择了新日期,更新周的显示范围
if (selectedTab === 'week') {
// 自动滚动到新选择的日期
setTimeout(() => {
scrollToIndex(index, true);
}, 100);
}
}
};
// 将目标转换为TodoItem数据
const todayTodos = useMemo(() => {
const today = dayjs();
const activeGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' ||
(goal.repeatType === 'weekly' && today.day() !== 0) ||
(goal.repeatType === 'monthly' && today.date() <= 28))
);
return activeGoals.map(goalToTodoItem);
}, [goals]);
// 将目标转换为时间轴事件数据
const filteredTimelineEvents = useMemo(() => {
const selected = dayjs(selectedDate);
let filteredGoals: GoalListItem[] = [];
switch (selectedTab) {
case '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':
filteredGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' || goal.repeatType === 'weekly')
);
break;
case 'month':
filteredGoals = goals.filter(goal => goal.status === 'active');
break;
default:
filteredGoals = goals.filter(goal => goal.status === 'active');
}
return filteredGoals.map(goalToTimelineEvent);
}, [selectedTab, selectedDate, goals]);
console.log('filteredTimelineEvents', filteredTimelineEvents);
const handleTodoPress = (item: TodoItem) => {
console.log('Goal pressed:', item.title);
// 这里可以导航到目标详情页面
};
const handleToggleComplete = async (item: TodoItem) => {
// 完成任务处理
const handleCompleteTask = async (task: TaskListItem) => {
try {
await dispatch(completeGoal({
goalId: item.id,
await dispatch(completeTask({
taskId: task.id,
completionData: {
completionCount: 1,
notes: '通过待办卡片完成'
count: 1,
notes: '通过任务卡片完成'
}
})).unwrap();
} catch (error) {
Alert.alert('错误', '记录完成失败');
Alert.alert('错误', '完成任务失败');
}
};
const handleEventPress = (event: any) => {
console.log('Event pressed:', event.title);
// 这里可以处理时间轴事件点击
// 跳过任务处理
const handleSkipTask = async (task: TaskListItem) => {
try {
await dispatch(skipTask({
taskId: task.id,
skipData: {
reason: '用户主动跳过'
}
})).unwrap();
} catch (error) {
Alert.alert('错误', '跳过任务失败');
}
};
// 导航到目标管理页面
const handleNavigateToGoals = () => {
router.push('/goals-detail');
};
// 渲染任务项
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
<TaskCard
task={item}
onPress={handleTaskPress}
onComplete={handleCompleteTask}
onSkip={handleSkipTask}
/>
);
// 渲染空状态
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
</Text>
</View>
);
// 渲染加载更多
const renderLoadMore = () => {
if (!tasksPagination.hasMore) return null;
return (
<View style={styles.loadMoreContainer}>
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
{tasksLoading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
);
};
return (
@@ -284,103 +203,61 @@ export default function GoalsScreen() {
{/* 背景渐变 */}
<LinearGradient
colors={[
colorTokens.backgroundGradientStart,
colorTokens.backgroundGradientEnd,
]}
style={styles.backgroundGradient}
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.header}>
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => setShowCreateModal(true)}
>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
<View style={styles.headerButtons}>
<TouchableOpacity
style={styles.goalsButton}
onPress={handleNavigateToGoals}
>
<MaterialIcons name="flag" size={16} color="#0EA5E9" />
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={() => setShowCreateModal(true)}
>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
</View>
{/* 今日待办事项卡片 */}
<TodoCarousel
todos={todayTodos}
onTodoPress={handleTodoPress}
onToggleComplete={handleToggleComplete}
/>
{/* 时间筛选选项卡 */}
<TimeTabSelector
selectedTab={selectedTab}
onTabChange={handleTabChange}
/>
{/* 任务进度卡片 */}
<TaskProgressCard tasks={tasks} />
{/* 日期选择器 - 在周和月模式下显示 */}
{(selectedTab === 'week' || selectedTab === 'month') && (
<View style={styles.dateSelector}>
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>
{monthTitle}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
const isFutureDate = d.date.isAfter(dayjs(), 'day');
// 根据选择的tab模式决定是否显示该日期
let shouldShow = true;
if (selectedTab === 'week') {
// 周模式:只显示选中日期所在周的日期
const selectedWeekStart = dayjs(selectedDate).startOf('week');
const selectedWeekEnd = dayjs(selectedDate).endOf('week');
shouldShow = d.date.isBetween(selectedWeekStart, selectedWeekEnd, 'day', '[]');
}
if (!shouldShow) return null;
return (
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
onPress={() => !isFutureDate && onSelectDate(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}> {d.weekdayZh} </Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
</View>
);
})}
</ScrollView>
</View>
)}
{/* 时间轴安排 */}
<View style={styles.timelineSection}>
<TimelineSchedule
events={filteredTimelineEvents}
selectedDate={selectedDate}
onEventPress={handleEventPress}
{/* 任务列表 */}
<View style={styles.taskListContainer}>
<FlatList
data={tasks}
renderItem={renderTaskItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.taskList}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#0EA5E9']}
tintColor="#0EA5E9"
/>
}
onEndReached={handleLoadMoreTasks}
onEndReachedThreshold={0.1}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderLoadMore}
/>
</View>
@@ -400,12 +277,33 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
backgroundGradient: {
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
content: {
flex: 1,
@@ -418,16 +316,35 @@ const styles = StyleSheet.create({
paddingTop: 20,
paddingBottom: 16,
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
goalsButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
pageTitle: {
fontSize: 28,
fontWeight: '800',
marginBottom: 4,
},
addButton: {
width: 40,
height: 40,
width: 30,
height: 30,
borderRadius: 20,
backgroundColor: '#6366F1',
backgroundColor: '#0EA5E9',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
@@ -438,80 +355,44 @@ const styles = StyleSheet.create({
},
addButtonText: {
color: '#FFFFFF',
fontSize: 24,
fontSize: 22,
fontWeight: '600',
lineHeight: 24,
lineHeight: 22,
},
pageSubtitle: {
fontSize: 16,
fontWeight: '500',
},
timelineSection: {
taskListContainer: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
// 日期选择器样式 (参考 statistics.tsx)
dateSelector: {
taskList: {
paddingHorizontal: 20,
paddingVertical: 16,
paddingTop: 20,
paddingBottom: 20,
},
monthTitle: {
fontSize: 24,
fontWeight: '800',
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 48,
marginRight: 8,
},
dayPill: {
width: 40,
height: 60,
borderRadius: 24,
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
dayPillNormal: {
backgroundColor: 'transparent',
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
dayPillSelected: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
emptyStateSubtitle: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
dayPillDisabled: {
backgroundColor: 'transparent',
opacity: 0.4,
loadMoreContainer: {
alignItems: 'center',
paddingVertical: 20,
},
dayLabel: {
fontSize: 11,
fontWeight: '700',
color: 'gray',
marginBottom: 2,
},
dayLabelSelected: {
color: '#192126',
},
dayLabelDisabled: {
},
dayDate: {
fontSize: 12,
fontWeight: '800',
color: 'gray',
},
dayDateSelected: {
color: '#192126',
},
dayDateDisabled: {
loadMoreText: {
fontSize: 14,
fontWeight: '500',
},
});