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

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

View File

@@ -1,107 +1,132 @@
import CreateGoalModal from '@/components/CreateGoalModal';
import { TimeTabSelector, TimeTabType } from '@/components/TimeTabSelector';
import { TimelineSchedule } from '@/components/TimelineSchedule';
import { TodoItem } from '@/components/TodoCard';
import { TodoCarousel } from '@/components/TodoCarousel';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { clearErrors, completeGoal, createGoal, fetchGoals } from '@/store/goalsSlice';
import { CreateGoalRequest, GoalListItem } from '@/types/goals';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
dayjs.extend(isBetween);
// 模拟数据
const mockTodos: TodoItem[] = [
{
id: '1',
title: '每日健身训练',
description: '完成30分钟普拉提训练',
time: dayjs().hour(8).minute(0).toISOString(),
category: 'workout',
priority: 'high',
isCompleted: false,
},
{
id: '2',
title: '支付信用卡账单',
description: '本月信用卡账单到期',
time: dayjs().hour(10).minute(0).toISOString(),
category: 'finance',
priority: 'medium',
isCompleted: false,
},
{
id: '3',
title: '参加瑜伽课程',
description: '晚上瑜伽课程预约',
time: dayjs().hour(19).minute(0).toISOString(),
category: 'personal',
priority: 'low',
isCompleted: true,
},
];
// 将目标转换为TodoItem的辅助函数
const goalToTodoItem = (goal: GoalListItem): TodoItem => {
return {
id: goal.id,
title: goal.title,
description: goal.description || `${goal.frequency}次/${getRepeatTypeLabel(goal.repeatType)}`,
time: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString() || '',
const mockTimelineEvents = [
{
id: '1',
title: '每日健身训练',
startTime: dayjs().hour(8).minute(0).toISOString(),
endTime: dayjs().hour(8).minute(30).toISOString(),
category: 'workout' as const,
isCompleted: false,
},
{
id: '2',
title: '支付信用卡账单',
startTime: dayjs().hour(10).minute(0).toISOString(),
endTime: dayjs().hour(10).minute(15).toISOString(),
category: 'finance' as const,
isCompleted: false,
},
{
id: '3',
title: '团队会议',
startTime: dayjs().hour(10).minute(0).toISOString(),
endTime: dayjs().hour(11).minute(0).toISOString(),
category: 'work' as const,
isCompleted: false,
},
{
id: '4',
title: '午餐时间',
startTime: dayjs().hour(12).minute(0).toISOString(),
endTime: dayjs().hour(13).minute(0).toISOString(),
category: 'personal' as const,
isCompleted: true,
},
{
id: '5',
title: '健康检查',
startTime: dayjs().hour(14).minute(30).toISOString(),
endTime: dayjs().hour(15).minute(30).toISOString(),
category: 'health' as const,
isCompleted: false,
},
{
id: '6',
title: '参加瑜伽课程',
startTime: dayjs().hour(19).minute(0).toISOString(),
endTime: dayjs().hour(20).minute(0).toISOString(),
category: 'personal' as const,
isCompleted: true,
},
];
category: getCategoryFromGoal(goal.category),
priority: getPriorityFromGoal(goal.priority),
isCompleted: goal.status === 'completed',
};
};
// 获取重复类型标签
const getRepeatTypeLabel = (repeatType: string): string => {
switch (repeatType) {
case 'daily': return '每日';
case 'weekly': return '每周';
case 'monthly': return '每月';
default: return '自定义';
}
};
// 从目标分类获取TodoItem分类
const getCategoryFromGoal = (category?: string): TodoItem['category'] => {
if (!category) return 'personal';
if (category.includes('运动') || category.includes('健身')) return 'workout';
if (category.includes('工作')) return 'work';
if (category.includes('健康')) return 'health';
if (category.includes('财务')) return 'finance';
return 'personal';
};
// 从目标优先级获取TodoItem优先级
const getPriorityFromGoal = (priority: number): TodoItem['priority'] => {
if (priority >= 8) return 'high';
if (priority >= 5) return 'medium';
return 'low';
};
// 将目标转换为时间轴事件的辅助函数
const goalToTimelineEvent = (goal: GoalListItem) => {
return {
id: goal.id,
title: goal.title,
startTime: dayjs().startOf('day').add(goal.startTime, 'minute').toISOString(),
endTime: goal.endTime ? dayjs().startOf('day').add(goal.endTime, 'minute').toISOString() : undefined,
category: getCategoryFromGoal(goal.category),
isCompleted: goal.status === 'completed',
};
};
export default function GoalsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
// Redux状态
const {
goals,
goalsLoading,
goalsError,
createLoading,
createError
} = useAppSelector((state) => state.goals);
const [selectedTab, setSelectedTab] = useState<TimeTabType>('day');
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [todos, setTodos] = useState<TodoItem[]>(mockTodos);
const [showCreateModal, setShowCreateModal] = useState(false);
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect');
// 只在需要时刷新数据,比如从后台返回或从其他页面返回
dispatch(fetchGoals({
status: 'active',
page: 1,
pageSize: 200,
}));
}, [dispatch])
);
// 处理错误提示
useEffect(() => {
console.log('goalsError', goalsError);
console.log('createError', createError);
if (goalsError) {
Alert.alert('错误', goalsError);
dispatch(clearErrors());
}
if (createError) {
Alert.alert('创建失败', createError);
dispatch(clearErrors());
}
}, [goalsError, createError, dispatch]);
// 创建目标处理函数
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
try {
await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false);
Alert.alert('成功', '目标创建成功!');
} catch (error) {
// 错误已在useEffect中处理
}
};
// tab切换处理函数
const handleTabChange = (tab: TimeTabType) => {
@@ -181,51 +206,68 @@ export default function GoalsScreen() {
}
};
// 上半部分待办卡片始终只显示当日数据
// 将目标转换为TodoItem数据
const todayTodos = useMemo(() => {
const today = dayjs();
return todos.filter(todo =>
dayjs(todo.time).isSame(today, 'day')
const activeGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' ||
(goal.repeatType === 'weekly' && today.day() !== 0) ||
(goal.repeatType === 'monthly' && today.date() <= 28))
);
}, [todos]);
return activeGoals.map(goalToTodoItem);
}, [goals]);
// 下半部分时间轴根据选择的时间范围和日期过滤数据
// 将目标转换为时间轴事件数据
const filteredTimelineEvents = useMemo(() => {
const selected = dayjs(selectedDate);
let filteredGoals: GoalListItem[] = [];
switch (selectedTab) {
case 'day':
return mockTimelineEvents.filter(event =>
dayjs(event.startTime).isSame(selected, 'day')
);
filteredGoals = goals.filter(goal => {
if (goal.status !== 'active') return false;
if (goal.repeatType === 'daily') return true;
if (goal.repeatType === 'weekly') return selected.day() !== 0;
if (goal.repeatType === 'monthly') return selected.date() <= 28;
return false;
});
break;
case 'week':
return mockTimelineEvents.filter(event =>
dayjs(event.startTime).isSame(selected, 'week')
filteredGoals = goals.filter(goal =>
goal.status === 'active' &&
(goal.repeatType === 'daily' || goal.repeatType === 'weekly')
);
break;
case 'month':
return mockTimelineEvents.filter(event =>
dayjs(event.startTime).isSame(selected, 'month')
);
filteredGoals = goals.filter(goal => goal.status === 'active');
break;
default:
return mockTimelineEvents;
filteredGoals = goals.filter(goal => goal.status === 'active');
}
}, [selectedTab, selectedDate]);
return filteredGoals.map(goalToTimelineEvent);
}, [selectedTab, selectedDate, goals]);
console.log('filteredTimelineEvents', filteredTimelineEvents);
const handleTodoPress = (item: TodoItem) => {
console.log('Todo pressed:', item.title);
// 这里可以导航到详情页面或展示编辑模态框
console.log('Goal pressed:', item.title);
// 这里可以导航到目标详情页面
};
const handleToggleComplete = (item: TodoItem) => {
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === item.id
? { ...todo, isCompleted: !todo.isCompleted }
: todo
)
);
const handleToggleComplete = async (item: TodoItem) => {
try {
await dispatch(completeGoal({
goalId: item.id,
completionData: {
completionCount: 1,
notes: '通过待办卡片完成'
}
})).unwrap();
} catch (error) {
Alert.alert('错误', '记录完成失败');
}
};
const handleEventPress = (event: any) => {
@@ -255,9 +297,12 @@ export default function GoalsScreen() {
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
</Text>
<Text style={[styles.pageSubtitle, { color: colorTokens.textSecondary }]}>
{dayjs().format('YYYY年M月D日 dddd')}
</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => setShowCreateModal(true)}
>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
{/* 今日待办事项卡片 */}
@@ -323,7 +368,6 @@ export default function GoalsScreen() {
isFutureDate && styles.dayDateDisabled
]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
{selected && <View style={[styles.selectedDot, { backgroundColor: colorTokens.primary }]} />}
</View>
);
})}
@@ -339,6 +383,14 @@ export default function GoalsScreen() {
onEventPress={handleEventPress}
/>
</View>
{/* 创建目标弹窗 */}
<CreateGoalModal
visible={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateGoal}
loading={createLoading}
/>
</View>
</SafeAreaView>
);
@@ -359,6 +411,9 @@ const styles = StyleSheet.create({
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 16,
@@ -368,6 +423,25 @@ const styles = StyleSheet.create({
fontWeight: '800',
marginBottom: 4,
},
addButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#6366F1',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
addButtonText: {
color: '#FFFFFF',
fontSize: 24,
fontWeight: '600',
lineHeight: 24,
},
pageSubtitle: {
fontSize: 16,
fontWeight: '500',
@@ -377,7 +451,6 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
marginTop: 8,
overflow: 'hidden',
},
// 日期选择器样式 (参考 statistics.tsx)
@@ -399,8 +472,8 @@ const styles = StyleSheet.create({
marginRight: 8,
},
dayPill: {
width: 48,
height: 72,
width: 40,
height: 60,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
@@ -421,7 +494,7 @@ const styles = StyleSheet.create({
opacity: 0.4,
},
dayLabel: {
fontSize: 12,
fontSize: 11,
fontWeight: '700',
color: 'gray',
marginBottom: 2,
@@ -432,7 +505,7 @@ const styles = StyleSheet.create({
dayLabelDisabled: {
},
dayDate: {
fontSize: 14,
fontSize: 12,
fontWeight: '800',
color: 'gray',
},
@@ -441,12 +514,4 @@ const styles = StyleSheet.create({
},
dayDateDisabled: {
},
selectedDot: {
width: 5,
height: 5,
borderRadius: 2.5,
marginTop: 6,
marginBottom: 2,
alignSelf: 'center',
},
});

View 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;

View File

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

View File

@@ -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,
},

View File

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

View File

@@ -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,

View 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
View 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
View 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;

View File

@@ -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
View 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-60为周日
monthDays?: number[]; // 1-31
interval?: number; // 间隔周数或月数
}
// 提醒设置
export interface ReminderSettings {
enabled: boolean;
weekdays?: number[]; // 0-60为周日
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;
}