"use client"; import { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi"; import { Paperclip, ArrowUp, X } from "lucide-react"; import { toast } from "@/hooks/useToast"; interface VisitorMessageInputProps { value: string; onChange: (value: string) => void; onSubmit: (fileInfo?: UploadFileResult) => Promise | void; sending: boolean; conversationId?: number; toolsSlot?: ReactNode; submitLeftSlot?: ReactNode; } interface FilePreview { file: File; preview?: string; } export function VisitorMessageInput({ value, onChange, onSubmit, sending, conversationId, toolsSlot, submitLeftSlot, }: VisitorMessageInputProps) { const inputRef = useRef(null); const fileInputRef = useRef(null); const prevSendingRef = useRef(false); const [filePreview, setFilePreview] = useState(null); const [uploading, setUploading] = useState(false); useEffect(() => { if (prevSendingRef.current && !sending && inputRef.current) { setTimeout(() => inputRef.current?.focus(), 0); } prevSendingRef.current = sending; }, [sending]); const handleFileSelect = useCallback(async (file: File) => { const MAX_FILE_SIZE = 10 * 1024 * 1024; if (file.size > MAX_FILE_SIZE) { toast.error("文件大小超过限制(最大10MB)"); return; } const ext = file.name.toLowerCase().split(".").pop(); const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"]; if (!ext || !allowedExts.includes(ext)) { toast.error("不支持的文件类型"); return; } let preview: string | undefined; if (file.type.startsWith("image/")) { preview = URL.createObjectURL(file); } setFilePreview({ file, preview }); }, []); const handleFileInputChange = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) handleFileSelect(file); if (fileInputRef.current) fileInputRef.current.value = ""; }, [handleFileSelect] ); const handleRemoveFile = useCallback(() => { if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview); setFilePreview(null); }, [filePreview]); const formatFileSize = (bytes: number): string => { if (bytes < 1024) return bytes + " B"; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; return (bytes / (1024 * 1024)).toFixed(1) + " MB"; }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (sending || uploading) return; if (!value.trim() && !filePreview) return; try { let fileInfo: UploadFileResult | undefined; if (filePreview) { setUploading(true); try { fileInfo = await uploadFile(filePreview.file, conversationId); } catch (error) { toast.error((error as Error).message || "文件上传失败"); setUploading(false); return; } setUploading(false); } await onSubmit(fileInfo); onChange(""); handleRemoveFile(); } catch (error) { // 发送异常由上层统一处理 } }; useEffect(() => { const handlePaste = (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) { event.preventDefault(); void handleFileSelect(file); break; } } } }; const input = inputRef.current; if (!input) return; input.addEventListener("paste", handlePaste); return () => input.removeEventListener("paste", handlePaste); }, [handleFileSelect]); useEffect(() => { return () => { if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview); }; }, [filePreview]); return (
{filePreview && (
{filePreview.preview ? (
预览
{filePreview.file.name} ({formatFileSize(filePreview.file.size)})
) : (
{filePreview.file.name} ({formatFileSize(filePreview.file.size)})
)}
)} onChange(event.target.value)} className="w-full bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1" disabled={sending || uploading} />
{toolsSlot}
{submitLeftSlot}
); }