1396 lines
62 KiB
JavaScript
1396 lines
62 KiB
JavaScript
/* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// 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 || '操作取消');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── 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();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── 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) {
|
||
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
|
||
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) {
|
||
var badges = [];
|
||
if (u.role === 'admin') badges.push('<span class="badge badge-on">管理员</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('#pending-requests [data-action], #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') {
|
||
_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);
|
||
|
||
})();
|