New Database
This commit is contained in:
+111
-35
@@ -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;
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
Reference in New Issue
Block a user