import { Send, Loader2, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react' import { useState, useRef, useEffect } from 'react' import type { Attachment } from '../../types/protocol' const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB interface MessageInputProps { onSend: (content: string, attachments: Attachment[]) => void disabled?: boolean isLoading?: boolean placeholder?: string isReadOnly?: boolean channelName?: string } interface FileAttachment { file: File attachment: Attachment preview?: string // 用于图片预览 } // 根据 MIME 类型判断 media_type function getMediaType(mimeType: string): string { if (mimeType.startsWith('image/')) return 'image' if (mimeType.startsWith('audio/')) return 'audio' if (mimeType.startsWith('video/')) return 'video' return 'file' } export function MessageInput({ onSend, disabled = false, isLoading = false, placeholder = '输入消息...按 / 查看命令', isReadOnly = false, channelName, }: MessageInputProps) { const [content, setContent] = useState('') const [attachments, setAttachments] = useState([]) const [isDragging, setIsDragging] = useState(false) const [error, setError] = useState(null) const textareaRef = useRef(null) const fileInputRef = useRef(null) const wasLoadingRef = useRef(false) useEffect(() => { const textarea = textareaRef.current if (textarea) { textarea.style.height = 'auto' textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` } }, [content]) // 当 isLoading 从 true 变为 false 时,自动聚焦输入框 useEffect(() => { if (wasLoadingRef.current && !isLoading && !isReadOnly) { textareaRef.current?.focus() } wasLoadingRef.current = isLoading }, [isLoading, isReadOnly]) // 处理文件选择 const handleFileSelect = async (files: FileList | null) => { if (!files) return setError(null) const newAttachments: FileAttachment[] = [] for (const file of Array.from(files)) { // 检查文件大小 if (file.size > MAX_FILE_SIZE) { setError(`文件 "${file.name}" 超过 50MB 限制`) continue } // 读取文件为 base64 const base64 = await readFileAsBase64(file) const mimeType = file.type || 'application/octet-stream' const mediaType = getMediaType(mimeType) const attachment: Attachment = { path: file.name, media_type: mediaType, mime_type: mimeType, content_base64: base64, file_name: file.name, } const fileAttachment: FileAttachment = { file, attachment, preview: mediaType === 'image' ? base64 : undefined, } newAttachments.push(fileAttachment) } setAttachments(prev => [...prev, ...newAttachments]) } // 读取文件为 base64 const readFileAsBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { const result = reader.result as string // 移除 data:xxx;base64, 前缀 const base64 = result.split(',')[1] resolve(base64) } reader.onerror = reject reader.readAsDataURL(file) }) } // 点击附件按钮 const handleAttachClick = () => { fileInputRef.current?.click() } // 删除附件 const handleRemoveAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)) } // 粘贴事件处理 const handlePaste = async (e: React.ClipboardEvent) => { if (disabled || isReadOnly) return const clipboardData = e.clipboardData const items = clipboardData.items // 检查是否有文件(图片或其他文件) const files: File[] = [] for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile() if (file) { files.push(file) } } } // 如果有文件,处理文件并阻止默认粘贴行为 if (files.length > 0) { e.preventDefault() // 直接处理文件数组 setError(null) for (const file of files) { if (file.size > MAX_FILE_SIZE) { setError(`文件 "${file.name}" 超过 50MB 限制`) continue } const base64 = await readFileAsBase64(file) const mimeType = file.type || 'application/octet-stream' const mediaType = getMediaType(mimeType) const attachment: Attachment = { path: file.name, media_type: mediaType, mime_type: mimeType, content_base64: base64, file_name: file.name, } const fileAttachment: FileAttachment = { file, attachment, preview: mediaType === 'image' ? base64 : undefined, } setAttachments(prev => [...prev, fileAttachment]) } } // 否则让默认的文本粘贴行为继续 } // 拖拽事件 const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() if (!disabled && !isReadOnly) { setIsDragging(true) } } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() // 检查是否真的离开了拖拽区域(而不是进入子元素) const relatedTarget = e.relatedTarget as Node | null const currentTarget = e.currentTarget if (!relatedTarget || !currentTarget.contains(relatedTarget)) { setIsDragging(false) } } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) if (!disabled && !isReadOnly) { handleFileSelect(e.dataTransfer.files) } } const handleSend = () => { const hasContent = content.trim() || attachments.length > 0 if (hasContent && !disabled && !isReadOnly) { onSend( content.trim(), attachments.map(a => a.attachment) ) setContent('') setAttachments([]) setError(null) if (textareaRef.current) { textareaRef.current.style.height = 'auto' } } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } // 获取附件图标 const getAttachmentIcon = (mediaType: string) => { switch (mediaType) { case 'image': return case 'audio': return case 'video': return default: return } } // 只读模式:显示提示占位符 if (isReadOnly) { return (
只读模式

{channelName ? ( <>{channelName} 通道仅支持查看历史消息 ) : ( '当前通道仅支持查看历史消息' )}

请切换至 WebSocket 通道进行输入

) } return (
{/* 错误提示 */} {error && (
{error}
)} {/* 附件预览列表 */} {attachments.length > 0 && (
{attachments.map((att, index) => (
{att.preview ? ( {att.attachment.file_name} ) : (
{getAttachmentIcon(att.attachment.media_type)}
)} {att.attachment.file_name}
))}
)} {/* 输入区域 */}
{/* 拖拽提示 */} {isDragging && (
拖放文件到这里
)} {/* 附件按钮 */} handleFileSelect(e.target.files)} /> {/* 输入框 */}