Files
2026-08-13 12:50:18 +08:00

1489 lines
65 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Control page - vanilla JS, no frameworks */
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
// ─── Tabs ─────────────────────────────────
document.querySelectorAll('.tab').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.tab').forEach(function (b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function (c) { c.classList.remove('active'); });
btn.classList.add('active');
var target = $('tab-' + btn.dataset.tab);
if (target) target.classList.add('active');
});
});
// ─── Helpers ──────────────────────────────
// 统一请求封装:自动附加 Authorization: Bearer <access>,401 时自动刷新并重试,
// 刷新失败则清空 token 并跳转登录页(见 token.js)。
function api(method, url, data, callback) {
Auth.request(method, url, data, callback);
}
// HTML escape for untrusted data — prevents XSS in innerHTML
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Re-authentication guard for sensitive operations (user management, MFA changes)
// Passkey first; TOTP is the fallback when no Passkey is configured.
function _reauthThen(callback) {
api('GET', '/api/auth/mfa/status', null, function (s) {
var hasPasskey = !!(s && s.passkey_enabled);
var hasTotp = !!(s && s.totp_enabled);
if (hasPasskey) {
_passkeyReauth(callback, function () {
// Passkey 失败/取消 → 有 TOTP 则回退 TOTP
if (hasTotp) _totpReauth(callback);
});
} else if (hasTotp) {
_totpReauth(callback);
} else {
// temp_pending sessions pass reauth without a code
api('POST', '/api/auth/reauth', { code: '' }, function (res) {
if (res.ok) { callback(); } else { toast('验证失败,请重试'); }
});
}
});
}
function _totpReauth(callback) {
showPrompt('身份验证', '6 位 TOTP 验证码', '', function (code) {
if (code === null || code === '') return; // 取消
api('POST', '/api/auth/reauth', { code: code }, function (res) {
if (res.ok) { callback(); } else { toast('验证失败,请重试'); }
});
}, { okText: '验证', inputType: 'text', inputmode: 'numeric', maxlength: 6 });
}
function _passkeyReauth(callback, onFail) {
onFail = onFail || function () {};
api('POST', '/api/auth/reauth/webauthn/begin', null, function (options) {
if (options.error) { toast(options.error); onFail(); return; }
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.assertion(options)
.then(function (credential) {
api('POST', '/api/auth/reauth/webauthn/complete', { credential: credential }, function (res) {
if (res.ok) { callback(); } else { toast('验证失败,请重试'); onFail(); }
});
})
.catch(function (err) {
toast('Passkey 验证已取消');
onFail();
});
});
}
function toast(msg) {
var el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 2500);
}
// ─── Calendar Groups ──────────────────────
function loadGroups() {
api('GET', '/api/calendar/groups', null, function (groups) {
var html = '';
groups.forEach(function (g) {
html += '<div class="group-item">' +
'<div class="info">' +
'<div class="group-color-dot" style="background:' + g.color + '"></div>' +
'<div>' +
'<div class="group-name">' + g.name +
' <span class="badge ' + (g.enabled ? 'badge-on' : 'badge-off') + '">' + (g.enabled ? '显示' : '隐藏') + '</span></div>' +
'<div class="group-sources">' + g.sources.length + ' 个数据源</div>' +
'</div>' +
'</div>' +
'<div class="group-actions">' +
'<button class="small-btn" data-action="view-events" data-id="' + g.id + '">日程</button>' +
'<button class="small-btn" data-action="toggle" data-id="' + g.id + '" data-enabled="' + (g.enabled ? 0 : 1) + '">' + (g.enabled ? '隐藏' : '显示') + '</button>' +
'<button class="small-btn" data-action="delete" data-id="' + g.id + '" style="color:#E05555">删除</button>' +
'</div>' +
'</div>';
// Sources
g.sources.forEach(function (s) {
html += '<div style="padding:4px 12px 4px 36px;font-size:13px;color:var(--text-dim);display:flex;justify-content:space-between;">' +
'<span>' + (s.type === 'upload' ? '📄' : '🔗') + ' ' + s.name + '</span>' +
'<span>' +
(s.type === 'subscription' ? '<button class="small-btn" data-action="sync" data-id="' + s.id + '">同步</button> ' : '') +
'<button class="small-btn" data-action="delete-source" data-id="' + s.id + '" style="color:#E05555">删除</button>' +
'</span></div>';
});
});
$('group-list').innerHTML = html || '<div style="color:var(--text-dim)">暂无分组</div>';
bindGroupActions();
// Update selects
[['upload-group','subscribe-group','reminder-group']].forEach(function () {
var selHtml = '';
groups.forEach(function (g) {
selHtml += '<option value="' + g.id + '">' + g.name + '</option>';
});
['upload-group','subscribe-group','reminder-group'].forEach(function (id) {
var sel = $(id);
if (sel) sel.innerHTML = selHtml;
});
});
});
}
function bindGroupActions() {
document.querySelectorAll('#group-list [data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.dataset.action;
var id = btn.dataset.id;
if (action === 'toggle') {
api('PUT', '/api/calendar/groups/' + id, { enabled: btn.dataset.enabled === '1' }, function () {
loadGroups();
});
} else if (action === 'delete') {
showConfirm('删除分组', '确认删除此分组及其所有数据?', function () {
api('DELETE', '/api/calendar/groups/' + id, null, function () { loadGroups(); });
}, { okText: '删除' });
} else if (action === 'sync') {
api('POST', '/api/calendar/sources/' + id + '/sync', null, function () {
toast('同步完成');
loadGroups();
});
} else if (action === 'delete-source') {
showConfirm('删除数据源', '删除此数据源?', function () {
api('DELETE', '/api/calendar/sources/' + id, null, function () { loadGroups(); });
}, { okText: '删除' });
} else if (action === 'view-events') {
var eventsDiv = $('group-events-' + id);
if (eventsDiv) {
eventsDiv.style.display = eventsDiv.style.display === 'none' ? 'block' : 'none';
} else {
loadInlineGroupEvents(id);
}
}
});
});
}
$('group-form').addEventListener('submit', function (e) {
e.preventDefault();
api('POST', '/api/calendar/groups', {
name: $('group-name').value,
color: $('group-color').value,
}, function () {
$('group-name').value = '';
loadGroups();
});
});
// ─── ICS Upload ───────────────────────────
$('upload-form').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData();
fd.append('group_id', $('upload-group').value);
fd.append('name', $('upload-name').value);
fd.append('file', $('upload-file').files[0]);
api('POST', '/api/calendar/sources/upload', fd, function () {
$('upload-name').value = '';
$('upload-file').value = '';
toast('上传成功');
loadGroups();
});
});
// ─── ICS Subscribe ────────────────────────
$('subscribe-form').addEventListener('submit', function (e) {
e.preventDefault();
api('POST', '/api/calendar/sources/subscribe', {
group_id: parseInt($('subscribe-group').value),
name: $('subscribe-name').value,
url: $('subscribe-url').value,
}, function () {
$('subscribe-name').value = '';
$('subscribe-url').value = '';
toast('订阅成功');
loadGroups();
});
});
// ─── Reminders ────────────────────────────
$('reminder-form').addEventListener('submit', function (e) {
e.preventDefault();
api('PUT', '/api/calendar/groups/' + $('reminder-group').value + '/reminder', {
minutes_before: parseInt($('reminder-minutes').value),
enabled: $('reminder-enabled').checked,
}, function () {
toast('提醒设置已保存');
});
});
// ─── Weather (QWeather) ────────────────
function loadWeatherConfig() {
api('GET', '/api/weather/config', null, function (cfg) {
$('weather-city').value = cfg.city || '';
$('weather-lat').value = cfg.lat || '';
$('weather-lon').value = cfg.lon || '';
$('weather-units').value = cfg.units || 'metric';
$('weather-kid').value = cfg.credential_id || '';
$('weather-sub').value = cfg.project_id || '';
$('weather-host').value = cfg.qweather_host || '';
$('weather-apikey').value = cfg.api_key === '***' ? '' : (cfg.api_key || '');
if (cfg.jwt_public_key) {
$('weather-public-key').value = cfg.jwt_public_key;
}
_refreshWeatherPreview();
});
}
// ─── City Search ──────────────────────────
$('city-search-btn').addEventListener('click', function () {
var q = $('weather-city-search').value.trim();
if (!q) return;
api('GET', '/api/weather/city_lookup?q=' + encodeURIComponent(q), null, function (results) {
var container = $('city-results');
if (!results || results.length === 0) {
container.innerHTML = '<div style="color:var(--text-dim);font-size:13px;">未找到匹配城市</div>';
return;
}
var html = '';
results.forEach(function (loc) {
html += '<div class="city-result" data-lat="' + loc.lat + '" data-lon="' + loc.lon + '" data-name="' + loc.name + '" style="padding:6px 10px;border:1px solid var(--border);border-radius:4px;margin-bottom:4px;cursor:pointer;display:flex;justify-content:space-between;">' +
'<span><strong>' + loc.name + '</strong> ' + (loc.adm1 || '') + ' ' + (loc.country || '') + '</span>' +
'<span style="color:var(--text-dim);font-size:12px;">' + loc.lat + ', ' + loc.lon + '</span>' +
'</div>';
});
container.innerHTML = html;
// Click to select
container.querySelectorAll('.city-result').forEach(function (el) {
el.addEventListener('click', function () {
$('weather-city').value = el.dataset.name;
$('weather-lat').value = el.dataset.lat;
$('weather-lon').value = el.dataset.lon;
container.innerHTML = '<div style="color:var(--accent);font-size:13px;">已选择: ' + el.dataset.name + '</div>';
});
});
});
});
// ─── Key Generation ───────────────────────
$('generate-keys-btn').addEventListener('click', function () {
var statusEl = $('key-gen-status');
statusEl.textContent = '生成中...';
api('POST', '/api/weather/generate_keys', null, function (result) {
if (result.public_key) {
$('weather-public-key').value = result.public_key;
statusEl.textContent = '✅ 密钥对已生成,请将公钥复制到和风控制台';
} else {
statusEl.textContent = '❌ 生成失败';
}
});
});
// ─── Save Config ──────────────────────────
$('weather-form').addEventListener('submit', function (e) {
e.preventDefault();
var statusEl = $('weather-save-status');
statusEl.textContent = '保存中...';
var data = {
city: $('weather-city').value,
lat: parseFloat($('weather-lat').value) || undefined,
lon: parseFloat($('weather-lon').value) || undefined,
units: $('weather-units').value,
api_key: $('weather-apikey').value || undefined,
credential_id: $('weather-kid').value || undefined,
project_id: $('weather-sub').value || undefined,
qweather_host: $('weather-host').value || undefined,
};
api('PUT', '/api/weather/config', data, function () {
statusEl.textContent = '✅ 已保存';
_refreshWeatherPreview();
});
});
// ─── Refresh ──────────────────────────────
function _refreshWeatherPreview() {
api('GET', '/api/weather/current', null, function (data) {
var el = $('weather-preview');
if (data.error) {
el.innerHTML = '<span style="color:var(--accent-red);">' + data.error + '</span>';
} else {
el.innerHTML = '<strong>' + (data.city || '') + '</strong> ' +
(data.temp !== undefined ? data.temp + '°C' : '') +
(data.description ? ' ' + data.description : '') +
(data.feels_like ? ' 体感' + data.feels_like + '°C' : '') +
(data.wind_dir ? ' ' + data.wind_dir : '') +
(data.wind_speed ? ' ' + data.wind_speed + 'km/h' : '') +
(data.humidity ? ' 湿度' + data.humidity + '%' : '') +
(data.precip && data.precip != '0' ? ' 降水' + data.precip + 'mm' : '') +
(data.visibility ? ' 能见度' + data.visibility + 'km' : '') +
(data.dew ? ' 露点' + data.dew + '°C' : '') +
(data.updated_at ? '<br><span style="font-size:12px;">更新: ' + data.updated_at + '</span>' : '');
}
});
}
$('weather-refresh').addEventListener('click', function () {
api('POST', '/api/weather/refresh', null, function (data) {
if (data.error) {
toast('刷新失败: ' + data.error);
} else {
toast(data.city + ': ' + data.temp + '°C ' + data.description);
_refreshWeatherPreview();
}
});
});
// ─── Alarms ───────────────────────────────
function loadAlarms() {
api('GET', '/api/alarms', null, function (alarms) {
var dayNames = ['', '一', '二', '三', '四', '五', '六', '日'];
var html = '';
alarms.forEach(function (a) {
var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' ');
html += '<div class="alarm-item">' +
'<div class="info"><strong>' + a.time + '</strong> ' +
(a.label || '') +
' <span style="font-size:12px;color:var(--text-dim)">' + days + '</span>' +
' <span class="badge ' + (a.enabled ? 'badge-on' : 'badge-off') + '">' + (a.enabled ? '开' : '关') + '</span>' +
'</div>' +
'<div>' +
'<button class="small-btn" data-action="toggle-alarm" data-id="' + a.id + '" data-enabled="' + (a.enabled ? 0 : 1) + '">' + (a.enabled ? '关闭' : '开启') + '</button>' +
'<button class="small-btn" data-action="delete-alarm" data-id="' + a.id + '" style="color:#E05555">删除</button>' +
'</div>' +
'</div>';
});
$('alarm-list').innerHTML = html || '<div style="color:var(--text-dim)">暂无闹钟</div>';
document.querySelectorAll('[data-action="toggle-alarm"]').forEach(function (btn) {
btn.addEventListener('click', function () {
api('PUT', '/api/alarms/' + btn.dataset.id, { enabled: btn.dataset.enabled === '1' }, function () {
loadAlarms();
});
});
});
document.querySelectorAll('[data-action="delete-alarm"]').forEach(function (btn) {
btn.addEventListener('click', function () {
api('DELETE', '/api/alarms/' + btn.dataset.id, null, function () { loadAlarms(); });
});
});
});
}
// Once-only toggle — hide day picker
var alarmOnceCb = $('alarm-once');
if (alarmOnceCb) {
alarmOnceCb.addEventListener('change', function () {
var dp = $('alarm-days-picker');
if (dp) dp.style.display = alarmOnceCb.checked ? 'none' : '';
});
}
$('alarm-form').addEventListener('submit', function (e) {
e.preventDefault();
var days = [];
document.querySelectorAll('#alarm-days-picker input:checked').forEach(function (cb) {
days.push(cb.value);
});
api('POST', '/api/alarms', {
label: $('alarm-label').value,
time: $('alarm-time').value,
days: days.join(','),
sound: $('alarm-sound').value,
once: $('alarm-once').checked,
}, function () {
$('alarm-label').value = '';
$('alarm-once').checked = false;
var dp = $('alarm-days-picker');
if (dp) dp.style.display = '';
loadAlarms();
});
});
// ─── Ringtone ────────────────────────────
$('ringtone-form').addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData();
fd.append('file', $('ringtone-file').files[0]);
api('POST', '/api/alarms/ringtone', fd, function (res) {
if (res.filename) {
toast('铃声上传成功: ' + res.filename);
$('ringtone-file').value = '';
// Add to sound selector
var sel = $('alarm-sound');
var opt = document.createElement('option');
opt.value = res.filename;
opt.textContent = res.filename;
sel.appendChild(opt);
// Show in ringtone list
var list = $('ringtone-list');
list.innerHTML += '<div>' + res.filename + '</div>';
}
});
});
// ─── Display Mode ─────────────────────────
var htmlTemplates = { 0: '', 1: '', 2: '', 3: '' };
var currentHtmlIndex = 0;
function loadDisplayMode() {
api('GET', '/api/display/mode', null, function (cfg) {
$('display-mode').value = cfg.mode || 'default';
htmlTemplates[0] = cfg.custom_html || '';
htmlTemplates[1] = cfg.custom_html_1 || '';
htmlTemplates[2] = cfg.custom_html_2 || '';
htmlTemplates[3] = cfg.custom_html_3 || '';
updateHtmlEditor();
});
}
function updateHtmlEditor() {
$('custom-html-editor').value = htmlTemplates[currentHtmlIndex];
$('html-editor-label').textContent = 'HTML ' + currentHtmlIndex;
document.querySelectorAll('.html-tab-btn').forEach(function (btn) {
btn.classList.toggle('active', parseInt(btn.dataset.idx, 10) === currentHtmlIndex);
});
}
document.querySelectorAll('.html-tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
// Auto-save current before switching
htmlTemplates[currentHtmlIndex] = $('custom-html-editor').value;
var idx = parseInt(btn.dataset.idx, 10);
currentHtmlIndex = idx;
updateHtmlEditor();
// Auto-save to server
var url = idx === 0 ? '/api/display/custom_html' : '/api/display/custom_html/' + idx;
api('PUT', url, { html: htmlTemplates[idx] }, function () {});
});
});
$('mode-form').addEventListener('submit', function (e) {
e.preventDefault();
var mode = $('display-mode').value;
var msg = $('mode-msg');
api('PUT', '/api/display/mode', { mode: mode }, function (res) {
if (res.ok) {
msg.textContent = '模式已切换为: ' + (mode === 'custom' ? '自定义 HTML' : '默认页面');
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '切换失败';
msg.style.color = '#E05555';
}
});
});
$('save-html-btn').addEventListener('click', function () {
htmlTemplates[currentHtmlIndex] = $('custom-html-editor').value;
var msg = $('mode-msg');
var url = currentHtmlIndex === 0 ? '/api/display/custom_html' : '/api/display/custom_html/' + currentHtmlIndex;
api('PUT', url, { html: htmlTemplates[currentHtmlIndex] }, function (res) {
if (res.ok) {
msg.textContent = 'HTML ' + currentHtmlIndex + ' 已保存';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '保存失败';
msg.style.color = '#E05555';
}
});
});
$('apply-html-btn').addEventListener('click', function () {
htmlTemplates[currentHtmlIndex] = $('custom-html-editor').value;
var msg = $('mode-msg');
// First save current template
var saveUrl = currentHtmlIndex === 0 ? '/api/display/custom_html' : '/api/display/custom_html/' + currentHtmlIndex;
api('PUT', saveUrl, { html: htmlTemplates[currentHtmlIndex] }, function () {
// Then apply it
api('POST', '/api/display/apply_html/' + currentHtmlIndex, null, function (res) {
if (res.ok) {
msg.textContent = '已应用 HTML ' + currentHtmlIndex + ' 到显示端';
msg.style.color = '#2e7d32';
$('display-mode').value = 'custom';
} else {
msg.textContent = res.error || '应用失败';
msg.style.color = '#E05555';
}
});
});
});
$('refresh-display-btn').addEventListener('click', function () {
var msg = $('mode-msg');
api('POST', '/api/display/refresh', null, function (res) {
if (res.ok) {
msg.textContent = '刷新指令已发送';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '发送失败';
msg.style.color = '#E05555';
}
});
});
$('kick-all-btn').addEventListener('click', function () {
var msg = $('mode-msg');
showConfirm('踢掉当前账号的所有设备', '将立即下线本账号的全部设备(含当前控制端),确定?', function () {
msg.textContent = '正在下线全部设备...';
api('POST', '/api/display/kick_all', null, function (res) {
if (res.ok) {
// 当前设备也会被踢下线:由 401 → refresh 失败 → 跳转登录页完成
msg.textContent = '已下线本账号全部设备,即将跳转登录页';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '操作失败';
msg.style.color = '#E05555';
}
});
});
});
// ─── Timezone ──────────────────────────────
function loadTimezone() {
api('GET', '/api/time/config', null, function (cfg) {
$('timezone-input').value = cfg.timezone || 'Asia/Shanghai';
});
}
$('timezone-form').addEventListener('submit', function (e) {
e.preventDefault();
var msg = $('timezone-msg');
var tz = $('timezone-input').value.trim();
if (!tz) { msg.textContent = '请输入时区'; return; }
msg.textContent = '保存中...';
api('PUT', '/api/time/config', { timezone: tz }, function (res) {
if (res.ok) {
msg.textContent = '✅ 时区已更新,将在下次请求时生效';
msg.style.color = '#2e7d32';
} else {
msg.textContent = res.error || '保存失败';
msg.style.color = '#E05555';
}
});
});
// ─── Logout ────────────────────────────────
$('logout-btn').addEventListener('click', function () {
Auth.logout(function () {
location.href = '/login?view=control';
});
});
// ─── Manual Notification ──────────────────
$('notify-form').addEventListener('submit', function (e) {
e.preventDefault();
var msg = $('notify-msg');
api('POST', '/api/display/notify', {
title: $('notify-title').value,
message: $('notify-message').value,
sound: $('notify-sound').checked,
}, function (res) {
if (res.ok) {
msg.textContent = '提醒已推送';
msg.style.color = '#2e7d32';
$('notify-message').value = '';
} else {
msg.textContent = res.error || '推送失败';
msg.style.color = '#E05555';
}
});
});
// ─── Pomodoro ─────────────────────────────
function loadPomodoro() {
api('GET', '/api/pomodoro', null, function (p) {
var statusEl = $('pomodoro-status');
if (!statusEl) return;
if (!p.enabled) {
statusEl.textContent = '状态: 未启动';
} else {
var phase = p.phase === 'work' ? '工作' : '休息';
var remaining = p.remaining_seconds || 0;
if (p.running && p.end_time) {
try {
// end_time is a Unix timestamp (e.g. "1785285282.447")
remaining = Math.max(0, Math.floor(parseFloat(p.end_time) - Date.now() / 1000));
} catch (_) {}
}
var remainingMinutes = Math.max(0, Math.ceil(remaining / 60));
statusEl.textContent = '状态: ' + (p.running ? '进行中' : '已暂停') + ' | 阶段: ' + phase + ' | 剩余: ' + remainingMinutes + ' 分钟 | 已完成 ' + (p.cycles || 0) + ' 组 | 下次: ' + (p.next_break_type === 'long' ? '长休息' : '短休息');
}
// Only update input values if user is not currently editing them
var workInput = $('pomodoro-work');
var breakInput = $('pomodoro-break');
var longBreakInput = $('pomodoro-long-break');
var cyclesBeforeLongInput = $('pomodoro-cycles-before-long');
var displayControlsInput = $('pomodoro-display-controls');
var workLabelInput = $('pomodoro-work-label');
var breakLabelInput = $('pomodoro-break-label');
if (document.activeElement !== workInput) {
workInput.value = p.work_minutes || 25;
}
if (document.activeElement !== breakInput) {
breakInput.value = p.break_minutes || 5;
}
if (document.activeElement !== longBreakInput) {
longBreakInput.value = p.long_break_minutes || 15;
}
if (document.activeElement !== cyclesBeforeLongInput) {
cyclesBeforeLongInput.value = p.cycles_before_long_break || 4;
}
if (document.activeElement !== displayControlsInput) {
displayControlsInput.checked = p.display_controls_enabled !== false;
}
if (document.activeElement !== workLabelInput) {
workLabelInput.value = p.work_label || '';
}
if (document.activeElement !== breakLabelInput) {
breakLabelInput.value = p.break_label || '';
}
});
}
$('pomodoro-form').addEventListener('submit', function (e) {
e.preventDefault();
api('POST', '/api/pomodoro', {
work_minutes: parseInt($('pomodoro-work').value) || 25,
break_minutes: parseInt($('pomodoro-break').value) || 5,
long_break_minutes: parseInt($('pomodoro-long-break').value) || 15,
cycles_before_long_break: parseInt($('pomodoro-cycles-before-long').value) || 4,
display_controls_enabled: $('pomodoro-display-controls').checked,
work_label: $('pomodoro-work-label').value || '工作中',
break_label: $('pomodoro-break-label').value || '休息中',
}, function () {
toast('番茄钟设置已保存');
loadPomodoro();
});
});
$('pomodoro-start-btn').addEventListener('click', function () {
api('POST', '/api/pomodoro/start', null, function () {
toast('番茄钟已开始');
loadPomodoro();
});
});
$('pomodoro-pause-btn').addEventListener('click', function () {
api('POST', '/api/pomodoro/pause', null, function () {
toast('番茄钟已暂停');
loadPomodoro();
});
});
$('pomodoro-skip-btn').addEventListener('click', function () {
api('POST', '/api/pomodoro/skip', null, function () {
toast('已跳过当前阶段');
loadPomodoro();
});
});
$('pomodoro-stop-btn').addEventListener('click', function () {
api('POST', '/api/pomodoro/stop', null, function () {
toast('番茄钟已停止');
loadPomodoro();
});
});
// ─── Inline Group Events ──────────────────
function loadInlineGroupEvents(groupId) {
api('GET', '/api/calendar/groups/' + groupId + '/events', null, function (events) {
var container = document.createElement('div');
container.id = 'group-events-' + groupId;
container.style.cssText = 'padding:8px 12px;background:#f8f9fa;border-radius:6px;margin-top:8px;';
var html = '<div style="font-size:13px;font-weight:600;margin-bottom:6px;">日程列表</div>';
if (events.length === 0) {
html += '<div style="color:var(--text-dim);font-size:13px;">暂无日程</div>';
} else {
events.forEach(function (ev) {
var start = ev.dtstart || '';
var end = ev.dtend || '';
var startStr = start.length >= 13 ? start.substring(0, 4) + '-' + start.substring(4, 6) + '-' + start.substring(6, 8) + ' ' + start.substring(9, 11) + ':' + start.substring(11, 13) : start;
var endStr = end.length >= 13 ? end.substring(0, 4) + '-' + end.substring(4, 6) + '-' + end.substring(6, 8) + ' ' + end.substring(9, 11) + ':' + end.substring(11, 13) : end;
var timeRange = startStr + (endStr && endStr !== startStr ? ' 至 ' + endStr : '');
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);font-size:13px;">' +
'<div>' +
'<div style="font-weight:500;">' + _esc(ev.summary || '无标题') + '</div>' +
'<div style="color:var(--text-dim);font-size:12px;">' + timeRange + (ev.location ? ' · ' + _esc(ev.location) : '') + '</div>' +
'</div>' +
'<div style="display:flex;gap:4px;">' +
'<button class="small-btn" data-action="edit-inline-event" data-id="' + ev.id + '">编辑</button>' +
'<button class="small-btn" data-action="delete-inline-event" data-id="' + ev.id + '" style="color:#E05555">删除</button>' +
'</div>' +
'</div>';
});
}
container.innerHTML = html;
// Insert after the group-item
var groupEl = document.querySelector('.group-item [data-action="view-events"][data-id="' + groupId + '"]').closest('.group-item');
groupEl.parentNode.insertBefore(container, groupEl.nextSibling);
bindInlineEventActions(groupId);
});
}
function bindInlineEventActions(groupId) {
var container = $('group-events-' + groupId);
if (!container) return;
container.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.dataset.action;
var id = btn.dataset.id;
if (action === 'delete-inline-event') {
showConfirm('删除日程', '确认删除此日程?', function () {
api('DELETE', '/api/calendar/events/' + id, null, function () {
toast('已删除');
loadInlineGroupEvents(groupId);
});
}, { okText: '删除' });
} else if (action === 'edit-inline-event') {
showPrompt('编辑日程', '日程标题', '', function (newSummary) {
if (newSummary === null || newSummary === '') return;
api('PUT', '/api/calendar/events/' + id, { summary: newSummary }, function (res) {
if (res.ok) {
toast('已更新');
loadInlineGroupEvents(groupId);
} else {
toast(res.error || '更新失败');
}
});
}, { okText: '保存' });
}
});
});
}
// ─── Manual Event ─────────────────────────
function updateEventGroupSelects() {
api('GET', '/api/calendar/groups', null, function (groups) {
var selHtml = '';
groups.forEach(function (g) {
selHtml += '<option value="' + g.id + '">' + g.name + '</option>';
});
['event-group', 'browse-group'].forEach(function (id) {
var sel = $(id);
if (sel) sel.innerHTML = selHtml;
});
});
}
$('event-form').addEventListener('submit', function (e) {
e.preventDefault();
var dtstart = $('event-dtstart').value;
var dtend = $('event-dtend').value;
if (!dtstart) { toast('请选择开始时间'); return; }
// Convert from datetime-local (YYYY-MM-DDTHH:MM) to our format (YYYYMMDDTHHMMSS)
var startStr = dtstart.replace(/[-:]/g, '') + '00';
var endStr = dtend ? dtend.replace(/[-:]/g, '') + '00' : null;
api('POST', '/api/calendar/events', {
group_id: parseInt($('event-group').value),
summary: $('event-summary').value,
dtstart: startStr,
dtend: endStr,
location: $('event-location').value,
}, function (res) {
if (res.id) {
toast('日程添加成功');
$('event-summary').value = '';
$('event-dtstart').value = '';
$('event-dtend').value = '';
$('event-location').value = '';
} else {
toast(res.error || '添加失败');
}
});
});
// ─── Browse Group Events ──────────────────
function loadGroupEvents(groupId) {
api('GET', '/api/calendar/groups/' + groupId + '/events', null, function (events) {
var html = '';
if (events.length === 0) {
html = '<div style="color:var(--text-dim)">暂无日程</div>';
} else {
events.forEach(function (ev) {
var start = ev.dtstart || '';
var end = ev.dtend || '';
var startStr = start.length >= 13 ? start.substring(0, 4) + '-' + start.substring(4, 6) + '-' + start.substring(6, 8) + ' ' + start.substring(9, 11) + ':' + start.substring(11, 13) : start;
var endStr = end.length >= 13 ? end.substring(0, 4) + '-' + end.substring(4, 6) + '-' + end.substring(6, 8) + ' ' + end.substring(9, 11) + ':' + end.substring(11, 13) : end;
var timeRange = startStr + (endStr && endStr !== startStr ? ' 至 ' + endStr : '');
html += '<div class="event-row" style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);">' +
'<div>' +
'<div style="font-weight:500;">' + _esc(ev.summary || '无标题') + '</div>' +
'<div style="font-size:12px;color:var(--text-dim);">' + timeRange + (ev.location ? ' · ' + _esc(ev.location) : '') + '</div>' +
'</div>' +
'<div style="display:flex;gap:6px;">' +
'<button class="small-btn" data-action="edit-event" data-id="' + ev.id + '" data-summary="' + encodeURIComponent(ev.summary || '') + '" data-dtstart="' + (ev.dtstart || '') + '" data-dtend="' + (ev.dtend || '') + '" data-location="' + encodeURIComponent(ev.location || '') + '">编辑</button>' +
'<button class="small-btn" data-action="delete-event" data-id="' + ev.id + '" style="color:#E05555">删除</button>' +
'</div>' +
'</div>';
});
}
$('group-events-list').innerHTML = html;
bindEventActions();
});
}
function bindEventActions() {
document.querySelectorAll('#group-events-list [data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.dataset.action;
var id = btn.dataset.id;
if (action === 'delete-event') {
showConfirm('删除日程', '确认删除此日程?', function () {
api('DELETE', '/api/calendar/events/' + id, null, function () {
toast('已删除');
var gid = $('browse-group').value;
if (gid) loadGroupEvents(gid);
});
}, { okText: '删除' });
} else if (action === 'edit-event') {
showPrompt('编辑日程', '日程标题', decodeURIComponent(btn.dataset.summary || ''), function (newSummary) {
if (newSummary === null || newSummary === '') return;
showPrompt('编辑日程', '地点(可留空)', decodeURIComponent(btn.dataset.location || ''), function (newLocation) {
if (newLocation === null) return;
api('PUT', '/api/calendar/events/' + id, {
summary: newSummary,
location: newLocation,
}, function (res) {
if (res.ok) {
toast('已更新');
var gid = $('browse-group').value;
if (gid) loadGroupEvents(gid);
} else {
toast(res.error || '更新失败');
}
});
}, { okText: '保存' });
}, { okText: '下一步' });
}
});
});
}
$('browse-events-btn').addEventListener('click', function () {
var gid = $('browse-group').value;
if (gid) loadGroupEvents(gid);
});
// ─── MFA Management ────────────────────────
function loadMFAStatus() {
api('GET', '/api/auth/mfa/status', null, function (s) {
var statusEl = $('mfa-status');
if (!statusEl) return;
var methods = [];
if (s.passkey_enabled) methods.push('🔑 Passkey');
if (s.totp_enabled) methods.push('📱 TOTP');
if (methods.length > 0) {
statusEl.textContent = '已配置: ' + methods.join(', ');
statusEl.style.color = '#2e7d32';
} else {
statusEl.textContent = '⚠️ 未配置任何 MFA 方式';
statusEl.style.color = '#E05555';
}
// Show/hide remove buttons
var removePasskeyBtn = $('mfa-remove-passkey');
var removeTotpBtn = $('mfa-remove-totp');
if (removePasskeyBtn) removePasskeyBtn.style.display = s.passkey_enabled ? '' : 'none';
if (removeTotpBtn) removeTotpBtn.style.display = s.totp_enabled ? '' : 'none';
// Show recovery codes if any
var recSection = $('mfa-recovery-section');
if (s.recovery_codes_remaining > 0) {
loadRecoveryCodes();
} else if (recSection) {
recSection.style.display = 'none';
}
});
}
// ─── Passkey Registration (from settings) ─
$('mfa-register-passkey').addEventListener('click', function () {
_reauthThen(function () {
var statusEl = $('mfa-passkey-status');
statusEl.textContent = '正在准备注册...';
api('POST', '/api/auth/webauthn/register/begin', null, function (options) {
if (options.error) {
statusEl.textContent = '❌ ' + options.error;
return;
}
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options)
.then(function (credential) {
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
api('POST', '/api/auth/webauthn/register/complete', { credential: credential }, function (res) {
if (res.ok) {
statusEl.textContent = '✅ Passkey 注册成功';
loadMFAStatus();
} else {
statusEl.textContent = '❌ ' + (res.error || '注册失败');
}
});
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
});
});
});
});
// ─── Passkey Removal (from settings) ──────
var removePasskeyBtn = $('mfa-remove-passkey');
if (removePasskeyBtn) {
removePasskeyBtn.addEventListener('click', function () {
_reauthThen(function () {
api('DELETE', '/api/auth/passkey', null, function (res) {
if (res.ok) {
toast('✅ Passkey 已移除');
loadMFAStatus();
} else {
toast('❌ ' + (res.error || '移除失败'));
}
});
});
});
}
// ─── TOTP Setup ───────────────────────────
$('mfa-setup-totp').addEventListener('click', function () {
_reauthThen(function () {
var area = $('mfa-totp-area');
if (area.style.display === 'block') {
area.style.display = 'none';
return;
}
api('POST', '/api/auth/totp/setup', null, function (data) {
$('mfa-totp-qr').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR" style="max-width:180px;border-radius:8px;">';
$('mfa-totp-secret').textContent = '密钥: ' + data.secret;
area.style.display = 'block';
});
});
});
$('mfa-totp-verify-btn').addEventListener('click', function () {
var code = $('mfa-totp-code').value;
var msgEl = $('mfa-totp-msg');
api('POST', '/api/auth/totp/verify', { code: code }, function (res) {
if (res.ok) {
msgEl.textContent = '✅ TOTP 已启用';
msgEl.style.color = '#2e7d32';
$('mfa-totp-area').style.display = 'none';
$('mfa-totp-code').value = '';
loadMFAStatus();
// Show recovery codes if returned
if (res.recovery_codes && res.recovery_codes.length > 0) {
showRecoveryCodesUI(res.recovery_codes);
}
} else {
msgEl.textContent = res.error || '验证失败';
msgEl.style.color = '#E05555';
}
});
});
// ─── Recovery Codes ───────────────────────
function loadRecoveryCodes() {
api('GET', '/api/auth/recovery-codes', null, function (data) {
if (data.codes && data.codes.length > 0) {
showRecoveryCodesUI(data.codes);
}
});
}
function showRecoveryCodesUI(codes) {
var section = $('mfa-recovery-section');
var container = $('mfa-recovery-codes');
if (section) section.style.display = 'block';
if (container) container.innerHTML = codes.map(function (c) { return '<code>' + c + '</code> '; }).join('');
}
$('mfa-copy-recovery').addEventListener('click', function () {
var text = $('mfa-recovery-codes').textContent;
navigator.clipboard.writeText(text).then(function () {
toast('恢复码已复制');
});
});
$('mfa-regenerate-recovery').addEventListener('click', function () {
_reauthThen(function () {
showConfirm('重新生成恢复码', '重新生成将使旧的恢复码全部失效,确认?', function () {
var msgEl = $('mfa-recovery-msg');
api('POST', '/api/auth/recovery-codes/regenerate', null, function (data) {
if (data.codes) {
showRecoveryCodesUI(data.codes);
msgEl.textContent = '✅ 已重新生成';
msgEl.style.color = '#2e7d32';
}
});
}, { okText: '重新生成' });
});
});
// ─── Remove MFA (future: passkey & totp removal) ─
// For now, user can simply re-register to override
// ─── Helpers ──────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// Convert ArrayBuffer to base64url (for WebAuthn API)
function _arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ─── Theme 设置 ──────────────────────────
function loadThemeSetting() {
api('GET', '/api/theme', null, function (d) {
var sel = $('theme-mode');
if (sel && d.mode) sel.value = d.mode;
});
}
$('theme-save-btn').addEventListener('click', function () {
var mode = $('theme-mode').value;
api('PUT', '/api/theme', { mode: mode }, function (d) {
if (d.ok) {
if (window.ThemeBridge && d.effective) ThemeBridge.apply(d.effective);
toast('主题已保存并同步到显示端');
} else {
toast(d.error || '保存失败');
}
});
});
// ─── Current user + admin visibility ──────
function loadAuthStatus() {
api('GET', '/api/auth/status', null, function (status) {
var badge = $('current-user-badge');
if (badge && status.user) {
badge.textContent = '· ' + (status.user.remark || '用户');
}
if (status.user && status.user.is_admin) {
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;
function showModal(title, html) {
closeModal();
var overlay = document.createElement('div');
overlay.id = 'ux-modal';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:999;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = '<div style="background:var(--card-bg);border-radius:12px;max-width:520px;width:92vw;max-height:86vh;overflow:auto;padding:20px 24px;box-shadow:0 8px 30px rgba(0,0,0,.2);border:1px solid var(--border);">' +
'<h3 style="margin:0 0 14px;font-size:17px;color:var(--text);">' + title + '</h3>' +
'<div id="ux-modal-body">' + html + '</div>' +
'<div style="margin-top:16px;text-align:right;">' +
'<button id="ux-modal-close" class="btn-secondary" style="padding:8px 18px;border:none;border-radius:6px;cursor:pointer;">关闭</button>' +
'</div></div>';
document.body.appendChild(overlay);
overlay.querySelector('#ux-modal-close').addEventListener('click', closeModal);
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
}
function closeModal() {
if (_totpTimer) { clearInterval(_totpTimer); _totpTimer = null; }
var m = $('ux-modal');
if (m) m.remove();
}
// ─── 自定义对话框(替代浏览器 prompt/confirm/alert)───
function showConfirm(title, message, onOk, opts) {
opts = opts || {};
var okText = opts.okText || '确认';
var danger = opts.danger !== false;
showModal(title,
'<div style="font-size:14px;line-height:1.7;color:var(--text);padding:2px 0 12px;">' + message + '</div>' +
'<div style="display:flex;gap:8px;justify-content:flex-end;">' +
'<button id="ux-confirm-cancel" class="btn-secondary" style="padding:8px 18px;border:none;border-radius:6px;cursor:pointer;background:#eee;color:var(--text);">取消</button>' +
'<button id="ux-confirm-ok" style="padding:8px 18px;border:none;border-radius:6px;cursor:pointer;color:#fff;' + (danger ? 'background:#E05555;' : 'background:var(--accent);') + '">' + _esc(okText) + '</button>' +
'</div>');
var closeBtn = $('ux-modal-close');
if (closeBtn) closeBtn.style.display = 'none';
$('ux-confirm-ok').addEventListener('click', function () { closeModal(); if (onOk) onOk(); });
$('ux-confirm-cancel').addEventListener('click', closeModal);
}
function showPrompt(title, placeholder, defaultValue, onOk, opts) {
opts = opts || {};
var inputType = opts.inputType || 'text';
var okText = opts.okText || '确定';
showModal(title,
'<input id="ux-prompt-input" type="' + inputType + '" placeholder="' + _esc(placeholder || '') + '" value="' + _esc(defaultValue == null ? '' : String(defaultValue)) + '"' +
(opts.maxlength ? ' maxlength="' + opts.maxlength + '"' : '') +
(opts.inputmode ? ' inputmode="' + opts.inputmode + '"' : '') +
' style="width:100%;padding:10px 12px;font-size:15px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;color:var(--text);background:var(--card-bg);">' +
'<div id="ux-prompt-msg" style="font-size:13px;color:var(--accent-red);min-height:18px;margin-top:6px;"></div>' +
'<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px;">' +
'<button id="ux-prompt-cancel" class="btn-secondary" style="padding:8px 18px;border:none;border-radius:6px;cursor:pointer;background:#eee;color:var(--text);">取消</button>' +
'<button id="ux-prompt-ok" style="padding:8px 18px;border:none;border-radius:6px;cursor:pointer;background:var(--accent);color:#fff;">' + _esc(okText) + '</button>' +
'</div>');
var closeBtn = $('ux-modal-close');
if (closeBtn) closeBtn.style.display = 'none';
var input = $('ux-prompt-input');
function submit() {
closeModal();
if (onOk) onOk(input.value);
}
$('ux-prompt-ok').addEventListener('click', submit);
$('ux-prompt-cancel').addEventListener('click', function () { closeModal(); if (onOk) onOk(null); });
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
input.focus();
input.select();
}
function loadUsers() {
if (!$('users-tab') || $('users-tab').style.display === 'none') return;
api('GET', '/api/admin/users', null, function (users) {
// 统一渲染全部用户(含历史 pending 申请用户,标记后可删除)
var html = '<h4 style="margin:0 0 8px;font-size:14px;">👥 用户列表</h4>';
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;">' +
'<div>' +
'<strong>' + _esc(u.remark) + '</strong> ' + badges.join(' ') +
'<div style="font-size:12px;color:var(--text-dim);">创建于 ' + _esc(u.created_at || '') + '</div>' +
'</div>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' +
(u.role !== 'admin' ? '<button class="small-btn" data-action="edit-remark" data-id="' + u.id + '">编辑备注</button>' : '') +
(u.is_temp ? '<button class="small-btn" data-action="show-temp-totp" data-id="' + u.id + '" style="color:#e65100;">实时TOTP</button>' : '') +
(u.role !== 'admin' && !u.is_temp ? '<button class="small-btn" data-action="provision-totp" data-id="' + u.id + '">配置TOTP</button>' +
'<button class="small-btn" data-action="provision-passkey" data-id="' + u.id + '">配置Passkey</button>' : '') +
(u.role !== 'admin' ? '<button class="small-btn" data-action="delete-user" data-id="' + u.id + '" style="color:#E05555;">删除</button>' : '') +
'</div></div>';
});
$('user-list').innerHTML = html;
bindUserActions();
});
}
function bindUserActions() {
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 === 'edit-remark') {
_reauthThen(function () {
showPrompt('编辑用户备注', '新的备注', '', function (newRemark) {
if (newRemark === null || !newRemark.trim()) return;
api('PUT', '/api/admin/users/' + id, { remark: newRemark.trim() }, function (res) {
if (res.ok) { toast('备注已更新'); loadUsers(); } else { toast(res.error || '更新失败'); }
});
}, { okText: '保存' });
});
} else if (action === 'delete-user') {
showConfirm('删除用户', '确认删除该用户及其全部数据?此操作不可恢复!', function () {
_reauthThen(function () {
api('DELETE', '/api/admin/users/' + id, null, function (res) {
if (res.ok) { toast('用户已删除'); loadUsers(); } else { toast(res.error || '删除失败'); }
});
});
}, { okText: '删除' });
} else if (action === 'show-temp-totp') {
showTempTotp(id);
} else if (action === 'provision-totp') {
_reauthThen(function () { provisionTotp(id); });
} else if (action === 'provision-passkey') {
_reauthThen(function () { provisionPasskey(id); });
}
});
});
}
// Real-time temp TOTP (admin helper — secret stays on server).
// 高危操作:查看前必须先完成二次验证;轮询期间验证过期则停止并提示。
function showTempTotp(userId) {
_reauthThen(function () {
showModal('临时账户实时 TOTP', '<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">将该实时验证码发给用户,用于首次登录并配置登录方式。</div>' +
'<div id="temp-totp-code" style="font-family:monospace;font-size:34px;letter-spacing:8px;text-align:center;padding:14px;background:#f5f5f5;border-radius:8px;">—</div>' +
'<div id="temp-totp-tip" style="font-size:12px;color:var(--text-dim);text-align:center;margin-top:8px;">每 30 秒自动刷新</div>');
function refresh() {
api('GET', '/api/admin/users/' + userId + '/totp', null, function (res) {
var el = $('temp-totp-code');
if (!el) return;
var tip = $('temp-totp-tip');
if (res && res.code) {
el.textContent = res.code;
if (tip) { tip.textContent = '每 30 秒自动刷新'; tip.style.color = ''; }
} else if (res && res.reauth_required) {
if (_totpTimer) { clearInterval(_totpTimer); _totpTimer = null; }
el.textContent = '—';
if (tip) { tip.textContent = '⚠️ 身份验证已过期,请关闭后重新查看'; tip.style.color = '#E05555'; }
} else {
el.textContent = (res && res.error) || '—';
}
});
}
refresh();
_totpTimer = setInterval(refresh, 5000);
});
}
// Admin-fills TOTP provisioning: show QR + secret; admin verifies the user's code.
function provisionTotp(userId) {
api('POST', '/api/admin/users/' + userId + '/totp/begin', null, function (data) {
if (data.error) { toast(data.error); return; }
showModal('为该用户配置 TOTP', '<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">将此二维码/密钥转交给用户添加到身份验证器应用,然后输入用户手机上的 6 位验证码完成启用。</div>' +
'<div style="text-align:center;margin-bottom:8px;"><img src="data:image/png;base64,' + data.qr_base64 + '" style="max-width:180px;border-radius:8px;"></div>' +
'<div style="font-family:monospace;font-size:12px;background:#f5f5f5;padding:8px;border-radius:6px;text-align:center;word-break:break-all;margin-bottom:10px;">密钥: ' + data.secret + '</div>' +
'<div style="display:flex;gap:8px;justify-content:center;">' +
'<input id="admin-totp-code" maxlength="6" inputmode="numeric" placeholder="6位验证码" style="width:130px;text-align:center;font-size:20px;letter-spacing:4px;">' +
'<button id="admin-totp-verify" class="btn-primary" style="border:none;border-radius:6px;padding:8px 16px;cursor:pointer;">验证并启用</button></div>' +
'<div id="admin-totp-msg" style="font-size:13px;text-align:center;margin-top:8px;"></div>');
$('admin-totp-verify').addEventListener('click', function () {
var code = $('admin-totp-code').value.trim();
if (!code) return;
api('POST', '/api/admin/users/' + userId + '/totp/verify', { code: code }, function (res) {
var msg = $('admin-totp-msg');
if (res.ok) {
msg.textContent = '✅ TOTP 已启用';
msg.style.color = '#2e7d32';
setTimeout(closeModal, 1200);
loadUsers();
} else {
msg.textContent = res.error || '验证失败';
msg.style.color = '#E05555';
}
});
});
});
}
// Admin-fills Passkey provisioning: ceremony completes in the admin's browser.
function provisionPasskey(userId) {
api('POST', '/api/admin/users/' + userId + '/passkey/begin', null, function (options) {
if (options.error) { toast(options.error); return; }
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options)
.then(function (credential) {
credential.user_name = 'user_' + userId;
api('POST', '/api/admin/users/' + userId + '/passkey/complete', { credential: credential }, function (res) {
if (res.ok) { toast('✅ Passkey 已为该用户注册(保存在当前设备)'); loadUsers(); }
else { toast(res.error || '注册失败'); }
});
})
.catch(function (err) {
toast('Passkey 注册已取消');
});
});
}
// ─── Admin register dialogs ───────────────
$('user-add-admin').addEventListener('click', function () {
showModal('管理员代注册', '<div style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">由管理员填写用户信息,账户即刻生效。随后可为其配置 TOTP 或 Passkey。</div>' +
'<label style="font-size:13px;display:block;margin-bottom:4px;">备注(默认为用户 ID)</label>' +
'<input id="admin-add-remark" type="text" placeholder="例如:客厅副屏" style="width:100%;padding:9px;border:1px solid var(--border);border-radius:6px;font-size:14px;margin-bottom:12px;">' +
'<div style="display:flex;gap:8px;justify-content:center;">' +
'<button id="admin-add-ok" class="btn-primary" style="border:none;border-radius:6px;padding:9px 16px;cursor:pointer;">创建账户</button>' +
'</div>');
$('admin-add-ok').addEventListener('click', function () {
var remark = $('admin-add-remark').value.trim();
_reauthThen(function () {
api('POST', '/api/admin/users', { remark: remark, mode: 'admin_fills' }, function (res) {
if (res.ok) {
toast('账户已创建');
closeModal();
loadUsers();
} else {
toast(res.error || '创建失败');
}
});
});
});
});
$('user-add-temp').addEventListener('click', function () {
showModal('创建临时账户(用户自助填写)', '<div style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">系统生成临时 TOTP(密钥仅存于服务器)。创建后点击用户列表中的「实时TOTP」获取实时验证码交给用户;用户用它登录后会被强制引导配置自己的登录方式,配置完成前临时 TOTP 不会失效。</div>' +
'<label style="font-size:13px;display:block;margin-bottom:4px;">备注(默认为用户 ID)</label>' +
'<input id="temp-add-remark" type="text" placeholder="例如:次卧副屏" style="width:100%;padding:9px;border:1px solid var(--border);border-radius:6px;font-size:14px;margin-bottom:12px;">' +
'<div style="display:flex;gap:8px;justify-content:center;">' +
'<button id="temp-add-ok" class="btn-primary" style="border:none;border-radius:6px;padding:9px 16px;cursor:pointer;">生成临时账户</button>' +
'</div>');
$('temp-add-ok').addEventListener('click', function () {
var remark = $('temp-add-remark').value.trim();
_reauthThen(function () {
api('POST', '/api/admin/users', { remark: remark, mode: 'user_fills' }, function (res) {
if (res.ok) {
toast('临时账户已创建');
closeModal();
loadUsers();
} else {
toast(res.error || '创建失败');
}
});
});
});
});
$('user-refresh-btn').addEventListener('click', loadUsers);
// Root: kick every user's devices (control + display, all accounts)
var kickAllUsersBtn = $('kick-all-users-btn');
if (kickAllUsersBtn) {
kickAllUsersBtn.addEventListener('click', function () {
showConfirm('踢掉所有用户', '所有用户的控制端与显示端将立即全部下线(包含当前设备),确定?', function () {
_reauthThen(function () {
api('POST', '/api/admin/kick_all_users', null, function (res) {
if (res.ok) {
toast('已踢掉所有用户,即将下线');
} else {
toast(res.error || '操作失败');
}
});
});
});
});
}
// ─── Init ─────────────────────────────────
loadGroups();
updateEventGroupSelects();
loadWeatherConfig();
loadAlarms();
loadPomodoro();
loadDisplayMode();
loadTimezone();
loadMFAStatus();
loadAuthStatus();
loadThemeSetting();
// Refresh pomodoro status every minute when on pomodoro tab
setInterval(function () {
var tab = document.querySelector('.tab.active');
if (tab && tab.dataset.tab === 'pomodoro') {
loadPomodoro();
}
}, 60000);
})();