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
@@ -0,0 +1,9 @@
/* AiDisclosureBanner 样式(独立文件以便 chat-area 内联版本可复用) */
.banner {
align-self: center;
background: transparent;
font-size: var(--font-size-xs, 11px);
color: var(--color-text-banner, #8c8b8b);
text-align: center;
}
@@ -0,0 +1,18 @@
"use client";
/**
* AiDisclosureBanner AI 声明横幅
*
* 原始 Dart: lib/ui/chat/widgets/ai_disclosure_banner.dart17 行)
*
* 视觉:居中灰色小字 "All content is generated by AI"
* 用途:位于聊天区顶部,声明 AI 生成内容(合规 / 法律要求)
*/
import styles from "./chat-area.module.css";
export function AiDisclosureBanner() {
return (
<div className={styles.aiDisclosure} role="note">
All content is generated by AI
</div>
);
}
@@ -0,0 +1,29 @@
/* BrowserHintOverlay 浏览器提示样式 */
.overlay {
position: absolute;
top: var(--spacing-2, 8px);
right: var(--spacing-2, 8px);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-1, 4px);
z-index: 20;
pointer-events: none;
}
.emoji {
font-size: 24px;
line-height: 1;
}
.hintBubble {
padding: var(--spacing-2, 8px) var(--spacing-3, 12px);
background: var(--color-blur-background, rgba(13, 11, 20, 0.85));
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-sm, 12px);
font-weight: 500;
color: #fff;
backdrop-filter: blur(10px);
max-width: 200px;
}
@@ -0,0 +1,57 @@
"use client";
/**
* BrowserHintOverlay 浏览器提示
*
* 原始 Dart: lib/ui/chat/widgets/browser_hint_overlay.dart76 行)
*
* 用途:在应用内浏览器(WebView、微信内置浏览器等)中显示提示,
* 引导用户使用外部浏览器访问。
*
* 行为:
* - 开发环境始终显示(测试方便)
* - 生产环境仅在应用内浏览器中显示
* - 视觉:右上角 👆 + 模糊背景气泡
*/
import { useEffect, useState } from "react";
import styles from "./browser-hint-overlay.module.css";
export interface BrowserHintOverlayProps {
/** 自定义提示文本 */
text?: string;
/** 强制显示(开发模式测试用) */
forceShow?: boolean;
}
const DEFAULT_TEXT = "Tap ⋮ menu, then 'Open in browser'";
function detectInAppBrowser(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
// 常见应用内浏览器 UA 特征
return /MicroMessenger|Weibo|QQ\//.test(ua);
}
export function BrowserHintOverlay({
text = DEFAULT_TEXT,
forceShow = false,
}: BrowserHintOverlayProps) {
const [isInAppBrowser, setIsInAppBrowser] = useState(false);
useEffect(() => {
setIsInAppBrowser(detectInAppBrowser());
}, []);
// 开发模式或应用内浏览器才显示
const shouldShow = forceShow || isInAppBrowser;
if (!shouldShow) return null;
return (
<div className={styles.overlay} role="status" aria-live="polite">
<span className={styles.emoji} aria-hidden="true">
👆
</span>
<div className={styles.hintBubble}>{text}</div>
</div>
);
}
@@ -0,0 +1,17 @@
/* CharacterIntro 角色介绍样式 */
.intro {
padding: var(--spacing-2, 8px) var(--spacing-5, 20px);
}
.card {
padding: var(--spacing-3, 12px);
background: var(--color-bubble-background, rgba(255, 255, 255, 0.05));
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-sm, 14px);
line-height: 1.5;
color: var(--color-text-secondary, #9e9e9e);
max-height: 7.5em; /* 5 行 max */
overflow: hidden;
backdrop-filter: blur(10px);
}
@@ -0,0 +1,40 @@
"use client";
/**
* CharacterIntro 角色介绍卡片
*
* 原始 Dart: lib/ui/chat/widgets/character_intro.dart49 行)
*
* 视觉:模糊背景卡片,灰色字体,5 行 max
* 用途:首次进入聊天时展示 AI 角色介绍(Elio 设定)
*/
import styles from "./character-intro.module.css";
export interface CharacterIntroProps {
/** 角色介绍文本 */
text?: string;
/** 最大行数(默认 5 */
maxLines?: number;
}
const DEFAULT_TEXT =
"Meet Elio Silvestri, your exclusive AI boyfriend. " +
"He's charming, witty, and always there for you. " +
"Chat with him anytime, anywhere — about your day, your dreams, or anything in between.";
export function CharacterIntro({ text, maxLines = 5 }: CharacterIntroProps) {
return (
<div className={styles.intro} role="complementary" aria-label="Character intro">
<div
className={styles.card}
style={{
// 近似 -webkit-line-clamp(多行截断)
display: "-webkit-box",
WebkitLineClamp: maxLines,
WebkitBoxOrient: "vertical",
}}
>
{text ?? DEFAULT_TEXT}
</div>
</div>
);
}
@@ -0,0 +1,105 @@
/* ChatArea 消息列表样式 */
.area {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
display: flex;
flex-direction: column;
gap: var(--spacing-3, 12px);
}
.dateHeader {
display: flex;
align-items: center;
justify-content: center;
margin: var(--spacing-2, 8px) 0;
}
.dateChip {
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
border-radius: var(--radius-md, 8px);
padding: var(--spacing-1, 4px) var(--spacing-3, 12px);
font-size: var(--font-size-xs, 11px);
opacity: 0.75;
}
.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;
}
.bubbleTime {
font-size: var(--font-size-xs, 11px);
opacity: 0.5;
}
.typingBubble {
display: flex;
align-items: center;
gap: var(--spacing-1, 4px);
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-lg, 12px);
width: fit-content;
}
.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; }
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
/**
* ChatArea 消息列表区
*
* 原始 Dart: lib/ui/chat/widgets/chat_area.dart153 行)
*
* 组成(自上而下):
* - AI 披露横幅
* - 日期分隔条
* - 消息气泡(AI 左 / 用户右)
* - AI 正在回复时的加载动画气泡
*
* 滚动行为:
* - 新消息到达时自动滚到底部
* - 用户向上滚动时不强制拉回(保持阅读上下文)
*/
import { useEffect, useRef } from "react";
import type { UiMessage } from "@/models/chat/ui-message";
import { DateHeader } from "./date-header";
import { LottieMessageBubble } from "./lottie-message-bubble";
import { MessageBubble } from "./message-bubble";
import styles from "./chat-area.module.css";
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
isGuest: boolean;
}
export function ChatArea({ messages, isReplyingAI, isGuest }: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
// 新消息 → 滚到底部
useEffect(() => {
if (messages.length !== prevLengthRef.current) {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
prevLengthRef.current = messages.length;
}
}, [messages.length]);
return (
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
<AiDisclosure />
{messages.length === 0 && !isReplyingAI && (
<EmptyState isGuest={isGuest} />
)}
{renderMessagesWithDateHeaders(messages)}
{isReplyingAI && <LottieMessageBubble />}
</main>
);
}
/** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
messages.forEach((m, i) => {
if (i === 0 || m.date !== messages[i - 1].date) {
items.push({ type: "date", date: m.date, key: `d-${i}-${m.date}` });
}
items.push({ type: "msg", message: m, key: `m-${i}` });
});
return items.map((item) =>
item.type === "date" ? (
<DateHeader key={item.key} date={item.date} />
) : (
<MessageBubble
key={item.key}
content={item.message.content}
imageUrl={item.message.imageUrl}
isFromAI={item.message.isFromAI}
/>
),
);
}
function AiDisclosure() {
return (
<div className={styles.aiDisclosure}>
You're chatting with an AI companion.
</div>
);
}
function EmptyState({ isGuest }: { isGuest: boolean }) {
return (
<div className={styles.aiDisclosure}>
{isGuest
? "Say hi to start chatting. (Guest mode · 40 free messages)"
: "Say hi to start chatting."}
</div>
);
}
@@ -0,0 +1,56 @@
/* ChatHeader 顶部栏样式 */
.header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
background: var(--color-header-background, rgba(0, 0, 0, 0.3));
}
.guestBanner {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2, 8px);
padding: var(--spacing-2, 8px) var(--spacing-4, 16px);
background: var(--color-accent, #f84d96);
color: #fff;
font-size: var(--font-size-sm, 12px);
font-weight: 500;
cursor: pointer;
border: 0;
text-align: center;
}
.guestBannerIcon {
width: var(--icon-size-sm, 16px);
height: var(--icon-size-sm, 16px);
}
.headerRow {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--spacing-4, 16px);
}
.menuButton {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2, 8px);
background: var(--color-dark-background, #111)cc;
border-radius: 9999px;
border: 0;
cursor: pointer;
color: var(--color-text-primary, #fff);
}
.menuIcon {
width: var(--icon-size-xl, 32px);
height: var(--icon-size-xl, 32px);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* ChatHeader 顶部栏
*
* 原始 Dart: lib/ui/chat/widgets/chat_header.dart112 行)
*
* 两种模式:
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → 打开 ChatMenuBar
*/
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ROUTES } from "@/lib/routes";
import { ChatMenuBar } from "./chat-menu-bar";
import styles from "./chat-header.module.css";
export interface ChatHeaderProps {
isGuest: boolean;
}
export function ChatHeader({ isGuest }: ChatHeaderProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
if (isGuest) {
return (
<header className={styles.header}>
<button
type="button"
className={styles.guestBanner}
onClick={() => router.push(ROUTES.auth)}
aria-label="Sign up to unlock more features"
>
<span className={styles.guestBannerIcon} aria-hidden="true">
🔒
</span>
<span>Sign up to unlock more features</span>
</button>
</header>
);
}
return (
<>
<header className={styles.header}>
<div className={styles.headerRow}>
{/* 菜单按钮(点击 → 打开 ChatMenuBar 浮层) */}
<button
type="button"
className={styles.menuButton}
onClick={() => setMenuOpen(true)}
aria-label="Menu"
aria-expanded={menuOpen}
>
<span className={styles.menuIcon} aria-hidden="true">
</span>
</button>
</div>
</header>
<ChatMenuBar
open={menuOpen}
onClose={() => setMenuOpen(false)}
environmentName="dev"
/>
</>
);
}
@@ -0,0 +1,35 @@
/* ChatInputBar 输入栏容器样式 */
.bar {
flex: 0 0 auto;
padding: var(--spacing-3, 12px) var(--spacing-5, 20px) var(--spacing-5, 20px);
background: var(--color-input-bar-background, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(8px);
border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12));
display: flex;
flex-direction: column;
gap: var(--spacing-2, 8px);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-2, 8px);
padding: var(--spacing-1, 4px) var(--spacing-3, 12px);
background: var(--color-bubble-background, #fff);
border-radius: 9999px;
border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2));
transition: border-color 0.2s, box-shadow 0.2s;
}
.rowFocused {
border-color: var(--color-accent, #f84d96);
box-shadow: 0 4px 12px var(--color-input-shadow, rgba(0, 0, 0, 0.1));
}
.textFieldWrap {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
}
@@ -0,0 +1,86 @@
"use client";
/**
* ChatInputBar 输入栏
*
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart121 行)
*
* 组成(从左到右):
* - ChatMoreButton(⊞):展开更多选项(图片/语音/功能)
* - ChatInputTextField:文本输入
* - ChatSendButton(↑):发送
*
* 状态管理(自包含):
* - input:受控文本
* - focused:是否聚焦(视觉状态)
* - hasContent:是否有内容(决定发送按钮启用)
*/
import { useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { ChatInputTextField } from "./chat-input-text-field";
import { ChatMoreButton } from "./chat-more-button";
import { ChatSendButton } from "./chat-send-button";
import { FunctionButtonBar } from "./function-button-bar";
import { ImageUploadButton } from "./image-upload-button";
import styles from "./chat-input-bar.module.css";
export interface ChatInputBarProps {
disabled?: boolean;
}
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch();
const [input, setInput] = useState("");
const [focused, setFocused] = useState(false);
const [showFunctions, setShowFunctions] = useState(false);
const hasContent = input.trim().length > 0;
const handleSend = () => {
if (!hasContent) return;
dispatch({ type: "ChatSendMessage", content: input });
setInput("");
};
return (
<div className={styles.bar}>
{showFunctions && (
<FunctionButtonBar
items={[
{
key: "image",
icon: "🖼",
label: "Image",
// ImageUploadButton 内部已管理 sheetFunctionButtonBar 仅触发
onClick: () => {
/* 触发:ImageUploadButton 已在 ChatInputBar 中渲染 */
},
},
{ key: "restart", icon: "↻", label: "Restart" },
{ key: "history", icon: "⏱", label: "History" },
{ key: "call", icon: "📞", label: "Call" },
]}
/>
)}
<div className={`${styles.row} ${focused ? styles.rowFocused : ""}`}>
<ImageUploadButton disabled={disabled} />
<ChatMoreButton onClick={() => setShowFunctions((v) => !v)} />
<div
className={styles.textFieldWrap}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
<ChatInputTextField
value={input}
onChange={setInput}
onSubmit={handleSend}
disabled={disabled}
/>
</div>
<ChatSendButton disabled={!hasContent || disabled} onClick={handleSend} />
</div>
</div>
);
}
@@ -0,0 +1,18 @@
/* ChatInputTextField 输入框样式 */
.textField {
width: 100%;
height: 40px;
padding: 0 var(--spacing-4, 16px);
border: 0;
outline: none;
background: transparent;
color: var(--color-text-foreground, #000);
font-size: var(--font-size-base, 14px);
resize: none;
font-family: inherit;
}
.textField::placeholder {
color: var(--color-text-hint, #757575);
}
@@ -0,0 +1,56 @@
"use client";
/**
* ChatInputTextField 输入框
*
* 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart75 行)
*
* 行为:
* - 多行输入(最多 5 行)
* - Enter 直接发送(无 Shift/Ctrl
* - Shift+Enter / Ctrl+Enter 换行
* - 受控 + 非受控:value + onChange
*/
import { type FormEvent, type KeyboardEvent } from "react";
import styles from "./chat-input-text-field.module.css";
export interface ChatInputTextFieldProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}
export function ChatInputTextField({
value,
onChange,
onSubmit,
placeholder = "Say something…",
disabled = false,
autoFocus = false,
}: ChatInputTextFieldProps) {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return;
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
e.preventDefault();
onSubmit();
};
return (
<textarea
className={styles.textField}
value={value}
onChange={(e: FormEvent<HTMLTextAreaElement>) =>
onChange(e.currentTarget.value)
}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
rows={1}
aria-label="Message"
/>
);
}
@@ -0,0 +1,69 @@
/* ChatMenuBar 菜单栏样式 */
.overlay {
position: fixed;
inset: 0;
z-index: 60;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.menu {
position: absolute;
top: 56px;
right: 12px;
min-width: 200px;
background: var(--color-surface, #1f1a2e);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md, 8px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
padding: var(--spacing-1, 4px) 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.item {
display: flex;
align-items: center;
gap: var(--spacing-3, 12px);
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
background: transparent;
border: 0;
border-radius: 0;
color: var(--color-text-primary, #fff);
font-size: var(--font-size-base, 14px);
text-align: left;
cursor: pointer;
width: 100%;
}
.item:hover,
.item:focus-visible {
background: rgba(255, 255, 255, 0.08);
}
.itemIcon {
font-size: 18px;
width: 20px;
text-align: center;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: var(--spacing-1, 4px) 0;
}
.badge {
margin-left: auto;
font-size: var(--font-size-xs, 11px);
padding: 2px 6px;
background: var(--color-accent, #f84d96);
color: #fff;
border-radius: 9999px;
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
/**
* ChatMenuBar 菜单栏(悬浮下拉菜单)
*
* 原始 Dart: lib/ui/chat/widgets/chat_menu_bar.dart224 行,原始文件全部注释)
* 原始设计:横向 Row[环境 / 语言 / 登录] 三个胶囊按钮
*
* 本轮采用 TS 现代版:**悬浮下拉菜单**
* - 由 ChatHeader 的 ☰ 按钮触发
* - 右上方弹出 menu(环境/语言/设置/退出登录)
* - 点击外部 / 选项 → 关闭
*
* 优势:
* - 占用更少屏幕空间(与原 Dart 三个胶囊按钮对比)
* - 与 ChatHeader 的 ☰ 按钮天然配合
* - 易于扩展更多菜单项
*/
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { ROUTES } from "@/lib/routes";
import { SpAsyncUtil } from "@/utils/storage";
import { useAuthDispatch } from "@/stores/auth/auth-context";
import { useUserDispatch } from "@/stores/user/user-context";
import styles from "./chat-menu-bar.module.css";
export interface ChatMenuBarProps {
open: boolean;
onClose: () => void;
/** 当前环境名(开发/测试/生产),用于显示在菜单中 */
environmentName?: string;
}
export interface ChatMenuItem {
key: string;
icon: string;
label: string;
badge?: string;
onClick: () => void;
danger?: boolean;
}
export function ChatMenuBar({
open,
onClose,
environmentName = "dev",
}: ChatMenuBarProps) {
const router = useRouter();
const authDispatch = useAuthDispatch();
const userDispatch = useUserDispatch();
const menuRef = useRef<HTMLDivElement>(null);
// ESC 关闭
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const items: ChatMenuItem[] = [
{
key: "language",
icon: "🌐",
label: "Language",
onClick: () => {
// 触发语言切换弹窗(占位:alert)
window.alert("Language dialog: see Round 5 LanguageDialog");
onClose();
},
},
{
key: "environment",
icon: "⚙️",
label: `Environment: ${environmentName}`,
onClick: () => {
window.alert("Environment switcher: see Round 5");
onClose();
},
},
{ key: "divider1", icon: "", label: "", onClick: () => {} },
{
key: "logout",
icon: "🚪",
label: "Sign out",
danger: true,
onClick: async () => {
// 清空本地存储 + 触发 logout 事件
await SpAsyncUtil.clear();
authDispatch({ type: "AuthReset" });
userDispatch({ type: "UserUpdate", user: null as never });
onClose();
router.push(ROUTES.splash);
},
},
];
return (
<div className={styles.overlay}>
<div
className={styles.backdrop}
onClick={onClose}
aria-hidden="true"
/>
<div
ref={menuRef}
className={styles.menu}
role="menu"
aria-orientation="vertical"
>
{items.map((item) =>
item.key.startsWith("divider") ? (
<div key={item.key} className={styles.divider} />
) : (
<button
key={item.key}
type="button"
className={styles.item}
onClick={item.onClick}
role="menuitem"
>
<span className={styles.itemIcon} aria-hidden="true">
{item.icon}
</span>
<span>{item.label}</span>
{item.badge && <span className={styles.badge}>{item.badge}</span>}
</button>
),
)}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
/* ChatMoreButton "更多"按钮 */
.button {
flex: 0 0 auto;
width: 40px;
height: 40px;
border: 0;
border-radius: 9999px;
background: var(--color-bubble-transparent, rgba(255, 255, 255, 0));
color: var(--color-text-primary, #fff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon {
width: 24px;
height: 24px;
font-size: 24px;
line-height: 1;
}
@@ -0,0 +1,29 @@
"use client";
/**
* ChatMoreButton "更多"按钮
*
* 原始 Dart: lib/ui/chat/widgets/chat_more_button.dart29 行)
*
* 用途:展开图片上传 / 语音输入 / 功能按钮等更多选项
* 第 2 轮:仅占位(点击无操作或打开菜单)
*/
import styles from "./chat-more-button.module.css";
export interface ChatMoreButtonProps {
onClick: () => void;
}
export function ChatMoreButton({ onClick }: ChatMoreButtonProps) {
return (
<button
type="button"
className={styles.button}
onClick={onClick}
aria-label="More options"
>
<span className={styles.icon} aria-hidden="true">
</span>
</button>
);
}
+12 -193
View File
@@ -1,3 +1,5 @@
/* ChatScreen 编排层样式(仅 shell 容器 + 背景) */
.shell {
display: flex;
flex-direction: column;
@@ -8,201 +10,18 @@
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 {
.background {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 10;
z-index: 0;
pointer-events: none;
}
.overlayCard {
background: var(--color-surface, #1f1f1f);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-5, 20px);
max-width: 320px;
.layout {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
+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>
);
}
@@ -0,0 +1,37 @@
/* ChatSendButton 发送按钮样式 */
.button {
flex: 0 0 auto;
width: 48px;
height: 40px;
border: 0;
border-radius: 9999px;
background: var(--color-button-gradient-end, #fc69df);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.1s;
}
.button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.button:focus-visible,
.buttonActive {
background: linear-gradient(
to right,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
}
.icon {
width: 24px;
height: 24px;
font-size: 24px;
line-height: 1;
}
@@ -0,0 +1,32 @@
"use client";
/**
* ChatSendButton 发送按钮
*
* 原始 Dart: lib/ui/chat/widgets/chat_send_button.dart37 行)
*
* 视觉:
* - 默认:粉色背景 + 渐变边缘
* - 聚焦 / 激活:完整 primaryGradientaccent → 透明)
*/
import styles from "./chat-send-button.module.css";
export interface ChatSendButtonProps {
disabled: boolean;
onClick: () => void;
}
export function ChatSendButton({ disabled, onClick }: ChatSendButtonProps) {
return (
<button
type="button"
className={`${styles.button} ${disabled ? "" : styles.buttonActive}`}
disabled={disabled}
onClick={onClick}
aria-label="Send message"
>
<span className={styles.icon} aria-hidden="true">
</span>
</button>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
/**
* DateHeader 日期分隔条
*
* 原始 Dart: lib/ui/chat/widgets/date_header.dart26 行)
*
* 视觉:居中灰色 chip"今天 14:32" / "昨天 09:15" / 具体日期)
*/
import styles from "./chat-area.module.css";
export interface DateHeaderProps {
date: string;
}
export function DateHeader({ date }: DateHeaderProps) {
return (
<div className={styles.dateHeader}>
<span className={styles.dateChip}>{date}</span>
</div>
);
}
@@ -0,0 +1,38 @@
/* FullscreenImageViewer 全屏图片查看器样式 */
.viewer {
position: fixed;
inset: 0;
z-index: 100;
background: #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.viewer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.loading {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--color-accent, #f84d96);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.error {
color: var(--color-text-secondary, #9e9e9e);
font-size: 48px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,49 @@
"use client";
/**
* FullscreenImageViewer 全屏图片查看器
*
* 原始 Dart: lib/ui/chat/widgets/fullscreen_image_viewer.dart76 行)
*
* 行为:
* - 全屏黑色背景 + 图片居中(contain)
* - 点击空白 / 下滑手势 → 关闭
* - 双指缩放(CSS `touch-action: pinch-zoom`
*/
import { useEffect } from "react";
import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps {
imageUrl: string;
onClose: () => void;
}
export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageViewerProps) {
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
return (
<div
className={styles.viewer}
onClick={onClose}
role="dialog"
aria-label="Fullscreen image"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl.startsWith("data:") || imageUrl.startsWith("http") ? imageUrl : `data:image/png;base64,${imageUrl}`}
alt=""
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
(e.currentTarget.parentElement as HTMLElement).innerHTML =
'<div class="error">🖼</div>';
}}
/>
</div>
);
}
@@ -0,0 +1,30 @@
/* FunctionButtonBar 功能按钮栏样式 */
.bar {
display: flex;
gap: var(--spacing-2, 8px);
padding: 0 var(--spacing-5, 20px) var(--spacing-2, 8px);
overflow-x: auto;
}
.button {
flex: 0 0 auto;
height: 36px;
padding: 0 var(--spacing-3, 12px);
border: 0;
border-radius: 9999px;
background: var(--color-button-background, rgba(255, 255, 255, 0.1));
color: var(--color-text-primary, #fff);
font-size: var(--font-size-sm, 12px);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--spacing-1, 4px);
}
.icon {
width: 16px;
height: 16px;
font-size: 16px;
line-height: 1;
}
@@ -0,0 +1,49 @@
"use client";
/**
* FunctionButtonBar 功能按钮栏
*
* 原始 Dart: lib/ui/chat/widgets/function_button_bar.dart52 行)
*
* 横向滚动按钮组:Restart / History / Call / 等
* 每个按钮:图标 + 标签
*/
import styles from "./function-button-bar.module.css";
export interface FunctionButtonItem {
key: string;
icon: string; // emoji 占位(生产环境可换为 react-icons / svg
label: string;
onClick?: () => void;
}
export interface FunctionButtonBarProps {
items?: readonly FunctionButtonItem[];
}
const DEFAULT_ITEMS: readonly FunctionButtonItem[] = [
{ key: "restart", icon: "↻", label: "Restart" },
{ key: "history", icon: "⏱", label: "History" },
{ key: "call", icon: "📞", label: "Call" },
];
export function FunctionButtonBar({
items = DEFAULT_ITEMS,
}: FunctionButtonBarProps) {
return (
<div className={styles.bar} role="toolbar" aria-label="Chat functions">
{items.map((it) => (
<button
key={it.key}
type="button"
className={styles.button}
onClick={it.onClick}
>
<span className={styles.icon} aria-hidden="true">
{it.icon}
</span>
<span>{it.label}</span>
</button>
))}
</div>
);
}
@@ -0,0 +1,28 @@
/* ImageBubble 图片气泡样式 */
.bubble {
max-width: 220px;
max-height: 220px;
border-radius: var(--radius-lg, 12px);
overflow: hidden;
cursor: pointer;
background: var(--color-bubble-background, #fff);
}
.image {
display: block;
width: 100%;
height: auto;
object-fit: cover;
}
.errorFallback {
display: flex;
align-items: center;
justify-content: center;
width: 220px;
height: 220px;
background: var(--color-bubble-background, #fff);
color: var(--color-text-secondary, #9e9e9e);
font-size: 24px;
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
/**
* ImageBubble 图片气泡
*
* 原始 Dart: lib/ui/chat/widgets/image_bubble.dart63 行)
*
* 支持:
* - base64 data URI 解码(`data:image/png;base64,...`
* - 点击 → 打开全屏查看器
* - 错误占位符(broken image icon
*/
import { useState } from "react";
import { FullscreenImageViewer } from "./fullscreen-image-viewer";
import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
imageUrl: string; // base64 data URI 或 URL
}
export function ImageBubble({ imageUrl }: ImageBubbleProps) {
const [open, setOpen] = useState(false);
const [error, setError] = useState(false);
const src = decodeBase64Image(imageUrl);
return (
<>
<div
className={styles.bubble}
onClick={() => setOpen(true)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
aria-label="Open image in fullscreen"
>
{error ? (
<div className={styles.errorFallback}>🖼</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.image}
src={src}
alt=""
onError={() => setError(true)}
/>
)}
</div>
{open && <FullscreenImageViewer imageUrl={imageUrl} onClose={() => setOpen(false)} />}
</>
);
}
/** 解析 base64 data URI`data:image/png;base64,...`),否则原样返回 */
function decodeBase64Image(uri: string): string {
// 已是 data URI 或 http(s) URL:直接返回
if (uri.startsWith("data:") || uri.startsWith("http")) {
return uri;
}
// 裸 base64 字符串 → 包装成 data URIPNG 兜底)
return `data:image/png;base64,${uri}`;
}
@@ -0,0 +1,62 @@
/* ImageUploadButton 图片上传按钮样式 */
.button {
flex: 0 0 auto;
width: 40px;
height: 40px;
border: 1px solid var(--color-bubble-border, rgba(255, 255, 255, 0.2));
border-radius: 9999px;
background: var(--color-bubble-transparent, rgba(255, 255, 255, 0));
color: var(--color-text-primary, #fff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden {
display: none;
}
.bottomSheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
background: var(--color-dark-surface, #1f1a2e);
border-top-left-radius: var(--radius-xl, 20px);
border-top-right-radius: var(--radius-xl, 20px);
padding: var(--spacing-4, 16px) 0;
padding-bottom: max(var(--spacing-4, 16px), env(safe-area-inset-bottom));
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
}
.sheetItem {
display: flex;
align-items: center;
gap: var(--spacing-3, 12px);
padding: var(--spacing-3, 12px) var(--spacing-5, 20px);
background: transparent;
border: 0;
color: var(--color-text-primary, #fff);
font-size: var(--font-size-base, 14px);
width: 100%;
text-align: left;
cursor: pointer;
}
.sheetItem:hover,
.sheetItem:focus-visible {
background: rgba(255, 255, 255, 0.05);
}
.sheetIcon {
font-size: 20px;
}
@@ -0,0 +1,119 @@
"use client";
/**
* ImageUploadButton 图片上传按钮
*
* 原始 Dart: lib/ui/chat/widgets/image_upload_button.dart159 行)
*
* 原 Dart 使用 image_picker 库(移动端原生相册/相机)
* 本轮使用 Web File API`<input type="file">`)以避免添加原生依赖
*
* 行为:
* - 点击按钮 → 弹出底部 sheet(相册 / 取消)
* - 选择图片 → 读取为 base64 → dispatch ChatSendImage
* - 上传中按钮显示加载指示器
*/
import { useRef, useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import styles from "./image-upload-button.module.css";
export interface ImageUploadButtonProps {
disabled?: boolean;
}
export function ImageUploadButton({ disabled = false }: ImageUploadButtonProps) {
const dispatch = useChatDispatch();
const fileInputRef = useRef<HTMLInputElement>(null);
const [showSheet, setShowSheet] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleFile = async (file: File) => {
if (isUploading) return;
setIsUploading(true);
setShowSheet(false);
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = window.btoa(binary);
const ext = file.name.split(".").pop()?.toLowerCase() || "png";
const mime = file.type || `image/${ext}`;
const dataUri = `data:${mime};base64,${base64}`;
dispatch({ type: "ChatSendImage", imageBase64: dataUri });
} catch (e) {
console.error("[ImageUpload] failed:", e);
} finally {
setIsUploading(false);
}
};
return (
<>
<button
type="button"
className={styles.button}
disabled={disabled || isUploading}
onClick={() => setShowSheet(true)}
aria-label="Upload image"
>
{isUploading ? "⏳" : "🖼"}
</button>
{/* 隐藏的文件 input(触发文件选择) */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.hidden}
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void handleFile(file);
e.currentTarget.value = ""; // 允许重选同一文件
}}
/>
{/* 底部选择 sheet(相册/取消) */}
{showSheet && (
<>
<div
style={{
position: "fixed",
inset: 0,
zIndex: 49,
background: "rgba(0,0,0,0.4)",
}}
onClick={() => setShowSheet(false)}
/>
<div className={styles.bottomSheet} role="dialog">
<button
type="button"
className={styles.sheetItem}
onClick={() => fileInputRef.current?.click()}
>
<span className={styles.sheetIcon} aria-hidden="true">
🖼
</span>
<span>Choose from gallery</span>
</button>
<button
type="button"
className={styles.sheetItem}
onClick={() => setShowSheet(false)}
>
<span className={styles.sheetIcon} aria-hidden="true">
</span>
<span>Cancel</span>
</button>
</div>
</>
)}
</>
);
}
+37 -2
View File
@@ -1,5 +1,40 @@
/**
* @file Automatically generated by barrelsby.
* Chat 公共组件导出
*/
export * from "./chat-screen";
// 屏幕 + 顶层
export { ChatScreen } from "./chat-screen";
// 顶部 + 菜单
export { ChatHeader } from "./chat-header";
export { ChatMenuBar } from "./chat-menu-bar";
// 消息区
export { ChatArea } from "./chat-area";
// 输入栏
export { ChatInputBar } from "./chat-input-bar";
export { ChatInputTextField } from "./chat-input-text-field";
export { ChatSendButton } from "./chat-send-button";
export { ChatMoreButton } from "./chat-more-button";
export { ImageUploadButton } from "./image-upload-button";
export { FunctionButtonBar } from "./function-button-bar";
// 消息气泡
export { MessageBubble } from "./message-bubble";
export { MessageContent } from "./message-content";
export { MessageAvatar } from "./message-avatar";
export { TextBubble } from "./text-bubble";
export { ImageBubble } from "./image-bubble";
export { LottieMessageBubble } from "./lottie-message-bubble";
export { DateHeader } from "./date-header";
export { FullscreenImageViewer } from "./fullscreen-image-viewer";
// 弹窗 / 浮层 / 卡片
export { QuotaDialog } from "./quota-dialog";
export { PwaInstallDialog } from "./pwa-install-dialog";
export { PwaInstallOverlay } from "./pwa-install-overlay";
export { BrowserHintOverlay } from "./browser-hint-overlay";
export { AiDisclosureBanner } from "./ai-disclosure-banner";
export { CharacterIntro } from "./character-intro";
export { LanguageDialog, DEFAULT_LANGUAGES } from "./language-dialog";
@@ -0,0 +1,66 @@
/* LanguageDialog 语言切换弹窗样式 */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.dialog {
width: calc(100% - 40px);
max-width: 360px;
background: var(--color-surface, #1f1a2e);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-5, 20px);
display: flex;
flex-direction: column;
gap: var(--spacing-3, 12px);
max-height: 80vh;
}
.title {
font-size: var(--font-size-lg, 18px);
font-weight: 600;
margin: 0;
color: var(--color-text-primary, #fff);
}
.list {
display: flex;
flex-direction: column;
gap: var(--spacing-1, 4px);
overflow-y: auto;
max-height: 60vh;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
background: transparent;
border: 0;
border-radius: var(--radius-md, 8px);
color: var(--color-text-primary, #fff);
font-size: var(--font-size-base, 14px);
text-align: left;
cursor: pointer;
}
.item:hover,
.item:focus-visible {
background: rgba(255, 255, 255, 0.05);
}
.itemActive {
background: rgba(248, 77, 150, 0.15);
}
.check {
color: var(--color-accent, #f84d96);
font-weight: 700;
}
@@ -0,0 +1,95 @@
"use client";
/**
* LanguageDialog 语言切换弹窗
*
* 原始 Dart: lib/ui/chat/widgets/language_dialog.dart222 行)
*
* 简化版:单语种列表选择(保留可扩展性)
* - 支持未来添加更多语言
* - 当前选中状态高亮
* - ESC / 点击外部 → 关闭
*/
import { useEffect } from "react";
import styles from "./language-dialog.module.css";
export interface LanguageItem {
code: string;
label: string;
nativeLabel: string;
}
export const DEFAULT_LANGUAGES: readonly LanguageItem[] = [
{ code: "en", label: "English", nativeLabel: "English" },
{ code: "zh", label: "Chinese (Simplified)", nativeLabel: "简体中文" },
{ code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" },
{ code: "ja", label: "Japanese", nativeLabel: "日本語" },
{ code: "ko", label: "Korean", nativeLabel: "한국어" },
{ code: "es", label: "Spanish", nativeLabel: "Español" },
{ code: "fr", label: "French", nativeLabel: "Français" },
{ code: "de", label: "German", nativeLabel: "Deutsch" },
];
export interface LanguageDialogProps {
open: boolean;
onClose: () => void;
currentLanguageCode?: string;
languages?: readonly LanguageItem[];
onSelect: (code: string) => void;
}
export function LanguageDialog({
open,
onClose,
currentLanguageCode = "en",
languages = DEFAULT_LANGUAGES,
onSelect,
}: LanguageDialogProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<h2 className={styles.title}>Select Language</h2>
<div className={styles.list} role="listbox">
{languages.map((lang) => {
const isActive = currentLanguageCode === lang.code;
return (
<button
key={lang.code}
type="button"
role="option"
aria-selected={isActive}
className={`${styles.item} ${isActive ? styles.itemActive : ""}`}
onClick={() => {
onSelect(lang.code);
onClose();
}}
>
<span>
{lang.nativeLabel}{" "}
<span style={{ opacity: 0.5 }}>({lang.label})</span>
</span>
{isActive && <span className={styles.check}></span>}
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
/* LottieMessageBubble 动画消息气泡(CSS keyframes 模拟) */
.bubbleRow {
display: flex;
align-items: center;
gap: var(--spacing-2, 8px);
padding: var(--spacing-1, 4px) 0;
}
.bubble {
display: flex;
align-items: center;
gap: 4px;
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
background: #fff;
border-radius: var(--radius-xxl, 24px);
border-top-left-radius: 0;
min-width: 60px;
min-height: 40px;
box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-text-foreground, #000);
animation: bounce 1.4s infinite ease-in-out both;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@@ -0,0 +1,28 @@
"use client";
/**
* LottieMessageBubble 动画消息气泡
*
* 原始 Dart: lib/ui/chat/widgets/lottie_message_bubble.dart51 行)
*
* 原始实现:使用 lottie SDK 渲染"打字指示器"动画
* 本轮实现:使用 CSS keyframes 模拟(无 lottie 依赖)
* - 3 个跳动的圆点(左右 + 中间)
* - 1.4s 周期,stagger 动画延迟
*
* 行为一致(视觉类似 lottie 的"三点跳跃"动画)
*/
import { MessageAvatar } from "./message-avatar";
import styles from "./lottie-message-bubble.module.css";
export function LottieMessageBubble() {
return (
<div className={styles.bubbleRow} role="status" aria-label="AI typing">
<MessageAvatar isFromAI={true} />
<div className={styles.bubble}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
</div>
);
}
@@ -0,0 +1,30 @@
/* MessageAvatar 头像样式 */
.avatar {
flex: 0 0 auto;
width: 43px;
height: 43px;
border-radius: 9999px;
overflow: hidden;
border: 2px solid var(--color-avatar-border, #fbf3f5);
box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
background: var(--color-avatar-border, #fbf3f5);
display: flex;
align-items: center;
justify-content: center;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
@@ -0,0 +1,55 @@
"use client";
/**
* MessageAvatar 消息头像
*
* 原始 Dart: lib/ui/chat/widgets/message_avatar.dart85 行)
*
* 显示规则:
* - AI 消息:Elio 头像(`/images/chat/pic-chat-elio.png`
* - 用户消息(有 avatarUrl):用户头像
* - 用户消息(无 avatarUrl):游客头像(`/images/chat/pic-chat-guest.png`
*/
import Image from "next/image";
import styles from "./message-avatar.module.css";
export interface MessageAvatarProps {
isFromAI: boolean;
userAvatarUrl?: string | null;
}
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
if (isFromAI) {
return (
<div className={styles.avatar} aria-label="AI avatar">
<Image
src="/images/chat/pic-chat-elio.png"
alt="Elio"
width={43}
height={43}
priority
/>
</div>
);
}
if (userAvatarUrl && userAvatarUrl.length > 0) {
return (
<div className={styles.avatar} aria-label="User avatar">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={userAvatarUrl} alt="" />
</div>
);
}
return (
<div className={styles.avatar} aria-label="Guest avatar">
<Image
src="/images/chat/pic-chat-guest.png"
alt="Guest"
width={43}
height={43}
/>
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
/**
* MessageBubble 消息气泡(容器)
*
* 原始 Dart: lib/ui/chat/widgets/message_bubble.dart52 行)
*
* 组成(从左到右):
* - AI[Avatar] [Content] [占位 spacer]
* - 用户:[占位 spacer] [Content] [Avatar]
*/
import { useUserState } from "@/stores/user/user-context";
import { MessageAvatar } from "./message-avatar";
import { MessageContent } from "./message-content";
import styles from "./chat-area.module.css";
export interface MessageBubbleProps {
content: string;
imageUrl?: string | null;
isFromAI: boolean;
}
export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProps) {
const { avatarUrl } = useUserState();
if (isFromAI) {
return (
<div className={styles.bubbleRowAi} aria-label="AI message">
<MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} />
<MessageContent
content={content}
imageUrl={imageUrl}
isFromAI={true}
/>
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
</div>
);
}
return (
<div className={styles.bubbleRowUser} aria-label="User message">
<div style={{ width: 43 }} /> {/* 占位 */}
<MessageContent content={content} imageUrl={imageUrl} isFromAI={false} />
<div style={{ width: 8 }} />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
/**
* MessageContent 消息内容容器
*
* 原始 Dart: lib/ui/chat/widgets/message_content.dart43 行)
*
* 决定渲染 ImageBubble 还是 TextBubble
* - 有 imageUrl:渲染 ImageBubble(图片)
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
* - 两者都有:图片在上,文字在下
*/
import { ImageBubble } from "./image-bubble";
import { TextBubble } from "./text-bubble";
export interface MessageContentProps {
content: string;
imageUrl?: string | null;
isFromAI: boolean;
}
const IMAGE_PLACEHOLDER = "[图片]";
export function MessageContent({ content, imageUrl, isFromAI }: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: isFromAI ? "flex-start" : "flex-end",
gap: 8,
maxWidth: "100%",
}}
>
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
</div>
);
}
@@ -0,0 +1,78 @@
/* PwaInstallDialog PWA 安装弹窗样式 */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
}
.dialog {
width: calc(100% - 40px);
max-width: 360px;
background: var(--color-surface, #1f1a2e);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-5, 20px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(248, 77, 150, 0.2);
color: var(--color-accent, #f84d96);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: var(--spacing-4, 16px);
}
.title {
font-size: var(--font-size-lg, 18px);
font-weight: 600;
margin: 0 0 var(--spacing-2, 8px);
color: var(--color-text-primary, #fff);
}
.content {
font-size: var(--font-size-sm, 14px);
line-height: 1.5;
color: var(--color-text-secondary, #9e9e9e);
margin: 0 0 var(--spacing-5, 20px);
}
.actions {
display: flex;
gap: var(--spacing-3, 12px);
width: 100%;
}
.btn {
flex: 1 1 auto;
height: 40px;
border: 0;
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-base, 14px);
font-weight: 600;
cursor: pointer;
}
.btnSecondary {
background: transparent;
color: var(--color-text-primary, #fff);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btnPrimary {
background: var(--color-accent, #f84d96);
color: #fff;
}
@@ -0,0 +1,67 @@
"use client";
/**
* PwaInstallDialog PWA 安装弹窗
*
* 原始 Dart: lib/ui/chat/widgets/pwa_install_dialog.dart145 行)
*
* 用途:引导用户将 Web App 安装到桌面
* 触发:由 PwaInstallOverlay 在延迟 3.5s 后自动显示
*/
import styles from "./pwa-install-dialog.module.css";
export interface PwaInstallDialogProps {
onClose: () => void;
onInstall?: () => void;
}
export function PwaInstallDialog({ onClose, onInstall }: PwaInstallDialogProps) {
const handleInstall = () => {
if (onInstall) {
onInstall();
} else {
// 提示用户使用浏览器"添加到主屏幕"功能
window.alert(
"To install: tap the browser menu, then 'Add to Home Screen'.",
);
}
onClose();
};
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="pwa-dialog-title"
>
<div className={styles.dialog}>
<div className={styles.icon} aria-hidden="true">
📱
</div>
<h2 id="pwa-dialog-title" className={styles.title}>
Install cozsweet
</h2>
<p className={styles.content}>
Add to your home screen for the best experience. Faster loading,
full-screen mode, and offline support.
</p>
<div className={styles.actions}>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Not now
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleInstall}
>
Install
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
/* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */
/.hidden {
display: none;
}
@@ -0,0 +1,87 @@
"use client";
/**
* PwaInstallOverlay PWA 安装提示触发器
*
* 原始 Dart: lib/ui/chat/widgets/pwa_install_overlay.dart76 行)
*
* 行为:
* - 检查 PWA 支持
* - 检查每日是否已显示(使用 AppStorage 持久化)
* - 延迟 3.5s 后弹出 PwaInstallDialog
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
*
* 注意:PWA install prompt API`beforeinstallprompt`)在浏览器差异较大,
* 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 Dialog 内部处理。
*/
import { useEffect } from "react";
import { SpAsyncUtil } from "@/utils/storage";
import { PwaInstallDialog } from "./pwa-install-dialog";
import styles from "./pwa-install-overlay.module.css";
const PWA_DIALOG_KEY = "cozsweet:lastPwaDialogShown";
export function PwaInstallOverlay() {
useEffect(() => {
let mounted = true;
const init = async () => {
// 检查每日是否已显示
const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY);
if (!lastResult.success) return;
const today = new Date().toISOString().slice(0, 10);
if (lastResult.data === today) return; // 今天已显示过
// 检查 PWA 支持
if (typeof window === "undefined") return;
if (!("serviceWorker" in navigator)) return;
// 延迟 3.5s 后显示
setTimeout(() => {
if (!mounted) return;
showPwaDialog();
}, 3500);
};
void init();
return () => {
mounted = false;
};
}, []);
return <div className={styles.hidden} aria-hidden="true" />;
}
function showPwaDialog() {
if (typeof document === "undefined") return;
// 记录显示时间
const today = new Date().toISOString().slice(0, 10);
void SpAsyncUtil.setString(PWA_DIALOG_KEY, today);
// 创建挂载点
const root = document.createElement("div");
document.body.appendChild(root);
// 用 React DOM 渲染(通过 import
import("react-dom/client").then(({ createRoot }) => {
const reactRoot = createRoot(root);
reactRoot.render(<PwaInstallDialogMount rootEl={root} reactRoot={reactRoot} />);
});
}
function PwaInstallDialogMount({
rootEl,
reactRoot,
}: {
rootEl: HTMLElement;
reactRoot: { unmount: () => void };
}) {
return (
<PwaInstallDialog
onClose={() => {
reactRoot.unmount();
rootEl.remove();
}}
/>
);
}
@@ -0,0 +1,85 @@
/* QuotaDialog 配额提示弹窗样式 */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.dialog {
width: calc(100% - 40px);
max-width: 360px;
background: var(--color-surface, #1f1a2e);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-5, 20px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.icon {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: var(--spacing-4, 16px);
}
.iconExhausted {
background: rgba(255, 80, 80, 0.2);
color: #ff5050;
}
.iconWarning {
background: rgba(248, 77, 150, 0.2);
color: var(--color-accent, #f84d96);
}
.title {
font-size: var(--font-size-lg, 18px);
font-weight: 600;
margin: 0 0 var(--spacing-2, 8px);
color: var(--color-text-primary, #fff);
}
.content {
font-size: var(--font-size-sm, 14px);
line-height: 1.5;
color: var(--color-text-secondary, #9e9e9e);
margin: 0 0 var(--spacing-5, 20px);
}
.actions {
display: flex;
gap: var(--spacing-3, 12px);
width: 100%;
}
.btn {
flex: 1 1 auto;
height: 40px;
border: 0;
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-base, 14px);
font-weight: 600;
cursor: pointer;
}
.btnSecondary {
background: transparent;
color: var(--color-text-primary, #fff);
}
.btnPrimary {
background: var(--color-accent, #f84d96);
color: #fff;
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
/**
* QuotaDialog 配额提示弹窗
*
* 原始 Dart: lib/ui/chat/widgets/quota_dialog.dart130 行)
*
* 两种模式:
* - 警告模式(isExhausted=false):剩余配额 < 5 时,提示用户登录
* - 耗尽模式(isExhausted=true):配额已用完,强制登录
*/
import { useRouter } from "next/navigation";
import { ROUTES } from "@/lib/routes";
import styles from "./quota-dialog.module.css";
export interface QuotaDialogProps {
open: boolean;
onClose: () => void;
isExhausted: boolean;
}
export function QuotaDialog({ open, onClose, isExhausted }: QuotaDialogProps) {
const router = useRouter();
if (!open) return null;
const title = isExhausted
? "Daily quota exhausted"
: "Login for unlimited access!";
const content = isExhausted
? "Login to continue chatting~"
: "Dear, sign up now and all your chats with Elio will be saved forever. In guest mode, your chat history will be deleted when you leave, and Elio will forget you.";
const handleLogin = () => {
onClose();
router.push(ROUTES.auth);
};
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="quota-dialog-title"
>
<div className={styles.dialog}>
<div
className={`${styles.icon} ${isExhausted ? styles.iconExhausted : styles.iconWarning}`}
aria-hidden="true"
>
{isExhausted ? "🚫" : "⚠️"}
</div>
<h2 id="quota-dialog-title" className={styles.title}>
{title}
</h2>
<p className={styles.content}>{content}</p>
<div className={styles.actions}>
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleLogin}
>
Go to Login
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
/* TextBubble 文字气泡样式 */
.bubble {
display: inline-block;
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
border-radius: var(--radius-xxl, 24px);
max-width: 75%;
font-size: var(--font-size-body-lg, 16px);
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
}
.bubbleAi {
background: #fff;
color: var(--color-text-foreground, #000);
border-top-left-radius: 0;
}
.bubbleUser {
background: linear-gradient(
to right,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: #fff;
border-top-right-radius: 0;
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
/**
* TextBubble 文字气泡
*
* 原始 Dart: lib/ui/chat/widgets/text_bubble.dart41 行)
*
* 视觉差异:
* - AI 消息:白底黑字 + 左上角尖角
* - 用户消息:渐变(primaryGradient+ 白字 + 右上角尖角
*/
import styles from "./text-bubble.module.css";
export interface TextBubbleProps {
content: string;
isFromAI: boolean;
}
export function TextBubble({ content, isFromAI }: TextBubbleProps) {
return (
<div
className={`${styles.bubble} ${isFromAI ? styles.bubbleAi : styles.bubbleUser}`}
>
{content}
</div>
);
}