"use client"; import { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi"; import { updateVisitorContactEmail } from "@/features/visitor/services/conversationApi"; import { getVisitorContactEmail, saveVisitorContactEmail, saveVisitorEmailPromptDone, shouldShowVisitorEmailPrompt, } from "@/lib/visitor-session"; import { useI18n } from "@/lib/i18n/provider"; import { cn } from "@/lib/utils"; 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; accessToken?: string; visitorId?: number; toolsSlot?: ReactNode; submitLeftSlot?: ReactNode; } interface FilePreview { file: File; preview?: string; } const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function VisitorMessageInput({ value, onChange, onSubmit, sending, conversationId, accessToken, visitorId, toolsSlot, submitLeftSlot, }: VisitorMessageInputProps) { const { t } = useI18n(); const inputRef = useRef(null); const fileInputRef = useRef(null); const prevSendingRef = useRef(false); const emailSaveTimerRef = useRef | null>(null); const lastSavedEmailRef = useRef(""); const [filePreview, setFilePreview] = useState(null); const [uploading, setUploading] = useState(false); const [contactExpanded, setContactExpanded] = useState(false); const [email, setEmail] = useState(""); const [emailPromptActive, setEmailPromptActive] = useState(true); useEffect(() => { if (!visitorId) return; const saved = getVisitorContactEmail(visitorId); if (saved) { setEmail(saved); lastSavedEmailRef.current = saved; } setEmailPromptActive(shouldShowVisitorEmailPrompt(visitorId)); }, [visitorId]); const dismissEmailPrompt = useCallback(() => { setContactExpanded(false); setEmailPromptActive(false); if (visitorId) { saveVisitorEmailPromptDone(visitorId); } }, [visitorId]); useEffect(() => { if (prevSendingRef.current && !sending && inputRef.current) { setTimeout(() => inputRef.current?.focus(), 0); } prevSendingRef.current = sending; }, [sending]); const persistEmail = useCallback( async (raw: string) => { const trimmed = raw.trim(); if (!trimmed || !EMAIL_PATTERN.test(trimmed)) return; if (!conversationId) return; if (trimmed === lastSavedEmailRef.current) return; try { await updateVisitorContactEmail(conversationId, trimmed, accessToken); lastSavedEmailRef.current = trimmed; if (visitorId) { saveVisitorContactEmail(visitorId, trimmed); } dismissEmailPrompt(); } catch (error) { console.warn("保存访客邮箱失败:", error); } }, [accessToken, conversationId, visitorId, dismissEmailPrompt] ); const scheduleEmailSave = useCallback( (raw: string) => { if (emailSaveTimerRef.current) { clearTimeout(emailSaveTimerRef.current); } emailSaveTimerRef.current = setTimeout(() => { void persistEmail(raw); }, 700); }, [persistEmail] ); useEffect(() => { if (!conversationId || !email.trim()) return; if (!EMAIL_PATTERN.test(email.trim())) return; scheduleEmailSave(email); return () => { if (emailSaveTimerRef.current) clearTimeout(emailSaveTimerRef.current); }; }, [email, conversationId, scheduleEmailSave]); useEffect(() => { if (!conversationId || !email.trim()) return; if (!EMAIL_PATTERN.test(email.trim())) return; void persistEmail(email); }, [conversationId, email, persistEmail]); const handleFirstInteraction = useCallback(() => { if (!emailPromptActive) return; setContactExpanded((prev) => (prev ? prev : true)); }, [emailPromptActive]); 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; if (email.trim() && EMAIL_PATTERN.test(email.trim())) { await persistEmail(email); } try { let fileInfo: UploadFileResult | undefined; if (filePreview) { setUploading(true); try { fileInfo = await uploadFile(filePreview.file, conversationId, accessToken); } catch (error) { toast.error((error as Error).message || "文件上传失败"); setUploading(false); return; } setUploading(false); } await onSubmit(fileInfo); onChange(""); handleRemoveFile(); dismissEmailPrompt(); } catch { // 发送异常由上层统一处理 } }; 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); if (emailSaveTimerRef.current) clearTimeout(emailSaveTimerRef.current); }; }, [filePreview]); return (
{filePreview && (
{filePreview.preview ? (
预览
{filePreview.file.name} ({formatFileSize(filePreview.file.size)})
) : (
{filePreview.file.name} ({formatFileSize(filePreview.file.size)})
)}
)} {emailPromptActive && (
setEmail(e.target.value)} onBlur={() => void persistEmail(email)} className="flex-1 min-w-0 bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1 py-0.5" disabled={sending || uploading} /> {t("chat.email.optional")}

{t("chat.email.privacy")}

)} onChange(event.target.value)} onKeyDown={handleFirstInteraction} onPaste={handleFirstInteraction} 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}
); }