/* 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 ────────────────────────────── function api(method, url, data, callback) { var xhr = new XMLHttpRequest(); xhr.open(method, url); if (data && !(data instanceof FormData)) { xhr.setRequestHeader('Content-Type', 'application/json'); } xhr.onload = function () { if (xhr.status === 401) { var res = JSON.parse(xhr.responseText || '{}'); if (res.login_required) { location.href = '/login?view=control&next=' + encodeURIComponent(location.pathname + location.search); return; } } if (callback) callback(JSON.parse(xhr.responseText || '{}')); }; if (data instanceof FormData) { xhr.send(data); } else if (data) { xhr.send(JSON.stringify(data)); } else { xhr.send(); } } // 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, '''); } // 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 += '
' + '
' + '
' + '
' + '
' + g.name + ' ' + (g.enabled ? '显示' : '隐藏') + '
' + '
' + g.sources.length + ' 个数据源
' + '
' + '
' + '
' + '' + '' + '' + '
' + '
'; // Sources g.sources.forEach(function (s) { html += '
' + '' + (s.type === 'upload' ? '📄' : '🔗') + ' ' + s.name + '' + '' + (s.type === 'subscription' ? ' ' : '') + '' + '
'; }); }); $('group-list').innerHTML = html || '
暂无分组
'; bindGroupActions(); // Update selects [['upload-group','subscribe-group','reminder-group']].forEach(function () { var selHtml = ''; groups.forEach(function (g) { selHtml += ''; }); ['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 = '
未找到匹配城市
'; return; } var html = ''; results.forEach(function (loc) { html += '
' + '' + loc.name + ' ' + (loc.adm1 || '') + ' ' + (loc.country || '') + '' + '' + loc.lat + ', ' + loc.lon + '' + '
'; }); 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 = '
已选择: ' + el.dataset.name + '
'; }); }); }); }); // ─── 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 = '' + data.error + ''; } else { el.innerHTML = '' + (data.city || '') + ' ' + (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 ? '
更新: ' + data.updated_at + '' : ''); } }); } $('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 += '
' + '
' + a.time + ' ' + (a.label || '') + ' ' + days + '' + ' ' + (a.enabled ? '开' : '关') + '' + '
' + '
' + '' + '' + '
' + '
'; }); $('alarm-list').innerHTML = html || '
暂无闹钟
'; 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 += '
' + res.filename + '
'; } }); }); // ─── 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'); api('POST', '/api/display/kick_all', null, function (res) { if (res.ok) { 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 () { api('POST', '/api/auth/logout', null, 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 = '
日程列表
'; if (events.length === 0) { html += '
暂无日程
'; } 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 += '
' + '
' + '
' + _esc(ev.summary || '无标题') + '
' + '
' + timeRange + (ev.location ? ' · ' + _esc(ev.location) : '') + '
' + '
' + '
' + '' + '' + '
' + '
'; }); } 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 += ''; }); ['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 = '
暂无日程
'; } 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 += '
' + '
' + '
' + _esc(ev.summary || '无标题') + '
' + '
' + timeRange + (ev.location ? ' · ' + _esc(ev.location) : '') + '
' + '
' + '
' + '' + '' + '
' + '
'; }); } $('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 = 'TOTP QR'; $('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 '' + c + ' '; }).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 = '
' + '

' + title + '

' + '
' + html + '
' + '
' + '' + '
'; 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, '
' + message + '
' + '
' + '' + '' + '
'); 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, '' + '
' + '
' + '' + '' + '
'); 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 = '

⏳ 待确认申请(用户自助填写信息)

'; pending.forEach(function (u) { pendHtml += '
' + '
' + _esc(u.remark) + ' ' + _esc(u.created_at || '') + '
' + '
' + '' + '' + '
'; }); } $('pending-requests').innerHTML = pendHtml; // Active users var html = '

👥 用户列表

'; if (active.length === 0) html += '
暂无用户
'; active.forEach(function (u) { var badges = []; if (u.role === 'admin') badges.push('管理员'); if (u.is_temp) badges.push('临时账户'); if (u.has_mfa) badges.push('已配置 MFA'); html += '
' + '
' + '' + _esc(u.remark) + ' ' + badges.join(' ') + '
创建于 ' + _esc(u.created_at || '') + '
' + '
' + '
' + (u.role !== 'admin' ? '' : '') + (u.is_temp ? '' : '') + (u.role !== 'admin' && !u.is_temp ? '' + '' : '') + (u.role !== 'admin' ? '' : '') + '
'; }); $('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', '
将该实时验证码发给用户,用于首次登录并配置登录方式。
' + '
—
' + '
每 30 秒自动刷新
'); 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', '
将此二维码/密钥转交给用户添加到身份验证器应用,然后输入用户手机上的 6 位验证码完成启用。
' + '
' + '
密钥: ' + data.secret + '
' + '
' + '' + '
' + '
'); $('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('管理员代注册', '
由管理员填写用户信息,账户即刻生效。随后可为其配置 TOTP 或 Passkey。
' + '' + '' + '
' + '' + '
'); $('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('创建临时账户(用户自助填写)', '
系统生成临时 TOTP(密钥仅存于服务器)。创建后点击用户列表中的「实时TOTP」获取实时验证码交给用户;用户用它登录后会被强制引导配置自己的登录方式,配置完成前临时 TOTP 不会失效。
' + '' + '' + '
' + '' + '
'); $('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); // ─── 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); })();