diff --git a/public/js/calendar.js b/public/js/calendar.js index 9348e71..b472c89 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -13,6 +13,7 @@ export default class CalendarView { this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; this.onUnauthorized = onUnauthorized; this.saveDelay = 500; + this.pendingTooltipDate = null; // Track date that needs tooltip after render } init() { @@ -51,7 +52,20 @@ export default class CalendarView { try { showToast('正在加载...', 'info'); const result = await fetchCalendar(state.currentServer, state.token); - state.markedDates = result.markedDates || {}; + // Convert the new data structure to the old one for backward compatibility + const markedDates = {}; + for (const [date, data] of Object.entries(result.markedDates || {})) { + if (typeof data === 'string') { + // Old format - just status + markedDates[date] = data; + } else if (data && typeof data === 'object') { + // New format - object with status and updatedAt + markedDates[date] = data.status; + } + } + state.markedDates = markedDates; + // Store the full data including timestamps + state.markedDatesWithTimestamps = result.markedDates || {}; this.renderCalendar(); showToast('数据加载成功', 'success'); } catch (error) { @@ -68,6 +82,7 @@ export default class CalendarView { clearData() { state.markedDates = {}; + state.markedDatesWithTimestamps = {}; this.renderCalendar(); } @@ -149,7 +164,34 @@ export default class CalendarView { dayEl.appendChild(markEl); } - dayEl.addEventListener('click', () => { + // 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); } @@ -158,6 +200,106 @@ export default class CalendarView { 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'); @@ -183,8 +325,13 @@ export default class CalendarView { if (nextStatus === 'none') { delete state.markedDates[dateKey]; + // Clear pending tooltip for removed marks + this.pendingTooltipDate = null; + this.hideTooltip(); } else { state.markedDates[dateKey] = nextStatus; + // Store the date for tooltip after save completes + this.pendingTooltipDate = dateKey; } this.renderCalendar(); @@ -208,6 +355,13 @@ export default class CalendarView { try { await saveCalendar(state.currentServer, state.token, state.markedDates); 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'); @@ -233,6 +387,15 @@ export default class CalendarView { return state.markedDates[dateKey] || 'none'; } + getDateTimestamp(date) { + const dateKey = this.formatDate(date); + const dateData = state.markedDatesWithTimestamps[dateKey]; + if (dateData && typeof dateData === 'object' && dateData.updatedAt) { + return dateData.updatedAt; + } + return null; + } + isToday(date) { const today = new Date(); return date.getFullYear() === today.getFullYear() && diff --git a/public/js/state.js b/public/js/state.js index ca6603f..b734708 100644 --- a/public/js/state.js +++ b/public/js/state.js @@ -1,6 +1,7 @@ const state = { currentDate: new Date(), markedDates: {}, + markedDatesWithTimestamps: {}, currentServer: '', token: '', user: null, diff --git a/public/style.css b/public/style.css index 307828d..a97c442 100644 --- a/public/style.css +++ b/public/style.css @@ -575,6 +575,37 @@ header h1 { color: white; } +/* Custom tooltip for modification time */ +.tooltip { + position: fixed; + background: #333; + color: white; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + z-index: 10001; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.tooltip.show { + opacity: 1; +} + +.tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -6px; + border-width: 6px; + border-style: solid; + border-color: #333 transparent transparent transparent; +} + @media (max-width: 768px) { .container { padding: 20px; diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 6701599..cb607b4 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -7,7 +7,7 @@ router.get('/', (req, res) => { const userId = req.user.userId; db.all( - 'SELECT date, status FROM calendar_marks WHERE user_id = ?', + 'SELECT date, status, updated_at FROM calendar_marks WHERE user_id = ?', [userId], (err, rows) => { if (err) { @@ -16,7 +16,10 @@ router.get('/', (req, res) => { const markedDates = {}; rows.forEach((row) => { - markedDates[row.date] = row.status; + markedDates[row.date] = { + status: row.status, + updatedAt: row.updated_at + }; }); res.json({ success: true, markedDates }); @@ -32,18 +35,54 @@ router.post('/', (req, res) => { return res.status(400).json({ success: false, error: '无效的数据格式' }); } + // Helper function to get current timestamp in ISO format (UTC) + const getLocalTimestamp = () => { + return new Date().toISOString(); + }; + const dbOperations = Object.entries(markedDates).map(([date, status]) => { return new Promise((resolve, reject) => { - db.run( - `INSERT INTO calendar_marks (user_id, date, status, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(user_id, date) DO UPDATE SET - status = excluded.status, - updated_at = CURRENT_TIMESTAMP`, - [userId, date, status], - (err) => { - if (err) reject(err); - else resolve(); + // First check if this date exists and if status has changed + db.get( + 'SELECT status FROM calendar_marks WHERE user_id = ? AND date = ?', + [userId, date], + (err, row) => { + if (err) { + return reject(err); + } + + // Only update timestamp if status changed or it's a new entry + const statusChanged = !row || row.status !== status; + + if (statusChanged) { + // Status changed - update with new timestamp + const currentTimestamp = getLocalTimestamp(); + db.run( + `INSERT INTO calendar_marks (user_id, date, status, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, date) DO UPDATE SET + status = excluded.status, + updated_at = excluded.updated_at`, + [userId, date, status, currentTimestamp], + (err) => { + if (err) reject(err); + else resolve(); + } + ); + } else { + // Status unchanged - keep existing timestamp + db.run( + `INSERT INTO calendar_marks (user_id, date, status, updated_at) + VALUES (?, ?, ?, (SELECT updated_at FROM calendar_marks WHERE user_id = ? AND date = ?)) + ON CONFLICT(user_id, date) DO UPDATE SET + status = excluded.status`, + [userId, date, status, userId, date], + (err) => { + if (err) reject(err); + else resolve(); + } + ); + } } ); });