import { Component, type ErrorInfo, type FormEvent, type ReactNode, useEffect, useRef, useState, } from 'react'; import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; import { loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, normalizeAuthPhoneInput, readClientAuthState, sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; import { ClientAuthErrorWrapper } from '../services/clientAuthErrorWrapper'; import { type ClientServerPreset, type ClientServerSelection, clientServerSelectionEnabled, getClientServerBaseUrl, getClientServerSelection, hydrateClientServerSelection, normalizeClientServerBaseUrl, setClientServerSelection, } from '../services/clientHttp'; import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; import type { AuthServiceUnavailable } from '../services/generated/AuthServiceUnavailable'; import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected'; import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected'; import type { SendCodeRejected } from '../services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../services/generated/UnexpectedRejection'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, commitAuthenticatedPlatformSession, currentPlatformSessionGeneration, } from '../services/platformSession'; import { expectNever } from './expectNever'; type ClientRuntimeErrorBoundaryProps = { children: ReactNode; onLogout: () => void; }; type ClientRuntimeErrorBoundaryState = { errorMessage: string; }; type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner'; const AUTH_CHECK_STAGE_LABELS: Record = { token: '读取本地登录凭据', refresh: '刷新登录状态', me: '确认当前用户', runner: '连接本地运行时', }; const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; // Existing endpoint probes can consume the 10s IPC budget before Runner's // 30s startup deadline. Keep the UI fence slightly above that worst case. const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; function withAuthCheckTimeout( promise: Promise, timeoutMs: number, message: string, ) { void promise.catch(() => undefined); let timeoutId: number | undefined; const timeout = new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); }); return Promise.race([promise, timeout]).finally(() => { if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } }); } export class ClientRuntimeErrorBoundary extends Component< ClientRuntimeErrorBoundaryProps, ClientRuntimeErrorBoundaryState > { state: ClientRuntimeErrorBoundaryState = { errorMessage: '' }; static getDerivedStateFromError(error: unknown) { return { errorMessage: error instanceof Error && error.message.trim() ? error.message : '登录后页面发生未知错误', }; } componentDidCatch(error: unknown, errorInfo: ErrorInfo) { console.error('AGC authenticated client render failed', error, errorInfo); void captureClientError(error, { source: 'react-render' }); } render() { if (!this.state.errorMessage) { return this.props.children; } return (
陶泥儿

客户端页面加载失败

