MessageList.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. "use client";
  2. import { useEffect, useRef, useState } from "react";
  3. import { MessageItem } from "@/features/agent/types";
  4. import { formatMessageTime } from "@/utils/format";
  5. import { highlightText } from "@/utils/highlight";
  6. import { Badge } from "@/components/ui/badge";
  7. import { Button } from "@/components/ui/button";
  8. import { Dialog, DialogContent } from "@/components/ui/dialog";
  9. import { Paperclip, Download, X } from "lucide-react";
  10. import { API_BASE_URL } from "@/lib/config";
  11. interface MessageListProps {
  12. messages: MessageItem[];
  13. loading: boolean;
  14. highlightKeyword: string;
  15. onHighlightClear: () => void;
  16. currentUserIsAgent?: boolean;
  17. disableAutoScroll?: boolean;
  18. conversationId?: number | null;
  19. onMarkMessagesRead?: (conversationId: number, readerIsAgent: boolean) => void;
  20. }
  21. export function MessageList({
  22. messages,
  23. loading,
  24. highlightKeyword,
  25. onHighlightClear,
  26. currentUserIsAgent = true,
  27. disableAutoScroll = false,
  28. conversationId = null,
  29. onMarkMessagesRead,
  30. }: MessageListProps) {
  31. const containerRef = useRef<HTMLDivElement>(null);
  32. const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
  33. const shouldStickToBottomRef = useRef(true);
  34. const lastConversationIdRef = useRef<number | null>(null);
  35. const markReadTimerRef = useRef<NodeJS.Timeout | null>(null);
  36. const lastMarkedReadRef = useRef<number>(0);
  37. const lastMessageIdRef = useRef<number | null>(null);
  38. const lastMessageCountRef = useRef<number>(0);
  39. const hasInitialScrolledRef = useRef(false); // 标记是否已经完成初始滚动
  40. // 图片预览状态(必须在所有条件返回之前声明)
  41. const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
  42. const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
  43. useEffect(() => {
  44. if (conversationId !== lastConversationIdRef.current) {
  45. lastConversationIdRef.current = conversationId;
  46. shouldStickToBottomRef.current = true;
  47. lastMessageIdRef.current = null;
  48. lastMessageCountRef.current = 0;
  49. hasInitialScrolledRef.current = false; // 重置初始滚动标记
  50. }
  51. }, [conversationId]);
  52. // 监听滚动事件,当滚动到底部附近时标记消息为已读
  53. // 注意:即使 disableAutoScroll 为 true,也应该允许通过滚动来标记消息为已读
  54. useEffect(() => {
  55. const container = containerRef.current;
  56. if (!container || !conversationId || !onMarkMessagesRead) {
  57. return;
  58. }
  59. const handleScroll = () => {
  60. const { scrollTop, scrollHeight, clientHeight } = container;
  61. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  62. const isNearBottom = distanceToBottom < 100;
  63. shouldStickToBottomRef.current = isNearBottom;
  64. // 当滚动到底部附近时,检查是否有未读消息需要标记为已读
  65. if (isNearBottom) {
  66. // 防抖:延迟 500ms 后标记为已读,避免频繁调用
  67. if (markReadTimerRef.current) {
  68. clearTimeout(markReadTimerRef.current);
  69. }
  70. markReadTimerRef.current = setTimeout(() => {
  71. // 检查是否有未读的消息(对方发送的消息)
  72. const unreadMessages = messages.filter((msg) => {
  73. // 对于客服端:检查访客发送的未读消息
  74. // 对于访客端:检查客服发送的未读消息
  75. const isFromOther = currentUserIsAgent
  76. ? !msg.sender_is_agent
  77. : msg.sender_is_agent;
  78. return isFromOther && !msg.is_read;
  79. });
  80. if (unreadMessages.length > 0) {
  81. // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
  82. const now = Date.now();
  83. if (now - lastMarkedReadRef.current < 2000) {
  84. return;
  85. }
  86. // 标记为已读
  87. onMarkMessagesRead(conversationId, currentUserIsAgent);
  88. lastMarkedReadRef.current = now;
  89. }
  90. }, 500);
  91. }
  92. };
  93. handleScroll();
  94. container.addEventListener("scroll", handleScroll);
  95. return () => {
  96. container.removeEventListener("scroll", handleScroll);
  97. if (markReadTimerRef.current) {
  98. clearTimeout(markReadTimerRef.current);
  99. }
  100. };
  101. }, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent]);
  102. useEffect(() => {
  103. if (messages.length === 0) {
  104. return;
  105. }
  106. const container = containerRef.current;
  107. if (!container) {
  108. return;
  109. }
  110. const keyword = highlightKeyword.trim();
  111. const lastMessage = messages[messages.length - 1];
  112. const isLastMessageFromCurrentUser = lastMessage
  113. ? currentUserIsAgent
  114. ? lastMessage.sender_is_agent
  115. : !lastMessage.sender_is_agent
  116. : false;
  117. // 检查是否有新消息(通过比较消息ID或消息数量)
  118. const hasNewMessage =
  119. lastMessage.id !== lastMessageIdRef.current ||
  120. messages.length !== lastMessageCountRef.current;
  121. // 更新记录
  122. lastMessageIdRef.current = lastMessage.id;
  123. lastMessageCountRef.current = messages.length;
  124. // 使用 requestAnimationFrame 确保 DOM 已更新后再检查位置
  125. requestAnimationFrame(() => {
  126. // 重新获取容器引用,确保使用最新的 DOM 元素
  127. const currentContainer = containerRef.current;
  128. if (!currentContainer) {
  129. return;
  130. }
  131. // 对于新消息,需要延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
  132. // 使用双重 requestAnimationFrame + 小延迟,给图片加载留出时间
  133. const checkAndScroll = () => {
  134. const container = containerRef.current;
  135. if (!container) {
  136. return;
  137. }
  138. // 在 DOM 更新后检查当前位置
  139. const { scrollTop, scrollHeight, clientHeight } = container;
  140. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  141. const isNearBottom = distanceToBottom < 100;
  142. // 更新 shouldStickToBottomRef,确保使用最新的位置信息
  143. shouldStickToBottomRef.current = isNearBottom;
  144. // 检查是否是初始加载(首次加载消息或切换对话后首次加载)
  145. const isInitialLoad = !hasInitialScrolledRef.current && messages.length > 0;
  146. // 滚动逻辑:
  147. // 1. 如果是初始加载(首次加载消息或切换对话),无论什么情况都自动滚动到底部
  148. // 2. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
  149. // 3. 如果最后一条消息是对方发送的:
  150. // - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
  151. // - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
  152. // - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
  153. // 4. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
  154. // 这样确保访客端和客服端的行为一致:初始加载时显示最新消息,当用户在底部附近时,收到新消息会自动滚动到底部
  155. const shouldAutoScroll =
  156. isInitialLoad ||
  157. (hasNewMessage &&
  158. (isLastMessageFromCurrentUser || isNearBottom));
  159. if (keyword) {
  160. const keywordLower = keyword.toLowerCase();
  161. const matchingMessage = messages.find((message) =>
  162. message.content.toLowerCase().includes(keywordLower)
  163. );
  164. if (matchingMessage) {
  165. const scroll = () => {
  166. const target = messageRefs.current[matchingMessage.id];
  167. if (target) {
  168. target.scrollIntoView({
  169. behavior: "smooth",
  170. block: "center",
  171. inline: "nearest",
  172. });
  173. }
  174. setTimeout(onHighlightClear, 3000);
  175. };
  176. setTimeout(scroll, 200);
  177. } else {
  178. if (!shouldAutoScroll) {
  179. return;
  180. }
  181. const scrollBottom = () => {
  182. const container = containerRef.current;
  183. if (!container) {
  184. return;
  185. }
  186. container.scrollTo({
  187. top: container.scrollHeight,
  188. behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
  189. });
  190. // 标记初始滚动已完成
  191. if (isInitialLoad) {
  192. hasInitialScrolledRef.current = true;
  193. }
  194. };
  195. setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
  196. onHighlightClear();
  197. }
  198. } else {
  199. if (!shouldAutoScroll) {
  200. return;
  201. }
  202. const scrollBottom = () => {
  203. const container = containerRef.current;
  204. if (!container) {
  205. return;
  206. }
  207. // 如果 scrollHeight === clientHeight,说明没有滚动条,强制设置高度
  208. // 这通常发生在 flex 布局中,子元素高度没有正确限制时
  209. if (container.scrollHeight === container.clientHeight && container.parentElement) {
  210. const parent = container.parentElement;
  211. const parentHeight = parent.offsetHeight;
  212. container.style.height = `${parentHeight}px`;
  213. container.style.maxHeight = `${parentHeight}px`;
  214. }
  215. container.scrollTo({
  216. top: container.scrollHeight,
  217. behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
  218. });
  219. // 标记初始滚动已完成
  220. if (isInitialLoad) {
  221. hasInitialScrolledRef.current = true;
  222. }
  223. };
  224. setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
  225. }
  226. // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
  227. // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
  228. if (conversationId && onMarkMessagesRead && messages.length > 0) {
  229. // 延迟标记为已读,确保滚动动画完成
  230. if (markReadTimerRef.current) {
  231. clearTimeout(markReadTimerRef.current);
  232. }
  233. markReadTimerRef.current = setTimeout(() => {
  234. // 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
  235. const shouldMarkRead = shouldAutoScroll || isNearBottom;
  236. if (!shouldMarkRead) {
  237. return;
  238. }
  239. const unreadMessages = messages.filter((msg) => {
  240. const isFromOther = currentUserIsAgent
  241. ? !msg.sender_is_agent
  242. : msg.sender_is_agent;
  243. return isFromOther && !msg.is_read;
  244. });
  245. if (unreadMessages.length > 0) {
  246. // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
  247. const now = Date.now();
  248. if (now - lastMarkedReadRef.current < 2000) {
  249. return;
  250. }
  251. onMarkMessagesRead(conversationId, currentUserIsAgent);
  252. lastMarkedReadRef.current = now;
  253. }
  254. }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
  255. }
  256. };
  257. // 对于新消息,延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
  258. if (hasNewMessage) {
  259. // 检查最后一条消息是否包含图片/文件
  260. const lastMessageHasFile = lastMessage.file_url;
  261. if (lastMessageHasFile) {
  262. // 如果包含文件,延迟更长时间,确保图片加载完成
  263. requestAnimationFrame(() => {
  264. requestAnimationFrame(() => {
  265. setTimeout(() => {
  266. checkAndScroll();
  267. }, 200); // 给图片加载留出更多时间
  268. });
  269. });
  270. } else {
  271. // 普通消息,正常延迟
  272. requestAnimationFrame(() => {
  273. requestAnimationFrame(() => {
  274. checkAndScroll();
  275. });
  276. });
  277. }
  278. } else {
  279. // 非新消息(如状态更新),直接检查
  280. checkAndScroll();
  281. }
  282. });
  283. }, [
  284. messages,
  285. highlightKeyword,
  286. onHighlightClear,
  287. disableAutoScroll,
  288. currentUserIsAgent,
  289. conversationId,
  290. onMarkMessagesRead,
  291. ]);
  292. if (loading) {
  293. return (
  294. <div className="flex-1 flex items-center justify-center bg-muted/30">
  295. <span className="text-sm text-muted-foreground">消息加载中...</span>
  296. </div>
  297. );
  298. }
  299. if (messages.length === 0) {
  300. return (
  301. <div ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/30 scrollbar-auto">
  302. <div className="text-center text-muted-foreground mt-8 text-sm">暂无消息</div>
  303. </div>
  304. );
  305. }
  306. return (
  307. <>
  308. {/* 图片预览对话框 */}
  309. <Dialog open={imagePreviewOpen} onOpenChange={setImagePreviewOpen}>
  310. <DialogContent className="max-w-4xl max-h-[90vh] p-0">
  311. {previewImageUrl && (
  312. <div className="relative">
  313. <Button
  314. variant="ghost"
  315. size="sm"
  316. className="absolute top-2 right-2 z-10"
  317. onClick={() => setImagePreviewOpen(false)}
  318. >
  319. <X className="w-4 h-4" />
  320. </Button>
  321. <img
  322. src={previewImageUrl}
  323. alt="预览"
  324. className="w-full h-auto max-h-[90vh] object-contain"
  325. />
  326. </div>
  327. )}
  328. </DialogContent>
  329. </Dialog>
  330. <div
  331. ref={containerRef}
  332. className="h-full w-full overflow-y-auto p-4 bg-muted/30 scrollbar-auto"
  333. style={{ height: '100%' }}
  334. >
  335. <div className="space-y-4">
  336. {messages.map((message) => {
  337. const keyword = highlightKeyword.trim();
  338. const isMatching =
  339. keyword !== "" &&
  340. message.content.toLowerCase().includes(keyword.toLowerCase());
  341. const bubbleContent =
  342. keyword !== "" && isMatching
  343. ? highlightText(message.content, keyword)
  344. : message.content;
  345. if (message.message_type === "system_message") {
  346. return (
  347. <div
  348. key={message.id}
  349. ref={(element) => {
  350. messageRefs.current[message.id] = element;
  351. }}
  352. className={`text-center text-xs text-muted-foreground`}
  353. >
  354. <Badge variant="secondary" className="inline-block">
  355. {message.content}
  356. </Badge>
  357. </div>
  358. );
  359. }
  360. // 确保 sender_is_agent 是布尔值
  361. const isSenderAgent = Boolean(message.sender_is_agent);
  362. const isCurrentUser = currentUserIsAgent
  363. ? isSenderAgent
  364. : !isSenderAgent;
  365. const alignment = isCurrentUser ? "justify-end" : "justify-start";
  366. const bubbleColor = isCurrentUser
  367. ? "bg-primary text-primary-foreground shadow-md"
  368. : "bg-card text-card-foreground border border-border/50 shadow-sm";
  369. const cornerClass = isCurrentUser ? "rounded-br-none" : "rounded-bl-none";
  370. // 计算已读回执的样式类名
  371. // 统一使用相同的样式:蓝色半透明(text-primary/70)
  372. // 因为访客端和客服端的当前用户消息都是蓝色背景(bg-primary),所以使用相同的样式
  373. const receiptClass = isCurrentUser ? "text-primary/70" : "";
  374. // 文件相关
  375. const hasFile = Boolean(message.file_url);
  376. const isImage = message.file_type === "image";
  377. const isDocument = message.file_type === "document";
  378. // 获取文件URL(完整URL)
  379. const getFileUrl = (fileUrl: string | null | undefined): string => {
  380. if (!fileUrl) return "";
  381. if (fileUrl.startsWith("http")) return fileUrl;
  382. return `${API_BASE_URL}${fileUrl}`;
  383. };
  384. // 格式化文件大小
  385. const formatFileSize = (bytes: number | null | undefined): string => {
  386. if (!bytes) return "";
  387. if (bytes < 1024) return bytes + " B";
  388. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  389. return (bytes / (1024 * 1024)).toFixed(1) + " MB";
  390. };
  391. // 打开图片预览
  392. const handleImageClick = (url: string) => {
  393. setPreviewImageUrl(url);
  394. setImagePreviewOpen(true);
  395. };
  396. // 下载文件
  397. const handleDownload = (url: string, fileName: string | null | undefined) => {
  398. const link = document.createElement("a");
  399. link.href = url;
  400. link.download = fileName || "file";
  401. link.target = "_blank";
  402. document.body.appendChild(link);
  403. link.click();
  404. document.body.removeChild(link);
  405. };
  406. return (
  407. <div
  408. key={message.id}
  409. ref={(element) => {
  410. messageRefs.current[message.id] = element;
  411. }}
  412. className={`flex ${alignment}`}
  413. >
  414. <div className="max-w-[70%]">
  415. <div
  416. className={`px-4 py-2.5 rounded-2xl ${
  417. cornerClass
  418. } ${bubbleColor} transition-shadow hover:shadow-md`}
  419. >
  420. {/* 文本内容 */}
  421. {message.content && (
  422. <div className="whitespace-pre-wrap break-words text-sm">
  423. {bubbleContent}
  424. </div>
  425. )}
  426. {/* 文件显示 */}
  427. {hasFile && message.file_url && (
  428. <div className={message.content ? "mt-2" : ""}>
  429. {isImage ? (
  430. // 图片预览
  431. <div
  432. className="cursor-pointer rounded-lg overflow-hidden max-w-[300px] border border-border/30 hover:border-primary/50 transition-colors shadow-sm"
  433. onClick={() => handleImageClick(getFileUrl(message.file_url))}
  434. >
  435. <img
  436. src={getFileUrl(message.file_url)}
  437. alt={message.file_name || "图片"}
  438. className="max-w-full h-auto"
  439. loading="lazy"
  440. />
  441. </div>
  442. ) : isDocument ? (
  443. // 文档显示
  444. <div className="flex items-center gap-2 p-3 bg-background/60 rounded-lg border border-border/30 hover:bg-background/80 transition-colors">
  445. <Paperclip className="w-4 h-4 flex-shrink-0" />
  446. <div className="flex-1 min-w-0">
  447. <div className="text-sm font-medium truncate">
  448. {message.file_name || "文件"}
  449. </div>
  450. {message.file_size && (
  451. <div className="text-xs text-muted-foreground">
  452. {formatFileSize(message.file_size)}
  453. </div>
  454. )}
  455. </div>
  456. <Button
  457. variant="ghost"
  458. size="sm"
  459. onClick={() =>
  460. handleDownload(
  461. getFileUrl(message.file_url),
  462. message.file_name
  463. )
  464. }
  465. className="flex-shrink-0"
  466. >
  467. <Download className="w-4 h-4" />
  468. </Button>
  469. </div>
  470. ) : null}
  471. </div>
  472. )}
  473. </div>
  474. <div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground">
  475. {isCurrentUser && (
  476. <span className={receiptClass}>
  477. {message.is_read ? "✓✓" : "✓"}
  478. </span>
  479. )}
  480. <span>{formatMessageTime(message.created_at)}</span>
  481. </div>
  482. </div>
  483. </div>
  484. );
  485. })}
  486. </div>
  487. </div>
  488. </>
  489. );
  490. }