实现账号绑定、持久登录与跨设备对局恢复
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

This commit is contained in:
herryyun committed 2026-10-14 23:22:50 +08:00
1 parent 488db49f11
commit 7c25c89992
62 files changed
+6855 -1005

No files matched your search

+12 -9
View File
@@ -8,13 +8,13 @@
// otherwise → Lobby
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
// session is online (after a short grace period in case the server restores a room on resume).
// Reloading a tab that already passed the title re-enters automatically (sessionStorage flag) and
// Reloading a tab that already passed the title re-enters after checking its current role binding and
// resumes the server session with the saved token; stale room/match state is dropped if the
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
// `identity.init()` (which token this tab may use without stealing another live tab's session)
// before the first connect; a page restored from the back/forward cache reloads.
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
// toast 「服务器会话已重置,上一局模拟已结束」 (store.js sessionResetNotice) and return to the lobby with nothing stale.
// drop stale match state (store.js sessionResetNotice); an unauthenticated transport returns to the title for checked entry.
//
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
@@ -41,6 +41,7 @@ import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectat
import { data } from './data.js';
import { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js';
import { acceptAccountWelcome, restoreBoundEntry } from './skland/gate.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js';
import { GameScreen } from './screens/game.js';
@@ -145,6 +146,7 @@ function backToLobby() {
}
function onWelcome(msg) {
const accountAccepted = acceptAccountWelcome(msg);
identity.saveToken(msg.token);
identity.rememberMatch(null);
const prev = store.get();
@@ -156,8 +158,8 @@ function onWelcome(msg) {
if (prevId != null && prevId !== msg.playerId) {
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
// expired on it): whatever we showed before is gone — back to the lobby cleanly and say why.
const notice = sessionResetNotice(prev, msg.playerId);
// expired on it): clear the old snapshot. A login-only transport stays at the title until checked account entry.
const notice = accountAccepted ? sessionResetNotice(prev, msg.playerId) : null;
backToLobby();
if (notice) toast(t(notice), 'warn', { ttl: 7000 });
} else if (prev.room || prev.match.public) {
@@ -303,7 +305,7 @@ function App() {
return html`<div class="app-root">
<div class="app-bg" aria-hidden="true"></div>
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
<${ConnectionBanner} />
<${ConnectionBanner} continueSession=${() => restoreBoundEntry(sanitizeName)} />
<${ToastHost} />
<${UiHosts} />
<${GuideHost} />
@@ -360,7 +362,7 @@ async function boot() {
const entered = identity.wasEntered() && !!savedName;
store.set((s) => ({
me: { ...s.me, name: savedName },
session: { entered },
session: { entered: false },
ui: { ...s.ui, pendingJoin },
}));
@@ -378,9 +380,10 @@ async function boot() {
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
data.load('local').catch(() => {});
const connectWhenReady = identityReady.then(() => {
if (entered) net.setName(savedName);
else net.connect();
const connectWhenReady = identityReady.then((token) => {
if (entered && token) return restoreBoundEntry(sanitizeName, { automatic: true });
identity.setEntered(false);
net.connect();
});
// the language (and its UI translations) before the first render: no Chinese flash for an English player
const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
+12
View File
@@ -746,6 +746,7 @@ export function createIdentity(deps = {}) {
let initPromise = null;
let initialized = false;
let resumeBusy = false;
let explicitClaim = false; // account entry POST supplied the token; do not persist until welcome
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
let owned = sget(session, K_TOKEN);
@@ -811,6 +812,7 @@ export function createIdentity(deps = {}) {
// The original page can reappear after resume's query but before its hello. Keep our old
// session in storage and give back the tentative selection immediately in that interval.
if (current && tokenHash(current) === hash) {
explicitClaim = false;
if (current === owned) {
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_WELCOME);
@@ -911,6 +913,13 @@ export function createIdentity(deps = {}) {
} catch { return false; }
finally { resumeBusy = false; }
},
/** Select an authenticated account entry token without treating an unacknowledged hello as a saved session. */
stageToken(token) {
if (!isToken(token)) return false;
current = token;
explicitClaim = true;
return true;
},
/** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */
@@ -924,6 +933,7 @@ export function createIdentity(deps = {}) {
},
/** A shared candidate must never replace a connected session, including a hello already in flight. */
getTokenClaim() {
if (explicitClaim) return { noReplace: false };
return current === owned && owned
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
: { noReplace: true };
@@ -935,6 +945,7 @@ export function createIdentity(deps = {}) {
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
explicitClaim = false;
if (current !== token) rememberMatch(null);
if (owned !== token || welcomeAt == null) welcomeAt = now();
owned = token;
@@ -946,6 +957,7 @@ export function createIdentity(deps = {}) {
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() {
explicitClaim = false;
rememberMatch(null);
const t = current || sget(session, K_TOKEN);
current = null;
-2
View File
@@ -11,7 +11,6 @@
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
@@ -321,7 +320,6 @@ export function LobbyScreen() {
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div>
<div class="topbar__right">
<${ResumeMatchButton} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
+25 -18
View File
@@ -1,8 +1,8 @@
// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu
// Title screen: season-style backdrop, big title 卫戍协议:盟约, bound role callsign, 开始, the language menu
// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and
// the small wordmark above it hides).
//
// Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it,
// Binding reads the role nickname; pressing 开始 validates it (1..NAME_MAX_LEN chars, no control characters), stores it,
// marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which
// sends `hello` (now, or as soon as the socket is open). The router then shows the lobby.
//
@@ -10,17 +10,18 @@
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { AccountResumeMatchButton } from '../skland/resume.js';
import { AccountBindingEntry, BoundCallsign } from '../skland/entry.js';
import { enterBoundAccount, restoreBoundEntry } from '../skland/gate.js';
import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
import { html, Button, Icon, MicroLabel, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
import { FullscreenButton, PwaInstallButton } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js';
@@ -192,8 +193,10 @@ export function TitleScreen() {
const conn = useStore((s) => s.connection, shallowEqual);
const pendingJoin = useStore((s) => s.ui.pendingJoin);
useLang(); // re-render on a language switch
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const [name, setName] = useState('');
const [bindingOpen, setBindingOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [entering, setEntering] = useState(false);
const assetsSettled = useData('assets');
const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
@@ -209,16 +212,17 @@ export function TitleScreen() {
const cssRidges = assetsSettled && (!ridges || ridgesFailed);
const valid = isValidName(name);
const start = () => {
if (!valid) { toast(t('请输入博士代号'), 'warn'); return; }
enterSession(name);
const start = async () => {
if (entering) return;
if (!valid) { setBindingOpen(true); return; }
setEntering(true);
if (!await restoreBoundEntry(sanitizeName)) { setName(''); setBindingOpen(true); }
setEntering(false);
};
const online = conn.status === 'online' || conn.status === 'connected';
const dotClass = online ? 'is-on' : conn.status === 'reconnecting' || conn.status === 'connecting' || conn.status === 'handshaking' ? 'is-warn' : 'is-bad';
// touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view)
const touchUi = useMemo(() => detectFeatures().coarse, []);
// a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it
// hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag)
const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic';
@@ -267,12 +271,10 @@ export function TitleScreen() {
<div class="title-login">
${pendingJoin ? html`<div class="title-invite">
<${Icon} name="key" />
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 输入代号后将自动加入')}</span>
<span>${t('收到同盟邀请')}</span><b class="num">${pendingJoin}</b><span class="t-lo">${t('· 绑定账号并开始后将自动加入')}</span>
</div>` : null}
<${TextField} label=${t('博士代号')} micro="CALLSIGN" size="lg" icon="user" value=${name} maxLength=${NAME_MAX_LEN}
placeholder=${t('输入你的代号(最多 {NAME_MAX_LEN} 字)', { NAME_MAX_LEN })} autoFocus=${!touchUi}
onInput=${setName} onEnter=${start} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" disabled=${!valid} onClick=${start}>${t('开始')}<//>
<${BoundCallsign} name=${name} />
<${Button} variant="primary" size="xl" block=${true} iconRight="chevrons" data-testid="binding-start" disabled=${entering} onClick=${start}>${valid ? t('开始') : t('绑定账号')}<//>
<div class="title-conn">
<span class=${`status-dot ${dotClass}`}></span>
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
@@ -283,12 +285,17 @@ export function TitleScreen() {
<${FullscreenButton} class="title-fs" />
</div>
</div>
<${AccountBindingEntry} name=${name} open=${bindingOpen} onOpen=${() => setBindingOpen(true)} onClose=${() => setBindingOpen(false)}
onNickname=${(nickname) => {
const value = sanitizeName(nickname); setName(value); identity.saveName(value);
if (!value) identity.setEntered(false);
}} />
</main>
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<${ResumeMatchButton} />
<${AccountResumeMatchButton} bound=${valid} onResume=${(id) => enterBoundAccount(sanitizeName, { recoveryId: id })} />
<${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<span class="title-foot__meta">
+70
View File
@@ -0,0 +1,70 @@
// Independent title adapter: the game reconnect token stays in a header, never a URL, QR code or provider request.
export class BindingError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
}
export function createBindingApi({ token, fetchImpl = globalThis.fetch, signal } = {}) {
return async (path, body) => {
const credential = typeof token === 'function' ? token() : token;
if (!credential) throw new BindingError('session');
let response;
try {
response = await fetchImpl(`/skland/session/${path}`, {
method: body === undefined ? 'GET' : 'POST', cache: 'no-store', signal,
credentials: 'same-origin',
headers: { authorization: `Bearer ${credential}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
} catch (error) {
if (signal?.aborted) throw error;
throw new BindingError('network');
}
if (response.status === 404) throw new BindingError('off');
if (response.status === 401) throw new BindingError('session');
let result;
try { result = await response.json(); } catch { throw new BindingError('response'); }
if (!response.ok) throw new BindingError(result.code === 'resume_expired' ? 'resume_expired' : 'upstream', result.error);
return result;
};
}
/** Establish hello without pressing Start or changing the router's entered flag. */
export async function ensureBindingSession({ net, identity, name, timeoutMs = 10_000 }) {
await identity.init();
if (net.status === 'online' && identity.getToken()) return identity.getToken();
return new Promise((resolve, reject) => {
const clean = () => { clearTimeout(timer); offWelcome(); offError(); };
const offWelcome = net.on('welcome', (message) => { clean(); resolve(message.token); });
const offError = net.on('helloError', () => { clean(); reject(new BindingError('session')); });
const timer = setTimeout(() => { clean(); reject(new BindingError('network')); }, timeoutMs);
net.setName(name);
net.connect();
});
}
export { roleKey } from '../../../shared/accountIdentity.js';
import { roleKey, hasRoleIdentity } from '../../../shared/accountIdentity.js';
export function preferredRole(roles, selected) {
const named = roles?.filter((role) => hasRoleIdentity(role) && typeof role?.nickName === 'string' && role.nickName.trim()) || [];
return named.find((role) => roleKey(role) === selected) || named.find((role) => role.isDefault) || named[0] || null;
}
let qrLoader;
export async function qrDataUrl(value) {
if (typeof globalThis.qrcode !== 'function') {
qrLoader ||= new Promise((resolve, reject) => {
const script = document.createElement('script');
const fail = () => { clearTimeout(timer); script.remove(); qrLoader = null; reject(new BindingError('qr')); };
const timer = setTimeout(fail, 10_000);
script.src = '/vendor/qrcode.js';
script.onload = () => { clearTimeout(timer); if (typeof globalThis.qrcode === 'function') resolve(); else fail(); };
script.onerror = fail;
document.head.append(script);
});
await qrLoader;
}
const qr = globalThis.qrcode(0, 'M');
qr.addData(value); qr.make();
return qr.createDataURL(6, 4);
}
+69
View File
@@ -0,0 +1,69 @@
// Interactive Geetest hand-off, following the official HG account SDK. The user completes the provider's widget;
// only its proof is returned to the original login request. No phone, password or account token is sent here.
const scripts = new Map();
const PROVIDERS = {
v4: { src: 'https://static.geetest.com/v4/gt4.js', init: 'initGeetest4' },
v3: { src: 'https://static.geetest.com/static/tools/gt.js', init: 'initGeetest' },
};
async function loadProvider(version) {
const provider = PROVIDERS[version];
if (typeof globalThis[provider.init] === 'function') return;
if (!scripts.has(version)) {
const pending = new Promise((resolve, reject) => {
const script = document.createElement('script');
const fail = () => { clearTimeout(timer); script.remove(); reject(new Error('captcha_load')); };
const timer = setTimeout(fail, 20_000);
script.src = provider.src;
script.async = true;
script.onerror = fail;
script.onload = () => {
clearTimeout(timer);
if (typeof globalThis[provider.init] === 'function') resolve();
else fail();
};
document.head.append(script);
});
scripts.set(version, pending);
pending.catch(() => scripts.delete(version));
}
await scripts.get(version);
}
/**
* Called only by the user's captcha button. `scope` and `load` allow isolated widget contract tests without a
* network call or a real challenge. Resolves null on user cancellation; never completes a real puzzle itself.
*/
export async function solveCaptcha(challenge, { scope = globalThis, load = loadProvider } = {}) {
const version = challenge.geetestId ? 'v4' : challenge.gt && challenge.challenge ? 'v3' : null;
if (!version) throw new Error('captcha_type');
await load(version);
return new Promise((resolve, reject) => {
let settled = false;
const init = scope[PROVIDERS[version].init];
const options = version === 'v4'
? { captchaId: challenge.geetestId, riskType: challenge.riskType, product: 'bind', language: 'zho' }
: { gt: challenge.gt, challenge: challenge.challenge, new_captcha: challenge.new_captcha,
offline: false, product: 'bind', https: true, lang: 'zh-cn' };
init(options, (widget) => {
const finish = (proof, error) => {
if (settled) return;
settled = true;
widget.destroy?.();
if (error) reject(error); else resolve(proof);
};
widget.onSuccess(() => {
const proof = widget.getValidate();
if (!proof) { finish(null, new Error('captcha_validate')); return; }
finish(version === 'v4' ? { ...proof, challenge: challenge.challenge } : proof);
});
widget.onError((error) => {
if (error?.error_code !== 'error_01') finish(null, new Error('captcha_validate'));
});
widget.onClose(() => finish(null));
widget.onReady(() => {
if (version === 'v4') widget.showCaptcha(); else widget.verify();
});
});
});
}
+276
View File
@@ -0,0 +1,276 @@
// Login UI, callsign display and HTTP integration stay inside the account module.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal, Icon } from '../ui/components.js';
import { net, identity } from '../net.js';
import { useStore } from '../store.js';
import { useLang } from '../ui/lang.js';
import { t } from '../../../shared/i18n.js';
import { createBindingApi, ensureBindingSession, preferredRole, roleKey, qrDataUrl } from './api.js';
import { solveCaptcha } from './captcha.js';
import { selectedBoundRole, syncAccountSession, resetAccountSession } from './gate.js';
import { createQrLogin } from './scan.js';
import { hasRoleIdentity } from '../../../shared/accountIdentity.js';
// Brand images served by the official Arknights account page; no extracted game art is stored in this repository.
const LOGOS = {
hg: 'https://web.hycdn.cn/arknights/user/assets/imgs/hypergryph.d69547.png',
bili: 'https://web.hycdn.cn/arknights/user/assets/imgs/bilibili.11810a.png',
};
function Brand({ kind }) {
return html`<img class="sp-binding__brand" src=${LOGOS[kind]} alt="" referrerpolicy="no-referrer" onError=${(event) => { event.currentTarget.hidden = true; }} />`;
}
function errorText(error) {
if (error.code === 'off') return t('此服务器未启用账号绑定,请联系服主开启后再进入。');
if (error.code === 'session') return t('游戏会话已失效,请关闭后重试。');
if (error.code === 'network') return t('连接失败,请重试。');
if (error.code === 'qr') return t('二维码加载失败,请重试。');
if (error.code === 'response') return t('服务器响应异常,请重试。');
if (error.message === 'scan_response') return t('服务器响应异常,请重试。');
if (error.message === 'scan_expired') return t('二维码已失效,请重新获取。');
if (error.message === 'captcha_load') return t('验证组件加载失败,请重试。');
if (error.message === 'captcha_type') return t('暂不支持此验证类型,请使用验证码或扫码登录。');
if (error.message === 'captcha_validate') return t('验证未完成,请重试。');
return error.message;
}
export function BoundCallsign({ name }) {
if (!name) return null;
return html`<dl class="sp-binding-callsign">
<dt class="sp-binding-callsign__label">${t('博士代号')}<span class="micro">CALLSIGN</span></dt>
<dd class="sp-binding-callsign__name"><${Icon} name="user" class="sp-binding-callsign__icon" /><span data-testid="bound-callsign">${name}</span></dd>
</dl>`;
}
export function AccountBindingEntry({ name, open, onOpen, onClose, onNickname }) {
useLang();
const playerId = useStore((state) => state.me.playerId);
const checkedPlayer = useRef(playerId);
const nicknameRef = useRef(onNickname);
nicknameRef.current = onNickname;
useEffect(() => {
if (document.querySelector('link[data-sp-binding]')) return;
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/css/skland/binding.css'; link.dataset.spBinding = '';
document.head.append(link);
}, []);
// Restore from the authenticated status, never from the locally remembered callsign.
useEffect(() => {
if (checkedPlayer.current !== playerId) {
checkedPlayer.current = playerId;
if (!open) nicknameRef.current('');
}
if (open) return undefined;
let alive = true;
const abort = new AbortController();
(async () => {
try {
const token = await ensureBindingSession({ net, identity, name: t('博士') });
const state = await createBindingApi({ token, signal: abort.signal })('status');
if (alive) {
await syncAccountSession(state);
if (alive) nicknameRef.current(selectedBoundRole(state)?.nickName || '');
}
} catch { if (alive) nicknameRef.current(''); }
})();
return () => { alive = false; abort.abort(); };
}, [open, playerId]);
const choose = (role) => {
onNickname(role?.nickName || '');
};
return html`<div class="sp-binding-entry">
${name ? html`<${Button} variant="ghost" size="sm" icon="user" data-testid="account-binding-entry" onClick=${onOpen}>
${t('已绑定:{name}', { name })}<//>` : null}
${open ? html`<${BindingDialog} name=${name} onChoose=${choose} onUnbind=${() => choose(null)} onClose=${onClose} />` : null}
</div>`;
}
function BindingDialog({ name, onChoose, onUnbind, onClose }) {
const [api, setApi] = useState(null);
const [tab, setTab] = useState('scan');
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [roles, setRoles] = useState([]);
const [selected, setSelected] = useState('');
const [bound, setBound] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [qr, setQr] = useState(null);
const [scanBusy, setScanBusy] = useState(false);
const [scanAwaiting, setScanAwaiting] = useState(false);
const [challenge, setChallenge] = useState(null);
const [verifying, setVerifying] = useState(false);
const [cooldown, setCooldown] = useState(0);
const alive = useRef(true);
const scan = useRef(null);
const scanTab = useRef(tab);
scanTab.current = tab;
const requestLock = useRef(false);
const selectionKey = useRef('');
const selectedRef = useRef('');
const chooseRef = useRef(onChoose);
chooseRef.current = onChoose;
const showSelection = (role) => {
const key = roleKey(role);
selectedRef.current = key; setSelected(key); chooseRef.current(role);
try { if (selectionKey.current) sessionStorage.setItem(selectionKey.current, key); } catch { /* optional preference */ }
};
const accept = async (state) => {
await syncAccountSession(state);
if (!alive.current) return;
const list = state.roles || [];
setBound(!!state.bound); setRoles(list);
const role = state.bound ? preferredRole(list, state.selectedRoleKey || selectedRef.current) : null;
if (role) showSelection(role);
else { selectedRef.current = ''; setSelected(''); chooseRef.current(null); }
};
useEffect(() => {
const abort = new AbortController();
alive.current = true;
(async () => {
try {
const token = await ensureBindingSession({ net, identity, name: name || t('博士') });
if (!alive.current) return;
const request = createBindingApi({ token: () => identity.getToken() || token, signal: abort.signal });
const state = await request('status');
selectionKey.current = `sp.binding.role.${state.playerId}`;
try { selectedRef.current = sessionStorage.getItem(selectionKey.current) || ''; } catch { /* optional preference */ }
if (alive.current) { setApi(() => request); await accept(state); }
} catch (failure) { if (alive.current) setError(errorText(failure)); }
finally { if (alive.current) setBusy(false); }
})();
return () => { alive.current = false; abort.abort(); };
}, []);
useEffect(() => {
if (!cooldown) return undefined;
const timer = setTimeout(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
const run = async (work) => {
if (!api || requestLock.current) return;
requestLock.current = true; setBusy(true); setError(''); setNotice('');
try { await work(); } catch (failure) { if (alive.current) setError(errorText(failure)); }
finally { requestLock.current = false; if (alive.current) setBusy(false); }
};
const finish = async () => {
const state = await api('status');
if (!alive.current) return;
await accept(state); setPassword(''); setCode(''); setChallenge(null);
if (!state.roles?.length) setNotice(t('登录成功,未找到明日方舟角色。'));
};
const finishRef = useRef(finish);
finishRef.current = finish;
const login = async (path, body) => {
const result = await api(path, body);
if (!alive.current) return;
if (result.needCaptcha) { setChallenge({ result, path, body }); return; }
if (!result.ok) throw new Error(result.error || t('登录失败,请重试。'));
setChallenge(null);
if (path === 'phone/send') { setCooldown(60); setNotice(t('验证码已发送,请查看短信。')); }
else await finish();
};
const verify = () => run(async () => {
setVerifying(true);
try {
const proof = await solveCaptcha(challenge.result.captcha);
if (!alive.current) return;
if (!proof) { setNotice(t('验证已取消,请点击按钮重试。')); return; }
await login(challenge.path, { ...challenge.body, captcha: proof, captchaStep: challenge.result.captchaStep });
} finally { if (alive.current) setVerifying(false); }
});
useEffect(() => {
if (!api || bound) return undefined;
const controller = createQrLogin({ request: api, encode: qrDataUrl, onQr: setQr,
onPending: setScanAwaiting, onBusy: setScanBusy,
onError: (failure) => setError(failure ? errorText(failure) : ''),
onConfirmed: () => finishRef.current(),
});
scan.current = controller;
const visibility = () => controller.setActive(scanTab.current === 'scan' && !document.hidden);
visibility();
document.addEventListener('visibilitychange', visibility);
return () => { controller.stop(); scan.current = null; document.removeEventListener('visibilitychange', visibility); };
}, [api, bound]);
useEffect(() => { scan.current?.setActive(tab === 'scan' && !document.hidden); }, [tab, api, bound]);
const switchTab = (value) => {
setTab(value); setChallenge(null); setPassword(''); setError(''); setNotice('');
};
const field = (label, value, change, type, autocomplete) => html`<label class="field sp-binding__field">
<span class="field__label">${label}</span><span class="field__box brackets">
<input class="field__input" type=${type} value=${value} autocomplete=${autocomplete} required disabled=${busy}
inputmode=${type === 'tel' ? 'tel' : autocomplete === 'one-time-code' ? 'numeric' : undefined}
onInput=${(event) => { change(event.currentTarget.value); setChallenge(null); }} />
</span></label>`;
return html`
${verifying ? html`<div class="sp-binding__verifying" role="status">${t('请在验证窗口中完成人机验证。')}</div>` : null}
<${Modal} open=${!verifying} title=${t('绑定明日方舟账号')} micro="ARKNIGHTS ACCOUNT" width="6.8rem" class="sp-binding" onClose=${onClose}
actions=${html`<${Button} variant="ghost" onClick=${onClose}>${t('完成')}<//>`}>
<p class="sp-binding__intro">${t('绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。')}</p>
${error ? html`<p class="sp-binding__error" role="alert">${error}</p>` : null}
${notice ? html`<p class="sp-binding__notice" role="status">${notice}</p>` : null}
${bound ? html`<div class="sp-binding__roles">
<p>${t('选择明日方舟角色')}</p>
${roles.map((role) => html`<${Button} key=${roleKey(role)} block=${true} active=${selected === roleKey(role)}
class="sp-binding__role" disabled=${busy || !hasRoleIdentity(role) || !role.nickName?.trim()} onClick=${() => run(async () => {
const state = await api('role/select', { roleKey: roleKey(role) });
if (!state.ok) throw new Error(state.error);
if (alive.current) await accept(state);
})}>
<${Brand} kind=${/bilibili|b服|b站/i.test(role.channelName || '') ? 'bili' : 'hg'} />
<span><b>${role.nickName || role.uid}</b><small>${role.channelName || ''} · UID ${role.uid}</small></span><//>`)}
<div class="sp-binding__actions">
<${Button} size="sm" disabled=${busy} onClick=${() => run(async () => {
const result = await api('roles?refresh=1');
if (!result.ok) throw new Error(result.error);
await finish();
})}>${t('刷新角色')}<//>
<${Button} size="sm" variant="ghost" disabled=${busy} onClick=${() => run(async () => {
const result = await api('unbind', {});
if (!result.ok) throw new Error(result.error);
await resetAccountSession();
if (alive.current) {
setBound(false); setRoles([]); setSelected(''); selectedRef.current = '';
try { sessionStorage.removeItem(selectionKey.current); } catch { /* optional preference */ }
onUnbind();
}
})}>${t('解绑账号')}<//>
</div>
</div>` : html`
<div class="sp-binding__tabs" role="tablist" aria-label=${t('登录方式')}>
<${Button} size="sm" role="tab" aria-selected=${tab === 'scan'} active=${tab === 'scan'} disabled=${busy || scanBusy} onClick=${() => switchTab('scan')}>${t('森空岛扫码')}<//>
<${Button} size="sm" role="tab" aria-selected=${tab === 'sms'} active=${tab === 'sms'} disabled=${busy || scanBusy} onClick=${() => switchTab('sms')}>${t('验证码')}<//>
<${Button} size="sm" role="tab" aria-selected=${tab === 'password'} active=${tab === 'password'} disabled=${busy || scanBusy} onClick=${() => switchTab('password')}>${t('密码')}<//>
</div>
<div class="sp-binding__channels"><span><${Brand} kind="hg" />${t('官服')}</span><span><${Brand} kind="bili" />${t('B服:扫码后按提示完成授权')}</span></div>
${busy && !api ? html`<p role="status">${t('正在连接服务器')}</p>` : null}
${tab === 'scan' ? html`<div class="sp-binding__scan">
${qr ? html`<img class="sp-binding__qr" src=${qr} alt=${t('使用森空岛 App 扫描二维码')} />` : null}
<p>${t('使用森空岛 App 扫码并确认,支持官服和 B 服。')}</p>
<p class="t-lo" role="status">${scanAwaiting ? t('已扫码,等待确认;二维码将在 1 分钟后自动刷新。') : t('二维码每 10 秒自动刷新,也可手动刷新。')}</p>
<${Button} disabled=${!api || busy || scanBusy} loading=${busy || scanBusy} onClick=${() => scan.current?.refresh()}>${t('重新获取二维码')}<//>
</div>` : html`<form class="sp-binding__form" onSubmit=${(event) => {
event.preventDefault();
run(() => login(tab === 'sms' ? 'phone/login' : 'password/login', tab === 'sms' ? { phone, code } : { phone, password }));
}}>
<p class="t-lo">${t('使用鹰角账号登录官服。B服请选择森空岛扫码。')}</p>
${field(t('手机号'), phone, setPhone, 'tel', 'username')}
${tab === 'sms' ? html`<div class="sp-binding__code">
${field(t('验证码'), code, setCode, 'text', 'one-time-code')}
<${Button} size="sm" disabled=${!api || busy || !phone.trim() || cooldown > 0} onClick=${() => run(() => login('phone/send', { phone }))}>
${cooldown ? t('{n} 秒后重发', { n: cooldown }) : t('发送验证码')}<//>
</div>` : field(t('密码'), password, setPassword, 'password', 'current-password')}
<${Button} type="submit" block=${true} variant="primary" loading=${busy} disabled=${!api || busy || !phone.trim() || !(tab === 'sms' ? code.trim() : password)}>${t('登录并绑定')}<//>
</form>`}
${challenge ? html`<div class="sp-binding__captcha"><p>${t('需要人机验证,请点击下方按钮完成验证。')}</p>
<${Button} loading=${busy} onClick=${verify}>${t('开始人机验证')}<//></div>` : null}
`}
<//>`;
}
+78
View File
@@ -0,0 +1,78 @@
// Fork-local entry policy: saved callsigns/entered flags are hints, never proof of a role binding.
import { net, identity } from '../net.js';
import { store } from '../store.js';
import { BindingError, createBindingApi, ensureBindingSession, preferredRole } from './api.js';
export function selectedBoundRole(state) {
if (!state?.bound) return null;
let selected = '';
try { selected = sessionStorage.getItem('sp.binding.role.' + state.playerId) || ''; } catch { /* optional preference */ }
return preferredRole(state.roles, state.selectedRoleKey || selected);
}
/** Losing the authenticated game transport must never leave an anonymous socket inside the game shell. */
export function acceptAccountWelcome(message) {
if (typeof message.playerId === 'string' && message.playerId.startsWith('a_')) return true;
identity.setEntered(false);
store.patch('session', { entered: false });
return false;
}
/** A title lookup restores the login only. Changing roles releases an old, roomless game transport. */
export async function syncAccountSession(state) {
const current = store.get().me.playerId;
if (current?.startsWith('a_') && current !== state?.gamePlayerId) await resetAccountSession();
}
export async function resetAccountSession() {
identity.clearToken(); identity.setEntered(false);
store.patch('session', { entered: false });
net.reconnectNow('Doctor');
await ensureBindingSession({ net, identity, name: 'Doctor' });
}
export async function accountRecovery({ signal } = {}) {
await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' });
const state = await createBindingApi({ token: () => identity.getToken(), signal })('recovery');
if (!state.ok) throw new BindingError('response');
return state.recovery;
}
/** Explicit entry/recovery: ask for the selected role's token, then verify the WebSocket actually attached it. */
export async function enterBoundAccount(normalizeName, { recoveryId, automatic = false } = {}) {
identity.setEntered(false);
store.patch('session', { entered: false });
await ensureBindingSession({ net, identity, name: identity.loadName() || 'Doctor' });
const api = createBindingApi({ token: () => identity.getToken() });
const state = await api('status');
const name = normalizeName(selectedBoundRole(state)?.nickName || '');
if (!name || !state.gamePlayerId) throw new BindingError('session');
if (automatic) {
// A copied/stale local token must not silently take the role back from the device that now owns it.
if (net.playerId !== state.gamePlayerId) return false;
} else {
const entry = await api(recoveryId ? 'resume' : 'enter', recoveryId ? { id: recoveryId } : {});
if (!entry.ok || entry.gamePlayerId !== state.gamePlayerId || typeof entry.token !== 'string') throw new BindingError('response');
net.close();
if (!identity.stageToken(entry.token)) throw new BindingError('response');
try {
net.reconnectNow(name);
await ensureBindingSession({ net, identity, name });
if (net.playerId !== state.gamePlayerId) throw new BindingError('session');
} catch (error) {
net.close(); identity.clearToken();
throw error;
}
}
identity.saveName(name);
store.patch('me', { name });
net.setName(name);
identity.setEntered(true);
store.patch('session', { entered: true });
return true;
}
/** Boot/Start keep the title visible when the server cannot verify the selected role. */
export async function restoreBoundEntry(normalizeName, options) {
try { return await enterBoundAccount(normalizeName, options); } catch { return false; }
}
+67
View File
@@ -0,0 +1,67 @@
// Cross-device recovery uses authenticated server state, not the browser's recent-token list.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal } from '../ui/components.js';
import { useLang } from '../ui/lang.js';
import { t } from '../../../shared/i18n.js';
import { accountRecovery } from './gate.js';
function recoveryError(error) {
if (error.code === 'resume_expired') return t('此对局已结束或过期,请刷新后重试。');
if (error.code === 'session') return t('游戏会话已失效,请关闭后重试。');
return t('连接失败,请重试。');
}
export function AccountResumeMatchButton({ bound, onResume }) {
useLang();
const [open, setOpen] = useState(false);
const [item, setItem] = useState(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [revision, setRevision] = useState(0);
const lock = useRef(false);
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
if (!bound) { setOpen(false); setItem(null); return undefined; }
if (!open) return undefined;
const abort = new AbortController();
setLoading(true); setItem(null); setError('');
accountRecovery({ signal: abort.signal }).then((value) => {
if (!abort.signal.aborted) setItem(value);
}).catch((failure) => {
if (!abort.signal.aborted) setError(recoveryError(failure));
}).finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [open, bound, revision]);
if (!bound) return null;
const resume = async () => {
if (!item || lock.current) return;
lock.current = true; setBusy(true); setError('');
try {
await onResume(item.id);
if (alive.current) setOpen(false);
} catch (failure) {
if (alive.current) {
setError(recoveryError(failure));
if (failure.code === 'resume_expired') setItem(null);
}
} finally {
lock.current = false;
if (alive.current) setBusy(false);
}
};
return html`<${Button} variant="secondary" size="sm" data-testid="resume-account-match" onClick=${() => setOpen(true)}>${t('恢复账号对局')}<//>
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复账号对局')} class="sp-account-resume"
actions=${html`<${Button} variant="secondary" disabled=${busy || loading} onClick=${() => setRevision((value) => value + 1)}>${t('刷新')}<//>
<${Button} variant="ghost" disabled=${busy} onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<p>${t('登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。')}</p>
${loading ? html`<p role="status">${t('正在查询账号对局…')}</p>` : null}
${error ? html`<p role="alert">${error}</p>` : null}
${!loading && !item && !error ? html`<p role="status">${t('当前角色没有可恢复的对局。')}</p>` : null}
${item ? html`<p>${item.inMatch ? t('对局进行中') : t('同盟等候中')}${item.inMatch && item.round > 0 ? ' · ' + t('第 {r} 回合', { r: item.round }) : ''}</p>
<p class="t-lo">${t('如果此角色仍在其他设备在线,恢复后将断开旧连接。')}</p>
<${Button} variant="primary" block=${true} disabled=${busy || loading} data-testid="confirm-account-resume" onClick=${resume}>
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code })}<//>` : null}
<//>`;
}
+70
View File
@@ -0,0 +1,70 @@
// Serial QR requests: a refresh cannot replace a scan while its confirmation poll is still in flight.
export const SCAN_POLL_MS = 2_000;
export const SCAN_REFRESH_MS = 10_000;
export const SCAN_CONFIRM_REFRESH_MS = 60_000;
export function createQrLogin({ request, encode, onQr, onPending, onError, onConfirmed, onBusy,
now = Date.now, timers = globalThis, isHidden = () => false }) {
let stopped = false, completed = false, confirmed = false, active = false, busy = false, timer;
let ready = false, awaiting = false, manual = true;
let refreshAt = Infinity, pollAt = Infinity;
const clear = () => { timers.clearTimeout(timer); timer = undefined; };
const schedule = () => {
clear();
if (stopped || completed || !active || busy) return;
const due = manual ? now() : ready ? Math.min(refreshAt, pollAt) : refreshAt;
timer = timers.setTimeout(tick, isHidden() ? SCAN_POLL_MS : Math.max(0, due - now()));
};
const generate = async () => {
manual = false; ready = false; awaiting = false;
// Keep the displayed image until its replacement is encoded; refreshing only changes the existing img's src.
onBusy(true); onPending(false); onError(null);
try {
const result = await request('scan/start', {});
if (stopped) return;
if (!result.ok || !result.scanUrl) throw new Error(result.error || 'scan_response');
const image = await encode(result.scanUrl);
if (stopped) return;
ready = true; refreshAt = now() + SCAN_REFRESH_MS; pollAt = now() + SCAN_POLL_MS;
onQr(image);
} finally { if (!stopped) onBusy(false); }
};
async function tick() {
clear();
if (stopped || completed || !active || busy) return;
if (isHidden()) { schedule(); return; }
busy = true;
try {
if (confirmed) { manual = false; await onConfirmed(); completed = true; }
else if (manual || !ready) await generate();
else {
// Poll once before an automatic refresh too: a just-scanned QR gets its full confirmation window.
const result = await request('scan/poll', {});
if (stopped) return;
if (!result.ok || result.error) throw new Error(result.error || 'scan_response');
if (result.state === 'confirmed') {
confirmed = true; onQr(null);
await onConfirmed();
completed = true;
return;
}
if (result.state !== 'waiting') throw new Error('scan_expired');
if (result.awaitingConfirmation && !awaiting) {
awaiting = true; refreshAt = now() + SCAN_CONFIRM_REFRESH_MS; onPending(true);
}
pollAt = now() + SCAN_POLL_MS;
if (active && !isHidden() && (manual || now() >= refreshAt)) await generate();
}
} catch (error) {
if (!stopped) {
ready = false; refreshAt = now() + SCAN_REFRESH_MS;
awaiting = false; onPending(false); onQr(null); onError(error);
}
} finally { busy = false; schedule(); }
}
return {
setActive(value) { active = value; schedule(); },
refresh() { manual = true; schedule(); },
stop() { stopped = true; clear(); },
};
}
+2 -2
View File
@@ -19,7 +19,7 @@ export function bannerVisible(conn, entered, restoring, buildStale = false) {
return true;
}
export function ConnectionBanner() {
export function ConnectionBanner({ continueSession } = {}) {
const conn = useStore((s) => s.connection, shallowEqual);
const entered = useStore((s) => s.session.entered);
const restoring = useStore((s) => s.ui.restoring);
@@ -53,7 +53,7 @@ export function ConnectionBanner() {
: conn.status === 'closed' ? t('连接已关闭')
: rejected ? t(conn.lastError.text) : t('正在连接服务器');
const action = conn.status === 'reconnecting' ? { label: t('立即重连'), run: () => net.retryNow() }
: conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: () => net.connect() }
: conn.status === 'closed' ? { label: replaced ? t('在此页面继续') : t('重新连接'), run: continueSession || (() => net.connect()) }
: versionMismatch ? { label: t('刷新页面'), run: () => location.reload() }
: rejected ? { label: t('重试'), run: () => net.reconnectNow() } : null;
return html`<div class=${`conn-banner${transient ? ' conn-banner--soft' : ''}`} role="alert">
+5 -3
View File
@@ -6,7 +6,7 @@ import { store } from '../store.js';
import { t } from '../../../shared/i18n.js';
import { toast } from './toasts.js';
export function ResumeMatchButton() {
export function ResumeMatchButton({ beforeEnter } = {}) {
const [items, setItems] = useState(() => identity.recoverable());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
@@ -22,13 +22,15 @@ export function ResumeMatchButton() {
if (busy) return;
setBusy(true);
if (await identity.resume(item.id)) {
identity.setEntered(true);
store.patch('session', { entered: true });
// Keep the tentative token in this document until welcome persists it. Reloading here
// would either lose the selection or falsely turn it into an established session claim.
net.close();
net.setName(item.name);
net.connect();
if (!beforeEnter || await beforeEnter()) {
identity.setEntered(true);
store.patch('session', { entered: true });
}
setOpen(false);
setBusy(false);
} else {