补齐客户端验证码登录
新增独立客户端手机号验证码发送和登录流程。 登录请求在网络层失败时展示登录服务不可达提示。 补充验证码登录、Load failed 文案和登录页基线测试。 同步 AI 游戏创作 App 登录口径文档。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 对话历史的事实源;目录在首次写入时创建。
|
||||
|
||||
Reference in New Issue
Block a user