Files
shiroko-monitor/public/assets/admin.js
T
tamakyi 6b0a23c38e feat: v0.12.2 —— 跨域只读支持(面板可部署在别处)
用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages
并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。

硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止
HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。
因此 settings.dataUrl 必须与面板页面同为 HTTPS。

服务端
- 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、
  /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin
- /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了
- 绝不使用 '*':白名单里写 '*' 不放行任何来源

前端
- settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址
- 数据源标记为「远程探针(跨域)」,页脚可区分
- 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效)

关键设计:origin 匹配必须考虑端口
origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口),
导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。
修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。

测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。
全项目 8 套 334 项通过。

附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin
(CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据,
不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
2026-10-14 16:19:31 +08:00

625 lines
25 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';
$('admin-url').value = d.settings?.adminUrl ?? '';
$('admin-url').dataset.saved = $('admin-url').value;
$('data-url').value = d.settings?.dataUrl ?? '';
$('cors-origins').value = (d.settings?.corsOrigins ?? []).join(', ');
// 维护窗口目标下拉
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');
});
$('admin-url').addEventListener('change', async (e) => {
const v = e.target.value.trim();
const r = await api('/admin-url', { method: 'POST', body: { adminUrl: v } });
if (r.ok) {
msg($('action-msg'), v ? `面板页脚将显示指向 ${v} 的「管理」入口` : '已清空,面板不再显示管理入口', 'ok');
refresh();
} else {
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
e.target.value = $('admin-url').dataset.saved ?? '';
}
});
async function saveRemoteSettings() {
const dataUrl = $('data-url').value.trim();
const corsOrigins = $('cors-origins').value.split(',').map((s) => s.trim()).filter(Boolean);
const r = await api('/remote', { method: 'POST', body: { dataUrl, corsOrigins } });
if (r.ok) {
msg($('action-msg'), dataUrl ? `跨域数据源:${r.data.dataUrl};已放行 ${r.data.corsOrigins.length} 个来源` : '已清空跨域数据源', 'ok');
refresh();
} else {
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
}
}
$('data-url').addEventListener('change', saveRemoteSettings);
$('cors-origins').addEventListener('change', saveRemoteSettings);
$('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();