MessageList.tsx 22 KB

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