New Database

This commit is contained in:
喆OvO
2026-08-11 22:07:55 +08:00
parent cbbb2e6d34
commit ceb4404c6a
28 changed files with 1825 additions and 768 deletions
+111 -35
View File
@@ -1060,10 +1060,115 @@
var tab = $('users-tab');
if (tab) tab.style.display = '';
loadUsers();
// 数据库管理仅 Root (id 0) 可见
if (status.user.id === 0) {
var dbSec = $('db-admin-section');
if (dbSec) dbSec.style.display = '';
loadDbStatus();
}
}
});
}
// ─── Database administration (Root only) ───
function loadDbStatus() {
var el = $('db-status');
if (!el) return;
api('GET', '/api/admin/db/status', null, function (d) {
if (d && d.database) {
el.textContent = 'PostgreSQL · schema v' + d.db_version + ' · 表 ' + (d.tables ? d.tables.length : '?') + ' 张';
el.style.color = '#2e7d32';
} else {
el.textContent = (d && d.error) || '获取状态失败';
el.style.color = '#E05555';
}
});
}
function dbMsg(text, isErr) {
var el = $('db-admin-msg');
if (!el) return;
el.textContent = text;
el.style.color = isErr ? '#E05555' : '#2e7d32';
}
function initDbAdmin() {
var exportBtn = $('db-export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
exportBtn.disabled = true;
dbMsg('正在导出...', false);
_reauthThen(function () {
// 用 fetch 携带 Authorization 下载备份文件
var token = Auth.getAccess && Auth.getAccess();
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/admin/db/export');
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.responseType = 'blob';
xhr.onload = function () {
exportBtn.disabled = false;
if (xhr.status === 200) {
var blob = xhr.response;
var disp = xhr.getResponseHeader('Content-Disposition') || '';
var m = disp.match(/filename="?([^";]+)"?/);
var name = m ? m[1] : 'iot-dboard-backup.json';
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
dbMsg('导出成功,文件已开始下载', false);
} else if (xhr.status === 403) {
dbMsg('需要重新验证身份,请重试', true);
} else {
dbMsg('导出失败 (HTTP ' + xhr.status + ')', true);
}
};
xhr.onerror = function () { exportBtn.disabled = false; dbMsg('导出失败:网络错误', true); };
xhr.send();
}, function () {
exportBtn.disabled = false;
dbMsg('身份验证未通过,导出已取消', true);
});
});
}
var form = $('db-import-form');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
var fileInput = $('db-import-file');
var file = fileInput && fileInput.files && fileInput.files[0];
if (!file) { dbMsg('请先选择文件', true); return; }
var btn = $('db-import-btn');
btn.disabled = true;
dbMsg('正在导入(大文件可能需要一些时间)...', false);
_reauthThen(function () {
var fd = new FormData();
fd.append('file', file);
api('POST', '/api/admin/db/import', fd, function (res) {
btn.disabled = false;
if (res && res.ok) {
dbMsg(res.message || '导入成功', false);
// 导入会重建全部数据并清空会话(含当前设备),提示后跳转登录页
setTimeout(function () {
try { localStorage.removeItem('dashboard_access_token'); } catch (e) {}
location.href = '/login?view=control&next=%2Fcontrol';
}, 1800);
} else {
dbMsg((res && res.error) || '导入失败', true);
}
});
}, function () {
btn.disabled = false;
dbMsg('身份验证未通过,导入已取消', true);
});
});
}
}
initDbAdmin();
// ─── User Management (admin only) ─────────
var _totpTimer = null;
@@ -1137,30 +1242,13 @@
function loadUsers() {
if (!$('users-tab') || $('users-tab').style.display === 'none') return;
api('GET', '/api/admin/users', null, function (users) {
var pending = users.filter(function (u) { return u.status === 'pending'; });
var active = users.filter(function (u) { return u.status === 'active'; });
// Pending requests
var pendHtml = '';
if (pending.length > 0) {
pendHtml = '<h4 style="margin:0 0 8px;font-size:14px;">⏳ 待确认申请(用户自助填写信息)</h4>';
pending.forEach(function (u) {
pendHtml += '<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--warn-bg);">' +
'<div><strong>' + _esc(u.remark) + '</strong> <span style="font-size:12px;color:var(--text-dim);">' + _esc(u.created_at || '') + '</span></div>' +
'<div style="display:flex;gap:6px;">' +
'<button class="small-btn" data-action="confirm-request" data-id="' + u.id + '" style="color:#2e7d32;">确认并生成临时账户</button>' +
'<button class="small-btn" data-action="delete-request" data-id="' + u.id + '" style="color:#E05555;">拒绝</button>' +
'</div></div>';
});
}
$('pending-requests').innerHTML = pendHtml;
// Active users
// 统一渲染全部用户(含历史 pending 申请用户,标记后可删除)
var html = '<h4 style="margin:0 0 8px;font-size:14px;">👥 用户列表</h4>';
if (active.length === 0) html += '<div style="color:var(--text-dim);font-size:13px;">暂无用户</div>';
active.forEach(function (u) {
if (users.length === 0) html += '<div style="color:var(--text-dim);font-size:13px;">暂无用户</div>';
users.forEach(function (u) {
var badges = [];
if (u.role === 'admin') badges.push('<span class="badge badge-on">管理员</span>');
if (u.status === 'pending') badges.push('<span class="badge badge-off">待确认申请</span>');
if (u.is_temp) badges.push('<span class="badge badge-off">临时账户</span>');
if (u.has_mfa) badges.push('<span class="badge badge-on">已配置 MFA</span>');
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;flex-wrap:wrap;gap:6px;">' +
@@ -1183,23 +1271,11 @@
}
function bindUserActions() {
document.querySelectorAll('#pending-requests [data-action], #user-list [data-action]').forEach(function (btn) {
document.querySelectorAll('#user-list [data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.dataset.action;
var id = parseInt(btn.dataset.id, 10);
if (action === 'confirm-request') {
_reauthThen(function () {
api('POST', '/api/admin/users/confirm', { request_id: id }, function (res) {
if (res.ok) { toast('已生成临时账户'); loadUsers(); } else { toast(res.error || '操作失败'); }
});
});
} else if (action === 'delete-request') {
_reauthThen(function () {
api('DELETE', '/api/admin/users/' + id, null, function (res) {
if (res.ok) { toast('已拒绝该申请'); loadUsers(); } else { toast(res.error || '操作失败'); }
});
});
} else if (action === 'edit-remark') {
if (action === 'edit-remark') {
_reauthThen(function () {
showPrompt('编辑用户备注', '新的备注', '', function (newRemark) {
if (newRemark === null || !newRemark.trim()) return;
+97 -15
View File
@@ -124,6 +124,7 @@
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;
@@ -145,8 +146,79 @@
});
}
// ─── 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);
@@ -174,7 +246,11 @@
function handleEvent(msg) {
if (msg.type === 'alarm_triggered') {
fetchState();
showAlarmOverlay(msg.data);
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();
@@ -205,8 +281,13 @@
showCustomNotification(msg.data);
} else if (msg.type === 'countdown_triggered') {
fetchState();
showAlarmOverlay(msg.data); // reuse alarm overlay UI
switchPanel('countdown');
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') {
@@ -224,13 +305,14 @@
if (state.alarmActive) return; // already showing
state.alarmActive = true;
// Build info text (supports both alarm and countdown data)
// Build info text (supports both alarm and countdown data).
// 用户可控文本(label/time/target_time)一律转义,防止存储型 XSS。
var info = '';
(data.alarms || []).forEach(function (a) {
info += a.time + (a.label ? ' ' + a.label : '') + '<br>';
info += _esc(a.time) + (a.label ? ' ' + _esc(a.label) : '') + '<br>';
});
(data.countdowns || []).forEach(function (cd) {
info += cd.target_time + (cd.label ? ' ' + cd.label : '') + '<br>';
info += _esc(cd.target_time) + (cd.label ? ' ' + _esc(cd.label) : '') + '<br>';
});
var overlay = $('alarm-overlay');
@@ -658,7 +740,7 @@
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">' + cd.label + '</div>' : '') +
(cd.label ? '<div class="cd-label">' + _esc(cd.label) + '</div>' : '') +
'</div>' +
'<div class="cd-remain' + (overdue ? ' overdue' : '') + '">' + remainStr + '</div>' +
'<div class="alarm-actions">' +
@@ -1297,9 +1379,9 @@
}
if (m.error) {
if (m.cooldown) {
content.innerHTML = '<div style="color:var(--accent-red);font-size:13px;padding:8px 0;">' + m.error + '</div>';
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;">' + m.error + '</div>';
content.innerHTML = '<div style="color:var(--text-dim);font-size:13px;padding:8px 0;">' + _esc(m.error) + '</div>';
}
return;
}
@@ -1307,7 +1389,7 @@
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;">' + summary + '</div>';
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) {
@@ -1355,11 +1437,11 @@
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>' + key + ':</strong> ' + item[key] + '</div>';
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">' + (item.title || item.type || '预警') + '</div>' +
'<div class="warning-title">' + _esc(item.title || item.type || '预警') + '</div>' +
fields +
'</div>';
});
@@ -1443,9 +1525,9 @@
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">' + a.time + '</div>' +
(a.label ? '<div class="alarm-label">' + a.label + '</div>' : '') +
'<div class="alarm-days">' + days + '</div></div>' +
'<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>' +