feat(chat): port chat widget components from Dart to React

This commit is contained in:
2026-06-09 20:19:52 +08:00
parent 199a14650e
commit 109a3e3855
48 changed files with 2463 additions and 382 deletions
+57 -187
View File
@@ -1,38 +1,45 @@
"use client";
/**
* ChatScreen 编排层
*
* 原始 Dart: lib/ui/chat/chat_screen.dart172 行)
*
* 职责(本文件):
* - 屏幕生命周期事件(init / visible / invisible
* - 配额告警监听(quotaExceededTrigger + warningThreshold
* - 渲染骨架(背景 + 顶部栏 + 消息区 + 输入栏)
*
* 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
* - ChatArea:消息列表
* - ChatInputBar:输入栏(文字 + 发送 + More)
* - QuotaDialog:配额告警弹窗
* - PwaInstallOverlayPWA 安装提示触发器
* - 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&apos;re chatting with an AI companion.
</div>
);
}
{/* PWA 安装提示触发器(无可见 UI3.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>
);
}