feat(chat): port chat widget components from Dart to React
This commit is contained in:
@@ -1,38 +1,45 @@
|
||||
"use client";
|
||||
/**
|
||||
* ChatScreen 编排层
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
|
||||
*
|
||||
* 职责(本文件):
|
||||
* - 屏幕生命周期事件(init / visible / invisible)
|
||||
* - 配额告警监听(quotaExceededTrigger + warningThreshold)
|
||||
* - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏)
|
||||
*
|
||||
* 子组件职责:
|
||||
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
|
||||
* - ChatArea:消息列表
|
||||
* - ChatInputBar:输入栏(文字 + 发送 + More)
|
||||
* - QuotaDialog:配额告警弹窗
|
||||
* - PwaInstallOverlay:PWA 安装提示触发器
|
||||
* - BrowserHintOverlay:浏览器提示
|
||||
* - CharacterIntro:角色介绍卡片(首屏)
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { Dialog } from "@/app/_components/core/dialog";
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
import { GuestChatQuota } from "@/stores/chat/chat-types";
|
||||
import type { UiMessage } from "@/models/chat/ui-message";
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* 聊天屏幕
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/chat_screen.dart
|
||||
*
|
||||
* 组成(自上而下):
|
||||
* - ChatHeader(标题 + 菜单)
|
||||
* - ChatArea(消息列表 + AI 披露 + 日期分隔 + 加载动画)
|
||||
* - ChatInputBar(输入框 + 发送 / 语音 / 菜单)
|
||||
* - QuotaDialog(游客配额告警弹窗)
|
||||
* - PwaInstallOverlay / BrowserHintOverlay(占位)
|
||||
*/
|
||||
export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const dispatch = useChatDispatch();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
|
||||
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [showPwaOverlay, setShowPwaOverlay] = useState(false);
|
||||
|
||||
// 初始化 + 切屏事件
|
||||
// 屏幕生命周期
|
||||
useEffect(() => {
|
||||
dispatch({ type: "ChatInit" });
|
||||
dispatch({ type: "ChatScreenVisible" });
|
||||
@@ -41,7 +48,7 @@ export function ChatScreen() {
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// 配额触发监听
|
||||
// 配额耗尽触发器
|
||||
const prevTriggerRef = useRef(state.quotaExceededTrigger);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -54,7 +61,7 @@ export function ChatScreen() {
|
||||
prevTriggerRef.current = state.quotaExceededTrigger;
|
||||
}, [state.quotaExceededTrigger]);
|
||||
|
||||
// 配额告警监听(剩余 = 5)
|
||||
// 配额警告(剩余 = 5)
|
||||
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -68,180 +75,43 @@ export function ChatScreen() {
|
||||
prevRemainingRef.current = state.guestRemainingQuota;
|
||||
}, [state.guestRemainingQuota, state.isGuest]);
|
||||
|
||||
// 滚动到底部
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [state.messages.length, state.isReplyingAI]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
<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}>
|
||||
<ChatHeader isGuest={state.isGuest} />
|
||||
|
||||
<main ref={scrollRef} className={styles.area}>
|
||||
<AiDisclosure />
|
||||
{state.messages.map((m, i) => (
|
||||
<MessageBubble key={`m-${i}`} message={m} />
|
||||
))}
|
||||
{state.isReplyingAI && <LoadingBubble />}
|
||||
</main>
|
||||
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={onSubmit}
|
||||
<ChatArea
|
||||
messages={state.messages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
isGuest={state.isGuest}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={quotaDialogOpen}
|
||||
onClose={() => setQuotaDialogOpen(false)}
|
||||
ariaLabel={quotaExhausted ? "Quota exhausted" : "Running low"}
|
||||
>
|
||||
<h2 className={styles.dialogTitle}>
|
||||
{quotaExhausted ? "Quota exhausted" : "Running low"}
|
||||
</h2>
|
||||
<p>
|
||||
{quotaExhausted
|
||||
? "You've used all your free messages. Please sign in to continue."
|
||||
: "You have a few messages left. Sign in to keep chatting."}
|
||||
</p>
|
||||
<div className={styles.dialogActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotaDialogOpen(false)}
|
||||
className={styles.dialogSecondary}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuotaDialogOpen(false);
|
||||
window.location.href = "/auth";
|
||||
}}
|
||||
className={styles.dialogPrimary}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{showPwaOverlay && (
|
||||
<PwaInstallOverlay onClose={() => setShowPwaOverlay(false)} />
|
||||
)}
|
||||
<ChatInputBar disabled={state.isReplyingAI} />
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatHeader({ isGuest }: { isGuest: boolean }) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>cozsweet</h1>
|
||||
<span className={styles.subtitle}>
|
||||
{isGuest ? "Guest mode" : "Signed in"}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
{/* 浏览器提示(应用内浏览器检测) */}
|
||||
<BrowserHintOverlay />
|
||||
|
||||
function AiDisclosure() {
|
||||
return (
|
||||
<div className={styles.aiDisclosure}>
|
||||
You're chatting with an AI companion.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||
<PwaInstallOverlay />
|
||||
|
||||
function MessageBubble({ message }: { message: UiMessage }) {
|
||||
const isAI = message.isFromAI;
|
||||
return (
|
||||
<div
|
||||
className={isAI ? styles.bubbleRowAi : styles.bubbleRowUser}
|
||||
aria-label={isAI ? "AI message" : "User message"}
|
||||
>
|
||||
{message.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={message.imageUrl}
|
||||
alt=""
|
||||
className={styles.bubbleImage}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={isAI ? styles.bubbleAi : styles.bubbleUser}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
<time className={styles.bubbleTime}>{message.date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBubble() {
|
||||
return (
|
||||
<div className={styles.bubbleRowAi} aria-label="AI typing">
|
||||
<div className={styles.bubbleAi}>
|
||||
<span className={styles.typingDot} />
|
||||
<span className={styles.typingDot} />
|
||||
<span className={styles.typingDot} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInputBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<form className={styles.inputBar} onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Say something…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label="Message"
|
||||
{/* 配额告警弹窗 */}
|
||||
<QuotaDialog
|
||||
open={quotaDialogOpen}
|
||||
onClose={() => setQuotaDialogOpen(false)}
|
||||
isExhausted={quotaExhausted}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.sendButton}
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PwaInstallOverlay({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className={styles.overlay} role="dialog" aria-label="Install app">
|
||||
<div className={styles.overlayCard}>
|
||||
<h2>Install cozsweet</h2>
|
||||
<p>Add to home screen for the best experience.</p>
|
||||
<button type="button" onClick={onClose}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user