VisitorMessageInput.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. "use client";
  2. import { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react";
  3. import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
  4. import { Paperclip, ArrowUp, X } from "lucide-react";
  5. import { toast } from "@/hooks/useToast";
  6. interface VisitorMessageInputProps {
  7. value: string;
  8. onChange: (value: string) => void;
  9. onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
  10. sending: boolean;
  11. conversationId?: number;
  12. toolsSlot?: ReactNode;
  13. submitLeftSlot?: ReactNode;
  14. }
  15. interface FilePreview {
  16. file: File;
  17. preview?: string;
  18. }
  19. export function VisitorMessageInput({
  20. value,
  21. onChange,
  22. onSubmit,
  23. sending,
  24. conversationId,
  25. toolsSlot,
  26. submitLeftSlot,
  27. }: VisitorMessageInputProps) {
  28. const inputRef = useRef<HTMLInputElement>(null);
  29. const fileInputRef = useRef<HTMLInputElement>(null);
  30. const prevSendingRef = useRef<boolean>(false);
  31. const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
  32. const [uploading, setUploading] = useState(false);
  33. useEffect(() => {
  34. if (prevSendingRef.current && !sending && inputRef.current) {
  35. setTimeout(() => inputRef.current?.focus(), 0);
  36. }
  37. prevSendingRef.current = sending;
  38. }, [sending]);
  39. const handleFileSelect = useCallback(async (file: File) => {
  40. const MAX_FILE_SIZE = 10 * 1024 * 1024;
  41. if (file.size > MAX_FILE_SIZE) {
  42. toast.error("文件大小超过限制(最大10MB)");
  43. return;
  44. }
  45. const ext = file.name.toLowerCase().split(".").pop();
  46. const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
  47. if (!ext || !allowedExts.includes(ext)) {
  48. toast.error("不支持的文件类型");
  49. return;
  50. }
  51. let preview: string | undefined;
  52. if (file.type.startsWith("image/")) {
  53. preview = URL.createObjectURL(file);
  54. }
  55. setFilePreview({ file, preview });
  56. }, []);
  57. const handleFileInputChange = useCallback(
  58. (event: React.ChangeEvent<HTMLInputElement>) => {
  59. const file = event.target.files?.[0];
  60. if (file) handleFileSelect(file);
  61. if (fileInputRef.current) fileInputRef.current.value = "";
  62. },
  63. [handleFileSelect]
  64. );
  65. const handleRemoveFile = useCallback(() => {
  66. if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
  67. setFilePreview(null);
  68. }, [filePreview]);
  69. const formatFileSize = (bytes: number): string => {
  70. if (bytes < 1024) return bytes + " B";
  71. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  72. return (bytes / (1024 * 1024)).toFixed(1) + " MB";
  73. };
  74. const handleSubmit = async (event: FormEvent) => {
  75. event.preventDefault();
  76. if (sending || uploading) return;
  77. if (!value.trim() && !filePreview) return;
  78. try {
  79. let fileInfo: UploadFileResult | undefined;
  80. if (filePreview) {
  81. setUploading(true);
  82. try {
  83. fileInfo = await uploadFile(filePreview.file, conversationId);
  84. } catch (error) {
  85. toast.error((error as Error).message || "文件上传失败");
  86. setUploading(false);
  87. return;
  88. }
  89. setUploading(false);
  90. }
  91. await onSubmit(fileInfo);
  92. onChange("");
  93. handleRemoveFile();
  94. } catch (error) {
  95. // 发送异常由上层统一处理
  96. }
  97. };
  98. useEffect(() => {
  99. const handlePaste = (event: ClipboardEvent) => {
  100. const items = event.clipboardData?.items;
  101. if (!items) return;
  102. for (let i = 0; i < items.length; i++) {
  103. const item = items[i];
  104. if (item.type.startsWith("image/")) {
  105. const file = item.getAsFile();
  106. if (file) {
  107. event.preventDefault();
  108. void handleFileSelect(file);
  109. break;
  110. }
  111. }
  112. }
  113. };
  114. const input = inputRef.current;
  115. if (!input) return;
  116. input.addEventListener("paste", handlePaste);
  117. return () => input.removeEventListener("paste", handlePaste);
  118. }, [handleFileSelect]);
  119. useEffect(() => {
  120. return () => {
  121. if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
  122. };
  123. }, [filePreview]);
  124. return (
  125. <form onSubmit={handleSubmit} className="rounded-2xl border border-slate-200/90 bg-white shadow-[0_8px_24px_-20px_rgba(15,23,42,0.35)] px-3 py-2">
  126. {filePreview && (
  127. <div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 p-2 flex items-start gap-2">
  128. <div className="flex-1 min-w-0">
  129. {filePreview.preview ? (
  130. <div className="inline-block">
  131. <img src={filePreview.preview} alt="预览" className="max-w-[180px] max-h-[140px] rounded-lg object-cover border border-slate-200" />
  132. <div className="mt-1 text-xs text-slate-500">
  133. {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
  134. </div>
  135. </div>
  136. ) : (
  137. <div className="text-xs text-slate-600">
  138. {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
  139. </div>
  140. )}
  141. </div>
  142. <button
  143. type="button"
  144. onClick={handleRemoveFile}
  145. className="rounded-md p-1 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
  146. disabled={sending || uploading}
  147. >
  148. <X className="w-4 h-4" />
  149. </button>
  150. </div>
  151. )}
  152. <input
  153. ref={inputRef}
  154. type="text"
  155. placeholder={filePreview ? "添加消息(可选)..." : "输入消息"}
  156. value={value}
  157. onChange={(event) => onChange(event.target.value)}
  158. className="w-full bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1"
  159. disabled={sending || uploading}
  160. />
  161. <div className="mt-2 flex items-center justify-between">
  162. <input
  163. ref={fileInputRef}
  164. type="file"
  165. accept="image/*,.pdf,.doc,.docx,.txt"
  166. onChange={handleFileInputChange}
  167. className="hidden"
  168. />
  169. <div className="flex items-center gap-2">
  170. <button
  171. type="button"
  172. onClick={() => fileInputRef.current?.click()}
  173. disabled={sending || uploading}
  174. className="inline-flex items-center justify-center rounded-full w-8 h-8 text-slate-500 hover:bg-slate-100 hover:text-slate-700 transition-colors"
  175. title="上传文件"
  176. >
  177. <Paperclip className="w-4 h-4" />
  178. </button>
  179. {toolsSlot}
  180. </div>
  181. <div className="flex items-center gap-2">
  182. {submitLeftSlot}
  183. <button
  184. type="submit"
  185. disabled={sending || uploading || (!value.trim() && !filePreview)}
  186. className="inline-flex items-center justify-center rounded-full w-8 h-8 bg-blue-400 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
  187. title={uploading ? "上传中" : sending ? "发送中" : "发送"}
  188. >
  189. <ArrowUp className="w-4 h-4" />
  190. </button>
  191. </div>
  192. </div>
  193. </form>
  194. );
  195. }