实现账号绑定、持久登录与跨设备对局恢复
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
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:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -0,0 +1,45 @@
|
||||
/* Feature-local styles: keep upstream title/components styles untouched. */
|
||||
.sp-binding-callsign { display: grid; gap: .1rem; margin: 0; }
|
||||
.sp-binding-callsign__label { display: flex; align-items: baseline; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); font-weight: 500; letter-spacing: .04em; }
|
||||
.sp-binding-callsign__name { display: flex; align-items: center; gap: .12rem; margin: 0; color: var(--text-hi); font-size: calc(.26 * var(--t)); font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.sp-binding-callsign__icon { flex: none; color: var(--text-lo); }
|
||||
.sp-binding-callsign__name > span { min-width: 0; }
|
||||
.sp-binding-entry { display: flex; justify-content: flex-end; width: 4.9rem; margin-top: -.08rem; }
|
||||
.sp-binding-entry:empty { display: none; }
|
||||
.sp-binding-entry > .btn { max-width: 100%; white-space: normal; text-align: left; }
|
||||
.sp-binding .modal__body { display: grid; gap: .2rem; font-size: calc(.18 * var(--t)); }
|
||||
.sp-binding__intro { color: var(--text-lo); line-height: 1.6; margin: 0; }
|
||||
.sp-binding__tabs, .sp-binding__channels, .sp-binding__actions { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; }
|
||||
.sp-binding__channels { color: var(--text-lo); font-size: calc(.15 * var(--t)); }
|
||||
.sp-binding__channels > span { display: inline-flex; align-items: center; gap: .08rem; }
|
||||
.sp-binding__brand { width: .26rem; height: .26rem; object-fit: contain; flex: none; }
|
||||
.sp-binding__scan { display: grid; justify-items: center; gap: .16rem; text-align: center; }
|
||||
.sp-binding__scan p { margin: 0; }
|
||||
.sp-binding__qr { width: 2.4rem; height: 2.4rem; max-width: 100%; image-rendering: pixelated; background: #fff; }
|
||||
.sp-binding__form, .sp-binding__roles { display: grid; gap: .16rem; }
|
||||
.sp-binding__form p, .sp-binding__roles p { margin: 0; line-height: 1.6; }
|
||||
.sp-binding__code { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: .12rem; }
|
||||
.sp-binding__field { min-width: 0; }
|
||||
.sp-binding__role { justify-content: flex-start; gap: .14rem; height: auto; min-height: .65rem; text-align: left; white-space: normal; }
|
||||
.sp-binding__role > span { display: grid; gap: .04rem; overflow-wrap: anywhere; }
|
||||
.sp-binding__role small { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
|
||||
.sp-binding__error { color: var(--red); overflow-wrap: anywhere; }
|
||||
.sp-binding__notice { color: var(--mint-500); }
|
||||
.sp-binding__captcha { border-top: 1px solid var(--line-2); padding-top: .16rem; }
|
||||
.sp-binding__verifying { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; background: rgba(0, 0, 0, .75); color: var(--text-hi); font-size: calc(.2 * var(--t)); }
|
||||
|
||||
/* Short landscape screens: the title scene can be taller than the browser's visual viewport. Anchor the account box
|
||||
to the top of the overlay and let its body scroll, so the close action and role list remain reachable. */
|
||||
@media (max-height: 600px) {
|
||||
.sp-binding { align-self: start; max-height: calc(100dvh - .3rem); margin-top: .15rem; }
|
||||
.sp-binding .modal__head { padding-top: .12rem; }
|
||||
.sp-binding .modal__body { padding-top: .1rem; padding-bottom: .14rem; }
|
||||
.sp-binding .modal__actions { padding-top: .1rem; padding-bottom: .1rem; }
|
||||
}
|
||||
|
||||
/* Recovery remains readable on another device even at the game's smallest landscape root scale. */
|
||||
.sp-account-resume { width: min(94vw, max(6.8rem, 420px)); max-height: calc(100dvh - 24px); }
|
||||
.sp-account-resume .modal__title { font-size: max(18px, calc(.26 * var(--t))); }
|
||||
.sp-account-resume .modal__body { display: grid; gap: max(.16rem, 10px); font-size: max(14px, calc(.18 * var(--t))); line-height: 1.7; }
|
||||
.sp-account-resume .modal__body p { margin: 0; }
|
||||
.sp-account-resume .btn { min-height: 44px; font-size: max(14px, calc(.17 * var(--t))); }
|
||||
+54
-1
@@ -1218,5 +1218,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round",
|
||||
"装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received",
|
||||
"库存不足": "Out of stock"
|
||||
"库存不足": "Out of stock",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "Human verification is required. Use the button below to continue.",
|
||||
"开始人机验证": "Start verification",
|
||||
"正在加载验证组件…": "Loading verification…",
|
||||
"验证已取消,请点击按钮重试。": "Verification cancelled. Use the button to try again.",
|
||||
"验证通过,正在继续…": "Verified. Continuing…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "This verification type is not supported. Use SMS or QR login.",
|
||||
"验证组件加载失败,请重试。": "Could not load verification. Please try again.",
|
||||
"验证未完成,请重试。": "Verification was not completed. Please try again.",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "For the official server, you can log in with your phone number. For the Bilibili server, scan with Skland and follow the authorization prompts.",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "Account binding is disabled on this server. Ask the host to enable it before entering.",
|
||||
"游戏会话已失效,请关闭后重试。": "Your game session expired. Close this dialog and try again.",
|
||||
"连接失败,请重试。": "Connection failed. Please try again.",
|
||||
"二维码加载失败,请重试。": "Could not load the QR code. Please try again.",
|
||||
"服务器响应异常,请重试。": "Unexpected server response. Please try again.",
|
||||
"已绑定:{name}": "Bound: {name}",
|
||||
"绑定明日方舟账号": "Bind Arknights account",
|
||||
"登录成功,未找到明日方舟角色。": "Logged in, but no Arknights characters were found.",
|
||||
"登录失败,请重试。": "Login failed. Please try again.",
|
||||
"验证码已发送,请查看短信。": "Code sent. Check your text messages.",
|
||||
"二维码已失效,请重新获取。": "The QR code expired. Get a new code.",
|
||||
"请在验证窗口中完成人机验证。": "Complete human verification in the verification window.",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "Your character nickname becomes your callsign after binding. You can start once it is retrieved.",
|
||||
"绑定账号后自动获取": "Retrieved after binding your account",
|
||||
"绑定账号": "Bind account",
|
||||
"· 绑定账号并开始后将自动加入": "· Join automatically after binding your account and starting",
|
||||
"选择明日方舟角色": "Select an Arknights character",
|
||||
"刷新角色": "Refresh characters",
|
||||
"解绑账号": "Unbind account",
|
||||
"登录方式": "Login method",
|
||||
"森空岛扫码": "Skland QR login",
|
||||
"验证码": "Verification code",
|
||||
"密码": "Password",
|
||||
"官服": "Official server",
|
||||
"B服:扫码后按提示完成授权": "Bilibili: scan and follow the authorization prompts",
|
||||
"使用森空岛 App 扫描二维码": "Scan the QR code with the Skland app",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "Scan and confirm with the Skland app. Supports the official and Bilibili servers.",
|
||||
"重新获取二维码": "Get a new QR code",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "The QR code refreshes every 10 seconds. You can also refresh it manually.",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "Scanned. Waiting for confirmation; the QR code will refresh automatically after 1 minute.",
|
||||
"获取二维码": "Get QR code",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "Use a Hypergryph account for the official server. For Bilibili, choose Skland QR login.",
|
||||
"手机号": "Phone number",
|
||||
"{n} 秒后重发": "Resend in {n} seconds",
|
||||
"发送验证码": "Send code",
|
||||
"登录并绑定": "Log in and bind",
|
||||
"恢复账号对局": "Resume Account Match",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "Sign in to the same account and select the same server and character to continue an unexpired match on another device. Matches cannot be restored after a server restart.",
|
||||
"正在查询账号对局…": "Checking for account matches…",
|
||||
"此对局已结束或过期,请刷新后重试。": "This match has ended or expired. Refresh and try again.",
|
||||
"当前角色没有可恢复的对局。": "There is no match to resume for the selected character.",
|
||||
"对局进行中": "Match in progress",
|
||||
"同盟等候中": "Waiting in the alliance",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "If this character is still online on another device, resuming will disconnect that device."
|
||||
}
|
||||
+54
-1
@@ -1224,5 +1224,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
|
||||
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
|
||||
"库存不足": "在庫不足"
|
||||
"库存不足": "在庫不足",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "本人確認が必要です。下のボタンから認証を完了してください。",
|
||||
"开始人机验证": "認証を開始",
|
||||
"正在加载验证组件…": "認証を読み込み中…",
|
||||
"验证已取消,请点击按钮重试。": "認証をキャンセルしました。ボタンから再試行してください。",
|
||||
"验证通过,正在继续…": "認証が完了しました。処理を続けています…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "この認証形式には対応していません。SMSまたはQRコードでログインしてください。",
|
||||
"验证组件加载失败,请重试。": "認証を読み込めませんでした。再試行してください。",
|
||||
"验证未完成,请重试。": "認証が完了していません。再試行してください。",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "公式サーバーは電話番号でログインできます。Bilibiliサーバーは森空島でQRコードを読み取り、画面の案内に従って連携を許可してください。",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "このサーバーではアカウント連携が無効です。管理者に有効化を依頼してから入場してください。",
|
||||
"游戏会话已失效,请关闭后重试。": "ゲームセッションが無効です。閉じてから再試行してください。",
|
||||
"连接失败,请重试。": "接続に失敗しました。再試行してください。",
|
||||
"二维码加载失败,请重试。": "QRコードを読み込めませんでした。再試行してください。",
|
||||
"服务器响应异常,请重试。": "サーバーの応答が異常です。再試行してください。",
|
||||
"已绑定:{name}": "連携済み:{name}",
|
||||
"绑定明日方舟账号": "アークナイツのアカウントを連携",
|
||||
"登录成功,未找到明日方舟角色。": "ログインしましたが、アークナイツのキャラクターが見つかりません。",
|
||||
"登录失败,请重试。": "ログインに失敗しました。再試行してください。",
|
||||
"验证码已发送,请查看短信。": "認証コードを送信しました。SMSを確認してください。",
|
||||
"二维码已失效,请重新获取。": "QRコードの有効期限が切れました。再取得してください。",
|
||||
"请在验证窗口中完成人机验证。": "認証ウィンドウで認証を完了してください。",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "連携後、キャラクター名をコードネームとして自動取得します。取得できたら開始できます。",
|
||||
"绑定账号后自动获取": "アカウント連携後に自動取得",
|
||||
"绑定账号": "アカウント連携",
|
||||
"· 绑定账号并开始后将自动加入": "· アカウント連携後、開始すると自動で参加します",
|
||||
"选择明日方舟角色": "アークナイツのキャラクターを選択",
|
||||
"刷新角色": "キャラクターを更新",
|
||||
"解绑账号": "アカウント連携を解除",
|
||||
"登录方式": "ログイン方法",
|
||||
"森空岛扫码": "森空島でQRログイン",
|
||||
"验证码": "認証コード",
|
||||
"密码": "パスワード",
|
||||
"官服": "公式サーバー",
|
||||
"B服:扫码后按提示完成授权": "Bilibili:QR読取後に案内に従って連携を許可",
|
||||
"使用森空岛 App 扫描二维码": "森空島アプリでQRコードを読み取る",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "森空島アプリでQRコードを読み取り、確認してください。公式・Bilibiliサーバーに対応しています。",
|
||||
"重新获取二维码": "QRコードを再取得",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "QRコードは10秒ごとに自動更新されます。手動でも更新できます。",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "読み取り済み、確認待ちです。QRコードは1分後に自動更新されます。",
|
||||
"获取二维码": "QRコードを取得",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "公式サーバーはHypergryphアカウントでログインしてください。Bilibiliは森空島のQRログインを選択してください。",
|
||||
"手机号": "電話番号",
|
||||
"{n} 秒后重发": "{n} 秒後に再送信",
|
||||
"发送验证码": "認証コードを送信",
|
||||
"登录并绑定": "ログインして連携",
|
||||
"恢复账号对局": "アカウントの対局を再開",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "同じアカウントでログインし、同じサーバーのキャラクターを選択すると、有効期限内の対局を別の端末で続行できます。サーバー再起動後は復元できません。",
|
||||
"正在查询账号对局…": "アカウントの対局を確認中…",
|
||||
"此对局已结束或过期,请刷新后重试。": "この対局は終了したか、有効期限が切れています。更新して再試行してください。",
|
||||
"当前角色没有可恢复的对局。": "選択中のキャラクターには再開できる対局がありません。",
|
||||
"对局进行中": "対局進行中",
|
||||
"同盟等候中": "同盟で待機中",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "このキャラクターが別の端末でオンラインの場合、再開すると以前の接続は切断されます。"
|
||||
}
|
||||
+54
-1
@@ -1220,5 +1220,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.",
|
||||
"库存不足": "재고 부족",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)."
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "자동 입력 방지 인증이 필요합니다. 아래 버튼을 눌러 인증해 주세요.",
|
||||
"开始人机验证": "인증 시작",
|
||||
"正在加载验证组件…": "인증을 불러오는 중…",
|
||||
"验证已取消,请点击按钮重试。": "인증이 취소되었습니다. 버튼을 눌러 다시 시도해 주세요.",
|
||||
"验证通过,正在继续…": "인증이 완료되어 계속 진행합니다…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "지원하지 않는 인증 방식입니다. SMS 또는 QR 코드로 로그인해 주세요.",
|
||||
"验证组件加载失败,请重试。": "인증을 불러오지 못했습니다. 다시 시도해 주세요.",
|
||||
"验证未完成,请重试。": "인증을 완료하지 못했습니다. 다시 시도해 주세요.",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "공식 서버는 휴대폰 번호로 로그인할 수 있습니다. Bilibili 서버는 스카이랜드로 QR 코드를 스캔하고 화면 안내에 따라 연동을 승인해 주세요.",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "이 서버에서는 계정 연동이 비활성화되어 있습니다. 서버 관리자에게 활성화를 요청한 뒤 입장하세요.",
|
||||
"游戏会话已失效,请关闭后重试。": "게임 세션이 만료되었습니다. 창을 닫고 다시 시도해 주세요.",
|
||||
"连接失败,请重试。": "연결하지 못했습니다. 다시 시도해 주세요.",
|
||||
"二维码加载失败,请重试。": "QR 코드를 불러오지 못했습니다. 다시 시도해 주세요.",
|
||||
"服务器响应异常,请重试。": "서버 응답에 문제가 있습니다. 다시 시도해 주세요.",
|
||||
"已绑定:{name}": "연동됨: {name}",
|
||||
"绑定明日方舟账号": "명일방주 계정 연동",
|
||||
"登录成功,未找到明日方舟角色。": "로그인했으나 명일방주 캐릭터를 찾지 못했습니다.",
|
||||
"登录失败,请重试。": "로그인하지 못했습니다. 다시 시도해 주세요.",
|
||||
"验证码已发送,请查看短信。": "인증 코드를 보냈습니다. 문자 메시지를 확인하세요.",
|
||||
"二维码已失效,请重新获取。": "QR 코드가 만료되었습니다. 새로 발급해 주세요.",
|
||||
"请在验证窗口中完成人机验证。": "인증 창에서 사람 인증을 완료해 주세요.",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "연동 후 캐릭터 닉네임을 코드네임으로 자동으로 가져옵니다. 가져오면 시작할 수 있습니다.",
|
||||
"绑定账号后自动获取": "계정 연동 후 자동으로 가져옵니다",
|
||||
"绑定账号": "계정 연동",
|
||||
"· 绑定账号并开始后将自动加入": "· 계정 연동 후 시작하면 자동으로 참가합니다",
|
||||
"选择明日方舟角色": "명일방주 캐릭터 선택",
|
||||
"刷新角色": "캐릭터 새로고침",
|
||||
"解绑账号": "계정 연동 해제",
|
||||
"登录方式": "로그인 방법",
|
||||
"森空岛扫码": "스카이랜드 QR 로그인",
|
||||
"验证码": "인증 코드",
|
||||
"密码": "비밀번호",
|
||||
"官服": "공식 서버",
|
||||
"B服:扫码后按提示完成授权": "Bilibili: 스캔 후 안내에 따라 연동 승인",
|
||||
"使用森空岛 App 扫描二维码": "스카이랜드 앱으로 QR 코드 스캔",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "스카이랜드 앱으로 QR 코드를 스캔하고 확인하세요. 공식 및 Bilibili 서버를 지원합니다.",
|
||||
"重新获取二维码": "QR 코드 재발급",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "QR 코드는 10초마다 자동으로 갱신됩니다. 직접 갱신할 수도 있습니다.",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "스캔 완료, 확인을 기다리고 있습니다. QR 코드는 1분 후 자동으로 갱신됩니다.",
|
||||
"获取二维码": "QR 코드 발급",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "공식 서버는 Hypergryph 계정으로 로그인하세요. Bilibili 서버는 스카이랜드 QR 로그인을 선택하세요.",
|
||||
"手机号": "휴대폰 번호",
|
||||
"{n} 秒后重发": "{n}초 후 재전송",
|
||||
"发送验证码": "인증 코드 전송",
|
||||
"登录并绑定": "로그인 및 연동",
|
||||
"恢复账号对局": "계정 대전 이어하기",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "같은 계정으로 로그인하고 같은 서버의 캐릭터를 선택하면 다른 기기에서 만료되지 않은 대전을 이어할 수 있습니다. 서버가 재시작되면 복구할 수 없습니다.",
|
||||
"正在查询账号对局…": "계정 대전 확인 중…",
|
||||
"此对局已结束或过期,请刷新后重试。": "대전이 종료되었거나 만료되었습니다. 새로고침한 후 다시 시도하세요.",
|
||||
"当前角色没有可恢复的对局。": "현재 캐릭터로 이어할 수 있는 대전이 없습니다.",
|
||||
"对局进行中": "대전 진행 중",
|
||||
"同盟等候中": "동맹 대기 중",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "이 캐릭터가 다른 기기에 접속 중이라면 이어하기 후 기존 연결이 끊어집니다."
|
||||
}
|
||||
+54
-1
@@ -1222,5 +1222,58 @@
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
|
||||
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
|
||||
"库存不足": "庫存不足"
|
||||
"库存不足": "庫存不足",
|
||||
"需要人机验证,请点击下方按钮完成验证。": "需要人機驗證,請點擊下方按鈕完成驗證。",
|
||||
"开始人机验证": "開始人機驗證",
|
||||
"正在加载验证组件…": "正在載入驗證元件…",
|
||||
"验证已取消,请点击按钮重试。": "驗證已取消,請點擊按鈕重試。",
|
||||
"验证通过,正在继续…": "驗證通過,正在繼續…",
|
||||
"暂不支持此验证类型,请使用验证码或扫码登录。": "暫不支援此驗證類型,請使用驗證碼或掃碼登入。",
|
||||
"验证组件加载失败,请重试。": "驗證元件載入失敗,請重試。",
|
||||
"验证未完成,请重试。": "驗證未完成,請重試。",
|
||||
"官服可使用手机号登录;B服请使用森空岛扫码,并按页面提示完成授权。": "官服可使用手機號登入;B服請使用森空島掃碼,並按頁面提示完成授權。",
|
||||
"此服务器未启用账号绑定,请联系服主开启后再进入。": "此伺服器未啟用帳號綁定,請聯絡服主開啟後再進入。",
|
||||
"游戏会话已失效,请关闭后重试。": "遊戲工作階段已失效,請關閉後重試。",
|
||||
"连接失败,请重试。": "連線失敗,請重試。",
|
||||
"二维码加载失败,请重试。": "二維碼載入失敗,請重試。",
|
||||
"服务器响应异常,请重试。": "伺服器回應異常,請重試。",
|
||||
"已绑定:{name}": "已綁定:{name}",
|
||||
"绑定明日方舟账号": "綁定明日方舟帳號",
|
||||
"登录成功,未找到明日方舟角色。": "登入成功,未找到明日方舟角色。",
|
||||
"登录失败,请重试。": "登入失敗,請重試。",
|
||||
"验证码已发送,请查看短信。": "驗證碼已傳送,請查看簡訊。",
|
||||
"二维码已失效,请重新获取。": "二維碼已失效,請重新取得。",
|
||||
"请在验证窗口中完成人机验证。": "請在驗證視窗中完成人機驗證。",
|
||||
"绑定后自动获取角色昵称作为博士代号,获取成功后即可开始。": "綁定後自動取得角色暱稱作為博士代號,取得成功後即可開始。",
|
||||
"绑定账号后自动获取": "綁定帳號後自動取得",
|
||||
"绑定账号": "綁定帳號",
|
||||
"· 绑定账号并开始后将自动加入": "· 綁定帳號並開始後將自動加入",
|
||||
"选择明日方舟角色": "選擇明日方舟角色",
|
||||
"刷新角色": "重新整理角色",
|
||||
"解绑账号": "解除帳號綁定",
|
||||
"登录方式": "登入方式",
|
||||
"森空岛扫码": "森空島掃碼",
|
||||
"验证码": "驗證碼",
|
||||
"密码": "密碼",
|
||||
"官服": "官服",
|
||||
"B服:扫码后按提示完成授权": "B服:掃碼後按提示完成授權",
|
||||
"使用森空岛 App 扫描二维码": "使用森空島 App 掃描二維碼",
|
||||
"使用森空岛 App 扫码并确认,支持官服和 B 服。": "使用森空島 App 掃碼並確認,支援官服和 B 服。",
|
||||
"重新获取二维码": "重新取得二維碼",
|
||||
"二维码每 10 秒自动刷新,也可手动刷新。": "二維碼每 10 秒自動更新,也可手動更新。",
|
||||
"已扫码,等待确认;二维码将在 1 分钟后自动刷新。": "已掃碼,等待確認;二維碼將在 1 分鐘後自動更新。",
|
||||
"获取二维码": "取得二維碼",
|
||||
"使用鹰角账号登录官服。B服请选择森空岛扫码。": "使用鷹角帳號登入官服。B服請選擇森空島掃碼。",
|
||||
"手机号": "手機號碼",
|
||||
"{n} 秒后重发": "{n} 秒後重傳",
|
||||
"发送验证码": "傳送驗證碼",
|
||||
"登录并绑定": "登入並綁定",
|
||||
"恢复账号对局": "恢復帳號對局",
|
||||
"登录同一账号并选择同一区服角色,可在其他设备继续尚未过期的对局。服务器重启后无法恢复。": "登入同一帳號並選擇同一區服角色,可在其他裝置繼續尚未過期的對局。伺服器重新啟動後無法恢復。",
|
||||
"正在查询账号对局…": "正在查詢帳號對局…",
|
||||
"此对局已结束或过期,请刷新后重试。": "此對局已結束或過期,請重新整理後重試。",
|
||||
"当前角色没有可恢复的对局。": "目前角色沒有可恢復的對局。",
|
||||
"对局进行中": "對局進行中",
|
||||
"同盟等候中": "同盟等候中",
|
||||
"如果此角色仍在其他设备在线,恢复后将断开旧连接。": "如果此角色仍在其他裝置上線,恢復後將中斷舊連線。"
|
||||
}
|
||||
+12
-9
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
`}
|
||||
<//>`;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}
|
||||
<//>`;
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user