d55694a73e
- chat-screen no longer manages WebSocket directly; it now dispatches three auth lifecycle events (ChatGuestLogin, ChatNonGuestLogin, ChatLogout) derived from auth.loginStatus - chat-machine internally owns the WebSocket long-lived actor via fromCallback, completing the previously deferred migration scope - removed ChatWebSocket import, getWsToken helper, and direct WS effect from chat-screen; renamed dispatchInitialLoad to dispatchAuthLifecycle to reflect the new responsibility - decouples auth from chat machine via event-driven boundary: machine stays unaware of loginStatus, guest semantics live at the dispatch layer (guest → skip WS, non-guest → connect with token)
200 lines
8.3 KiB
TypeScript
200 lines
8.3 KiB
TypeScript
"use client";
|
||
/**
|
||
* ChatScreen 编排层
|
||
*
|
||
* 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
|
||
*
|
||
* 职责(本文件):
|
||
* - **订阅 auth 状态机**(loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器
|
||
* - 屏幕生命周期事件(init / visible / invisible)
|
||
* - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实)
|
||
* - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值)
|
||
*
|
||
* 子组件职责:
|
||
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
|
||
* - ChatArea:消息列表
|
||
* - ChatInputBar:输入栏(文字 + 发送 + More)
|
||
* - QuotaDialog:配额告警弹窗(**仅**游客)
|
||
* - PwaInstallOverlay:PWA 安装提示触发器
|
||
* - BrowserHintOverlay:浏览器提示
|
||
* - CharacterIntro:角色介绍卡片(首屏)
|
||
*
|
||
* **鉴权解耦**(**事件驱动**):
|
||
* - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
|
||
* - chat-screen **只**派 3 个事件:
|
||
* - `ChatGuestLogin` → 游客会话(**断** WS = 不连)
|
||
* - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS)
|
||
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
|
||
* - chat 机器**内部**用 fromCallback actor 完整管理 WS 生命周期
|
||
*/
|
||
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 { MobileShell } from "@/app/_components/core/mobile-shell";
|
||
|
||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
||
import { ChatArea } from "./chat-area";
|
||
import { ChatHeader } from "./chat-header";
|
||
import { ChatInputBar } from "./chat-input-bar";
|
||
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";
|
||
}
|
||
|
||
/**
|
||
* 按 loginStatus 派鉴权生命周期事件(chat 机器**不**感知 loginStatus —— 这层转换在此)
|
||
*
|
||
* 设计:**不**直接管理 WS / **不**读 AuthStorage(除取 token)
|
||
*/
|
||
function dispatchAuthLifecycle(
|
||
loginStatus: LoginStatus,
|
||
chatDispatch: ReturnType<typeof useChatDispatch>,
|
||
): 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 state = useChatState();
|
||
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);
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// 屏幕生命周期 + 派初次鉴权生命周期事件
|
||
// ─────────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
chatDispatch({ type: "ChatScreenVisible" });
|
||
dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
|
||
return () => {
|
||
chatDispatch({ type: "ChatScreenInvisible" });
|
||
};
|
||
// 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的
|
||
// ("用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。
|
||
// loginStatus 后续变化由下方"副作用 ②" 统一处理。
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [chatDispatch]);
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// 副作用 ②:loginStatus 变化 → 派对应鉴权生命周期事件
|
||
//
|
||
// 规则:
|
||
// - 首次 mount(prev === null)→ 由 mount useEffect 统一处理
|
||
// - "notLoggedIn" → 派 ChatLogout
|
||
// - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin
|
||
// ─────────────────────────────────────────────────────────────
|
||
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
|
||
useEffect(() => {
|
||
const prev = prevLoginStatusRef.current;
|
||
prevLoginStatusRef.current = authState.loginStatus;
|
||
if (prev === null) return; // 首次 mount 由 mount useEffect 处理
|
||
if (prev === authState.loginStatus) return; // 登录态**没**变
|
||
dispatchAuthLifecycle(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
|
||
) {
|
||
setQuotaExhausted(true);
|
||
setQuotaDialogOpen(true);
|
||
}
|
||
prevTriggerRef.current = state.quotaExceededTrigger;
|
||
}, [state.quotaExceededTrigger, isGuest]);
|
||
|
||
// 配额警告(剩余 = 5)—— **仅**游客
|
||
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
||
useEffect(() => {
|
||
if (
|
||
isGuest && // ← 派生自 auth.loginStatus
|
||
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
|
||
prevRemainingRef.current !== state.guestRemainingQuota
|
||
) {
|
||
setQuotaExhausted(false);
|
||
setQuotaDialogOpen(true);
|
||
}
|
||
prevRemainingRef.current = state.guestRemainingQuota;
|
||
}, [state.guestRemainingQuota, isGuest]);
|
||
|
||
return (
|
||
<MobileShell>
|
||
<div className={styles.shell}>
|
||
{/* 背景图(与原 Dart MobileLayout 一致) */}
|
||
<div className={styles.background}>
|
||
<Image
|
||
src="/images/chat/bg-chatpage.png"
|
||
alt=""
|
||
fill
|
||
priority
|
||
sizes="(max-width: 500px) 100vw, 500px"
|
||
/>
|
||
</div>
|
||
|
||
<div className={styles.layout}>
|
||
{/* isGuest 派生自 auth.loginStatus */}
|
||
<ChatHeader isGuest={isGuest} />
|
||
|
||
<ChatArea
|
||
messages={state.messages}
|
||
isReplyingAI={state.isReplyingAI}
|
||
isGuest={isGuest}
|
||
/>
|
||
|
||
<ChatInputBar disabled={state.isReplyingAI} />
|
||
</div>
|
||
|
||
{/* 浏览器提示(应用内浏览器检测) */}
|
||
<BrowserHintOverlay />
|
||
|
||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||
<PwaInstallOverlay />
|
||
|
||
{/* 配额告警弹窗(**仅**游客,gate 在 isGuest) */}
|
||
<QuotaDialog
|
||
open={quotaDialogOpen}
|
||
onClose={() => setQuotaDialogOpen(false)}
|
||
isExhausted={quotaExhausted}
|
||
/>
|
||
</div>
|
||
</MobileShell>
|
||
);
|
||
}
|