From 14208ccb64d2dbc88de748cb30ec19b38f878a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Fri, 1 May 2026 16:08:19 +0800 Subject: [PATCH 1/4] 1 --- ...WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md | 6 +- ...UTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md | 63 +++++++ ...ZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md | 3 + server-rs/crates/api-server/src/puzzle.rs | 58 ++++++- .../crates/api-server/src/runtime_profile.rs | 58 +++++-- src/components/auth/AuthGate.test.tsx | 106 ++++++++++-- src/components/auth/AuthGate.tsx | 65 +++++--- src/components/auth/LoginScreen.tsx | 89 +--------- .../auth/RegistrationInviteModal.tsx | 115 +++++++++++++ .../PlatformEntryFlowShellImpl.tsx | 65 ++++---- .../PuzzleRuntimeShell.test.tsx | 70 ++++++++ .../puzzle-runtime/PuzzleRuntimeShell.tsx | 21 ++- ...gEntryFlowShell.agent.interaction.test.tsx | 156 +++++++++++++++++- src/services/authService.test.ts | 37 +++++ src/services/authService.ts | 15 ++ 15 files changed, 752 insertions(+), 175 deletions(-) create mode 100644 docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md create mode 100644 src/components/auth/RegistrationInviteModal.tsx diff --git a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md index f68970bd..becb328f 100644 --- a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md +++ b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md @@ -29,6 +29,7 @@ - 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。 6. 简介区:展示玩法标签和作品简介;不追加说明类文案。 7. 底部动作:左侧按钮为“作品改造”,右侧主按钮为“启动”;两个按钮必须位于同一行,点击“启动”后进入对应玩法运行态并记录游玩次数。 + - 未登录用户可进入并浏览作品详情页,但点击“作品改造”和“启动”都必须先弹出登录入口面板;登录成功后自动继续刚才点击的动作,不直接发起 Remix、启动 run 或本地运行态。 8. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。 9. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。 @@ -100,5 +101,6 @@ 4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。 5. 点赞公开作品会走对应后端记录入口,首次点赞后刷新仍能看到递增后的点赞次数,重复点赞不会继续增加。 6. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。 -7. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播。 -8. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 +7. 未登录进入作品详情页后,点击“作品改造”和“启动”只打开登录入口面板;登录成功后恢复对应动作,未登录期间不会创建 Remix 草稿、开始拼图 run、记录 RPG 游玩或启动大鱼本地运行态。 +8. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播。 +9. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 diff --git a/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md b/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md new file mode 100644 index 00000000..cd6e06f5 --- /dev/null +++ b/docs/technical/AUTH_NEW_ACCOUNT_INVITE_MODAL_2026-05-01.md @@ -0,0 +1,63 @@ +# 新账号短信登录后置邀请码弹窗设计 + +日期:`2026-05-01` + +## 1. 目标 + +账号入口不再展示独立注册入口。用户统一从短信登录进入,后端通过 `POST /api/auth/phone/login` 返回的 `created` 字段判断本次是否创建了新账号。 + +当 `created=true` 时,前端在登录成功后额外弹出独立邀请码面板: + +1. 标题固定为 `请填写邀请码`。 +2. 标题下方展示邀请码输入框。 +3. 输入为空时主按钮显示 `跳过`,点击后关闭面板。 +4. 输入非空时主按钮显示 `提交`,点击后提交邀请码。 +5. 面板右上角提供取消按钮,点击后关闭面板。 + +## 2. 入口调整 + +登录弹窗只保留可用登录方式: + +1. 短信登录。 +2. 密码登录。 +3. 微信登录。 + +不得再展示 `注册` 页签、注册按钮或注册表单。邀请码不再出现在短信验证码表单中,避免用户把登录和注册理解成两套入口。 + +## 3. 邀请码提交 + +后置弹窗提交邀请码时调用已登录接口: + +```text +POST /api/profile/referrals/redeem-code +``` + +请求体: + +```json +{ + "inviteCode": "SPRING2026" +} +``` + +后端继续使用 SpacetimeDB 的 `redeem_profile_referral_invite_code` procedure 作为唯一真相源。该 procedure 已负责校验: + +1. 每个用户最多只能填写一个邀请码。 +2. 邀请码必须存在。 +3. 用户不能填写自己的邀请码。 +4. 双方奖励与钱包流水在同一事务内落地。 + +## 4. URL 邀请码 + +若地址中存在 `inviteCode` 或 `invite_code`,前端只将其作为新账号后置弹窗的默认输入值。它不会触发注册页签,也不会在短信登录请求中提前提交。 + +若用户登录的是已有账号,则不会弹出新账号邀请码面板。 + +## 5. 完成定义 + +1. 登录弹窗内不可见注册入口。 +2. 短信登录创建新账号后弹出邀请码面板。 +3. 邀请码为空时按钮为 `跳过`,非空时按钮为 `提交`。 +4. 取消按钮可关闭面板。 +5. 已登录邀请码接口允许提交,并继续由 SpacetimeDB procedure 兜底业务校验。 +6. 前端测试覆盖注册入口删除、新账号弹窗、URL 邀请码预填与提交。 diff --git a/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md b/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md index acc5e584..69043f80 100644 --- a/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md +++ b/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md @@ -91,6 +91,7 @@ remainingMs = max(0, timeLimitMs - effectiveElapsedMs) 1. 播放冻结视觉特效。 2. 显示冻结剩余时长。 3. 第一版冻结 `10000ms`。 +4. 若玩家打开冻结确认弹窗前视觉上仍是 `playing`,但确认扣费期间正式 run 已被服务端计时结算为 `failed`,服务端不得返回“操作不合法”。本次调用视为一次边界同步,已预扣费用必须退款,只返回失败态快照并刷新存档;前端关闭道具确认窗,展示失败面板,不播放冻结特效。 ## 计费规则 @@ -106,6 +107,8 @@ remainingMs = max(0, timeLimitMs - effectiveElapsedMs) 若扣费或道具过程失败,确认弹窗保持打开并继续暂停倒计时,在弹窗内展示失败原因;只有成功确认后才关闭弹窗并播放对应反馈。 +补充规则:冻结时间的边界同步不属于道具使用成功。服务端若发现 run 已超时失败,应退回本次预扣、把失败态落库并返回最新快照,避免玩家在确认窗内看到“操作不合法”。 + ## UI 规则 1. 底部只放 3 个道具按钮,不写规则说明文案。 diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index a1c26f29..15a90d4e 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1396,9 +1396,13 @@ pub async fn use_puzzle_runtime_prop( )); } }; + let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); let reducer_owner_user_id = owner_user_id.clone(); - let run = execute_billable_asset_operation( + let reducer_run_id = run_id.clone(); + let fallback_run_id = run_id.clone(); + let fallback_owner_user_id = owner_user_id.clone(); + let run_result = execute_billable_asset_operation( &state, &owner_user_id, billing_asset_kind, @@ -1407,7 +1411,7 @@ pub async fn use_puzzle_runtime_prop( state .spacetime_client() .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { - run_id, + run_id: reducer_run_id, owner_user_id: reducer_owner_user_id, prop_kind, used_at_micros: current_utc_micros(), @@ -1417,8 +1421,30 @@ pub async fn use_puzzle_runtime_prop( .map_err(map_puzzle_client_error) }, ) - .await - .map_err(|error| puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error))?; + .await; + + let run = match run_result { + Ok(run) => run, + Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { + // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 + // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 + state + .spacetime_client() + .get_puzzle_run(fallback_run_id, fallback_owner_user_id) + .await + .map_err(map_puzzle_client_error) + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) + })? + } + Err(error) => { + return Err(puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + error, + )); + } + }; Ok(json_success_body( Some(&request_context), @@ -2503,6 +2529,10 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError { })) } +fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool { + is_freeze_time && error.body_text().contains("操作不合法") +} + fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool { matches!(error, SpacetimeClientError::Procedure(message) if message.contains("save_puzzle_form_draft") @@ -3580,6 +3610,26 @@ mod tests { let response = error.into_response(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } + + #[test] + fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { + let invalid_operation = + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "操作不合法", + })); + let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "陶泥币余额不足", + })); + + assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true)); + assert!(!should_sync_puzzle_freeze_boundary( + &invalid_operation, + false + )); + assert!(!should_sync_puzzle_freeze_boundary(&other_error, true)); + } } struct PuzzleDashScopeSettings { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index a14a550b..8d6c3499 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -29,7 +29,8 @@ use shared_contracts::runtime::{ ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, - RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, + RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, + RedeemProfileRewardCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; @@ -216,14 +217,27 @@ pub async fn get_profile_referral_invite_center( } pub async fn redeem_profile_referral_invite_code( - State(_state): State, + State(state): State, Extension(request_context): Extension, - Extension(_authenticated): Extension, - Json(_payload): Json, + Extension(authenticated): Extension, + Json(payload): Json, ) -> Result, Response> { - Err(runtime_profile_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"), + let user_id = authenticated.claims().user_id().to_string(); + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_redeem_profile_referral_invite_code_response(record), )) } @@ -507,6 +521,18 @@ fn build_profile_referral_invite_center_response( } } +fn build_redeem_profile_referral_invite_code_response( + record: module_runtime::RuntimeReferralRedeemRecord, +) -> RedeemProfileReferralInviteCodeResponse { + RedeemProfileReferralInviteCodeResponse { + center: build_profile_referral_invite_center_response(record.center), + invitee_reward_granted: record.invitee_reward_granted, + inviter_reward_granted: record.inviter_reward_granted, + invitee_balance_after: record.invitee_balance_after, + inviter_balance_after: record.inviter_balance_after, + } +} + fn build_redeem_profile_reward_code_response( record: RuntimeProfileRewardCodeRedeemRecord, ) -> RedeemProfileRewardCodeResponse { @@ -603,6 +629,7 @@ mod tests { AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::Value; + use std::time::Duration; use time::OffsetDateTime; use tower::ServiceExt; @@ -759,7 +786,7 @@ mod tests { } #[tokio::test] - async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() { + async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() { let state = seed_authenticated_state().await; let token = issue_access_token(&state); let app = build_router(state); @@ -777,7 +804,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() .collect() @@ -787,8 +814,8 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( - payload["error"]["message"], - Value::String("邀请码仅注册时填写".to_string()) + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) ); } @@ -900,7 +927,7 @@ mod tests { } async fn seed_authenticated_state() -> AppState { - let state = AppState::new(AppConfig::default()).expect("state should build"); + let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build"); state .seed_test_phone_user_with_password("13800138104", "secret123") .await @@ -908,6 +935,13 @@ mod tests { state } + fn fast_spacetime_timeout_config() -> AppConfig { + AppConfig { + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + } + } + fn issue_access_token(state: &AppState) -> String { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index c3feca1a..0d0616ed 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -18,6 +18,7 @@ const authMocks = vi.hoisted(() => ({ loginWithPhoneCode: vi.fn(), logoutAllAuthSessions: vi.fn(), logoutAuthUser: vi.fn(), + redeemRegistrationInviteCode: vi.fn(), resetPassword: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -46,6 +47,7 @@ vi.mock('../../services/authService', () => ({ loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAuthUser: authMocks.logoutAuthUser, + redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, revokeAuthSession: vi.fn(), sendPhoneLoginCode: authMocks.sendPhoneLoginCode, @@ -105,6 +107,25 @@ beforeEach(() => { authMocks.changePassword.mockResolvedValue(mockUser); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); authMocks.logoutAuthUser.mockResolvedValue(undefined); + authMocks.redeemRegistrationInviteCode.mockResolvedValue({ + center: { + inviteCode: 'SY12345678', + inviteLinkPath: '/?inviteCode=SY12345678', + invitedCount: 1, + rewardedInviteCount: 1, + todayInviterRewardCount: 0, + todayInviterRewardRemaining: 3, + rewardPoints: 30, + hasRedeemedCode: true, + boundInviterUserId: 'user_inviter', + boundAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + }, + inviteeRewardGranted: true, + inviterRewardGranted: true, + inviteeBalanceAfter: 30, + inviterBalanceAfter: 30, + }); authMocks.resetPassword.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, @@ -250,7 +271,9 @@ test('auth gate keeps password entry available when login options are empty', as test('auth gate falls back to password entry when login options request fails', async () => { const user = userEvent.setup(); - authMocks.getAuthLoginOptions.mockRejectedValue(new Error('读取登录方式失败')); + authMocks.getAuthLoginOptions.mockRejectedValue( + new Error('读取登录方式失败'), + ); render( @@ -293,7 +316,6 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( '13800000000', '123456', - undefined, ); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1); @@ -302,44 +324,98 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); -test('auth gate opens register tab and preloads invite code from url', async () => { +test('auth gate hides register entry and opens invite modal for new sms account', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?inviteCode=spring-2026'); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); + authMocks.loginWithPhoneCode.mockResolvedValueOnce({ + token: 'jwt-phone-new', + user: mockUser, + created: true, + referral: null, + }); render(
公开内容
+
, ); - const dialog = await screen.findByRole('dialog', { name: '账号入口' }); - await waitFor(() => { - expect( - within(dialog) - .getByRole('tab', { name: '注册' }) - .getAttribute('aria-selected'), - ).toBe('true'); - }); - expect( - (within(dialog).getByLabelText('邀请码') as HTMLInputElement).value, - ).toBe('SPRING2026'); + expect(await screen.findByText('公开内容')).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); + await user.click(screen.getByRole('button', { name: '进入作品' })); + const dialog = await screen.findByRole('dialog', { name: '账号入口' }); + expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull(); + expect(within(dialog).queryByLabelText('邀请码')).toBeNull(); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); - await user.click(within(dialog).getByRole('button', { name: '注册' })); + await user.click(within(dialog).getByRole('button', { name: '登录' })); await waitFor(() => { expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( '13800000000', '123456', + ); + }); + + const inviteDialog = await screen.findByRole('dialog', { + name: '请填写邀请码', + }); + expect( + (within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value, + ).toBe('SPRING2026'); + expect( + within(inviteDialog).getByRole('button', { name: '提交' }), + ).toBeTruthy(); + + await user.click(within(inviteDialog).getByRole('button', { name: '提交' })); + + await waitFor(() => { + expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith( 'SPRING2026', ); }); }); +test('registration invite modal can skip when invite code is empty', async () => { + const user = userEvent.setup(); + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone'], + }); + authMocks.loginWithPhoneCode.mockResolvedValueOnce({ + token: 'jwt-phone-new', + user: mockUser, + created: true, + referral: null, + }); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + const dialog = screen.getByRole('dialog', { name: '账号入口' }); + await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); + await user.type(within(dialog).getByLabelText('验证码'), '123456'); + await user.click(within(dialog).getByRole('button', { name: '登录' })); + + const inviteDialog = await screen.findByRole('dialog', { + name: '请填写邀请码', + }); + await user.click(within(inviteDialog).getByRole('button', { name: '跳过' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull(); + }); + expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled(); +}); + test('auth state refresh keeps mounted platform content and local tab state', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 1a60c3d2..df87a481 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -34,6 +34,7 @@ import { loginWithPhoneCode, logoutAllAuthSessions, logoutAuthUser, + redeemRegistrationInviteCode, resetPassword, revokeAuthSession, sendPhoneLoginCode, @@ -44,6 +45,7 @@ import { AccountModal } from './AccountModal'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; import { BindPhoneScreen } from './BindPhoneScreen'; import { LoginScreen } from './LoginScreen'; +import { RegistrationInviteModal } from './RegistrationInviteModal'; type AuthGateProps = { children: ReactNode; @@ -91,10 +93,12 @@ export function AuthGate({ children }: AuthGateProps) { const [bindingPhone, setBindingPhone] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); - const [loginInitialMode, setLoginInitialMode] = useState< - 'login' | 'register' - >('login'); const [pendingInviteCode, setPendingInviteCode] = useState(''); + const [showRegistrationInviteModal, setShowRegistrationInviteModal] = + useState(false); + const [submittingRegistrationInvite, setSubmittingRegistrationInvite] = + useState(false); + const [registrationInviteError, setRegistrationInviteError] = useState(''); const [showSettingsModal, setShowSettingsModal] = useState(false); const [settingsEntryMode, setSettingsEntryMode] = useState< 'settings' | 'account' @@ -141,6 +145,7 @@ export function AuthGate({ children }: AuthGateProps) { setUser(null); setStatus('unauthenticated'); setShowLoginModal(false); + setShowRegistrationInviteModal(false); setShowSettingsModal(false); setSettingsEntryMode('settings'); setInitialSettingsSection(null); @@ -150,6 +155,8 @@ export function AuthGate({ children }: AuthGateProps) { setLoginCaptchaChallenge(null); setBindCaptchaChallenge(null); setChangePhoneCaptchaChallenge(null); + setPendingInviteCode(''); + setRegistrationInviteError(''); setError(''); }, []); @@ -182,12 +189,16 @@ export function AuthGate({ children }: AuthGateProps) { const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; setShowLoginModal(false); - setLoginInitialMode('login'); - setPendingInviteCode(''); setLoginCaptchaChallenge(null); setError(''); }, []); + const closeRegistrationInviteModal = useCallback(() => { + setShowRegistrationInviteModal(false); + setRegistrationInviteError(''); + setPendingInviteCode(''); + }, []); + const closeSettingsModal = useCallback(() => { setShowSettingsModal(false); setSettingsEntryMode('settings'); @@ -202,8 +213,6 @@ export function AuthGate({ children }: AuthGateProps) { } pendingProtectedActionRef.current = postLoginAction ?? null; - setLoginInitialMode('login'); - setPendingInviteCode(''); setShowLoginModal(true); }, [readyUser], @@ -253,10 +262,7 @@ export function AuthGate({ children }: AuthGateProps) { return; } autoOpenedInviteCodeRef.current = inviteCode; - pendingProtectedActionRef.current = null; setPendingInviteCode(inviteCode); - setLoginInitialMode('register'); - setShowLoginModal(true); }, [readyUser, showLoginModal, status]); useEffect(() => { @@ -738,8 +744,6 @@ export function AuthGate({ children }: AuthGateProps) { wechatLoading={wechatLoading} error={error} captchaChallenge={loginCaptchaChallenge} - initialMode={loginInitialMode} - initialInviteCode={pendingInviteCode} onClose={closeLoginModal} onSendCode={async (phone, scene, captcha) => { setSendingCode(true); @@ -764,20 +768,15 @@ export function AuthGate({ children }: AuthGateProps) { setSendingCode(false); } }} - onPhoneSubmit={async (phone, code, inviteCode) => { + onPhoneSubmit={async (phone, code) => { setLoggingIn(true); setError(''); try { - const response = await loginWithPhoneCode( - phone, - code, - inviteCode, - ); + const response = await loginWithPhoneCode(phone, code); setStoredLastLoginPhone(phone); setLoginCaptchaChallenge(null); - if (response.referral && !response.referral.ok) { - setError(response.referral.message || '邀请码未绑定'); - } + setShowRegistrationInviteModal(response.created); + setRegistrationInviteError(''); activateReadyUser(response.user); } catch (loginError) { setError( @@ -839,6 +838,30 @@ export function AuthGate({ children }: AuthGateProps) { } }} /> + { + setSubmittingRegistrationInvite(true); + setRegistrationInviteError(''); + try { + await redeemRegistrationInviteCode(inviteCode); + closeRegistrationInviteModal(); + } catch (inviteError) { + setRegistrationInviteError( + inviteError instanceof Error + ? inviteError.message + : '填写邀请码失败,请稍后再试。', + ); + } finally { + setSubmittingRegistrationInvite(false); + } + }} + /> {children} diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 1629bdcc..4f859b72 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService'; import { CaptchaChallengeField } from './CaptchaChallengeField'; type SmsScene = 'login' | 'reset_password'; -type LoginTab = 'phone' | 'password' | 'register'; +type LoginTab = 'phone' | 'password'; type LoginScreenProps = { isOpen: boolean; @@ -21,8 +21,6 @@ type LoginScreenProps = { wechatLoading: boolean; error: string; captchaChallenge: AuthCaptchaChallenge | null; - initialMode?: 'login' | 'register'; - initialInviteCode?: string; onClose: () => void; onSendCode: ( phone: string, @@ -35,11 +33,7 @@ type LoginScreenProps = { cooldownSeconds: number; expiresInSeconds: number; }>; - onPhoneSubmit: ( - phone: string, - code: string, - inviteCode?: string, - ) => Promise; + onPhoneSubmit: (phone: string, code: string) => Promise; onPasswordSubmit: (phone: string, password: string) => Promise; onResetPassword: ( phone: string, @@ -58,8 +52,6 @@ export function LoginScreen({ wechatLoading, error, captchaChallenge, - initialMode = 'login', - initialInviteCode = '', onClose, onSendCode, onPhoneSubmit, @@ -74,7 +66,6 @@ export function LoginScreen({ const [resetPhone, setResetPhone] = useState(''); const [resetCode, setResetCode] = useState(''); const [resetPasswordValue, setResetPasswordValue] = useState(''); - const [inviteCode, setInviteCode] = useState(initialInviteCode); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [cooldownSeconds, setCooldownSeconds] = useState(0); const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0); @@ -97,23 +88,16 @@ export function LoginScreen({ setResetPhone(''); setResetCode(''); setResetPasswordValue(''); - setInviteCode(initialInviteCode); setCaptchaAnswer(''); setCooldownSeconds(0); setResetCooldownSeconds(0); setHint(''); - setActiveLoginTab( - initialMode === 'register' && phoneLoginEnabled - ? 'register' - : phoneLoginEnabled - ? 'phone' - : 'password', - ); - }, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]); + setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password'); + }, [isOpen, phoneLoginEnabled]); useEffect(() => { if ( - (activeLoginTab === 'phone' || activeLoginTab === 'register') && + activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled ) { @@ -215,7 +199,7 @@ export function LoginScreen({ {phoneLoginEnabled ? (
) : null} - setActiveLoginTab('register')} - > - 注册 -
) : null} @@ -338,42 +316,6 @@ export function LoginScreen({ /> ) : null} - {phoneLoginEnabled && activeLoginTab === 'register' ? ( - { - setHint(''); - const result = await onSendCode(phone, 'login', { - challengeId: captchaChallenge?.challengeId, - answer: captchaAnswer, - }); - setCooldownSeconds(result.cooldownSeconds); - setHint( - `短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, - ); - setCaptchaAnswer(''); - }} - onSubmit={() => onPhoneSubmit(phone, code, inviteCode)} - /> - ) : null} - {!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? ( @@ -420,7 +362,6 @@ function LoginTabButton({ function PhoneCodeForm({ phone, code, - inviteCode = '', captchaAnswer, captchaChallenge, cooldownSeconds, @@ -431,17 +372,14 @@ function PhoneCodeForm({ submitLabel, enabled, showPhoneField, - showInviteCodeField = false, onPhoneChange, onCodeChange, - onInviteCodeChange, onCaptchaAnswerChange, onSendCode, onSubmit, }: { phone: string; code: string; - inviteCode?: string; captchaAnswer: string; captchaChallenge: AuthCaptchaChallenge | null; cooldownSeconds: number; @@ -452,10 +390,8 @@ function PhoneCodeForm({ submitLabel: string; enabled: boolean; showPhoneField: boolean; - showInviteCodeField?: boolean; onPhoneChange: (value: string) => void; onCodeChange: (value: string) => void; - onInviteCodeChange?: (value: string) => void; onCaptchaAnswerChange: (value: string) => void; onSendCode: () => Promise; onSubmit: () => Promise; @@ -486,19 +422,6 @@ function PhoneCodeForm({ ) : null} - {showInviteCodeField ? ( - - ) : null} -