{this.state.errorMessage}

); } } export function AuthenticatedClient({ children, }: { children: (session: { user: AuthUser; logout: () => void }) => ReactNode; }) { const [authStatus, setAuthStatus] = useState< 'checking' | 'authenticated' | 'unauthenticated' >('checking'); const [authUser, setAuthUser] = useState(null); const [authCheckStage, setAuthCheckStage] = useState('token'); const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0); const [authCheckError, setAuthCheckError] = useState(''); const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); const authCheckRunRef = useRef(0); /** * 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后 * 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。 */ const loginAttemptRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [password, setPassword] = useState(''); const [loginStatus, setLoginStatus] = useState('请登录后继续'); const [loginBusy, setLoginBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); const initialServerSelection = getClientServerSelection(); const [serverSelection, setServerSelection] = useState( initialServerSelection, ); const [customServerUrl, setCustomServerUrl] = useState( initialServerSelection.customBaseUrl, ); function persistServerSelection() { try { const next = setClientServerSelection({ preset: serverSelection.preset, customBaseUrl: customServerUrl, }); setServerSelection(next); return true; } catch (error) { setLoginStatus(error instanceof Error ? error.message : String(error)); return false; } } function handleServerPresetChange(preset: ClientServerPreset) { if (preset === 'custom') { setServerSelection((current) => ({ ...current, preset })); return; } const next = setClientServerSelection({ preset, customBaseUrl: customServerUrl, }); setServerSelection(next); setLoginStatus(`已选择 ${preset} 服务器`); } // 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。 useEffect(() => { if (!clientServerSelectionEnabled) return; let disposed = false; void hydrateClientServerSelection().then((selection) => { if (disposed) return; setServerSelection(selection); setCustomServerUrl(selection.customBaseUrl); }); return () => { disposed = true; }; }, []); useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { void captureClientError(event.error ?? event.message, { source: 'window.onerror', }); }; const handleRejection = (event: PromiseRejectionEvent) => { void captureClientError(event.reason, { source: 'unhandledrejection' }); }; window.addEventListener('error', handleError); window.addEventListener('unhandledrejection', handleRejection); return () => { uninstallWebviewLogBridge(); window.removeEventListener('error', handleError); window.removeEventListener('unhandledrejection', handleRejection); }; }, []); /** 恢复登录态:凭据续期、用户复核与原生会话安装都在 Rust 内完成。 */ useEffect(() => { let disposed = false; async function hydrateAuth() { const runId = ++authCheckRunRef.current; const isActiveRun = () => !disposed && authCheckRunRef.current === runId; setAuthCheckError(''); setAuthCheckStage('refresh'); try { const state = await withAuthCheckTimeout( readClientAuthState(getClientServerBaseUrl()), AUTH_CHECK_REQUEST_TIMEOUT_MS, '检查登录状态超时,请检查服务器地址和网络后重试', ); if (!isActiveRun()) return; if (state.status !== 'authenticated') { setAuthStatus('unauthenticated'); return; } setAuthCheckStage('runner'); const committedGeneration = await withAuthCheckTimeout( commitAuthenticatedPlatformSession( state.user, currentPlatformSessionGeneration(), state.apiBaseUrl, ), AUTH_CHECK_RUNNER_TIMEOUT_MS, '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null || !isActiveRun()) return; setAuthUser(state.user); setAuthCheckError(''); setAuthStatus('authenticated'); } catch (error) { if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 if (!(error instanceof ClientAuthErrorWrapper)) { const text = error instanceof Error ? error.message : String(error); setAuthCheckError(text); setLoginStatus(text); throw error; } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; let text: string; switch (payload.reason) { case 'emptyOrTooLong': text = '服务器地址非法: 不能为空或超长'; break; case 'notAUrl': text = '服务器地址非法: 不是合法地址'; break; case 'hasCredentials': text = '服务器地址非法: 不能包含用户名或密码'; break; case 'hasPathOrQueryOrFragment': text = '服务器地址非法: 不能带路径、查询或片段'; break; case 'notHttps': text = '服务器地址非法: 远程地址必须使用 https'; break; case 'unsupportedScheme': text = '服务器地址非法: 只支持 http 或 https'; break; case 'outsideChannel': text = '服务器地址非法: 不在当前构建渠道范围内'; break; default: { expectNever(payload.reason); text = '服务器地址非法'; } } setAuthCheckError(text); setLoginStatus(text); break; } case 'phoneNumberInvalid': { const text = '手机号无效: 需为纯数字且不超过 32 位'; setAuthCheckError(text); setLoginStatus(text); break; } case 'passwordMissing': { const text = '密码无效: 不能为空'; setAuthCheckError(text); setLoginStatus(text); break; } case 'loginCodeMissing': { const text = '验证码无效: 不能为空'; setAuthCheckError(text); setLoginStatus(text); break; } case 'passwordLoginRejected': { const payload = failure as PasswordLoginRejected; const text = `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`; setAuthCheckError(text); setLoginStatus(text); break; } case 'phoneOrPasswordMismatch': { const text = '手机号或密码错误'; setAuthCheckError(text); setLoginStatus(text); break; } case 'sendCodeRejected': { const payload = failure as SendCodeRejected; const text = `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`; setAuthCheckError(text); setLoginStatus(text); break; } case 'smsCodeThrottled': { const text = '发送验证码过于频繁,请稍后再试'; setAuthCheckError(text); setLoginStatus(text); break; } case 'phoneCodeLoginRejected': { const payload = failure as PhoneCodeLoginRejected; const text = `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`; setAuthCheckError(text); setLoginStatus(text); break; } // 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。 case 'sessionInvalidated': case 'permissionDenied': break; // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 case 'authNetworkFailure': { const payload = failure as AuthNetworkFailure; const text = payload.reason === 'timeout' ? '登录服务响应超时,请检查服务器地址和网络后重试' : '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; setAuthCheckError(text); setLoginStatus(text); throw error; } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; const text = `登录服务暂时不可用(HTTP ${payload.status})`; setAuthCheckError(text); setLoginStatus(text); throw error; } case 'unexpectedRejection': { const payload = failure as UnexpectedRejection; const text = payload.serverMessage ? `登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` : `登录服务请求失败(HTTP ${payload.status})`; setAuthCheckError(text); setLoginStatus(text); throw error; } case 'authResponseInvalid': { const payload = failure as AuthResponseInvalid; let text: string; switch (payload.reason) { case 'notJson': text = '登录服务响应不是合法 JSON'; break; case 'invalidBody': text = '登录服务响应结构不符合契约'; break; case 'missingRefreshCookie': text = '登录服务未返回新的续期凭据'; break; case 'missingUserIdentity': text = '登录服务未返回用户身份'; break; case 'serverRejected': text = payload.serverMessage ? `登录服务拒绝了本次请求:${payload.serverMessage}` : '登录服务拒绝了本次请求'; break; default: { expectNever(payload.reason); text = '登录服务响应不符合契约'; } } setAuthCheckError(text); setLoginStatus(text); throw error; } case 'clientSessionPersistFailed': { const text = '本机登录凭据读写失败'; setAuthCheckError(text); setLoginStatus(text); throw error; } case 'runtimeSessionInstallFailed': { const text = '本机运行时会话安装失败'; setAuthCheckError(text); setLoginStatus(text); throw error; } case 'authClientInitFailed': { const text = '创建登录网络客户端失败'; setAuthCheckError(text); setLoginStatus(text); throw error; } default: { expectNever(failure); // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; setAuthCheckError(text); setLoginStatus(text); throw error; } } } } void hydrateAuth(); return () => { disposed = true; }; }, [authCheckRetryKey]); useEffect(() => { if (authStatus !== 'checking') { return; } setAuthCheckElapsedSeconds(0); const timer = window.setInterval(() => { setAuthCheckElapsedSeconds((current) => current + 1); }, 1000); return () => window.clearInterval(timer); }, [authStatus, authCheckRetryKey]); // Rust 是认证态事实源:另一个窗口登录/登出、或 Rust 内部判定登录失效时同步界面。 useEffect(() => { let disposed = false; let unsubscribe: (() => void) | null = null; void subscribeClientAuthState((state) => { if (disposed) return; if (state.status === 'authenticated') { setAuthUser((current) => current?.id === state.user.id ? state.user : state.user, ); setAuthCheckError(''); setAuthStatus('authenticated'); return; } setAuthUser(null); setAuthStatus('unauthenticated'); setLoginStatus('登录已失效,请重新登录'); }) .then((release) => { if (disposed) release(); else unsubscribe = release; }) .catch(() => undefined); return () => { disposed = true; unsubscribe?.(); }; }, []); useEffect(() => { if (codeCooldownSeconds <= 0) { return; } const timer = window.setInterval(() => { setCodeCooldownSeconds((current) => Math.max(0, current - 1)); }, 1000); return () => window.clearInterval(timer); }, [codeCooldownSeconds]); /** * 认证失败的分流判据直接写在每个 catch 里: * * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合, * `failure.type` 是唯一分流键; * - 业务 / 会话变体:无载荷变体用本 catch 的固定文案;带载荷变体先 `as` 取具名类型, * 可枚举的细分再用 `switch (payload.reason)` 在类型化 reason 上分流并拼上下文 * (Rust 不预拼用户可见文案),永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 * * 不把判据抽成函数:判定必须发生在 catch 里。 */ async function handleSendCode() { if (codeBusy || codeCooldownSeconds > 0) { return; } if (!persistServerSelection()) return; const apiBaseUrl = getClientServerBaseUrl(); const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); return; } setCodeBusy(true); setLoginStatus('正在发送验证码'); try { const response = await sendClientPhoneLoginCode( normalizedPhone, apiBaseUrl, ); setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { setLoginStatus(error instanceof Error ? error.message : String(error)); throw error; } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; let text: string; switch (payload.reason) { case 'emptyOrTooLong': text = '服务器地址非法: 不能为空或超长'; break; case 'notAUrl': text = '服务器地址非法: 不是合法地址'; break; case 'hasCredentials': text = '服务器地址非法: 不能包含用户名或密码'; break; case 'hasPathOrQueryOrFragment': text = '服务器地址非法: 不能带路径、查询或片段'; break; case 'notHttps': text = '服务器地址非法: 远程地址必须使用 https'; break; case 'unsupportedScheme': text = '服务器地址非法: 只支持 http 或 https'; break; case 'outsideChannel': text = '服务器地址非法: 不在当前构建渠道范围内'; break; default: { expectNever(payload.reason); text = '服务器地址非法'; } } setLoginStatus(text); break; } case 'phoneNumberInvalid': { setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); break; } case 'passwordMissing': { setLoginStatus('密码无效: 不能为空'); break; } case 'loginCodeMissing': { setLoginStatus('验证码无效: 不能为空'); break; } case 'passwordLoginRejected': { const payload = failure as PasswordLoginRejected; setLoginStatus( `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`, ); break; } case 'phoneOrPasswordMismatch': { setLoginStatus('手机号或密码错误'); break; } case 'sendCodeRejected': { const payload = failure as SendCodeRejected; setLoginStatus( `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, ); break; } case 'smsCodeThrottled': { setLoginStatus('发送验证码过于频繁,请稍后再试'); break; } case 'phoneCodeLoginRejected': { const payload = failure as PhoneCodeLoginRejected; setLoginStatus( `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`, ); break; } case 'sessionInvalidated': { setLoginStatus('登录状态已失效,请重新登录'); break; } case 'permissionDenied': { setLoginStatus('当前账号没有权限执行此操作'); break; } // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 case 'authNetworkFailure': { const payload = failure as AuthNetworkFailure; setLoginStatus( payload.reason === 'timeout' ? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试' : '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ); throw error; } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } case 'unexpectedRejection': { const payload = failure as UnexpectedRejection; setLoginStatus( payload.serverMessage ? `发送验证码失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` : `发送验证码失败:登录服务请求失败(HTTP ${payload.status})`, ); throw error; } case 'authResponseInvalid': { const payload = failure as AuthResponseInvalid; let text: string; switch (payload.reason) { case 'notJson': text = '发送验证码失败:登录服务响应不是合法 JSON'; break; case 'invalidBody': text = '发送验证码失败:登录服务响应结构不符合契约'; break; case 'missingRefreshCookie': text = '发送验证码失败:登录服务未返回新的续期凭据'; break; case 'missingUserIdentity': text = '发送验证码失败:登录服务未返回用户身份'; break; case 'serverRejected': text = payload.serverMessage ? `发送验证码失败:登录服务拒绝了本次请求:${payload.serverMessage}` : '发送验证码失败:登录服务拒绝了本次请求'; break; default: { expectNever(payload.reason); text = '发送验证码失败:登录服务响应不符合契约'; } } setLoginStatus(text); throw error; } case 'clientSessionPersistFailed': { setLoginStatus('发送验证码失败:本机登录凭据读写失败'); throw error; } case 'runtimeSessionInstallFailed': { setLoginStatus('发送验证码失败:本机运行时会话安装失败'); throw error; } case 'authClientInitFailed': { setLoginStatus('发送验证码失败:创建登录网络客户端失败'); throw error; } default: { expectNever(failure); setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); throw error; } } } finally { setCodeBusy(false); } } async function handleLoginSubmit(event: FormEvent) { event.preventDefault(); if (loginBusy) { return; } const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); return; } if (loginMode === 'code' && !code.trim()) { setLoginStatus('请输入验证码'); return; } if (loginMode === 'password' && !password.trim()) { setLoginStatus('请输入密码'); return; } if (!persistServerSelection()) return; const loginApiBaseUrl = getClientServerBaseUrl(); const loginAttempt = (loginAttemptRef.current += 1); setLoginBusy(true); setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); try { // Rust 登录命令内部完成:凭据持久化 → 本机运行时会话安装 → Runner 传播 → 事件。 const user = loginMode === 'code' ? await loginClientWithPhoneCode( normalizedPhone, code, loginApiBaseUrl, ) : await loginClientWithPassword( normalizedPhone, password, loginApiBaseUrl, ); const committedGeneration = await withAuthCheckTimeout( commitAuthenticatedPlatformSession( user, loginGeneration, loginApiBaseUrl, ), AUTH_CHECK_RUNNER_TIMEOUT_MS, '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null) { return; } if (loginAttemptRef.current !== loginAttempt) { return; } setAuthUser(user); setAuthCheckError(''); setAuthStatus('authenticated'); setCode(''); setPassword(''); } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { setLoginStatus(error instanceof Error ? error.message : String(error)); throw error; } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 case 'serverAddressRejected': { const payload = failure as ServerAddressRejected; let text: string; switch (payload.reason) { case 'emptyOrTooLong': text = '服务器地址非法: 不能为空或超长'; break; case 'notAUrl': text = '服务器地址非法: 不是合法地址'; break; case 'hasCredentials': text = '服务器地址非法: 不能包含用户名或密码'; break; case 'hasPathOrQueryOrFragment': text = '服务器地址非法: 不能带路径、查询或片段'; break; case 'notHttps': text = '服务器地址非法: 远程地址必须使用 https'; break; case 'unsupportedScheme': text = '服务器地址非法: 只支持 http 或 https'; break; case 'outsideChannel': text = '服务器地址非法: 不在当前构建渠道范围内'; break; default: { expectNever(payload.reason); text = '服务器地址非法'; } } setLoginStatus(text); break; } case 'phoneNumberInvalid': { setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); break; } case 'passwordMissing': { setLoginStatus('密码无效: 不能为空'); break; } case 'loginCodeMissing': { setLoginStatus('验证码无效: 不能为空'); break; } case 'passwordLoginRejected': { const payload = failure as PasswordLoginRejected; setLoginStatus( `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`, ); break; } case 'phoneOrPasswordMismatch': { setLoginStatus('手机号或密码错误'); break; } case 'sendCodeRejected': { const payload = failure as SendCodeRejected; setLoginStatus( `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, ); break; } case 'smsCodeThrottled': { setLoginStatus('发送验证码过于频繁,请稍后再试'); break; } case 'phoneCodeLoginRejected': { const payload = failure as PhoneCodeLoginRejected; setLoginStatus( `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`, ); break; } case 'sessionInvalidated': { setLoginStatus('登录状态已失效,请重新登录'); break; } case 'permissionDenied': { setLoginStatus('当前账号没有权限执行此操作'); break; } // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 case 'authNetworkFailure': { const payload = failure as AuthNetworkFailure; setLoginStatus( payload.reason === 'timeout' ? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试' : '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ); throw error; } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } case 'unexpectedRejection': { const payload = failure as UnexpectedRejection; setLoginStatus( payload.serverMessage ? `登录失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` : `登录失败:登录服务请求失败(HTTP ${payload.status})`, ); throw error; } case 'authResponseInvalid': { const payload = failure as AuthResponseInvalid; let text: string; switch (payload.reason) { case 'notJson': text = '登录失败:登录服务响应不是合法 JSON'; break; case 'invalidBody': text = '登录失败:登录服务响应结构不符合契约'; break; case 'missingRefreshCookie': text = '登录失败:登录服务未返回新的续期凭据'; break; case 'missingUserIdentity': text = '登录失败:登录服务未返回用户身份'; break; case 'serverRejected': text = payload.serverMessage ? `登录失败:登录服务拒绝了本次请求:${payload.serverMessage}` : '登录失败:登录服务拒绝了本次请求'; break; default: { expectNever(payload.reason); text = '登录失败:登录服务响应不符合契约'; } } setLoginStatus(text); throw error; } case 'clientSessionPersistFailed': { setLoginStatus('登录失败:本机登录凭据读写失败'); throw error; } case 'runtimeSessionInstallFailed': { setLoginStatus('登录失败:本机运行时会话安装失败'); throw error; } case 'authClientInitFailed': { setLoginStatus('登录失败:创建登录网络客户端失败'); throw error; } default: { expectNever(failure); setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); throw error; } } } finally { setLoginBusy(false); } } async function logout() { beginPlatformSessionClearTransition(); let nativeClearError: unknown = null; try { await withAuthCheckTimeout( logoutClientAuthSession(), AUTH_CHECK_RUNNER_TIMEOUT_MS, '清理本地运行时超时,请重启客户端后再登录', ); } catch (error) { nativeClearError = error; } setAuthUser(null); setAuthCheckError(''); setAuthStatus('unauthenticated'); setLoginStatus( nativeClearError ? '已退出登录;本地运行时登录态同步失败,请重启客户端后再登录' : '已退出登录', ); } if (authStatus === 'checking') { const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage]; return (
陶泥儿

正在检查登录状态

{stageLabel} · 已等待 {authCheckElapsedSeconds} 秒

{authCheckElapsedSeconds >= 8 ? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试' : '正在恢复会话,请稍候'}

); } if (!authUser) { return (
陶泥儿

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

{clientServerSelectionEnabled ? ( <> {serverSelection.preset === 'custom' ? ( ) : null} ) : null} {authCheckError ? (

{authCheckError}

) : null}
{loginMode === 'code' ? (
) : ( )} {!authCheckError ? (

{loginStatus}

) : null}
); } return ( <> {children({ user: authUser, logout })} ); }