refactor(chat): decouple chat state from auth and gate quota alerts to guests

- Remove ChatAuthStatusChanged dispatch from auth login flow; chat screen now subscribes directly to auth state machine (loginStatus) to derive isGuest and drive WS lifecycle
- Gate quota-exceeded and warning dialogs to guests only (non-guests have no message limit)
- Dispatch ChatLoadMoreHistory for non-guest initial load vs ChatInit for guests
- Fix AppConstants import path from @/core/constants/app_constants to @/core/app_constants
- Add defensive isGuest checks in quota effects to prevent non-guest quota triggers
This commit is contained in:
2026-06-11 18:31:08 +08:00
parent e3d069e49f
commit ed4ccddae3
11 changed files with 233 additions and 63 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
*/
import { useState } from "react";
import { AppConstants } from "@/core/constants/app_constants";
import { AppConstants } from "@/core/app_constants";
import styles from "./auth-legal-text.module.css";
+2 -6
View File
@@ -10,7 +10,6 @@ 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 { ROUTES } from "@/router/routes";
import styles from "./auth-screen.module.css";
@@ -22,15 +21,14 @@ export function AuthScreen() {
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
// 本组件只负责:
// 1. 渲染登录 UI
// 2. 邮箱登录成功 → 初始化 chat/user store + navigate 到 /chat(业务层动作)
// 2. 邮箱登录成功 → 初始化 user store + navigate 到 /chat(业务层动作)
const state = useAuthState();
const authDispatch = useAuthDispatch();
const chatDispatch = useChatDispatch();
const userDispatch = useUserDispatch();
const router = useRouter();
// 邮箱登录成功 → 通知 Chat + User + 跳转
// 邮箱登录成功 → 通知 User + 跳转
// 用 **pendingRedirect flag**(不是 useRef transition detection)——
// re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。
useEffect(() => {
@@ -46,7 +44,6 @@ export function AuthScreen() {
"[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
chatDispatch({ type: "ChatAuthStatusChanged" });
userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat);
}
@@ -54,7 +51,6 @@ export function AuthScreen() {
state.loginStatus,
state.pendingRedirect,
authDispatch,
chatDispatch,
userDispatch,
router,
]);
+165 -16
View File
@@ -5,24 +5,43 @@
* 原始 Dart: lib/ui/chat/chat_screen.dart172 行)
*
* 职责(本文件):
* - **订阅 auth 状态机**loginStatus)——
* - 派生 isGuest / 驱动 WS 生命周期 / 驱动初次加载(按 loginStatus 选 ChatInit vs ChatLoadMoreHistory
* - 驱动历史加载
* - 屏幕生命周期事件(init / visible / invisible
* - 配额告警监听(quotaExceededTrigger + warningThreshold
* - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实
* - WebSocket 生命周期管理(基于 auth 状态机的 loginStatus
* - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏)
*
* 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
* - ChatArea:消息列表
* - ChatInputBar:输入栏(文字 + 发送 + More)
* - QuotaDialog:配额告警弹窗
* - QuotaDialog:配额告警弹窗**仅**游客)
* - PwaInstallOverlayPWA 安装提示触发器
* - BrowserHintOverlay:浏览器提示
* - CharacterIntro:角色介绍卡片(首屏)
*
* **鉴权解耦**
* chat 机器**不**感知鉴权 —— isGuest 在此派生自 `auth.loginStatus === "guest"`。
* chat 机器**不**管 WebSocket 生命周期 —— 由此 useEffect 监听 loginStatus 驱动。
*
* **配额(**仅**游客)**
* - 游客:派 ChatInit → readInitData 调 ChatStorage → onDone 条件赋
* - 非游客:派 ChatLoadMoreHistory(无配额 init)—— "非游客无消息限制"业务事实
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useAuthState } from "@/stores/auth/auth-context";
import type { LoginStatus } from "@/models/auth/login-status";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { GuestChatQuota } from "@/stores/chat";
import {
ChatWebSocket,
createChatWebSocket,
} from "@/core/net/chat-websocket";
import { MobileShell } from "@/app/_components/core/mobile-shell";
@@ -34,25 +53,154 @@ import { PwaInstallOverlay } from "./pwa-install-overlay";
import { QuotaDialog } from "./quota-dialog";
import styles from "./chat-screen.module.css";
/**
* 派生 isGuest —— 单一来源:`auth.loginStatus`
*/
function deriveIsGuest(loginStatus: LoginStatus): boolean {
return loginStatus === "guest";
}
/**
* 拿 WebSocket token —— 按 loginStatus 选 loginToken 或 guestToken
*/
async function getWsToken(
loginStatus: LoginStatus,
): Promise<string | null> {
if (loginStatus === "notLoggedIn") return null;
const storage = AuthStorage.getInstance();
if (loginStatus === "guest") {
const r = await storage.getGuestToken();
return r.success ? r.data : null;
}
// email / google / facebook / apple:都用业务 loginTokenOAuth sync 写下的)
const r = await storage.getLoginToken();
return r.success ? r.data : null;
}
/**
* 按 loginStatus 派初次加载事件(**仅**游客派 ChatInit —— 配额 init
*/
function dispatchInitialLoad(
loginStatus: LoginStatus,
chatDispatch: ReturnType<typeof useChatDispatch>,
): void {
if (loginStatus === "guest") {
chatDispatch({ type: "ChatInit" }); // 拉本地消息 + 初始化配额
} else if (loginStatus !== "notLoggedIn") {
chatDispatch({ type: "ChatLoadMoreHistory" }); // 拉服务器端首屏(**无**配额 init)
}
}
export function ChatScreen() {
const state = useChatState();
const dispatch = useChatDispatch();
const authState = useAuthState();
const chatDispatch = useChatDispatch();
// isGuest 派生(chat-screen 是**唯一**知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
const [quotaExhausted, setQuotaExhausted] = useState(false);
// 屏幕生命周期
// ─────────────────────────────────────────────────────────────
// 屏幕生命周期 —— mount 时按 loginStatus 派初次加载
// ─────────────────────────────────────────────────────────────
useEffect(() => {
dispatch({ type: "ChatInit" });
dispatch({ type: "ChatScreenVisible" });
chatDispatch({ type: "ChatScreenVisible" });
dispatchInitialLoad(authState.loginStatus, chatDispatch);
return () => {
dispatch({ type: "ChatScreenInvisible" });
chatDispatch({ type: "ChatScreenInvisible" });
};
}, [dispatch]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatDispatch]);
// 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的
// "用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。
// loginStatus 后续变化由下方"副作用 ②" 统一处理。
// 配额耗尽触发器
// ─────────────────────────────────────────────────────────────
// 核心副作用 ①:loginStatus 变化 → 连/断 WebSocket
// ─────────────────────────────────────────────────────────────
const wsRef = useRef<ChatWebSocket | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
if (authState.loginStatus === "notLoggedIn") {
// 登出 / 未登录:断开 WS
if (wsRef.current) {
wsRef.current.disconnect();
wsRef.current = null;
}
return;
}
// 已登录:拿 token → 连 WS
const token = await getWsToken(authState.loginStatus);
if (cancelled || !token) return;
// 清理旧连接(如有)
if (wsRef.current) {
wsRef.current.disconnect();
wsRef.current = null;
}
const ws = createChatWebSocket(token);
ws.onConnected = (userId) => {
chatDispatch({ type: "ChatWebSocketConnected", userId });
};
ws.onSentence = (p) => {
chatDispatch({
type: "ChatAISentenceReceived",
index: p.index,
text: p.text,
total: p.total,
done: p.done,
});
};
ws.onError = (msg) => {
chatDispatch({ type: "ChatWebSocketError", errorMessage: msg });
};
ws.connect();
wsRef.current = ws;
})();
return () => {
cancelled = true;
if (wsRef.current) {
wsRef.current.disconnect();
wsRef.current = null;
}
};
}, [authState.loginStatus, chatDispatch]);
// ─────────────────────────────────────────────────────────────
// 副作用 ②:loginStatus 变化 → 重新加载
//
// 规则:
// - 首次 mountprev === null)→ 由 mount useEffect 统一处理
// - "notLoggedIn" → 不加载
// - 登录态变化(guest→email 等)→ 重新加载
// - 游客派 ChatInit(拉本地 + **初始化配额**)
// - 非游客派 ChatLoadMoreHistory(拉服务器端首屏,**无**配额 init)
// ─────────────────────────────────────────────────────────────
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
useEffect(() => {
const prev = prevLoginStatusRef.current;
prevLoginStatusRef.current = authState.loginStatus;
if (prev === null) return; // 首次 mount 由 mount useEffect 处理
if (authState.loginStatus === "notLoggedIn") return;
if (prev !== authState.loginStatus) {
dispatchInitialLoad(authState.loginStatus, chatDispatch);
}
}, [authState.loginStatus, chatDispatch]);
// ─────────────────────────────────────────────────────────────
// 配额耗尽触发器(**仅**游客 —— "非游客无消息限制"
// ─────────────────────────────────────────────────────────────
const prevTriggerRef = useRef(state.quotaExceededTrigger);
useEffect(() => {
if (!isGuest) return; // ← 防御:非游客 backend 不会发 ChatQuotaExceeded
if (
state.quotaExceededTrigger !== prevTriggerRef.current &&
state.quotaExceededTrigger > 0
@@ -61,13 +209,13 @@ export function ChatScreen() {
setQuotaDialogOpen(true);
}
prevTriggerRef.current = state.quotaExceededTrigger;
}, [state.quotaExceededTrigger]);
}, [state.quotaExceededTrigger, isGuest]);
// 配额警告(剩余 = 5
// 配额警告(剩余 = 5—— **仅**游客
const prevRemainingRef = useRef(state.guestRemainingQuota);
useEffect(() => {
if (
state.isGuest &&
isGuest && // ← 派生自 auth.loginStatus
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
prevRemainingRef.current !== state.guestRemainingQuota
) {
@@ -75,7 +223,7 @@ export function ChatScreen() {
setQuotaDialogOpen(true);
}
prevRemainingRef.current = state.guestRemainingQuota;
}, [state.guestRemainingQuota, state.isGuest]);
}, [state.guestRemainingQuota, isGuest]);
return (
<MobileShell>
@@ -92,12 +240,13 @@ export function ChatScreen() {
</div>
<div className={styles.layout}>
<ChatHeader isGuest={state.isGuest} />
{/* isGuest 派生自 auth.loginStatus */}
<ChatHeader isGuest={isGuest} />
<ChatArea
messages={state.messages}
isReplyingAI={state.isReplyingAI}
isGuest={state.isGuest}
isGuest={isGuest}
/>
<ChatInputBar disabled={state.isReplyingAI} />
@@ -109,7 +258,7 @@ export function ChatScreen() {
{/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay />
{/* 配额告警弹窗 */}
{/* 配额告警弹窗**仅**游客,gate 在 isGuest */}
<QuotaDialog
open={quotaDialogOpen}
onClose={() => setQuotaDialogOpen(false)}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata, Viewport } from "next";
import { SessionProvider } from "@/providers/session-provider";
import { geistSans, geistMono, athelas } from "@/lib/fonts";
import { geistSans, geistMono, athelas } from "@/core/fonts";
import "./globals.css";
import { RootProviders } from "@/providers/root-providers";
@@ -8,7 +8,6 @@ import { MobileShell } from "@/app/_components/core/mobile-shell";
import { SettingsSection } from "@/app/_components/core/settings-section";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { useAuthDispatch } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { ROUTES } from "@/router/routes";
import type { UserView } from "@/models/user/user";
@@ -23,7 +22,6 @@ import styles from "./sidebar-screen.module.css";
export function SidebarScreen() {
const user = useUserState();
const userDispatch = useUserDispatch();
const chatDispatch = useChatDispatch();
const authDispatch = useAuthDispatch();
const router = useRouter();
@@ -34,17 +32,18 @@ export function SidebarScreen() {
// 等价 Dart: BlocConsumer<UserBloc> 的 listenerprofile_view.dart:24-34
// 仅在 currentUser 由非 null 跳到 null 时触发清理
// 注:chat 机器**不**再需要 `ChatAuthStatusChanged` 通知(已**删**)—— chat-screen
// 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat
const prevUserRef = useRef<UserView | null>(user.currentUser);
useEffect(() => {
const wasLoggedIn = prevUserRef.current != null;
const isNowLoggedOut = user.currentUser == null;
prevUserRef.current = user.currentUser;
if (wasLoggedIn && isNowLoggedOut) {
chatDispatch({ type: "ChatAuthStatusChanged" });
authDispatch({ type: "AuthReset" });
router.replace(ROUTES.chat);
}
}, [user.currentUser, chatDispatch, authDispatch, router]);
}, [user.currentUser, authDispatch, router]);
return (
<MobileShell>
@@ -5,7 +5,6 @@ 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";
@@ -20,7 +19,6 @@ export function SplashScreen() {
const router = useRouter();
const state = useAuthState();
const authDispatch = useAuthDispatch();
const chatDispatch = useChatDispatch();
const userDispatch = useUserDispatch();
// ─────────────────────────────────────────────────────────────
@@ -66,7 +64,6 @@ export function SplashScreen() {
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
chatDispatch({ type: "ChatAuthStatusChanged" });
userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat);
}
@@ -74,7 +71,6 @@ export function SplashScreen() {
state.loginStatus,
state.pendingRedirect,
authDispatch,
chatDispatch,
userDispatch,
router,
]);