补齐客户端验证码登录

新增独立客户端手机号验证码发送和登录流程。

登录请求在网络层失败时展示登录服务不可达提示。

补充验证码登录、Load failed 文案和登录页基线测试。

同步 AI 游戏创作 App 登录口径文档。
This commit is contained in:
AIGameCreator App
2026-07-08 10:39:40 +08:00
parent ada9bc9553
commit 7afc0da5c4
4 changed files with 339 additions and 18 deletions
+165 -18
View File
@@ -25,8 +25,10 @@ import {
import type {
AuthEntryResponse,
AuthRefreshResponse,
AuthMeResponse,
AuthPhoneLoginResponse,
AuthPhoneSendCodeResponse,
AuthRefreshResponse,
AuthUser,
LogoutResponse,
} from '../../../packages/shared/src/contracts/auth';
@@ -75,6 +77,7 @@ const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
const CONVERSATION_VISIBLE_STEP = 20;
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082';
const launcherNotifications: Array<{
label: string;
detail: string;
@@ -926,6 +929,22 @@ function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
function resolveClientAuthApiUrl(url: string) {
if (/^https?:\/\//iu.test(url)) {
return url;
}
if (import.meta.env.DEV) {
return url;
}
const isHttpPage =
window.location.protocol === 'http:' ||
window.location.protocol === 'https:';
if (!window.__TAURI__ && isHttpPage) {
return url;
}
return `${DEFAULT_CLIENT_AUTH_API_BASE_URL}${url}`;
}
let clientAuthRefreshPromise: Promise<string> | null = null;
async function readAuthErrorMessage(response: Response, fallback: string) {
@@ -956,11 +975,16 @@ async function requestAuthJson<T>(
headers.set('Authorization', `Bearer ${token}`);
}
}
const response = await fetch(url, {
...init,
credentials: 'same-origin',
headers,
});
let response: Response;
try {
response = await fetch(resolveClientAuthApiUrl(url), {
...init,
credentials: 'same-origin',
headers,
});
} catch {
throw new Error('无法连接登录服务,请确认配套后端或 API 代理已启动后重试');
}
if (!response.ok) {
throw new Error(await readAuthErrorMessage(response, fallbackMessage));
}
@@ -1014,6 +1038,40 @@ async function loginClientWithPassword(phone: string, password: string) {
return response.user;
}
async function sendClientPhoneLoginCode(phone: string) {
return requestAuthJson<AuthPhoneSendCodeResponse>(
'/api/auth/phone/send-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizeAuthPhoneInput(phone),
scene: 'login',
}),
},
'发送验证码失败',
{ skipAuth: true },
);
}
async function loginClientWithPhoneCode(phone: string, code: string) {
const response = await requestAuthJson<AuthPhoneLoginResponse>(
'/api/auth/phone/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizeAuthPhoneInput(phone),
code: code.trim(),
}),
},
'登录失败',
{ skipAuth: true },
);
setStoredAuthAccessToken(response.token);
return response.user;
}
async function logoutClientAuthSession() {
try {
if (!getStoredAuthAccessToken()) {
@@ -1055,10 +1113,14 @@ export function AuthenticatedClient({
'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;
@@ -1108,22 +1170,66 @@ export function AuthenticatedClient({
};
}, []);
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 || !password.trim()) {
setLoginStatus('请输入手机号和密码');
if (!normalizedPhone) {
setLoginStatus('请输入手机号');
return;
}
if (loginMode === 'code' && !code.trim()) {
setLoginStatus('请输入验证码');
return;
}
if (loginMode === 'password' && !password.trim()) {
setLoginStatus('请输入密码');
return;
}
setLoginBusy(true);
setLoginStatus('正在登录');
try {
const user = await loginClientWithPassword(normalizedPhone, password);
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));
@@ -1163,6 +1269,22 @@ export function AuthenticatedClient({
<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
@@ -1172,15 +1294,40 @@ export function AuthenticatedClient({
onChange={(event) => setPhone(event.currentTarget.value)}
/>
</label>
<label>
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(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>
+32
View File
@@ -102,6 +102,38 @@ textarea {
opacity: 0.62;
}
.client-auth-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 4px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.client-auth-tabs button {
background: transparent;
color: #4b5563;
}
.client-auth-tabs button.is-active {
background: #111827;
color: #fff;
}
.client-auth-code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 104px;
gap: 10px;
align-items: end;
}
.client-auth-code-row button {
padding: 0 10px;
white-space: nowrap;
}
.client-auth-status {
min-height: 18px;
overflow-wrap: anywhere;
@@ -128,6 +128,147 @@ describe('AI 游戏创作 App 界面边界', () => {
);
});
it('shows phone code login before entering the workspace', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(
screen.getByRole('button', { name: '验证码登录' }),
).not.toBeNull();
expect(screen.getByRole('button', { name: '密码登录' })).not.toBeNull();
expect(screen.getByLabelText('手机号')).not.toBeNull();
expect(screen.getByLabelText('验证码')).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('logs in with a phone code and stores the returned token', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/send-code') {
expect(JSON.parse(String(init?.body))).toMatchObject({
phone: '13800000000',
scene: 'login',
});
return new Response(
JSON.stringify({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'sms-1',
}),
{ status: 200 },
);
}
if (url === '/api/auth/phone/login') {
expect(JSON.parse(String(init?.body))).toMatchObject({
phone: '13800000000',
code: '123456',
});
return new Response(
JSON.stringify({
token: 'phone-token',
user: { ...testAuthUser, loginMethod: 'phone' },
created: false,
referral: null,
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement('main', { 'aria-label': '已登录' }, user.loginMethod),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '138 0000 0000' },
});
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
expect(await screen.findByText('验证码已发送,300 秒内有效')).not.toBeNull();
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(screen.getByLabelText('已登录').textContent).toBe('phone');
expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBe(
'phone-token',
);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/phone/send-code',
),
).toHaveLength(1);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/phone/login',
),
).toHaveLength(1);
});
it('shows a clear login service error instead of raw Load failed', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
throw new TypeError('Load failed');
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(
await screen.findByText(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(screen.queryByText(/Load failed/u)).toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('derives agent card status from the latest run trace step', () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
@@ -236,6 +236,7 @@ game-project/
## 当前最小落地
- `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`
- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code``/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`Tauri dev 下 `/api` 走固定 3080 Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,启动器 / 主窗口切换命令放在 `windows.rs`Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
- 本地项目初始化会创建 `game/``assets/``memory/``memory/agents/``exports/``.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion``role``content``agentId``updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。