page.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { MessageList } from "@/components/dashboard/MessageList";
  4. import { MessageInput } from "@/components/dashboard/MessageInput";
  5. import {
  6. ChatWebSocketPayload,
  7. MessageItem,
  8. MessagesReadPayload,
  9. } from "@/features/agent/types";
  10. import {
  11. fetchMessages,
  12. markMessagesRead,
  13. sendMessage,
  14. } from "@/features/agent/services/messageApi";
  15. import { initVisitorConversation } from "@/features/visitor/services/conversationApi";
  16. import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
  17. import type { WSMessage } from "@/lib/websocket";
  18. function parseUserAgent(userAgent: string) {
  19. const ua = userAgent.toLowerCase();
  20. let browser = "Unknown";
  21. let os = "Unknown";
  22. if (ua.includes("edg/")) {
  23. browser = "Edge";
  24. } else if (ua.includes("chrome/")) {
  25. browser = "Chrome";
  26. } else if (ua.includes("firefox/")) {
  27. browser = "Firefox";
  28. } else if (ua.includes("safari/")) {
  29. browser = "Safari";
  30. }
  31. if (ua.includes("windows nt")) {
  32. os = "Windows";
  33. } else if (ua.includes("mac os x") || ua.includes("macintosh")) {
  34. os = "macOS";
  35. } else if (ua.includes("android")) {
  36. os = "Android";
  37. } else if (ua.includes("iphone") || ua.includes("ipad")) {
  38. os = "iOS";
  39. } else if (ua.includes("linux")) {
  40. os = "Linux";
  41. }
  42. return { browser, os };
  43. }
  44. export default function ChatPage() {
  45. // ===== 访客与会话状态 =====
  46. const [visitorId, setVisitorId] = useState<number | null>(null);
  47. const [conversationId, setConversationId] = useState<number | null>(null);
  48. const [conversationStatus, setConversationStatus] = useState<string>("open");
  49. const [messages, setMessages] = useState<MessageItem[]>([]);
  50. const [loadingMessages, setLoadingMessages] = useState(true);
  51. const [sending, setSending] = useState(false);
  52. const [input, setInput] = useState("");
  53. const noopHighlight = useCallback(() => {}, []);
  54. // 初始化访客 ID(使用 localStorage 保持连续性)
  55. useEffect(() => {
  56. let stored = window.localStorage.getItem("visitor_id");
  57. if (!stored) {
  58. stored = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
  59. window.localStorage.setItem("visitor_id", stored);
  60. }
  61. const parsed = Number.parseInt(stored, 10);
  62. setVisitorId(Number.isNaN(parsed) ? null : parsed);
  63. }, []);
  64. // 创建或恢复访客会话
  65. const initializeConversation = useCallback(async (id: number) => {
  66. const { browser, os } = parseUserAgent(navigator.userAgent);
  67. const language =
  68. navigator.language || (navigator.languages && navigator.languages[0]) || "";
  69. const result = await initVisitorConversation({
  70. visitorId: id,
  71. website: window.location.href,
  72. referrer: document.referrer || "",
  73. browser,
  74. os,
  75. language,
  76. });
  77. if (result.conversation_id) {
  78. setConversationId(result.conversation_id);
  79. setConversationStatus(result.status);
  80. }
  81. }, []);
  82. useEffect(() => {
  83. if (visitorId === null) {
  84. return;
  85. }
  86. initializeConversation(visitorId).catch((error) =>
  87. console.error("初始化对话失败:", error)
  88. );
  89. }, [initializeConversation, visitorId]);
  90. // 标记客服消息已读(readerIsAgent = false 表示访客读取了客服的消息)
  91. const handleMarkAgentMessagesRead = useCallback(
  92. async (conversationIdParam?: number, readerIsAgentParam?: boolean) => {
  93. const targetConversationId = conversationIdParam ?? conversationId;
  94. const targetReaderIsAgent = readerIsAgentParam ?? false;
  95. if (!targetConversationId) {
  96. return;
  97. }
  98. const result = await markMessagesRead(targetConversationId, targetReaderIsAgent);
  99. if (!result || result.message_ids.length === 0) {
  100. return;
  101. }
  102. const idSet = new Set(result.message_ids);
  103. setMessages((prev) =>
  104. prev.map((msg) =>
  105. idSet.has(msg.id)
  106. ? {
  107. ...msg,
  108. is_read: true,
  109. read_at: result.read_at ?? msg.read_at ?? null,
  110. }
  111. : msg
  112. )
  113. );
  114. },
  115. [conversationId]
  116. );
  117. // 拉取历史消息
  118. const loadMessages = useCallback(async () => {
  119. if (!conversationId) {
  120. return;
  121. }
  122. setLoadingMessages(true);
  123. try {
  124. const data = await fetchMessages(conversationId);
  125. setMessages(data);
  126. // 注意:不再自动标记客服消息为已读,而是通过滚动检测来处理
  127. } catch (error) {
  128. console.error("拉取消息失败:", error);
  129. } finally {
  130. setLoadingMessages(false);
  131. }
  132. }, [conversationId]);
  133. useEffect(() => {
  134. loadMessages();
  135. }, [loadMessages]);
  136. // 收到新消息时更新状态(包括访客消息和客服消息)
  137. const handleNewMessage = useCallback(
  138. (message: MessageItem) => {
  139. if (!conversationId || message.conversation_id !== conversationId) {
  140. return;
  141. }
  142. setMessages((prev) => {
  143. const exists = prev.some((item) => item.id === message.id);
  144. if (exists) {
  145. return prev;
  146. }
  147. return [...prev, message];
  148. });
  149. // 注意:不再自动标记客服消息为已读,而是通过滚动检测来处理
  150. },
  151. [conversationId]
  152. );
  153. // 处理 WebSocket 的已读事件
  154. // 只有当客服读取了访客的消息时(reader_is_agent === true),才更新访客消息的已读状态
  155. const handleMessagesReadEvent = useCallback(
  156. (payload: MessagesReadPayload) => {
  157. if (!conversationId) {
  158. return;
  159. }
  160. // 检查对话ID是否匹配
  161. const payloadConversationId = payload?.conversation_id;
  162. if (payloadConversationId && payloadConversationId !== conversationId) {
  163. return;
  164. }
  165. // 只有当 reader_is_agent === true 时,才表示客服读取了访客的消息
  166. // 此时应该更新访客消息(sender_is_agent === false)的已读状态
  167. if (payload?.reader_is_agent !== true) {
  168. return;
  169. }
  170. const ids = Array.isArray(payload?.message_ids)
  171. ? payload.message_ids
  172. : [];
  173. if (ids.length === 0) {
  174. return;
  175. }
  176. const idSet = new Set(ids);
  177. const readAt = payload?.read_at;
  178. setMessages((prev) =>
  179. prev.map((msg) =>
  180. // 只更新访客自己的消息(sender_is_agent === false)的已读状态
  181. idSet.has(msg.id) && !msg.sender_is_agent
  182. ? {
  183. ...msg,
  184. is_read: true,
  185. read_at: readAt ?? msg.read_at ?? null,
  186. }
  187. : msg
  188. )
  189. );
  190. },
  191. [conversationId]
  192. );
  193. const handleWebSocketMessage = useCallback(
  194. (event: WSMessage<ChatWebSocketPayload>) => {
  195. if (!event) {
  196. return;
  197. }
  198. if (event.type === "new_message" && event.data) {
  199. handleNewMessage(event.data as MessageItem);
  200. } else if (event.type === "messages_read") {
  201. // 确保处理已读事件,并传入对话ID
  202. const payload = event.data as MessagesReadPayload;
  203. if (!payload.conversation_id && event.conversation_id) {
  204. payload.conversation_id = event.conversation_id;
  205. }
  206. handleMessagesReadEvent(payload);
  207. }
  208. },
  209. [handleMessagesReadEvent, handleNewMessage]
  210. );
  211. useWebSocket<ChatWebSocketPayload>({
  212. conversationId,
  213. enabled: Boolean(conversationId),
  214. isVisitor: true, // 访客端明确设置为 true
  215. onMessage: handleWebSocketMessage,
  216. onError: (error) => {
  217. console.error("WebSocket 连接错误(访客端):", error);
  218. },
  219. });
  220. const handleSendMessage = useCallback(async () => {
  221. if (!conversationId || !input.trim() || sending) {
  222. return;
  223. }
  224. const messageContent = input.trim();
  225. setSending(true);
  226. try {
  227. await sendMessage({
  228. conversationId,
  229. content: messageContent,
  230. senderIsAgent: false,
  231. });
  232. setInput("");
  233. } catch (error) {
  234. console.error("❌ 发送消息失败:", error);
  235. alert((error as Error).message || "发送消息失败,请稍后重试");
  236. } finally {
  237. setSending(false);
  238. }
  239. }, [conversationId, input, sending]);
  240. const headerStatus = useMemo(() => {
  241. if (!conversationId) {
  242. return "正在建立会话...";
  243. }
  244. return `会话 #${conversationId} · ${
  245. conversationStatus === "open" ? "进行中" : "已关闭"
  246. }`;
  247. }, [conversationId, conversationStatus]);
  248. if (visitorId === null) {
  249. return (
  250. <div className="flex items-center justify-center min-h-screen bg-gray-50 text-gray-500">
  251. 正在初始化对话...
  252. </div>
  253. );
  254. }
  255. return (
  256. <div className="flex flex-col h-screen bg-gray-50">
  257. <div className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-white p-4 shadow-md">
  258. <h1 className="text-xl font-bold">访客聊天</h1>
  259. <div className="text-sm opacity-90 mt-1">{headerStatus}</div>
  260. </div>
  261. <MessageList
  262. messages={messages}
  263. loading={loadingMessages}
  264. highlightKeyword=""
  265. onHighlightClear={noopHighlight}
  266. currentUserIsAgent={false}
  267. disableAutoScroll
  268. conversationId={conversationId}
  269. onMarkMessagesRead={handleMarkAgentMessagesRead}
  270. />
  271. <MessageInput
  272. value={input}
  273. onChange={setInput}
  274. onSubmit={handleSendMessage}
  275. sending={sending}
  276. />
  277. </div>
  278. );
  279. }