Flask Session -> Double Token
This commit is contained in:
+115
-154
@@ -35,6 +35,7 @@
|
||||
update_schedule: {},
|
||||
_cdModalMode: false, // true = countdown modal, false = alarm modal
|
||||
_editingCountdown: null,
|
||||
timezone: 'Asia/Shanghai', // 账户独立时区(来自 /api/display/state)
|
||||
};
|
||||
|
||||
// ─── DOM refs ─────────────────────────────
|
||||
@@ -48,6 +49,48 @@
|
||||
.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
|
||||
@@ -74,36 +117,36 @@
|
||||
|
||||
// ─── Data fetching ────────────────────────
|
||||
function fetchState() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/display/state');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 401) {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (res.login_required) {
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
state.weather = data.weather || {};
|
||||
state.hourly = data.hourly || [];
|
||||
state.events = data.events || [];
|
||||
state.alarms = data.alarms || [];
|
||||
state.countdowns = data.countdowns || [];
|
||||
state.update_schedule = data.update_schedule || {};
|
||||
state.minutely = data.minutely || null;
|
||||
state.warnings = data.warnings || null;
|
||||
state.groups = data.groups || [];
|
||||
renderAll();
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
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.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() {
|
||||
var es = new EventSource('/api/events');
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
function openSSE(url) {
|
||||
var es = new EventSource(url);
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
@@ -112,24 +155,19 @@
|
||||
};
|
||||
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 () {
|
||||
var g = new XMLHttpRequest();
|
||||
g.open('GET', '/api/display/state');
|
||||
g.onload = function () {
|
||||
if (g.status === 401) {
|
||||
var res = JSON.parse(g.responseText || '{}');
|
||||
if (res.login_required) {
|
||||
es.close();
|
||||
clearInterval(state._sseGuard);
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
g.send();
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
@@ -175,12 +213,9 @@
|
||||
// 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画)
|
||||
if (window.ThemeBridge) ThemeBridge.fetch();
|
||||
} else if (msg.type === 'kick_all') {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/logout');
|
||||
xhr.onload = function () {
|
||||
Auth.logout(function () {
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,20 +436,9 @@
|
||||
|
||||
// ─── Alarm CRUD ───────────────────────────
|
||||
function apiJson(method, url, data, callback) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
if (data) { xhr.setRequestHeader('Content-Type', 'application/json'); }
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 401) {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (res.login_required) {
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (callback) callback(JSON.parse(xhr.responseText || '{}'));
|
||||
};
|
||||
xhr.send(data ? JSON.stringify(data) : null);
|
||||
Auth.request(method, url, data, function (res, status) {
|
||||
if (callback) callback(res, status);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAlarm(id) {
|
||||
@@ -841,12 +865,9 @@
|
||||
var lockBtn = $('lock-btn');
|
||||
if (lockBtn) {
|
||||
lockBtn.addEventListener('click', function () {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/logout');
|
||||
xhr.onload = function () {
|
||||
Auth.logout(function () {
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -943,34 +964,19 @@
|
||||
|
||||
// ─── Pomodoro controls (reuse /control endpoints) ─────────────
|
||||
function pomodoroApi(action) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/pomodoro/' + action);
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 401) {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (res.login_required) {
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Auth.request('POST', '/api/pomodoro/' + action, null, function () {
|
||||
fetchState();
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function fetchPomodoro() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/pomodoro');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
state.pomodoro = JSON.parse(xhr.responseText);
|
||||
renderPomodoro();
|
||||
renderPomodoroCard();
|
||||
} catch (_) {}
|
||||
Auth.request('GET', '/api/pomodoro', null, function (data, status) {
|
||||
if (status === 200) {
|
||||
state.pomodoro = data || {};
|
||||
renderPomodoro();
|
||||
renderPomodoroCard();
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
// Card pomodoro buttons (in sidebar card, not the panel)
|
||||
@@ -1004,10 +1010,8 @@
|
||||
}
|
||||
|
||||
function renderTimeCard() {
|
||||
var now = new Date();
|
||||
var h = String(now.getHours()).padStart(2, '0');
|
||||
var m = String(now.getMinutes()).padStart(2, '0');
|
||||
$('time-value').textContent = h + ':' + m;
|
||||
var n = _tzNow();
|
||||
$('time-value').textContent = _pad2(n.h) + ':' + _pad2(n.mi);
|
||||
}
|
||||
|
||||
function renderWeatherCard() {
|
||||
@@ -1026,8 +1030,7 @@
|
||||
}
|
||||
|
||||
function renderCalendarCard() {
|
||||
var now = new Date();
|
||||
var nowStr = formatDateTimeLocal(now);
|
||||
var nowStr = _nowLocalKey();
|
||||
var upcoming = null;
|
||||
for (var i = 0; i < state.events.length; i++) {
|
||||
var ev = state.events[i];
|
||||
@@ -1052,19 +1055,10 @@
|
||||
return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13);
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(d) {
|
||||
return d.getFullYear() +
|
||||
String(d.getMonth() + 1).padStart(2, '0') +
|
||||
String(d.getDate()).padStart(2, '0') + 'T' +
|
||||
String(d.getHours()).padStart(2, '0') +
|
||||
String(d.getMinutes()).padStart(2, '0') +
|
||||
String(d.getSeconds()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function renderAlarmCard() {
|
||||
var now = new Date();
|
||||
var currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
var todayWeekday = String(now.getDay() === 0 ? 7 : now.getDay());
|
||||
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;
|
||||
|
||||
@@ -1208,15 +1202,12 @@
|
||||
}
|
||||
|
||||
function renderTimePanel() {
|
||||
var now = new Date();
|
||||
var n = _tzNow();
|
||||
$('detailed-time').textContent =
|
||||
String(now.getHours()).padStart(2, '0') + ':' +
|
||||
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(now.getSeconds()).padStart(2, '0');
|
||||
_pad2(n.h) + ':' + _pad2(n.mi) + ':' + _pad2(n.s);
|
||||
var days = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
$('date-str').textContent =
|
||||
now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日 星期' + days[now.getDay()];
|
||||
|
||||
n.y + '年' + n.mo + '月' + n.d + '日 星期' + days[n.wd];
|
||||
}
|
||||
|
||||
function renderWeatherPanel() {
|
||||
@@ -1268,8 +1259,9 @@
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
var nowHour = new Date().getHours();
|
||||
var nowMin = new Date().getMinutes();
|
||||
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);
|
||||
@@ -1376,49 +1368,16 @@
|
||||
|
||||
function _renderWeatherUpdateInfo() {
|
||||
var s = state.update_schedule || {};
|
||||
var wUpdated = s.weather_updated_at || '';
|
||||
|
||||
// Compute next update times on the frontend from current local time
|
||||
var now = new Date();
|
||||
var m = now.getMinutes();
|
||||
|
||||
// Next weather: next :00 or :30
|
||||
var nextW = new Date(now);
|
||||
if (m < 30) { nextW.setMinutes(30, 0, 0); }
|
||||
else { nextW.setHours(now.getHours() + 1, 0, 0, 0); }
|
||||
|
||||
// Next hourly: next :01
|
||||
var nextH = new Date(now);
|
||||
if (m < 1) { nextH.setMinutes(1, 0, 0); }
|
||||
else { nextH.setHours(now.getHours() + 1, 1, 0, 0); }
|
||||
|
||||
// 后端返回的时间字符串均为账户时区本地时间,直接截取显示(勿再按 UTC 解析)
|
||||
var fmt = function (d) {
|
||||
if (!d) return '--';
|
||||
// Server sends UTC datetime — parse as UTC, display in local timezone
|
||||
var s = String(d).replace('T', ' ');
|
||||
// Safari needs 'T' not space for ISO parsing
|
||||
var utcDate = new Date(String(d).replace(' ', 'T') + 'Z');
|
||||
if (!isNaN(utcDate.getTime())) {
|
||||
var pad = function (n) { return String(n).padStart(2, '0'); };
|
||||
return utcDate.getFullYear() + '-' + pad(utcDate.getMonth() + 1) + '-' + pad(utcDate.getDate()) +
|
||||
' ' + pad(utcDate.getHours()) + ':' + pad(utcDate.getMinutes());
|
||||
}
|
||||
// Fallback to raw string
|
||||
return s.substring(0, 16);
|
||||
};
|
||||
|
||||
var fmtDate = function (d) {
|
||||
return String(d.getFullYear()) + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0') + ' ' +
|
||||
String(d.getHours()).padStart(2, '0') + ':' +
|
||||
String(d.getMinutes()).padStart(2, '0');
|
||||
return String(d).replace('T', ' ').substring(0, 16);
|
||||
};
|
||||
|
||||
var html = '<div class="update-info">' +
|
||||
'<div><span class="label">实况更新:</span> ' + fmt(wUpdated) +
|
||||
' <span class="label">下次:</span> ' + fmtDate(nextW) + '</div>' +
|
||||
'<div><span class="label">逐时下次:</span> ' + fmtDate(nextH) + '</div>' +
|
||||
'<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
|
||||
@@ -1443,9 +1402,12 @@
|
||||
if (!groups[dk]) groups[dk] = [];
|
||||
groups[dk].push(ev);
|
||||
});
|
||||
var todayKey = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
var t = new Date(); t.setDate(t.getDate() + 1);
|
||||
var tomorrowKey = t.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
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 ? '明天' :
|
||||
@@ -1579,8 +1541,7 @@
|
||||
}
|
||||
// When weather panel is open, check if hourly needs refresh (past :01, stale)
|
||||
if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') {
|
||||
var now = new Date();
|
||||
var min = now.getMinutes();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user