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
@@ -0,0 +1,208 @@
.shell {
display: flex;
flex-direction: column;
height: 100dvh;
background: var(--color-dark-background, #111);
color: var(--color-text-primary, #fff);
position: relative;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
border-bottom: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08));
background: var(--color-header-background, rgba(0, 0, 0, 0.3));
}
.title {
font-size: var(--font-size-lg, 18px);
font-weight: 600;
margin: 0;
}
.subtitle {
font-size: var(--font-size-sm, 12px);
opacity: 0.7;
}
.area {
flex: 1 1 auto;
overflow-y: auto;
padding: var(--spacing-4, 16px);
display: flex;
flex-direction: column;
gap: var(--spacing-3, 12px);
}
.aiDisclosure {
align-self: center;
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
border-radius: var(--radius-md, 8px);
padding: var(--spacing-2, 8px) var(--spacing-4, 16px);
font-size: var(--font-size-sm, 12px);
opacity: 0.75;
text-align: center;
}
.bubbleRowAi {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-1, 4px);
}
.bubbleRowUser {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-1, 4px);
}
.bubbleAi {
max-width: 75%;
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
border-radius: var(--radius-lg, 12px);
font-size: var(--font-size-base, 14px);
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.bubbleUser {
max-width: 75%;
background: var(--color-bubble-user, #4f46e5);
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
border-radius: var(--radius-lg, 12px);
font-size: var(--font-size-base, 14px);
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
color: #fff;
}
.bubbleImage {
max-width: 220px;
max-height: 220px;
border-radius: var(--radius-md, 8px);
object-fit: cover;
}
.bubbleTime {
font-size: var(--font-size-xs, 11px);
opacity: 0.5;
}
.typingDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
margin: 0 2px;
animation: typingBlink 1.2s infinite;
}
.typingDot:nth-child(2) { animation-delay: 0.15s; }
.typingDot:nth-child(3) { animation-delay: 0.3s; }
@keyframes typingBlink {
0%, 60%, 100% { opacity: 0.2; }
30% { opacity: 1; }
}
.inputBar {
display: flex;
align-items: center;
gap: var(--spacing-2, 8px);
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12));
background: var(--color-input-bar-background, rgba(0, 0, 0, 0.2));
}
.input {
flex: 1 1 auto;
height: 40px;
padding: 0 var(--spacing-3, 12px);
border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2));
background: transparent;
color: inherit;
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-base, 14px);
outline: none;
}
.input:focus {
border-color: var(--color-accent, #4f46e5);
}
.sendButton {
height: 40px;
padding: 0 var(--spacing-4, 16px);
background: var(--color-accent, #4f46e5);
color: #fff;
border: 0;
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-base, 14px);
font-weight: 600;
cursor: pointer;
}
.sendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-2, 8px);
margin-top: var(--spacing-4, 16px);
}
.dialogTitle {
margin: 0 0 var(--spacing-2, 8px);
font-size: var(--font-size-lg, 18px);
font-weight: 600;
}
.dialogPrimary,
.dialogSecondary {
height: 36px;
padding: 0 var(--spacing-4, 16px);
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-base, 14px);
font-weight: 600;
border: 0;
cursor: pointer;
}
.dialogPrimary {
background: var(--color-accent, #4f46e5);
color: #fff;
}
.dialogSecondary {
background: transparent;
color: inherit;
border: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.2));
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 10;
}
.overlayCard {
background: var(--color-surface, #1f1f1f);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-5, 20px);
max-width: 320px;
}
+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>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { ChatScreen } from "@/components/chat/chat-screen";
import { ChatScreen } from "@/app/chat/components/chat-screen";
export default function ChatPage() {
return <ChatScreen />;