feat: 新增短链接工具,优化日历提醒功能

1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持
2. 新增移动端日历提醒工具与鉴权配置
3. 优化日历事件编辑与展示:
   - 添加事件循环重复设置
   - 新增全天事件支持
   - 时间选择器自动对齐15分钟间隔
   - 保存状态加载动画
   - 事件卡片显示重复规则
4. 更新API基础地址与路由配置
5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
yangxiangyuan
2026-07-28 16:23:37 +08:00
parent 212dbc4e1d
commit 154b79c8f1
18 changed files with 3508 additions and 23 deletions
@@ -0,0 +1,936 @@
/* ============================================
移动端日历提醒 - 核心逻辑
对标 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();
}
})();