feat(chat): port chat widget components from Dart to React
This commit is contained in:
@@ -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.dart(17 行)
|
||||||
|
*
|
||||||
|
* 视觉:居中灰色小字 "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.dart(76 行)
|
||||||
|
*
|
||||||
|
* 用途:在应用内浏览器(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.dart(49 行)
|
||||||
|
*
|
||||||
|
* 视觉:模糊背景卡片,灰色字体,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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* ChatArea 消息列表区
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/chat_area.dart(153 行)
|
||||||
|
*
|
||||||
|
* 组成(自上而下):
|
||||||
|
* - 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* ChatHeader 顶部栏
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/chat_header.dart(112 行)
|
||||||
|
*
|
||||||
|
* 两种模式:
|
||||||
|
* - 游客模式(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.dart(121 行)
|
||||||
|
*
|
||||||
|
* 组成(从左到右):
|
||||||
|
* - 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 内部已管理 sheet;FunctionButtonBar 仅触发
|
||||||
|
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.dart(75 行)
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 多行输入(最多 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* ChatMenuBar 菜单栏(悬浮下拉菜单)
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/chat_menu_bar.dart(224 行,原始文件全部注释)
|
||||||
|
* 原始设计:横向 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.dart(29 行)
|
||||||
|
*
|
||||||
|
* 用途:展开图片上传 / 语音输入 / 功能按钮等更多选项
|
||||||
|
* 第 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* ChatScreen 编排层样式(仅 shell 容器 + 背景) */
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -8,201 +10,18 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.background {
|
||||||
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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
z-index: 0;
|
||||||
align-items: center;
|
pointer-events: none;
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlayCard {
|
.layout {
|
||||||
background: var(--color-surface, #1f1f1f);
|
position: relative;
|
||||||
border-radius: var(--radius-lg, 12px);
|
z-index: 1;
|
||||||
padding: var(--spacing-5, 20px);
|
display: flex;
|
||||||
max-width: 320px;
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
"use client";
|
"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 { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
import { GuestChatQuota } from "@/stores/chat/chat-types";
|
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";
|
import styles from "./chat-screen.module.css";
|
||||||
|
|
||||||
/**
|
|
||||||
* 聊天屏幕
|
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/chat/chat_screen.dart
|
|
||||||
*
|
|
||||||
* 组成(自上而下):
|
|
||||||
* - ChatHeader(标题 + 菜单)
|
|
||||||
* - ChatArea(消息列表 + AI 披露 + 日期分隔 + 加载动画)
|
|
||||||
* - ChatInputBar(输入框 + 发送 / 语音 / 菜单)
|
|
||||||
* - QuotaDialog(游客配额告警弹窗)
|
|
||||||
* - PwaInstallOverlay / BrowserHintOverlay(占位)
|
|
||||||
*/
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const dispatch = useChatDispatch();
|
const dispatch = useChatDispatch();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
|
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
|
||||||
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [showPwaOverlay, setShowPwaOverlay] = useState(false);
|
|
||||||
|
|
||||||
// 初始化 + 切屏事件
|
// 屏幕生命周期
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: "ChatInit" });
|
dispatch({ type: "ChatInit" });
|
||||||
dispatch({ type: "ChatScreenVisible" });
|
dispatch({ type: "ChatScreenVisible" });
|
||||||
@@ -41,7 +48,7 @@ export function ChatScreen() {
|
|||||||
};
|
};
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// 配额触发监听
|
// 配额耗尽触发器
|
||||||
const prevTriggerRef = useRef(state.quotaExceededTrigger);
|
const prevTriggerRef = useRef(state.quotaExceededTrigger);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -54,7 +61,7 @@ export function ChatScreen() {
|
|||||||
prevTriggerRef.current = state.quotaExceededTrigger;
|
prevTriggerRef.current = state.quotaExceededTrigger;
|
||||||
}, [state.quotaExceededTrigger]);
|
}, [state.quotaExceededTrigger]);
|
||||||
|
|
||||||
// 配额告警监听(剩余 = 5)
|
// 配额警告(剩余 = 5)
|
||||||
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -68,180 +75,43 @@ export function ChatScreen() {
|
|||||||
prevRemainingRef.current = state.guestRemainingQuota;
|
prevRemainingRef.current = state.guestRemainingQuota;
|
||||||
}, [state.guestRemainingQuota, state.isGuest]);
|
}, [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 (
|
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} />
|
<ChatHeader isGuest={state.isGuest} />
|
||||||
|
|
||||||
<main ref={scrollRef} className={styles.area}>
|
<ChatArea
|
||||||
<AiDisclosure />
|
messages={state.messages}
|
||||||
{state.messages.map((m, i) => (
|
isReplyingAI={state.isReplyingAI}
|
||||||
<MessageBubble key={`m-${i}`} message={m} />
|
isGuest={state.isGuest}
|
||||||
))}
|
|
||||||
{state.isReplyingAI && <LoadingBubble />}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<ChatInputBar
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<ChatInputBar disabled={state.isReplyingAI} />
|
||||||
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>
|
</div>
|
||||||
</MobileShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatHeader({ isGuest }: { isGuest: boolean }) {
|
{/* 浏览器提示(应用内浏览器检测) */}
|
||||||
return (
|
<BrowserHintOverlay />
|
||||||
<header className={styles.header}>
|
|
||||||
<h1 className={styles.title}>cozsweet</h1>
|
|
||||||
<span className={styles.subtitle}>
|
|
||||||
{isGuest ? "Guest mode" : "Signed in"}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AiDisclosure() {
|
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||||
return (
|
<PwaInstallOverlay />
|
||||||
<div className={styles.aiDisclosure}>
|
|
||||||
You're chatting with an AI companion.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageBubble({ message }: { message: UiMessage }) {
|
{/* 配额告警弹窗 */}
|
||||||
const isAI = message.isFromAI;
|
<QuotaDialog
|
||||||
return (
|
open={quotaDialogOpen}
|
||||||
<div
|
onClose={() => setQuotaDialogOpen(false)}
|
||||||
className={isAI ? styles.bubbleRowAi : styles.bubbleRowUser}
|
isExhausted={quotaExhausted}
|
||||||
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>
|
</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.dart(37 行)
|
||||||
|
*
|
||||||
|
* 视觉:
|
||||||
|
* - 默认:粉色背景 + 渐变边缘
|
||||||
|
* - 聚焦 / 激活:完整 primaryGradient(accent → 透明)
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* DateHeader 日期分隔条
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/date_header.dart(26 行)
|
||||||
|
*
|
||||||
|
* 视觉:居中灰色 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.dart(76 行)
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 全屏黑色背景 + 图片居中(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.dart(52 行)
|
||||||
|
*
|
||||||
|
* 横向滚动按钮组: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* ImageBubble 图片气泡
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/image_bubble.dart(63 行)
|
||||||
|
*
|
||||||
|
* 支持:
|
||||||
|
* - 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 URI(PNG 兜底)
|
||||||
|
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.dart(159 行)
|
||||||
|
*
|
||||||
|
* 原 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.dart(222 行)
|
||||||
|
*
|
||||||
|
* 简化版:单语种列表选择(保留可扩展性)
|
||||||
|
* - 支持未来添加更多语言
|
||||||
|
* - 当前选中状态高亮
|
||||||
|
* - 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.dart(51 行)
|
||||||
|
*
|
||||||
|
* 原始实现:使用 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.dart(85 行)
|
||||||
|
*
|
||||||
|
* 显示规则:
|
||||||
|
* - 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.dart(52 行)
|
||||||
|
*
|
||||||
|
* 组成(从左到右):
|
||||||
|
* - 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.dart(43 行)
|
||||||
|
*
|
||||||
|
* 决定渲染 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.dart(145 行)
|
||||||
|
*
|
||||||
|
* 用途:引导用户将 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.dart(76 行)
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 检查 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* QuotaDialog 配额提示弹窗
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/quota_dialog.dart(130 行)
|
||||||
|
*
|
||||||
|
* 两种模式:
|
||||||
|
* - 警告模式(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* TextBubble 文字气泡
|
||||||
|
*
|
||||||
|
* 原始 Dart: lib/ui/chat/widgets/text_bubble.dart(41 行)
|
||||||
|
*
|
||||||
|
* 视觉差异:
|
||||||
|
* - 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user