/* 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 + ' 个数据源
' +
'
' +
'
' +
'
' +
'日程 ' +
'' + (g.enabled ? '隐藏' : '显示') + ' ' +
'删除 ' +
'
' +
'
';
// 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 += '' + g.name + ' ';
});
['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 ? '开' : '关') + ' ' +
'
' +
'
' +
'' + (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 += '' + g.name + ' ';
});
['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 = ' ';
$('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 + '
' +
'' +
'取消 ' +
'' + _esc(okText) + ' ' +
'
');
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,
' ' +
'
' +
'' +
'取消 ' +
'' + _esc(okText) + ' ' +
'
');
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 ? '实时TOTP ' : '') +
(u.role !== 'admin' && !u.is_temp ? '配置TOTP ' +
'配置Passkey ' : '') +
(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。
' +
'备注(默认为用户 ID) ' +
'' +
'' +
'创建账户 ' +
'
');
$('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 不会失效。
' +
'备注(默认为用户 ID) ' +
'' +
'' +
'生成临时账户 ' +
'
');
$('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);
})();