useConversations.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. fetchConversations,
  5. searchConversations,
  6. } from "../../agent/services/conversationApi";
  7. import { ConversationSummary, VisitorStatusUpdatePayload } from "../../agent/types";
  8. import { useWebSocket } from "./useWebSocket";
  9. import { WSMessage } from "@/lib/websocket";
  10. import { ChatWebSocketPayload } from "../../agent/types";
  11. const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
  12. [...list].sort(
  13. (a, b) =>
  14. new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
  15. );
  16. import type { ConversationFilter } from "@/components/dashboard/ConversationHeader";
  17. interface UseConversationsOptions {
  18. agentId?: number | null; // 客服ID(用于建立 WebSocket 连接接收全局事件)
  19. filter?: ConversationFilter; // 会话过滤类型
  20. }
  21. export function useConversations(options?: UseConversationsOptions) {
  22. const { agentId, filter = "all" } = options || {};
  23. const [conversations, setConversations] = useState<ConversationSummary[]>([]);
  24. const [filteredConversations, setFilteredConversations] = useState<
  25. ConversationSummary[]
  26. >([]);
  27. const [selectedConversationId, setSelectedConversationId] = useState<
  28. number | null
  29. >(null);
  30. const [searchQuery, setSearchQuery] = useState("");
  31. const [loading, setLoading] = useState(true);
  32. const [isInitialLoad, setIsInitialLoad] = useState(true);
  33. const searchRef = useRef("");
  34. // 根据 filter 过滤会话
  35. const applyFilter = useCallback(
  36. (conversations: ConversationSummary[]): ConversationSummary[] => {
  37. if (!agentId) {
  38. return conversations;
  39. }
  40. switch (filter) {
  41. case "mine":
  42. // 只显示当前用户参与过的会话(has_participated === true)
  43. // 即当前用户在该会话中发送过消息的会话
  44. return conversations.filter((conv) => conv.has_participated === true);
  45. case "others":
  46. // 显示除了当前用户参与过的其他人的会话(has_participated !== true)
  47. return conversations.filter((conv) => conv.has_participated !== true);
  48. case "all":
  49. default:
  50. return conversations;
  51. }
  52. },
  53. [agentId, filter]
  54. );
  55. const loadConversations = useCallback(async () => {
  56. setLoading(true);
  57. try {
  58. const data = await fetchConversations(agentId ?? undefined);
  59. setConversations(data);
  60. const filtered = applyFilter(data);
  61. if (!searchRef.current.trim()) {
  62. setFilteredConversations(filtered);
  63. }
  64. setSelectedConversationId((prev) => {
  65. if (prev) {
  66. return prev;
  67. }
  68. return filtered.length > 0 ? filtered[0].id : null;
  69. });
  70. } catch (error) {
  71. console.error(error);
  72. } finally {
  73. setLoading(false);
  74. setIsInitialLoad(false);
  75. }
  76. }, [applyFilter, agentId, filter]);
  77. useEffect(() => {
  78. loadConversations();
  79. }, [loadConversations]);
  80. // 当 filter 改变时,重新应用过滤(不重新加载数据)
  81. useEffect(() => {
  82. if (isInitialLoad) {
  83. return;
  84. }
  85. const filtered = applyFilter(conversations);
  86. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  87. }, [filter, conversations, isInitialLoad, applyFilter]);
  88. useEffect(() => {
  89. if (isInitialLoad) {
  90. return;
  91. }
  92. const handler = setTimeout(async () => {
  93. const query = searchQuery.trim();
  94. searchRef.current = query;
  95. if (!query) {
  96. const filtered = applyFilter(conversations);
  97. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  98. return;
  99. }
  100. try {
  101. setLoading(true);
  102. const data = await searchConversations(query, agentId ?? undefined);
  103. const filtered = applyFilter(data);
  104. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  105. } catch (error) {
  106. console.error(error);
  107. setFilteredConversations([]);
  108. } finally {
  109. setLoading(false);
  110. }
  111. }, 300);
  112. return () => clearTimeout(handler);
  113. }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId]);
  114. const selectConversation = useCallback((conversationId: number | null) => {
  115. setSelectedConversationId((prev) =>
  116. prev === conversationId ? prev : conversationId
  117. );
  118. }, []);
  119. const updateConversation = useCallback(
  120. (
  121. conversationId: number,
  122. updater: (conversation: ConversationSummary) => ConversationSummary,
  123. options?: { skipResort?: boolean }
  124. ) => {
  125. const applyUpdate = (list: ConversationSummary[]) => {
  126. let changed = false;
  127. const next = list.map((conv) => {
  128. if (conv.id === conversationId) {
  129. changed = true;
  130. return updater(conv);
  131. }
  132. return conv;
  133. });
  134. if (!changed) {
  135. return list;
  136. }
  137. if (options?.skipResort) {
  138. return next;
  139. }
  140. return sortByUpdatedAtDesc(next);
  141. };
  142. setConversations((prev) => applyUpdate(prev));
  143. setFilteredConversations((prev) => {
  144. if (searchRef.current && !prev.some((item) => item.id === conversationId)) {
  145. return prev;
  146. }
  147. return applyUpdate(prev);
  148. });
  149. },
  150. []
  151. );
  152. const setAllConversations = useCallback((data: ConversationSummary[]) => {
  153. setConversations(data);
  154. if (!searchRef.current.trim()) {
  155. const filtered = applyFilter(data);
  156. setFilteredConversations(filtered);
  157. }
  158. }, [applyFilter]);
  159. const hasConversation = useCallback(
  160. (conversationId: number) => {
  161. return conversations.some((conv) => conv.id === conversationId);
  162. },
  163. [conversations]
  164. );
  165. // 建立全局 WebSocket 连接以接收 visitor_status_update 等全局事件
  166. // 使用第一个对话的 ID(如果存在),否则不建立连接
  167. const globalConversationId = conversations.length > 0 ? conversations[0].id : null;
  168. // 处理 visitor_status_update 事件
  169. const handleVisitorStatusUpdate = useCallback(
  170. (event: WSMessage<ChatWebSocketPayload>) => {
  171. if (event.type === "visitor_status_update" && event.data) {
  172. const payload = event.data as VisitorStatusUpdatePayload;
  173. if (payload?.conversation_id) {
  174. if (payload.is_online === true) {
  175. // 在线:更新为当前时间(实时更新在线状态)
  176. updateConversation(payload.conversation_id, (conv) => ({
  177. ...conv,
  178. last_seen_at: new Date().toISOString(),
  179. }));
  180. }
  181. // 离线时,last_seen_at 会在后端更新,这里不需要特殊处理
  182. // 因为对话列表会定期刷新,或者通过其他方式更新
  183. }
  184. }
  185. },
  186. [updateConversation]
  187. );
  188. // 建立全局 WebSocket 连接(用于接收全局事件)
  189. useWebSocket<ChatWebSocketPayload>({
  190. conversationId: globalConversationId,
  191. enabled: Boolean(globalConversationId && agentId),
  192. isVisitor: false,
  193. agentId: agentId ?? undefined,
  194. onMessage: handleVisitorStatusUpdate,
  195. onError: (error) => {
  196. // 静默处理错误,避免影响用户体验
  197. },
  198. onClose: () => {
  199. // 静默处理关闭,避免影响用户体验
  200. },
  201. });
  202. const contextValue = useMemo(
  203. () => ({
  204. conversations,
  205. filteredConversations,
  206. selectedConversationId,
  207. searchQuery,
  208. loading,
  209. isInitialLoad,
  210. setSearchQuery,
  211. selectConversation,
  212. refresh: loadConversations,
  213. updateConversation,
  214. setAllConversations,
  215. hasConversation,
  216. }),
  217. [
  218. conversations,
  219. filteredConversations,
  220. selectedConversationId,
  221. searchQuery,
  222. loading,
  223. isInitialLoad,
  224. selectConversation,
  225. loadConversations,
  226. updateConversation,
  227. setAllConversations,
  228. setSearchQuery,
  229. hasConversation,
  230. ]
  231. );
  232. return contextValue;
  233. }