Files

536 lines
21 KiB
JavaScript
Raw Permalink 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.
// 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 = `
<div class="tooltip-header">
<strong>${escapeHtml(event.title || '无标题')}</strong>
${event.completed ? '<span class="badge bg-success ms-2">已完成</span>' : ''}
</div>
<div class="tooltip-content">
<table class="tooltip-table">
<tr><th>开始时间:</th><td>${formatDateTime(event.start)}</td></tr>
${event.end ? `<tr><th>结束时间:</th><td>${formatDateTime(event.end)}</td></tr>` : ''}
${event.content ? `<tr><th>内容:</th><td>${escapeHtmlWithLinks(event.content)}</td></tr>` : ''}
${event.remindAt ? `<tr><th>提醒时间:</th><td>${formatDateTime(event.remindAt)}</td></tr>` : ''}
${recurText ? `<tr><th>循环:</th><td>${recurText}</td></tr>` : ''}
<tr><th>状态:</th><td>${event.completed ? '已完成' : '进行中'}</td></tr>
</table>
</div>
`;
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 `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
}).replace(/\n/g, '<br>');
}
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 = `<div class="cell-header"><span></span></div>`;
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 = `<span>${day}</span>`;
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 = `
<tr><th>开始时间:</th><td>${formatDateTime(event.start)}</td></tr>
${event.end ? `<tr><th>结束时间:</th><td>${formatDateTime(event.end)}</td></tr>` : ''}
${event.content ? `<tr><th>内容:</th><td>${escapeHtml(event.content)}</td></tr>` : ''}
${event.remindAt ? `<tr><th>提醒时间:</th><td>${formatDateTime(event.remindAt)}</td></tr>` : ''}
<tr><th>状态:</th><td>${event.completed ? '已完成' : '进行中'}</td></tr>
`;
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">${escapeHtml(event.title || '无标题')}</div>
<button type="button" class="modal-close" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<table class="tooltip-table">${tableRows}</table>
</div>
`;
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;