实现账号绑定、持久登录与跨设备对局恢复
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s

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

No files matched your search

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