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
277 lines
15 KiB
JavaScript
277 lines
15 KiB
JavaScript
// 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}
|
|
`}
|
|
<//>`;
|
|
}
|