394b8de9d2
- 三条错误表去冗余前缀:DirectTurnError→TurnError、DirectTurnEnqueueError→EnqueueError、DirectTurnFailure→TurnFailure、DirectTurnDeadline→Deadline、DirectCodexFailureStage→FailureStage、DirectCodexNativeKind→NativeKind、DirectModelCallKind→ModelCallKind
- 用户条目去前缀:DirectCodexUserItem→UserItem 等六个类型;agent/direct_turn_error.rs 移到 codex_app_server/turn_error.rs
- thread_manager/wire.rs 拆为 wire/{mod,clock,items,turn,failure,tests}.rs;direct_turn_failure.rs 的失败载荷并入 wire/failure.rs
- 宿主侧回合终态从 struct TurnTerminal 改为 enum TurnCompletion{Completed,Interrupted,Aborted,Failed},从 wire 移到 thread_manager/turn_completion.rs,status 由变体反推
- 过期回合释放兜底改走 TurnCompletion::Aborted.event
- 同步生成绑定、前端调用点与测试;修正终态单测里过时的 TransportClosed 断言
- 对 src-tauri 全量 cargo fmt,满足 pre-commit 的 rustfmt 门禁
1140 lines
41 KiB
TypeScript
1140 lines
41 KiB
TypeScript
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<AuthCheckStage, string> = {
|
||
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<T>(
|
||
promise: Promise<T>,
|
||
timeoutMs: number,
|
||
message: string,
|
||
) {
|
||
void promise.catch(() => undefined);
|
||
let timeoutId: number | undefined;
|
||
const timeout = new Promise<T>((_, 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 (
|
||
<main
|
||
className="client-auth-shell platform-theme platform-theme--light"
|
||
aria-label="客户端页面加载失败"
|
||
>
|
||
<section className="client-auth-panel">
|
||
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
|
||
<div>
|
||
<h1>客户端页面加载失败</h1>
|
||
<p className="client-auth-status">{this.state.errorMessage}</p>
|
||
</div>
|
||
<button type="button" onClick={this.props.onLogout}>
|
||
返回登录
|
||
</button>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
}
|
||
|
||
export function AuthenticatedClient({
|
||
children,
|
||
}: {
|
||
children: (session: { user: AuthUser; logout: () => void }) => ReactNode;
|
||
}) {
|
||
const [authStatus, setAuthStatus] = useState<
|
||
'checking' | 'authenticated' | 'unauthenticated'
|
||
>('checking');
|
||
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
|
||
const [authCheckStage, setAuthCheckStage] = useState<AuthCheckStage>('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<ClientServerSelection>(
|
||
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<HTMLFormElement>) {
|
||
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 (
|
||
<main
|
||
className="client-auth-shell platform-theme platform-theme--light"
|
||
aria-label="登录状态检查"
|
||
>
|
||
<section className="client-auth-panel">
|
||
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
|
||
<h1>正在检查登录状态</h1>
|
||
<p className="client-auth-status" aria-live="polite">
|
||
{stageLabel} · 已等待 {authCheckElapsedSeconds} 秒
|
||
</p>
|
||
<p className="client-auth-status" aria-live="polite">
|
||
{authCheckElapsedSeconds >= 8
|
||
? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试'
|
||
: '正在恢复会话,请稍候'}
|
||
</p>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (!authUser) {
|
||
return (
|
||
<main
|
||
className="client-auth-shell platform-theme platform-theme--light"
|
||
aria-label="登录"
|
||
>
|
||
<form className="client-auth-panel" onSubmit={handleLoginSubmit}>
|
||
<img className="client-auth-logo" src={brandIcon} alt="陶泥儿" />
|
||
<div>
|
||
<h1>登录陶泥儿 GameAgent</h1>
|
||
<p>登录后进入首页和本地项目工作区</p>
|
||
</div>
|
||
{clientServerSelectionEnabled ? (
|
||
<>
|
||
<label>
|
||
服务器
|
||
<select
|
||
aria-label="服务器"
|
||
disabled={loginBusy || codeBusy}
|
||
value={serverSelection.preset}
|
||
onChange={(event) =>
|
||
handleServerPresetChange(
|
||
event.currentTarget.value as ClientServerPreset,
|
||
)
|
||
}
|
||
>
|
||
<option value="release">release</option>
|
||
<option value="dev">dev</option>
|
||
<option value="custom">custom</option>
|
||
</select>
|
||
</label>
|
||
{serverSelection.preset === 'custom' ? (
|
||
<label>
|
||
自定义服务器地址
|
||
<input
|
||
aria-label="自定义服务器地址"
|
||
disabled={loginBusy || codeBusy}
|
||
inputMode="url"
|
||
placeholder="https://example.com"
|
||
value={customServerUrl}
|
||
onChange={(event) =>
|
||
setCustomServerUrl(event.currentTarget.value)
|
||
}
|
||
onBlur={() => {
|
||
if (!customServerUrl.trim()) return;
|
||
try {
|
||
normalizeClientServerBaseUrl(customServerUrl);
|
||
persistServerSelection();
|
||
} catch (error) {
|
||
setLoginStatus(
|
||
error instanceof Error
|
||
? error.message
|
||
: String(error),
|
||
);
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
{authCheckError ? (
|
||
<div role="alert" className="client-auth-status">
|
||
<p>{authCheckError}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
beginPlatformSessionTransition();
|
||
setAuthCheckError('');
|
||
setAuthStatus('checking');
|
||
setAuthCheckRetryKey((current) => current + 1);
|
||
}}
|
||
disabled={loginBusy || codeBusy}
|
||
>
|
||
重试登录状态检查
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<div className="client-auth-tabs" role="group" aria-label="登录方式">
|
||
<button
|
||
type="button"
|
||
className={loginMode === 'code' ? 'is-active' : ''}
|
||
onClick={() => setLoginMode('code')}
|
||
>
|
||
验证码登录
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={loginMode === 'password' ? 'is-active' : ''}
|
||
onClick={() => setLoginMode('password')}
|
||
>
|
||
密码登录
|
||
</button>
|
||
</div>
|
||
<label>
|
||
手机号
|
||
<input
|
||
autoComplete="tel"
|
||
inputMode="tel"
|
||
value={phone}
|
||
onChange={(event) => setPhone(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
{loginMode === 'code' ? (
|
||
<div className="client-auth-code-row">
|
||
<label>
|
||
验证码
|
||
<input
|
||
autoComplete="one-time-code"
|
||
inputMode="numeric"
|
||
value={code}
|
||
onChange={(event) => setCode(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={handleSendCode}
|
||
disabled={codeBusy || codeCooldownSeconds > 0}
|
||
>
|
||
{codeBusy
|
||
? '发送中'
|
||
: codeCooldownSeconds > 0
|
||
? `${codeCooldownSeconds}s`
|
||
: '获取验证码'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<label>
|
||
密码
|
||
<input
|
||
autoComplete="current-password"
|
||
type="password"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.currentTarget.value)}
|
||
/>
|
||
</label>
|
||
)}
|
||
<button type="submit" disabled={loginBusy}>
|
||
{loginBusy ? '登录中' : '登录'}
|
||
</button>
|
||
{!authCheckError ? (
|
||
<p className="client-auth-status">{loginStatus}</p>
|
||
) : null}
|
||
</form>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<ClientRuntimeErrorBoundary onLogout={logout}>
|
||
{children({ user: authUser, logout })}
|
||
</ClientRuntimeErrorBoundary>
|
||
<ErrorReportNotice />
|
||
</>
|
||
);
|
||
}
|