VisitorMessageInput.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. "use client";
  2. import { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react";
  3. import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
  4. import { updateVisitorContactEmail } from "@/features/visitor/services/conversationApi";
  5. import {
  6. getVisitorContactEmail,
  7. saveVisitorContactEmail,
  8. saveVisitorEmailPromptDone,
  9. shouldShowVisitorEmailPrompt,
  10. } from "@/lib/visitor-session";
  11. import { useI18n } from "@/lib/i18n/provider";
  12. import { cn } from "@/lib/utils";
  13. import { Paperclip, ArrowUp, X } from "lucide-react";
  14. import { toast } from "@/hooks/useToast";
  15. interface VisitorMessageInputProps {
  16. value: string;
  17. onChange: (value: string) => void;
  18. onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
  19. sending: boolean;
  20. conversationId?: number;
  21. accessToken?: string;
  22. visitorId?: number;
  23. toolsSlot?: ReactNode;
  24. submitLeftSlot?: ReactNode;
  25. }
  26. interface FilePreview {
  27. file: File;
  28. preview?: string;
  29. }
  30. const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  31. export function VisitorMessageInput({
  32. value,
  33. onChange,
  34. onSubmit,
  35. sending,
  36. conversationId,
  37. accessToken,
  38. visitorId,
  39. toolsSlot,
  40. submitLeftSlot,
  41. }: VisitorMessageInputProps) {
  42. const { t } = useI18n();
  43. const inputRef = useRef<HTMLInputElement>(null);
  44. const fileInputRef = useRef<HTMLInputElement>(null);
  45. const prevSendingRef = useRef<boolean>(false);
  46. const emailSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  47. const lastSavedEmailRef = useRef<string>("");
  48. const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
  49. const [uploading, setUploading] = useState(false);
  50. const [contactExpanded, setContactExpanded] = useState(false);
  51. const [email, setEmail] = useState("");
  52. const [emailPromptActive, setEmailPromptActive] = useState(true);
  53. useEffect(() => {
  54. if (!visitorId) return;
  55. const saved = getVisitorContactEmail(visitorId);
  56. if (saved) {
  57. setEmail(saved);
  58. lastSavedEmailRef.current = saved;
  59. }
  60. setEmailPromptActive(shouldShowVisitorEmailPrompt(visitorId));
  61. }, [visitorId]);
  62. const dismissEmailPrompt = useCallback(() => {
  63. setContactExpanded(false);
  64. setEmailPromptActive(false);
  65. if (visitorId) {
  66. saveVisitorEmailPromptDone(visitorId);
  67. }
  68. }, [visitorId]);
  69. useEffect(() => {
  70. if (prevSendingRef.current && !sending && inputRef.current) {
  71. setTimeout(() => inputRef.current?.focus(), 0);
  72. }
  73. prevSendingRef.current = sending;
  74. }, [sending]);
  75. const persistEmail = useCallback(
  76. async (raw: string) => {
  77. const trimmed = raw.trim();
  78. if (!trimmed || !EMAIL_PATTERN.test(trimmed)) return;
  79. if (!conversationId) return;
  80. if (trimmed === lastSavedEmailRef.current) return;
  81. try {
  82. await updateVisitorContactEmail(conversationId, trimmed, accessToken);
  83. lastSavedEmailRef.current = trimmed;
  84. if (visitorId) {
  85. saveVisitorContactEmail(visitorId, trimmed);
  86. }
  87. dismissEmailPrompt();
  88. } catch (error) {
  89. console.warn("保存访客邮箱失败:", error);
  90. }
  91. },
  92. [accessToken, conversationId, visitorId, dismissEmailPrompt]
  93. );
  94. const scheduleEmailSave = useCallback(
  95. (raw: string) => {
  96. if (emailSaveTimerRef.current) {
  97. clearTimeout(emailSaveTimerRef.current);
  98. }
  99. emailSaveTimerRef.current = setTimeout(() => {
  100. void persistEmail(raw);
  101. }, 700);
  102. },
  103. [persistEmail]
  104. );
  105. useEffect(() => {
  106. if (!conversationId || !email.trim()) return;
  107. if (!EMAIL_PATTERN.test(email.trim())) return;
  108. scheduleEmailSave(email);
  109. return () => {
  110. if (emailSaveTimerRef.current) clearTimeout(emailSaveTimerRef.current);
  111. };
  112. }, [email, conversationId, scheduleEmailSave]);
  113. useEffect(() => {
  114. if (!conversationId || !email.trim()) return;
  115. if (!EMAIL_PATTERN.test(email.trim())) return;
  116. void persistEmail(email);
  117. }, [conversationId, email, persistEmail]);
  118. const handleFirstInteraction = useCallback(() => {
  119. if (!emailPromptActive) return;
  120. setContactExpanded((prev) => (prev ? prev : true));
  121. }, [emailPromptActive]);
  122. const handleFileSelect = useCallback(async (file: File) => {
  123. const MAX_FILE_SIZE = 10 * 1024 * 1024;
  124. if (file.size > MAX_FILE_SIZE) {
  125. toast.error("文件大小超过限制(最大10MB)");
  126. return;
  127. }
  128. const ext = file.name.toLowerCase().split(".").pop();
  129. const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
  130. if (!ext || !allowedExts.includes(ext)) {
  131. toast.error("不支持的文件类型");
  132. return;
  133. }
  134. let preview: string | undefined;
  135. if (file.type.startsWith("image/")) {
  136. preview = URL.createObjectURL(file);
  137. }
  138. setFilePreview({ file, preview });
  139. }, []);
  140. const handleFileInputChange = useCallback(
  141. (event: React.ChangeEvent<HTMLInputElement>) => {
  142. const file = event.target.files?.[0];
  143. if (file) handleFileSelect(file);
  144. if (fileInputRef.current) fileInputRef.current.value = "";
  145. },
  146. [handleFileSelect]
  147. );
  148. const handleRemoveFile = useCallback(() => {
  149. if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
  150. setFilePreview(null);
  151. }, [filePreview]);
  152. const formatFileSize = (bytes: number): string => {
  153. if (bytes < 1024) return bytes + " B";
  154. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  155. return (bytes / (1024 * 1024)).toFixed(1) + " MB";
  156. };
  157. const handleSubmit = async (event: FormEvent) => {
  158. event.preventDefault();
  159. if (sending || uploading) return;
  160. if (!value.trim() && !filePreview) return;
  161. if (email.trim() && EMAIL_PATTERN.test(email.trim())) {
  162. await persistEmail(email);
  163. }
  164. try {
  165. let fileInfo: UploadFileResult | undefined;
  166. if (filePreview) {
  167. setUploading(true);
  168. try {
  169. fileInfo = await uploadFile(filePreview.file, conversationId, accessToken);
  170. } catch (error) {
  171. toast.error((error as Error).message || "文件上传失败");
  172. setUploading(false);
  173. return;
  174. }
  175. setUploading(false);
  176. }
  177. await onSubmit(fileInfo);
  178. onChange("");
  179. handleRemoveFile();
  180. dismissEmailPrompt();
  181. } catch {
  182. // 发送异常由上层统一处理
  183. }
  184. };
  185. useEffect(() => {
  186. const handlePaste = (event: ClipboardEvent) => {
  187. const items = event.clipboardData?.items;
  188. if (!items) return;
  189. for (let i = 0; i < items.length; i++) {
  190. const item = items[i];
  191. if (item.type.startsWith("image/")) {
  192. const file = item.getAsFile();
  193. if (file) {
  194. event.preventDefault();
  195. void handleFileSelect(file);
  196. break;
  197. }
  198. }
  199. }
  200. };
  201. const input = inputRef.current;
  202. if (!input) return;
  203. input.addEventListener("paste", handlePaste);
  204. return () => input.removeEventListener("paste", handlePaste);
  205. }, [handleFileSelect]);
  206. useEffect(() => {
  207. return () => {
  208. if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
  209. if (emailSaveTimerRef.current) clearTimeout(emailSaveTimerRef.current);
  210. };
  211. }, [filePreview]);
  212. return (
  213. <form
  214. onSubmit={handleSubmit}
  215. 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"
  216. >
  217. {filePreview && (
  218. <div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 p-2 flex items-start gap-2">
  219. <div className="flex-1 min-w-0">
  220. {filePreview.preview ? (
  221. <div className="inline-block">
  222. <img
  223. src={filePreview.preview}
  224. alt="预览"
  225. className="max-w-[180px] max-h-[140px] rounded-lg object-cover border border-slate-200"
  226. />
  227. <div className="mt-1 text-xs text-slate-500">
  228. {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
  229. </div>
  230. </div>
  231. ) : (
  232. <div className="text-xs text-slate-600">
  233. {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
  234. </div>
  235. )}
  236. </div>
  237. <button
  238. type="button"
  239. onClick={handleRemoveFile}
  240. className="rounded-md p-1 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
  241. disabled={sending || uploading}
  242. >
  243. <X className="w-4 h-4" />
  244. </button>
  245. </div>
  246. )}
  247. {emailPromptActive && (
  248. <div
  249. className={cn(
  250. "grid transition-[grid-template-rows,opacity,margin] duration-200 ease-out",
  251. contactExpanded ? "grid-rows-[1fr] opacity-100 mb-2" : "grid-rows-[0fr] opacity-0 mb-0"
  252. )}
  253. >
  254. <div className="overflow-hidden">
  255. <div className="pb-2 border-b border-slate-100">
  256. <div className="flex items-center gap-2">
  257. <input
  258. type="email"
  259. inputMode="email"
  260. autoComplete="email"
  261. placeholder={t("chat.email.placeholder")}
  262. value={email}
  263. onChange={(e) => setEmail(e.target.value)}
  264. onBlur={() => void persistEmail(email)}
  265. 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"
  266. disabled={sending || uploading}
  267. />
  268. <span className="shrink-0 text-[10px] text-slate-400 tracking-wide">
  269. {t("chat.email.optional")}
  270. </span>
  271. </div>
  272. <p className="mt-1 px-1 text-[10px] leading-snug text-slate-400">
  273. {t("chat.email.privacy")}
  274. </p>
  275. </div>
  276. </div>
  277. </div>
  278. )}
  279. <input
  280. ref={inputRef}
  281. type="text"
  282. placeholder={filePreview ? "添加消息(可选)..." : t("chat.input.placeholder")}
  283. value={value}
  284. onChange={(event) => onChange(event.target.value)}
  285. onKeyDown={handleFirstInteraction}
  286. onPaste={handleFirstInteraction}
  287. className="w-full bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1"
  288. disabled={sending || uploading}
  289. />
  290. <div className="mt-2 flex items-center justify-between">
  291. <input
  292. ref={fileInputRef}
  293. type="file"
  294. accept="image/*,.pdf,.doc,.docx,.txt"
  295. onChange={handleFileInputChange}
  296. className="hidden"
  297. />
  298. <div className="flex items-center gap-2">
  299. <button
  300. type="button"
  301. onClick={() => fileInputRef.current?.click()}
  302. disabled={sending || uploading}
  303. 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"
  304. title="上传文件"
  305. >
  306. <Paperclip className="w-4 h-4" />
  307. </button>
  308. {toolsSlot}
  309. </div>
  310. <div className="flex items-center gap-2">
  311. {submitLeftSlot}
  312. <button
  313. type="submit"
  314. disabled={sending || uploading || (!value.trim() && !filePreview)}
  315. 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"
  316. title={uploading ? "上传中" : sending ? "发送中" : "发送"}
  317. >
  318. <ArrowUp className="w-4 h-4" />
  319. </button>
  320. </div>
  321. </div>
  322. </form>
  323. );
  324. }