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',
|
||||
},
|
||||
});
|
||||
|
||||
523
components/CreateGoalModal.tsx
Normal file
523
components/CreateGoalModal.tsx
Normal file
@@ -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<CreateGoalModalProps> = ({
|
||||
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<RepeatType>('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<GoalPriority>(5);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [tempSelectedTime, setTempSelectedTime] = useState<Date | null>(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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleClose} disabled={loading}>
|
||||
<Text style={[styles.cancelButton, { color: colorTokens.text }]}>
|
||||
←
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>
|
||||
创建新目标
|
||||
</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* 目标标题输入 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.iconTitleContainer}>
|
||||
<View style={styles.iconPlaceholder}>
|
||||
<Text style={styles.iconText}>图标</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
style={[styles.titleInput, { color: colorTokens.text }]}
|
||||
placeholder="写点什么..."
|
||||
placeholderTextColor={colorTokens.textSecondary}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
multiline
|
||||
maxLength={100}
|
||||
/>
|
||||
</View>
|
||||
{/* 装饰图案 */}
|
||||
<View style={styles.decorationContainer}>
|
||||
<View style={styles.decoration} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 目标重复周期 */}
|
||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>🔄</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
目标重复周期
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.optionValue}>
|
||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||
{REPEAT_TYPE_OPTIONS.find(opt => opt.value === repeatType)?.label}
|
||||
</Text>
|
||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||
›
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 频率设置 */}
|
||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>📊</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
频率
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.optionValue}>
|
||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||
{frequency}
|
||||
</Text>
|
||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||
›
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 提醒设置 */}
|
||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>🔔</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
提醒
|
||||
</Text>
|
||||
<Switch
|
||||
value={hasReminder}
|
||||
onValueChange={setHasReminder}
|
||||
trackColor={{ false: '#E5E5E5', true: '#6366F1' }}
|
||||
thumbColor={hasReminder ? '#FFFFFF' : '#FFFFFF'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 时间设置 */}
|
||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={styles.optionIcon}>
|
||||
<Text style={styles.optionIconText}>⏰</Text>
|
||||
</View>
|
||||
<Text style={[styles.optionLabel, { color: colorTokens.text }]}>
|
||||
时间
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.optionValue]}
|
||||
onPress={showTimePickerModal}
|
||||
>
|
||||
<Text style={[styles.optionValueText, { color: colorTokens.textSecondary }]}>
|
||||
{reminderTime}
|
||||
</Text>
|
||||
<Text style={[styles.chevron, { color: colorTokens.textSecondary }]}>
|
||||
›
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 时间选择器弹窗 */}
|
||||
<Modal
|
||||
visible={showTimePicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowTimePicker(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalBackdrop}
|
||||
activeOpacity={1}
|
||||
onPress={handleCancelTime}
|
||||
/>
|
||||
<View style={styles.modalSheet}>
|
||||
<DateTimePicker
|
||||
value={tempSelectedTime || getCurrentTimeDate()}
|
||||
mode="time"
|
||||
is24Hour={true}
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalBtn]}
|
||||
onPress={handleCancelTime}
|
||||
>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary]}
|
||||
onPress={handleConfirmTime}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* 描述输入(可选) */}
|
||||
<View style={[styles.optionCard, { backgroundColor: colorTokens.card }]}>
|
||||
<TextInput
|
||||
style={[styles.descriptionInput, { color: colorTokens.text }]}
|
||||
placeholder="添加描述(可选)"
|
||||
placeholderTextColor={colorTokens.textSecondary}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{ opacity: loading || !title.trim() ? 0.5 : 1 }
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={loading || !title.trim()}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<TouchableOpacity
|
||||
key={event.id}
|
||||
@@ -104,8 +109,8 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
||||
{
|
||||
top,
|
||||
height,
|
||||
left: TIME_LABEL_WIDTH + 20 + leftOffset,
|
||||
width: eventWidth - 4,
|
||||
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
|
||||
width: eventWidth - 8, // 增加卡片间距
|
||||
backgroundColor: event.isCompleted
|
||||
? `${categoryColor}40`
|
||||
: `${categoryColor}80`,
|
||||
@@ -124,15 +129,17 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
||||
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
|
||||
}
|
||||
]}
|
||||
numberOfLines={2}
|
||||
numberOfLines={shouldShowTimeRange ? 1 : 2} // 当显示时间时,标题只显示1行
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
{shouldShowTimeRange && (
|
||||
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
|
||||
{dayjs(event.startTime).format('HH:mm')}
|
||||
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.isCompleted && (
|
||||
<View style={styles.completedIcon}>
|
||||
@@ -146,22 +153,23 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 日期标题 */}
|
||||
<View style={styles.dateHeader}>
|
||||
<Text style={[styles.dateText, { color: colorTokens.text }]}>
|
||||
{dayjs(selectedDate).format('YYYY年M月D日 dddd')}
|
||||
</Text>
|
||||
<Text style={[styles.eventCount, { color: colorTokens.textSecondary }]}>
|
||||
{events.length} 项任务
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 时间轴 */}
|
||||
<ScrollView
|
||||
style={styles.timelineContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50 }}
|
||||
>
|
||||
{/* 日期标题 */}
|
||||
<View style={styles.dateHeader}>
|
||||
<Text style={[styles.dateText, { color: colorTokens.text }]}>
|
||||
{dayjs(selectedDate).format('YYYY年M月D日 dddd')}
|
||||
</Text>
|
||||
<Text style={[styles.eventCount, { color: colorTokens.textSecondary }]}>
|
||||
{events.length} 个目标
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.timeline}>
|
||||
{/* 时间标签 */}
|
||||
<View style={styles.timeLabelsContainer}>
|
||||
@@ -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 (
|
||||
<View
|
||||
@@ -245,16 +254,14 @@ const styles = StyleSheet.create({
|
||||
dateHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
eventCount: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
timelineContainer: {
|
||||
@@ -283,7 +290,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginLeft: 8,
|
||||
marginRight: 20,
|
||||
marginRight: 24,
|
||||
},
|
||||
eventsContainer: {
|
||||
position: 'absolute',
|
||||
@@ -309,6 +316,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
justifyContent: 'space-between',
|
||||
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 12,
|
||||
@@ -327,8 +335,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
currentTimeLine: {
|
||||
position: 'absolute',
|
||||
left: TIME_LABEL_WIDTH + 20,
|
||||
right: 20,
|
||||
left: TIME_LABEL_WIDTH + 24,
|
||||
right: 24,
|
||||
height: 2,
|
||||
zIndex: 10,
|
||||
},
|
||||
|
||||
@@ -95,9 +95,7 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
|
||||
<Text style={styles.categoryText}>{item.category}</Text>
|
||||
</View>
|
||||
|
||||
{item.priority && (
|
||||
<View style={[styles.priorityDot, { backgroundColor: priorityColor }]} />
|
||||
)}
|
||||
<Ionicons name="star-outline" size={18} color={colorTokens.textMuted} />
|
||||
</View>
|
||||
|
||||
{/* 主要内容 */}
|
||||
@@ -117,11 +115,11 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.timeContainer}>
|
||||
<Ionicons
|
||||
name={isToday ? "time" : "calendar-outline"}
|
||||
name='time-outline'
|
||||
size={14}
|
||||
color={colorTokens.textMuted}
|
||||
/>
|
||||
<Text style={[styles.timeText, { color: colorTokens.textMuted }]}>
|
||||
<Text style={[styles.timeText]}>
|
||||
{timeFormatted}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部指示器 */}
|
||||
<View style={styles.indicatorContainer}>
|
||||
{/* <View style={styles.indicatorContainer}>
|
||||
{todos.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
@@ -62,7 +62,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View> */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,6 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
},
|
||||
scrollView: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
|
||||
112
docs/goals-implementation-summary.md
Normal file
112
docs/goals-implementation-summary.md
Normal file
@@ -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. **端到端测试**: 测试完整的用户流程
|
||||
154
services/goalsApi.ts
Normal file
154
services/goalsApi.ts
Normal file
@@ -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<Goal> => {
|
||||
return api.post<Goal>('/goals', goalData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取目标列表
|
||||
*/
|
||||
export const getGoals = async (query: GetGoalsQuery = {}): Promise<PaginatedResponse<GoalListItem>> => {
|
||||
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<PaginatedResponse<GoalListItem>>(path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取目标详情
|
||||
*/
|
||||
export const getGoalById = async (goalId: string): Promise<ApiResponse<GoalDetailResponse>> => {
|
||||
return api.get<ApiResponse<GoalDetailResponse>>(`/goals/${goalId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新目标
|
||||
*/
|
||||
export const updateGoal = async (goalId: string, goalData: UpdateGoalRequest): Promise<ApiResponse<Goal>> => {
|
||||
return api.put<ApiResponse<Goal>>(`/goals/${goalId}`, goalData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除目标
|
||||
*/
|
||||
export const deleteGoal = async (goalId: string): Promise<ApiResponse<boolean>> => {
|
||||
return api.delete<ApiResponse<boolean>>(`/goals/${goalId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录目标完成
|
||||
*/
|
||||
export const completeGoal = async (goalId: string, completionData: CompleteGoalRequest = {}): Promise<ApiResponse<GoalCompletion>> => {
|
||||
return api.post<ApiResponse<GoalCompletion>>(`/goals/${goalId}/complete`, completionData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取目标完成记录
|
||||
*/
|
||||
export const getGoalCompletions = async (
|
||||
goalId: string,
|
||||
query: GetGoalCompletionsQuery = {}
|
||||
): Promise<ApiResponse<PaginatedResponse<GoalCompletion>>> => {
|
||||
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<ApiResponse<PaginatedResponse<GoalCompletion>>>(path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取目标统计信息
|
||||
*/
|
||||
export const getGoalStats = async (): Promise<ApiResponse<GoalStats>> => {
|
||||
return api.get<ApiResponse<GoalStats>>('/goals/stats/overview');
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量操作目标
|
||||
*/
|
||||
export const batchOperateGoals = async (operationData: BatchGoalOperationRequest): Promise<ApiResponse<BatchGoalOperationResult[]>> => {
|
||||
return api.post<ApiResponse<BatchGoalOperationResult[]>>('/goals/batch', operationData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 暂停目标
|
||||
*/
|
||||
export const pauseGoal = async (goalId: string): Promise<ApiResponse<Goal>> => {
|
||||
return updateGoal(goalId, { status: 'paused' });
|
||||
};
|
||||
|
||||
/**
|
||||
* 恢复目标
|
||||
*/
|
||||
export const resumeGoal = async (goalId: string): Promise<ApiResponse<Goal>> => {
|
||||
return updateGoal(goalId, { status: 'active' });
|
||||
};
|
||||
|
||||
/**
|
||||
* 完成目标
|
||||
*/
|
||||
export const markGoalCompleted = async (goalId: string): Promise<ApiResponse<Goal>> => {
|
||||
return updateGoal(goalId, { status: 'completed' });
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消目标
|
||||
*/
|
||||
export const cancelGoal = async (goalId: string): Promise<ApiResponse<Goal>> => {
|
||||
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;
|
||||
608
store/goalsSlice.ts
Normal file
608
store/goalsSlice.ts
Normal file
@@ -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<Partial<GetGoalsQuery>>) => {
|
||||
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;
|
||||
@@ -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,
|
||||
|
||||
180
types/goals.ts
Normal file
180
types/goals.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
// 创建目标的请求数据
|
||||
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<string, number>;
|
||||
byRepeatType: Record<RepeatType, number>;
|
||||
totalCompletions: number;
|
||||
thisWeekCompletions: number;
|
||||
thisMonthCompletions: number;
|
||||
}
|
||||
|
||||
// API响应格式
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应格式
|
||||
export interface PaginatedResponse<T> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user