refactor(app): move route screens out of components
Exclude test and spec files from generated barrels so page imports do not load test modules at runtime.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
/**
|
||||
* ChatScreen 编排层
|
||||
*
|
||||
* 职责(本文件):
|
||||
* - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器
|
||||
* - 屏幕生命周期事件(init / visible / invisible)
|
||||
* - 不直接管理 WebSocket / 不直接读 AuthStorage(除 token 取值)
|
||||
*
|
||||
* 子组件职责:
|
||||
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
|
||||
* - ChatArea:消息列表
|
||||
* - ChatInputBar:输入栏(文字 + 发送 + More)
|
||||
* - PwaInstallOverlay:PWA 安装提示触发器
|
||||
* - BrowserHintOverlay:浏览器提示
|
||||
*
|
||||
* 鉴权解耦(事件驱动):
|
||||
* - chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus
|
||||
* - chat-screen 只派 3 个事件:
|
||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||||
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
|
||||
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
|
||||
* - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期
|
||||
*/
|
||||
import { useEffect, useRef } 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 { MobileShell } from "@/app/_components/core";
|
||||
|
||||
import {
|
||||
BrowserHintOverlay,
|
||||
ChatArea,
|
||||
ChatHeader,
|
||||
ChatInputBar,
|
||||
ChatQuotaExhaustedBanner,
|
||||
PwaInstallOverlay,
|
||||
} from "./components";
|
||||
import styles from "./components/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);
|
||||
|
||||
// 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar)
|
||||
// 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次
|
||||
// - quotaLoaded 避免一进 /chat 就跳 banner(loadQuota 还在 in-flight)
|
||||
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
|
||||
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1,
|
||||
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
|
||||
const showExhaustedBanner =
|
||||
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 屏幕生命周期 + 派初次鉴权生命周期事件
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
|
||||
return () => {
|
||||
};
|
||||
// 注: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]);
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
{showExhaustedBanner ? (
|
||||
<ChatQuotaExhaustedBanner />
|
||||
) : (
|
||||
<ChatInputBar disabled={state.isReplyingAI} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 浏览器提示(应用内浏览器检测) */}
|
||||
<BrowserHintOverlay />
|
||||
|
||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||
<PwaInstallOverlay />
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user