// 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` { 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`
${t('博士代号')}CALLSIGN
<${Icon} name="user" class="sp-binding-callsign__icon" />${name}
`; } 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`
${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}
`; } 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``; return html` ${verifying ? html`
${t('请在验证窗口中完成人机验证。')}
` : 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('完成')}`}>

${t('绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。')}

${error ? html`` : null} ${notice ? html`

${notice}

` : null} ${bound ? html`

${t('选择明日方舟角色')}

${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'} /> ${role.nickName || role.uid}${role.channelName || ''} · UID ${role.uid}`)}
<${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('解绑账号')}
` : html`
<${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('密码')}
<${Brand} kind="hg" />${t('官服')}<${Brand} kind="bili" />${t('B服:扫码后按提示完成授权')}
${busy && !api ? html`

${t('正在连接服务器')}

` : null} ${tab === 'scan' ? html`
${qr ? html`${t('使用森空岛` : null}

${t('使用森空岛 App 扫码并确认,支持官服和 B 服。')}

${scanAwaiting ? t('已扫码,等待确认;二维码将在 1 分钟后自动刷新。') : t('二维码每 10 秒自动刷新,也可手动刷新。')}

<${Button} disabled=${!api || busy || scanBusy} loading=${busy || scanBusy} onClick=${() => scan.current?.refresh()}>${t('重新获取二维码')}
` : html`
{ event.preventDefault(); run(() => login(tab === 'sms' ? 'phone/login' : 'password/login', tab === 'sms' ? { phone, code } : { phone, password })); }}>

${t('使用鹰角账号登录官服。B服请选择森空岛扫码。')}

${field(t('手机号'), phone, setPhone, 'tel', 'username')} ${tab === 'sms' ? html`
${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('发送验证码')}
` : 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('登录并绑定')}
`} ${challenge ? html`

${t('需要人机验证,请点击下方按钮完成验证。')}

<${Button} loading=${busy} onClick=${verify}>${t('开始人机验证')}
` : null} `} `; }