Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
444 lines
14 KiB
JavaScript
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}`;
|
|
}
|
|
}
|