154b79c8f1
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
275 lines
7.4 KiB
TypeScript
275 lines
7.4 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
Alert,
|
|
ActivityIndicator,
|
|
} 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 TodayScreen() {
|
|
const navigation = useNavigation();
|
|
const [events, setEvents] = useState<Event[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const loadEvents = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const today = new Date();
|
|
const allEvents = await eventApi.getEvents(today.getFullYear(), today.getMonth() + 1);
|
|
const todayEvents = allEvents.filter(event => {
|
|
if (event.deleted) return false;
|
|
return shouldShowEventOnDate(event, today);
|
|
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
|
setEvents(todayEvents);
|
|
} catch (error) {
|
|
showError('加载失败', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
loadEvents();
|
|
}, [loadEvents])
|
|
);
|
|
|
|
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 getGreeting = () => {
|
|
const hour = new Date().getHours();
|
|
if (hour < 12) return '早上好';
|
|
if (hour < 18) return '下午好';
|
|
return '晚上好';
|
|
};
|
|
|
|
const pendingEvents = events.filter(e => !e.completed);
|
|
const completedEvents = events.filter(e => e.completed);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.header}>
|
|
<View style={styles.headerLeft}>
|
|
<Text style={styles.greeting}>{getGreeting()} 👋</Text>
|
|
<Text style={styles.date}>
|
|
{new Date().toLocaleDateString('zh-CN', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity style={styles.addButton} onPress={handleAddEvent}>
|
|
<Ionicons name="add" size={28} color={theme.colors.background} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.statsContainer}>
|
|
<View style={styles.statCard}>
|
|
<Text style={styles.statNumber}>{pendingEvents.length}</Text>
|
|
<Text style={styles.statLabel}>待完成</Text>
|
|
</View>
|
|
<View style={styles.statCard}>
|
|
<Text style={styles.statNumber}>{completedEvents.length}</Text>
|
|
<Text style={styles.statLabel}>已完成</Text>
|
|
</View>
|
|
<View style={styles.statCard}>
|
|
<Text style={styles.statNumber}>{events.length}</Text>
|
|
<Text style={styles.statLabel}>共计</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={styles.sectionTitle}>待完成</Text>
|
|
|
|
{loading ? (
|
|
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
|
) : (
|
|
<FlatList
|
|
data={pendingEvents}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<EventCard
|
|
event={item}
|
|
onEdit={handleEditEvent}
|
|
onToggleComplete={handleToggleComplete}
|
|
onDelete={handleDeleteEvent}
|
|
/>
|
|
)}
|
|
contentContainerStyle={styles.list}
|
|
showsVerticalScrollIndicator={false}
|
|
ListEmptyComponent={
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyText}>今天没有待办事项 🎉</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: 'flex-start',
|
|
marginBottom: theme.spacing.lg,
|
|
},
|
|
headerLeft: {
|
|
flex: 1,
|
|
},
|
|
greeting: {
|
|
...theme.typography.h1,
|
|
color: theme.colors.text,
|
|
marginBottom: theme.spacing.xs,
|
|
},
|
|
date: {
|
|
...theme.typography.bodySmall,
|
|
color: theme.colors.textSecondary,
|
|
},
|
|
addButton: {
|
|
backgroundColor: theme.colors.primary,
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
statsContainer: {
|
|
flexDirection: 'row',
|
|
gap: theme.spacing.sm,
|
|
marginBottom: theme.spacing.lg,
|
|
},
|
|
statCard: {
|
|
flex: 1,
|
|
backgroundColor: theme.colors.surface,
|
|
padding: theme.spacing.md,
|
|
borderRadius: theme.borderRadius.lg,
|
|
alignItems: 'center',
|
|
},
|
|
statNumber: {
|
|
...theme.typography.h2,
|
|
color: theme.colors.primary,
|
|
},
|
|
statLabel: {
|
|
...theme.typography.bodySmall,
|
|
color: theme.colors.textSecondary,
|
|
},
|
|
sectionTitle: {
|
|
...theme.typography.h3,
|
|
color: theme.colors.text,
|
|
marginBottom: theme.spacing.sm,
|
|
},
|
|
list: {
|
|
paddingBottom: 20,
|
|
},
|
|
loader: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
},
|
|
emptyContainer: {
|
|
padding: theme.spacing.xl,
|
|
alignItems: 'center',
|
|
},
|
|
emptyText: {
|
|
...theme.typography.body,
|
|
color: theme.colors.textSecondary,
|
|
textAlign: 'center',
|
|
},
|
|
});
|