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
540 lines
25 KiB
HTML
540 lines
25 KiB
HTML
<!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>
|