1647 lines
58 KiB
JavaScript
1647 lines
58 KiB
JavaScript
/* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// ─── 账户时区时间 ─────────────────────────
|
||
// 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断
|
||
// 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。
|
||
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 === '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 || '🔔';
|
||
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');
|
||
|
||
})();
|