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:
@@ -13,7 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
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";
|
import styles from "./auth-legal-text.module.css";
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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 { useAuthDispatch, useAuthState } from "@/stores/auth/auth-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 styles from "./auth-screen.module.css";
|
import styles from "./auth-screen.module.css";
|
||||||
@@ -22,15 +21,14 @@ export function AuthScreen() {
|
|||||||
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
|
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
|
||||||
// 本组件只负责:
|
// 本组件只负责:
|
||||||
// 1. 渲染登录 UI
|
// 1. 渲染登录 UI
|
||||||
// 2. 邮箱登录成功 → 初始化 chat/user store + navigate 到 /chat(业务层动作)
|
// 2. 邮箱登录成功 → 初始化 user store + navigate 到 /chat(业务层动作)
|
||||||
|
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// 邮箱登录成功 → 通知 Chat + User + 跳转
|
// 邮箱登录成功 → 通知 User + 跳转
|
||||||
// 用 **pendingRedirect flag**(不是 useRef transition detection)——
|
// 用 **pendingRedirect flag**(不是 useRef transition detection)——
|
||||||
// re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。
|
// re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +44,6 @@ export function AuthScreen() {
|
|||||||
"[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]",
|
"[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]",
|
||||||
);
|
);
|
||||||
authDispatch({ type: "AuthClearPendingRedirect" });
|
authDispatch({ type: "AuthClearPendingRedirect" });
|
||||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
|
||||||
userDispatch({ type: "UserInit" });
|
userDispatch({ type: "UserInit" });
|
||||||
router.replace(ROUTES.chat);
|
router.replace(ROUTES.chat);
|
||||||
}
|
}
|
||||||
@@ -54,7 +51,6 @@ export function AuthScreen() {
|
|||||||
state.loginStatus,
|
state.loginStatus,
|
||||||
state.pendingRedirect,
|
state.pendingRedirect,
|
||||||
authDispatch,
|
authDispatch,
|
||||||
chatDispatch,
|
|
||||||
userDispatch,
|
userDispatch,
|
||||||
router,
|
router,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,24 +5,43 @@
|
|||||||
* 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
|
* 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
|
||||||
*
|
*
|
||||||
* 职责(本文件):
|
* 职责(本文件):
|
||||||
|
* - **订阅 auth 状态机**(loginStatus)——
|
||||||
|
* - 派生 isGuest / 驱动 WS 生命周期 / 驱动初次加载(按 loginStatus 选 ChatInit vs ChatLoadMoreHistory)
|
||||||
|
* - 驱动历史加载
|
||||||
* - 屏幕生命周期事件(init / visible / invisible)
|
* - 屏幕生命周期事件(init / visible / invisible)
|
||||||
* - 配额告警监听(quotaExceededTrigger + warningThreshold)
|
* - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实)
|
||||||
|
* - WebSocket 生命周期管理(基于 auth 状态机的 loginStatus)
|
||||||
* - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏)
|
* - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏)
|
||||||
*
|
*
|
||||||
* 子组件职责:
|
* 子组件职责:
|
||||||
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
|
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
|
||||||
* - ChatArea:消息列表
|
* - ChatArea:消息列表
|
||||||
* - ChatInputBar:输入栏(文字 + 发送 + More)
|
* - ChatInputBar:输入栏(文字 + 发送 + More)
|
||||||
* - QuotaDialog:配额告警弹窗
|
* - QuotaDialog:配额告警弹窗(**仅**游客)
|
||||||
* - PwaInstallOverlay:PWA 安装提示触发器
|
* - PwaInstallOverlay:PWA 安装提示触发器
|
||||||
* - BrowserHintOverlay:浏览器提示
|
* - BrowserHintOverlay:浏览器提示
|
||||||
* - CharacterIntro:角色介绍卡片(首屏)
|
* - CharacterIntro:角色介绍卡片(首屏)
|
||||||
|
*
|
||||||
|
* **鉴权解耦**:
|
||||||
|
* chat 机器**不**感知鉴权 —— isGuest 在此派生自 `auth.loginStatus === "guest"`。
|
||||||
|
* chat 机器**不**管 WebSocket 生命周期 —— 由此 useEffect 监听 loginStatus 驱动。
|
||||||
|
*
|
||||||
|
* **配额(**仅**游客)**:
|
||||||
|
* - 游客:派 ChatInit → readInitData 调 ChatStorage → onDone 条件赋
|
||||||
|
* - 非游客:派 ChatLoadMoreHistory(无配额 init)—— "非游客无消息限制"业务事实
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
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 { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
import { GuestChatQuota } from "@/stores/chat";
|
import { GuestChatQuota } from "@/stores/chat";
|
||||||
|
import {
|
||||||
|
ChatWebSocket,
|
||||||
|
createChatWebSocket,
|
||||||
|
} from "@/core/net/chat-websocket";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||||
|
|
||||||
@@ -34,25 +53,154 @@ import { PwaInstallOverlay } from "./pwa-install-overlay";
|
|||||||
import { QuotaDialog } from "./quota-dialog";
|
import { QuotaDialog } from "./quota-dialog";
|
||||||
import styles from "./chat-screen.module.css";
|
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:都用业务 loginToken(OAuth 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() {
|
export function ChatScreen() {
|
||||||
const state = useChatState();
|
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 [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
|
||||||
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
||||||
|
|
||||||
// 屏幕生命周期
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 屏幕生命周期 —— mount 时按 loginStatus 派初次加载
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: "ChatInit" });
|
chatDispatch({ type: "ChatScreenVisible" });
|
||||||
dispatch({ type: "ChatScreenVisible" });
|
dispatchInitialLoad(authState.loginStatus, chatDispatch);
|
||||||
return () => {
|
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 变化 → 重新加载
|
||||||
|
//
|
||||||
|
// 规则:
|
||||||
|
// - 首次 mount(prev === 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);
|
const prevTriggerRef = useRef(state.quotaExceededTrigger);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isGuest) return; // ← 防御:非游客 backend 不会发 ChatQuotaExceeded
|
||||||
if (
|
if (
|
||||||
state.quotaExceededTrigger !== prevTriggerRef.current &&
|
state.quotaExceededTrigger !== prevTriggerRef.current &&
|
||||||
state.quotaExceededTrigger > 0
|
state.quotaExceededTrigger > 0
|
||||||
@@ -61,13 +209,13 @@ export function ChatScreen() {
|
|||||||
setQuotaDialogOpen(true);
|
setQuotaDialogOpen(true);
|
||||||
}
|
}
|
||||||
prevTriggerRef.current = state.quotaExceededTrigger;
|
prevTriggerRef.current = state.quotaExceededTrigger;
|
||||||
}, [state.quotaExceededTrigger]);
|
}, [state.quotaExceededTrigger, isGuest]);
|
||||||
|
|
||||||
// 配额警告(剩余 = 5)
|
// 配额警告(剩余 = 5)—— **仅**游客
|
||||||
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
state.isGuest &&
|
isGuest && // ← 派生自 auth.loginStatus
|
||||||
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
|
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
|
||||||
prevRemainingRef.current !== state.guestRemainingQuota
|
prevRemainingRef.current !== state.guestRemainingQuota
|
||||||
) {
|
) {
|
||||||
@@ -75,7 +223,7 @@ export function ChatScreen() {
|
|||||||
setQuotaDialogOpen(true);
|
setQuotaDialogOpen(true);
|
||||||
}
|
}
|
||||||
prevRemainingRef.current = state.guestRemainingQuota;
|
prevRemainingRef.current = state.guestRemainingQuota;
|
||||||
}, [state.guestRemainingQuota, state.isGuest]);
|
}, [state.guestRemainingQuota, isGuest]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
@@ -92,12 +240,13 @@ export function ChatScreen() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
<ChatHeader isGuest={state.isGuest} />
|
{/* isGuest 派生自 auth.loginStatus */}
|
||||||
|
<ChatHeader isGuest={isGuest} />
|
||||||
|
|
||||||
<ChatArea
|
<ChatArea
|
||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isGuest={state.isGuest}
|
isGuest={isGuest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatInputBar disabled={state.isReplyingAI} />
|
<ChatInputBar disabled={state.isReplyingAI} />
|
||||||
@@ -109,7 +258,7 @@ export function ChatScreen() {
|
|||||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||||
<PwaInstallOverlay />
|
<PwaInstallOverlay />
|
||||||
|
|
||||||
{/* 配额告警弹窗 */}
|
{/* 配额告警弹窗(**仅**游客,gate 在 isGuest) */}
|
||||||
<QuotaDialog
|
<QuotaDialog
|
||||||
open={quotaDialogOpen}
|
open={quotaDialogOpen}
|
||||||
onClose={() => setQuotaDialogOpen(false)}
|
onClose={() => setQuotaDialogOpen(false)}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { SessionProvider } from "@/providers/session-provider";
|
import { SessionProvider } from "@/providers/session-provider";
|
||||||
import { geistSans, geistMono, athelas } from "@/lib/fonts";
|
import { geistSans, geistMono, athelas } from "@/core/fonts";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
import { RootProviders } from "@/providers/root-providers";
|
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 { SettingsSection } from "@/app/_components/core/settings-section";
|
||||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||||
import { useAuthDispatch } from "@/stores/auth/auth-context";
|
import { useAuthDispatch } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import type { UserView } from "@/models/user/user";
|
import type { UserView } from "@/models/user/user";
|
||||||
@@ -23,7 +22,6 @@ import styles from "./sidebar-screen.module.css";
|
|||||||
export function SidebarScreen() {
|
export function SidebarScreen() {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -34,17 +32,18 @@ export function SidebarScreen() {
|
|||||||
|
|
||||||
// 等价 Dart: BlocConsumer<UserBloc> 的 listener(profile_view.dart:24-34)
|
// 等价 Dart: BlocConsumer<UserBloc> 的 listener(profile_view.dart:24-34)
|
||||||
// 仅在 currentUser 由非 null 跳到 null 时触发清理
|
// 仅在 currentUser 由非 null 跳到 null 时触发清理
|
||||||
|
// 注:chat 机器**不**再需要 `ChatAuthStatusChanged` 通知(已**删**)—— chat-screen
|
||||||
|
// 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat
|
||||||
const prevUserRef = useRef<UserView | null>(user.currentUser);
|
const prevUserRef = useRef<UserView | null>(user.currentUser);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasLoggedIn = prevUserRef.current != null;
|
const wasLoggedIn = prevUserRef.current != null;
|
||||||
const isNowLoggedOut = user.currentUser == null;
|
const isNowLoggedOut = user.currentUser == null;
|
||||||
prevUserRef.current = user.currentUser;
|
prevUserRef.current = user.currentUser;
|
||||||
if (wasLoggedIn && isNowLoggedOut) {
|
if (wasLoggedIn && isNowLoggedOut) {
|
||||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
|
||||||
authDispatch({ type: "AuthReset" });
|
authDispatch({ type: "AuthReset" });
|
||||||
router.replace(ROUTES.chat);
|
router.replace(ROUTES.chat);
|
||||||
}
|
}
|
||||||
}, [user.currentUser, chatDispatch, authDispatch, router]);
|
}, [user.currentUser, authDispatch, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
|
||||||
import { useUserDispatch } from "@/stores/user/user-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";
|
||||||
@@ -20,7 +19,6 @@ export function SplashScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -66,7 +64,6 @@ export function SplashScreen() {
|
|||||||
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
|
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
|
||||||
);
|
);
|
||||||
authDispatch({ type: "AuthClearPendingRedirect" });
|
authDispatch({ type: "AuthClearPendingRedirect" });
|
||||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
|
||||||
userDispatch({ type: "UserInit" });
|
userDispatch({ type: "UserInit" });
|
||||||
router.replace(ROUTES.chat);
|
router.replace(ROUTES.chat);
|
||||||
}
|
}
|
||||||
@@ -74,7 +71,6 @@ export function SplashScreen() {
|
|||||||
state.loginStatus,
|
state.loginStatus,
|
||||||
state.pendingRedirect,
|
state.pendingRedirect,
|
||||||
authDispatch,
|
authDispatch,
|
||||||
chatDispatch,
|
|
||||||
userDispatch,
|
userDispatch,
|
||||||
router,
|
router,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import type { DeviceSize } from "@/lib/breakpoints";
|
import type { DeviceSize } from "@/core/breakpoints";
|
||||||
|
|
||||||
const QUERIES = {
|
const QUERIES = {
|
||||||
mobile: "(max-width: 599.98px)",
|
mobile: "(max-width: 599.98px)",
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
* 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。
|
* 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。
|
||||||
* 内部将 XState 状态机快照映射回原 `ChatState` 形状。
|
* 内部将 XState 状态机快照映射回原 `ChatState` 形状。
|
||||||
*
|
*
|
||||||
* 注:WebSocket 长生命周期 actor 暂未集成到状态机(保留在 chat-side-effects.ts
|
* 注:WebSocket 长生命周期 actor **由 chat-screen.tsx 监听 auth 状态机驱动**——
|
||||||
* 由外部 chat-side-effects 模块管理),待下一轮迁移。
|
* 鉴权解耦(loginStatus 在 auth,chat 机器**不**感知鉴权)。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
@@ -24,13 +24,17 @@ import { chatMachine } from "./chat-machine";
|
|||||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对外暴露的 State 形状(保持与原 ChatState 兼容)
|
* 对外暴露的 State 形状
|
||||||
|
*
|
||||||
|
* **isGuest 字段已删** —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
|
||||||
|
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
|
||||||
*/
|
*/
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
messages: MachineContext["messages"];
|
messages: MachineContext["messages"];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: boolean;
|
/** 游客剩余配额(次/天)—— 业务展示用,鉴权判断由 chat-screen 派生 loginStatus */
|
||||||
guestRemainingQuota: number;
|
guestRemainingQuota: number;
|
||||||
|
/** 游客总配额 */
|
||||||
guestTotalQuota: number;
|
guestTotalQuota: number;
|
||||||
quotaExceededTrigger: number;
|
quotaExceededTrigger: number;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
@@ -53,7 +57,6 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||||||
() => ({
|
() => ({
|
||||||
messages: state.context.messages,
|
messages: state.context.messages,
|
||||||
isReplyingAI: state.context.isReplyingAI,
|
isReplyingAI: state.context.isReplyingAI,
|
||||||
isGuest: state.context.isGuest,
|
|
||||||
guestRemainingQuota: state.context.guestRemainingQuota,
|
guestRemainingQuota: state.context.guestRemainingQuota,
|
||||||
guestTotalQuota: state.context.guestTotalQuota,
|
guestTotalQuota: state.context.guestTotalQuota,
|
||||||
quotaExceededTrigger: state.context.quotaExceededTrigger,
|
quotaExceededTrigger: state.context.quotaExceededTrigger,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
* Chat 状态机:事件联合
|
* Chat 状态机:事件联合
|
||||||
*
|
*
|
||||||
* 事件名与原 Dart ChatEvent 对齐。
|
* 事件名与原 Dart ChatEvent 对齐。
|
||||||
|
*
|
||||||
|
* 历史:原本有 `ChatAuthStatusChanged`(chat 机器刷新 isGuest)—— 已**删**。
|
||||||
|
* 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅,
|
||||||
|
* chat 机器**不**感知鉴权。
|
||||||
*/
|
*/
|
||||||
export type ChatEvent =
|
export type ChatEvent =
|
||||||
| { type: "ChatInit" }
|
| { type: "ChatInit" }
|
||||||
@@ -19,5 +23,4 @@ export type ChatEvent =
|
|||||||
}
|
}
|
||||||
| { type: "ChatWebSocketError"; errorMessage: string }
|
| { type: "ChatWebSocketError"; errorMessage: string }
|
||||||
| { type: "ChatWebSocketConnected"; userId: string }
|
| { type: "ChatWebSocketConnected"; userId: string }
|
||||||
| { type: "ChatQuotaExceeded" }
|
| { type: "ChatQuotaExceeded" };
|
||||||
| { type: "ChatAuthStatusChanged" };
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@
|
|||||||
* - HTTP 操作用 `fromPromise` actor(一次性 Promise)
|
* - HTTP 操作用 `fromPromise` actor(一次性 Promise)
|
||||||
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
|
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
|
||||||
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
|
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
|
||||||
|
*
|
||||||
|
* **鉴权解耦**:
|
||||||
|
* chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。
|
||||||
|
* WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。
|
||||||
|
*
|
||||||
|
* **配额(**仅**游客)**:
|
||||||
|
* - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义
|
||||||
|
* - 非游客"无消息限制"(业务事实)—— 永不被赋值
|
||||||
|
* - chat-screen 派 `ChatInit` 时(**只**在 guest)→ onDone 条件赋
|
||||||
|
* - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op
|
||||||
*/
|
*/
|
||||||
import { setup, fromPromise, assign } from "xstate";
|
import { setup, fromPromise, assign } from "xstate";
|
||||||
|
|
||||||
@@ -85,6 +95,8 @@ interface InitResult {
|
|||||||
|
|
||||||
async function readInitData(): Promise<InitResult> {
|
async function readInitData(): Promise<InitResult> {
|
||||||
const chatStorage = ChatStorage.getInstance();
|
const chatStorage = ChatStorage.getInstance();
|
||||||
|
// isGuest 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额" —— **不**是鉴权信号
|
||||||
|
// 鉴权判断已上提到 chat-screen 派生
|
||||||
const isGuest = !AuthStorage.getInstance().hasLoginToken();
|
const isGuest = !AuthStorage.getInstance().hasLoginToken();
|
||||||
|
|
||||||
const [dailyResult, totalResult, localResult] = await Promise.all([
|
const [dailyResult, totalResult, localResult] = await Promise.all([
|
||||||
@@ -95,13 +107,14 @@ async function readInitData(): Promise<InitResult> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isGuest,
|
isGuest,
|
||||||
|
// fallback 0 —— 非游客路径用不到;onDone 会**条件**赋
|
||||||
guestRemainingQuota: mapQuotaResult(
|
guestRemainingQuota: mapQuotaResult(
|
||||||
dailyResult as Result<{ remaining: number } | null>,
|
dailyResult as Result<{ remaining: number } | null>,
|
||||||
40,
|
0,
|
||||||
),
|
),
|
||||||
guestTotalQuota: mapTotalQuotaResult(
|
guestTotalQuota: mapTotalQuotaResult(
|
||||||
totalResult as Result<number | null>,
|
totalResult as Result<number | null>,
|
||||||
50,
|
0,
|
||||||
),
|
),
|
||||||
localMessages:
|
localMessages:
|
||||||
localResult && Result.isOk(localResult) && localResult.data
|
localResult && Result.isOk(localResult) && localResult.data
|
||||||
@@ -145,8 +158,7 @@ const loadMoreHistoryActor = fromPromise<
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================// Machine
|
||||||
// Machine
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export const chatMachine = setup({
|
export const chatMachine = setup({
|
||||||
types: {
|
types: {
|
||||||
@@ -171,6 +183,8 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
|
// 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制")
|
||||||
|
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -187,6 +201,8 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
|
// 游客减配额;非游客 no-op
|
||||||
|
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -223,10 +239,6 @@ export const chatMachine = setup({
|
|||||||
return { messages, isReplyingAI: false };
|
return { messages, isReplyingAI: false };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
refreshAuthStatus: assign(() => ({
|
|
||||||
isGuest: !AuthStorage.getInstance().hasLoginToken(),
|
|
||||||
})),
|
|
||||||
|
|
||||||
incrementQuotaExceeded: assign(({ context }) => ({
|
incrementQuotaExceeded: assign(({ context }) => ({
|
||||||
quotaExceededTrigger: context.quotaExceededTrigger + 1,
|
quotaExceededTrigger: context.quotaExceededTrigger + 1,
|
||||||
})),
|
})),
|
||||||
@@ -248,9 +260,6 @@ export const chatMachine = setup({
|
|||||||
ChatSendImage: {
|
ChatSendImage: {
|
||||||
actions: "appendUserImage",
|
actions: "appendUserImage",
|
||||||
},
|
},
|
||||||
ChatAuthStatusChanged: {
|
|
||||||
actions: "refreshAuthStatus",
|
|
||||||
},
|
|
||||||
ChatAISentenceReceived: {
|
ChatAISentenceReceived: {
|
||||||
actions: "appendOrUpdateAISentence",
|
actions: "appendOrUpdateAISentence",
|
||||||
},
|
},
|
||||||
@@ -269,11 +278,18 @@ export const chatMachine = setup({
|
|||||||
src: "chatInit",
|
src: "chatInit",
|
||||||
onDone: {
|
onDone: {
|
||||||
target: "ready",
|
target: "ready",
|
||||||
actions: assign({
|
// **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制")
|
||||||
messages: ({ event }) => event.output.localMessages,
|
// - 游客:messages + quota 都更新
|
||||||
isGuest: ({ event }) => event.output.isGuest,
|
// - 非游客:仅 messages(local 空),quota 保持 default 0(不更新其值)
|
||||||
guestRemainingQuota: ({ event }) => event.output.guestRemainingQuota,
|
actions: assign(({ event }) => {
|
||||||
guestTotalQuota: ({ event }) => event.output.guestTotalQuota,
|
const updates: Partial<ChatState> = {
|
||||||
|
messages: event.output.localMessages,
|
||||||
|
};
|
||||||
|
if (event.output.isGuest) {
|
||||||
|
updates.guestRemainingQuota = event.output.guestRemainingQuota;
|
||||||
|
updates.guestTotalQuota = event.output.guestTotalQuota;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
@@ -293,9 +309,6 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
ChatLoadMoreHistory: "loadingMoreHistory",
|
ChatLoadMoreHistory: "loadingMoreHistory",
|
||||||
ChatScreenInvisible: "idle",
|
ChatScreenInvisible: "idle",
|
||||||
ChatAuthStatusChanged: {
|
|
||||||
actions: "refreshAuthStatus",
|
|
||||||
},
|
|
||||||
ChatAISentenceReceived: {
|
ChatAISentenceReceived: {
|
||||||
actions: "appendOrUpdateAISentence",
|
actions: "appendOrUpdateAISentence",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,26 @@
|
|||||||
* Chat 状态机:State 形状 + 初始值
|
* Chat 状态机:State 形状 + 初始值
|
||||||
*
|
*
|
||||||
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
|
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
|
||||||
|
*
|
||||||
|
* 历史:
|
||||||
|
* - `isGuest: boolean` 字段已**删**(由 chat-screen 派生自 `auth.loginStatus === "guest"`)
|
||||||
|
* - `guestRemainingQuota` / `guestTotalQuota` default **改 0**:
|
||||||
|
* - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋
|
||||||
|
* - 非游客:**永不被赋值**(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op
|
||||||
|
* - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤
|
||||||
*/
|
*/
|
||||||
import type { UiMessage } from "@/models/chat/ui-message";
|
import type { UiMessage } from "@/models/chat/ui-message";
|
||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: boolean;
|
/**
|
||||||
|
* 游客剩余配额(次/天)—— **仅**游客业务展示用
|
||||||
|
* - default 0 = "未初始化" / "已耗尽"(同码,靠 `isGuest` 区分)
|
||||||
|
* - 非游客:永不被赋值(业务事实"无消息限制")
|
||||||
|
*/
|
||||||
guestRemainingQuota: number;
|
guestRemainingQuota: number;
|
||||||
|
/** 游客总配额(仅游客展示用) */
|
||||||
guestTotalQuota: number;
|
guestTotalQuota: number;
|
||||||
quotaExceededTrigger: number;
|
quotaExceededTrigger: number;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
@@ -20,9 +32,8 @@ export interface ChatState {
|
|||||||
export const initialState: ChatState = {
|
export const initialState: ChatState = {
|
||||||
messages: [],
|
messages: [],
|
||||||
isReplyingAI: false,
|
isReplyingAI: false,
|
||||||
isGuest: true,
|
guestRemainingQuota: 0,
|
||||||
guestRemainingQuota: 40,
|
guestTotalQuota: 0,
|
||||||
guestTotalQuota: 50,
|
|
||||||
quotaExceededTrigger: 0,
|
quotaExceededTrigger: 0,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user