Files
Toolbox/apps/app_calendar_reminder/screens/CalendarScreen.tsx
T
yangxiangyuan 154b79c8f1 feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持
2. 新增移动端日历提醒工具与鉴权配置
3. 优化日历事件编辑与展示:
   - 添加事件循环重复设置
   - 新增全天事件支持
   - 时间选择器自动对齐15分钟间隔
   - 保存状态加载动画
   - 事件卡片显示重复规则
4. 更新API基础地址与路由配置
5. 新增SSL证书文件与相关工具静态资源
2026-07-28 16:23:37 +08:00

406 lines
11 KiB
TypeScript

import React, { useState, useCallback, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
FlatList,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { theme } from '../theme';
import { Event } from '../types';
import { eventApi } from '../api';
import EventModal from '../components/EventModal';
import EventCard from '../components/EventCard';
import { shouldShowEventOnDate, safeParseDate } from '../utils/recurrence';
const showError = (title: string, error: any) => {
const message = error instanceof Error ? error.message : '加载事件失败';
Alert.alert(title, message);
};
export default function CalendarScreen() {
const navigation = useNavigation();
const [currentDate, setCurrentDate] = useState(new Date());
const [events, setEvents] = useState<Event[]>([]);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [saving, setSaving] = useState(false);
const loadEvents = useCallback(async () => {
try {
const allEvents: Event[] = [];
const seenIds = new Set<string>();
// 加载前 6 个月 + 当前月 + 后 1 个月的数据,确保覆盖所有持续事件
for (let i = -6; i <= 1; i++) {
let targetDate = new Date(currentDate);
targetDate.setMonth(targetDate.getMonth() + i);
const year = targetDate.getFullYear();
const month = targetDate.getMonth() + 1;
const monthEvents = await eventApi.getEvents(year, month);
for (const event of monthEvents) {
if (!seenIds.has(event.id)) {
seenIds.add(event.id);
allEvents.push(event);
}
}
}
setEvents(allEvents.filter(e => !e.deleted));
} catch (error) {
showError('加载失败', error);
}
}, [currentDate]);
useFocusEffect(
useCallback(() => {
loadEvents();
}, [loadEvents])
);
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const days = [];
// 添加上个月的日期
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = firstDay.getDay() - 1; i >= 0; i--) {
const prevMonthDay = new Date(year, month - 1, prevMonthLastDay - i);
days.push({ date: prevMonthDay, isCurrentMonth: false });
}
// 添加当前月的日期
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
}
// 添加下个月的日期
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
}
return days;
};
const hasEventOnDate = (date: Date) => {
return events.some(event => {
if (event.deleted) return false;
return shouldShowEventOnDate(event, date);
});
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isSelected = (date: Date) => {
if (!selectedDate) return false;
return date.toDateString() === selectedDate.toDateString();
};
const getEventsForSelectedDate = () => {
if (!selectedDate) return [];
return events
.filter(event => {
if (event.deleted) return false;
return shouldShowEventOnDate(event, selectedDate);
})
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
};
const previousMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
setSelectedDate(null);
};
const nextMonth = () => {
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
setSelectedDate(null);
};
const handleAddEvent = () => {
setSelectedEvent(null);
setModalVisible(true);
};
const handleEditEvent = (event: Event) => {
setSelectedEvent(event);
setModalVisible(true);
};
const handleToggleComplete = async (event: Event) => {
try {
setSaving(true);
await eventApi.updateEvent(event.id, { completed: !event.completed });
await loadEvents();
} catch (error) {
showError('更新失败', error);
} finally {
setSaving(false);
}
};
const handleDeleteEvent = async (event: Event) => {
Alert.alert(
'确认删除',
'确定要删除这个事件吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
await eventApi.deleteEvent(event.id);
await loadEvents();
} catch (error) {
showError('删除失败', error);
}
},
},
]
);
};
const handleSaveEvent = async (eventData: Partial<Event>) => {
try {
setSaving(true);
if (selectedEvent) {
await eventApi.updateEvent(selectedEvent.id, eventData);
} else {
await eventApi.createEvent({
...eventData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deleted: false,
} as Event);
}
setModalVisible(false);
await loadEvents();
} catch (error) {
showError('保存失败', error);
} finally {
setSaving(false);
}
};
const days = getDaysInMonth(currentDate);
const selectedDateEvents = getEventsForSelectedDate();
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={previousMonth}>
<Ionicons name="chevron-back" size={28} color={theme.colors.text} />
</TouchableOpacity>
<Text style={styles.monthTitle}>
{currentDate.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
})}
</Text>
<TouchableOpacity onPress={nextMonth}>
<Ionicons name="chevron-forward" size={28} color={theme.colors.text} />
</TouchableOpacity>
</View>
<View style={styles.weekdays}>
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
<Text key={index} style={styles.weekday}>
{day}
</Text>
))}
</View>
<View style={styles.calendarGrid}>
{days.map(({ date, isCurrentMonth }, index) => (
<TouchableOpacity
key={index}
style={[
styles.dayCell,
isSelected(date) && styles.selectedDay,
isToday(date) && styles.today,
]}
onPress={() => setSelectedDate(date)}
>
<Text
style={[
styles.dayText,
!isCurrentMonth && styles.otherMonthText,
isSelected(date) && styles.selectedDayText,
isToday(date) && styles.todayText,
]}
>
{date.getDate()}
</Text>
{hasEventOnDate(date) && <View style={styles.eventDot} />}
</TouchableOpacity>
))}
</View>
{selectedDate && (
<>
<View style={styles.eventsHeader}>
<Text style={styles.eventsHeaderTitle}>
{selectedDate.toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric',
weekday: 'long',
})}
</Text>
<TouchableOpacity onPress={handleAddEvent}>
<Ionicons name="add-circle" size={28} color={theme.colors.primary} />
</TouchableOpacity>
</View>
<FlatList
data={selectedDateEvents}
keyExtractor={(item) => item.id}
style={styles.eventsListContainer}
renderItem={({ item }) => (
<EventCard
event={item}
onEdit={handleEditEvent}
onToggleComplete={handleToggleComplete}
onDelete={handleDeleteEvent}
/>
)}
contentContainerStyle={styles.eventsList}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>这一天没有事件</Text>
</View>
}
/>
</>
)}
{!selectedDate && (
<View style={styles.noDateSelected}>
<Text style={styles.noDateText}>点击日期查看事项</Text>
</View>
)}
<EventModal
visible={modalVisible}
event={selectedEvent}
onClose={() => setModalVisible(false)}
onSave={handleSaveEvent}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
padding: theme.spacing.md,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing.lg,
},
monthTitle: {
...theme.typography.h2,
color: theme.colors.text,
},
weekdays: {
flexDirection: 'row',
marginBottom: theme.spacing.sm,
},
weekday: {
flex: 1,
textAlign: 'center',
...theme.typography.caption,
color: theme.colors.textSecondary,
},
calendarGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: theme.spacing.sm,
},
dayCell: {
width: '14.28%',
aspectRatio: 1,
justifyContent: 'center',
alignItems: 'center',
borderRadius: theme.borderRadius.md,
},
selectedDay: {
backgroundColor: theme.colors.surface,
borderWidth: 2,
borderColor: theme.colors.primary,
},
today: {
backgroundColor: theme.colors.surface,
},
dayText: {
...theme.typography.body,
color: theme.colors.text,
},
otherMonthText: {
color: theme.colors.textSecondary,
},
selectedDayText: {
color: theme.colors.primary,
fontWeight: 'bold',
},
todayText: {
color: theme.colors.primary,
},
eventDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.primary,
marginTop: 4,
},
eventsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing.sm,
},
eventsHeaderTitle: {
...theme.typography.h3,
color: theme.colors.text,
},
eventsListContainer: {
flex: 1,
},
eventsList: {
paddingBottom: 20,
},
emptyContainer: {
padding: theme.spacing.xl,
alignItems: 'center',
},
emptyText: {
...theme.typography.body,
color: theme.colors.textSecondary,
},
noDateSelected: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
noDateText: {
...theme.typography.body,
color: theme.colors.textSecondary,
},
});