From b22f23bcc480fcf8b7ae7c2916346d4a92f8c237 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 3 Jul 2026 15:57:01 +0800 Subject: [PATCH] refactor(auth): simplify splash guest entry --- src/app/auth/auth-screen.tsx | 23 +++--- .../components/splash-button.module.css | 26 +----- src/app/splash/components/splash-button.tsx | 35 ++------ src/app/splash/splash-screen.tsx | 80 +++---------------- .../auth/__tests__/auth-machine.test.ts | 31 +++---- src/stores/auth/auth-actors.ts | 6 +- src/stores/auth/auth-context.tsx | 9 +-- src/stores/auth/auth-events.ts | 9 +-- src/stores/auth/auth-machine.ts | 48 +++++------ src/stores/auth/auth-state.ts | 10 --- 10 files changed, 72 insertions(+), 205 deletions(-) diff --git a/src/app/auth/auth-screen.tsx b/src/app/auth/auth-screen.tsx index 62031029..e83bb5f5 100644 --- a/src/app/auth/auth-screen.tsx +++ b/src/app/auth/auth-screen.tsx @@ -7,7 +7,8 @@ import { useEffect, useSyncExternalStore } from "react"; import { MobileShell } from "@/app/_components/core"; -import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; +import { useAuthState } from "@/stores/auth/auth-context"; +import { isAuthenticatedUser } from "@/router/navigation-resolver"; import { ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator"; @@ -19,7 +20,6 @@ const log = new Logger("AppAuthAuthScreen"); export function AuthScreen() { const state = useAuthState(); - const authDispatch = useAuthDispatch(); const navigator = useAppNavigator(); const redirectTo = useSyncExternalStore( subscribeLocationSnapshot, @@ -28,28 +28,23 @@ export function AuthScreen() { ); const safeRedirectTo = getSafeRedirectPath(redirectTo) ?? ROUTES.chat; - // 邮箱登录成功 → 跳转;User hydrate 由根级 监听 loginStatus 变化处理。 - // 用 pendingRedirect flag(不是 useRef transition detection)—— - // re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。 + // 真实用户登录成功 → 跳转;User hydrate 由根级 监听 loginStatus 变化处理。 + // 游客态不触发跳转,避免自动游客登录打断 auth 页面。 useEffect(() => { - log.debug("[auth-screen] useEffect (loginStatus + pendingRedirect)", { - pending: state.pendingRedirect, + const shouldRedirect = isAuthenticatedUser(state.loginStatus); + log.debug("[auth-screen] useEffect (loginStatus)", { loginStatus: state.loginStatus, - willRedirect: - state.pendingRedirect && state.loginStatus !== "notLoggedIn", + willRedirect: shouldRedirect, }); - if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") { - log.debug("[auth-screen] useEffect → navigator.replace [via pendingRedirect flag]", { + if (shouldRedirect) { + log.debug("[auth-screen] useEffect → navigator.replace", { redirectTo: safeRedirectTo, }); - authDispatch({ type: "AuthClearPendingRedirect" }); navigator.replace(safeRedirectTo); } }, [ state.loginStatus, - state.pendingRedirect, - authDispatch, navigator, safeRedirectTo, ]); diff --git a/src/app/splash/components/splash-button.module.css b/src/app/splash/components/splash-button.module.css index 19016031..d0842164 100644 --- a/src/app/splash/components/splash-button.module.css +++ b/src/app/splash/components/splash-button.module.css @@ -5,31 +5,13 @@ justify-content: center; gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px)); width: 100%; + padding: 0 clamp(var(--spacing-sm, 8px), 2.963vw, var(--spacing-lg, 16px)); z-index: 2; } -.skip { - font-size: var(--responsive-section-title, var(--font-size-xxl)); - font-weight: 700; - font-style: italic; - color: #ffffff; - background: transparent; - border: 0; - cursor: pointer; - padding: 0; -} - -.skip:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.separator { - width: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px)); - height: 1px; -} - .facebookButton { + width: 100%; + max-width: 480px; min-height: var(--responsive-control-height, 48px); padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px); border: 0; @@ -47,7 +29,7 @@ display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; + flex: 1 1 auto; box-shadow: 0 0 10px rgba(248, 89, 168, 0.3); } diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index 6f46414e..6af03253 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -2,55 +2,30 @@ /** * Splash 底部按钮组 */ -import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; +import { useAuthState } from "@/stores/auth/auth-context"; import { LoadingIndicator } from "@/app/_components/core/loading-indicator"; import styles from "./splash-button.module.css"; export interface SplashButtonProps { - onSkip: () => void; + onStartChat: () => void; } -export function SplashButton({ onSkip }: SplashButtonProps) { +export function SplashButton({ onStartChat }: SplashButtonProps) { const state = useAuthState(); - const authDispatch = useAuthDispatch(); const isLoading = state.isLoading; - const handleFacebookLogin = () => { - authDispatch({ - type: "AuthFacebookLoginSubmitted", - provider: "facebook", - }); - }; - return (
- {/* Skip 按钮 = 显式游客登录入口(派发事件 + 立即跳 /chat) */} - - diff --git a/src/app/splash/splash-screen.tsx b/src/app/splash/splash-screen.tsx index 25ac4bd7..35fe78e4 100644 --- a/src/app/splash/splash-screen.tsx +++ b/src/app/splash/splash-screen.tsx @@ -3,10 +3,9 @@ import { useEffect } from "react"; import { MobileShell } from "@/app/_components/core"; -import { hasBusinessLoginToken } from "@/lib/auth/auth_session"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { pwaUtil, Logger } from "@/utils"; +import { useAuthState } from "@/stores/auth/auth-context"; +import { pwaUtil } from "@/utils"; import { SplashBackground, @@ -16,7 +15,7 @@ import { } from "./components"; import styles from "./components/splash-screen.module.css"; -const log = new Logger("AppSplashSplashScreen"); +const AUTO_OPEN_CHAT_DELAY_MS = 1800; // 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。 if (typeof window !== "undefined") { @@ -26,10 +25,8 @@ if (typeof window !== "undefined") { export function SplashScreen() { const navigator = useAppNavigator(); const state = useAuthState(); - const authDispatch = useAuthDispatch(); - const handleSkip = () => { - authDispatch({ type: "AuthGuestLoginSubmitted" }); + const handleStartChat = () => { navigator.openChat({ replace: true }); }; @@ -37,50 +34,13 @@ export function SplashScreen() { pwaUtil.prepareInstallPrompt(); }, []); - // ───────────────────────────────────────────────────────────── - // useEffect ① 启动时检查 localStorage 里的 loginToken: - // - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat - // - 不查 guestToken —— 游客用户停留 splash(按你要求) - // - 都没有 → 留 splash - // proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。 - // ───────────────────────────────────────────────────────────── useEffect(() => { - let cancelled = false; - void (async () => { - if (!cancelled && (await hasBusinessLoginToken())) { - navigator.openChat({ replace: true }); - } - })(); - return () => { - cancelled = true; - }; - }, [navigator]); - - // ───────────────────────────────────────────────────────────── - // useEffect ② 按钮刚派了事件(pendingRedirect=true) + - // loginStatus 是非 notLoggedIn → 跳 /chat - // - // 关键:`pendingRedirect` 是 auth context 里的持久状态(不是 ref)—— - // re-mount 时不重置,区分"刚按了"和"历史登录"准确无误。 - // 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。 - // ───────────────────────────────────────────────────────────── - useEffect(() => { - // 调试日志(保留,不删 —— 用于排查"re-mount 误跳 /chat"问题) - log.debug("[splash] useEffect ② (loginStatus + pendingRedirect)", { - pending: state.pendingRedirect, - loginStatus: state.loginStatus, - willRedirect: - state.pendingRedirect && state.loginStatus !== "notLoggedIn", - }); - - if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") { - log.debug( - "[splash] useEffect ② → navigator.openChat [via pendingRedirect flag]", - ); - authDispatch({ type: "AuthClearPendingRedirect" }); + const timer = window.setTimeout(() => { navigator.openChat({ replace: true }); - } - }, [state.loginStatus, state.pendingRedirect, authDispatch, navigator]); + }, AUTO_OPEN_CHAT_DELAY_MS); + + return () => window.clearTimeout(timer); + }, [navigator]); return ( @@ -93,28 +53,8 @@ export function SplashScreen() {
- +
- {/* - * 登录错误提示 —— auth state machine 的 onError 会设 errorMessage - * (如 Facebook 同步后端失败 / 后端返回数据 schema 不匹配等)。 - * 之前没显示,导致用户卡在 splash 看不到原因。 - */} - {state.errorMessage ? ( -

- {state.errorMessage} -

- ) : null}

Elio Silvestri, Your exclusive AI boyfriend
diff --git a/src/stores/auth/__tests__/auth-machine.test.ts b/src/stores/auth/__tests__/auth-machine.test.ts index e8e496cc..473d3cc0 100644 --- a/src/stores/auth/__tests__/auth-machine.test.ts +++ b/src/stores/auth/__tests__/auth-machine.test.ts @@ -9,11 +9,13 @@ function createTestAuthMachine( overrides: Partial<{ checkAuthStatus: LoginStatus; guestLogin: LoginStatus; + guestLoginSpy: () => void; emailLogin: LoginStatus; logoutSpy: () => void; }> = {}, ) { const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); + const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>(); return authMachine.provide({ actors: { @@ -21,7 +23,10 @@ function createTestAuthMachine( async () => overrides.checkAuthStatus ?? "notLoggedIn", ), guestLogin: fromPromise( - async () => overrides.guestLogin ?? "guest", + async () => { + guestLoginSpy(); + return overrides.guestLogin ?? "guest"; + }, ), emailLogin: fromPromise< LoginStatus, @@ -72,9 +77,10 @@ describe("authMachine", () => { actor.stop(); }); - it("initializes from storage without triggering a pending redirect", async () => { + it("initializes from storage without triggering guest login", async () => { + const guestLoginSpy = vi.fn<() => void>(); const actor = createActor( - createTestAuthMachine({ checkAuthStatus: "facebook" }), + createTestAuthMachine({ checkAuthStatus: "facebook", guestLoginSpy }), ).start(); actor.send({ type: "AuthInit" }); @@ -83,26 +89,27 @@ describe("authMachine", () => { const context = actor.getSnapshot().context; expect(context.loginStatus).toBe("facebook"); expect(context.hasInitialized).toBe(true); - expect(context.pendingRedirect).toBe(false); + expect(guestLoginSpy).not.toHaveBeenCalled(); actor.stop(); }); - it("guest login marks initialized but does not set pending redirect", async () => { - const actor = createActor(createTestAuthMachine()).start(); + it("automatically guest logs in when initialization finds no login state", async () => { + const guestLoginSpy = vi.fn<() => void>(); + const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); - actor.send({ type: "AuthGuestLoginSubmitted" }); + actor.send({ type: "AuthInit" }); await waitFor(actor, (snapshot) => snapshot.matches("idle")); const context = actor.getSnapshot().context; expect(context.loginStatus).toBe("guest"); expect(context.hasInitialized).toBe(true); - expect(context.pendingRedirect).toBe(false); + expect(guestLoginSpy).toHaveBeenCalledOnce(); actor.stop(); }); - it("email login sets pending redirect until it is cleared", async () => { + it("email login updates login status", async () => { const actor = createActor(createTestAuthMachine()).start(); actor.send({ @@ -113,10 +120,7 @@ describe("authMachine", () => { await waitFor(actor, (snapshot) => snapshot.matches("idle")); expect(actor.getSnapshot().context.loginStatus).toBe("email"); - expect(actor.getSnapshot().context.pendingRedirect).toBe(true); - - actor.send({ type: "AuthClearPendingRedirect" }); - expect(actor.getSnapshot().context.pendingRedirect).toBe(false); + expect(actor.getSnapshot().context.hasInitialized).toBe(true); actor.stop(); }); @@ -138,7 +142,6 @@ describe("authMachine", () => { const context = actor.getSnapshot().context; expect(logoutSpy).toHaveBeenCalledOnce(); expect(context.loginStatus).toBe("notLoggedIn"); - expect(context.pendingRedirect).toBe(false); expect(context.hasInitialized).toBe(true); actor.stop(); diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index ee2e209d..8d9fc7b4 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -193,13 +193,13 @@ async function persistFacebookProfile(accessToken: string): Promise { } } -// ========== 显式游客登录 actor(opt-in) ========== -// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。 +// ========== 自动游客登录 actor ========== +// AuthInit 检查不到任何本地登录态时触发,用于自动补齐游客会话。 export const guestLoginActor = fromPromise(async () => { log.debug("[auth-machine] guestLoginActor ENTRY"); - // 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect) + // 1. 先查本地 guestToken —— 已有就直接进入游客态 const hasTokenR = await AuthStorage.getInstance().hasGuestToken(); log.debug("[auth-machine] guestLoginActor hasGuestToken", { success: hasTokenR.success, diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx index 88b85f5f..ac70c41f 100644 --- a/src/stores/auth/auth-context.tsx +++ b/src/stores/auth/auth-context.tsx @@ -36,12 +36,6 @@ interface AuthState { loginStatus: MachineContext["loginStatus"]; /** 启动时的本地登录态恢复是否已经完成 */ hasInitialized: MachineContext["hasInitialized"]; - /** - * 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派完业务事件后置 true, - * splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` - * 触发跳转 + 调 `AuthClearPendingRedirect` 置 false。 - */ - pendingRedirect: boolean; } const AuthStateCtx = createContext(null); @@ -64,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) { username: state.context.username, confirmPassword: state.context.confirmPassword, // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ - // OAuth 回调后端 sync / 显式游客登录 + // OAuth 回调后端 sync / 自动游客登录 isLoading: state.matches("loadingEmailLogin") || state.matches("loadingEmailRegister") || @@ -77,7 +71,6 @@ export function AuthProvider({ children }: AuthProviderProps) { errorMessage: state.context.errorMessage, loginStatus: state.context.loginStatus, hasInitialized: state.context.hasInitialized, - pendingRedirect: state.context.pendingRedirect, }), [state], ); diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index 4eabf42f..d77a1b7a 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -14,8 +14,6 @@ export type AuthEvent = | { type: "AuthLogoutSubmitted" } /** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */ | { type: "AuthInit" } - /** 显式游客登录 —— splash 上点"游客模式"按钮触发(不自动) */ - | { type: "AuthGuestLoginSubmitted" } // 业务事件(提交) | { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { @@ -32,9 +30,4 @@ export type AuthEvent = // OAuth 回调后:把 OAuth provider token 转交后端换业务 token // 由 监听 useSession() 派发 | { type: "AuthGoogleSyncSubmitted"; idToken: string } - | { type: "AuthFacebookSyncSubmitted"; accessToken: string } - // ────────────────────────────────────────────────────────────────────── - // splash / auth screen 跳完 /chat 后清"刚按了"信号(一次性): - // `pendingRedirect` 在 login onDone action 里自动置 true(不是按钮派的) - // —— 因此只需要 `AuthClearPendingRedirect` 一个事件 - | { type: "AuthClearPendingRedirect" }; + | { type: "AuthFacebookSyncSubmitted"; accessToken: string }; diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 8ba1eb7f..8af4f397 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -32,11 +32,7 @@ const toAuthErrorMessage = (error: unknown): string => // // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // -// `pendingRedirect: boolean`("用户显式触发了登录"信号)—— -// 在登录成功的 onDone 里自动置 true: -// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat -// 跳完派 `AuthClearPendingRedirect` 置 false -// `initializing` 不设 flag —— 启动 init 是被动读 token,不算"用户意图"。 +// 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话。 // ============================================================ export const authMachine = setup({ types: { @@ -86,7 +82,6 @@ export const authMachine = setup({ // 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 派发 AuthInit: "initializing", - AuthGuestLoginSubmitted: "loadingGuestLogin", AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthGoogleLoginSubmitted: "loadingOAuth", @@ -100,10 +95,6 @@ export const authMachine = setup({ // OAuth 回调后 sync 入口 —— 由 派发 AuthGoogleSyncSubmitted: "syncingGoogleBackend", AuthFacebookSyncSubmitted: "syncingFacebookBackend", - // splash / auth screen 跳完 /chat 后清flag - AuthClearPendingRedirect: { - actions: assign({ pendingRedirect: false }), - }, }, }, @@ -111,15 +102,26 @@ export const authMachine = setup({ entry: assign({ errorMessage: null }), invoke: { src: "checkAuthStatus", - onDone: { - target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) - actions: assign({ - loginStatus: ({ event }) => event.output, - hasInitialized: true, - // 不置 pendingRedirect —— 状态查询是被动读 token,不算"用户意图" - errorMessage: null, - }), - }, + onDone: [ + { + guard: ({ event }) => event.output === "notLoggedIn", + target: "loadingGuestLogin", + actions: assign({ + loginStatus: "notLoggedIn", + // 自动游客登录仍属于初始化流程,完成前不标记 initialized。 + hasInitialized: false, + errorMessage: null, + }), + }, + { + target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) + actions: assign({ + loginStatus: ({ event }) => event.output, + hasInitialized: true, + errorMessage: null, + }), + }, + ], onError: { target: "idle", actions: assign({ @@ -139,8 +141,6 @@ export const authMachine = setup({ // 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型 actions: assign({ loginStatus: ({ event }) => event.output, - // Skip 点击后已在 splash 里立即跳 /chat;guest login 不再依赖 pendingRedirect。 - pendingRedirect: false, errorMessage: null, hasInitialized: true, }), @@ -168,7 +168,6 @@ export const authMachine = setup({ target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, - pendingRedirect: true, // ← 邮箱登录成功 → 跳 errorMessage: null, hasInitialized: true, }), @@ -201,7 +200,6 @@ export const authMachine = setup({ target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, - pendingRedirect: true, // ← 注册成功 → 跳 errorMessage: null, hasInitialized: true, }), @@ -238,7 +236,7 @@ export const authMachine = setup({ }, loggingOut: { - entry: assign({ errorMessage: null, pendingRedirect: false }), + entry: assign({ errorMessage: null }), invoke: { src: "logout", onDone: { @@ -271,7 +269,6 @@ export const authMachine = setup({ target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, - pendingRedirect: true, // ← OAuth sync 成功 → 跳 errorMessage: null, hasInitialized: true, }), @@ -298,7 +295,6 @@ export const authMachine = setup({ target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, - pendingRedirect: true, // ← OAuth sync 成功 → 跳 errorMessage: null, hasInitialized: true, }), diff --git a/src/stores/auth/auth-state.ts b/src/stores/auth/auth-state.ts index 6a9bda0c..d71dd3a1 100644 --- a/src/stores/auth/auth-state.ts +++ b/src/stores/auth/auth-state.ts @@ -17,15 +17,6 @@ export interface AuthState { loginStatus: LoginStatus; /** 启动时的本地登录态恢复是否已经完成 */ hasInitialized: boolean; - /** - * 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮刚派事件 → 置 true - * → splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 触发跳转 - * → 跳完置 false - * - * 历史:之前用 useRef 做 transition detection,但 re-mount 时 ref 重置,bug 复发。 - * 现在 auth state 里持久(machine 不重置)—— re-mount 时也能区分"刚按"和"历史"。 - */ - pendingRedirect: boolean; } export const initialState: AuthState = { @@ -38,5 +29,4 @@ export const initialState: AuthState = { errorMessage: null, loginStatus: "notLoggedIn", hasInitialized: false, - pendingRedirect: false, };