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
+6 -15
View File
@@ -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,16 +43,14 @@ export function ChatHeader({ isGuest }: ChatHeaderProps) {
}
return (
<>
<header className={styles.header}>
<div className={styles.headerRow}>
{/* 菜单按钮(点击 → 打开 ChatMenuBar 浮层 */}
{/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致 */}
<button
type="button"
className={styles.menuButton}
onClick={() => setMenuOpen(true)}
onClick={() => router.push(ROUTES.sidebar)}
aria-label="Menu"
aria-expanded={menuOpen}
>
<span className={styles.menuIcon} aria-hidden="true">
@@ -60,12 +58,5 @@ export function ChatHeader({ isGuest }: ChatHeaderProps) {
</button>
</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 {
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;
}
+26 -44
View File
@@ -4,25 +4,28 @@
*
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart121 行)
*
* 组成(从左到右)
* - 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 内部已管理 sheetFunctionButtonBar 仅触发
onClick: () => {
/* 触发:ImageUploadButton 已在 ChatInputBar 中渲染 */
},
},
{ key: "restart", icon: "↻", label: "Restart" },
{ key: "history", icon: "⏱", label: "History" },
{ key: "call", icon: "📞", label: "Call" },
]}
/>
)}
<div className={`${styles.row} ${focused ? styles.rowFocused : ""}`}>
<ImageUploadButton disabled={disabled} />
<ChatMoreButton onClick={() => setShowFunctions((v) => !v)} />
<div
className={styles.textFieldWrap}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
<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}
/>
</div>
<ChatSendButton disabled={!hasContent || disabled} onClick={handleSend} />
<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.dart75 行)
*
* 行为:
* - 多行输入(最多 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({
export const ChatInputTextField = forwardRef<
HTMLTextAreaElement,
ChatInputTextFieldProps
>(function ChatInputTextField(
{
value,
onChange,
onSubmit,
onFocusChange,
placeholder = "Say something…",
disabled = false,
autoFocus = false,
}: ChatInputTextFieldProps) {
},
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 (
<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;
}
-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>
);
}
+4
View File
@@ -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,6 +78,7 @@ export function ChatScreen() {
}, [state.guestRemainingQuota, state.isGuest]);
return (
<MobileShell>
<div className={styles.shell}>
{/* 背景图(与原 Dart MobileLayout 一致) */}
<div className={styles.background}>
@@ -113,5 +116,6 @@ export function ChatScreen() {
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.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 { 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
View File
@@ -1,3 +1,4 @@
"use client";
/**
* SpAsyncUtil — 键值对持久化工具类(基于 unstorage)
*