MessageList.tsx 24 KB

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