125 lines
3.8 KiB
TypeScript
125 lines
3.8 KiB
TypeScript
"use client";
|
||
/**
|
||
* ChatInputBar 输入栏
|
||
*
|
||
* 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart(121 行)
|
||
*
|
||
* 状态:
|
||
* - input:受控文本
|
||
* - 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 { useRef, useState } from "react";
|
||
|
||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||
import { useUserState } from "@/stores/user/user-context";
|
||
import { Logger } from "@/utils";
|
||
|
||
import { ChatInputActionPanel } from "./chat-input-action-panel";
|
||
import { ChatInputTextField } from "./chat-input-text-field";
|
||
import { ChatSendButton } from "./chat-send-button";
|
||
import styles from "./chat-input-bar.module.css";
|
||
|
||
const log = new Logger("AppChatComponentsChatInputBar");
|
||
|
||
export interface ChatInputBarProps {
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||
const dispatch = useChatDispatch();
|
||
const { currentUser } = useUserState();
|
||
const [input, setInput] = useState("");
|
||
const [isFocused, setIsFocused] = useState(false);
|
||
const [isActionPanelOpen, setIsActionPanelOpen] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
const hasContent = input.trim().length > 0;
|
||
const isActive = isFocused || isActionPanelOpen;
|
||
const voiceMinutesRemaining = currentUser?.voiceMinutesRemaining ?? 0;
|
||
|
||
const handleInputChange = (value: string) => {
|
||
setInput(value);
|
||
if (value.trim().length > 0) {
|
||
setIsActionPanelOpen(false);
|
||
}
|
||
};
|
||
|
||
const handleSend = () => {
|
||
if (!hasContent) return;
|
||
log.debug("[chat-input-bar] handleSend", {
|
||
contentLength: input.length,
|
||
contentPreview: input.slice(0, 50),
|
||
hasContent,
|
||
disabled,
|
||
isFocused,
|
||
});
|
||
dispatch({ type: "ChatSendMessage", content: input });
|
||
setInput("");
|
||
textareaRef.current?.focus();
|
||
};
|
||
|
||
const handleActionButtonClick = () => {
|
||
if (hasContent) {
|
||
handleSend();
|
||
return;
|
||
}
|
||
|
||
textareaRef.current?.blur();
|
||
setIsFocused(false);
|
||
setIsActionPanelOpen((open) => !open);
|
||
};
|
||
|
||
const handleVoiceMessageClick = () => {
|
||
log.debug("[chat-input-bar] voice message action clicked", {
|
||
voiceMinutesRemaining,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`${styles.bar} ${isActive ? styles.barFocused : ""} ${
|
||
isActionPanelOpen ? styles.barExpanded : ""
|
||
}`}
|
||
>
|
||
<div
|
||
className={`${styles.row} ${isActive ? styles.rowFocused : ""} ${
|
||
isActionPanelOpen ? styles.rowExpanded : ""
|
||
}`}
|
||
>
|
||
<ChatInputTextField
|
||
ref={textareaRef}
|
||
value={input}
|
||
onChange={handleInputChange}
|
||
onSubmit={handleSend}
|
||
onFocusChange={setIsFocused}
|
||
disabled={disabled}
|
||
/>
|
||
<ChatSendButton
|
||
disabled={disabled}
|
||
hasContent={hasContent}
|
||
isExpanded={isActionPanelOpen}
|
||
onClick={handleActionButtonClick}
|
||
/>
|
||
</div>
|
||
{isActionPanelOpen ? (
|
||
<ChatInputActionPanel
|
||
voiceMinutesRemaining={voiceMinutesRemaining}
|
||
onVoiceMessageClick={handleVoiceMessageClick}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|