// UI 渲染与交互 const UI = (function () { const gridEl = () => document.getElementById('calendar-grid'); const monthLabelEl = () => document.getElementById('currentMonth'); const dayEventsEl = () => document.getElementById('dayEvents'); const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; function isMobile() { try { return window.matchMedia && window.matchMedia('(max-width: 768px)').matches || (navigator && navigator.maxTouchPoints > 0); } catch (_) { return false; } } // 创建详细悬停提示框 function createEventTooltip(event) { let recurText = ''; try { if (typeof parseRecurrenceStr === 'function') { const rec = parseRecurrenceStr(event.recurrence || ''); if (rec && rec.type && rec.type !== 'NONE') { const parts = []; if (rec.type === 'DAILY') { parts.push(rec.interval > 1 ? `每 ${rec.interval} 天` : '每天'); } else if (rec.type === 'WEEKLY') { parts.push(rec.interval > 1 ? `每 ${rec.interval} 周` : '每周'); if (Array.isArray(rec.by) && rec.by.length) { const map = ['一','二','三','四','五','六','日']; const names = rec.by.map(n => map[(n - 1 + 7) % 7]).map(ch => '周' + ch); parts.push(names.join('、')); } } recurText = parts.join(','); } } } catch (_) {} const tooltip = document.createElement('div'); tooltip.className = 'event-tooltip'; tooltip.innerHTML = `
${escapeHtml(event.title || '无标题')} ${event.completed ? '已完成' : ''}
${event.end ? `` : ''} ${event.content ? `` : ''} ${event.remindAt ? `` : ''} ${recurText ? `` : ''}
开始时间:${formatDateTime(event.start)}
结束时间:${formatDateTime(event.end)}
内容:${escapeHtmlWithLinks(event.content)}
提醒时间:${formatDateTime(event.remindAt)}
循环:${recurText}
状态:${event.completed ? '已完成' : '进行中'}
`; return tooltip; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function escapeHtmlWithLinks(text) { const raw = String(text || ''); const escaped = escapeHtml(raw); const linkPattern = /((?:https?:\/\/|\/tools\/)[^\s<]+)/g; return escaped.replace(linkPattern, (m) => { const href = m.startsWith('/tools/') ? m : m; return `${m}`; }).replace(/\n/g, '
'); } function formatDateTime(dateString) { const date = new Date(dateString); return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function renderWeekdayHeader() { const head = document.createElement('div'); head.className = 'calendar-weekday'; WEEKDAYS.forEach(w => { const d = document.createElement('div'); d.textContent = w; head.appendChild(d); }); return head; } function monthStart(year, month) { return new Date(Date.UTC(year, month - 1, 1)); } function monthEnd(year, month) { return new Date(Date.UTC(year, month, 0)); } function loadEventIntoForm(e) { if (!e) return; const form = document.getElementById('eventForm'); if (!form) return; form.eventLoaded = e; const idInput = document.getElementById('eventId'); if (idInput) idInput.value = e.id || ''; const titleInput = document.getElementById('title'); if (titleInput) titleInput.value = e.title || ''; const contentInput = document.getElementById('content'); if (contentInput) contentInput.value = e.content || ''; const completedInput = document.getElementById('completedFlag'); if (completedInput) completedInput.checked = !!e.completed; const startInput = document.getElementById('start'); if (startInput && e.start) startInput.value = toInputValue(e.start); const endInput = document.getElementById('end'); if (endInput && e.end) endInput.value = toInputValue(e.end); const typeSel = document.getElementById('recurrenceType'); const intervalInput = document.getElementById('recurrenceInterval'); const weekdayInputs = Array.from(document.querySelectorAll('#weekdayGroup [data-weekday]')); const recStr = String(e.recurrence || ''); if (typeSel) { typeSel.value = recStr.startsWith('DAILY') ? 'DAILY' : (recStr.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE'); } if (intervalInput) { const m = /interval=(\d+)/.exec(recStr); intervalInput.value = m ? Math.max(1, parseInt(m[1], 10)) : 1; } const by = (() => { const m = /by=([0-9,]+)/.exec(recStr); return m ? m[1].split(',').map(s => parseInt(s, 10)) : []; })(); weekdayInputs.forEach(el => { const v = parseInt(el.getAttribute('data-weekday'), 10); el.checked = by.includes(v); }); const enableReminderInput = document.getElementById('enableReminder'); const hasRemind = !!e.remindAt; if (enableReminderInput) enableReminderInput.checked = hasRemind; const dt = document.getElementById('remindDateTime'); const tm = document.getElementById('remindTime'); const typeVal = typeSel ? typeSel.value : 'NONE'; if (typeVal === 'NONE') { if (dt) { dt.classList.remove('d-none'); dt.value = e.remindAt ? toInputValue(e.remindAt) : ''; } if (tm) tm.classList.add('d-none'); } else { if (dt) dt.classList.add('d-none'); if (tm) { tm.classList.remove('d-none'); tm.value = e.remindAt ? toTimeInputValue(e.remindAt) : ''; } } const btnDelete = document.getElementById('btnDelete'); if (btnDelete) btnDelete.classList.remove('d-none'); if (isMobile()) { openEventModal(e); } } // 在 UI.renderCalendar 的事件 pill 点击中,改为填充统一表单字段与解析循环 function renderCalendar(year, month, events) { const grid = gridEl(); grid.innerHTML = ''; // 清理旧的星期头部,避免重复累加 Array.from(grid.parentElement.querySelectorAll('.calendar-weekday')).forEach(el => el.remove()); const head = renderWeekdayHeader(); grid.parentElement.insertBefore(head, grid); // 性能优化:建立日期索引,避免在循环中重复遍历所有事件 const eventsByDate = new Map(); // 辅助:生成本地日期 Key YYYY-MM-DD const toKey = (y, m, d) => `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`; // 确定当月范围(本地时间) const monthStart = new Date(year, month - 1, 1, 0, 0, 0, 0); const monthEnd = new Date(year, month, 0, 23, 59, 59, 999); const overlapsDay = (e, dateObj) => { try { if (!e || e.deleted || !e.start) return false; const s = new Date(e.start); const ep = e.end ? new Date(e.end) : new Date(e.start); const ds = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0); const de = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 23, 59, 59, 999); return s <= de && ep >= ds; } catch (_) { return false; } }; events.forEach(e => { if (!e || e.deleted || !e.start) return; try { const s = new Date(e.start); const end = e.end ? new Date(e.end) : new Date(e.start); // 计算事件涉及的日期范围(截断到当月) // 转换为本地日期的 0 点进行比较 const sDay = new Date(s.getFullYear(), s.getMonth(), s.getDate()); const eDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()); // 如果事件范围与当月无交集,跳过 if (eDay < monthStart || sDay > monthEnd) return; const iterStart = sDay < monthStart ? monthStart : sDay; const iterEnd = eDay > monthEnd ? monthEnd : eDay; // 遍历涉及的每一天 for (let d = new Date(iterStart); d <= iterEnd; d.setDate(d.getDate() + 1)) { if (!overlapsDay(e, d)) continue; const key = toKey(d.getFullYear(), d.getMonth() + 1, d.getDate()); if (!eventsByDate.has(key)) eventsByDate.set(key, []); eventsByDate.get(key).push(e); } } catch (_) {} }); const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 0)); const firstWeekday = start.getUTCDay(); const daysInMonth = end.getUTCDate(); // 补齐上月空白格 for (let i = 0; i < firstWeekday; i++) { const cell = document.createElement('div'); cell.className = 'calendar-cell is-outside'; cell.innerHTML = `
`; grid.appendChild(cell); } const today = new Date(); const isToday = (d) => d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate(); const frag = document.createDocumentFragment(); for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month - 1, day); // 本地时间对象用于 isToday 判断 const cell = document.createElement('div'); cell.className = 'calendar-cell' + (isToday(date) ? ' is-today' : ''); const header = document.createElement('div'); header.className = 'cell-header'; header.innerHTML = `${day}`; const body = document.createElement('div'); body.className = 'events'; const key = toKey(year, month, day); const esRaw = eventsByDate.get(key) || []; const es = (function dedupDay(list) { if (list.length <= 1) return list; const seenDisplay = new Set(); const seenId = new Set(); return list.filter(e => { // 优化:减少 Date 对象创建 const sStr = e.start || ''; // 简单提取时分,假设 ISO 格式 let hh = '00', mm = '00'; if (sStr.length >= 16) { const tPart = new Date(sStr); hh = String(tPart.getHours()).padStart(2, '0'); mm = String(tPart.getMinutes()).padStart(2, '0'); } const title = (e.title || '').trim(); const done = e.completed ? '1' : '0'; const dk = `${title}|${hh}:${mm}|${done}`; if (seenDisplay.has(dk)) return false; if (e.id) { if (seenId.has(e.id)) return false; seenId.add(e.id); } seenDisplay.add(dk); return true; }); })(esRaw); const MAX_PER_DAY = 50; const visibleEs = es.slice(0, MAX_PER_DAY); visibleEs.forEach(e => { const pill = document.createElement('div'); pill.className = 'event-pill' + (e.completed ? ' is-done' : ''); pill.textContent = e.title; if (!isMobile()) { pill.addEventListener('mouseenter', () => { const tooltip = createEventTooltip(e); document.body.appendChild(tooltip); const rect = pill.getBoundingClientRect(); tooltip.style.position = 'fixed'; tooltip.style.left = rect.right + 10 + 'px'; tooltip.style.top = rect.top + 'px'; tooltip.style.zIndex = '1000'; pill._currentTooltip = tooltip; }); pill.addEventListener('mouseleave', () => { if (pill._currentTooltip) { document.body.removeChild(pill._currentTooltip); pill._currentTooltip = null; } }); } pill.addEventListener('click', () => { loadEventIntoForm(e); }); body.appendChild(pill); }); if (es.length > MAX_PER_DAY) { const more = document.createElement('div'); more.className = 'event-pill more'; more.textContent = `还有 ${es.length - MAX_PER_DAY} 项...`; more.style.backgroundColor = '#f0f0f0'; more.style.color = '#888'; more.style.textAlign = 'center'; more.style.cursor = 'pointer'; more.style.fontSize = '0.85em'; body.appendChild(more); } cell.appendChild(header); cell.appendChild(body); cell.addEventListener('click', (ev) => { const isPill = ev.target.closest('.event-pill'); if (!isPill) renderDayList(date, events); }); frag.appendChild(cell); } grid.appendChild(frag); monthLabelEl().textContent = `${year}年${month}月`; } function renderDayList(date, events) { const list = dayEventsEl(); list.innerHTML = ''; function overlapsDay(e, dateObj) { try { if (!e || e.deleted || !e.start) return false; const s = new Date(e.start); const ep = e.end ? new Date(e.end) : new Date(e.start); const ds = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 0, 0, 0, 0); const de = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate(), 23, 59, 59, 999); return s <= de && ep >= ds; } catch (_) { return false; } } const esRaw = events.filter(e => overlapsDay(e, date) && !e.deleted); const es = (function dedupDay(list) { const seenId = new Set(); const seenDisplay = new Set(); function displayKey(e) { const d = e.start ? new Date(e.start) : null; const hh = d ? String(d.getHours()).padStart(2, '0') : '00'; const mm = d ? String(d.getMinutes()).padStart(2, '0') : '00'; const title = (e.title || '').trim(); const done = e.completed ? '1' : '0'; return `${title}|${hh}:${mm}|${done}`; } return list.filter(e => { const dk = displayKey(e); if (seenDisplay.has(dk)) return false; if (e.id) { if (seenId.has(e.id)) return false; seenId.add(e.id); } seenDisplay.add(dk); return true; }); })(esRaw); if (!es.length) { const li = document.createElement('li'); li.className = 'list-group-item text-muted small'; li.textContent = '当天暂无事件'; list.appendChild(li); return; } es.forEach(e => { const li = document.createElement('li'); li.className = 'list-group-item'; if (e.completed) li.classList.add('is-done'); const wrap = document.createElement('div'); wrap.className = 'event-list-content'; const titleEl = document.createElement('p'); titleEl.className = 'event-list-title'; titleEl.textContent = e.title || ''; const timeEl = document.createElement('p'); timeEl.className = 'event-list-time'; const s = e.start ? new Date(e.start) : null; const ed = e.end ? new Date(e.end) : null; const pad = (n) => String(n).padStart(2, '0'); const fmt = (d) => d ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : ''; const sTxt = fmt(s); const eTxt = fmt(ed); timeEl.textContent = eTxt ? `${sTxt} - ${eTxt}` : sTxt; const descEl = document.createElement('p'); descEl.className = 'event-list-desc'; descEl.innerHTML = escapeHtmlWithLinks(e.content || ''); wrap.appendChild(titleEl); wrap.appendChild(timeEl); if (descEl.textContent) wrap.appendChild(descEl); li.appendChild(wrap); li.addEventListener('click', () => { loadEventIntoForm(e); }); list.appendChild(li); }); } function toInputValue(iso) { const d = new Date(iso); const q = Math.round(d.getMinutes() / 15) * 15; d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0); const pad = (n) => String(n).padStart(2, '0'); const yyyy = d.getFullYear(); const MM = pad(d.getMonth() + 1); const dd = pad(d.getDate()); const hh = pad(d.getHours()); const mm = pad(d.getMinutes()); return `${yyyy}-${MM}-${dd}T${hh}:${mm}`; } function toTimeInputValue(iso) { const d = new Date(iso); const q = Math.round(d.getMinutes() / 15) * 15; d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0); const pad = (n) => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; } function openEventModal(event) { const backdrop = document.createElement('div'); backdrop.className = 'event-modal-backdrop'; const modal = document.createElement('div'); modal.className = 'event-modal'; const tableRows = ` 开始时间:${formatDateTime(event.start)} ${event.end ? `结束时间:${formatDateTime(event.end)}` : ''} ${event.content ? `内容:${escapeHtml(event.content)}` : ''} ${event.remindAt ? `提醒时间:${formatDateTime(event.remindAt)}` : ''} 状态:${event.completed ? '已完成' : '进行中'} `; modal.innerHTML = ` `; document.body.appendChild(backdrop); document.body.appendChild(modal); const close = () => { modal.remove(); backdrop.remove(); }; modal.querySelector('.modal-close').addEventListener('click', close); backdrop.addEventListener('click', close); } function showGlobalModal(message) { let backdrop = document.getElementById('globalModalBackdrop'); if (!backdrop) { backdrop = document.createElement('div'); backdrop.id = 'globalModalBackdrop'; backdrop.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:2000;'; const box = document.createElement('div'); box.id = 'globalModalBox'; box.style.cssText = 'min-width:260px;max-width:90vw;padding:16px 20px;border-radius:10px;background:#1f2937;color:#fff;box-shadow:0 10px 25px rgba(0,0,0,.35);text-align:center;'; const spinner = document.createElement('div'); spinner.className = 'spinner-border text-light'; spinner.setAttribute('role', 'status'); spinner.style.cssText = 'width:2rem;height:2rem;'; const msg = document.createElement('div'); msg.id = 'globalModalMessage'; msg.style.cssText = 'margin-top:10px;font-size:14px;'; msg.textContent = message || '正在处理...'; const logPanel = document.createElement('div'); logPanel.id = 'globalModalLog'; logPanel.style.cssText = 'margin-top:10px;padding:10px;background:#374151;border-radius:5px;text-align:left;font-size:12px;max-height:200px;overflow-y:auto;white-space:pre-wrap;'; const copyBtn = document.createElement('button'); copyBtn.id = 'globalModalCopy'; copyBtn.style.cssText = 'margin-top:10px;padding:5px 10px;background:#4b5563;border:none;border-radius:5px;color:#fff;cursor:pointer;'; copyBtn.textContent = '复制日志'; copyBtn.addEventListener('click', () => { try { const text = logPanel.textContent || ''; if (!text) return; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => {}); } } catch (_) {} }); box.appendChild(spinner); box.appendChild(msg); box.appendChild(logPanel); box.appendChild(copyBtn); backdrop.appendChild(box); document.body.appendChild(backdrop); } else { const msg = document.getElementById('globalModalMessage'); if (msg) msg.textContent = message || '正在处理...'; const logPanel = document.getElementById('globalModalLog'); if (logPanel) logPanel.textContent = ''; } } function appendGlobalModalLog(text) { const logPanel = document.getElementById('globalModalLog'); if (!logPanel) return; const line = new Date().toLocaleTimeString() + ' - ' + text; logPanel.textContent = (logPanel.textContent || '') + line + '\n'; logPanel.scrollTop = logPanel.scrollHeight; } function hideGlobalModal() { const backdrop = document.getElementById('globalModalBackdrop'); if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); } function updateGlobalModalMessage(message) { const msg = document.getElementById('globalModalMessage'); if (msg) msg.textContent = message || ''; } function completeGlobalModal(message) { try { const box = document.getElementById('globalModalBox'); const spinner = box ? box.querySelector('.spinner-border') : null; if (spinner && spinner.parentNode) spinner.parentNode.removeChild(spinner); } catch (_) {} updateGlobalModalMessage(message || '完成'); setTimeout(() => { try { hideGlobalModal(); } catch (_) {} }, 1000); } return { renderCalendar, renderDayList, toInputValue, showGlobalModal, appendGlobalModalLog, hideGlobalModal, updateGlobalModalMessage, completeGlobalModal }; })(); window.UI = UI;