Files
iot-dboard/frontend/static/js/display.js
T
2026-08-12 09:50:37 +08:00

1653 lines
58 KiB
JavaScript
Raw 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.
/* Display page - pure vanilla JS, minimal and fast */
(function () {
'use strict';
// 自定义提示(替代浏览器 alert):轻量浮层,自动消失
function showAlert(msg) {
var el = document.createElement('div');
el.style.cssText = 'position:fixed;top:56px;left:50%;transform:translateX(-50%);z-index:9999;' +
'background:#fff;color:#E05555;border:1px solid #f0c2c2;border-radius:12px;' +
'padding:12px 24px;font-size:15px;box-shadow:0 6px 24px rgba(0,0,0,.18);';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 2200);
}
// ─── State ────────────────────────────────
var state = {
weather: {},
events: [],
clocks: [],
alarms: [],
groups: [],
pomodoro: {},
activePanel: 'time',
alarmActive: false,
pomodoroRinging: false,
customNotifyActive: false,
audioCtx: null,
editingAlarm: null, // alarm object being edited, or null for new
_alarmModalOpen: false,
countdowns: [],
hourly: [],
minutely: null,
warnings: null,
update_schedule: {},
_cdModalMode: false, // true = countdown modal, false = alarm modal
_editingCountdown: null,
timezone: 'Asia/Shanghai', // 账户独立时区(来自 /api/display/state)
};
// ─── DOM refs ─────────────────────────────
var $ = function (id) { return document.getElementById(id); };
var cards = document.querySelectorAll('.card');
var panels = document.querySelectorAll('.panel-content');
// HTML escape for untrusted data — prevents XSS in innerHTML
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ─── 账户时区时间 ─────────────────────────
// 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断
// 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。
function _pad2(n) { return String(n).padStart(2, '0'); }
function _tzNow() {
var tz = state.timezone || 'Asia/Shanghai';
var out = { y: 1970, mo: 1, d: 1, h: 0, mi: 0, s: 0, wd: 0 };
try {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23', weekday: 'short'
}).formatToParts(new Date());
var wdMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
parts.forEach(function (p) {
switch (p.type) {
case 'year': out.y = parseInt(p.value, 10); break;
case 'month': out.mo = parseInt(p.value, 10); break; // 1-12
case 'day': out.d = parseInt(p.value, 10); break;
case 'hour': out.h = parseInt(p.value, 10); break;
case 'minute': out.mi = parseInt(p.value, 10); break;
case 'second': out.s = parseInt(p.value, 10); break;
case 'weekday': out.wd = wdMap[p.value] || 0; break;
}
});
} catch (e) {
var l = new Date();
out = { y: l.getFullYear(), mo: l.getMonth() + 1, d: l.getDate(),
h: l.getHours(), mi: l.getMinutes(), s: l.getSeconds(), wd: l.getDay() };
}
return out;
}
// 生成 "YYYYMMDDTHHMMSS" 比较键(与后端本地时间字符串可比)
function _nowLocalKey() {
var n = _tzNow();
return String(n.y) + _pad2(n.mo) + _pad2(n.d) + 'T' +
_pad2(n.h) + _pad2(n.mi) + _pad2(n.s);
}
// ─── Panel switching ──────────────────────
function switchPanel(name) {
if (state.alarmActive && name !== 'alarm') return; // lock during alarm
state.activePanel = name;
cards.forEach(function (c) { c.classList.remove('active'); });
panels.forEach(function (p) { p.classList.remove('active'); });
var card = document.querySelector('[data-card="' + name + '"]');
var panel = $('panel-' + name);
if (card) card.classList.add('active');
if (panel) panel.classList.add('active');
// Re-render pomodoro card to show/hide buttons based on active panel
renderPomodoroCard();
// Refresh pomodoro state when switching to pomodoro panel
if (name === 'pomodoro') {
fetchPomodoro();
}
}
cards.forEach(function (card) {
card.addEventListener('click', function () {
switchPanel(this.dataset.card);
});
});
// ─── Data fetching ────────────────────────
function fetchState() {
Auth.request('GET', '/api/display/state', null, function (data, status) {
if (status !== 200) return;
state.weather = data.weather || {};
state.hourly = data.hourly || [];
state.events = data.events || [];
state.alarms = data.alarms || [];
state.countdowns = data.countdowns || [];
state.pomodoro = data.pomodoro || state.pomodoro || {};
state.update_schedule = data.update_schedule || {};
state.minutely = data.minutely || null;
state.warnings = data.warnings || null;
state.groups = data.groups || [];
state.timezone = data.timezone || state.timezone || 'Asia/Shanghai';
renderAll();
});
}
// ─── SSE for real-time events ─────────────
function connectSSE() {
// EventSource 不能带 Authorization header → 先换一次性 ticket
Auth.request('POST', '/api/events/ticket', null, function (res, status) {
if (status !== 201 || !res.ticket) {
setTimeout(connectSSE, 5000);
return;
}
openSSE('/api/events?ticket=' + encodeURIComponent(res.ticket));
});
}
// ─── Local due-check fallback ─────────────
// 多 Gunicorn worker 下,后台 scheduler 与显示端 SSE 连接可能落在不同进程
// (进程内 EventBus 无法跨进程投递),SSE 提醒事件可能丢失。这里每秒本地
// 检测一次,保证闹钟/倒计时/番茄钟到点必定提醒;SSE 正常时由 fireOnce 去重。
state._firedLocal = {};
function fireOnce(key) {
if (state._firedLocal[key]) return false;
state._firedLocal[key] = true;
return true;
}
function localDueCheck() {
var nowSec = Date.now() / 1000;
var todayKey = new Date().toDateString();
// 1) 闹钟:按账户时区匹配 HH:MM + 星期
if (state.timezone) {
try {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: state.timezone, hour: '2-digit', minute: '2-digit',
weekday: 'short', hourCycle: 'h23',
}).formatToParts(new Date());
var hm = '', wd = '';
parts.forEach(function (p) {
if (p.type === 'hour') hm = p.value;
else if (p.type === 'minute') hm += ':' + p.value;
else if (p.type === 'weekday') wd = p.value;
});
var wdNum = { Sun: 7, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }[wd];
(state.alarms || []).forEach(function (a) {
if (!a.enabled || String(a.time) !== hm) return;
var days = String(a.days || '1,2,3,4,5,6,7').split(',');
if (days.indexOf(String(wdNum)) === -1) return;
if (fireOnce('alarm-' + a.id + '-' + todayKey)) {
showAlarmOverlay({ alarms: [a], ts: new Date().toISOString(), source: 'local' });
}
});
} catch (e) {}
}
// 2) 倒计时:end_time(Unix 秒)到期
(state.countdowns || []).forEach(function (cd) {
if (!cd.enabled || !cd.end_time) return;
if (parseFloat(cd.end_time) > nowSec) return;
if (fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''))) {
showAlarmOverlay({ countdowns: [cd], ts: new Date().toISOString(), source: 'local' });
switchPanel('countdown');
}
});
// 3) 番茄钟:running 且 end_time(Unix 秒)到期
var p = state.pomodoro || {};
if (p.running && p.end_time && parseFloat(p.end_time) <= nowSec) {
if (fireOnce('pomo-' + p.end_time)) {
showPomodoroOverlay();
}
}
}
function startLocalDueCheck() {
if (!state._localCheckTimer) {
state._localCheckTimer = setInterval(localDueCheck, 1000);
}
}
function openSSE(url) {
var es = new EventSource(url);
// SSE 与后台 scheduler 可能不在同一 Gunicorn worker(进程内事件总线无法跨
// 进程投递):本地到期检测 + 周期性重拉完整状态作为兜底,保证最终一致。
startLocalDueCheck();
if (!state._stateTimer) {
state._stateTimer = setInterval(fetchState, 15000);
}
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
handleEvent(msg);
} catch (_) {}
};
es.onerror = function () {
es.close();
clearInterval(state._sseGuard);
setTimeout(connectSSE, 5000);
};
// SSE doesn't give us status codes; if auth fails the browser will error-loop.
// We add a small guard: if we get no messages for 30s, try a normal fetch to check auth.
state._sseGuard = setInterval(function () {
Auth.request('GET', '/api/display/state', null, function (data, st) {
if (st === 401) {
es.close();
clearInterval(state._sseGuard);
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}
});
}, 30000);
}
function handleEvent(msg) {
if (msg.type === 'alarm_triggered') {
fetchState();
var todayKey = new Date().toDateString();
var anyNew = (msg.data.alarms || []).some(function (a) {
return fireOnce('alarm-' + a.id + '-' + todayKey);
});
if (anyNew) showAlarmOverlay(msg.data);
} else if (msg.type === 'pomodoro_updated') {
state.pomodoro = msg.data || {};
renderPomodoro();
} else if (msg.type === 'pomodoro_due') {
state.pomodoro = msg.data || {};
renderPomodoro();
showPomodoroOverlay();
} else if (msg.type === 'calendar_updated') {
fetchState();
var card = $('card-calendar');
if (card) card.classList.add('alert');
setTimeout(function () { if (card) card.classList.remove('alert'); }, 6000);
switchPanel('calendar');
} else if (msg.type === 'weather_updated') {
fetchState();
} else if (msg.type === 'reminder_due') {
var card = $('card-calendar');
if (card) card.classList.add('alert');
switchPanel('calendar');
fetchState();
playReminderSound();
// 5秒后自动跳回时间页面并移除高亮
setTimeout(function () {
if (card) card.classList.remove('alert');
switchPanel('time');
}, 5000);
} else if (msg.type === 'custom_notification') {
showCustomNotification(msg.data);
} else if (msg.type === 'countdown_triggered') {
fetchState();
var anyCd = (msg.data.countdowns || []).some(function (cd) {
return fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''));
});
if (anyCd) {
showAlarmOverlay(msg.data); // reuse alarm overlay UI
switchPanel('countdown');
}
} else if (msg.type === 'timezone_changed') {
// 控制端改了账户时区 → 立即重拉状态(state.timezone 更新后时钟/闹钟兜底随之生效)
fetchState();
} else if (msg.type === 'db_changed') {
// 超大事件(NOTIFY 放不下)→ 从数据库重拉完整状态
fetchState();
} else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') {
location.reload();
} else if (msg.type === 'theme_changed') {
// 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画)
if (window.ThemeBridge) ThemeBridge.fetch();
} else if (msg.type === 'kick_all') {
Auth.logout(function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
});
}
}
// ─── Alarm Overlay & Sound ────────────────
function showAlarmOverlay(data) {
if (state.alarmActive) return; // already showing
state.alarmActive = true;
// Build info text (supports both alarm and countdown data).
// 用户可控文本(label/time/target_time)一律转义,防止存储型 XSS。
var info = '';
(data.alarms || []).forEach(function (a) {
info += _esc(a.time) + (a.label ? ' ' + _esc(a.label) : '') + '<br>';
});
(data.countdowns || []).forEach(function (cd) {
info += _esc(cd.target_time) + (cd.label ? ' ' + _esc(cd.label) : '') + '<br>';
});
var overlay = $('alarm-overlay');
var infoEl = $('alarm-overlay-info');
var audio = $('alarm-audio');
if (infoEl) infoEl.innerHTML = info || '闹钟响了';
if (overlay) overlay.classList.remove('hidden');
// Highlight the alarm card
var card = $('card-alarm');
if (card) card.classList.add('alert');
// Switch panel
switchPanel('alarm');
// Try custom ringtone first
var hasCustomRingtone = false;
(data.alarms || []).forEach(function (a) {
if (a.sound && a.sound !== 'default') {
audio.src = '/static/ringtones/' + encodeURIComponent(a.sound);
audio.play().catch(function () { playDefaultAlarm(); });
hasCustomRingtone = true;
}
});
if (!hasCustomRingtone) {
playDefaultAlarm();
}
}
function playDefaultAlarm() {
// Generate alarm sound with Web Audio API
try {
state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (_) {
return;
}
// Create a two-tone alarm beep pattern
var ctx = state.audioCtx;
var beepDuration = 0.15; // seconds per beep
var beepGap = 0.1;
var cycleTime = (beepDuration * 2 + beepGap * 2);
var startTime = ctx.currentTime;
function scheduleBeep(t, freq) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + beepDuration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + beepDuration);
}
function scheduleCycle(cycleStart) {
scheduleBeep(cycleStart, 880); // A5
scheduleBeep(cycleStart + beepDuration + beepGap, 660); // E5
}
// Schedule 60 seconds of alarm (will be stopped on dismiss)
var cycles = Math.floor(60 / cycleTime);
for (var i = 0; i < cycles; i++) {
scheduleCycle(startTime + i * cycleTime);
}
// Re-schedule if still active
state._alarmTimer = setInterval(function () {
if (!state.alarmActive) { clearInterval(state._alarmTimer); return; }
var t = state.audioCtx.currentTime;
for (var i = 0; i < 10; i++) {
scheduleCycle(t + i * cycleTime);
}
}, 5000);
}
function playReminderSound() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
var t = ctx.currentTime;
// 轻柔的三声提示音 (增益 1.0)
[0, 0.3, 0.6].forEach(function (offset) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(1.0, t + offset);
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t + offset);
osc.stop(t + offset + 0.15);
});
setTimeout(function () { ctx.close(); }, 2000);
} catch (_) {}
}
function dismissAlarm() {
state.alarmActive = false;
var overlay = $('alarm-overlay');
var audio = $('alarm-audio');
var card = $('card-alarm');
if (overlay) overlay.classList.add('hidden');
if (card) card.classList.remove('alert');
// Stop audio
if (audio) { audio.pause(); audio.src = ''; }
if (state.audioCtx) {
try { state.audioCtx.close(); } catch (_) {}
state.audioCtx = null;
}
if (state._alarmTimer) {
clearInterval(state._alarmTimer);
state._alarmTimer = null;
}
// Return to time panel
switchPanel('time');
fetchState();
}
// ─── Pomodoro Overlay & Sound ─────────────
function showPomodoroOverlay() {
if (state.pomodoroRinging) return;
state.pomodoroRinging = true;
var overlay = $('pomodoro-overlay');
var titleEl = $('pomodoro-overlay-title');
var infoEl = $('pomodoro-overlay-info');
// dismiss() has already advanced phase, so invert the mapping:
// phase=="work" means break just finished, phase=="break" means work just finished.
var phase = state.pomodoro.phase === 'work' ? '休息结束' : '工作结束';
var nextPhase = state.pomodoro.phase === 'work' ? '进入工作' : '进入休息';
if (titleEl) titleEl.textContent = '番茄钟 - ' + phase;
if (infoEl) infoEl.textContent = nextPhase;
if (overlay) overlay.classList.remove('hidden');
var card = $('card-pomodoro');
if (card) card.classList.add('alert');
switchPanel('pomodoro');
playPomodoroRingtone();
// Auto dismiss after 3 rings (~3 seconds)
state._pomodoroAutoDismiss = setTimeout(function () {
dismissPomodoro();
}, 3500);
}
function playPomodoroRingtone() {
// Play calendar-like reminder sound: three gentle beeps, gain 1.0
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
state._pomodoroAudioCtx = ctx;
var t = ctx.currentTime;
[0, 0.3, 0.6].forEach(function (offset) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(1.0, t + offset);
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.4);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t + offset);
osc.stop(t + offset + 0.4);
});
} catch (_) {}
}
function dismissPomodoro() {
state.pomodoroRinging = false;
var overlay = $('pomodoro-overlay');
var card = $('card-pomodoro');
if (overlay) overlay.classList.add('hidden');
if (card) card.classList.remove('alert');
if (state._pomodoroAudioCtx) {
try { state._pomodoroAudioCtx.close(); } catch (_) {}
state._pomodoroAudioCtx = null;
}
if (state._pomodoroAutoDismiss) {
clearTimeout(state._pomodoroAutoDismiss);
state._pomodoroAutoDismiss = null;
}
// Phase advancement is now handled by the backend when the timer expires.
switchPanel('pomodoro');
}
var pomodoroDismissBtn = $('pomodoro-dismiss');
if (pomodoroDismissBtn) {
pomodoroDismissBtn.addEventListener('click', dismissPomodoro);
}
// ─── Alarm CRUD ───────────────────────────
function apiJson(method, url, data, callback) {
Auth.request(method, url, data, function (res, status) {
if (callback) callback(res, status);
});
}
function deleteAlarm(id) {
apiJson('DELETE', '/api/alarms/' + id, null, function () {
fetchState();
});
}
function _nextAlarmName() {
// Find next available "闹钟 N" name
var used = {};
state.alarms.forEach(function (a) {
var m = (a.label || '').match(/^闹钟 (\d+)$/);
if (m) used[parseInt(m[1], 10)] = true;
});
var n = 1;
while (used[n]) { n++; }
return '闹钟 ' + n;
}
// ─── Alarm Modal (mouse-only time picker) ─
function openAlarmModal(alarm) {
if (state._alarmModalOpen) return;
state._alarmModalOpen = true;
state.editingAlarm = alarm || null;
state._cdModalMode = false;
var modal = $('alarm-modal');
var titleEl = $('alarm-modal-title');
var nameEl = $('alarm-modal-name');
var saveBtn = $('alarm-modal-save');
// Title and name
if (alarm) {
titleEl.textContent = '编辑闹钟';
nameEl.textContent = alarm.label || '';
saveBtn.textContent = '保存修改';
} else {
titleEl.textContent = '添加闹钟';
nameEl.textContent = _nextAlarmName();
saveBtn.textContent = '添加';
}
// Set time
var timeStr = alarm ? alarm.time : '08:00';
var parts = timeStr.split(':');
$('tp-hours').textContent = parts[0] || '08';
$('tp-minutes').textContent = parts[1] || '00';
$('tp-seconds').textContent = '00'; // not used for alarms
// Set days
var days = alarm ? (alarm.days || '1,2,3,4,5,6,7').split(',') : ['1','2','3','4','5','6','7'];
document.querySelectorAll('#alarm-modal-days .day-btn').forEach(function (btn) {
btn.classList.toggle('active', days.indexOf(btn.dataset.day) !== -1);
});
// Set enabled + once
$('alarm-modal-enabled').checked = alarm ? alarm.enabled !== false : true;
$('alarm-modal-once').checked = alarm ? !!alarm.once : false;
_updateAlarmDaysVisibility();
// Show alarm-only fields, hide countdown-only fields
$('alarm-once-field').style.display = '';
$('alarm-days-field').style.display = '';
document.querySelector('#alarm-modal .modal-label[data-cd-label]') &&
(document.querySelector('#alarm-modal .modal-label[data-cd-label]').style.display = 'none');
modal.classList.remove('hidden');
}
function _updateAlarmDaysVisibility() {
var isOnce = $('alarm-modal-once').checked;
$('alarm-days-field').style.display = isOnce ? 'none' : '';
}
function closeAlarmModal() {
state._alarmModalOpen = false;
state.editingAlarm = null;
$('alarm-modal').classList.add('hidden');
}
function saveAlarmFromModal() {
var hours = parseInt($('tp-hours').textContent, 10);
var minutes = parseInt($('tp-minutes').textContent, 10);
if (isNaN(hours) || isNaN(minutes)) return;
var time = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0');
var days = [];
document.querySelectorAll('#alarm-modal-days .day-btn.active').forEach(function (btn) {
days.push(btn.dataset.day);
});
if (days.length === 0) { showAlert('请至少选择一天'); return; }
var enabled = $('alarm-modal-enabled').checked;
var once = $('alarm-modal-once').checked;
// For once-only alarms, days are irrelevant
if (once) days = ['1','2','3','4','5','6','7'];
var editing = state.editingAlarm;
if (editing) {
// Edit: update existing alarm (name NOT changed)
apiJson('PUT', '/api/alarms/' + editing.id, {
time: time,
days: days.join(','),
enabled: enabled,
once: once,
}, function () {
closeAlarmModal();
fetchState();
});
} else {
// Add: create new alarm with auto-generated name
apiJson('POST', '/api/alarms', {
label: _nextAlarmName(),
time: time,
days: days.join(','),
enabled: enabled,
once: once,
}, function () {
closeAlarmModal();
fetchState();
});
}
}
// ─── Countdown (duration-based, independent of alarms) ─
function _parseEndTime(endStr) {
if (!endStr) return null;
try {
// end_time is a Unix timestamp (e.g. "1785285282.447")
return new Date(parseFloat(endStr) * 1000);
} catch (_) { return null; }
}
function renderCountdownCard() {
var now = new Date();
var nearest = null;
var nearestSec = null;
state.countdowns.forEach(function (cd) {
if (!cd.enabled) return;
var endDate = _parseEndTime(cd.end_time);
if (!endDate) return;
var remaining = Math.max(0, Math.floor((endDate - now) / 1000));
if (nearestSec === null || remaining < nearestSec) {
nearest = cd;
nearestSec = remaining;
}
});
var el = $('countdown-next');
if (nearest && nearestSec !== null && nearestSec > 0) {
if (nearestSec < 60) {
el.textContent = nearestSec + '秒';
} else if (nearestSec < 3600) {
el.textContent = Math.floor(nearestSec / 60) + '分';
} else {
el.textContent = Math.floor(nearestSec / 3600) + '时' +
String(Math.floor((nearestSec % 3600) / 60)).padStart(2, '0') + '分';
}
} else {
el.textContent = '无';
}
}
function renderCountdownPanel() {
var list = $('countdown-list');
if (!list) return;
var now = new Date();
var items = [];
state.countdowns.forEach(function (cd) {
if (!cd.enabled) return;
var endDate = _parseEndTime(cd.end_time);
if (!endDate) return;
var remaining = Math.max(0, Math.floor((endDate - now) / 1000));
items.push({ countdown: cd, remainingSec: remaining, endDate: endDate });
});
// Sort by soonest first
items.sort(function (x, y) { return x.remainingSec - y.remainingSec; });
if (items.length === 0) {
list.innerHTML = '<div style="color:var(--text-dim);padding:20px 0;">暂无活跃倒计时</div>';
return;
}
var html = '';
items.forEach(function (item) {
var cd = item.countdown;
var remainingSec = item.remainingSec;
var hours = Math.floor(remainingSec / 3600);
var mins = Math.floor((remainingSec % 3600) / 60);
var secs = remainingSec % 60;
var remainStr;
var overdue = false;
if (remainingSec <= 0) {
remainStr = '已结束';
overdue = true;
} else {
remainStr =
String(hours).padStart(2, '0') + ':' +
String(mins).padStart(2, '0') + ':' +
String(secs).padStart(2, '0');
}
var dur = cd.duration_seconds || 0;
var durStr = String(Math.floor(dur / 3600)).padStart(2, '0') + ':' +
String(Math.floor((dur % 3600) / 60)).padStart(2, '0') + ':' +
String(dur % 60).padStart(2, '0');
html += '<div class="cd-item" data-cd-id="' + cd.id + '">' +
'<div class="cd-info">' +
'<div class="cd-time">' + durStr + '</div>' +
(cd.label ? '<div class="cd-label">' + _esc(cd.label) + '</div>' : '') +
'</div>' +
'<div class="cd-remain' + (overdue ? ' overdue' : '') + '">' + remainStr + '</div>' +
'<div class="alarm-actions">' +
'<button class="alarm-action-btn edit-btn" data-action="edit-cd" data-id="' + cd.id + '">编辑</button>' +
'<button class="alarm-action-btn del-btn" data-action="delete-cd" data-id="' + cd.id + '">删除</button>' +
'</div></div>';
});
list.innerHTML = html;
// Bind edit buttons
list.querySelectorAll('[data-action="edit-cd"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var id = parseInt(btn.dataset.id, 10);
var cd = null;
for (var i = 0; i < state.countdowns.length; i++) {
if (state.countdowns[i].id === id) { cd = state.countdowns[i]; break; }
}
if (cd) openCountdownModal(cd);
});
});
// Bind delete buttons (two-step inline confirmation)
list.querySelectorAll('[data-action="delete-cd"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (btn.dataset.confirm === 'yes') {
deleteCountdown(parseInt(btn.dataset.id, 10));
} else {
btn.dataset.confirm = 'yes';
btn.textContent = '确认删除?';
btn.style.background = '#E05555';
btn.style.color = '#fff';
setTimeout(function () {
btn.dataset.confirm = '';
btn.textContent = '删除';
btn.style.background = '';
btn.style.color = '';
}, 3000);
}
});
});
}
// ─── Countdown CRUD (via /api/countdowns) ─
function deleteCountdown(id) {
apiJson('DELETE', '/api/countdowns/' + id, null, function () {
fetchState();
});
}
function _nextCountdownName() {
var used = {};
state.countdowns.forEach(function (cd) {
var m = (cd.label || '').match(/^倒计时 (\d+)$/);
if (m) used[parseInt(m[1], 10)] = true;
});
var n = 1;
while (used[n]) { n++; }
return '倒计时 ' + n;
}
// ─── Countdown Modal (duration-based, no repeat) ─
function openCountdownModal(cd) {
if (state._alarmModalOpen) return;
state._alarmModalOpen = true;
state._cdModalMode = true;
state._editingCountdown = cd || null;
var modal = $('alarm-modal');
var titleEl = $('alarm-modal-title');
var nameEl = $('alarm-modal-name');
var saveBtn = $('alarm-modal-save');
if (cd) {
titleEl.textContent = '编辑倒计时';
nameEl.textContent = cd.label || '';
saveBtn.textContent = '保存修改';
// Parse existing duration_seconds into hours:minutes:seconds
var totalSec = cd.duration_seconds || 0;
var h = Math.floor(totalSec / 3600);
var m = Math.floor((totalSec % 3600) / 60);
var s = totalSec % 60;
$('tp-hours').textContent = String(h).padStart(2, '0');
$('tp-minutes').textContent = String(m).padStart(2, '0');
$('tp-seconds').textContent = String(s).padStart(2, '0');
} else {
titleEl.textContent = '添加倒计时';
nameEl.textContent = _nextCountdownName();
saveBtn.textContent = '添加';
$('tp-hours').textContent = '00';
$('tp-minutes').textContent = '05';
$('tp-seconds').textContent = '00';
}
$('alarm-modal-enabled').checked = cd ? cd.enabled !== false : true;
// Hide alarm-only fields (once toggle + days picker)
$('alarm-once-field').style.display = 'none';
$('alarm-days-field').style.display = 'none';
// Show duration label
var labelEl = document.querySelector('#alarm-modal .time-picker').previousElementSibling;
if (labelEl) labelEl.textContent = '时长';
modal.classList.remove('hidden');
}
function closeCountdownModal() {
state._alarmModalOpen = false;
state._cdModalMode = false;
state._editingCountdown = null;
$('alarm-modal').classList.add('hidden');
}
function saveCountdownFromModal() {
var hours = parseInt($('tp-hours').textContent, 10);
var minutes = parseInt($('tp-minutes').textContent, 10);
var seconds = parseInt($('tp-seconds').textContent, 10);
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return;
var totalSec = hours * 3600 + minutes * 60 + seconds;
if (totalSec <= 0) { showAlert('时长必须大于 0'); return; }
var enabled = $('alarm-modal-enabled').checked;
var editing = state._editingCountdown;
if (editing) {
apiJson('PUT', '/api/countdowns/' + editing.id, {
duration_seconds: totalSec,
enabled: enabled,
}, function () {
closeCountdownModal();
fetchState();
});
} else {
apiJson('POST', '/api/countdowns', {
label: _nextCountdownName(),
duration_seconds: totalSec,
enabled: enabled,
}, function () {
closeCountdownModal();
fetchState();
});
}
}
// Custom notification overlay
function showCustomNotification(data) {
if (state.customNotifyActive) return;
state.customNotifyActive = true;
var overlay = $('custom-notify-overlay');
var iconEl = $('custom-notify-icon');
var titleEl = $('custom-notify-title');
var infoEl = $('custom-notify-info');
if (iconEl) iconEl.innerHTML = data.icon || '&#128276;';
if (titleEl) titleEl.textContent = data.title || '提醒';
if (infoEl) infoEl.textContent = data.message || '';
if (overlay) overlay.classList.remove('hidden');
switchPanel('time');
if (data.sound !== false) {
playReminderSound();
}
state._customNotifyAutoDismiss = setTimeout(function () {
dismissCustomNotification();
}, 5000);
}
function dismissCustomNotification() {
state.customNotifyActive = false;
var overlay = $('custom-notify-overlay');
if (overlay) overlay.classList.add('hidden');
if (state._customNotifyAutoDismiss) {
clearTimeout(state._customNotifyAutoDismiss);
state._customNotifyAutoDismiss = null;
}
}
var customNotifyDismissBtn = $('custom-notify-dismiss');
if (customNotifyDismissBtn) {
customNotifyDismissBtn.addEventListener('click', dismissCustomNotification);
}
// Dismiss button
var dismissBtn = $('alarm-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', dismissAlarm);
}
// Refresh button
var refreshBtn = $('refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', function () {
location.reload();
});
}
// Lock button — log out current display session
var lockBtn = $('lock-btn');
if (lockBtn) {
lockBtn.addEventListener('click', function () {
Auth.logout(function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
});
});
}
// Fullscreen button (enter only, hides when already fullscreen)
var fullscreenBtn = $('fullscreen-btn');
if (fullscreenBtn) {
function updateFsBtn() {
fullscreenBtn.style.display = document.fullscreenElement ? 'none' : '';
}
document.addEventListener('fullscreenchange', updateFsBtn);
fullscreenBtn.addEventListener('click', function () {
document.documentElement.requestFullscreen();
});
}
// ─── Alarm modal wiring ───────────────────
var alarmAddBtn = $('alarm-add-btn');
if (alarmAddBtn) {
alarmAddBtn.addEventListener('click', function () { openAlarmModal(null); });
}
var countdownAddBtn = $('countdown-add-btn');
if (countdownAddBtn) {
countdownAddBtn.addEventListener('click', function () { openCountdownModal(null); });
}
// Modal save/cancel — route to alarm or countdown based on _cdModalMode
var modalSave = $('alarm-modal-save');
if (modalSave) {
modalSave.addEventListener('click', function () {
if (state._cdModalMode) {
saveCountdownFromModal();
} else {
saveAlarmFromModal();
}
});
}
var modalCancel = $('alarm-modal-cancel');
if (modalCancel) {
modalCancel.addEventListener('click', function () {
if (state._cdModalMode) {
closeCountdownModal();
} else {
closeAlarmModal();
}
});
}
// Time picker buttons (mouse-only)
function setupTimePicker() {
// Up/down for hours
document.querySelectorAll('.tp-up, .tp-down').forEach(function (btn) {
btn.addEventListener('click', function () {
var target = btn.dataset.target; // 'hours' or 'minutes'
var isUp = btn.classList.contains('tp-up');
var el = $('tp-' + target);
var val = parseInt(el.textContent, 10);
if (target === 'hours') {
val = isUp ? (val + 1) % 24 : (val + 23) % 24;
} else {
val = isUp ? (val + 1) % 60 : (val + 59) % 60;
}
el.textContent = String(val).padStart(2, '0');
});
});
// Hold-to-repeat for up/down buttons
document.querySelectorAll('.tp-btn').forEach(function (btn) {
var timer = null;
btn.addEventListener('mousedown', function () {
if (timer) clearInterval(timer);
timer = setInterval(function () {
btn.click();
}, 180);
});
btn.addEventListener('mouseup', function () { if (timer) { clearInterval(timer); timer = null; } });
btn.addEventListener('mouseleave', function () { if (timer) { clearInterval(timer); timer = null; } });
});
}
setupTimePicker();
// Day picker toggle
document.querySelectorAll('#alarm-modal-days .day-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
btn.classList.toggle('active');
});
});
// Once-only toggle — hide day picker
var onceCb = $('alarm-modal-once');
if (onceCb) {
onceCb.addEventListener('change', _updateAlarmDaysVisibility);
}
// ─── Pomodoro controls (reuse /control endpoints) ─────────────
function pomodoroApi(action) {
Auth.request('POST', '/api/pomodoro/' + action, null, function () {
fetchState();
});
}
function fetchPomodoro() {
Auth.request('GET', '/api/pomodoro', null, function (data, status) {
if (status === 200) {
state.pomodoro = data || {};
renderPomodoro();
renderPomodoroCard();
}
});
}
// Card pomodoro buttons (in sidebar card, not the panel)
var cardBtns = $('pomo-sidebar-btns');
if (cardBtns) {
['start', 'pause', 'skip', 'stop'].forEach(function (action) {
var btn = $('pomo-card-' + action);
if (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
pomodoroApi(action);
});
}
});
}
// ─── Rendering ────────────────────────────
function renderAll() {
renderTimeCard();
renderWeatherCard();
renderCalendarCard();
renderAlarmCard();
renderCountdownCard();
renderPomodoroCard();
renderTimePanel();
renderWeatherPanel();
renderCalendarPanel();
renderAlarmPanel();
renderCountdownPanel();
renderPomodoro();
}
function renderTimeCard() {
var n = _tzNow();
$('time-value').textContent = _pad2(n.h) + ':' + _pad2(n.mi);
}
function renderWeatherCard() {
var w = state.weather || {};
if (w.temp !== undefined) {
$('weather-temp').textContent = w.temp + '°';
}
var iconEl = $('weather-card-icon');
if (w.icon && iconEl) {
iconEl.src = '/static/icons/' + w.icon + '.svg';
iconEl.style.display = '';
iconEl.alt = w.description || '';
} else if (iconEl) {
iconEl.style.display = 'none';
}
}
function renderCalendarCard() {
var nowStr = _nowLocalKey();
var upcoming = null;
for (var i = 0; i < state.events.length; i++) {
var ev = state.events[i];
var dtend = ev.dtend || ev.dtstart;
if (dtend >= nowStr) {
upcoming = ev;
break;
}
}
if (upcoming) {
var text = upcoming.summary || '日程';
var startTime = upcoming.is_all_day ? '全天' : formatTime(upcoming.dtstart);
var endTime = upcoming.is_all_day ? '' : '-' + formatTime(upcoming.dtend || upcoming.dtstart);
$('calendar-next').textContent = text + ' ' + startTime + endTime;
} else {
$('calendar-next').textContent = '无日程';
}
}
function formatTime(dtStr) {
if (!dtStr || dtStr.length < 13) return '';
return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13);
}
function renderAlarmCard() {
var n = _tzNow();
var currentMinutes = n.h * 60 + n.mi;
var todayWeekday = String(n.wd === 0 ? 7 : n.wd);
var upcoming = null;
var upcomingDiff = null;
state.alarms.forEach(function (a) {
if (!a.enabled) return;
var days = (a.days || '').split(',');
if (days.indexOf(todayWeekday) === -1) return;
var parts = a.time.split(':');
var alarmMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
var diff = alarmMinutes - currentMinutes;
if (diff > 0) {
if (upcomingDiff === null || diff < upcomingDiff) {
upcoming = a;
upcomingDiff = diff;
}
}
});
// If no upcoming alarm today, look for the earliest alarm on future days
if (!upcoming) {
state.alarms.forEach(function (a) {
if (!a.enabled) return;
var days = (a.days || '').split(',');
var parts = a.time.split(':');
var alarmMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
var minDayDiff = null;
days.forEach(function (d) {
var dayNum = parseInt(d, 10);
var todayNum = parseInt(todayWeekday, 10);
var dayDiff = dayNum - todayNum;
if (dayDiff <= 0) dayDiff += 7;
if (minDayDiff === null || dayDiff < minDayDiff) {
minDayDiff = dayDiff;
}
});
if (minDayDiff !== null) {
var totalDiff = (minDayDiff * 24 * 60) + alarmMinutes - currentMinutes;
if (upcomingDiff === null || totalDiff < upcomingDiff) {
upcoming = a;
upcomingDiff = totalDiff;
}
}
});
}
if (upcoming) {
$('alarm-next').textContent = upcoming.time;
} else {
$('alarm-next').textContent = '无闹钟';
}
}
function renderPomodoroCard() {
var p = state.pomodoro || {};
var el = $('pomodoro-value');
var btnStart = $('pomo-card-start');
var btnPause = $('pomo-card-pause');
var btnSkip = $('pomo-card-skip');
var btnStop = $('pomo-card-stop');
if (!el || !btnStart) return;
// Time display
if (!p.enabled) {
el.textContent = '未启动';
} else {
var remaining = _calcPomodoroRemaining(p);
el.textContent = String(Math.floor(remaining / 60)).padStart(2, '0') + ':' +
String(remaining % 60).padStart(2, '0');
}
// Button visibility
var isPomodoroPanel = state.activePanel === 'pomodoro';
var showTime = !isPomodoroPanel;
var showBtns = isPomodoroPanel;
el.style.display = showTime ? '' : 'none';
if (btnStart) btnStart.style.display = 'none';
if (btnPause) btnPause.style.display = 'none';
if (btnSkip) btnSkip.style.display = 'none';
if (btnStop) btnStop.style.display = 'none';
if (showBtns && p.enabled) {
if (p.running) {
// Running → 只显示暂停
if (btnPause) btnPause.style.display = '';
} else {
// Paused → 显示继续/跳过/停止
if (btnStart) { btnStart.textContent = '继续'; btnStart.style.display = ''; }
if (btnSkip) btnSkip.style.display = '';
if (btnStop) btnStop.style.display = '';
}
} else if (showBtns && !p.enabled) {
// Stopped → 只显示开始
if (btnStart) { btnStart.textContent = '开始'; btnStart.style.display = ''; }
}
// When NOT on pomodoro panel: show nothing for buttons (already hidden above)
}
function _calcPomodoroRemaining(p) {
if (!p || !p.enabled) return 0;
if (!p.running) return p.remaining_seconds || 0;
var end = p.end_time;
if (!end) return p.remaining_seconds || 0;
try {
// end_time is a Unix timestamp string (e.g. "1785285282.447")
var remaining = Math.floor(parseFloat(end) - Date.now() / 1000);
return Math.max(0, remaining);
} catch (_) {
return p.remaining_seconds || 0;
}
}
function renderPomodoro() {
var p = state.pomodoro || {};
var phaseEl = $('pomodoro-phase');
var timerEl = $('pomodoro-timer');
var cyclesEl = $('pomodoro-cycles');
var nextBreakEl = $('pomodoro-next-break');
if (!phaseEl || !timerEl || !cyclesEl) return;
if (!p.enabled) {
phaseEl.textContent = '未启动';
timerEl.textContent = '--:--';
cyclesEl.textContent = '';
if (nextBreakEl) nextBreakEl.textContent = '';
return;
}
phaseEl.textContent = p.phase === 'work' ? (p.work_label || '工作中') : (p.break_label || '休息中');
// Show/hide card buttons based on display_controls_enabled
var cardBtns = $('pomo-sidebar-btns');
if (cardBtns) {
cardBtns.style.display = (p.display_controls_enabled !== false) ? '' : 'none';
}
var remaining = _calcPomodoroRemaining(p);
var m = String(Math.floor(remaining / 60)).padStart(2, '0');
var s = String(remaining % 60).padStart(2, '0');
timerEl.textContent = m + ':' + s;
cyclesEl.textContent = '已完成 ' + (p.cycles || 0) + ' 组';
if (nextBreakEl) {
nextBreakEl.textContent = '下次: ' + (p.next_break_type === 'long' ? '长休息' : '短休息');
}
}
function renderTimePanel() {
var n = _tzNow();
$('detailed-time').textContent =
_pad2(n.h) + ':' + _pad2(n.mi) + ':' + _pad2(n.s);
var days = ['日', '一', '二', '三', '四', '五', '六'];
$('date-str').textContent =
n.y + '年' + n.mo + '月' + n.d + '日 星期' + days[n.wd];
}
function renderWeatherPanel() {
var w = state.weather || {};
if (w.temp === undefined) {
$('weather-temp-big').textContent = '--°';
$('weather-desc').textContent = w.error || '无数据';
$('weather-details').innerHTML = '';
return;
}
$('weather-temp-big').textContent = w.temp + '°C';
$('weather-desc').textContent = w.description || '';
var iconEl = $('weather-icon');
if (w.icon && iconEl) {
iconEl.src = '/static/icons/' + w.icon + '.svg';
iconEl.style.display = 'inline';
iconEl.alt = w.description || '';
} else if (iconEl) {
iconEl.style.display = 'none';
}
var details = '';
if (w.feels_like !== undefined) details += '<div class="weather-detail"><div class="label">体感</div><div>' + w.feels_like + '°C</div></div>';
if (w.humidity !== undefined) details += '<div class="weather-detail"><div class="label">湿度</div><div>' + w.humidity + '%</div></div>';
if (w.wind_speed !== undefined) details += '<div class="weather-detail"><div class="label">风速</div><div>' + w.wind_speed + ' m/s</div></div>';
if (w.pressure !== undefined) details += '<div class="weather-detail"><div class="label">气压</div><div>' + w.pressure + ' hPa</div></div>';
if (w.wind_dir) details += '<div class="weather-detail"><div class="label">风向</div><div>' + w.wind_dir + (w.wind_scale ? ' ' + w.wind_scale + '级' : '') + '</div></div>';
if (w.precip !== undefined && w.precip != '0') details += '<div class="weather-detail"><div class="label">降水量</div><div>' + w.precip + ' mm</div></div>';
if (w.visibility) details += '<div class="weather-detail"><div class="label">能见度</div><div>' + w.visibility + ' km</div></div>';
if (w.dew) details += '<div class="weather-detail"><div class="label">露点温度</div><div>' + w.dew + '°C</div></div>';
$('weather-details').innerHTML = details;
// Render hourly forecast
_renderHourly();
// Render minutely + warnings
_renderMinutely();
_renderWarnings();
// Render update schedule
_renderWeatherUpdateInfo();
}
function _renderHourly() {
var list = $('hourly-list');
if (!list) return;
var hourly = state.hourly || [];
if (hourly.length === 0) {
list.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">暂无逐小时数据</div>';
return;
}
var html = '';
var n = _tzNow();
var nowHour = n.h;
var nowMin = n.mi;
hourly.forEach(function (h) {
var hh = parseInt(h.time.split(':')[0], 10);
var hm = parseInt(h.time.split(':')[1], 10);
var isNow = (hh === nowHour && hm <= nowMin + 5);
var dayLabel = h.day || '';
html += '<div class="hc-item' + (isNow ? ' hc-now' : '') + '">' +
'<div class="hc-time">' + (dayLabel === '明日' ? '<span class="tomorrow">明</span>' : '') + h.time + '</div>' +
'<div class="hc-icon">' + _qwIcon(h.icon) + '</div>' +
'<div class="hc-temp">' + h.temp + '°</div>' +
'<div class="hc-pop">' + (parseInt(h.pop, 10) > 0 ? h.pop + '%' : '') + '</div>' +
'</div>';
});
list.innerHTML = html;
}
function _qwIcon(code) {
if (code) return '<img src="/static/icons/' + code + '.svg" style="width:100%;height:100%;object-fit:contain;" alt="">';
return '';
}
function _renderMinutely() {
var section = $('minutely-section');
var content = $('minutely-content');
if (!section || !content) return;
// Always show the section (title + refresh button visible)
section.style.display = '';
var m = state.minutely;
if (!m) {
content.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">点击「刷新」获取分钟级降水预报</div>';
return;
}
if (m.error) {
if (m.cooldown) {
content.innerHTML = '<div style="color:var(--accent-red);font-size:13px;padding:8px 0;">' + _esc(m.error) + '</div>';
} else {
content.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">' + _esc(m.error) + '</div>';
}
return;
}
var list = m.minutely || [];
var summary = m.summary || '';
var html = '';
if (summary) html += '<div style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">' + _esc(summary) + '</div>';
if (list.length > 0) {
html += '<div class="minutely-bars">';
list.forEach(function (item) {
var pct = Math.min(parseFloat(item.precip) || 0, 5) / 5 * 100;
html += '<div class="mb-col"><div class="mb-bar" style="height:' + pct + '%"></div><div class="mb-time">' + (item.fxTime ? item.fxTime.substring(11, 16) : '') + '</div></div>';
});
html += '</div>';
}
content.innerHTML = html;
}
function _renderWarnings() {
var section = $('warning-section');
var content = $('warning-content');
if (!section || !content) return;
// Always show the section (title + refresh button visible)
section.style.display = '';
var w = state.warnings;
if (!w) {
content.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">点击「刷新」获取天气预警</div>';
return;
}
var html = '';
if (w.unavailable) {
html = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">天气预警为付费功能,当前订阅不可用</div>';
content.innerHTML = html;
return;
}
if (w.error && !w.cooldown) {
html = '<div style="color:var(--accent-red);font-size:13px;padding:8px 0;">' + w.error + '</div>';
content.innerHTML = html;
return;
}
if (w.zero_result || !w.warnings || w.warnings.length === 0) {
html = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">暂无预警</div>';
content.innerHTML = html;
return;
}
// Per QWeather docs: do NOT enumerate severity/type values — just output
// raw alert data as-is, since these values can change without notice.
w.warnings.forEach(function (item) {
var fields = '';
for (var key in item) {
if (item.hasOwnProperty(key) && item[key]) {
fields += '<div style="font-size:12px;color:var(--text-dim);margin-top:2px;"><strong>' + _esc(key) + ':</strong> ' + _esc(item[key]) + '</div>';
}
}
html += '<div class="warning-card">' +
'<div class="warning-title">' + _esc(item.title || item.type || '预警') + '</div>' +
fields +
'</div>';
});
content.innerHTML = html;
}
function _renderWeatherUpdateInfo() {
var s = state.update_schedule || {};
// 后端返回的时间字符串均为账户时区本地时间,直接截取显示(勿再按 UTC 解析)
var fmt = function (d) {
if (!d) return '--';
return String(d).replace('T', ' ').substring(0, 16);
};
var html = '<div class="update-info">' +
'<div><span class="label">实况更新:</span> ' + fmt(s.weather_updated_at) +
' <span class="label">下次:</span> ' + fmt(s.weather_next_update) + '</div>' +
'<div><span class="label">逐时下次:</span> ' + fmt(s.hourly_next_update) + '</div>' +
'</div>';
// Append after hourly list
var list = $('hourly-list');
if (list) {
var existing = list.parentNode.querySelector('.update-info');
if (existing) existing.remove();
list.insertAdjacentHTML('afterend', html);
}
}
// ─── State update from fetch ──────────────
function renderCalendarPanel() {
var list = $('event-list');
if (state.events.length === 0) {
list.innerHTML = '<div style="color:var(--text-dim);padding:20px 0;">暂无日程</div>';
return;
}
var groups = {};
state.events.forEach(function (ev) {
var dk = ev.dtstart ? ev.dtstart.substring(0, 8) : 'unknown';
if (!groups[dk]) groups[dk] = [];
groups[dk].push(ev);
});
var n = _tzNow();
var todayKey = String(n.y) + _pad2(n.mo) + _pad2(n.d);
// 明天:基于账户时区的年月日 +1 天(跨月/跨年交给 Date 处理)
var t = new Date();
t.setFullYear(n.y, n.mo - 1, n.d + 1);
var tomorrowKey = String(t.getFullYear()) + _pad2(t.getMonth() + 1) + _pad2(t.getDate());
var html = '';
Object.keys(groups).sort().forEach(function (dk) {
var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' :
dk.substring(0, 4) + '/' + dk.substring(4, 6) + '/' + dk.substring(6, 8);
html += '<div style="font-size:12px;color:var(--text-dim);margin-top:10px;">' + label + '</div>';
groups[dk].forEach(function (ev) {
var startTime = ev.is_all_day ? '全天' : (ev.dtstart ? ev.dtstart.substring(9, 11) + ':' + ev.dtstart.substring(11, 13) : '--:--');
var endTime = ev.is_all_day ? '' : (ev.dtend ? ' - ' + ev.dtend.substring(9, 11) + ':' + ev.dtend.substring(11, 13) : '');
var timeStr = startTime + endTime;
var color = '#4A90D9';
if (state.groups) {
var g = state.groups.find(function (x) { return x.id === ev.group_id; });
if (g) color = g.color || color;
}
html += '<div class="event-item"><div class="event-dot" style="background:' + color + '"></div>' +
'<div class="event-time">' + timeStr + '</div>' +
'<div><div class="event-summary">' + _esc(ev.summary || '无标题') + '</div>' +
(ev.location ? '<div class="event-sub">' + _esc(ev.location) + '</div>' : '') + '</div></div>';
});
});
list.innerHTML = html;
}
function renderAlarmPanel() {
var list = $('alarm-list');
var dayNames = ['', '一', '二', '三', '四', '五', '六', '日'];
if (state.alarms.length === 0) {
list.innerHTML = '<div style="color:var(--text-dim);padding:20px 0;">暂无闹钟</div>';
return;
}
var html = '';
state.alarms.forEach(function (a) {
var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' ');
html += '<div class="alarm-item" data-alarm-id="' + a.id + '">' +
'<div class="alarm-info">' +
'<div class="alarm-time">' + _esc(a.time) + '</div>' +
(a.label ? '<div class="alarm-label">' + _esc(a.label) + '</div>' : '') +
'<div class="alarm-days">' + _esc(days) + '</div></div>' +
'<div class="alarm-actions">' +
'<button class="alarm-action-btn edit-btn" data-action="edit-alarm" data-id="' + a.id + '">编辑</button>' +
'<button class="alarm-action-btn del-btn" data-action="delete-alarm" data-id="' + a.id + '">删除</button>' +
'</div></div>';
});
list.innerHTML = html;
// Bind edit buttons
list.querySelectorAll('[data-action="edit-alarm"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var id = parseInt(btn.dataset.id, 10);
var alarm = null;
for (var i = 0; i < state.alarms.length; i++) {
if (state.alarms[i].id === id) { alarm = state.alarms[i]; break; }
}
if (alarm) openAlarmModal(alarm);
});
});
// Bind delete buttons (two-step inline confirmation)
list.querySelectorAll('[data-action="delete-alarm"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (btn.dataset.confirm === 'yes') {
deleteAlarm(parseInt(btn.dataset.id, 10));
} else {
btn.dataset.confirm = 'yes';
btn.textContent = '确认删除?';
btn.style.background = '#E05555';
btn.style.color = '#fff';
var _reset = function () { setTimeout(function () {
btn.dataset.confirm = '';
btn.textContent = '删除';
btn.style.background = '';
btn.style.color = '';
}, 3000); };
_reset();
}
});
});
}
// ─── Simple toast popup ──────────────────
function showToast(msg, duration) {
duration = duration || 2500;
var el = document.createElement('div');
el.className = 'display-toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, duration);
}
// ─── Minutely / Warning refresh buttons ───
$('minutely-refresh-btn').addEventListener('click', function () {
apiJson('POST', '/api/weather/minutely', null, function (data) {
if (data.error) {
var remaining = data.cooldown;
var msg = data.error;
if (remaining) msg += '(' + remaining + '秒)';
showToast(msg);
} else {
state.minutely = data;
_renderMinutely();
}
});
});
$('warning-refresh-btn').addEventListener('click', function () {
apiJson('POST', '/api/weather/warnings', null, function (data) {
if (data.error) {
var remaining = data.cooldown;
var msg = data.error;
if (remaining) msg += '(' + remaining + '秒)';
showToast(msg);
} else {
state.warnings = data;
_renderWarnings();
}
});
});
// ─── Clock tick ───────────────────────────
setInterval(function () {
renderTimeCard();
renderCountdownCard();
renderPomodoroCard();
if (state.activePanel === 'time') renderTimePanel();
if (state.activePanel === 'pomodoro') renderPomodoro();
if (state.activePanel === 'countdown') renderCountdownPanel();
// Refresh update schedule info every 10s to keep next-update times current
if ((state._tickCount || 0) % 10 === 0 && state.activePanel === 'weather') {
_renderWeatherUpdateInfo();
}
// When weather panel is open, check if hourly needs refresh (past :01, stale)
if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') {
var min = _tzNow().mi;
var s = state.update_schedule || {};
// If it's past :05 and the hourly was likely last fetched before this hour
if (min > 5) {
apiJson('GET', '/api/weather/hourly', null, function (data) {
if (data && data.length > 0) {
state.hourly = data;
_renderHourly();
}
});
}
}
state._tickCount = (state._tickCount || 0) + 1;
}, 1000);
// ─── Init ─────────────────────────────────
fetchState();
connectSSE();
switchPanel('time');
})();