Files
cozsweet-frontend-nextjs/src/app/chat/components/chat-screen.tsx
T
admin d55694a73e refactor(chat): move WebSocket lifecycle into chat machine via fromCallback actor
- 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)
2026-06-11 18:57:11 +08:00

200 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* ChatScreen 编排层
*
* 原始 Dart: lib/ui/chat/chat_screen.dart172 行)
*
* 职责(本文件):
* - **订阅 auth 状态机**loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器
* - 屏幕生命周期事件(init / visible / invisible
* - 配额告警监听(**仅**游客 —— "非游客无消息限制"业务事实)
* - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值)
*
* 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
* - ChatArea:消息列表
* - ChatInputBar:输入栏(文字 + 发送 + More)
* - QuotaDialog:配额告警弹窗(**仅**游客)
* - PwaInstallOverlayPWA 安装提示触发器
* - 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") {
// 游客:派 ChatGuestLoginchat 机器**不**连 WS —— 业务事实"断 WS"
chatDispatch({ type: "ChatGuestLogin" });
return;
}
if (loginStatus === "notLoggedIn") {
// 登出:派 ChatLogoutchat 机器自动 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 变化 → 派对应鉴权生命周期事件
//
// 规则:
// - 首次 mountprev === 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 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay />
{/* 配额告警弹窗(**仅**游客,gate 在 isGuest */}
<QuotaDialog
open={quotaDialogOpen}
onClose={() => setQuotaDialogOpen(false)}
isExhausted={quotaExhausted}
/>
</div>
</MobileShell>
);
}