import state from './state.js'; import { fetchCalendar, saveCalendar, ApiError } from './api.js'; import { showToast } from './toast.js'; export default class CalendarView { constructor({ onUnauthorized } = {}) { this.calendarEl = document.getElementById('calendar'); this.monthDisplayEl = document.getElementById('currentMonth'); this.prevBtn = document.getElementById('prevMonth'); this.nextBtn = document.getElementById('nextMonth'); this.todayBtn = document.getElementById('todayBtn'); this.weekdays = ['日', '一', '二', '三', '四', '五', '六']; this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; this.onUnauthorized = onUnauthorized; this.saveDelay = 500; this.pendingTooltipDate = null; // Track date that needs tooltip after render } init() { this.bindNavigation(); this.renderCalendar(); } bindNavigation() { if (this.prevBtn) { this.prevBtn.addEventListener('click', () => { state.currentDate.setMonth(state.currentDate.getMonth() - 1); this.renderCalendar(); // Load data for the new month this.loadData(); }); } if (this.nextBtn) { this.nextBtn.addEventListener('click', () => { state.currentDate.setMonth(state.currentDate.getMonth() + 1); this.renderCalendar(); // Load data for the new month this.loadData(); }); } if (this.todayBtn) { this.todayBtn.addEventListener('click', () => { state.currentDate = new Date(); this.renderCalendar(); // Load data for the current month this.loadData(); }); } } async loadData() { if (!state.currentServer || !state.token) { return; } try { showToast('正在加载...', 'info'); // Pass current year and month to only fetch data for the displayed month const year = state.currentDate.getFullYear(); const month = state.currentDate.getMonth() + 1; // getMonth() returns 0-11, need 1-12 const result = await fetchCalendar(state.currentServer, state.token, year, month); // Merge with existing data Object.assign(state.markedDates, result.markedDates || {}); this.renderCalendar(); showToast('数据加载成功', 'success'); } catch (error) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { showToast('认证已过期,请重新登录', 'error'); if (this.onUnauthorized) { this.onUnauthorized(); } return; } showToast(error.message || '加载失败', 'error'); } } clearData() { state.markedDates = {}; state.deletedDates = []; this.renderCalendar(); } renderCalendar() { if (!this.calendarEl || !this.monthDisplayEl) return; const currentDate = state.currentDate; const year = currentDate.getFullYear(); const month = currentDate.getMonth(); this.calendarEl.innerHTML = ''; this.weekdays.forEach(day => { const weekdayEl = document.createElement('div'); weekdayEl.className = 'weekday'; weekdayEl.textContent = day; this.calendarEl.appendChild(weekdayEl); }); this.monthDisplayEl.textContent = `${year}年 ${this.monthNames[month]}`; const firstDay = this.getFirstDayOfMonth(currentDate); const daysInMonth = this.getDaysInMonth(currentDate); const prevMonthDate = new Date(year, month, 0); const daysInPrevMonth = prevMonthDate.getDate(); for (let i = firstDay - 1; i >= 0; i--) { const day = daysInPrevMonth - i; const date = new Date(year, month - 1, day); this.calendarEl.appendChild(this.createDayElement(date, day, true)); } for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); this.calendarEl.appendChild(this.createDayElement(date, day, false)); } const totalCells = this.calendarEl.children.length - this.weekdays.length; const remainingCells = 42 - totalCells; for (let day = 1; day <= remainingCells; day++) { const date = new Date(year, month + 1, day); this.calendarEl.appendChild(this.createDayElement(date, day, true)); } } createDayElement(date, dayNumber, isOtherMonth) { const dayEl = document.createElement('div'); dayEl.className = 'day'; if (isOtherMonth) { dayEl.classList.add('other-month'); } // Check if the day is in the future if (this.isFutureDate(date)) { dayEl.classList.add('future-day'); } if (this.isToday(date)) { dayEl.classList.add('today'); } const status = this.getDateStatus(date); if (status !== 'none') { dayEl.classList.add(status); } const dayNumberEl = document.createElement('div'); dayNumberEl.className = 'day-number'; dayNumberEl.textContent = dayNumber; dayEl.appendChild(dayNumberEl); if (status !== 'none') { const markEl = document.createElement('div'); markEl.className = 'day-mark'; markEl.textContent = status === 'completed' ? '✓' : '○'; dayEl.appendChild(markEl); } // Add timestamp tooltip functionality const timestamp = this.getDateTimestamp(date); if (timestamp) { // Store the ISO timestamp dayEl.dataset.modificationTime = timestamp; // Add hover events for custom tooltip dayEl.addEventListener('mouseenter', (e) => { const isoTime = e.currentTarget.dataset.modificationTime; // Convert to user's local timezone const localTime = new Date(isoTime).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); this.showTooltip(e, `修改时间: ${localTime}`); }); dayEl.addEventListener('mouseleave', () => { this.hideTooltip(); }); } dayEl.addEventListener('click', (e) => { if (!isOtherMonth) { this.toggleDateStatus(date); } }); return dayEl; } // Tooltip methods showTooltip(event, content) { // Remove any existing tooltips completely const existingTooltips = document.querySelectorAll('.tooltip'); existingTooltips.forEach(tip => { if (tip.parentNode) { tip.parentNode.removeChild(tip); } }); // Create new tooltip element with unique ID const tooltip = document.createElement('div'); const uniqueId = 'tooltip-' + Date.now(); tooltip.id = uniqueId; tooltip.className = 'tooltip'; tooltip.textContent = content; document.body.appendChild(tooltip); // Position tooltip relative to the target element const rect = event.currentTarget.getBoundingClientRect(); tooltip.style.left = (rect.left + rect.width / 2) + 'px'; tooltip.style.top = (rect.top - 10) + 'px'; tooltip.style.transform = 'translate(-50%, -100%)'; // Show tooltip with a slight delay to ensure position is set requestAnimationFrame(() => { tooltip.classList.add('show'); }); } hideTooltip() { const tooltips = document.querySelectorAll('.tooltip'); tooltips.forEach(tooltip => { tooltip.classList.remove('show'); // Remove tooltip element after transition setTimeout(() => { if (tooltip.parentNode) { tooltip.parentNode.removeChild(tooltip); } }, 200); }); } refreshTooltip(event, element) { // Check if element has timestamp data const isoTime = element.dataset.modificationTime; if (isoTime) { // Convert to user's local timezone const localTime = new Date(isoTime).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); this.showTooltip(event, `修改时间: ${localTime}`); } else { // No timestamp, hide tooltip this.hideTooltip(); } } showTooltipForDate(dateKey) { // Find the day element for this date const dayElements = this.calendarEl.querySelectorAll('.day:not(.other-month)'); for (const dayEl of dayElements) { if (dayEl.dataset.modificationTime) { // Extract date from the element's position const dayNumber = dayEl.querySelector('.day-number')?.textContent; if (dayNumber) { const year = state.currentDate.getFullYear(); const month = state.currentDate.getMonth(); const testDate = new Date(year, month, parseInt(dayNumber)); if (this.formatDate(testDate) === dateKey) { // Found the element, show tooltip const isoTime = dayEl.dataset.modificationTime; const localTime = new Date(isoTime).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); // Create a synthetic event for positioning const rect = dayEl.getBoundingClientRect(); const syntheticEvent = { currentTarget: dayEl }; this.showTooltip(syntheticEvent, `修改时间: ${localTime}`); break; } } } } } toggleDateStatus(date) { if (!state.currentServer || !state.token) { showToast('请先选择服务器并登录', 'error'); return; } const dateKey = this.formatDate(date); const dateData = state.markedDates[dateKey]; const currentStatus = (dateData && dateData.status) || 'none'; let nextStatus; switch (currentStatus) { case 'none': nextStatus = 'completed'; break; case 'completed': nextStatus = 'partial'; break; case 'partial': default: nextStatus = 'none'; break; } if (nextStatus === 'none') { delete state.markedDates[dateKey]; // Track this deletion if (!state.deletedDates.includes(dateKey)) { state.deletedDates.push(dateKey); } // Clear pending tooltip for removed marks this.pendingTooltipDate = null; this.hideTooltip(); } else { // Store status, timestamp will be updated by server on save state.markedDates[dateKey] = { status: nextStatus }; // Remove from deleted dates if it was there const deleteIndex = state.deletedDates.indexOf(dateKey); if (deleteIndex !== -1) { state.deletedDates.splice(deleteIndex, 1); } // Store the date for tooltip after save completes this.pendingTooltipDate = dateKey; } this.renderCalendar(); this.scheduleSave(); } scheduleSave() { if (!state.currentServer || !state.token) return; if (state.saveTimeoutId) { clearTimeout(state.saveTimeoutId); } state.saveTimeoutId = setTimeout(() => { this.persistMarkedDates(); }, this.saveDelay); } async persistMarkedDates() { state.saveTimeoutId = null; try { // Convert state format { date: { status, updatedAt } } to API format { date: status } const markedDatesForAPI = {}; for (const [date, data] of Object.entries(state.markedDates)) { if (data && typeof data === 'object' && data.status) { markedDatesForAPI[date] = data.status; } } await saveCalendar(state.currentServer, state.token, markedDatesForAPI, state.deletedDates); // Clear the deleted dates array after successful save state.deletedDates = []; showToast('数据已保存', 'success'); // Reload data to get updated timestamps await this.loadData(); // Show tooltip for the pending date after data is loaded if (this.pendingTooltipDate) { this.showTooltipForDate(this.pendingTooltipDate); this.pendingTooltipDate = null; } } catch (error) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { showToast('认证已过期,请重新登录', 'error'); if (this.onUnauthorized) { this.onUnauthorized(); } return; } showToast(error.message || '保存失败', 'error'); } } getFirstDayOfMonth(date) { return new Date(date.getFullYear(), date.getMonth(), 1).getDay(); } getDaysInMonth(date) { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); } getDateStatus(date) { const dateKey = this.formatDate(date); const dateData = state.markedDates[dateKey]; if (dateData && typeof dateData === 'object') { return dateData.status || 'none'; } return 'none'; } getDateTimestamp(date) { const dateKey = this.formatDate(date); const dateData = state.markedDates[dateKey]; if (dateData && typeof dateData === 'object' && dateData.updatedAt) { return dateData.updatedAt; } return null; } isToday(date) { const today = new Date(); return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); } // Check if a date is in the future isFutureDate(date) { const today = new Date(); today.setHours(0, 0, 0, 0); const checkDate = new Date(date); checkDate.setHours(0, 0, 0, 0); return checkDate > today; } formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } }