Files
shiroko-monitor/public/assets/admin.js
T
tamakyi 752fad412b feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)
① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
2026-10-14 12:37:07 +08:00

594 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 管理后台前端:绑定 2FA → 登录 → 管理面板
*
* 零依赖,QR 码用内置的最小实现生成(只支持 byte 模式、ECC L、版本自动 1–10),
* 覆盖 otpauth 链接(通常 60–120 字节)的长度。
*/
const $ = (id) => document.getElementById(id);
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const API = '/api/admin';
let setupDraft = null; // 绑定过程中的临时状态:{ setupToken, secret, uri }
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`${API}${path}`, {
method,
credentials: 'same-origin',
cache: 'no-store',
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30000),
});
let data = null;
try { data = await res.json(); } catch { /* 空响应体 */ }
return { ok: res.ok, status: res.status, data, retryAfter: res.headers.get('retry-after') };
}
function msg(el, text, kind = '') {
el.textContent = text;
el.className = `admin-msg${kind ? ` ${kind}` : ''}`;
}
const ERROR_TEXT = {
'bad-setup-key': '绑定口令不正确',
'setup-disabled': '未设置 SHIROKO_ADMIN_SETUP_KEY 环境变量,管理端未开放绑定',
'already-bound': '已完成绑定,不能重复绑定',
'no-setup': '绑定流程已失效,请重新开始',
'setup-expired': '绑定流程超过 10 分钟已失效,请重新开始',
'invalid-code': '验证码不正确',
'invalid-format': '格式不正确',
replayed: '该验证码已被使用过,请等下一个时间窗',
locked: '失败次数过多,请稍后再试',
'not-bound': '尚未绑定 2FA',
unauthorized: '会话已失效,请重新登录',
'invalid-window': '时间无效,或结束早于开始',
'invalid-index': '窗口序号无效',
'invalid-scope': '探测范围取值无效',
'invalid-filter': '展示口径取值无效',
'notify-disabled': '未启用任何通知渠道',
};
const errText = (code, hint) => ERROR_TEXT[code] ?? hint ?? '操作失败';
/* ============================================================
* QR 码生成(byte 模式 / ECC L / 版本 1–10)
* 参考 ISO/IEC 18004;这里只保留生成 otpauth 链接所需的最小子集。
* ============================================================ */
// GF(256) 对数/指数表,本原多项式 0x11D
const GF_EXP = new Uint8Array(512);
const GF_LOG = new Uint8Array(256);
(function initGf() {
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
})();
const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]);
function rsGeneratorPoly(degree) {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next = new Array(poly.length + 1).fill(0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= poly[j];
next[j + 1] ^= gfMul(poly[j], GF_EXP[i]);
}
poly = next;
}
return poly;
}
function rsEncode(data, ecLen) {
const gen = rsGeneratorPoly(ecLen);
const res = new Uint8Array(data.length + ecLen);
res.set(data);
for (let i = 0; i < data.length; i++) {
const factor = res[i];
if (factor === 0) continue;
for (let j = 0; j < gen.length; j++) res[i + j] ^= gfMul(gen[j], factor);
}
return res.slice(data.length);
}
// 版本 1–10 在 ECC L 下的参数表:[总码字数, EC 每块码字数, 组1块数, 组1数据码字, 组2块数, 组2数据码字]
// 索引 0 = 版本 1
const VERSIONS = [
null,
[26, 7, 1, 19, 0, 0],
[44, 10, 1, 34, 0, 0],
[70, 15, 1, 55, 0, 0],
[100, 20, 1, 80, 0, 0],
[134, 26, 1, 108, 0, 0],
[172, 18, 2, 68, 0, 0],
[196, 20, 2, 78, 0, 0],
[242, 24, 2, 97, 0, 0],
[292, 30, 2, 116, 0, 0],
[346, 18, 2, 68, 2, 69],
];
const ALIGN_POS = {
1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
};
function pickVersion(byteLen) {
for (let v = 1; v <= 10; v++) {
const [, ecLen, b1, d1, b2, d2] = VERSIONS[v];
const capacityBits = b1 * d1 * 8 + b2 * d2 * 8;
const headerBits = 4 + (v < 10 ? 8 : 16);
if (headerBits + byteLen * 8 <= capacityBits) return v;
}
throw new Error('内容过长,无法生成二维码');
}
function buildCodewords(bytes, version) {
const [, ecLen, b1, d1, b2, d2] = VERSIONS[version];
const totalData = b1 * d1 + b2 * d2;
const bits = [];
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
put(0b0100, 4); // byte 模式
put(bytes.length, version < 10 ? 8 : 16);
for (const b of bytes) put(b, 8);
// 终止符最多 4 个 0,再补齐到字节边界
for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0);
while (bits.length % 8 !== 0) bits.push(0);
const codewords = new Uint8Array(totalData);
for (let i = 0; i < bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
codewords[i / 8] = byte;
}
// 填充字节 0xEC / 0x11 交替
const PAD = [0xec, 0x11];
for (let i = bits.length / 8, k = 0; i < totalData; i++, k++) codewords[i] = PAD[k % 2];
// 分块 + 纠错 + 交织
const blocks = [];
let off = 0;
for (let i = 0; i < b1; i++) { blocks.push(codewords.slice(off, off + d1)); off += d1; }
for (let i = 0; i < b2; i++) { blocks.push(codewords.slice(off, off + d2)); off += d2; }
const ecBlocks = blocks.map((b) => rsEncode(b, ecLen));
const out = [];
const maxData = Math.max(d1, d2);
for (let i = 0; i < maxData; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
for (let i = 0; i < ecLen; i++) for (const b of ecBlocks) out.push(b[i]);
return out;
}
function makeMatrix(version, codewords) {
const size = version * 4 + 17;
const m = Array.from({ length: size }, () => new Array(size).fill(null));
const setFn = (r, c, v) => { if (r >= 0 && r < size && c >= 0 && c < size) m[r][c] = v; };
const finder = (r0, c0) => {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const inRing = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6));
const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
setFn(r0 + r, c0 + c, inRing || inCore ? 1 : 0);
}
}
};
finder(0, 0);
finder(0, size - 7);
finder(size - 7, 0);
// 校正图形(跳过与 finder 图案重叠的位置)
for (const r of ALIGN_POS[version] ?? []) {
for (const c of ALIGN_POS[version] ?? []) {
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue;
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
setFn(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0);
}
}
}
}
// 定时图形
for (let i = 8; i < size - 8; i++) {
setFn(6, i, i % 2 === 0 ? 1 : 0);
setFn(i, 6, i % 2 === 0 ? 1 : 0);
}
// 预留格式信息位(15 位)与版本信息位(版本 ≥ 7)
for (let i = 0; i <= 8; i++) {
if (m[8][i] === null) setFn(8, i, 0);
if (m[i][8] === null) setFn(i, 8, 0);
}
for (let i = 0; i < 8; i++) {
if (m[8][size - 1 - i] === null) setFn(8, size - 1 - i, 0);
if (m[size - 1 - i][8] === null) setFn(size - 1 - i, 8, 0);
}
if (version >= 7) {
for (let i = 0; i < 18; i++) {
setFn(Math.floor(i / 3), size - 11 + (i % 3), 0);
setFn(size - 11 + (i % 3), Math.floor(i / 3), 0);
}
}
const reserved = m.map((row) => row.map((v) => v !== null));
// 数据按之字形填入(从右下角起,每两列一组,方向交替)
let bitIndex = 0;
const dataBits = [];
for (const cw of codewords) for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
let upward = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col === 6) col--; // 跳过垂直定时图形所在列
for (let k = 0; k < size; k++) {
const row = upward ? size - 1 - k : k;
for (const c of [col, col - 1]) {
if (reserved[row][c]) continue;
m[row][c] = dataBits[bitIndex] ? 1 : 0;
bitIndex++;
}
}
upward = !upward;
}
// 8 种掩码,按 ISO 惩罚分选最低
const maskFns = [
(r, c) => (r + c) % 2 === 0,
(r) => r % 2 === 0,
(r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
const applyMask = (base, fn) => base.map((row, r) => row.map((v, c) => (reserved[r][c] ? v : (v ^ (fn(r, c) ? 1 : 0)))));
const penalty = (g) => {
let score = 0;
// 规则 1:同色连续 ≥5
for (const line of [...g, ...g[0].map((_, c) => g.map((row) => row[c]))]) {
let run = 1;
for (let i = 1; i < line.length; i++) {
if (line[i] === line[i - 1]) run++;
else { if (run >= 5) score += run - 2; run = 1; }
}
if (run >= 5) score += run - 2;
}
// 规则 2:2×2 同色块
for (let r = 0; r < g.length - 1; r++) {
for (let c = 0; c < g.length - 1; c++) {
const v = g[r][c];
if (v === g[r][c + 1] && v === g[r + 1][c] && v === g[r + 1][c + 1]) score += 3;
}
}
// 规则 4:黑白比例偏离 50%
let dark = 0;
for (const row of g) for (const v of row) dark += v;
const pct = (dark * 100) / (g.length * g.length);
score += Math.floor(Math.abs(pct - 50) / 5) * 10;
return score;
};
let best = null;
for (let i = 0; i < 8; i++) {
const cand = applyMask(m, maskFns[i]);
const sc = penalty(cand);
if (!best || sc < best.score) best = { score: sc, grid: cand, mask: i };
}
// 写回格式信息:ECC L 的指示位为 0b01,拼接 3 位掩码编号后按 BCH(15,5) 计算校验并与 0x5412 掩码异或
const fmtData = (0b01 << 3) | best.mask;
let bch = fmtData << 10;
for (let i = 4; i >= 0; i--) {
if (bch & (1 << (i + 10))) bch ^= 0b10100110111 << i;
}
const format = ((fmtData << 10) | (bch & 0x3ff)) ^ 0b101010000010010;
const f = bitsOf(format, 15);
for (let i = 0; i <= 5; i++) best.grid[8][i] = f[i];
best.grid[8][7] = f[6];
best.grid[8][8] = f[7];
best.grid[7][8] = f[8];
for (let i = 9; i <= 14; i++) best.grid[14 - i][8] = f[i];
for (let i = 0; i <= 7; i++) best.grid[size - 1 - i][8] = f[i];
for (let i = 8; i <= 14; i++) best.grid[8][size - 15 + i] = f[i];
best.grid[size - 8][8] = 1; // 固定的黑点
return best.grid;
}
function bitsOf(val, len) {
const out = [];
for (let i = len - 1; i >= 0; i--) out.push((val >> i) & 1);
return out;
}
/** 把文本渲染成 canvas 上的二维码 */
function renderQr(text, canvas) {
const bytes = [...new TextEncoder().encode(text)];
const version = pickVersion(bytes.length);
const grid = makeMatrix(version, buildCodewords(bytes, version));
const n = grid.length;
const scale = Math.floor(canvas.width / (n + 2));
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
const offset = Math.floor((canvas.width - n * scale) / 2);
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (grid[r][c]) ctx.fillRect(offset + c * scale, offset + r * scale, scale, scale);
}
}
}
/* ============================================================
* 流程控制
* ============================================================ */
async function boot() {
const { ok, data } = await api('/status');
if (!ok) { msg($('loading'), '无法连接服务端'); return; }
$('loading').hidden = true;
if (!data.bound) {
if (!data.setupKeyConfigured) {
$('bind-panel').hidden = false;
msg($('setup-key-msg'), '服务端未设置 SHIROKO_ADMIN_SETUP_KEY,绑定未开放。请在启动探针的环境里设置后重启。', 'err');
$('setup-start').disabled = true;
return;
}
$('bind-panel').hidden = false;
return;
}
// 已绑定:直接试拉概览,能拉到说明会话还有效
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
$('login-panel').hidden = false;
$('login-code').focus();
}
function showConsole(data) {
$('login-panel').hidden = true;
$('bind-panel').hidden = true;
$('console-panel').hidden = false;
$('logout-btn').hidden = false;
$('admin-ver').textContent = `shiroko-monitor v${data.version}`;
renderConsole(data);
}
function renderConsole(d) {
const s = d.stats ?? {};
$('admin-stats').innerHTML = [
`<div class="admin-stat"><b>${s.total ?? '–'}</b><label>监控数</label></div>`,
`<div class="admin-stat up"><b>${s.up ?? '–'}</b><label>在线</label></div>`,
`<div class="admin-stat down"><b>${s.down ?? '–'}</b><label>离线</label></div>`,
`<div class="admin-stat"><b>${s.avgLatency != null ? `${s.avgLatency} ms` : '–'}</b><label>平均延迟</label></div>`,
`<div class="admin-stat"><b>${s.avgUptime90 != null ? `${s.avgUptime90}%` : '–'}</b><label>90天可用率</label></div>`,
].join('');
const st = d.status ?? {};
const channels = d.notify?.channels ?? [];
$('updated-at').innerHTML =
`数据更新于 ${esc(new Date(st.dataUpdatedAt ?? Date.now()).toLocaleString('zh-CN', { hour12: false }))} · ` +
`自建目标 ${st.localCount ?? 0} 个 / 配置 ${st.configCount ?? 0} 个 / UptimeRobot ${st.urCount ?? 0} 个` +
(st.urEnabled ? (st.urOk === false ? `(<b>同步异常:${esc(st.urError ?? '')}</b>)` : '(已启用)') : '(未启用)') +
(channels.length ? ` · 通知渠道 ${esc(channels.join(' + '))}` : '');
$('probe-scope').value = d.settings?.probeScope ?? 'all';
$('data-filter').value = d.settings?.dataFilter ?? 'both';
// 维护窗口目标下拉
const sel = $('mw-id');
const cur = sel.value;
sel.innerHTML = '<option value="">全部目标</option>' + d.monitors.map((m) => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
if (cur) sel.value = cur;
const now = Date.now();
$('mw-list').innerHTML = d.maintenance.length
? d.maintenance.map((w, i) => {
const active = Date.parse(w.from) <= now && now <= Date.parse(w.to);
const scope = w.id ? (d.monitors.find((m) => m.id === w.id)?.name ?? w.id) : '全部目标';
return `<div class="mw-item${active ? ' active' : ''}">
<span class="mw-tag ${active ? 'active' : 'idle'}">${active ? '进行中' : '待生效'}</span>
<span class="mw-time">${esc(String(w.from).replace('T', ' ').slice(0, 16))} → ${esc(String(w.to).replace('T', ' ').slice(0, 16))}</span>
<span class="mw-note">${esc(scope)}${w.note ? ` · ${esc(w.note)}` : ''}</span>
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" data-del="${i}">删除</button>
</div>`;
}).join('')
: '<p class="admin-muted">暂无维护窗口。</p>';
for (const btn of $('mw-list').querySelectorAll('[data-del]')) {
btn.addEventListener('click', async () => {
const r = await api('/maintenance', { method: 'DELETE', body: { index: Number(btn.dataset.del) } });
if (r.ok) refresh();
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
}
$('monitor-list').innerHTML = d.monitors.map((m) => `<tr>
<td title="${esc(m.name)}">${esc(m.name)}</td>
<td>${esc(m.group)}</td>
<td>${esc(m.type)}</td>
<td class="mono" title="${esc(m.target)}">${esc(m.target)}</td>
<td>${m.interval != null ? `${m.interval}s` : '–'}</td>
</tr>`).join('');
const labels = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
$('event-list').innerHTML = d.events.length
? d.events.map((e) => `<tr data-status="${esc(statusOf[e.type] ?? 'up')}">
<td title="${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}">${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}</td>
<td><span class="ev-badge">${esc(labels[e.type] ?? e.type)}</span></td>
<td title="${esc(e.name)}">${esc(e.name)}</td>
<td class="mono" title="${esc(e.detail ?? '')}">${esc(e.detail ?? '')}</td>
</tr>`).join('')
: '<tr><td colspan="4">暂无事件</td></tr>';
const tf = d.twofa ?? {};
$('twofa-summary').innerHTML = `已绑定于 ${esc(tf.boundAt ?? '–')} · 剩余恢复码 ${tf.recoveryLeft ?? 0} 枚 · 当前会话 ${tf.sessions ?? 0} 个`
+ (tf.recoveryLow ? ' · <b style="color:var(--pending-text)">恢复码不足 3 枚,请重新绑定以获取新的一批</b>' : '');
}
async function refresh() {
const r = await api('/overview');
if (r.ok) renderConsole(r.data);
else if (r.status === 401) { $('console-panel').hidden = true; $('login-panel').hidden = false; }
}
/* ---------- 绑定 ---------- */
$('setup-start').addEventListener('click', async () => {
const key = $('setup-key').value;
msg($('setup-key-msg'), '');
const r = await api('/2fa/setup', { method: 'POST', body: { key } });
if (!r.ok) { msg($('setup-key-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
setupDraft = { setupToken: r.data.setupToken, secret: r.data.secret, uri: r.data.uri };
$('setup-key').value = '';
$('step-key').hidden = true;
$('step-secret').hidden = false;
$('secret-text').textContent = r.data.secret.replace(/(.{4})/g, '$1 ').trim();
$('uri-text').textContent = r.data.uri;
try { renderQr(r.data.uri, $('qr')); } catch (err) { msg($('confirm-msg'), `二维码生成失败,可手动输入密钥:${err.message}`, 'err'); }
$('confirm-code').focus();
});
$('copy-secret').addEventListener('click', () => copy(setupDraft?.secret ?? ''));
$('copy-uri').addEventListener('click', () => copy(setupDraft?.uri ?? ''));
async function copy(text) {
if (!text) return;
try { await navigator.clipboard.writeText(text); } catch { /* 非安全上下文下 clipboard 不可用 */ }
}
$('confirm-btn').addEventListener('click', async () => {
const code = $('confirm-code').value.trim();
msg($('confirm-msg'), '');
const r = await api('/2fa/confirm', { method: 'POST', body: { setupToken: setupDraft?.setupToken, code } });
if (!r.ok) { msg($('confirm-msg'), errText(r.data?.error), 'err'); return; }
$('step-secret').hidden = true;
$('step-recovery').hidden = false;
setupDraft.confirmCode = r.data.confirmCode;
$('recovery-list').innerHTML = r.data.recoveryCodes.map((c) => `<code>${esc(c)}</code>`).join('');
$('copy-recovery').onclick = () => copy(r.data.recoveryCodes.join('\n'));
});
// 绑定完成即视为本次操作已通过 2FA(刚用 App 的验证码证明了持有),
// 直接用绑定时那枚验证码换会话,不再要求用户再输一次
$('recovery-done').addEventListener('click', async () => {
$('recovery-done').disabled = true;
const r = await api('/login', { method: 'POST', body: { code: setupDraft?.confirmCode } });
$('recovery-done').disabled = false;
if (r.ok) {
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
}
// 兜底:让用户手动登录(同一时间窗内的码已被防重放消费,也属正常)
$('bind-panel').hidden = true;
$('login-panel').hidden = false;
$('login-code').focus();
});
/* ---------- 登录 ---------- */
$('login-btn').addEventListener('click', doLogin);
$('login-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
async function doLogin() {
const code = $('login-code').value.trim();
if (!code) return;
msg($('login-msg'), '');
$('login-btn').disabled = true;
try {
const r = await api('/login', { method: 'POST', body: { code } });
if (!r.ok) {
const extra = r.data?.fails ? `(已失败 ${r.data.fails} 次)` : (r.retryAfter ? `(约 ${r.retryAfter} 秒后可试)` : '');
msg($('login-msg'), errText(r.data?.error) + extra, 'err');
return;
}
$('login-code').value = '';
const ov = await api('/overview');
if (ov.ok) showConsole(ov.data);
else msg($('login-msg'), '登录成功但读取概览失败,请刷新页面', 'err');
} finally {
$('login-btn').disabled = false;
}
}
$('reset-btn').addEventListener('click', async () => {
const key = $('reset-key').value;
if (!key) return;
if (!confirm('重置 2FA 会清空已绑定的密钥与全部恢复码,需重新绑定。确定继续?')) return;
const r = await api('/2fa/reset', { method: 'POST', body: { key } });
if (r.ok) location.reload();
else msg($('login-msg'), errText(r.data?.error), 'err');
});
$('logout-btn').addEventListener('click', async () => {
await api('/logout', { method: 'POST' });
location.reload();
});
/* ---------- 管理操作 ---------- */
$('probe-scope').addEventListener('change', async (e) => {
const r = await api('/probe-scope', { method: 'POST', body: { probeScope: e.target.value } });
if (r.ok) { msg($('action-msg'), `探测范围已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('data-filter').addEventListener('change', async (e) => {
const r = await api('/data-filter', { method: 'POST', body: { dataFilter: e.target.value } });
if (r.ok) { msg($('action-msg'), `展示口径已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('probe-now').addEventListener('click', async () => {
const btn = $('probe-now');
btn.disabled = true;
msg($('action-msg'), '正在探测…');
try {
const r = await api('/probe-now', { method: 'POST' });
if (r.ok) { msg($('action-msg'), `探测完成:在线 ${r.data.stats.up},离线 ${r.data.stats.down}`, 'ok'); refresh(); }
else msg($('action-msg'), '探测失败', 'err');
} finally { btn.disabled = false; }
});
$('notify-test').addEventListener('click', async () => {
const btn = $('notify-test');
btn.disabled = true;
msg($('action-msg'), '正在发送…');
try {
const r = await api('/notify-test', { method: 'POST' });
if (!r.ok) { msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
const detail = (r.data.results ?? []).map((x) => `${x.channel} ${x.ok ? '成功' : `失败:${x.error}`}`).join(';');
msg($('action-msg'), detail || '无渠道', r.data.ok ? 'ok' : 'err');
} finally { btn.disabled = false; }
});
// 维护窗口默认填「现在起 1 小时」
(function initDefaults() {
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = new Date();
$('mw-from').value = fmt(now);
$('mw-to').value = fmt(new Date(now.getTime() + 60 * 60 * 1000));
})();
$('mw-add').addEventListener('click', async () => {
const from = $('mw-from').value;
const to = $('mw-to').value;
if (!from || !to) { msg($('action-msg'), '请填写开始与结束时间', 'err'); return; }
const body = { from: new Date(from).toISOString(), to: new Date(to).toISOString() };
const note = $('mw-note').value.trim();
const id = $('mw-id').value;
if (note) body.note = note;
if (id) body.id = id;
const r = await api('/maintenance', { method: 'POST', body });
if (r.ok) { msg($('action-msg'), '维护窗口已新增', 'ok'); $('mw-note').value = ''; refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
boot();