feat: 新增目标管理功能及相关组件
- 创建目标管理演示页面,展示高保真的目标管理界面 - 实现待办事项卡片的横向滑动展示,支持时间筛选功能 - 新增时间轴组件,展示选中日期的具体任务 - 更新底部导航,添加目标管理和演示页面的路由 - 优化个人页面菜单项,提供目标管理的快速访问 - 编写目标管理功能实现文档,详细描述功能和组件架构
This commit is contained in:
354
components/TimelineSchedule.tsx
Normal file
354
components/TimelineSchedule.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { TodoItem } from './TodoCard';
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
category: TodoItem['category'];
|
||||
isCompleted?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface TimelineScheduleProps {
|
||||
events: TimelineEvent[];
|
||||
selectedDate: Date;
|
||||
onEventPress?: (event: TimelineEvent) => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const HOUR_HEIGHT = 60;
|
||||
const TIME_LABEL_WIDTH = 60;
|
||||
|
||||
// 生成24小时的时间标签
|
||||
const generateTimeLabels = () => {
|
||||
const labels = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
labels.push(dayjs().hour(hour).minute(0).format('HH:mm'));
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
|
||||
// 获取事件在时间轴上的位置和高度
|
||||
const getEventStyle = (event: TimelineEvent) => {
|
||||
const startTime = dayjs(event.startTime);
|
||||
const endTime = event.endTime ? dayjs(event.endTime) : startTime.add(1, 'hour');
|
||||
|
||||
const startMinutes = startTime.hour() * 60 + startTime.minute();
|
||||
const endMinutes = endTime.hour() * 60 + endTime.minute();
|
||||
const durationMinutes = endMinutes - startMinutes;
|
||||
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max((durationMinutes / 60) * HOUR_HEIGHT, 30); // 最小高度30
|
||||
|
||||
return { top, height };
|
||||
};
|
||||
|
||||
// 获取分类颜色
|
||||
const getCategoryColor = (category: TodoItem['category']) => {
|
||||
switch (category) {
|
||||
case 'workout':
|
||||
return '#FF6B6B';
|
||||
case 'finance':
|
||||
return '#4ECDC4';
|
||||
case 'personal':
|
||||
return '#45B7D1';
|
||||
case 'work':
|
||||
return '#96CEB4';
|
||||
case 'health':
|
||||
return '#FFEAA7';
|
||||
default:
|
||||
return '#DDA0DD';
|
||||
}
|
||||
};
|
||||
|
||||
export function TimelineSchedule({ events, selectedDate, onEventPress }: TimelineScheduleProps) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const timeLabels = generateTimeLabels();
|
||||
|
||||
// 按开始时间分组事件,处理同一时间的多个事件
|
||||
const groupedEvents = useMemo(() => {
|
||||
const groups: { [key: string]: TimelineEvent[] } = {};
|
||||
|
||||
events.forEach(event => {
|
||||
const startHour = dayjs(event.startTime).format('HH:mm');
|
||||
if (!groups[startHour]) {
|
||||
groups[startHour] = [];
|
||||
}
|
||||
groups[startHour].push(event);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [events]);
|
||||
|
||||
const renderTimelineEvent = (event: TimelineEvent, index: number, groupSize: number) => {
|
||||
const { top, height } = getEventStyle(event);
|
||||
const categoryColor = getCategoryColor(event.category);
|
||||
|
||||
// 计算水平偏移和宽度,用于处理重叠事件
|
||||
const eventWidth = (screenWidth - TIME_LABEL_WIDTH - 40) / Math.max(groupSize, 1);
|
||||
const leftOffset = index * eventWidth;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={event.id}
|
||||
style={[
|
||||
styles.eventContainer,
|
||||
{
|
||||
top,
|
||||
height,
|
||||
left: TIME_LABEL_WIDTH + 20 + leftOffset,
|
||||
width: eventWidth - 4,
|
||||
backgroundColor: event.isCompleted
|
||||
? `${categoryColor}40`
|
||||
: `${categoryColor}80`,
|
||||
borderLeftColor: categoryColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => onEventPress?.(event)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.eventContent}>
|
||||
<Text
|
||||
style={[
|
||||
styles.eventTitle,
|
||||
{
|
||||
color: event.isCompleted ? colorTokens.textMuted : colorTokens.text,
|
||||
textDecorationLine: event.isCompleted ? 'line-through' : 'none'
|
||||
}
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
<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}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={categoryColor} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
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.timeline}>
|
||||
{/* 时间标签 */}
|
||||
<View style={styles.timeLabelsContainer}>
|
||||
{timeLabels.map((label, index) => (
|
||||
<View key={index} style={[styles.timeLabelRow, { height: HOUR_HEIGHT }]}>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.textMuted }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.timeLine,
|
||||
{ borderBottomColor: colorTokens.separator }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 事件容器 */}
|
||||
<View style={styles.eventsContainer}>
|
||||
{Object.entries(groupedEvents).map(([time, timeEvents]) =>
|
||||
timeEvents.map((event, index) =>
|
||||
renderTimelineEvent(event, index, timeEvents.length)
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 当前时间线 */}
|
||||
{dayjs(selectedDate).isSame(dayjs(), 'day') && (
|
||||
<CurrentTimeLine />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 空状态 */}
|
||||
{events.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={48}
|
||||
color={colorTokens.textMuted}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>
|
||||
今天暂无安排
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 当前时间指示线组件
|
||||
function CurrentTimeLine() {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
const now = dayjs();
|
||||
const currentMinutes = now.hour() * 60 + now.minute();
|
||||
const top = (currentMinutes / 60) * HOUR_HEIGHT;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.currentTimeLine,
|
||||
{
|
||||
top,
|
||||
backgroundColor: colorTokens.primary,
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={[styles.currentTimeDot, { backgroundColor: colorTokens.primary }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
dateHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
eventCount: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
timelineContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
timeline: {
|
||||
position: 'relative',
|
||||
minHeight: 24 * HOUR_HEIGHT,
|
||||
},
|
||||
timeLabelsContainer: {
|
||||
paddingLeft: 20,
|
||||
},
|
||||
timeLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
paddingTop: 8,
|
||||
},
|
||||
timeLabel: {
|
||||
width: TIME_LABEL_WIDTH,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
textAlign: 'right',
|
||||
paddingRight: 12,
|
||||
},
|
||||
timeLine: {
|
||||
flex: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginLeft: 8,
|
||||
marginRight: 20,
|
||||
},
|
||||
eventsContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 24 * HOUR_HEIGHT,
|
||||
},
|
||||
eventContainer: {
|
||||
position: 'absolute',
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
eventContent: {
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
lineHeight: 16,
|
||||
},
|
||||
eventTime: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
},
|
||||
completedIcon: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
},
|
||||
currentTimeLine: {
|
||||
position: 'absolute',
|
||||
left: TIME_LABEL_WIDTH + 20,
|
||||
right: 20,
|
||||
height: 2,
|
||||
zIndex: 10,
|
||||
},
|
||||
currentTimeDot: {
|
||||
position: 'absolute',
|
||||
left: -6,
|
||||
top: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user