991b2a1104
## 变更内容 AGC 登录、Runner 会话、最近项目检查和首页自动创建原先各自维护异步状态,响应体卡住、单目录变慢或切页会导致按钮长期 busy、项目列表整体不可用或重复创建项目。本 PR 将这些入口收口到可恢复的生命周期边界: - 认证响应体读取增加独立超时,refresh singleflight 在失败后释放; - Runner 会话安装/清除移到 blocking worker,登录/退出增加 45 秒 UI fence; - 最近项目逐项检查并设置单项目超时,已完成行不受其它慢目录阻塞; - 首页自动创建锁提升到 WorkspaceLauncher,切页后仍防重并保留状态; - 新增异步闭环技术方案、body 卡住测试、逐行项目测试和跨页创建测试。 ## 验证 - `npm --prefix apps/ai-game-creator-shell run typecheck` - `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientHttp.test.ts tests/clientApi.test.ts tests/recentProjectsModel.test.ts --reporter=dot` - `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot` - `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` - `npm run check:doc-index` - `npm run check:encoding` - `git diff --check` `appSurface.test.ts` 390/390 通过;保留仓库既有 act/jsdom media warning。本 PR 未触发真实 Provider 或发布安装包。 Reviewed-on: #346 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
725 lines
23 KiB
TypeScript
725 lines
23 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 {
|
|
clearStoredAuthAccessToken,
|
|
getClientAuthErrorMessage,
|
|
getCurrentClientAuthUser,
|
|
getStoredAuthAccessToken,
|
|
isClientAuthRecoverableCheckError,
|
|
loginClientWithPassword,
|
|
loginClientWithPhoneCode,
|
|
logoutClientAuthSession,
|
|
normalizeAuthPhoneInput,
|
|
sendClientPhoneLoginCode,
|
|
} from '../services/clientAuth';
|
|
import {
|
|
type ClientServerPreset,
|
|
type ClientServerSelection,
|
|
getClientServerBaseUrl,
|
|
getClientServerSelection,
|
|
normalizeClientServerBaseUrl,
|
|
setClientServerSelection,
|
|
} from '../services/clientHttp';
|
|
import {
|
|
captureClientError,
|
|
installWebviewLogBridge,
|
|
shouldCaptureClientError,
|
|
} from '../services/errorReporting';
|
|
import {
|
|
beginPlatformSessionClearTransition,
|
|
beginPlatformSessionTransition,
|
|
clearCommittedPlatformSession,
|
|
commitAuthenticatedPlatformSession,
|
|
currentPlatformSessionApiBaseUrl,
|
|
currentPlatformSessionGeneration,
|
|
refreshPlatformSessionForGeneration,
|
|
subscribePlatformSessionRefresh,
|
|
} from '../services/platformSession';
|
|
|
|
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);
|
|
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,
|
|
);
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
function persistServerSelection() {
|
|
try {
|
|
const next = setClientServerSelection({
|
|
preset: serverSelection.preset,
|
|
customBaseUrl: customServerUrl,
|
|
});
|
|
setServerSelection(next);
|
|
return next;
|
|
} catch (error) {
|
|
void captureClientError(error, {
|
|
source: 'auth-hydrate',
|
|
action: 'restore-session',
|
|
});
|
|
setLoginStatus(error instanceof Error ? error.message : String(error));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function handleServerPresetChange(preset: ClientServerPreset) {
|
|
if (preset === 'custom') {
|
|
setServerSelection((current) => ({ ...current, preset }));
|
|
return;
|
|
}
|
|
const next = setClientServerSelection({
|
|
preset,
|
|
customBaseUrl: customServerUrl,
|
|
});
|
|
setServerSelection(next);
|
|
setLoginStatus(`已选择 ${preset} 服务器`);
|
|
}
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
async function hydrateAuth() {
|
|
const runId = ++authCheckRunRef.current;
|
|
const isActiveRun = () => !disposed && authCheckRunRef.current === runId;
|
|
const hydrationGeneration = currentPlatformSessionGeneration();
|
|
const hydrationApiBaseUrl = getClientServerBaseUrl();
|
|
setAuthCheckError('');
|
|
setAuthCheckStage('token');
|
|
try {
|
|
if (!getStoredAuthAccessToken()) {
|
|
setAuthCheckStage('refresh');
|
|
const refreshed = await withAuthCheckTimeout(
|
|
refreshPlatformSessionForGeneration(
|
|
hydrationGeneration,
|
|
hydrationApiBaseUrl,
|
|
),
|
|
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
|
'刷新登录状态超时,请检查服务器地址和网络后重试',
|
|
);
|
|
if (!refreshed) {
|
|
if (isActiveRun()) {
|
|
setAuthStatus('unauthenticated');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
if (!isActiveRun()) return;
|
|
setAuthCheckStage('me');
|
|
const user = await withAuthCheckTimeout(
|
|
getCurrentClientAuthUser(hydrationApiBaseUrl),
|
|
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
|
'读取当前用户超时,请检查服务器地址和网络后重试',
|
|
);
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
if (user) {
|
|
setAuthCheckStage('runner');
|
|
const committedGeneration = await withAuthCheckTimeout(
|
|
commitAuthenticatedPlatformSession(
|
|
user,
|
|
hydrationGeneration,
|
|
hydrationApiBaseUrl,
|
|
),
|
|
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
|
'连接本地运行时超时,请重试或重启客户端',
|
|
);
|
|
if (committedGeneration === null) {
|
|
return;
|
|
}
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
setAuthUser(user);
|
|
setAuthCheckError('');
|
|
setAuthStatus('authenticated');
|
|
return;
|
|
}
|
|
clearStoredAuthAccessToken();
|
|
setAuthStatus('unauthenticated');
|
|
} catch (error) {
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
if (
|
|
getStoredAuthAccessToken() &&
|
|
isClientAuthRecoverableCheckError(error)
|
|
) {
|
|
const message = getClientAuthErrorMessage(
|
|
error,
|
|
'登录服务暂时不可用,请稍后重试',
|
|
);
|
|
setAuthCheckError(message);
|
|
setLoginStatus(message);
|
|
setAuthStatus('unauthenticated');
|
|
return;
|
|
}
|
|
if (getStoredAuthAccessToken()) {
|
|
try {
|
|
if (!isActiveRun()) return;
|
|
setAuthCheckStage('refresh');
|
|
const refreshed = await withAuthCheckTimeout(
|
|
refreshPlatformSessionForGeneration(
|
|
hydrationGeneration,
|
|
hydrationApiBaseUrl,
|
|
),
|
|
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
|
'刷新登录状态超时,请检查服务器地址和网络后重试',
|
|
);
|
|
if (!refreshed) {
|
|
if (isActiveRun()) {
|
|
setAuthStatus('unauthenticated');
|
|
}
|
|
return;
|
|
}
|
|
if (!isActiveRun()) return;
|
|
setAuthCheckStage('me');
|
|
const user = await withAuthCheckTimeout(
|
|
getCurrentClientAuthUser(hydrationApiBaseUrl),
|
|
AUTH_CHECK_REQUEST_TIMEOUT_MS,
|
|
'读取当前用户超时,请检查服务器地址和网络后重试',
|
|
);
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
if (user) {
|
|
if (!isActiveRun()) return;
|
|
setAuthCheckStage('runner');
|
|
const committedGeneration = await withAuthCheckTimeout(
|
|
commitAuthenticatedPlatformSession(
|
|
user,
|
|
hydrationGeneration,
|
|
hydrationApiBaseUrl,
|
|
),
|
|
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
|
'连接本地运行时超时,请重试或重启客户端',
|
|
);
|
|
if (committedGeneration === null) {
|
|
return;
|
|
}
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
setAuthCheckError('');
|
|
setAuthUser(user);
|
|
setAuthStatus('authenticated');
|
|
return;
|
|
}
|
|
} catch (retryError) {
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
if (
|
|
getStoredAuthAccessToken() &&
|
|
isClientAuthRecoverableCheckError(retryError)
|
|
) {
|
|
const message = getClientAuthErrorMessage(
|
|
retryError,
|
|
'登录服务暂时不可用,请稍后重试',
|
|
);
|
|
setAuthCheckError(message);
|
|
setLoginStatus(message);
|
|
setAuthStatus('unauthenticated');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if (!isActiveRun()) {
|
|
return;
|
|
}
|
|
if (isClientAuthRecoverableCheckError(error)) {
|
|
const message = getClientAuthErrorMessage(
|
|
error,
|
|
'登录服务暂时不可用,请稍后重试',
|
|
);
|
|
setAuthCheckError(message);
|
|
setLoginStatus(message);
|
|
}
|
|
clearStoredAuthAccessToken();
|
|
setAuthStatus('unauthenticated');
|
|
}
|
|
}
|
|
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]);
|
|
|
|
useEffect(
|
|
() =>
|
|
subscribePlatformSessionRefresh((result) => {
|
|
if (result.status === 'refreshed') {
|
|
setAuthUser((current) =>
|
|
current?.id === result.user.id ? result.user : current,
|
|
);
|
|
return;
|
|
}
|
|
if (result.status === 'failed') {
|
|
clearStoredAuthAccessToken();
|
|
setAuthUser(null);
|
|
setAuthStatus('unauthenticated');
|
|
setLoginStatus('登录已失效,请重新登录');
|
|
}
|
|
}),
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (codeCooldownSeconds <= 0) {
|
|
return;
|
|
}
|
|
const timer = window.setInterval(() => {
|
|
setCodeCooldownSeconds((current) => Math.max(0, current - 1));
|
|
}, 1000);
|
|
return () => window.clearInterval(timer);
|
|
}, [codeCooldownSeconds]);
|
|
|
|
async function handleSendCode() {
|
|
if (codeBusy || codeCooldownSeconds > 0) {
|
|
return;
|
|
}
|
|
const persistedSelection = persistServerSelection();
|
|
if (!persistedSelection) {
|
|
return;
|
|
}
|
|
const apiBaseUrl = getClientServerBaseUrl(persistedSelection);
|
|
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 (shouldCaptureClientError(error))
|
|
void captureClientError(error, {
|
|
source: 'auth',
|
|
action: 'send-login-code',
|
|
});
|
|
setLoginStatus(error instanceof Error ? error.message : String(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;
|
|
}
|
|
const persistedSelection = persistServerSelection();
|
|
if (!persistedSelection) {
|
|
return;
|
|
}
|
|
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
|
|
setLoginBusy(true);
|
|
setLoginStatus('正在登录');
|
|
const loginGeneration = beginPlatformSessionTransition();
|
|
try {
|
|
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;
|
|
}
|
|
setAuthUser(user);
|
|
setAuthCheckError('');
|
|
setAuthStatus('authenticated');
|
|
setCode('');
|
|
setPassword('');
|
|
} catch (error) {
|
|
if (shouldCaptureClientError(error))
|
|
void captureClientError(error, { source: 'auth', action: 'login' });
|
|
setLoginStatus(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
setLoginBusy(false);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
const logoutApiBaseUrl = currentPlatformSessionApiBaseUrl();
|
|
const logoutGeneration = beginPlatformSessionClearTransition();
|
|
let nativeClearError: unknown = null;
|
|
try {
|
|
await logoutClientAuthSession(logoutApiBaseUrl);
|
|
} catch {
|
|
clearStoredAuthAccessToken();
|
|
}
|
|
try {
|
|
await withAuthCheckTimeout(
|
|
clearCommittedPlatformSession(logoutGeneration),
|
|
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>
|
|
{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}
|
|
<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()) {
|
|
try {
|
|
normalizeClientServerBaseUrl(customServerUrl);
|
|
persistServerSelection();
|
|
} catch (error) {
|
|
setLoginStatus(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
) : 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 />
|
|
</>
|
|
);
|
|
}
|