/* ============================================ 移动端日历提醒 - 核心逻辑 对标 RN App (apps/app_calendar_reminder) 纯 JS + IIFE 模块化,所有时间按北京时间处理 ============================================ */ (function () { 'use strict'; /* ------------------------------------------ 常量与全局状态 ------------------------------------------ */ const API_BASE = '/api/app_calendar/events'; // 同域部署,相对路径 const WINDOW_MS = 2 * 60 * 1000; // 2 分钟补偿窗口 const REQUEST_TIMEOUT = 10000; // 请求超时 10s const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; const state = { events: [], // 已加载事件(去重、过滤 deleted 后) currentTab: 'today', // 当前 Tab:today / calendar calYear: 0, // 日历当前年 calMonth: 0, // 日历当前月(0-based) selectedDate: null, // 日历选中日期 editingEvent: null, // 正在编辑的事件(null 表示新建) deletingEvent: null, // 待删除事件 notifiedKeys: new Set(), // 已提醒防重复 scheduledTimers: [], // 预调度 setTimeout 句柄 heartbeatTimer: null, // 心跳巡检句柄 }; // 编辑表单的循环规则状态 const form = { recurrenceType: 'NONE', // NONE / DAILY / WEEKLY interval: 1, weekdays: [], // 1~7(1=周一,7=周日) }; // 简易选择器 function $(sel) { return document.querySelector(sel); } /* ------------------------------------------ 工具函数:日期解析与格式化 ------------------------------------------ */ // 安全日期解析(兼容 ISO、yyyy-MM-dd HH:mm、yyyy-MM-ddTHH:mm) function safeParseDate(dateStr) { if (!dateStr) return new Date(); try { const d = new Date(dateStr); if (!isNaN(d.getTime())) return d; } catch (e) { /* 忽略 */ } // yyyy-MM-dd HH:mm → 替换空格为 T if (String(dateStr).includes(' ')) { try { const d = new Date(String(dateStr).replace(' ', 'T')); if (!isNaN(d.getTime())) return d; } catch (e) { /* 忽略 */ } } // 兜底:仅解析日期部分 const match = String(dateStr).match(/(\d{4})-(\d{2})-(\d{2})/); if (match) { return new Date(parseInt(match[1], 10), parseInt(match[2], 10) - 1, parseInt(match[3], 10)); } return new Date(); } // 本地日期字符串 yyyy-MM-dd(北京时间) function formatDateStr(date) { const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } // Date → datetime-local 输入值(yyyy-MM-ddTHH:mm,本地时间) function toDatetimeLocalValue(date) { const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } // 15 分钟粒度对齐 function snapTo15(date) { const d = new Date(date); d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0); return d; } // 是否同一天 function isSameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } // 事件时间展示(HH:mm) function formatTime(dateStr) { return safeParseDate(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } /* ------------------------------------------ 循环规则解析与判断(与 RN recurrence.ts 一致) ------------------------------------------ */ // 解析循环规则字符串:DAILY;interval=N / WEEKLY;interval=N;by=1,3,5 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 }; } // 循环规则中文描述(如「每2周 周一、周三」) function getRecurrenceLabel(recurrence) { if (!recurrence) return null; const rec = parseRecurrenceStr(recurrence); if (rec.type === 'DAILY') { return rec.interval === 1 ? '每天' : `每${rec.interval}天`; } if (rec.type === 'WEEKLY') { const prefix = rec.interval === 1 ? '每周' : `每${rec.interval}周`; if (rec.by.length > 0) { return `${prefix} ${rec.by.map((d) => WEEKDAY_NAMES[d - 1] || '').join('、')}`; } return prefix; } return null; } // 判断事件在目标日期是否展示(与 RN shouldShowEventOnDate 逻辑一致) function shouldShowEventOnDate(event, targetDate) { const evStart = safeParseDate(event.start); const eventDateStr = formatDateStr(evStart); const targetDateStr = formatDateStr(targetDate); // 1. 普通事件:开始日期就是当天 if (eventDateStr === targetDateStr) return true; 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 : [(evStart.getDay() || 7)]; return by.includes(weekday) && (weeksSinceStart % rec.interval === 0); } return false; } // 将 timeSourceIso 的时分套用到 dateObj 上,返回 ISO 字符串 function setTimeFromIso(dateObj, timeSourceIso) { const d = new Date(dateObj.getTime()); const t = timeSourceIso ? safeParseDate(timeSourceIso) : null; d.setHours(t ? t.getHours() : 0, t ? t.getMinutes() : 0, 0, 0); return d.toISOString(); } // 计算事件在 [sinceMs, untilMs] 时间窗内的提醒时刻(与 RN computeUpcomingReminders 一致) function computeUpcomingReminders(e, sinceMs, untilMs) { const rec = parseRecurrenceStr(e.recurrence || null); const evStart = safeParseDate(e.start); const reminders = []; // 非循环事件:直接判断 remindAt 或 start 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 ws = new Date(sinceMs); const we = new Date(untilMs); const windowStartDayMs = new Date(ws.getFullYear(), ws.getMonth(), ws.getDate()).getTime(); const windowEndDayMs = new Date(we.getFullYear(), we.getMonth(), we.getDate()).getTime(); let endDayMs; if (e.end) { const evEnd = safeParseDate(e.end); endDayMs = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate()).getTime(); } else { endDayMs = windowEndDayMs; // 无结束日期时用窗口边界,避免提前终止 } const fromDay = Math.max(startDayMs, windowStartDayMs); const toDay = Math.min(endDayMs, windowEndDayMs); const timeSourceIso = e.remindAt || e.start; // 安全截断:最多遍历 400 天,防死循环 let guard = 0; for (let d = fromDay; d <= toDay && guard < 400; d += 24 * 3600 * 1000, guard++) { 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; } /* ------------------------------------------ API 层 ------------------------------------------ */ // 统一请求封装(含超时与错误处理) async function apiRequest(url, options) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); try { const resp = await fetch(url, Object.assign({ signal: controller.signal, headers: { 'Content-Type': 'application/json' }, }, options)); if (!resp.ok) throw new Error(`服务器响应 ${resp.status}`); const json = await resp.json(); if (json && json.ok === false) throw new Error(json.error || '请求失败'); return json; } catch (e) { if (e.name === 'AbortError') throw new Error('请求超时,请稍后重试'); throw e; } finally { clearTimeout(timer); } } // 按月查询 async function apiGetEventsMonth(year, month) { const json = await apiRequest(`${API_BASE}?year=${year}&month=${month}`); return (json && json.data) || []; } // 加载前 6 月 + 当月 + 后 1 月数据,按 id 去重,过滤已删除 async function loadAllEvents() { const now = new Date(); const all = []; const seen = new Set(); for (let i = -6; i <= 1; i++) { const d = new Date(now.getFullYear(), now.getMonth() + i, 1); const year = d.getFullYear(); const month = d.getMonth() + 1; try { const monthEvents = await apiGetEventsMonth(year, month); for (const ev of monthEvents) { if (!seen.has(ev.id)) { seen.add(ev.id); all.push(ev); } } } catch (e) { // 单月失败不阻断整体加载 console.error('加载月份数据失败', year, month, e); } } state.events = all.filter((e) => !e.deleted); } async function apiCreateEvent(data) { await apiRequest(API_BASE, { method: 'POST', body: JSON.stringify(data) }); } async function apiUpdateEvent(id, data) { await apiRequest(`${API_BASE}?id=${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(Object.assign({}, data, { id })), }); } async function apiDeleteEvent(id) { await apiRequest(`${API_BASE}?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); } /* ------------------------------------------ Toast 提示 ------------------------------------------ */ let toastTimer = null; function showToast(msg, type) { const toast = $('#toast'); toast.textContent = msg; toast.className = 'toast ' + (type || 'info'); void toast.offsetWidth; // 强制 reflow 重启动画 toast.classList.add('visible'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000); } /* ------------------------------------------ 事件卡片 ------------------------------------------ */ function createEventCard(ev) { const card = document.createElement('div'); card.className = 'event-card' + (ev.completed ? ' completed' : ''); // 圆形 checkbox const checkbox = document.createElement('div'); checkbox.className = 'event-checkbox' + (ev.completed ? ' checked' : ''); checkbox.addEventListener('click', (e) => { e.stopPropagation(); toggleComplete(ev); }); // 主体(点击编辑) const body = document.createElement('div'); body.className = 'event-body'; const title = document.createElement('div'); title.className = 'event-title'; title.textContent = ev.title; body.appendChild(title); if (ev.content) { const content = document.createElement('div'); content.className = 'event-content'; content.textContent = ev.content; body.appendChild(content); } const meta = document.createElement('div'); meta.className = 'event-meta'; const timeSpan = document.createElement('span'); timeSpan.textContent = '🕐 ' + formatTime(ev.start); meta.appendChild(timeSpan); const recLabel = getRecurrenceLabel(ev.recurrence); if (recLabel) { const recSpan = document.createElement('span'); recSpan.className = 'recurrence-label'; recSpan.textContent = '🔁 ' + recLabel; meta.appendChild(recSpan); } body.appendChild(meta); body.addEventListener('click', () => openModal(ev)); // 删除按钮 const del = document.createElement('div'); del.className = 'event-delete'; del.textContent = '🗑'; del.addEventListener('click', (e) => { e.stopPropagation(); openDeleteSheet(ev); }); card.appendChild(checkbox); card.appendChild(body); card.appendChild(del); return card; } /* ------------------------------------------ 今日视图 ------------------------------------------ */ function renderTodayView() { const today = new Date(); const hour = today.getHours(); const greeting = hour < 12 ? '早上好' : (hour < 18 ? '下午好' : '晚上好'); $('#greeting').textContent = greeting + ' 👋'; $('#today-date').textContent = today.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', }); const todayEvents = state.events .filter((ev) => shouldShowEventOnDate(ev, today)) .sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime()); const pending = todayEvents.filter((e) => !e.completed); const completed = todayEvents.filter((e) => e.completed); $('#stat-pending').textContent = pending.length; $('#stat-completed').textContent = completed.length; $('#stat-total').textContent = todayEvents.length; const list = $('#today-list'); list.innerHTML = ''; pending.forEach((ev) => list.appendChild(createEventCard(ev))); $('#today-empty').classList.toggle('hidden', pending.length > 0); } /* ------------------------------------------ 日历视图 ------------------------------------------ */ // 生成 6x7=42 格月历数据 function getDaysInMonth(year, month) { 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--) { days.push({ date: new Date(year, month - 1, prevMonthLastDay - i), isCurrentMonth: false }); } for (let i = 1; i <= lastDay.getDate(); i++) { days.push({ date: new Date(year, month, i), isCurrentMonth: true }); } const remaining = 42 - days.length; for (let i = 1; i <= remaining; i++) { days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false }); } return days; } function hasEventOnDate(date) { return state.events.some((ev) => shouldShowEventOnDate(ev, date)); } function renderCalendar() { $('#cal-month-title').textContent = `${state.calYear}年${state.calMonth + 1}月`; const grid = $('#calendar-grid'); grid.innerHTML = ''; const days = getDaysInMonth(state.calYear, state.calMonth); const today = new Date(); days.forEach(({ date, isCurrentMonth }) => { const cell = document.createElement('div'); cell.className = 'day-cell'; if (!isCurrentMonth) cell.classList.add('other-month'); if (isSameDay(date, today)) cell.classList.add('today'); if (state.selectedDate && isSameDay(date, state.selectedDate)) cell.classList.add('selected'); const num = document.createElement('div'); num.className = 'day-number'; num.textContent = date.getDate(); cell.appendChild(num); if (hasEventOnDate(date)) { const dot = document.createElement('div'); dot.className = 'event-dot'; cell.appendChild(dot); } cell.addEventListener('click', () => { state.selectedDate = date; renderCalendar(); renderCalendarEvents(); }); grid.appendChild(cell); }); if (state.selectedDate) { $('#cal-no-date').classList.add('hidden'); $('#cal-events-section').classList.remove('hidden'); } else { $('#cal-no-date').classList.remove('hidden'); $('#cal-events-section').classList.add('hidden'); } } function renderCalendarEvents() { if (!state.selectedDate) return; const d = state.selectedDate; $('#cal-selected-title').textContent = d.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long', }); const events = state.events .filter((ev) => shouldShowEventOnDate(ev, d)) .sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime()); const list = $('#cal-events-list'); list.innerHTML = ''; events.forEach((ev) => list.appendChild(createEventCard(ev))); $('#cal-events-empty').classList.toggle('hidden', events.length > 0); } function renderCurrentView() { if (state.currentTab === 'today') { renderTodayView(); } else { renderCalendar(); renderCalendarEvents(); } } /* ------------------------------------------ 完成状态切换 ------------------------------------------ */ async function toggleComplete(ev) { try { await apiUpdateEvent(ev.id, { completed: !ev.completed }); await loadAllEvents(); renderCurrentView(); scheduleReminders(); } catch (e) { showToast(e.message || '更新失败', 'error'); } } /* ------------------------------------------ 事件编辑弹窗 ------------------------------------------ */ function updateRecurrenceUI() { document.querySelectorAll('.recurrence-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.type === form.recurrenceType); }); const isNone = form.recurrenceType === 'NONE'; $('#field-interval').classList.toggle('hidden', isNone); $('#field-weekdays').classList.toggle('hidden', form.recurrenceType !== 'WEEKLY'); $('#interval-value').textContent = form.interval; $('#interval-label').textContent = `每 ${form.interval} ${form.recurrenceType === 'DAILY' ? '天' : '周'}`; document.querySelectorAll('.weekday-btn').forEach((btn) => { btn.classList.toggle('active', form.weekdays.includes(parseInt(btn.dataset.day, 10))); }); } function toggleRemindField() { $('#field-remind').classList.toggle('hidden', !$('#f-remind-enable').checked); } // 回填 / 重置表单 function fillForm(ev) { if (ev) { $('#f-title').value = ev.title || ''; $('#f-content').value = ev.content || ''; $('#f-allday').checked = !!ev.allDay; $('#f-start').value = toDatetimeLocalValue(safeParseDate(ev.start)); if (ev.end) { $('#f-end').value = toDatetimeLocalValue(safeParseDate(ev.end)); $('#f-end-clear').classList.remove('hidden'); } else { $('#f-end').value = ''; $('#f-end-clear').classList.add('hidden'); } const hasRemind = !!ev.remindAt; $('#f-remind-enable').checked = hasRemind; $('#f-remind').value = toDatetimeLocalValue(hasRemind ? safeParseDate(ev.remindAt) : snapTo15(new Date())); const rec = parseRecurrenceStr(ev.recurrence); form.recurrenceType = rec.type === 'NONE' ? 'NONE' : rec.type; form.interval = rec.interval || 1; form.weekdays = rec.by.slice(); } else { $('#f-title').value = ''; $('#f-content').value = ''; $('#f-allday').checked = false; $('#f-start').value = toDatetimeLocalValue(snapTo15(new Date())); $('#f-end').value = ''; $('#f-end-clear').classList.add('hidden'); $('#f-remind-enable').checked = true; $('#f-remind').value = toDatetimeLocalValue(snapTo15(new Date())); form.recurrenceType = 'NONE'; form.interval = 1; form.weekdays = []; } toggleRemindField(); updateRecurrenceUI(); } function openModal(ev) { state.editingEvent = ev || null; $('#modal-title').textContent = ev ? '编辑事件' : '新建事件'; fillForm(ev); $('#modal-overlay').classList.add('visible'); } function closeModal() { $('#modal-overlay').classList.remove('visible'); state.editingEvent = null; } // 收集表单并保存 async function handleSave() { const title = $('#f-title').value.trim(); if (!title) { showToast('请输入标题', 'error'); return; } const allDay = $('#f-allday').checked; const startVal = $('#f-start').value; if (!startVal) { showToast('请设置开始时间', 'error'); return; } const startDate = snapTo15(new Date(startVal)); // 结束时间自动补全:未设置时 +1h(全天则 +24h) let endDate; const endVal = $('#f-end').value; if (endVal) { endDate = snapTo15(new Date(endVal)); } else { endDate = new Date(startDate.getTime() + (allDay ? 24 : 1) * 3600 * 1000); } // 提醒 let remindAt = null; if ($('#f-remind-enable').checked) { const remindVal = $('#f-remind').value; remindAt = remindVal ? snapTo15(new Date(remindVal)).toISOString() : startDate.toISOString(); } // 组装循环规则 let recurrence = null; if (form.recurrenceType === 'DAILY') { recurrence = `DAILY;interval=${form.interval}`; } else if (form.recurrenceType === 'WEEKLY') { recurrence = `WEEKLY;interval=${form.interval}`; if (form.weekdays.length > 0) { recurrence += `;by=${form.weekdays.slice().sort((a, b) => a - b).join(',')}`; } } const eventData = { title, content: $('#f-content').value.trim(), start: startDate.toISOString(), end: endDate.toISOString(), allDay, remindAt, recurrence, completed: state.editingEvent ? !!state.editingEvent.completed : false, }; const saveBtn = $('#modal-save'); saveBtn.disabled = true; saveBtn.textContent = '保存中…'; try { if (state.editingEvent) { await apiUpdateEvent(state.editingEvent.id, eventData); showToast('事件已更新', 'success'); } else { eventData.createdAt = new Date().toISOString(); eventData.updatedAt = new Date().toISOString(); eventData.deleted = false; await apiCreateEvent(eventData); showToast('事件已创建', 'success'); } closeModal(); await loadAllEvents(); renderCurrentView(); scheduleReminders(); } catch (e) { showToast(e.message || '保存失败', 'error'); } finally { saveBtn.disabled = false; saveBtn.textContent = '保存'; } } /* ------------------------------------------ 删除确认 ActionSheet ------------------------------------------ */ function openDeleteSheet(ev) { state.deletingEvent = ev; $('#delete-sheet-overlay').classList.add('visible'); } function closeDeleteSheet() { $('#delete-sheet-overlay').classList.remove('visible'); state.deletingEvent = null; } async function handleDelete() { const ev = state.deletingEvent; if (!ev) return; const btn = $('#delete-confirm'); btn.disabled = true; btn.textContent = '删除中…'; try { await apiDeleteEvent(ev.id); showToast('事件已删除', 'success'); closeDeleteSheet(); await loadAllEvents(); renderCurrentView(); scheduleReminders(); } catch (e) { showToast(e.message || '删除失败', 'error'); } finally { btn.disabled = false; btn.textContent = '确认删除'; } } /* ------------------------------------------ 提醒通知 ------------------------------------------ */ function requestNotificationPermission() { if (!('Notification' in window)) return; if (Notification.permission === 'default') { Notification.requestPermission(); } } function triggerNotify(ev) { // 浏览器系统通知 if ('Notification' in window && Notification.permission === 'granted') { try { new Notification('提醒', { body: ev.title }); } catch (e) { console.error('通知发送失败', e); } } // 前台 Toast 兜底 showToast('提醒:' + ev.title, 'info'); } // 补偿过去 2 分钟内到期的提醒 function catchUpNotifications(now) { const since = now - WINDOW_MS; state.events.forEach((e) => { if (e.completed || e.deleted) return; const windowReminders = computeUpcomingReminders(e, since, now); windowReminders.forEach((remindIso) => { const key = `${e.id}|${remindIso}`; if (!state.notifiedKeys.has(key)) { triggerNotify(e); state.notifiedKeys.add(key); } }); }); } // 预调度未来 24h 提醒 + 补偿窗口 function scheduleReminders() { state.scheduledTimers.forEach((t) => clearTimeout(t)); state.scheduledTimers = []; const now = Date.now(); const next24h = now + 24 * 3600 * 1000; catchUpNotifications(now); state.events.forEach((e) => { if (e.completed || e.deleted) return; const upcoming = computeUpcomingReminders(e, now, next24h); upcoming.forEach((remindIso) => { const ts = safeParseDate(remindIso).getTime(); const delay = ts - now; if (delay <= 0) return; const key = `${e.id}|${remindIso}`; if (state.notifiedKeys.has(key)) return; const timer = setTimeout(() => { if (!state.notifiedKeys.has(key)) { triggerNotify(e); state.notifiedKeys.add(key); } }, delay); state.scheduledTimers.push(timer); }); }); } // 60 秒心跳巡检,捕捉休眠/时间漂移导致的遗漏 function startHeartbeat() { if (state.heartbeatTimer) clearInterval(state.heartbeatTimer); state.heartbeatTimer = setInterval(() => { const now = Date.now(); const since = now - WINDOW_MS; state.events.forEach((e) => { if (e.completed || e.deleted) return; const windowReminders = computeUpcomingReminders(e, since, now); windowReminders.forEach((remindIso) => { const key = `${e.id}|${remindIso}`; if (!state.notifiedKeys.has(key)) { triggerNotify(e); state.notifiedKeys.add(key); } }); }); }, 60 * 1000); } /* ------------------------------------------ Tab 切换 ------------------------------------------ */ function switchTab(tab) { state.currentTab = tab; document.querySelectorAll('.tab-item').forEach((t) => { t.classList.toggle('active', t.dataset.tab === tab); }); $('#view-today').classList.toggle('active', tab === 'today'); $('#view-calendar').classList.toggle('active', tab === 'calendar'); renderCurrentView(); } /* ------------------------------------------ 事件绑定 ------------------------------------------ */ function bindEvents() { // Tab 导航 document.querySelectorAll('.tab-item').forEach((t) => { t.addEventListener('click', () => switchTab(t.dataset.tab)); }); // FAB 新增 $('#fab-add').addEventListener('click', () => openModal(null)); // 日历月份切换 $('#cal-prev').addEventListener('click', () => { const d = new Date(state.calYear, state.calMonth - 1, 1); state.calYear = d.getFullYear(); state.calMonth = d.getMonth(); state.selectedDate = null; renderCalendar(); }); $('#cal-next').addEventListener('click', () => { const d = new Date(state.calYear, state.calMonth + 1, 1); state.calYear = d.getFullYear(); state.calMonth = d.getMonth(); state.selectedDate = null; renderCalendar(); }); // 选中日期旁的新增按钮 $('#cal-add-btn').addEventListener('click', () => openModal(null)); // 弹窗关闭 / 保存 $('#modal-close').addEventListener('click', closeModal); $('#modal-save').addEventListener('click', handleSave); $('#modal-overlay').addEventListener('click', (e) => { if (e.target === $('#modal-overlay')) closeModal(); }); // 结束时间清除 $('#f-end').addEventListener('input', () => { $('#f-end-clear').classList.toggle('hidden', !$('#f-end').value); }); $('#f-end-clear').addEventListener('click', () => { $('#f-end').value = ''; $('#f-end-clear').classList.add('hidden'); }); // 提醒开关 $('#f-remind-enable').addEventListener('change', toggleRemindField); // 重复类型按钮组 document.querySelectorAll('.recurrence-btn').forEach((btn) => { btn.addEventListener('click', () => { form.recurrenceType = btn.dataset.type; updateRecurrenceUI(); }); }); // 循环间隔 +/- $('#interval-minus').addEventListener('click', () => { form.interval = Math.max(1, form.interval - 1); updateRecurrenceUI(); }); $('#interval-plus').addEventListener('click', () => { form.interval = form.interval + 1; updateRecurrenceUI(); }); // 每周星期勾选 document.querySelectorAll('.weekday-btn').forEach((btn) => { btn.addEventListener('click', () => { const day = parseInt(btn.dataset.day, 10); if (form.weekdays.includes(day)) { form.weekdays = form.weekdays.filter((d) => d !== day); } else { form.weekdays = form.weekdays.concat(day).sort((a, b) => a - b); } updateRecurrenceUI(); }); }); // 删除确认 $('#delete-confirm').addEventListener('click', handleDelete); $('#delete-cancel').addEventListener('click', closeDeleteSheet); $('#delete-sheet-overlay').addEventListener('click', (e) => { if (e.target === $('#delete-sheet-overlay')) closeDeleteSheet(); }); } /* ------------------------------------------ 初始化 ------------------------------------------ */ function init() { const now = new Date(); state.calYear = now.getFullYear(); state.calMonth = now.getMonth(); bindEvents(); requestNotificationPermission(); // 初始加载数据 $('#today-loading').classList.remove('hidden'); loadAllEvents().then(() => { $('#today-loading').classList.add('hidden'); renderCurrentView(); scheduleReminders(); startHeartbeat(); }).catch((err) => { $('#today-loading').classList.add('hidden'); showToast(err.message || '加载失败', 'error'); }); // 回前台重新加载并调度(对应 RN 的 AppState active) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { loadAllEvents().then(() => { renderCurrentView(); scheduleReminders(); }).catch(() => { /* 静默失败 */ }); } }); } // 启动 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();