fix:splash 界面重定向界面问题

This commit is contained in:
2026-06-11 17:00:04 +08:00
parent e3d549d660
commit 1151a9a61b
7 changed files with 144 additions and 94 deletions
+35 -20
View File
@@ -1,48 +1,63 @@
"use client"; "use client";
/** /**
* 认证全屏页面 * Auth 屏
* *
* 实现方式与 SplashScreen 同款(MobileShell 顶层 + 内部 .wrapper + 背景组件): * 原始 Dart: lib/ui/auth/auth_screen.dart
* - <MobileShell> 作为 top-level500px 移动端宽度由 MobileShell 自身约束)
* - <AuthBackground> 提供全屏 bg image
* - .content 内部承载 AuthPanelz-index: 2,垂直居中)
* - 已登录用户:自动跳 /chat
* - 登录成功:通知 ChatBloc + UserBloc
*/ */
import { useEffect, useRef } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; 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 { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes"; 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 styles from "./auth-screen.module.css";
import { AuthBackground } from "./auth-background";
import { AuthPanel } from "./auth-panel";
export function AuthScreen() { export function AuthScreen() {
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
// 本组件只负责: // 本组件只负责:
// 1. 渲染登录面板 // 1. 渲染登录 UI
// 2. 登录成功初始化 chat/user store + navigate 到 /chat(业务层动作) // 2. 邮箱登录成功初始化 chat/user store + navigate 到 /chat(业务层动作)
const router = useRouter();
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const wasSuccess = useRef(false); const router = useRouter();
// 邮箱登录成功 → 通知 Chat + User + 跳转 // 邮箱登录成功 → 通知 Chat + User + 跳转
// 用 **pendingRedirect flag**(不是 useRef transition detection)——
// re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。
useEffect(() => { useEffect(() => {
if (state.isSuccess && !wasSuccess.current) { console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", {
wasSuccess.current = true; 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" }); chatDispatch({ type: "ChatAuthStatusChanged" });
userDispatch({ type: "UserInit" }); userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat); router.replace(ROUTES.chat);
} }
}, [state.isSuccess, chatDispatch, userDispatch, router]); }, [
state.loginStatus,
state.pendingRedirect,
authDispatch,
chatDispatch,
userDispatch,
router,
]);
return ( return (
<MobileShell> <MobileShell>
+9 -38
View File
@@ -5,56 +5,27 @@
* 原始 Dart: lib/ui/splash/widgets/splash_button.dart * 原始 Dart: lib/ui/splash/widgets/splash_button.dart
* `Row(children: [Skip 文本, SizedBox 26, 社交登录按钮])` * `Row(children: [Skip 文本, SizedBox 26, 社交登录按钮])`
* *
* 本组件是 splash 鉴权流程的**自治单元** * 本组件是 splash 鉴权流程的**纯 UI 入口**
* - 消费 `useSession()`next-auth/react)监听 NextAuth session * - 消费 `useAuthState` / `useAuthDispatch`(仅用于 isLoading 显示 + 派事件)
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录 + OAuth 流程统一收口到状态机 * - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
* - 自管路由跳转(已登录 / 登录成功 → /chat)
* - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`,不是路由跳转)
* - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth * - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth
* *
* 跳转时序: * **不**做路由跳转 —— 路由逻辑**全部**移到 splash-screen.tsx 统一管理。
* 1. 派发事件(如 `AuthGuestLoginSubmitted` *
* 2. auth machine 进 loading state → API 调用 → onDone → loginStatus 更新 * **派事件策略****单**事件):
* 3. useEffect 监听到 isSuccess (= loginStatus !== "notLoggedIn") → 调 chat/user store 初始化 + router.replace("/chat") * - 派业务事件(`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 { 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 { LoadingIndicator } from "@/app/_components/core/loading-indicator";
import styles from "./splash-button.module.css"; import styles from "./splash-button.module.css";
export function SplashButton() { export function SplashButton() {
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
// 本组件只负责:
// 1. 触发登录动作
// 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作,
// 不是鉴权判断;proxy 会让通过因为 cookie 已设)
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const isLoading = state.isLoading; 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 = 显式游客登录入口("以游客身份继续") // Skip = 显式游客登录入口("以游客身份继续")
const handleSkip = () => { const handleSkip = () => {
authDispatch({ type: "AuthGuestLoginSubmitted" }); authDispatch({ type: "AuthGuestLoginSubmitted" });
+50 -17
View File
@@ -4,6 +4,9 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core/mobile-shell"; 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 { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
@@ -15,29 +18,24 @@ import styles from "./splash-screen.module.css";
export function SplashScreen() { export function SplashScreen() {
const router = useRouter(); const router = useRouter();
const state = useAuthState();
const authDispatch = useAuthDispatch();
const chatDispatch = useChatDispatch();
const userDispatch = useUserDispatch();
// 启动时检查 localStorage 里的 token // ─────────────────────────────────────────────────────────────
// useEffect ① 启动时检查 localStorage 里的 loginToken
// - loginTokenOAuth/email sync 写下的)→ 视为"已登录"跳 /chat // - loginTokenOAuth/email sync 写下的)→ 视为"已登录"跳 /chat
// - guestToken(用户**显式**走"游客模式"留下的)→ 同样视为"已登录"跳 /chat // - **不**查 guestToken —— 游客用户**停留 splash**(按你要求)
// - 都没有 → 留 splash(**不**自动创建游客账号 —— 见 auth-machine.ts 的 checkAuthStatusActor // - 都没有 → 留 splash
//
// proxy 读不到 localStorageEdge runtime),所以这条 fallback 必须在 Client 侧。 // proxy 读不到 localStorageEdge runtime),所以这条 fallback 必须在 Client 侧。
// ─────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
const storage = AuthStorage.getInstance(); // 用 getLoginToken() 拿 string | null(不是 hasLoginToken() 拿 boolean
// 用 getXxxToken() 拿 string | null(不是 hasXxxToken() 拿 boolean const r = await AuthStorage.getInstance().getLoginToken();
const [loginR, guestR] = await Promise.all([ if (!cancelled && r.success && r.data) {
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) {
router.replace(ROUTES.chat); router.replace(ROUTES.chat);
} }
})(); })();
@@ -46,6 +44,41 @@ export function SplashScreen() {
}; };
}, [router]); }, [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 ( return (
<MobileShell background="var(--color-sidebar-background)"> <MobileShell background="var(--color-sidebar-background)">
<div className={styles.wrapper}> <div className={styles.wrapper}>
+9 -12
View File
@@ -21,7 +21,7 @@ import { authMachine } from "./auth-machine";
import type { AuthEvent, AuthState as MachineContext } from "./auth-machine"; import type { AuthEvent, AuthState as MachineContext } from "./auth-machine";
/** /**
* 对外暴露的 State 形状(保持与原 AuthState 兼容) * 对外暴露的 State 形状
*/ */
interface AuthState { interface AuthState {
authPanelMode: MachineContext["authPanelMode"]; authPanelMode: MachineContext["authPanelMode"];
@@ -32,16 +32,14 @@ interface AuthState {
confirmPassword: string; confirmPassword: string;
isLoading: boolean; isLoading: boolean;
errorMessage: string | null; errorMessage: string | null;
/** /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */
* 字段名 `isSuccess` 保留(接口兼容),语义改为:
* true = 用户**已登录**loginStatus ∈ {email, google, facebook, apple, guest}
* false = 未登录(loginStatus === "notLoggedIn"
*
* 之前用 `state.matches("success")` 当信号 —— 但 status check 流也会误入
* success,触发 splash-button 误跳 /chat。现改用 `loginStatus` 判定。
*/
isSuccess: boolean;
loginStatus: MachineContext["loginStatus"]; loginStatus: MachineContext["loginStatus"];
/**
* 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派完业务事件后置 true,
* splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"`
* 触发跳转 + 调 `AuthClearPendingRedirect` 置 false。
*/
pendingRedirect: boolean;
} }
const AuthStateCtx = createContext<AuthState | null>(null); const AuthStateCtx = createContext<AuthState | null>(null);
@@ -70,9 +68,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
state.matches("loadingOAuth") || state.matches("loadingOAuth") ||
state.matches("loadingGuestLogin"), state.matches("loadingGuestLogin"),
errorMessage: state.context.errorMessage, errorMessage: state.context.errorMessage,
// 改用 loginStatus 判断"已登录"(避开 success state 误触发)
isSuccess: state.context.loginStatus !== "notLoggedIn",
loginStatus: state.context.loginStatus, loginStatus: state.context.loginStatus,
pendingRedirect: state.context.pendingRedirect,
}), }),
[state], [state],
); );
+6 -1
View File
@@ -33,4 +33,9 @@ export type AuthEvent =
// OAuth 回调后:把 OAuth provider token 转交后端换业务 token // OAuth 回调后:把 OAuth provider token 转交后端换业务 token
// 由 <OAuthSessionSync /> 监听 useSession() 派发 // 由 <OAuthSessionSync /> 监听 useSession() 派发
| { type: "AuthGoogleSyncSubmitted"; idToken: string } | { type: "AuthGoogleSyncSubmitted"; idToken: string }
| { type: "AuthFacebookSyncSubmitted"; accessToken: string }; | { type: "AuthFacebookSyncSubmitted"; accessToken: string }
// ──────────────────────────────────────────────────────────────────────
// splash / auth screen 跳完 /chat 后**清**"刚按了"信号(一次性):
// `pendingRedirect` 在 login onDone action 里**自动**置 true(不是按钮派的)
// —— 因此**只**需要 `AuthClearPendingRedirect` 一个事件
| { type: "AuthClearPendingRedirect" };
+25 -6
View File
@@ -154,7 +154,15 @@ const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
// **不**再设 `success` state —— 之前曾用 "success" 当"刚成功"信号, // **不**再设 `success` state —— 之前曾用 "success" 当"刚成功"信号,
// 但 status check 流也会误入 success,触发 splash-button 误跳 /chat。 // 但 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({ export const authMachine = setup({
types: { types: {
@@ -217,6 +225,10 @@ export const authMachine = setup({
errorMessage: "Apple login not implemented", 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 target: "idle", // ← status check 完回 idle**不**再假冒 success
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
// **不**置 pendingRedirect —— 状态查询是**被动**读 token,不算"用户意图"
errorMessage: null, errorMessage: null,
}), }),
}, },
@@ -246,9 +259,11 @@ export const authMachine = setup({
invoke: { invoke: {
src: "guestLogin", src: "guestLogin",
onDone: { onDone: {
target: "idle", // ← 改自 "success" target: "idle",
// 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 显式登录成功 → splash / auth 看到跳 /chat
errorMessage: null, errorMessage: null,
}), }),
}, },
@@ -271,9 +286,10 @@ export const authMachine = setup({
return { email: event.email, password: event.password }; return { email: event.email, password: event.password };
}, },
onDone: { onDone: {
target: "idle", // ← 改自 "success" target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 邮箱登录成功 → 跳
errorMessage: null, errorMessage: null,
}), }),
}, },
@@ -301,9 +317,10 @@ export const authMachine = setup({
}; };
}, },
onDone: { onDone: {
target: "idle", // ← 改自 "success" target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 注册成功 → 跳
errorMessage: null, errorMessage: null,
}), }),
}, },
@@ -348,9 +365,10 @@ export const authMachine = setup({
return { idToken: event.idToken }; return { idToken: event.idToken };
}, },
onDone: { onDone: {
target: "idle", // ← 改自 "success" target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← OAuth sync 成功 → 跳
errorMessage: null, errorMessage: null,
}), }),
}, },
@@ -373,9 +391,10 @@ export const authMachine = setup({
return { accessToken: event.accessToken }; return { accessToken: event.accessToken };
}, },
onDone: { onDone: {
target: "idle", // ← 改自 "success" target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← OAuth sync 成功 → 跳
errorMessage: null, errorMessage: null,
}), }),
}, },
+10
View File
@@ -17,6 +17,15 @@ export interface AuthState {
errorMessage: string | null; errorMessage: string | null;
/** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */ /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */
loginStatus: LoginStatus; 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 = { export const initialState: AuthState = {
@@ -28,4 +37,5 @@ export const initialState: AuthState = {
confirmPassword: "", confirmPassword: "",
errorMessage: null, errorMessage: null,
loginStatus: "notLoggedIn", loginStatus: "notLoggedIn",
pendingRedirect: false,
}; };