diff --git a/src/app/auth/auth-screen.tsx b/src/app/auth/auth-screen.tsx
index f7620d1a..a86a8d4b 100644
--- a/src/app/auth/auth-screen.tsx
+++ b/src/app/auth/auth-screen.tsx
@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
-import { useUserDispatch } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes";
import { AuthBackground, AuthPanel } from "./components";
@@ -18,10 +17,9 @@ import styles from "./components/auth-screen.module.css";
export function AuthScreen() {
const state = useAuthState();
const authDispatch = useAuthDispatch();
- const userDispatch = useUserDispatch();
const router = useRouter();
- // 邮箱登录成功 → 通知 User + 跳转
+ // 邮箱登录成功 → 跳转;User hydrate 由根级 监听 loginStatus 变化处理。
// 用 pendingRedirect flag(不是 useRef transition detection)——
// re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
useEffect(() => {
@@ -37,16 +35,9 @@ export function AuthScreen() {
"[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
- userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat);
}
- }, [
- state.loginStatus,
- state.pendingRedirect,
- authDispatch,
- userDispatch,
- router,
- ]);
+ }, [state.loginStatus, state.pendingRedirect, authDispatch, router]);
return (
diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx
index bb92d3a7..af29ff6e 100644
--- a/src/app/chat/chat-screen.tsx
+++ b/src/app/chat/chat-screen.tsx
@@ -10,22 +10,18 @@
* - BrowserHintOverlay:浏览器提示
*
* 鉴权解耦(事件驱动):
- * - chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus
- * - chat-screen 只派 3 个事件:
- * - `ChatGuestLogin` → 游客会话(断 WS = 不连)
- * - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
- * - `ChatLogout` → 登出(机器自动 cleanup WS actor)
- * - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期
+ * - chat 机器不感知鉴权 / 不管 WebSocket
+ * - auth → chat 的生命周期同步由根级 负责
+ * - ChatScreen 只派生 UI 展示所需的 isGuest
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
-import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import type { LoginStatus } from "@/data/dto/auth";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
-import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
+import { useChatState } from "@/stores/chat/chat-context";
import { AppEnvUtil, BrowserDetector, UrlLauncherUtil } from "@/utils";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
@@ -50,39 +46,9 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
return loginStatus === "guest";
}
-/**
- * 按 loginStatus 派鉴权生命周期事件(chat 机器不感知 loginStatus —— 这层转换在此)
- *
- * 设计:不直接管理 WS / 不读 AuthStorage(除取 token)
- */
-function dispatchAuthLifecycle(
- loginStatus: LoginStatus,
- chatDispatch: ReturnType,
-): void {
- if (loginStatus === "guest") {
- // 游客:派 ChatGuestLogin(chat 机器不连 WS —— 业务事实"断 WS")
- chatDispatch({ type: "ChatGuestLogin" });
- return;
- }
- if (loginStatus === "notLoggedIn") {
- // 登出:派 ChatLogout(chat 机器自动 cleanup WS actor + 清消息)
- chatDispatch({ type: "ChatLogout" });
- return;
- }
- // 非游客:拿 loginToken → 派 ChatNonGuestLogin { token }
- void (async () => {
- const tokenR = await AuthStorage.getInstance().getLoginToken();
- if (tokenR.success && tokenR.data) {
- chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data });
- }
- })();
-}
-
export function ChatScreen() {
- const router = useRouter();
const state = useChatState();
const authState = useAuthState();
- const chatDispatch = useChatDispatch();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
@@ -98,37 +64,7 @@ export function ChatScreen() {
const showExhaustedBanner =
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
- // ─────────────────────────────────────────────────────────────
- // loginStatus 变化 → 派对应鉴权生命周期事件
- //
- // 规则:
- // - AuthInit 未完成 → 等,避免初始 notLoggedIn 把 /chat 变成死状态
- // - AuthInit 完成后仍 "notLoggedIn" → 清 chat 并回 splash
- // - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin
- // ─────────────────────────────────────────────────────────────
- const prevLoginStatusRef = useRef(null);
const externalBrowserPromptShownRef = useRef(false);
- useEffect(() => {
- if (!authState.hasInitialized || authState.isLoading) return;
-
- const prev = prevLoginStatusRef.current;
- if (prev === authState.loginStatus) return; // 登录态没变
- prevLoginStatusRef.current = authState.loginStatus;
-
- if (authState.loginStatus === "notLoggedIn") {
- chatDispatch({ type: "ChatLogout" });
- router.replace(ROUTES.splash);
- return;
- }
-
- dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
- }, [
- authState.hasInitialized,
- authState.isLoading,
- authState.loginStatus,
- chatDispatch,
- router,
- ]);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx
index f65780f9..a8845b96 100644
--- a/src/app/sidebar/sidebar-screen.tsx
+++ b/src/app/sidebar/sidebar-screen.tsx
@@ -1,11 +1,8 @@
"use client";
-import { useEffect, useRef } from "react";
-import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import { MobileShell, SettingsSection } from "@/app/_components/core";
-import { ROUTES } from "@/router/routes";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -36,22 +33,8 @@ export function SidebarScreen() {
const userDispatch = useUserDispatch();
const auth = useAuthState();
const authDispatch = useAuthDispatch();
- const router = useRouter();
- const isLogoutRedirectPendingRef = useRef(false);
-
- useEffect(() => {
- userDispatch({ type: "UserInit" });
- }, [userDispatch]);
-
- useEffect(() => {
- if (!isLogoutRedirectPendingRef.current) return;
- if (auth.isLoading || auth.loginStatus !== "notLoggedIn") return;
- isLogoutRedirectPendingRef.current = false;
- router.replace(ROUTES.splash);
- }, [auth.isLoading, auth.loginStatus, router]);
const handleLogoutClick = () => {
- isLogoutRedirectPendingRef.current = true;
userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false });
diff --git a/src/app/splash/splash-screen.tsx b/src/app/splash/splash-screen.tsx
index e4883c48..b5496703 100644
--- a/src/app/splash/splash-screen.tsx
+++ b/src/app/splash/splash-screen.tsx
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
-import { useUserDispatch } from "@/stores/user/user-context";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ROUTES } from "@/router/routes";
import { pwaUtil } from "@/utils";
@@ -27,7 +26,6 @@ export function SplashScreen() {
const router = useRouter();
const state = useAuthState();
const authDispatch = useAuthDispatch();
- const userDispatch = useUserDispatch();
const handleSkip = () => {
authDispatch({ type: "AuthGuestLoginSubmitted" });
@@ -81,16 +79,9 @@ export function SplashScreen() {
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
- userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat);
}
- }, [
- state.loginStatus,
- state.pendingRedirect,
- authDispatch,
- userDispatch,
- router,
- ]);
+ }, [state.loginStatus, state.pendingRedirect, authDispatch, router]);
return (
diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx
index a90817e6..8ad92a2f 100644
--- a/src/providers/root-providers.tsx
+++ b/src/providers/root-providers.tsx
@@ -8,6 +8,7 @@
* + AuthStatusChecker(启动时一次:派发 AuthInit)
* + OAuthSessionSync (持续监听 NextAuth session → auth machine)
* + UserAuthSync (auth 登录态恢复后 hydrate user machine)
+ * + ChatAuthSync (持续监听 auth loginStatus → chat machine)
*
* 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可,
* 无需在每个页面单独包裹。
@@ -18,6 +19,7 @@ import type { ReactNode } from "react";
import { AuthProvider } from "@/stores/auth/auth-context";
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
+import { ChatAuthSync } from "@/stores/chat/chat-auth-sync";
import { ChatProvider } from "@/stores/chat/chat-context";
import { SidebarProvider } from "@/stores/sidebar/sidebar-context";
import { UserAuthSync } from "@/stores/user/user-auth-sync";
@@ -39,6 +41,7 @@ export function RootProviders({ children }: RootProvidersProps) {
+
{children}
diff --git a/src/stores/auth/auth-state.ts b/src/stores/auth/auth-state.ts
index 84397bc1..6a9bda0c 100644
--- a/src/stores/auth/auth-state.ts
+++ b/src/stores/auth/auth-state.ts
@@ -13,7 +13,7 @@ export interface AuthState {
username: string;
confirmPassword: string;
errorMessage: string | null;
- /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */
+ /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱)—— 下游同步器监听此字段的双向变化 */
loginStatus: LoginStatus;
/** 启动时的本地登录态恢复是否已经完成 */
hasInitialized: boolean;
diff --git a/src/stores/chat/chat-auth-sync.tsx b/src/stores/chat/chat-auth-sync.tsx
new file mode 100644
index 00000000..f9f22c08
--- /dev/null
+++ b/src/stores/chat/chat-auth-sync.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+/**
+ * Auth → Chat 同步器
+ *
+ * Chat machine 不直接感知 Auth machine;这里作为根级桥接器,持续监听
+ * loginStatus 变化并派发 chat 鉴权生命周期事件。这样页面组件只负责 UI,
+ * 不再承担全局状态同步职责。
+ */
+import { useEffect, useRef } from "react";
+import { usePathname, useRouter } from "next/navigation";
+
+import type { LoginStatus } from "@/data/dto/auth";
+import { AuthStorage } from "@/data/storage/auth/auth_storage";
+import { useAuthState } from "@/stores/auth/auth-context";
+import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
+
+import { useChatDispatch } from "./chat-context";
+
+function isProtectedPath(pathname: string): boolean {
+ return PROTECTED_ROUTES.some((route: StaticRoute) => pathname === route);
+}
+
+export function ChatAuthSync() {
+ const authState = useAuthState();
+ const chatDispatch = useChatDispatch();
+ const pathname = usePathname();
+ const router = useRouter();
+ const prevLoginStatusRef = useRef(null);
+
+ useEffect(() => {
+ if (!authState.hasInitialized || authState.isLoading) return;
+
+ const prev = prevLoginStatusRef.current;
+ if (prev === authState.loginStatus) return;
+ prevLoginStatusRef.current = authState.loginStatus;
+
+ if (authState.loginStatus === "notLoggedIn") {
+ chatDispatch({ type: "ChatLogout" });
+ if (isProtectedPath(pathname)) {
+ router.replace(ROUTES.splash);
+ }
+ return;
+ }
+
+ if (authState.loginStatus === "guest") {
+ chatDispatch({ type: "ChatGuestLogin" });
+ return;
+ }
+
+ let cancelled = false;
+ void (async () => {
+ const tokenR = await AuthStorage.getInstance().getLoginToken();
+ if (!cancelled && tokenR.success && tokenR.data) {
+ chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data });
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ authState.hasInitialized,
+ authState.isLoading,
+ authState.loginStatus,
+ chatDispatch,
+ pathname,
+ router,
+ ]);
+
+ return null;
+}
diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts
index 1d2eee74..1b039c08 100644
--- a/src/stores/chat/chat-events.ts
+++ b/src/stores/chat/chat-events.ts
@@ -4,7 +4,7 @@
* 事件名与原 Dart ChatEvent 对齐。
*
* 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已删。
- * 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅,
+ * 鉴权状态变化(loginStatus)由 通过 `useAuthState()` 订阅,
* chat 机器不感知鉴权。
*
* 鉴权生命周期事件(本轮新增):
@@ -16,7 +16,7 @@
* chat-screen 不直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
*/
export type ChatEvent =
- // 鉴权生命周期(用户显式触发登录后由 chat-screen 派)
+ // 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
| { type: "ChatGuestLogin" }
| { type: "ChatNonGuestLogin"; token: string }
| { type: "ChatLogout" }
diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts
index 15788325..063db9b0 100644
--- a/src/stores/chat/chat-machine.ts
+++ b/src/stores/chat/chat-machine.ts
@@ -2,8 +2,8 @@
* Chat 状态机(XState v5)
*
* 鉴权解耦(事件驱动):
- * chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus
- * chat-screen 只派 3 个事件:
+ * chat 机器不感知鉴权 / 不管 WebSocket —— 由 派生 loginStatus
+ * ChatAuthSync 只派 3 个事件:
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
@@ -221,6 +221,10 @@ export const chatMachine = setup({
// guestSession(游客会话 —— 不连 WS,2 个 init 任务并行)
// ========================================================================
guestSession: {
+ on: {
+ ChatLogout: "#chat.idle",
+ ChatNonGuestLogin: "#chat.userSession",
+ },
initial: "initializing",
states: {
initializing: {
@@ -311,8 +315,6 @@ export const chatMachine = setup({
},
],
// 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页
- ChatLogout: "#chat.idle",
- ChatNonGuestLogin: "#chat.userSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
@@ -355,6 +357,12 @@ export const chatMachine = setup({
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
// —— XState v5: child handler 替换 parent;复制 action
on: {
+ ChatLogout: "#chat.idle",
+ ChatGuestLogin: "#chat.guestSession",
+ ChatNonGuestLogin: {
+ target: "#chat.userSession",
+ reenter: true,
+ },
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" },
@@ -424,8 +432,6 @@ export const chatMachine = setup({
ChatLoadMoreHistory: {
target: "loadingMore",
},
- ChatLogout: "#chat.idle",
- ChatGuestLogin: "#chat.guestSession",
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
// 已上提到 userSession.on,子级不再声明
diff --git a/src/stores/chat/index.ts b/src/stores/chat/index.ts
index 78cd11ae..57fcea66 100644
--- a/src/stores/chat/index.ts
+++ b/src/stores/chat/index.ts
@@ -2,6 +2,7 @@
* @file Automatically generated by barrelsby.
*/
+export * from "./chat-auth-sync";
export * from "./chat-context";
export * from "./chat-events";
export * from "./chat-machine.actors";
diff --git a/src/stores/user/user-auth-sync.tsx b/src/stores/user/user-auth-sync.tsx
index 78710f42..3d57041a 100644
--- a/src/stores/user/user-auth-sync.tsx
+++ b/src/stores/user/user-auth-sync.tsx
@@ -1,35 +1,39 @@
"use client";
/**
- * Auth → User 启动同步器
+ * Auth → User 同步器
*
- * AuthStatusChecker 只负责恢复 loginStatus;头像等登录用户资料由
- * UserStorage 持久化在 user machine 这一侧。冷启动直达 /chat 时,
- * 没有经过 splash/auth 的跳转副作用,因此需要在登录态恢复后主动
- * hydrate user store。
+ * AuthStatusChecker 只负责恢复 loginStatus;用户资料由 user machine
+ * 管理。这里持续监听登录态变化:
+ * - notLoggedIn → 清空本地 user context
+ * - guest / email / OAuth → 重新 hydrate user store
*/
import { useEffect, useRef } from "react";
+import type { LoginStatus } from "@/data/dto/auth";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch } from "./user-context";
export function UserAuthSync() {
- const { loginStatus } = useAuthState();
+ const { hasInitialized, isLoading, loginStatus } = useAuthState();
const userDispatch = useUserDispatch();
- const lastInitializedStatusRef = useRef(null);
+ const prevLoginStatusRef = useRef(null);
- //TODO 在测位栏界面监听变化,监听登录状态的变化若发生变化,则需要分发 user init 事件
useEffect(() => {
- if (loginStatus === "notLoggedIn" || loginStatus === "guest") {
- lastInitializedStatusRef.current = null;
+ if (!hasInitialized || isLoading) return;
+
+ const prev = prevLoginStatusRef.current;
+ if (prev === loginStatus) return;
+ prevLoginStatusRef.current = loginStatus;
+
+ if (loginStatus === "notLoggedIn") {
+ userDispatch({ type: "UserClearLocal" });
return;
}
- if (lastInitializedStatusRef.current === loginStatus) return;
- lastInitializedStatusRef.current = loginStatus;
userDispatch({ type: "UserInit" });
- }, [loginStatus, userDispatch]);
+ }, [hasInitialized, isLoading, loginStatus, userDispatch]);
return null;
}