From 1151a9a61b2af73be4c1862d97f71df53316060c Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 11 Jun 2026 17:00:04 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9Asplash=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=E7=95=8C=E9=9D=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/components/auth-screen.tsx | 55 +++++++++++------ src/app/splash/components/splash-button.tsx | 47 +++------------ src/app/splash/components/splash-screen.tsx | 67 +++++++++++++++------ src/stores/auth/auth-context.tsx | 21 +++---- src/stores/auth/auth-events.ts | 7 ++- src/stores/auth/auth-machine.ts | 31 ++++++++-- src/stores/auth/auth-state.ts | 10 +++ 7 files changed, 144 insertions(+), 94 deletions(-) diff --git a/src/app/auth/components/auth-screen.tsx b/src/app/auth/components/auth-screen.tsx index 3a488a35..edcb0b8b 100644 --- a/src/app/auth/components/auth-screen.tsx +++ b/src/app/auth/components/auth-screen.tsx @@ -1,48 +1,63 @@ "use client"; /** - * 认证全屏页面 + * Auth 屏 * - * 实现方式与 SplashScreen 同款(MobileShell 顶层 + 内部 .wrapper + 背景组件): - * - 作为 top-level(500px 移动端宽度由 MobileShell 自身约束) - * - 提供全屏 bg image - * - .content 内部承载 AuthPanel(z-index: 2,垂直居中) - * - 已登录用户:自动跳 /chat - * - 登录成功:通知 ChatBloc + UserBloc + * 原始 Dart: lib/ui/auth/auth_screen.dart */ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useAuthState } from "@/stores/auth/auth-context"; +import { MobileShell } from "@/app/_components/core/mobile-shell"; + +import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useChatDispatch } from "@/stores/chat/chat-context"; import { useUserDispatch } from "@/stores/user/user-context"; import { ROUTES } from "@/router/routes"; -import { MobileShell } from "@/app/_components/core/mobile-shell"; -import { AuthPanel } from "@/app/auth/components/auth-panel"; -import { AuthBackground } from "@/app/auth/components/auth-background"; - import styles from "./auth-screen.module.css"; +import { AuthBackground } from "./auth-background"; +import { AuthPanel } from "./auth-panel"; + export function AuthScreen() { // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== // 本组件只负责: - // 1. 渲染登录面板 - // 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作) + // 1. 渲染登录 UI + // 2. 邮箱登录成功 → 初始化 chat/user store + navigate 到 /chat(业务层动作) - const router = useRouter(); const state = useAuthState(); + const authDispatch = useAuthDispatch(); const chatDispatch = useChatDispatch(); const userDispatch = useUserDispatch(); - const wasSuccess = useRef(false); + const router = useRouter(); // 邮箱登录成功 → 通知 Chat + User + 跳转 + // 用 **pendingRedirect flag**(不是 useRef transition detection)—— + // re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。 useEffect(() => { - if (state.isSuccess && !wasSuccess.current) { - wasSuccess.current = true; + console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", { + pending: state.pendingRedirect, + loginStatus: state.loginStatus, + willRedirect: + state.pendingRedirect && state.loginStatus !== "notLoggedIn", + }); + + if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") { + console.log( + "[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]", + ); + authDispatch({ type: "AuthClearPendingRedirect" }); chatDispatch({ type: "ChatAuthStatusChanged" }); userDispatch({ type: "UserInit" }); router.replace(ROUTES.chat); } - }, [state.isSuccess, chatDispatch, userDispatch, router]); + }, [ + state.loginStatus, + state.pendingRedirect, + authDispatch, + chatDispatch, + userDispatch, + router, + ]); return ( diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index 6a72aaef..456a7c48 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -5,56 +5,27 @@ * 原始 Dart: lib/ui/splash/widgets/splash_button.dart * `Row(children: [Skip 文本, SizedBox 26, 社交登录按钮])` * - * 本组件是 splash 鉴权流程的**自治单元**: - * - 消费 `useSession()`(next-auth/react)监听 NextAuth session - * - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录 + OAuth 流程统一收口到状态机) - * - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化) - * - 自管路由跳转(已登录 / 登录成功 → /chat) - * - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`,不是路由跳转) + * 本组件是 splash 鉴权流程的**纯 UI 入口**: + * - 消费 `useAuthState` / `useAuthDispatch`(仅用于 isLoading 显示 + 派事件) + * - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`) * - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth * - * 跳转时序: - * 1. 派发事件(如 `AuthGuestLoginSubmitted`) - * 2. auth machine 进 loading state → API 调用 → onDone → loginStatus 更新 - * 3. useEffect 监听到 isSuccess (= loginStatus !== "notLoggedIn") → 调 chat/user store 初始化 + router.replace("/chat") + * **不**做路由跳转 —— 路由逻辑**全部**移到 splash-screen.tsx 统一管理。 + * + * **派事件策略**(**单**事件): + * - 派业务事件(`AuthGuestLoginSubmitted` / `AuthFacebookLoginSubmitted`)→ 状态机开始跑 + * - `pendingRedirect: true` 在**登录成功 onDone** 里**自动**置(auth-machine.ts `onLoginSuccess` helper) + * - splash-screen 看到 flag=true + loginStatus 非 notLoggedIn → 跳 /chat */ -import { useRouter } from "next/navigation"; -import { useEffect, useRef } from "react"; - import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { useChatDispatch } from "@/stores/chat/chat-context"; -import { useUserDispatch } from "@/stores/user/user-context"; -import { ROUTES } from "@/router/routes"; import { LoadingIndicator } from "@/app/_components/core/loading-indicator"; import styles from "./splash-button.module.css"; export function SplashButton() { - // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== - // 本组件只负责: - // 1. 触发登录动作 - // 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作, - // 不是鉴权判断;proxy 会让通过因为 cookie 已设) - const state = useAuthState(); const authDispatch = useAuthDispatch(); const isLoading = state.isLoading; - // ===== 跨 store 初始化(登录成功后触发) ===== - const chatDispatch = useChatDispatch(); - const userDispatch = useUserDispatch(); - const router = useRouter(); - const wasSuccess = useRef(false); - - // 邮箱/OAuth/游客登录成功跳 /chat + 通知 chat/user 初始化 - useEffect(() => { - if (state.isSuccess && !wasSuccess.current) { - wasSuccess.current = true; - chatDispatch({ type: "ChatAuthStatusChanged" }); - userDispatch({ type: "UserInit" }); - router.replace(ROUTES.chat); - } - }, [state.isSuccess, chatDispatch, userDispatch, router]); - // Skip = 显式游客登录入口("以游客身份继续") const handleSkip = () => { authDispatch({ type: "AuthGuestLoginSubmitted" }); diff --git a/src/app/splash/components/splash-screen.tsx b/src/app/splash/components/splash-screen.tsx index 56bffa8d..57500549 100644 --- a/src/app/splash/components/splash-screen.tsx +++ b/src/app/splash/components/splash-screen.tsx @@ -4,6 +4,9 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { MobileShell } from "@/app/_components/core/mobile-shell"; +import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; +import { useChatDispatch } from "@/stores/chat/chat-context"; +import { useUserDispatch } from "@/stores/user/user-context"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { ROUTES } from "@/router/routes"; @@ -15,29 +18,24 @@ import styles from "./splash-screen.module.css"; export function SplashScreen() { const router = useRouter(); + const state = useAuthState(); + const authDispatch = useAuthDispatch(); + const chatDispatch = useChatDispatch(); + const userDispatch = useUserDispatch(); - // 启动时检查 localStorage 里的 token: + // ───────────────────────────────────────────────────────────── + // useEffect ① 启动时检查 localStorage 里的 loginToken: // - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat - // - guestToken(用户**显式**走"游客模式"留下的)→ 同样视为"已登录"跳 /chat - // - 都没有 → 留在 splash(**不**自动创建游客账号 —— 见 auth-machine.ts 的 checkAuthStatusActor) - // + // - **不**查 guestToken —— 游客用户**停留 splash**(按你要求) + // - 都没有 → 留 splash // proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。 + // ───────────────────────────────────────────────────────────── useEffect(() => { let cancelled = false; void (async () => { - const storage = AuthStorage.getInstance(); - // 用 getXxxToken() 拿 string | null(不是 hasXxxToken() 拿 boolean) - const [loginR, guestR] = await Promise.all([ - storage.getLoginToken(), - storage.getGuestToken(), - ]); - const loginTok = loginR.success ? loginR.data : null; - const guestTok = guestR.success ? guestR.data : null; - const hasAny = - (loginTok != null && loginTok.length > 0) || - (guestTok != null && guestTok.length > 0); - - if (!cancelled && hasAny) { + // 用 getLoginToken() 拿 string | null(不是 hasLoginToken() 拿 boolean) + const r = await AuthStorage.getInstance().getLoginToken(); + if (!cancelled && r.success && r.data) { router.replace(ROUTES.chat); } })(); @@ -46,6 +44,41 @@ export function SplashScreen() { }; }, [router]); + // ───────────────────────────────────────────────────────────── + // useEffect ② 按钮**刚**派了事件(pendingRedirect=true) + + // loginStatus 是非 notLoggedIn → 跳 /chat + // + // **关键**:`pendingRedirect` 是 auth context 里的**持久**状态(不是 ref)—— + // re-mount 时**不**重置,区分"刚按了"和"历史登录"准确无误。 + // 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。 + // ───────────────────────────────────────────────────────────── + useEffect(() => { + // 调试日志(保留,不删 —— 用于排查"re-mount 误跳 /chat"问题) + console.log("[splash] useEffect ② (loginStatus + pendingRedirect)", { + pending: state.pendingRedirect, + loginStatus: state.loginStatus, + willRedirect: + state.pendingRedirect && state.loginStatus !== "notLoggedIn", + }); + + if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") { + console.log( + "[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]", + ); + authDispatch({ type: "AuthClearPendingRedirect" }); + chatDispatch({ type: "ChatAuthStatusChanged" }); + userDispatch({ type: "UserInit" }); + router.replace(ROUTES.chat); + } + }, [ + state.loginStatus, + state.pendingRedirect, + authDispatch, + chatDispatch, + userDispatch, + router, + ]); + return (
diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx index d68311cb..a255ea67 100644 --- a/src/stores/auth/auth-context.tsx +++ b/src/stores/auth/auth-context.tsx @@ -21,7 +21,7 @@ import { authMachine } from "./auth-machine"; import type { AuthEvent, AuthState as MachineContext } from "./auth-machine"; /** - * 对外暴露的 State 形状(保持与原 AuthState 兼容) + * 对外暴露的 State 形状 */ interface AuthState { authPanelMode: MachineContext["authPanelMode"]; @@ -32,16 +32,14 @@ interface AuthState { confirmPassword: string; isLoading: boolean; errorMessage: string | null; - /** - * 字段名 `isSuccess` 保留(接口兼容),语义改为: - * true = 用户**已登录**(loginStatus ∈ {email, google, facebook, apple, guest}) - * false = 未登录(loginStatus === "notLoggedIn") - * - * 之前用 `state.matches("success")` 当信号 —— 但 status check 流也会误入 - * success,触发 splash-button 误跳 /chat。现改用 `loginStatus` 判定。 - */ - isSuccess: boolean; + /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */ loginStatus: MachineContext["loginStatus"]; + /** + * 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派完业务事件后置 true, + * splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` + * 触发跳转 + 调 `AuthClearPendingRedirect` 置 false。 + */ + pendingRedirect: boolean; } const AuthStateCtx = createContext(null); @@ -70,9 +68,8 @@ export function AuthProvider({ children }: AuthProviderProps) { state.matches("loadingOAuth") || state.matches("loadingGuestLogin"), errorMessage: state.context.errorMessage, - // 改用 loginStatus 判断"已登录"(避开 success state 误触发) - isSuccess: state.context.loginStatus !== "notLoggedIn", loginStatus: state.context.loginStatus, + pendingRedirect: state.context.pendingRedirect, }), [state], ); diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index 9e17c75b..7e49d84b 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -33,4 +33,9 @@ export type AuthEvent = // OAuth 回调后:把 OAuth provider token 转交后端换业务 token // 由 监听 useSession() 派发 | { type: "AuthGoogleSyncSubmitted"; idToken: string } - | { type: "AuthFacebookSyncSubmitted"; accessToken: string }; + | { type: "AuthFacebookSyncSubmitted"; accessToken: string } + // ────────────────────────────────────────────────────────────────────── + // splash / auth screen 跳完 /chat 后**清**"刚按了"信号(一次性): + // `pendingRedirect` 在 login onDone action 里**自动**置 true(不是按钮派的) + // —— 因此**只**需要 `AuthClearPendingRedirect` 一个事件 + | { type: "AuthClearPendingRedirect" }; diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 2908a205..16ed3c65 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -154,7 +154,15 @@ const checkAuthStatusActor = fromPromise(async () => { // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // **不**再设 `success` state —— 之前曾用 "success" 当"刚成功"信号, // 但 status check 流也会误入 success,触发 splash-button 误跳 /chat。 -// 现在改用 `loginStatus !== "notLoggedIn"` 作为"已登录"信号(见 auth-context.tsx)。 +// +// `pendingRedirect: boolean`("用户**显式**触发了登录"信号)—— +// **在登录成功的 onDone 里自动置 true**: +// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat +// 跳完派 `AuthClearPendingRedirect` 置 false +// checkingAuthStatus **不**设 flag —— 被动查 token 不算"用户意图"。 +// +// 注:onDone 用了**内联**assign(不是 helper function)—— XState v5 的 TypeScript +// 推断对 helper return type 不友好;内联能保留 `state.matches("loadingXxx")` 的类型。 // ============================================================ export const authMachine = setup({ types: { @@ -217,6 +225,10 @@ export const authMachine = setup({ errorMessage: "Apple login not implemented", }), }, + // splash / auth screen 跳完 /chat 后**清**flag + AuthClearPendingRedirect: { + actions: assign({ pendingRedirect: false }), + }, }, }, @@ -228,6 +240,7 @@ export const authMachine = setup({ target: "idle", // ← status check 完回 idle(**不**再假冒 success) actions: assign({ loginStatus: ({ event }) => event.output, + // **不**置 pendingRedirect —— 状态查询是**被动**读 token,不算"用户意图" errorMessage: null, }), }, @@ -246,9 +259,11 @@ export const authMachine = setup({ invoke: { src: "guestLogin", onDone: { - target: "idle", // ← 改自 "success" + target: "idle", + // 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型 actions: assign({ loginStatus: ({ event }) => event.output, + pendingRedirect: true, // ← 显式登录成功 → splash / auth 看到跳 /chat errorMessage: null, }), }, @@ -271,9 +286,10 @@ export const authMachine = setup({ return { email: event.email, password: event.password }; }, onDone: { - target: "idle", // ← 改自 "success" + target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, + pendingRedirect: true, // ← 邮箱登录成功 → 跳 errorMessage: null, }), }, @@ -301,9 +317,10 @@ export const authMachine = setup({ }; }, onDone: { - target: "idle", // ← 改自 "success" + target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, + pendingRedirect: true, // ← 注册成功 → 跳 errorMessage: null, }), }, @@ -348,9 +365,10 @@ export const authMachine = setup({ return { idToken: event.idToken }; }, onDone: { - target: "idle", // ← 改自 "success" + target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, + pendingRedirect: true, // ← OAuth sync 成功 → 跳 errorMessage: null, }), }, @@ -373,9 +391,10 @@ export const authMachine = setup({ return { accessToken: event.accessToken }; }, onDone: { - target: "idle", // ← 改自 "success" + target: "idle", actions: assign({ loginStatus: ({ event }) => event.output, + pendingRedirect: true, // ← OAuth sync 成功 → 跳 errorMessage: null, }), }, diff --git a/src/stores/auth/auth-state.ts b/src/stores/auth/auth-state.ts index 5b3fa770..6ff27957 100644 --- a/src/stores/auth/auth-state.ts +++ b/src/stores/auth/auth-state.ts @@ -17,6 +17,15 @@ export interface AuthState { errorMessage: string | null; /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */ loginStatus: LoginStatus; + /** + * **一次性的"刚按了"信号** —— 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 = { @@ -28,4 +37,5 @@ export const initialState: AuthState = { confirmPassword: "", errorMessage: null, loginStatus: "notLoggedIn", + pendingRedirect: false, };