refactor(chat): align chat header and input bar with Dart source

This commit is contained in:
2026-06-10 11:59:53 +08:00
parent 1e5b7420d3
commit 1db3e3ae31
17 changed files with 183 additions and 688 deletions
+19 -28
View File
@@ -6,14 +6,15 @@
* *
* 两种模式: * 两种模式:
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth * - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → 打开 ChatMenuBar * - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar
*
* 注意:菜单按钮直接 push 到侧边栏路由,**不**打开下拉菜单
* Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。
*/ */
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { ChatMenuBar } from "./chat-menu-bar";
import styles from "./chat-header.module.css"; import styles from "./chat-header.module.css";
export interface ChatHeaderProps { export interface ChatHeaderProps {
@@ -22,7 +23,6 @@ export interface ChatHeaderProps {
export function ChatHeader({ isGuest }: ChatHeaderProps) { export function ChatHeader({ isGuest }: ChatHeaderProps) {
const router = useRouter(); const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
if (isGuest) { if (isGuest) {
return ( return (
@@ -43,29 +43,20 @@ export function ChatHeader({ isGuest }: ChatHeaderProps) {
} }
return ( return (
<> <header className={styles.header}>
<header className={styles.header}> <div className={styles.headerRow}>
<div className={styles.headerRow}> {/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */}
{/* 菜单按钮(点击 → 打开 ChatMenuBar 浮层) */} <button
<button type="button"
type="button" className={styles.menuButton}
className={styles.menuButton} onClick={() => router.push(ROUTES.sidebar)}
onClick={() => setMenuOpen(true)} aria-label="Menu"
aria-label="Menu" >
aria-expanded={menuOpen} <span className={styles.menuIcon} aria-hidden="true">
>
<span className={styles.menuIcon} aria-hidden="true"> </span>
</button>
</span> </div>
</button> </header>
</div>
</header>
<ChatMenuBar
open={menuOpen}
onClose={() => setMenuOpen(false)}
environmentName="dev"
/>
</>
); );
} }
@@ -1,35 +1,32 @@
/* ChatInputBar 输入栏容器样式 */ /* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* 外层:focused 时加 padding-top + 粉色背景 + 阴影 */
.bar { .bar {
flex: 0 0 auto; flex: 0 0 auto;
padding: var(--spacing-3, 12px) var(--spacing-5, 20px) var(--spacing-5, 20px); padding: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px);
background: var(--color-input-bar-background, rgba(0, 0, 0, 0.4)); background: transparent;
backdrop-filter: blur(8px); transition: padding-top 0.2s ease, background-color 0.2s ease,
border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12)); box-shadow 0.2s ease;
display: flex;
flex-direction: column;
gap: var(--spacing-2, 8px);
} }
.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 { .row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-2, 8px); gap: var(--spacing-sm, 8px);
padding: var(--spacing-1, 4px) var(--spacing-3, 12px); padding: var(--spacing-sm, 8px);
background: var(--color-bubble-background, #fff); background: #fff;
border-radius: 9999px; border-radius: 32px;
border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2)); border: 1px solid transparent;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s ease;
} }
.rowFocused { .rowFocused {
border-color: var(--color-accent, #f84d96); 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;
} }
+31 -49
View File
@@ -4,25 +4,28 @@
* *
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart121 行) * 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart121 行)
* *
* 组成(从左到右) * 状态
* - ChatMoreButton(⊞):展开更多选项(图片/语音/功能)
* - ChatInputTextField:文本输入
* - ChatSendButton(↑):发送
*
* 状态管理(自包含):
* - input:受控文本 * - input:受控文本
* - focused:是否聚焦(视觉状态 * - isFocused:是否聚焦(控制外层 pink-bg / box-shadow / padding-top
* - hasContent:是否有内容(决定发送按钮启用) * - 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 { useChatDispatch } from "@/stores/chat/chat-context";
import { ChatInputTextField } from "./chat-input-text-field"; import { ChatInputTextField } from "./chat-input-text-field";
import { ChatMoreButton } from "./chat-more-button";
import { ChatSendButton } from "./chat-send-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"; import styles from "./chat-input-bar.module.css";
export interface ChatInputBarProps { export interface ChatInputBarProps {
@@ -32,8 +35,8 @@ export interface ChatInputBarProps {
export function ChatInputBar({ disabled = false }: ChatInputBarProps) { export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch(); const dispatch = useChatDispatch();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [focused, setFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [showFunctions, setShowFunctions] = useState(false); const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasContent = input.trim().length > 0; const hasContent = input.trim().length > 0;
@@ -41,45 +44,24 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
if (!hasContent) return; if (!hasContent) return;
dispatch({ type: "ChatSendMessage", content: input }); dispatch({ type: "ChatSendMessage", content: input });
setInput(""); setInput("");
textareaRef.current?.focus();
}; };
return ( return (
<div className={styles.bar}> <div className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}>
{showFunctions && ( <div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
<FunctionButtonBar <ChatInputTextField
items={[ ref={textareaRef}
{ value={input}
key: "image", onChange={setInput}
icon: "🖼", onSubmit={handleSend}
label: "Image", onFocusChange={setIsFocused}
// ImageUploadButton 内部已管理 sheetFunctionButtonBar 仅触发 disabled={disabled}
onClick: () => { />
/* 触发:ImageUploadButton 已在 ChatInputBar 中渲染 */ <ChatSendButton
}, disabled={!hasContent || disabled}
}, onClick={handleSend}
{ 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>
</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 { .textField {
flex: 1 1 auto;
min-width: 0;
width: 100%; width: 100%;
height: 40px; min-height: 40px;
padding: 0 var(--spacing-4, 16px); max-height: 120px; /* 约 5 行 × 24px line-height */
padding: 0;
border: 0; border: 0;
outline: none; outline: none;
background: transparent; background: transparent;
color: var(--color-text-foreground, #000); color: var(--color-text-foreground, #000);
font-size: var(--font-size-base, 14px); font-size: var(--font-size-base, 14px);
line-height: 24px;
resize: none; resize: none;
font-family: inherit; font-family: inherit;
} }
@@ -5,12 +5,22 @@
* 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart75 行) * 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart75 行)
* *
* 行为: * 行为:
* - 多行输入(最多 5 行 * - 多行输入(min 1 行 / max 5 行 ≈ max-height 120px
* - Enter 直接发送(无 Shift/Ctrl * - Enter 直接发送(无 Shift/Ctrl/Meta
* - Shift+Enter / Ctrl+Enter 换行 * - Shift+Enter / Ctrl+Enter / Meta+Enter 换行
* - 受控 + 非受控:value + onChange * - 外层白底圆角容器(与 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"; import styles from "./chat-input-text-field.module.css";
@@ -18,19 +28,32 @@ export interface ChatInputTextFieldProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onSubmit: () => void; onSubmit: () => void;
/** 焦点变化回调(用于父组件 ChatInputBar 切换聚焦态视觉) */
onFocusChange?: (focused: boolean) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
} }
export function ChatInputTextField({ export const ChatInputTextField = forwardRef<
value, HTMLTextAreaElement,
onChange, ChatInputTextFieldProps
onSubmit, >(function ChatInputTextField(
placeholder = "Say something…", {
disabled = false, value,
autoFocus = false, onChange,
}: ChatInputTextFieldProps) { 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>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行 if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
@@ -39,18 +62,23 @@ export function ChatInputTextField({
}; };
return ( return (
<textarea <div className={styles.wrap}>
className={styles.textField} <textarea
value={value} ref={innerRef}
onChange={(e: FormEvent<HTMLTextAreaElement>) => className={styles.textField}
onChange(e.currentTarget.value) value={value}
} onChange={(e: FormEvent<HTMLTextAreaElement>) =>
onKeyDown={handleKeyDown} onChange(e.currentTarget.value)
placeholder={placeholder} }
disabled={disabled} onKeyDown={handleKeyDown}
autoFocus={autoFocus} onFocus={() => onFocusChange?.(true)}
rows={1} onBlur={() => onFocusChange?.(false)}
aria-label="Message" 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;
}
-138
View File
@@ -1,138 +0,0 @@
"use client";
/**
* ChatMenuBar 菜单栏(悬浮下拉菜单)
*
* 原始 Dart: lib/ui/chat/widgets/chat_menu_bar.dart224 行,原始文件全部注释)
* 原始设计:横向 Row[环境 / 语言 / 登录] 三个胶囊按钮
*
* 本轮采用 TS 现代版:**悬浮下拉菜单**
* - 由 ChatHeader 的 ☰ 按钮触发
* - 右上方弹出 menu(环境/语言/设置/退出登录)
* - 点击外部 / 选项 → 关闭
*
* 优势:
* - 占用更少屏幕空间(与原 Dart 三个胶囊按钮对比)
* - 与 ChatHeader 的 ☰ 按钮天然配合
* - 易于扩展更多菜单项
*/
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { ROUTES } from "@/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.dart29 行)
*
* 用途:展开图片上传 / 语音输入 / 功能按钮等更多选项
* 第 2 轮:仅占位(点击无操作或打开菜单)
*/
import styles from "./chat-more-button.module.css";
export interface ChatMoreButtonProps {
onClick: () => void;
}
export function ChatMoreButton({ onClick }: ChatMoreButtonProps) {
return (
<button
type="button"
className={styles.button}
onClick={onClick}
aria-label="More options"
>
<span className={styles.icon} aria-hidden="true">
</span>
</button>
);
}
+39 -35
View File
@@ -24,6 +24,8 @@ import Image from "next/image";
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 { MobileShell } from "@/app/_components/core/mobile-shell";
import { BrowserHintOverlay } from "./browser-hint-overlay"; import { BrowserHintOverlay } from "./browser-hint-overlay";
import { ChatArea } from "./chat-area"; import { ChatArea } from "./chat-area";
import { ChatHeader } from "./chat-header"; import { ChatHeader } from "./chat-header";
@@ -76,42 +78,44 @@ export function ChatScreen() {
}, [state.guestRemainingQuota, state.isGuest]); }, [state.guestRemainingQuota, state.isGuest]);
return ( return (
<div className={styles.shell}> <MobileShell>
{/* 背景图(与原 Dart MobileLayout 一致) */} <div className={styles.shell}>
<div className={styles.background}> {/* 背景图(与原 Dart MobileLayout 一致) */}
<Image <div className={styles.background}>
src="/images/chat/bg-chatpage.png" <Image
alt="" src="/images/chat/bg-chatpage.png"
fill alt=""
priority fill
sizes="(max-width: 500px) 100vw, 500px" 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 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay />
{/* 配额告警弹窗 */}
<QuotaDialog
open={quotaDialogOpen}
onClose={() => setQuotaDialogOpen(false)}
isExhausted={quotaExhausted}
/> />
</div> </div>
</MobileShell>
<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 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay />
{/* 配额告警弹窗 */}
<QuotaDialog
open={quotaDialogOpen}
onClose={() => setQuotaDialogOpen(false)}
isExhausted={quotaExhausted}
/>
</div>
); );
} }
@@ -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.dart52 行)
*
* 横向滚动按钮组:Restart / History / Call / 等
* 每个按钮:图标 + 标签
*/
import styles from "./function-button-bar.module.css";
export interface FunctionButtonItem {
key: string;
icon: string; // emoji 占位(生产环境可换为 react-icons / svg
label: string;
onClick?: () => void;
}
export interface FunctionButtonBarProps {
items?: readonly FunctionButtonItem[];
}
const DEFAULT_ITEMS: readonly FunctionButtonItem[] = [
{ key: "restart", icon: "↻", label: "Restart" },
{ key: "history", icon: "⏱", label: "History" },
{ key: "call", icon: "📞", label: "Call" },
];
export function FunctionButtonBar({
items = DEFAULT_ITEMS,
}: FunctionButtonBarProps) {
return (
<div className={styles.bar} role="toolbar" aria-label="Chat functions">
{items.map((it) => (
<button
key={it.key}
type="button"
className={styles.button}
onClick={it.onClick}
>
<span className={styles.icon} aria-hidden="true">
{it.icon}
</span>
<span>{it.label}</span>
</button>
))}
</div>
);
}
@@ -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.dart159 行)
*
* 原 Dart 使用 image_picker 库(移动端原生相册/相机)
* 本轮使用 Web File API`<input type="file">`)以避免添加原生依赖
*
* 行为:
* - 点击按钮 → 弹出底部 sheet(相册 / 取消)
* - 选择图片 → 读取为 base64 → dispatch ChatSendImage
* - 上传中按钮显示加载指示器
*/
import { useRef, useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import styles from "./image-upload-button.module.css";
export interface ImageUploadButtonProps {
disabled?: boolean;
}
export function ImageUploadButton({ disabled = false }: ImageUploadButtonProps) {
const dispatch = useChatDispatch();
const fileInputRef = useRef<HTMLInputElement>(null);
const [showSheet, setShowSheet] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleFile = async (file: File) => {
if (isUploading) return;
setIsUploading(true);
setShowSheet(false);
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = window.btoa(binary);
const ext = file.name.split(".").pop()?.toLowerCase() || "png";
const mime = file.type || `image/${ext}`;
const dataUri = `data:${mime};base64,${base64}`;
dispatch({ type: "ChatSendImage", imageBase64: dataUri });
} catch (e) {
console.error("[ImageUpload] failed:", e);
} finally {
setIsUploading(false);
}
};
return (
<>
<button
type="button"
className={styles.button}
disabled={disabled || isUploading}
onClick={() => setShowSheet(true)}
aria-label="Upload image"
>
{isUploading ? "⏳" : "🖼"}
</button>
{/* 隐藏的文件 input(触发文件选择) */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.hidden}
onChange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) void handleFile(file);
e.currentTarget.value = ""; // 允许重选同一文件
}}
/>
{/* 底部选择 sheet(相册/取消) */}
{showSheet && (
<>
<div
style={{
position: "fixed",
inset: 0,
zIndex: 49,
background: "rgba(0,0,0,0.4)",
}}
onClick={() => setShowSheet(false)}
/>
<div className={styles.bottomSheet} role="dialog">
<button
type="button"
className={styles.sheetItem}
onClick={() => fileInputRef.current?.click()}
>
<span className={styles.sheetIcon} aria-hidden="true">
🖼
</span>
<span>Choose from gallery</span>
</button>
<button
type="button"
className={styles.sheetItem}
onClick={() => setShowSheet(false)}
>
<span className={styles.sheetIcon} aria-hidden="true">
</span>
<span>Cancel</span>
</button>
</div>
</>
)}
</>
);
}
+1 -5
View File
@@ -5,9 +5,8 @@
// 屏幕 + 顶层 // 屏幕 + 顶层
export { ChatScreen } from "./chat-screen"; export { ChatScreen } from "./chat-screen";
// 顶部 + 菜单 // 顶部
export { ChatHeader } from "./chat-header"; export { ChatHeader } from "./chat-header";
export { ChatMenuBar } from "./chat-menu-bar";
// 消息区 // 消息区
export { ChatArea } from "./chat-area"; export { ChatArea } from "./chat-area";
@@ -16,9 +15,6 @@ export { ChatArea } from "./chat-area";
export { ChatInputBar } from "./chat-input-bar"; export { ChatInputBar } from "./chat-input-bar";
export { ChatInputTextField } from "./chat-input-text-field"; export { ChatInputTextField } from "./chat-input-text-field";
export { ChatSendButton } from "./chat-send-button"; 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 { MessageBubble } from "./message-bubble";
@@ -1,5 +1,5 @@
/* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */ /* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */
/.hidden { .hidden {
display: none; display: none;
} }
+1
View File
@@ -1,3 +1,4 @@
"use client";
/** /**
* SpAsyncUtil — 键值对持久化工具类(基于 unstorage) * SpAsyncUtil — 键值对持久化工具类(基于 unstorage)
* *