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>' +
+17 -1
View File
@@ -261,7 +261,6 @@
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
</div>
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
<div id="pending-requests" style="margin-bottom:16px;"></div>
<div id="user-list"></div>
</div>
</section>
@@ -315,6 +314,23 @@
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);">包含当前控制端与所有显示端,全部立即下线。</div>
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
<div class="section" id="db-admin-section" style="display:none;">
<h3>数据库管理</h3>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
仅 Root 可用。可导入旧版 SQLite 数据库文件(*.db)或本系统导出的备份(JSON),导入会<strong>覆盖当前全部数据(含所有账户的登录凭据)</strong>(单事务执行,失败自动回滚、不影响现有数据),且<strong>所有设备将下线需重新登录</strong>。导出的备份同样包含凭据,请妥善保管。请勿导入不可信的备份文件。
</p>
<div style="font-size:13px;margin-bottom:10px;">
当前状态: <span id="db-status" style="color:var(--text-dim);">加载中...</span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
<button type="button" id="db-export-btn" class="btn-secondary">⬇️ 导出现有数据库</button>
</div>
<form id="db-import-form" class="inline-form">
<input type="file" id="db-import-file" accept=".db,.sqlite,.sqlite3,.json" style="max-width:260px;">
<button type="submit" id="db-import-btn" class="btn-primary">⬆️ 导入数据库(自动识别 SQLite / 备份 JSON)</button>
</form>
<div id="db-admin-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
<div class="section">
<h3>时区设置</h3>
<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">
-4
View File
@@ -206,10 +206,6 @@
<button id="recovery-submit">验证恢复码</button>
<div class="back-link" id="back-to-totp">← 返回 TOTP</div>
</div>
<div style="margin-top:16px;font-size:12px;color:var(--text-dim);">
<a href="/register-request" style="color:var(--text-dim);">还没有账号?申请账号</a>
</div>
</div>
<script>
-4
View File
@@ -215,10 +215,6 @@
<span class="recovery-link" id="show-totp">返回 TOTP</span>
</div>
<div style="margin-top:20px;font-size:13px;">
<a href="/register-request" style="color:var(--accent);text-decoration:none;">还没有账号?申请账号</a>
</div>
</div>
<script>
-151
View File
@@ -1,151 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>申请账号 - Cal Display</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
--input-bg: #242838;
--input-border: rgba(255,255,255,0.14);
--divider: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.10);
--soft-bg: rgba(255,255,255,0.06);
--shadow: 0 4px 24px rgba(0,0,0,0.30);
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
--input-bg: #fafafa;
--input-border: #d7dfe8;
--divider: #d7dfe8;
--card-border: transparent;
--soft-bg: #eef2f7;
--shadow: 0 2px 12px rgba(30,40,60,0.08);
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
}
.box {
width: min(92vw, 420px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
text-align: center;
}
.box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
.box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; line-height: 1.6; }
input[type="text"] {
width: 100%;
padding: 12px;
font-size: 16px;
border-radius: 8px;
border: 1px solid #ddd;
background: #fafafa;
margin-bottom: 14px;
}
button {
width: 100%;
padding: 13px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.msg { min-height: 22px; font-size: 14px; margin-top: 12px; }
.msg.ok { color: var(--accent-green); }
.msg.err { color: var(--accent-red); }
.back { display: inline-block; margin-top: 16px; font-size: 13px; color: var(--accent); text-decoration: none; }
/* ═══ 主题适配(注册申请页)═══ */
body { background: var(--bg); }
.box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.box input[type="text"] { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
</style>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="box">
<h1>📋 申请账号</h1>
<p class="subtitle">填写你的称呼,提交后等待管理员确认。管理员确认后你会收到一个临时验证码,用它登录并配置你自己的登录方式(Passkey 或 TOTP)。</p>
<input type="text" id="remark-input" placeholder="你的称呼 / 备注" maxlength="64" autocomplete="off">
<button id="submit-btn">提交申请</button>
<div class="msg" id="msg"></div>
<a class="back" href="/login">← 返回登录</a>
</div>
<script>
(function () {
'use strict';
var btn = document.getElementById('submit-btn');
var input = document.getElementById('remark-input');
var msg = document.getElementById('msg');
function submit() {
var remark = input.value.trim();
if (!remark) { msg.className = 'msg err'; msg.textContent = '请填写你的称呼'; return; }
btn.disabled = true;
msg.textContent = '提交中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/register-request');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200 || xhr.status === 201) {
msg.className = 'msg ok';
msg.textContent = res.message || '✅ 已提交申请,请等待管理员确认';
input.disabled = true;
btn.disabled = true;
btn.textContent = '已提交';
} else {
msg.className = 'msg err';
msg.textContent = res.error || '提交失败,请稍后再试';
btn.disabled = false;
}
};
xhr.onerror = function () {
msg.className = 'msg err';
msg.textContent = '网络错误,请重试';
btn.disabled = false;
};
xhr.send(JSON.stringify({ remark: remark }));
}
btn.addEventListener('click', submit);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
})();
</script>
</body>
</html>