Files
timeline/public/js/calendar.js
licsber 68d13112ec
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
optimize: api resp size.
2025-11-11 17:06:17 +08:00

444 lines
14 KiB
JavaScript

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}`;
}
}