feat: 完善目标管理功能及相关组件
- 新增创建目标弹窗,支持用户输入目标信息并提交 - 实现目标数据的转换,支持将目标转换为待办事项和时间轴事件 - 优化目标页面,集成Redux状态管理,处理目标的创建、完成和错误提示 - 更新时间轴组件,支持动态显示目标安排 - 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
@@ -1,107 +1,132 @@
|
|||||||
|
import CreateGoalModal from '@/components/CreateGoalModal';
|
||||||
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
|
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
|
||||||
import { TimelineSchedule } from '@/components/TimelineSchedule';
|
import { TimelineSchedule } from '@/components/TimelineSchedule';
|
||||||
import { TodoItem } from '@/components/TodoCard';
|
import { TodoItem } from '@/components/TodoCard';
|
||||||
import { TodoCarousel } from '@/components/TodoCarousel';
|
import { TodoCarousel } from '@/components/TodoCarousel';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
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 { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
import isBetween from 'dayjs/plugin/isBetween';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
|
|
||||||
// 模拟数据
|
// 将目标转换为TodoItem的辅助函数
|
||||||
const mockTodos: TodoItem[] = [
|
const goalToTodoItem = (goal: GoalListItem): TodoItem => {
|
||||||
{
|
return {
|
||||||
id: '1',
|
id: goal.id,
|
||||||
title: '每日健身训练',
|
title: goal.title,
|
||||||
description: '完成30分钟普拉提训练',
|
description: goal.description || `${goal.frequency}次/${getRepeatTypeLabel(goal.repeatType)}`,
|
||||||
time: dayjs().hour(8).minute(0).toISOString(),
|
time: dayjs().startOf('day').add(goal.startTime, 'minute').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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockTimelineEvents = [
|
category: getCategoryFromGoal(goal.category),
|
||||||
{
|
priority: getPriorityFromGoal(goal.priority),
|
||||||
id: '1',
|
isCompleted: goal.status === 'completed',
|
||||||
title: '每日健身训练',
|
};
|
||||||
startTime: dayjs().hour(8).minute(0).toISOString(),
|
};
|
||||||
endTime: dayjs().hour(8).minute(30).toISOString(),
|
|
||||||
category: 'workout' as const,
|
// 获取重复类型标签
|
||||||
isCompleted: false,
|
const getRepeatTypeLabel = (repeatType: string): string => {
|
||||||
},
|
switch (repeatType) {
|
||||||
{
|
case 'daily': return '每日';
|
||||||
id: '2',
|
case 'weekly': return '每周';
|
||||||
title: '支付信用卡账单',
|
case 'monthly': return '每月';
|
||||||
startTime: dayjs().hour(10).minute(0).toISOString(),
|
default: return '自定义';
|
||||||
endTime: dayjs().hour(10).minute(15).toISOString(),
|
}
|
||||||
category: 'finance' as const,
|
};
|
||||||
isCompleted: false,
|
|
||||||
},
|
// 从目标分类获取TodoItem分类
|
||||||
{
|
const getCategoryFromGoal = (category?: string): TodoItem['category'] => {
|
||||||
id: '3',
|
if (!category) return 'personal';
|
||||||
title: '团队会议',
|
if (category.includes('运动') || category.includes('健身')) return 'workout';
|
||||||
startTime: dayjs().hour(10).minute(0).toISOString(),
|
if (category.includes('工作')) return 'work';
|
||||||
endTime: dayjs().hour(11).minute(0).toISOString(),
|
if (category.includes('健康')) return 'health';
|
||||||
category: 'work' as const,
|
if (category.includes('财务')) return 'finance';
|
||||||
isCompleted: false,
|
return 'personal';
|
||||||
},
|
};
|
||||||
{
|
|
||||||
id: '4',
|
// 从目标优先级获取TodoItem优先级
|
||||||
title: '午餐时间',
|
const getPriorityFromGoal = (priority: number): TodoItem['priority'] => {
|
||||||
startTime: dayjs().hour(12).minute(0).toISOString(),
|
if (priority >= 8) return 'high';
|
||||||
endTime: dayjs().hour(13).minute(0).toISOString(),
|
if (priority >= 5) return 'medium';
|
||||||
category: 'personal' as const,
|
return 'low';
|
||||||
isCompleted: true,
|
};
|
||||||
},
|
|
||||||
{
|
// 将目标转换为时间轴事件的辅助函数
|
||||||
id: '5',
|
const goalToTimelineEvent = (goal: GoalListItem) => {
|
||||||
title: '健康检查',
|
|
||||||
startTime: dayjs().hour(14).minute(30).toISOString(),
|
return {
|
||||||
endTime: dayjs().hour(15).minute(30).toISOString(),
|
id: goal.id,
|
||||||
category: 'health' as const,
|
title: goal.title,
|
||||||
isCompleted: false,
|
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),
|
||||||
id: '6',
|
isCompleted: goal.status === 'completed',
|
||||||
title: '参加瑜伽课程',
|
};
|
||||||
startTime: dayjs().hour(19).minute(0).toISOString(),
|
};
|
||||||
endTime: dayjs().hour(20).minute(0).toISOString(),
|
|
||||||
category: 'personal' as const,
|
|
||||||
isCompleted: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GoalsScreen() {
|
export default function GoalsScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
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 [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
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切换处理函数
|
// tab切换处理函数
|
||||||
const handleTabChange = (tab: TimeTabType) => {
|
const handleTabChange = (tab: TimeTabType) => {
|
||||||
@@ -181,51 +206,68 @@ export default function GoalsScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 上半部分待办卡片始终只显示当日数据
|
// 将目标转换为TodoItem数据
|
||||||
const todayTodos = useMemo(() => {
|
const todayTodos = useMemo(() => {
|
||||||
const today = dayjs();
|
const today = dayjs();
|
||||||
return todos.filter(todo =>
|
const activeGoals = goals.filter(goal =>
|
||||||
dayjs(todo.time).isSame(today, 'day')
|
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 filteredTimelineEvents = useMemo(() => {
|
||||||
const selected = dayjs(selectedDate);
|
const selected = dayjs(selectedDate);
|
||||||
|
let filteredGoals: GoalListItem[] = [];
|
||||||
|
|
||||||
switch (selectedTab) {
|
switch (selectedTab) {
|
||||||
case 'day':
|
case 'day':
|
||||||
return mockTimelineEvents.filter(event =>
|
filteredGoals = goals.filter(goal => {
|
||||||
dayjs(event.startTime).isSame(selected, 'day')
|
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':
|
case 'week':
|
||||||
return mockTimelineEvents.filter(event =>
|
filteredGoals = goals.filter(goal =>
|
||||||
dayjs(event.startTime).isSame(selected, 'week')
|
goal.status === 'active' &&
|
||||||
|
(goal.repeatType === 'daily' || goal.repeatType === 'weekly')
|
||||||
);
|
);
|
||||||
|
break;
|
||||||
case 'month':
|
case 'month':
|
||||||
return mockTimelineEvents.filter(event =>
|
filteredGoals = goals.filter(goal => goal.status === 'active');
|
||||||
dayjs(event.startTime).isSame(selected, 'month')
|
break;
|
||||||
);
|
|
||||||
default:
|
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) => {
|
const handleTodoPress = (item: TodoItem) => {
|
||||||
console.log('Todo pressed:', item.title);
|
console.log('Goal pressed:', item.title);
|
||||||
// 这里可以导航到详情页面或展示编辑模态框
|
// 这里可以导航到目标详情页面
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleComplete = (item: TodoItem) => {
|
const handleToggleComplete = async (item: TodoItem) => {
|
||||||
setTodos(prevTodos =>
|
try {
|
||||||
prevTodos.map(todo =>
|
await dispatch(completeGoal({
|
||||||
todo.id === item.id
|
goalId: item.id,
|
||||||
? { ...todo, isCompleted: !todo.isCompleted }
|
completionData: {
|
||||||
: todo
|
completionCount: 1,
|
||||||
)
|
notes: '通过待办卡片完成'
|
||||||
);
|
}
|
||||||
|
})).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '记录完成失败');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEventPress = (event: any) => {
|
const handleEventPress = (event: any) => {
|
||||||
@@ -255,9 +297,12 @@ export default function GoalsScreen() {
|
|||||||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||||
今日
|
今日
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.pageSubtitle, { color: colorTokens.textSecondary }]}>
|
<TouchableOpacity
|
||||||
{dayjs().format('YYYY年M月D日 dddd')}
|
style={styles.addButton}
|
||||||
</Text>
|
onPress={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.addButtonText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 今日待办事项卡片 */}
|
{/* 今日待办事项卡片 */}
|
||||||
@@ -323,7 +368,6 @@ export default function GoalsScreen() {
|
|||||||
isFutureDate && styles.dayDateDisabled
|
isFutureDate && styles.dayDateDisabled
|
||||||
]}>{d.dayOfMonth}</Text>
|
]}>{d.dayOfMonth}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{selected && <View style={[styles.selectedDot, { backgroundColor: colorTokens.primary }]} />}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -339,6 +383,14 @@ export default function GoalsScreen() {
|
|||||||
onEventPress={handleEventPress}
|
onEventPress={handleEventPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 创建目标弹窗 */}
|
||||||
|
<CreateGoalModal
|
||||||
|
visible={showCreateModal}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onSubmit={handleCreateGoal}
|
||||||
|
loading={createLoading}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -359,6 +411,9 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
@@ -368,6 +423,25 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
marginBottom: 4,
|
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: {
|
pageSubtitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -377,7 +451,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
marginTop: 8,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
// 日期选择器样式 (参考 statistics.tsx)
|
// 日期选择器样式 (参考 statistics.tsx)
|
||||||
@@ -399,8 +472,8 @@ const styles = StyleSheet.create({
|
|||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
dayPill: {
|
dayPill: {
|
||||||
width: 48,
|
width: 40,
|
||||||
height: 72,
|
height: 60,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -421,7 +494,7 @@ const styles = StyleSheet.create({
|
|||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
},
|
},
|
||||||
dayLabel: {
|
dayLabel: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
@@ -432,7 +505,7 @@ const styles = StyleSheet.create({
|
|||||||
dayLabelDisabled: {
|
dayLabelDisabled: {
|
||||||
},
|
},
|
||||||
dayDate: {
|
dayDate: {
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
},
|
},
|
||||||
@@ -441,12 +514,4 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
dayDateDisabled: {
|
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: {
|
tabContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderRadius: 12,
|
borderRadius: 20,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
@@ -87,7 +87,7 @@ const styles = StyleSheet.create({
|
|||||||
tab: {
|
tab: {
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 32,
|
paddingHorizontal: 32,
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const getEventStyle = (event: TimelineEvent) => {
|
|||||||
const endMinutes = endTime.hour() * 60 + endTime.minute();
|
const endMinutes = endTime.hour() * 60 + endTime.minute();
|
||||||
const durationMinutes = endMinutes - startMinutes;
|
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
|
const height = Math.max((durationMinutes / 60) * HOUR_HEIGHT, 30); // 最小高度30
|
||||||
|
|
||||||
return { top, height };
|
return { top, height };
|
||||||
@@ -93,9 +94,13 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
|||||||
const categoryColor = getCategoryColor(event.category);
|
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;
|
const leftOffset = index * eventWidth;
|
||||||
|
|
||||||
|
// 判断是否应该显示时间段 - 当卡片高度小于50或宽度小于80时隐藏时间段
|
||||||
|
const shouldShowTimeRange = height >= 50 && eventWidth >= 80;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -104,8 +109,8 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
|||||||
{
|
{
|
||||||
top,
|
top,
|
||||||
height,
|
height,
|
||||||
left: TIME_LABEL_WIDTH + 20 + leftOffset,
|
left: TIME_LABEL_WIDTH + 24 + leftOffset, // 调整左偏移对齐
|
||||||
width: eventWidth - 4,
|
width: eventWidth - 8, // 增加卡片间距
|
||||||
backgroundColor: event.isCompleted
|
backgroundColor: event.isCompleted
|
||||||
? `${categoryColor}40`
|
? `${categoryColor}40`
|
||||||
: `${categoryColor}80`,
|
: `${categoryColor}80`,
|
||||||
@@ -124,15 +129,17 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
|||||||
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
|
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
numberOfLines={2}
|
numberOfLines={shouldShowTimeRange ? 1 : 2} // 当显示时间时,标题只显示1行
|
||||||
>
|
>
|
||||||
{event.title}
|
{event.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{shouldShowTimeRange && (
|
||||||
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.eventTime, { color: colorTokens.textSecondary }]}>
|
||||||
{dayjs(event.startTime).format('HH:mm')}
|
{dayjs(event.startTime).format('HH:mm')}
|
||||||
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
|
{event.endTime && ` - ${dayjs(event.endTime).format('HH:mm')}`}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{event.isCompleted && (
|
{event.isCompleted && (
|
||||||
<View style={styles.completedIcon}>
|
<View style={styles.completedIcon}>
|
||||||
@@ -146,22 +153,23 @@ export function TimelineSchedule({ events, selectedDate, onEventPress }: Timelin
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<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
|
<ScrollView
|
||||||
style={styles.timelineContainer}
|
style={styles.timelineContainer}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 50 }}
|
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.timeline}>
|
||||||
{/* 时间标签 */}
|
{/* 时间标签 */}
|
||||||
<View style={styles.timeLabelsContainer}>
|
<View style={styles.timeLabelsContainer}>
|
||||||
@@ -220,7 +228,8 @@ function CurrentTimeLine() {
|
|||||||
|
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
const currentMinutes = now.hour() * 60 + now.minute();
|
const currentMinutes = now.hour() * 60 + now.minute();
|
||||||
const top = (currentMinutes / 60) * HOUR_HEIGHT;
|
// 当前时间线也需要加上时间标签的paddingTop偏移(8px)
|
||||||
|
const top = (currentMinutes / 60) * HOUR_HEIGHT + 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -245,16 +254,14 @@ const styles = StyleSheet.create({
|
|||||||
dateHeader: {
|
dateHeader: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#E5E7EB',
|
|
||||||
},
|
},
|
||||||
dateText: {
|
dateText: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
eventCount: {
|
eventCount: {
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
timelineContainer: {
|
timelineContainer: {
|
||||||
@@ -283,7 +290,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
marginRight: 20,
|
marginRight: 24,
|
||||||
},
|
},
|
||||||
eventsContainer: {
|
eventsContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -309,6 +316,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|
||||||
},
|
},
|
||||||
eventTitle: {
|
eventTitle: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -327,8 +335,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
currentTimeLine: {
|
currentTimeLine: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: TIME_LABEL_WIDTH + 20,
|
left: TIME_LABEL_WIDTH + 24,
|
||||||
right: 20,
|
right: 24,
|
||||||
height: 2,
|
height: 2,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,9 +95,7 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
|
|||||||
<Text style={styles.categoryText}>{item.category}</Text>
|
<Text style={styles.categoryText}>{item.category}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.priority && (
|
<Ionicons name="star-outline" size={18} color={colorTokens.textMuted} />
|
||||||
<View style={[styles.priorityDot, { backgroundColor: priorityColor }]} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 主要内容 */}
|
{/* 主要内容 */}
|
||||||
@@ -117,11 +115,11 @@ export function TodoCard({ item, onPress, onToggleComplete }: TodoCardProps) {
|
|||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.timeContainer}>
|
<View style={styles.timeContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isToday ? "time" : "calendar-outline"}
|
name='time-outline'
|
||||||
size={14}
|
size={14}
|
||||||
color={colorTokens.textMuted}
|
color={colorTokens.textMuted}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.timeText, { color: colorTokens.textMuted }]}>
|
<Text style={[styles.timeText]}>
|
||||||
{timeFormatted}
|
{timeFormatted}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -159,13 +157,6 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: 8,
|
marginHorizontal: 8,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 4,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部指示器 */}
|
{/* 底部指示器 */}
|
||||||
<View style={styles.indicatorContainer}>
|
{/* <View style={styles.indicatorContainer}>
|
||||||
{todos.map((_, index) => (
|
{todos.map((_, index) => (
|
||||||
<View
|
<View
|
||||||
key={index}
|
key={index}
|
||||||
@@ -62,7 +62,7 @@ export function TodoCarousel({ todos, onTodoPress, onToggleComplete }: TodoCarou
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View> */}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,6 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
marginBottom: 12,
|
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 20,
|
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 challengeReducer from './challengeSlice';
|
||||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||||
|
import goalsReducer from './goalsSlice';
|
||||||
import moodReducer from './moodSlice';
|
import moodReducer from './moodSlice';
|
||||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||||
import trainingPlanReducer from './trainingPlanSlice';
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
@@ -41,6 +42,7 @@ export const store = configureStore({
|
|||||||
user: userReducer,
|
user: userReducer,
|
||||||
challenge: challengeReducer,
|
challenge: challengeReducer,
|
||||||
checkin: checkinReducer,
|
checkin: checkinReducer,
|
||||||
|
goals: goalsReducer,
|
||||||
mood: moodReducer,
|
mood: moodReducer,
|
||||||
trainingPlan: trainingPlanReducer,
|
trainingPlan: trainingPlanReducer,
|
||||||
scheduleExercise: scheduleExerciseReducer,
|
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