Files
ark-stronghold-protocol/public/skland.html
T
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

540 lines
25 KiB
HTML
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.
<!doctype html>
<!--
public/skland.html — 森空岛(Skland)账号绑定的独立诊断 / 引导面板(server/skland/ 的浏览器端)。
这不是游戏 Preact UI 的一部分(public/js/screens/ 里的界面不动):一个单文件、无构建、无框架的页面,
三步走完绑定的三条路径 —— 扫码(QR)/ 验证码(SMS)/ 密码 —— 并随时显示绑定状态与角色列表。
身份:?player=<id> > localStorage('sp.skland.player') > 新生成的占位 id。这个 id 目前只是占位,
真正的游戏会话接入后应由会话自己的 player id 提供(server/skland/service.js 的头注释、docs/skland.md 安全模型)。
端点(全部 JSON;错误是 {ok:false,error} 或双语错误页;详见 server/skland/http.js):
GET /skland/status?player=… 绑定状态
POST /skland/scan/start {player} 开始扫码 → {scanId, scanUrl}(二维码编码 scanUrl 深链,不是 scanId)
POST /skland/scan/poll {player} 每 ~2s 轮询一次,confirmed 的那次调用同时完成登录
POST /skland/phone/send {player,phone,captcha?} 发短信验证码;可能要 geetest 人机验证
POST /skland/phone/login {player,phone,code} 验证码登录
POST /skland/password/login {player,phone,password} 密码登录;仅上游要求时才需要额外设备验证(needDevice)
POST /skland/unbind {player} 解绑
GET /skland/roles?player=…&refresh=1 重新拉取角色
模块默认关闭(SP_SKLAND=1 才挂载):此时 /skland/* 由占位处理器统一答 JSON 404。页面据此显示"该功能未启用",
而不是看起来坏掉。
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>森空岛绑定 · Skland · 卫戍协议:盟约</title>
<!-- 二维码库:qrcode-generator(MIT),由 tools/vendor.mjs 在安装期复制到 public/vendor/。
源码检出 / 未跑 npm install 时它可能不存在——脚本缺失不报错,renderQr() 回退为可选中的深链文本。 -->
<script src="/vendor/qrcode.js"></script>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #111614; color: #d8e3de; font: 15px/1.6 "Noto Sans SC", system-ui, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 22px; margin: 0 0 4px; color: #4ed8af; letter-spacing: .04em; }
h2 { font-size: 15px; margin: 0 0 10px; color: #4ed8af; letter-spacing: .08em; }
.sub { opacity: .7; margin: 0 0 16px; font-size: 13px; }
.card { border: 1px solid #2c3a35; background: #0c0f0e; padding: 16px; margin: 0 0 16px; }
.tabs { display: flex; gap: 8px; margin: 0 0 12px; }
.tabs button { flex: 1; padding: 8px 10px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; cursor: pointer; }
.tabs button[aria-selected="true"] { border-color: #4ed8af; color: #4ed8af; }
.panel { border: 1px solid #2c3a35; background: #0c0f0e; padding: 16px; }
label { display: block; margin: 10px 0 4px; font-size: 13px; opacity: .8; }
input { width: 100%; padding: 8px 10px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; }
button.act { padding: 8px 14px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: inherit; cursor: pointer; }
button.act:hover:not(:disabled) { border-color: #4ed8af; color: #4ed8af; }
button.act:disabled { opacity: .45; cursor: not-allowed; }
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 2px 14px; margin: 0 0 10px; }
.kv dt { opacity: .6; font-size: 13px; }
.kv dd { margin: 0; word-break: break-all; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0; }
th, td { border: 1px solid #2c3a35; padding: 4px 8px; text-align: left; }
th { opacity: .6; font-weight: 400; }
.hint { font-size: 13px; opacity: .75; margin: 10px 0 0; white-space: pre-wrap; }
.hint.err, .err { color: #ff9d8a; }
.off, .warn { border: 1px solid #4ed8af; background: #12211c; padding: 10px 12px; margin: 0 0 12px; color: #4ed8af; font-size: 13px; }
.warn { border-color: #ffb454; background: #241c10; color: #ffb454; }
.deeplink { width: 100%; margin-top: 8px; padding: 8px; background: #141816; border: 1px solid #2c3a35; color: #d8e3de; font: 12px/1.5 monospace; }
#qr-box svg { width: 220px; max-width: 100%; height: auto; background: #fff; padding: 4px; }
code { background: #141816; padding: 1px 4px; word-break: break-all; }
a { color: #4ed8af; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>森空岛绑定 · Skland binding</h1>
<p class="sub">
绑定身份 · Player id:<code id="player-id">…</code><br />
这个 id 目前是占位(?player= 或浏览器本地保存),等游戏会话提供真实 id 后再由会话注入 ·
placeholder until the game session provides the real player id.
</p>
<section class="card" id="status-card">
<h2>绑定状态 · Binding status</h2>
<!-- 404 = 模块没挂载(默认关闭,SP_SKLAND=1 才启用)。 -->
<p class="off" id="module-off" hidden>
该功能未启用 · the module is off —— 服务器没有挂载 /skland/*(以 <code>SP_SKLAND=1</code> 启动后可用)·
the server was started without the module (404/405).
</p>
<dl class="kv">
<dt>状态 · Status</dt><dd id="st-status">—</dd>
<dt>昵称 · Nickname</dt><dd id="st-nickname">—</dd>
<dt>森空岛 ID · hgId</dt><dd id="st-hgid">—</dd>
</dl>
<table id="roles-table">
<thead><tr><th>uid</th><th>昵称 · nickName</th><th>服务器 · channelName</th><th>gameId</th></tr></thead>
<tbody id="roles-body"></tbody>
</table>
<p class="hint" id="roles-empty">还没有角色 · No roles yet.</p>
<!-- 每个 fetch 的失败都落在这一行上,不会有未处理的 rejection。 -->
<p class="err" id="status-error" hidden></p>
<div class="row">
<button class="act" id="btn-refresh" type="button">刷新 · Refresh</button>
<button class="act" id="btn-unbind" type="button" data-needs-module>解绑 · Unbind</button>
</div>
</section>
<nav class="tabs" role="tablist">
<button class="act" type="button" role="tab" data-tab="scan">扫码 · QR</button>
<button class="act" type="button" role="tab" data-tab="sms">验证码 · SMS</button>
<button class="act" type="button" role="tab" data-tab="pw">密码 · Password</button>
</nav>
<p class="hint" id="login-scope"></p>
<section class="panel" id="panel-scan" role="tabpanel">
<h2>扫码登录 · Scan to log in</h2>
<p class="hint">用森空岛 App 扫描二维码,或在 App 内打开深链;面板每 2 秒问一次状态 ·
scan the code with the Skland app; the panel polls every 2 s.</p>
<div class="row"><button class="act" id="btn-scan-start" type="button" data-needs-module>获取二维码 · Get QR code</button></div>
<div id="qr-box"></div>
<p class="hint" id="qr-note"></p>
<p class="hint" id="scan-state"></p>
</section>
<section class="panel" id="panel-sms" role="tabpanel" hidden>
<h2>验证码登录 · SMS code</h2>
<label for="sms-phone">手机号 · Phone</label>
<input id="sms-phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="13800000000" />
<div class="row"><button class="act" id="btn-sms-send" type="button" data-needs-module>发送验证码 · Send code</button></div>
<label for="sms-code">验证码 · Code</label>
<input id="sms-code" type="text" autocomplete="one-time-code" inputmode="numeric" placeholder="123456" />
<div class="row"><button class="act" id="btn-sms-login" type="button" data-needs-module>登录并绑定 · Log in</button></div>
<p class="hint" id="sms-state"></p>
<div id="captcha-box" hidden>
<p class="hint" aria-live="polite"></p>
<button class="act" type="button"></button>
</div>
</section>
<section class="panel" id="panel-pw" role="tabpanel" hidden>
<h2>密码登录 · Phone + password</h2>
<label for="pw-phone">手机号 · Phone</label>
<input id="pw-phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="13800000000" />
<label for="pw-password">密码 · Password</label>
<input id="pw-password" type="password" autocomplete="current-password" />
<div class="row"><button class="act" id="btn-pw-login" type="button" data-needs-module>登录并绑定 · Log in</button></div>
<p class="hint" id="pw-state"></p>
<div id="pw-captcha-box" hidden>
<p class="hint" aria-live="polite"></p>
<button class="act" type="button"></button>
</div>
</section>
<p class="hint"><a href="/">返回首页 · Back to home</a></p>
</main>
<script type="module">
import { t } from '/shared/i18n.js';
import { solveCaptcha } from '/js/skland/captcha.js';
// 面板逻辑:所有 /skland/* 调用都过 api(),失败一律显示成文字;二维码编码 scanUrl 深链。
/** 轮询间隔(契约:约 2 秒)。 */
const POLL_MS = 2000;
/** 玩家 id 的 localStorage 键。 */
const PLAYER_KEY = 'sp.skland.player';
/** 404 = 模块未启用(默认关闭)。 */
const NOT_ENABLED = '该功能未启用 · the module is off(服务器未以 SP_SKLAND=1 启动)· not enabled (404)';
/** 状态值 → 双语标签(server/skland/service.js 的 status)。 */
const STATUS_LABEL = { unbound: '未绑定 · unbound', scanning: '扫码中 · scanning', bound: '已绑定 · bound', error: '错误 · error' };
const $ = (sel) => document.querySelector(sel);
$('#login-scope').textContent = t('官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。');
/** 面板自己的错误类型:message 已经是给人看的一句话。 */
class PanelError extends Error {}
/** 玩家标识:?player= > localStorage > 新生成的占位 id。 */
function resolvePlayer() {
const fromUrl = new URLSearchParams(location.search).get('player');
let id = (fromUrl || '').trim();
try { id = id || localStorage.getItem(PLAYER_KEY) || ''; } catch { /* 隐私模式:localStorage 不可用时只用 URL/新 id */ }
if (!id) id = `web-${Math.random().toString(36).slice(2, 10)}`;
try { localStorage.setItem(PLAYER_KEY, id); } catch { /* 同上 */ }
return id;
}
const player = resolvePlayer();
/** 把非 JSON 的错误页 HTML 压成一行可读文本。 */
function brief(body) {
return String(body).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
}
/** 模块没挂载:亮出未启用提示,并停掉所有依赖模块的按钮。 */
let moduleOff = false;
function setModuleOff() {
if (moduleOff) return;
moduleOff = true;
$('#module-off').hidden = false;
for (const b of document.querySelectorAll('[data-needs-module]')) b.disabled = true;
}
/**
* 调用一个 /skland/* JSON 接口。网络 / 非 JSON / HTTP 错误都抛 PanelError,由调用方展示;
* 404 先于解析处理:模块关闭时 GET 由静态服务器答 404 错误页(不是 JSON)。
*/
async function api(path, { body } = {}) {
const init = { method: 'GET', cache: 'no-store', headers: {} };
if (body !== undefined) {
init.method = 'POST';
init.headers['content-type'] = 'application/json';
init.body = JSON.stringify(body);
}
let res;
try { res = await fetch(path, init); } catch (e) { throw new PanelError(`网络错误 · Network error: ${e.message}`); }
let text = '';
try { text = await res.text(); } catch { /* 读体失败也当空体处理 */ }
// 模块默认关闭时 /skland/* 由一个占位处理器统一答 JSON 404(server/skland/http.js createSklandOffHandler),
// 所以"未启用"只需认这一种应答;HTML 应答不是这个接口的形态,一并当作未启用兜底。
if ((res.status === 404 && !(res.headers.get('content-type') || '').includes('application/json'))
|| (res.headers.get('content-type') || '').includes('text/html')) {
setModuleOff();
throw new PanelError(NOT_ENABLED);
}
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = null; }
if (!res.ok) throw new PanelError(data?.error || `HTTP ${res.status} · ${brief(text)}`);
if (!data || typeof data !== 'object') throw new PanelError(`响应不是 JSON · Response is not JSON (HTTP ${res.status})`);
return data;
}
// ---------------------------------------------------------------------------------------------
// 状态区(常显)
// ---------------------------------------------------------------------------------------------
function showError(message) {
const el = $('#status-error');
el.hidden = !message;
el.textContent = message || '';
}
/** 一行提示:id 指向某个 .hint,note 非空时整体标红。 */
function msg(id, main, note = '') {
const el = $(id);
el.textContent = note ? `${main} —— ${note}` : main;
el.classList.toggle('err', !!note);
}
function text(id, value) { $(id).textContent = value == null || value === '' ? '—' : String(value); }
/** roles = {uid, nickName, channelName, gameId}(上游没给的字段以 — 显示)。 */
function renderRoles(roles) {
const body = $('#roles-body');
body.textContent = '';
const list = Array.isArray(roles) ? roles : [];
$('#roles-empty').hidden = list.length > 0;
for (const r of list) {
const tr = document.createElement('tr');
for (const v of [r?.uid, r?.nickName, r?.channelName, r?.gameId]) {
const td = document.createElement('td');
td.textContent = v == null || v === '' ? '—' : String(v);
tr.append(td);
}
body.append(tr);
}
}
function renderStatus(s) {
text('#st-status', STATUS_LABEL[s.status] || s.status);
text('#st-nickname', s.nickname);
text('#st-hgid', s.hgId);
renderRoles(s.roles);
showError(s.error || '');
}
/** GET /skland/status。返回状态对象(失败返回 null;错误已显示)。 */
async function refreshStatus() {
try {
const s = await api(`/skland/status?player=${encodeURIComponent(player)}`);
renderStatus(s);
return s;
} catch (e) { showError(e.message); return null; }
}
/** 刷新按钮:先状态;已绑定再 ?refresh=1 让服务器重新问一次森空岛要角色列表。 */
async function refreshAll() {
const s = await refreshStatus();
if (moduleOff || !s?.bound) return;
try {
const r = await api(`/skland/roles?player=${encodeURIComponent(player)}&refresh=1`);
if (r.ok) renderRoles(r.roles);
else showError(r.error || '查询角色失败 · Roles lookup failed');
} catch (e) { showError(e.message); }
}
async function unbind() {
showError('');
try {
await api('/skland/unbind', { body: { player } });
await refreshStatus();
} catch (e) { showError(e.message); }
}
// ---------------------------------------------------------------------------------------------
// 扫码:编码 scanUrl → 每 2s 轮询直到 confirmed / expired
// ---------------------------------------------------------------------------------------------
/** 扫码会话是否还在进行(离开页面/Tab 只停表,不清会话)。 */
let scanLive = false;
let pollTimer = null;
let pollBusy = false;
/** 停表:唯一清 clearInterval 的地方。 */
function pollStop() {
if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; }
}
/** 起表:只有会话还在、扫码 Tab 在前台时才轮询。 */
function pollStart() {
pollStop();
if (!scanLive || activeTab !== 'scan' || document.hidden) return;
pollTimer = setInterval(() => { void pollOnce(); }, POLL_MS);
}
/** 二维码库缺失 / 绘制失败时的回退:把 scanUrl 深链作为可选中的文本放出来,绝不抛错。 */
function renderQrFallback(scanUrl) {
const box = $('#qr-box');
box.textContent = '';
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '二维码库 /vendor/qrcode.js 不可用(未执行 npm install / npm run vendor)——请手动打开下面的深链 · QR library unavailable; open the deep link manually.';
const link = document.createElement('textarea');
link.className = 'deeplink';
link.readOnly = true;
link.rows = 3;
link.value = String(scanUrl);
link.addEventListener('focus', () => link.select());
box.append(note, link);
$('#qr-note').textContent = '';
}
/**
* 二维码:编码 scanUrl(hypergryph:// 深链),不是会话标识。库缺失(源码检出没有 vendor 文件)或调用失败时
* 回退为可选中的深链文本,绝不让缺库抛出去。
*/
function renderQr(scanUrl) {
if (typeof qrcode !== 'function') { renderQrFallback(scanUrl); return; }
const box = $('#qr-box');
box.textContent = '';
try {
const qr = qrcode(0, 'M');
qr.addData(scanUrl);
qr.make();
box.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2 });
$('#qr-note').textContent = '用森空岛 App 扫码 · Scan with the Skland app';
} catch { renderQrFallback(scanUrl); }
}
async function startScan() {
scanLive = false;
pollStop();
showError('');
msg('#scan-state', '正在获取二维码… · Requesting a QR code…');
try {
const r = await api('/skland/scan/start', { body: { player } });
if (!r.ok) { msg('#scan-state', '获取二维码失败 · Could not start', r.error || ''); return; }
renderQr(r.scanUrl);
const secs = Number(r.expiresInMs) > 0 ? Math.round(Number(r.expiresInMs) / 1000) : 0;
msg('#scan-state', secs ? `等待手机确认…(${secs}s 内有效 · valid for ${secs}s)` : '等待手机确认… · Waiting for the phone…');
scanLive = true;
pollStart();
await refreshStatus();
} catch (e) {
msg('#scan-state', '获取二维码失败 · Could not start', e.message);
showError(e.message);
}
}
/** 一次轮询。confirmed / expired / none 都是终态:停表、刷新状态。 */
async function pollOnce() {
if (pollBusy || !scanLive) return;
pollBusy = true;
try {
const r = await api('/skland/scan/poll', { body: { player } });
// 服务端把上游失败答成 HTTP 200 + { ok: false, error }(没有 state)——先认它,否则真正的原因会被
// 下面那条 `state === undefined` 的兜底吞成"未知状态"。
if (r.ok === false) {
scanLive = false;
pollStop();
msg('#scan-state', '轮询失败 · Poll failed', r.error || '');
showError(r.error || '');
return;
}
if (r.state === 'waiting') { msg('#scan-state', '等待手机确认… · Waiting for the phone…'); return; }
scanLive = false;
pollStop();
if (r.state === 'confirmed') msg('#scan-state', '已确认 · Confirmed', r.error || '');
else if (r.state === 'expired') msg('#scan-state', '二维码已过期,请重新获取 · Expired — start again');
else if (r.state === 'none') msg('#scan-state', '没有进行中的扫码会话 · No scan session');
else if (r.state === 'error') msg('#scan-state', '轮询失败 · Poll failed', r.error || '');
else msg('#scan-state', `未知状态 · Unknown state: ${String(r.state)}`, `响应 · response: ${brief(JSON.stringify(r))}`);
await refreshStatus();
} catch (e) {
scanLive = false;
pollStop();
msg('#scan-state', '轮询已停止 · Polling stopped', e.message);
showError(e.message);
} finally { pollBusy = false; }
}
// ---------------------------------------------------------------------------------------------
// 验证码
// ---------------------------------------------------------------------------------------------
/** Send SMS, optionally retrying with the proof from the interactive widget. */
async function sendCode(extra) {
const phone = $('#sms-phone').value.trim();
if (!phone) { msg('#sms-state', '请填写手机号 · Enter a phone number'); return; }
showError('');
msg('#sms-state', '正在发送… · Sending…');
try {
const body = { player, phone };
if (extra) body.captcha = extra;
const r = await api('/skland/phone/send', { body });
if (r.ok) { $('#captcha-box').hidden = true; msg('#sms-state', '验证码已发送 · Code sent'); return; }
if (r.needCaptcha) { renderCaptcha(r.captcha || {}, '#captcha-box', (proof) => sendCode(proof)); return; }
msg('#sms-state', '发送失败 · Send failed', r.error || '');
showError(r.error || '发送失败 · Send failed');
} catch (e) {
msg('#sms-state', '发送失败 · Send failed', e.message);
showError(e.message);
}
}
/** Load and open the provider's widget only after the user clicks its button. */
function renderCaptcha(challenge, selector, retry) {
const box = $(selector);
const state = box.querySelector('p');
const button = box.querySelector('button');
box.hidden = false;
state.textContent = t('需要人机验证,请点击下方按钮完成验证。');
button.textContent = t('开始人机验证');
button.disabled = false;
button.onclick = async () => {
button.disabled = true;
state.textContent = t('正在加载验证组件…');
try {
const proof = await solveCaptcha(challenge);
if (!proof) { state.textContent = t('验证已取消,请点击按钮重试。'); return; }
state.textContent = t('验证通过,正在继续…');
box.hidden = true;
await retry(proof);
} catch (e) {
state.textContent = e.message === 'captcha_type'
? t('暂不支持此验证类型,请使用验证码或扫码登录。')
: e.message === 'captcha_load' ? t('验证组件加载失败,请重试。') : t('验证未完成,请重试。');
} finally { button.disabled = false; }
};
box.scrollIntoView({ block: 'nearest' });
}
async function smsLogin() {
const phone = $('#sms-phone').value.trim();
const code = $('#sms-code').value.trim();
if (!phone || !code) { msg('#sms-state', '请填写手机号与验证码 · Enter the phone and the code'); return; }
showError('');
msg('#sms-state', '正在登录… · Logging in…');
try {
const r = await api('/skland/phone/login', { body: { player, phone, code } });
if (r.ok) {
$('#captcha-box').hidden = true;
msg('#sms-state', `绑定成功 · Bound${r.nickname ? `:${r.nickname}` : ''}`);
} else {
msg('#sms-state', '登录失败 · Login failed', r.error || '');
showError(r.error || '登录失败 · Login failed');
}
await refreshStatus();
} catch (e) {
msg('#sms-state', '登录失败 · Login failed', e.message);
showError(e.message);
}
}
// ---------------------------------------------------------------------------------------------
// 密码
// ---------------------------------------------------------------------------------------------
async function passwordLogin(extra = {}) {
const phone = $('#pw-phone').value.trim();
const password = $('#pw-password').value;
if (!phone || !password) { msg('#pw-state', '请填写手机号与密码 · Enter the phone and the password'); return; }
showError('');
msg('#pw-state', '正在登录… · Logging in…');
try {
const r = await api('/skland/password/login', { body: { player, phone, password, ...extra } });
if (r.ok) {
$('#pw-captcha-box').hidden = true;
msg('#pw-state', `绑定成功 · Bound${r.nickname ? `:${r.nickname}` : ''}`);
} else if (r.needCaptcha) {
renderCaptcha(r.captcha || {}, '#pw-captcha-box', (proof) => passwordLogin({ captcha: proof, captchaStep: r.captchaStep }));
return;
} else {
msg('#pw-state', '登录失败 · Login failed', r.error || '');
showError(r.error || '登录失败 · Login failed');
}
await refreshStatus();
} catch (e) {
msg('#pw-state', '登录失败 · Login failed', e.message);
showError(e.message);
}
}
// ---------------------------------------------------------------------------------------------
// Tabs 与启动
// ---------------------------------------------------------------------------------------------
let activeTab = 'scan';
function switchTab(name) {
activeTab = name;
for (const b of document.querySelectorAll('[data-tab]')) b.setAttribute('aria-selected', String(b.dataset.tab === name));
for (const p of document.querySelectorAll('.panel')) p.hidden = p.id !== `panel-${name}`;
// 离开扫码区只停表:会话还在时切回来会继续问(scanLive 不由 Tab 决定)。
if (name !== 'scan') pollStop();
else pollStart();
}
$('#player-id').textContent = player;
for (const b of document.querySelectorAll('[data-tab]')) b.addEventListener('click', () => switchTab(b.dataset.tab));
$('#btn-refresh').addEventListener('click', () => { void refreshAll(); });
$('#btn-unbind').addEventListener('click', () => { void unbind(); });
$('#btn-scan-start').addEventListener('click', () => { void startScan(); });
$('#btn-sms-send').addEventListener('click', () => { void sendCode(); });
$('#btn-sms-login').addEventListener('click', () => { void smsLogin(); });
$('#btn-pw-login').addEventListener('click', () => { void passwordLogin(); });
// 页面隐藏 / 离开时停表,不给后台标签页留定时器;回到前台且扫码会话还在就继续。
document.addEventListener('visibilitychange', () => { if (document.hidden) pollStop(); else pollStart(); });
window.addEventListener('pagehide', () => pollStop());
switchTab('scan');
void refreshAll();
</script>
</body>
</html>