Files
Genarrative/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx
T
kdletters 755bf80858
Project CI / Repository checks (push) Failing after 44s
Project CI / Native shell tests (push) Failing after 1m42s
Project CI / Frontend tests (push) Successful in 2m50s
Project CI / Backend tests (push) Successful in 3m41s
支持 AI 游戏创作打开现有 Godot 项目
新增 Godot 项目选择、校验、导入和项目上下文切换入口。

只在所选项目根保留 .agent 元数据,并让总控使用标准运行档。

修复发布版原生 API 访问和 React runtime 重复加载问题。

收紧 Provider 工具 Schema 子集并增加回归门禁。

补充 Rust、前端测试及 PRD、技术方案和共享决策记录。
2026-08-11 13:25:29 +08:00

335 lines
9.6 KiB
TypeScript

import {
Component,
type ErrorInfo,
type FormEvent,
type ReactNode,
useEffect,
useState,
} from 'react';
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import {
clearStoredAuthAccessToken,
getClientAuthErrorMessage,
getCurrentClientAuthUser,
getStoredAuthAccessToken,
isClientAuthRecoverableCheckError,
loginClientWithPassword,
loginClientWithPhoneCode,
logoutClientAuthSession,
normalizeAuthPhoneInput,
refreshClientAuthAccessToken,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
type ClientRuntimeErrorBoundaryProps = {
children: ReactNode;
onLogout: () => void;
};
type ClientRuntimeErrorBoundaryState = {
errorMessage: string;
};
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);
}
render() {
if (!this.state.errorMessage) {
return this.props.children;
}
return (
<main className="client-auth-shell" aria-label="客户端页面加载失败">
<section className="client-auth-panel">
<span className="client-auth-logo">tn</span>
<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 [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);
useEffect(() => {
let disposed = false;
async function hydrateAuth() {
try {
if (!getStoredAuthAccessToken()) {
await refreshClientAuthAccessToken();
}
const user = await getCurrentClientAuthUser();
if (disposed) {
return;
}
if (user) {
setAuthUser(user);
setAuthStatus('authenticated');
return;
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
} catch (error) {
if (disposed) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(error)
) {
setLoginStatus(
getClientAuthErrorMessage(error, '登录服务暂时不可用,请稍后重试'),
);
setAuthStatus('unauthenticated');
return;
}
if (getStoredAuthAccessToken()) {
try {
await refreshClientAuthAccessToken();
const user = await getCurrentClientAuthUser();
if (disposed) {
return;
}
if (user) {
setAuthUser(user);
setAuthStatus('authenticated');
return;
}
} catch (retryError) {
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(retryError)
) {
setLoginStatus(
getClientAuthErrorMessage(
retryError,
'登录服务暂时不可用,请稍后重试',
),
);
setAuthStatus('unauthenticated');
return;
}
}
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
}
}
void hydrateAuth();
return () => {
disposed = true;
};
}, []);
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 normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
setLoginStatus('请输入手机号');
return;
}
setCodeBusy(true);
setLoginStatus('正在发送验证码');
try {
const response = await sendClientPhoneLoginCode(normalizedPhone);
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
} catch (error) {
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;
}
setLoginBusy(true);
setLoginStatus('正在登录');
try {
const user =
loginMode === 'code'
? await loginClientWithPhoneCode(normalizedPhone, code)
: await loginClientWithPassword(normalizedPhone, password);
setAuthUser(user);
setAuthStatus('authenticated');
setCode('');
setPassword('');
} catch (error) {
setLoginStatus(error instanceof Error ? error.message : String(error));
} finally {
setLoginBusy(false);
}
}
async function logout() {
try {
await logoutClientAuthSession();
} catch {
clearStoredAuthAccessToken();
}
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('已退出登录');
}
if (authStatus === 'checking') {
return (
<main className="client-auth-shell" aria-label="登录状态检查">
<section className="client-auth-panel">
<span className="client-auth-logo">tn</span>
<h1>正在检查登录状态</h1>
</section>
</main>
);
}
if (!authUser) {
return (
<main className="client-auth-shell" aria-label="登录">
<form className="client-auth-panel" onSubmit={handleLoginSubmit}>
<span className="client-auth-logo">tn</span>
<div>
<h1>登录陶泥儿 GameAgent</h1>
<p>登录后进入首页和本地项目工作区</p>
</div>
<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>
<p className="client-auth-status">{loginStatus}</p>
</form>
</main>
);
}
return (
<ClientRuntimeErrorBoundary onLogout={logout}>
{children({ user: authUser, logout })}
</ClientRuntimeErrorBoundary>
);
}