feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"urlRandomness": "oVzuXCs"
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { StatusBar, Alert, AppState, AppStateStatus } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from './theme';
|
||||
import TodayScreen from './screens/TodayScreen';
|
||||
import CalendarScreen from './screens/CalendarScreen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { getEvents } from './api';
|
||||
import { computeUpcomingReminders, EventForReminder } from './utils/recurrence';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const notifiedKeysRef = useRef<Set<string>>(new Set());
|
||||
const scheduledNotificationIdsRef = useRef<Set<string>>(new Set());
|
||||
const heartbeatTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
requestNotificationPermissions();
|
||||
initializeNotifications();
|
||||
|
||||
// 监听应用状态变化
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestNotificationPermissions = async () => {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('提示', '需要通知权限才能发送提醒');
|
||||
}
|
||||
};
|
||||
|
||||
const initializeNotifications = async () => {
|
||||
await loadAndScheduleReminders();
|
||||
startReminderHeartbeat();
|
||||
};
|
||||
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') {
|
||||
// 应用从后台回到前台,重新检查提醒
|
||||
loadAndScheduleReminders();
|
||||
}
|
||||
appStateRef.current = nextAppState;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
}
|
||||
// 取消所有已安排的通知
|
||||
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
||||
try {
|
||||
await Notifications.cancelScheduledNotificationAsync(id);
|
||||
} catch (e) {
|
||||
console.error('取消通知失败:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadAndScheduleReminders = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const next24h = now + 24 * 3600 * 1000;
|
||||
|
||||
// 加载事件(覆盖当前月和前后各一个月)
|
||||
const currentDate = new Date();
|
||||
const events = await getEvents(currentDate);
|
||||
|
||||
// 取消之前的所有通知
|
||||
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
||||
try {
|
||||
await Notifications.cancelScheduledNotificationAsync(id);
|
||||
} catch (e) {
|
||||
console.error('取消通知失败:', e);
|
||||
}
|
||||
});
|
||||
scheduledNotificationIdsRef.current.clear();
|
||||
|
||||
// 补偿通知:检查过去 2 分钟内到期的提醒
|
||||
catchUpNotifications(events, now);
|
||||
|
||||
// 安排未来 24 小时的通知
|
||||
scheduleNotifications(events, now, next24h);
|
||||
} catch (e) {
|
||||
console.error('加载提醒失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const catchUpNotifications = (events: EventForReminder[], now: number) => {
|
||||
const since = now - WINDOW_MS;
|
||||
events.forEach((e) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach((remindIso) => {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (!notifiedKeysRef.current.has(key)) {
|
||||
triggerNotify(e, remindIso);
|
||||
notifiedKeysRef.current.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleNotifications = async (
|
||||
events: EventForReminder[],
|
||||
now: number,
|
||||
next24h: number
|
||||
) => {
|
||||
for (const e of events) {
|
||||
if (e.completed || e.deleted) continue;
|
||||
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||
for (const remindIso of upcoming) {
|
||||
const ts = new Date(remindIso).getTime();
|
||||
const delay = ts - now;
|
||||
if (delay > 0) {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (notifiedKeysRef.current.has(key)) continue;
|
||||
|
||||
try {
|
||||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '提醒',
|
||||
body: e.title,
|
||||
sound: true,
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||
seconds: delay / 1000,
|
||||
} as any,
|
||||
});
|
||||
scheduledNotificationIdsRef.current.add(notificationId);
|
||||
console.log('已安排通知:', e.title, '在', new Date(ts).toLocaleString());
|
||||
} catch (e) {
|
||||
console.error('安排通知失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerNotify = (e: EventForReminder, remindIso: string) => {
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '提醒',
|
||||
body: e.title,
|
||||
sound: true,
|
||||
},
|
||||
trigger: null, // 立即触发
|
||||
});
|
||||
};
|
||||
|
||||
const startReminderHeartbeat = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
}
|
||||
|
||||
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
|
||||
heartbeatTimerRef.current = setInterval(async () => {
|
||||
const now = Date.now();
|
||||
const since = now - WINDOW_MS;
|
||||
|
||||
try {
|
||||
const events = await getEvents(new Date());
|
||||
events.forEach((e: EventForReminder) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach((remindIso) => {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (!notifiedKeysRef.current.has(key)) {
|
||||
triggerNotify(e, remindIso);
|
||||
notifiedKeysRef.current.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('心跳检查失败:', e);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 24,
|
||||
height: 70,
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
headerTintColor: theme.colors.text,
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Today"
|
||||
component={TodayScreen}
|
||||
options={{
|
||||
title: '今日',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="today" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
title: '日历',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="calendar" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import axios from 'axios';
|
||||
import { Event, ApiResponse } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const formatError = (error: any): string => {
|
||||
if (error.response) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
return `服务器无响应: 无法连接到 ${API_BASE_URL}\n请检查服务器是否已启动以及网络连接`;
|
||||
} else if (error.message) {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
return `请求错误: ${error.message}`;
|
||||
} else {
|
||||
return `未知错误: ${JSON.stringify(error)}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getEvents = async (date?: Date): Promise<Event[]> => {
|
||||
const year = date ? date.getFullYear() : undefined;
|
||||
const month = date ? date.getMonth() + 1 : undefined;
|
||||
return eventApi.getEvents(year, month);
|
||||
};
|
||||
|
||||
export const eventApi = {
|
||||
async getEvents(year?: number, month?: number): Promise<Event[]> {
|
||||
try {
|
||||
// 拉取更多月份的数据(为了包含所有可能的循环事件)
|
||||
const requests: Promise<any>[] = [];
|
||||
|
||||
if (year && month) {
|
||||
// 拉取前 6 个月和当前月的数据
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
let m = month - i;
|
||||
let y = year;
|
||||
while (m < 1) {
|
||||
m += 12;
|
||||
y--;
|
||||
}
|
||||
requests.push(api.get<ApiResponse<Event[]>>('/events', { params: { year: y, month: m } }));
|
||||
}
|
||||
} else {
|
||||
// 如果没指定年月,拉取所有
|
||||
requests.push(api.get<ApiResponse<Event[]>>('/events'));
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// 合并并去重(按 id)
|
||||
const allEvents: Event[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const response of responses) {
|
||||
const events = response.data.data || [];
|
||||
for (const event of events) {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id);
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async createEvent(event: Omit<Event, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> {
|
||||
try {
|
||||
await api.post('/events', event);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async updateEvent(id: string, event: Partial<Event>): Promise<void> {
|
||||
try {
|
||||
await api.put('/events', { ...event, id }, { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteEvent(id: string): Promise<void> {
|
||||
try {
|
||||
await api.delete('/events', { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Calendar Reminder",
|
||||
"slug": "calendar-reminder",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0a14"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0a0a14"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"sounds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "6fd3e270-80f9-40d1-97f3-38fd1bd60afc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 398 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 541 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||
|
||||
// 复制 recurrence 相关函数
|
||||
function parseRecurrenceStr(str) {
|
||||
const r = (str || '').trim();
|
||||
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||
const mInt = /interval=(\d+)/.exec(r);
|
||||
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||
const mBy = /by=([0-9,]+)/.exec(r);
|
||||
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= 7) : [];
|
||||
return { type, interval, by };
|
||||
}
|
||||
|
||||
function extractDateStr(dateStr) {
|
||||
if (dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
if (dateStr.includes(' ')) {
|
||||
return dateStr.split(' ')[0];
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
function safeParseDate(dateStr) {
|
||||
try {
|
||||
let date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (dateStr.includes(' ')) {
|
||||
try {
|
||||
let date = new Date(dateStr.replace(' ', 'T'));
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (match) {
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10) - 1;
|
||||
const day = parseInt(match[3], 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function shouldShowEventOnDate(event, targetDate) {
|
||||
const eventDate = extractDateStr(event.start);
|
||||
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||
|
||||
if (eventDate === targetDateStr) return true;
|
||||
|
||||
const evStart = safeParseDate(event.start);
|
||||
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
|
||||
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const rec = parseRecurrenceStr(event.recurrence);
|
||||
if (rec.type === 'NONE') return false;
|
||||
|
||||
if (targetDayStart < eventStartDay) return false;
|
||||
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
if (targetDayStart > eventEndDay) return false;
|
||||
}
|
||||
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||
return diffDays % rec.interval === 0;
|
||||
}
|
||||
|
||||
if (rec.type === 'WEEKLY') {
|
||||
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(event.start).getDay() || 7)];
|
||||
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getEvents(year, month) {
|
||||
const response = await axios.get(`${API_BASE_URL}/events`, { params: { year, month } });
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async function loadTodayTabEvents(targetDate) {
|
||||
console.log('加载【今日】Tab 数据...');
|
||||
const allEvents = await getEvents(targetDate.getFullYear(), targetDate.getMonth() + 1);
|
||||
const todayEvents = allEvents.filter(event => {
|
||||
if (event.deleted) return false;
|
||||
return shouldShowEventOnDate(event, targetDate);
|
||||
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
|
||||
return todayEvents;
|
||||
}
|
||||
|
||||
async function loadCalendarTabEvents(targetDate) {
|
||||
console.log('加载【日历】Tab 数据...');
|
||||
const allEvents = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
for (let i = -6; i <= 1; i++) {
|
||||
let tempDate = new Date(targetDate);
|
||||
tempDate.setMonth(tempDate.getMonth() + i);
|
||||
const year = tempDate.getFullYear();
|
||||
const month = tempDate.getMonth() + 1;
|
||||
|
||||
const monthEvents = await getEvents(year, month);
|
||||
for (const event of monthEvents) {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id);
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validEvents = allEvents.filter(e => !e.deleted);
|
||||
const targetEvents = validEvents.filter(event => {
|
||||
return shouldShowEventOnDate(event, targetDate);
|
||||
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
|
||||
return targetEvents;
|
||||
}
|
||||
|
||||
async function compare() {
|
||||
console.log('========================================');
|
||||
console.log('对比 2026-06-29 两个 Tab 的事件');
|
||||
console.log('========================================\n');
|
||||
|
||||
const targetDate = new Date(2026, 5, 29);
|
||||
|
||||
const [todayEvents, calendarEvents] = await Promise.all([
|
||||
loadTodayTabEvents(targetDate),
|
||||
loadCalendarTabEvents(targetDate)
|
||||
]);
|
||||
|
||||
console.log('\n===== 【今日】Tab 2026-06-29 事件 =====');
|
||||
console.log('总数:', todayEvents.length);
|
||||
todayEvents.forEach((event, index) => {
|
||||
console.log(`${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
|
||||
console.log('\n===== 【日历】Tab 2026-06-29 事件 =====');
|
||||
console.log('总数:', calendarEvents.length);
|
||||
calendarEvents.forEach((event, index) => {
|
||||
console.log(`${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
|
||||
console.log('\n===== 差异对比 =====');
|
||||
const todayIds = new Set(todayEvents.map(e => e.id));
|
||||
const calendarIds = new Set(calendarEvents.map(e => e.id));
|
||||
|
||||
const onlyInToday = todayEvents.filter(e => !calendarIds.has(e.id));
|
||||
const onlyInCalendar = calendarEvents.filter(e => !todayIds.has(e.id));
|
||||
|
||||
if (onlyInToday.length === 0 && onlyInCalendar.length === 0) {
|
||||
console.log('✅ 两个 Tab 的事件完全一致!');
|
||||
} else {
|
||||
if (onlyInToday.length > 0) {
|
||||
console.log(`❌ 只在【今日】Tab 中的事件 (${onlyInToday.length} 个):`);
|
||||
onlyInToday.forEach((event, index) => {
|
||||
console.log(` ${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
}
|
||||
if (onlyInCalendar.length > 0) {
|
||||
console.log(`❌ 只在【日历】Tab 中的事件 (${onlyInCalendar.length} 个):`);
|
||||
onlyInCalendar.forEach((event, index) => {
|
||||
console.log(` ${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
}
|
||||
|
||||
compare().catch(console.error);
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../theme';
|
||||
import { Event } from '../types';
|
||||
import { safeParseDate } from '../utils/recurrence';
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
onEdit: (event: Event) => void;
|
||||
onToggleComplete: (event: Event) => void;
|
||||
onDelete: (event: Event) => void;
|
||||
}
|
||||
|
||||
export default function EventCard({
|
||||
event,
|
||||
onEdit,
|
||||
onToggleComplete,
|
||||
onDelete,
|
||||
}: EventCardProps) {
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = safeParseDate(dateString);
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, event.completed && styles.completedContainer]}
|
||||
onPress={() => onEdit(event)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.checkbox}
|
||||
onPress={() => onToggleComplete(event)}
|
||||
>
|
||||
{event.completed ? (
|
||||
<Ionicons name="checkmark-circle" size={28} color={theme.colors.primary} />
|
||||
) : (
|
||||
<View style={styles.checkboxEmpty} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
event.completed && styles.completedText,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.content ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.contentText,
|
||||
event.completed && styles.completedText,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{event.content}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.timeContainer}>
|
||||
<Ionicons
|
||||
name="time-outline"
|
||||
size={16}
|
||||
color={theme.colors.textSecondary}
|
||||
/>
|
||||
<Text style={styles.timeText}>{formatTime(event.start)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => onDelete(event)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing.md,
|
||||
marginBottom: theme.spacing.sm,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surfaceVariant,
|
||||
},
|
||||
completedContainer: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
checkbox: {
|
||||
marginRight: theme.spacing.sm,
|
||||
},
|
||||
checkboxEmpty: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.colors.textSecondary,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
contentText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
completedText: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
timeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
timeText: {
|
||||
...theme.typography.caption,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
deleteButton: {
|
||||
padding: theme.spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { theme } from '../theme';
|
||||
import { Event } from '../types';
|
||||
import { safeParseDate } from '../utils/recurrence';
|
||||
|
||||
interface EventModalProps {
|
||||
visible: boolean;
|
||||
event: Event | null;
|
||||
onClose: () => void;
|
||||
onSave: (event: Partial<Event>) => void;
|
||||
}
|
||||
|
||||
export default function EventModal({
|
||||
visible,
|
||||
event,
|
||||
onClose,
|
||||
onSave,
|
||||
}: EventModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [enableReminder, setEnableReminder] = useState(true);
|
||||
const [remindDate, setRemindDate] = useState(new Date());
|
||||
const [recurrenceType, setRecurrenceType] = useState<'NONE' | 'DAILY' | 'WEEKLY'>('NONE');
|
||||
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||
const [showRemindPicker, setShowRemindPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (event) {
|
||||
setTitle(event.title);
|
||||
setContent(event.content || '');
|
||||
setStartDate(safeParseDate(event.start));
|
||||
setEndDate(event.end ? safeParseDate(event.end) : null);
|
||||
setEnableReminder(!!event.remindAt);
|
||||
setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date());
|
||||
|
||||
if (event.recurrence?.startsWith('DAILY')) {
|
||||
setRecurrenceType('DAILY');
|
||||
} else if (event.recurrence?.startsWith('WEEKLY')) {
|
||||
setRecurrenceType('WEEKLY');
|
||||
} else {
|
||||
setRecurrenceType('NONE');
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}, [event, visible]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setStartDate(new Date());
|
||||
setEndDate(null);
|
||||
setEnableReminder(true);
|
||||
setRemindDate(new Date());
|
||||
setRecurrenceType('NONE');
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
alert('请输入标题');
|
||||
return;
|
||||
}
|
||||
|
||||
let recurrence = '';
|
||||
if (recurrenceType === 'DAILY') {
|
||||
recurrence = 'DAILY;interval=1';
|
||||
} else if (recurrenceType === 'WEEKLY') {
|
||||
recurrence = 'WEEKLY;interval=1';
|
||||
}
|
||||
|
||||
const eventData: Partial<Event> = {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
start: startDate.toISOString(),
|
||||
end: endDate ? endDate.toISOString() : null,
|
||||
allDay: false,
|
||||
remindAt: enableReminder ? remindDate.toISOString() : null,
|
||||
recurrence: recurrence || null,
|
||||
completed: event?.completed || false,
|
||||
};
|
||||
|
||||
onSave(eventData);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Ionicons name="close" size={28} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>
|
||||
{event ? '编辑事件' : '新建事件'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleSave}>
|
||||
<Text style={styles.saveButton}>保存</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>标题</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="输入事件标题"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>内容</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="输入事件内容(可选)"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>开始时间</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowStartPicker(true)}
|
||||
>
|
||||
<Text style={styles.dateButtonText}>{formatDate(startDate)}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>结束时间(可选)</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowEndPicker(true)}
|
||||
>
|
||||
<Text style={styles.dateButtonText}>
|
||||
{endDate ? formatDate(endDate) : '未设置'}
|
||||
</Text>
|
||||
{endDate && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setEndDate(null)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.switchField}>
|
||||
<Text style={styles.label}>启用提醒</Text>
|
||||
<Switch
|
||||
value={enableReminder}
|
||||
onValueChange={setEnableReminder}
|
||||
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
|
||||
thumbColor={theme.colors.text}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{enableReminder && (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>提醒时间</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowRemindPicker(true)}
|
||||
>
|
||||
<Text style={styles.dateButtonText}>{formatDate(remindDate)}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>重复</Text>
|
||||
<View style={styles.recurrenceOptions}>
|
||||
{[
|
||||
{ label: '不重复', value: 'NONE' },
|
||||
{ label: '每天', value: 'DAILY' },
|
||||
{ label: '每周', value: 'WEEKLY' },
|
||||
].map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.recurrenceButton,
|
||||
recurrenceType === option.value && styles.recurrenceButtonActive,
|
||||
]}
|
||||
onPress={() => setRecurrenceType(option.value as any)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.recurrenceButtonText,
|
||||
recurrenceType === option.value && styles.recurrenceButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{showStartPicker && (
|
||||
<DateTimePicker
|
||||
value={startDate}
|
||||
mode="datetime"
|
||||
onChange={(event, date) => {
|
||||
setShowStartPicker(false);
|
||||
if (date) setStartDate(date);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEndPicker && (
|
||||
<DateTimePicker
|
||||
value={endDate || new Date()}
|
||||
mode="datetime"
|
||||
onChange={(event, date) => {
|
||||
setShowEndPicker(false);
|
||||
if (date) setEndDate(date);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRemindPicker && (
|
||||
<DateTimePicker
|
||||
value={remindDate}
|
||||
mode="datetime"
|
||||
onChange={(event, date) => {
|
||||
setShowRemindPicker(false);
|
||||
if (date) setRemindDate(date);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
saveButton: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
padding: theme.spacing.md,
|
||||
},
|
||||
field: {
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
label: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing.md,
|
||||
color: theme.colors.text,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
dateButton: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing.md,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dateButtonText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
clearButton: {
|
||||
marginLeft: theme.spacing.sm,
|
||||
},
|
||||
switchField: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
recurrenceOptions: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
recurrenceButton: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
recurrenceButtonActive: {
|
||||
borderColor: theme.colors.primary,
|
||||
backgroundColor: theme.colors.surfaceVariant,
|
||||
},
|
||||
recurrenceButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
recurrenceButtonTextActive: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
+8812
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "app-calendar-reminder",
|
||||
"version": "1.0.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-worklets": "^0.10.0",
|
||||
"react-native-worklets-core": "^1.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "~0.73.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
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 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 {
|
||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('更新失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
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 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 {
|
||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('更新失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TextStyle } from 'react-native';
|
||||
|
||||
interface Typography {
|
||||
[key: string]: TextStyle;
|
||||
}
|
||||
|
||||
export const theme = {
|
||||
colors: {
|
||||
background: '#0a0a14',
|
||||
surface: '#161624',
|
||||
surfaceVariant: '#1e1e30',
|
||||
primary: '#e8b86d',
|
||||
primaryVariant: '#d4a45c',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#9999aa',
|
||||
border: '#2a2a40',
|
||||
success: '#4ade80',
|
||||
warning: '#fbbf24',
|
||||
error: '#ef4444',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24,
|
||||
},
|
||||
typography: {
|
||||
h1: { fontSize: 28, fontWeight: 'bold' } as TextStyle,
|
||||
h2: { fontSize: 24, fontWeight: 'bold' } as TextStyle,
|
||||
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||
body: { fontSize: 16, fontWeight: 'normal' } as TextStyle,
|
||||
bodySmall: { fontSize: 14, fontWeight: 'normal' } as TextStyle,
|
||||
caption: { fontSize: 12, fontWeight: 'normal' } as TextStyle,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
start: string;
|
||||
end: string | null;
|
||||
allDay: boolean;
|
||||
remindAt: string | null;
|
||||
recurrence: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completed: boolean;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
export interface Recurrence {
|
||||
type: 'NONE' | 'DAILY' | 'WEEKLY';
|
||||
interval: number;
|
||||
by: number[];
|
||||
}
|
||||
|
||||
export interface EventForReminder {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end?: string | null;
|
||||
remindAt?: string | null;
|
||||
recurrence?: string | null;
|
||||
completed?: boolean;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export function parseRecurrenceStr(str: string | null): Recurrence {
|
||||
const r = (str || '').trim();
|
||||
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||
const mInt = /interval=(\d+)/.exec(r);
|
||||
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||
const mBy = /by=([0-9,]+)/.exec(r);
|
||||
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= 7) : [];
|
||||
return { type, interval, by };
|
||||
}
|
||||
|
||||
export function setTimeFromIso(dateObj: Date, timeSourceIso: string): string {
|
||||
const d = new Date(dateObj.getTime());
|
||||
const t = timeSourceIso ? safeParseDate(timeSourceIso) : null;
|
||||
const hh = t ? t.getHours() : 0;
|
||||
const mm = t ? t.getMinutes() : 0;
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function extractDateStr(dateStr: string): string {
|
||||
// 支持两种格式:2026-06-28T21:30:00 和 2026-06-28 21:30
|
||||
if (dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
if (dateStr.includes(' ')) {
|
||||
return dateStr.split(' ')[0];
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function safeParseDate(dateStr: string): Date {
|
||||
// 尝试安全地解析日期,支持多种格式
|
||||
try {
|
||||
// 先尝试直接解析
|
||||
let date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// 如果是 2026-06-28 21:30 格式,尝试替换空格为 T
|
||||
if (dateStr.includes(' ')) {
|
||||
try {
|
||||
let date = new Date(dateStr.replace(' ', 'T'));
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 最后尝试用 Date.UTC 解析
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (match) {
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10) - 1;
|
||||
const day = parseInt(match[3], 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
return new Date();
|
||||
}
|
||||
|
||||
export function shouldShowEventOnDate(
|
||||
event: { start: string; recurrence: string | null; end?: string | null },
|
||||
targetDate: Date
|
||||
): boolean {
|
||||
const eventDate = extractDateStr(event.start);
|
||||
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||
|
||||
// 1. 普通事件 - 开始日期就是当天
|
||||
if (eventDate === targetDateStr) return true;
|
||||
|
||||
const evStart = safeParseDate(event.start);
|
||||
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||
|
||||
// 2. 持续多天的事件 - 目标日期在 [start, end] 之间
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
|
||||
// 如果目标日期在事件日期范围内,就显示
|
||||
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 循环事件
|
||||
const rec = parseRecurrenceStr(event.recurrence);
|
||||
if (rec.type === 'NONE') return false;
|
||||
|
||||
// 如果目标日期在开始日期之前,不显示
|
||||
if (targetDayStart < eventStartDay) return false;
|
||||
|
||||
// 如果有结束日期,并且目标日期在结束日期之后,不显示
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
if (targetDayStart > eventEndDay) return false;
|
||||
}
|
||||
// 没有结束日期的话,就永远显示
|
||||
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||
return diffDays % rec.interval === 0;
|
||||
}
|
||||
|
||||
if (rec.type === 'WEEKLY') {
|
||||
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(event.start).getDay() || 7)];
|
||||
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeUpcomingReminders(
|
||||
e: EventForReminder,
|
||||
sinceMs: number,
|
||||
untilMs: number
|
||||
): string[] {
|
||||
const rec = parseRecurrenceStr(e.recurrence || null);
|
||||
const evStart = safeParseDate(e.start);
|
||||
const reminders: string[] = [];
|
||||
|
||||
if (rec.type === 'NONE') {
|
||||
const iso = e.remindAt || e.start;
|
||||
if (!iso) return reminders;
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
return reminders;
|
||||
}
|
||||
|
||||
const startDayMs = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate()).getTime();
|
||||
const windowStartDayMs = new Date(
|
||||
new Date(sinceMs).getFullYear(),
|
||||
new Date(sinceMs).getMonth(),
|
||||
new Date(sinceMs).getDate()
|
||||
).getTime();
|
||||
const windowEndDayMs = new Date(
|
||||
new Date(untilMs).getFullYear(),
|
||||
new Date(untilMs).getMonth(),
|
||||
new Date(untilMs).getDate()
|
||||
).getTime();
|
||||
|
||||
let endDayMs: number;
|
||||
if (e.end) {
|
||||
const evEnd = safeParseDate(e.end);
|
||||
endDayMs = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate()).getTime();
|
||||
} else {
|
||||
// 无结束日期时,使用 untilMs 作为结束边界,避免提前终止
|
||||
endDayMs = windowEndDayMs;
|
||||
}
|
||||
|
||||
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||
const timeSourceIso = e.remindAt || e.start;
|
||||
|
||||
for (let d = fromDay; d <= toDay; d += 24 * 3600 * 1000) {
|
||||
const cursor = new Date(d);
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((d - startDayMs) / (24 * 3600 * 1000));
|
||||
if (diffDays % rec.interval === 0) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
} else if (rec.type === 'WEEKLY') {
|
||||
const weekday = cursor.getDay() === 0 ? 7 : cursor.getDay();
|
||||
const weeksSinceStart = Math.floor((d - startDayMs) / (7 * 24 * 3600 * 1000));
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(e.start).getDay() || 7)];
|
||||
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reminders;
|
||||
}
|
||||
Reference in New Issue
Block a user