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

937 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================
移动端日历提醒 - 核心逻辑
对标 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();
}
})();