MessageInput.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. "use client";
  2. import { FormEvent, useEffect, useRef, useState, useCallback } from "react";
  3. import { Button } from "@/components/ui/button";
  4. import { Input } from "@/components/ui/input";
  5. import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
  6. import { X, Paperclip, Image as ImageIcon } from "lucide-react";
  7. import { toast } from "@/hooks/useToast";
  8. interface MessageInputProps {
  9. value: string;
  10. onChange: (value: string) => void;
  11. onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
  12. sending: boolean;
  13. conversationId?: number; // 对话ID,用于文件上传
  14. }
  15. interface FilePreview {
  16. file: File;
  17. preview?: string; // 图片预览URL
  18. }
  19. export function MessageInput({
  20. value,
  21. onChange,
  22. onSubmit,
  23. sending,
  24. conversationId,
  25. }: MessageInputProps) {
  26. // 输入框引用,用于发送消息后自动聚焦
  27. const inputRef = useRef<HTMLInputElement>(null);
  28. // 文件输入框引用
  29. const fileInputRef = useRef<HTMLInputElement>(null);
  30. // 记录上一次的 sending 状态,用于判断是否刚刚完成发送
  31. const prevSendingRef = useRef<boolean>(false);
  32. // 文件预览状态
  33. const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
  34. // 上传中状态
  35. const [uploading, setUploading] = useState(false);
  36. // 当发送状态从 true 变为 false 时(发送完成),自动聚焦到输入框
  37. useEffect(() => {
  38. // 如果上一次是发送中(true),现在是发送完成(false),说明刚刚发送完成
  39. if (prevSendingRef.current && !sending && inputRef.current) {
  40. // 使用 setTimeout 确保 DOM 更新完成后再聚焦
  41. // 这样可以避免在某些情况下聚焦失败
  42. setTimeout(() => {
  43. inputRef.current?.focus();
  44. }, 0);
  45. }
  46. // 更新上一次的 sending 状态
  47. prevSendingRef.current = sending;
  48. }, [sending]);
  49. // 处理文件选择
  50. const handleFileSelect = useCallback(
  51. async (file: File) => {
  52. // 验证文件大小(10MB)
  53. const MAX_FILE_SIZE = 10 * 1024 * 1024;
  54. if (file.size > MAX_FILE_SIZE) {
  55. toast.error("文件大小超过限制(最大10MB)");
  56. return;
  57. }
  58. // 验证文件类型
  59. const ext = file.name.toLowerCase().split(".").pop();
  60. const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
  61. if (!ext || !allowedExts.includes(ext)) {
  62. toast.error("不支持的文件类型");
  63. return;
  64. }
  65. // 如果是图片,生成预览
  66. let preview: string | undefined;
  67. if (file.type.startsWith("image/")) {
  68. preview = URL.createObjectURL(file);
  69. }
  70. setFilePreview({ file, preview });
  71. },
  72. []
  73. );
  74. // 处理文件输入框变化
  75. const handleFileInputChange = useCallback(
  76. (event: React.ChangeEvent<HTMLInputElement>) => {
  77. const file = event.target.files?.[0];
  78. if (file) {
  79. handleFileSelect(file);
  80. }
  81. // 清空文件输入框,允许重复选择同一文件
  82. if (fileInputRef.current) {
  83. fileInputRef.current.value = "";
  84. }
  85. },
  86. [handleFileSelect]
  87. );
  88. // 处理拖拽上传
  89. const handleDragOver = useCallback((event: React.DragEvent) => {
  90. event.preventDefault();
  91. event.stopPropagation();
  92. }, []);
  93. const handleDrop = useCallback(
  94. (event: React.DragEvent) => {
  95. event.preventDefault();
  96. event.stopPropagation();
  97. const file = event.dataTransfer.files?.[0];
  98. if (file) {
  99. handleFileSelect(file);
  100. }
  101. },
  102. [handleFileSelect]
  103. );
  104. // 处理粘贴图片
  105. useEffect(() => {
  106. const handlePaste = (event: ClipboardEvent) => {
  107. const items = event.clipboardData?.items;
  108. if (!items) return;
  109. for (let i = 0; i < items.length; i++) {
  110. const item = items[i];
  111. if (item.type.startsWith("image/")) {
  112. const file = item.getAsFile();
  113. if (file) {
  114. event.preventDefault();
  115. handleFileSelect(file);
  116. break;
  117. }
  118. }
  119. }
  120. };
  121. const input = inputRef.current;
  122. if (input) {
  123. input.addEventListener("paste", handlePaste);
  124. return () => {
  125. input.removeEventListener("paste", handlePaste);
  126. };
  127. }
  128. }, [handleFileSelect]);
  129. // 移除文件预览
  130. const handleRemoveFile = useCallback(() => {
  131. if (filePreview?.preview) {
  132. URL.revokeObjectURL(filePreview.preview);
  133. }
  134. setFilePreview(null);
  135. }, [filePreview]);
  136. // 格式化文件大小
  137. const formatFileSize = (bytes: number): string => {
  138. if (bytes < 1024) return bytes + " B";
  139. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  140. return (bytes / (1024 * 1024)).toFixed(1) + " MB";
  141. };
  142. // 处理提交
  143. const handleSubmit = async (event: FormEvent) => {
  144. event.preventDefault();
  145. if (sending || uploading) {
  146. return;
  147. }
  148. // 验证:必须有内容或文件
  149. if (!value.trim() && !filePreview) {
  150. return;
  151. }
  152. try {
  153. let fileInfo: UploadFileResult | undefined;
  154. // 如果有文件,先上传文件
  155. if (filePreview) {
  156. setUploading(true);
  157. try {
  158. fileInfo = await uploadFile(filePreview.file, conversationId);
  159. } catch (error) {
  160. toast.error((error as Error).message || "文件上传失败");
  161. setUploading(false);
  162. return;
  163. }
  164. setUploading(false);
  165. }
  166. // 发送消息(包含文件信息)
  167. await onSubmit(fileInfo);
  168. // 清空输入和文件预览
  169. onChange("");
  170. handleRemoveFile();
  171. } catch (error) {
  172. console.error("发送消息失败:", error);
  173. }
  174. };
  175. // 清理预览URL
  176. useEffect(() => {
  177. return () => {
  178. if (filePreview?.preview) {
  179. URL.revokeObjectURL(filePreview.preview);
  180. }
  181. };
  182. }, [filePreview]);
  183. return (
  184. <div
  185. className="bg-gradient-to-t from-background to-muted/30 flex-shrink-0 border-t border-border/50"
  186. onDragOver={handleDragOver}
  187. onDrop={handleDrop}
  188. >
  189. {/* 文件预览区域 */}
  190. {filePreview && (
  191. <div className="px-4 pt-3 pb-2 flex items-start gap-2">
  192. <div className="flex-1 min-w-0">
  193. {filePreview.preview ? (
  194. // 图片预览
  195. <div className="relative inline-block">
  196. <img
  197. src={filePreview.preview}
  198. alt="预览"
  199. className="max-w-[200px] max-h-[200px] rounded-lg object-cover border border-border shadow-sm"
  200. />
  201. <div className="mt-1 text-xs text-muted-foreground">
  202. {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
  203. </div>
  204. </div>
  205. ) : (
  206. // 文档预览
  207. <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border/50">
  208. <Paperclip className="w-4 h-4 text-muted-foreground" />
  209. <div className="flex-1 min-w-0">
  210. <div className="text-sm font-medium truncate">{filePreview.file.name}</div>
  211. <div className="text-xs text-muted-foreground">
  212. {formatFileSize(filePreview.file.size)}
  213. </div>
  214. </div>
  215. </div>
  216. )}
  217. </div>
  218. <Button
  219. type="button"
  220. variant="ghost"
  221. size="sm"
  222. onClick={handleRemoveFile}
  223. className="flex-shrink-0 hover:bg-destructive/10 hover:text-destructive"
  224. disabled={sending || uploading}
  225. >
  226. <X className="w-4 h-4" />
  227. </Button>
  228. </div>
  229. )}
  230. {/* 输入区域 */}
  231. <form onSubmit={handleSubmit} className="px-4 py-3 flex items-center gap-2">
  232. <input
  233. ref={fileInputRef}
  234. type="file"
  235. accept="image/*,.pdf,.doc,.docx,.txt"
  236. onChange={handleFileInputChange}
  237. className="hidden"
  238. />
  239. <Button
  240. type="button"
  241. variant="ghost"
  242. size="sm"
  243. onClick={() => fileInputRef.current?.click()}
  244. disabled={sending || uploading}
  245. title="上传文件"
  246. className="hover:bg-primary/10 hover:text-primary transition-colors"
  247. >
  248. <Paperclip className="w-4 h-4" />
  249. </Button>
  250. <Input
  251. ref={inputRef}
  252. type="text"
  253. placeholder={filePreview ? "添加消息(可选)..." : "输入消息..."}
  254. value={value}
  255. onChange={(event) => onChange(event.target.value)}
  256. className="flex-1 border-border/50 focus:border-primary/50 focus:ring-primary/20"
  257. disabled={sending || uploading}
  258. />
  259. <Button
  260. type="submit"
  261. disabled={sending || uploading || (!value.trim() && !filePreview)}
  262. variant="default"
  263. size="default"
  264. className="bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
  265. >
  266. {uploading ? "上传中..." : sending ? "发送中..." : "发送"}
  267. </Button>
  268. </form>
  269. </div>
  270. );
  271. }