refactor: relocate components to app directory structure

This commit is contained in:
2026-06-09 14:43:10 +08:00
parent f060301c24
commit cda55c8f9b
61 changed files with 19 additions and 27 deletions
+247
View File
@@ -0,0 +1,247 @@
"use client";
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 "@/contexts/chat/chat-context";
import { GuestChatQuota } from "@/contexts/chat/chat-types";
import type { UiMessage } from "@/models/chat/ui-message";
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" });
return () => {
dispatch({ type: "ChatScreenInvisible" });
};
}, [dispatch]);
// 配额触发监听
const prevTriggerRef = useRef(state.quotaExceededTrigger);
useEffect(() => {
if (
state.quotaExceededTrigger !== prevTriggerRef.current &&
state.quotaExceededTrigger > 0
) {
setQuotaExhausted(true);
setQuotaDialogOpen(true);
}
prevTriggerRef.current = state.quotaExceededTrigger;
}, [state.quotaExceededTrigger]);
// 配额告警监听(剩余 = 5
const prevRemainingRef = useRef(state.guestRemainingQuota);
useEffect(() => {
if (
state.isGuest &&
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
prevRemainingRef.current !== state.guestRemainingQuota
) {
setQuotaExhausted(false);
setQuotaDialogOpen(true);
}
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}>
<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}
/>
<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)} />
)}
</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>
);
}
function AiDisclosure() {
return (
<div className={styles.aiDisclosure}>
You&apos;re chatting with an AI companion.
</div>
);
}
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"
/>
<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>
);
}