diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1a1e0b4db..434713dbd 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -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 | null = null; async function readAuthErrorMessage(response: Response, fallback: string) { @@ -956,11 +975,16 @@ async function requestAuthJson( 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( + '/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( + '/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(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) { 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({

登录陶泥儿 GameAgent

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

+
+ + +
- + {loginMode === 'code' ? ( +
+ + +
+ ) : ( + + )} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d4f8ab2f8..ba9cb1d6f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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; diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 558e6029f..4c4d7b8e1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -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', diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1d8036505..73819307a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 对话历史的事实源;目录在首次写入时创建。