refactor(chat): align chat header and input bar with Dart source
This commit is contained in:
@@ -6,14 +6,15 @@
|
||||
*
|
||||
* 两种模式:
|
||||
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth)
|
||||
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → 打开 ChatMenuBar)
|
||||
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar)
|
||||
*
|
||||
* 注意:菜单按钮直接 push 到侧边栏路由,**不**打开下拉菜单
|
||||
* (Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。
|
||||
*/
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { ChatMenuBar } from "./chat-menu-bar";
|
||||
import styles from "./chat-header.module.css";
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
@@ -22,7 +23,6 @@ export interface ChatHeaderProps {
|
||||
|
||||
export function ChatHeader({ isGuest }: ChatHeaderProps) {
|
||||
const router = useRouter();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
if (isGuest) {
|
||||
return (
|
||||
@@ -43,29 +43,20 @@ export function ChatHeader({ isGuest }: ChatHeaderProps) {
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerRow}>
|
||||
{/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
onClick={() => router.push(ROUTES.sidebar)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<span className={styles.menuIcon} aria-hidden="true">
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
/* ChatInputBar 输入栏容器样式 */
|
||||
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
|
||||
|
||||
/* 外层:focused 时加 padding-top + 粉色背景 + 阴影 */
|
||||
.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);
|
||||
padding: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px);
|
||||
background: transparent;
|
||||
transition: padding-top 0.2s ease, background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.barFocused {
|
||||
padding-top: var(--spacing-md, 12px);
|
||||
background: #feeff2;
|
||||
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* 内层:白底 + 大圆角(Dart AppRadius.radius32)+ focused 时 accent 边框 */
|
||||
.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;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: var(--spacing-sm, 8px);
|
||||
background: #fff;
|
||||
border-radius: 32px;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,28 @@
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart(121 行)
|
||||
*
|
||||
* 组成(从左到右):
|
||||
* - ChatMoreButton(⊞):展开更多选项(图片/语音/功能)
|
||||
* - ChatInputTextField:文本输入
|
||||
* - ChatSendButton(↑):发送
|
||||
*
|
||||
* 状态管理(自包含):
|
||||
* 状态:
|
||||
* - input:受控文本
|
||||
* - focused:是否聚焦(视觉状态)
|
||||
* - hasContent:是否有内容(决定发送按钮启用)
|
||||
* - isFocused:是否聚焦(控制外层 pink-bg / box-shadow / padding-top)
|
||||
* - hasContent:是否有 trim 内容(决定发送按钮是否启用)
|
||||
*
|
||||
* 容器层次(与 Dart 一致):
|
||||
* - 外层 `.bar`:focused 时 `padding-top: md` + 粉色背景 + 阴影
|
||||
* - 内层 `.row`:白底 + `border-radius: 32` + focused 时 accent 边框
|
||||
* - Row:[ChatInputTextField (Expanded), ChatSendButton]
|
||||
*
|
||||
* 行为:
|
||||
* - send 后清空 input + 保持 textarea 焦点(与 Dart `_focusNode.requestFocus()` 一致)
|
||||
*
|
||||
* 注意:Dart 源里的 `ImageUploadButton` 已被注释(`// const ImageUploadButton()`),
|
||||
* `ChatMoreButton` / `FunctionButtonBar` 不存在于 Dart 源;本组件仅保留与 Dart 一致的部分。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRef, 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 {
|
||||
@@ -32,8 +35,8 @@ export interface ChatInputBarProps {
|
||||
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
const dispatch = useChatDispatch();
|
||||
const [input, setInput] = useState("");
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [showFunctions, setShowFunctions] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const hasContent = input.trim().length > 0;
|
||||
|
||||
@@ -41,45 +44,24 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||
if (!hasContent) return;
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
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.bar} ${isFocused ? styles.barFocused : ""}`}>
|
||||
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
||||
<ChatInputTextField
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSend}
|
||||
onFocusChange={setIsFocused}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ChatSendButton
|
||||
disabled={!hasContent || disabled}
|
||||
onClick={handleSend}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
/* ChatInputTextField 输入框样式 */
|
||||
/* ChatInputTextField 输入框样式(外层白底圆角容器 + 内部 textarea) */
|
||||
|
||||
/* 外层圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致) */
|
||||
.wrap {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent; /* 容器透明,背景由 .row 提供 */
|
||||
}
|
||||
|
||||
/* textarea 本身(裸元素,背景透明让父容器透出) */
|
||||
.textField {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-4, 16px);
|
||||
min-height: 40px;
|
||||
max-height: 120px; /* 约 5 行 × 24px line-height */
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-foreground, #000);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
line-height: 24px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,22 @@
|
||||
* 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart(75 行)
|
||||
*
|
||||
* 行为:
|
||||
* - 多行输入(最多 5 行)
|
||||
* - Enter 直接发送(无 Shift/Ctrl)
|
||||
* - Shift+Enter / Ctrl+Enter 换行
|
||||
* - 受控 + 非受控:value + onChange
|
||||
* - 多行输入(min 1 行 / max 5 行 ≈ max-height 120px)
|
||||
* - Enter 直接发送(无 Shift/Ctrl/Meta)
|
||||
* - Shift+Enter / Ctrl+Enter / Meta+Enter 换行
|
||||
* - 外层白底圆角容器(与 Dart `Container(borderRadius: AppRadius.xxxl ≈ 32)` 一致)
|
||||
*
|
||||
* 通过 `forwardRef` 暴露 textarea 节点,父组件可在 send 后调用 `.focus()`
|
||||
* (对齐 Dart `_focusNode.requestFocus()`)。
|
||||
* 通过 `onFocusChange` 回调通知父组件焦点状态,用于外层容器的聚焦样式切换。
|
||||
*/
|
||||
import { type FormEvent, type KeyboardEvent } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import styles from "./chat-input-text-field.module.css";
|
||||
|
||||
@@ -18,19 +28,32 @@ export interface ChatInputTextFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
/** 焦点变化回调(用于父组件 ChatInputBar 切换聚焦态视觉) */
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function ChatInputTextField({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Say something…",
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
}: ChatInputTextFieldProps) {
|
||||
export const ChatInputTextField = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
ChatInputTextFieldProps
|
||||
>(function ChatInputTextField(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onFocusChange,
|
||||
placeholder = "Say something…",
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const innerRef = useRef<HTMLTextAreaElement>(null);
|
||||
// 对外暴露与 forwardRef 同名的 ref(指向内部 textarea 节点)
|
||||
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
|
||||
@@ -39,18 +62,23 @@ export function ChatInputTextField({
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
<div className={styles.wrap}>
|
||||
<textarea
|
||||
ref={innerRef}
|
||||
className={styles.textField}
|
||||
value={value}
|
||||
onChange={(e: FormEvent<HTMLTextAreaElement>) =>
|
||||
onChange(e.currentTarget.value)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => onFocusChange?.(true)}
|
||||
onBlur={() => onFocusChange?.(false)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
rows={1}
|
||||
aria-label="Message"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"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 "@/router/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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import Image from "next/image";
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
import { GuestChatQuota } from "@/stores/chat/chat-types";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
|
||||
import { BrowserHintOverlay } from "./browser-hint-overlay";
|
||||
import { ChatArea } from "./chat-area";
|
||||
import { ChatHeader } from "./chat-header";
|
||||
@@ -76,42 +78,44 @@ export function ChatScreen() {
|
||||
}, [state.guestRemainingQuota, state.isGuest]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
<MobileShell>
|
||||
<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} />
|
||||
|
||||
<ChatArea
|
||||
messages={state.messages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
isGuest={state.isGuest}
|
||||
/>
|
||||
|
||||
<ChatInputBar disabled={state.isReplyingAI} />
|
||||
</div>
|
||||
|
||||
{/* 浏览器提示(应用内浏览器检测) */}
|
||||
<BrowserHintOverlay />
|
||||
|
||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||
<PwaInstallOverlay />
|
||||
|
||||
{/* 配额告警弹窗 */}
|
||||
<QuotaDialog
|
||||
open={quotaDialogOpen}
|
||||
onClose={() => setQuotaDialogOpen(false)}
|
||||
isExhausted={quotaExhausted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.layout}>
|
||||
<ChatHeader isGuest={state.isGuest} />
|
||||
|
||||
<ChatArea
|
||||
messages={state.messages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
isGuest={state.isGuest}
|
||||
/>
|
||||
|
||||
<ChatInputBar disabled={state.isReplyingAI} />
|
||||
</div>
|
||||
|
||||
{/* 浏览器提示(应用内浏览器检测) */}
|
||||
<BrowserHintOverlay />
|
||||
|
||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||
<PwaInstallOverlay />
|
||||
|
||||
{/* 配额告警弹窗 */}
|
||||
<QuotaDialog
|
||||
open={quotaDialogOpen}
|
||||
onClose={() => setQuotaDialogOpen(false)}
|
||||
isExhausted={quotaExhausted}
|
||||
/>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
// 屏幕 + 顶层
|
||||
export { ChatScreen } from "./chat-screen";
|
||||
|
||||
// 顶部 + 菜单
|
||||
// 顶部
|
||||
export { ChatHeader } from "./chat-header";
|
||||
export { ChatMenuBar } from "./chat-menu-bar";
|
||||
|
||||
// 消息区
|
||||
export { ChatArea } from "./chat-area";
|
||||
@@ -16,9 +15,6 @@ 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";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */
|
||||
|
||||
/.hidden {
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client";
|
||||
/**
|
||||
* SpAsyncUtil — 键值对持久化工具类(基于 unstorage)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user