DashboardShell.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. "use client";
  2. import { useCallback, useMemo, useState } from "react";
  3. import { useAuth } from "@/features/agent/hooks/useAuth";
  4. import { useConversations } from "@/features/agent/hooks/useConversations";
  5. import { useMessages } from "@/features/agent/hooks/useMessages";
  6. import { useProfile } from "@/features/agent/hooks/useProfile";
  7. import { Profile } from "@/features/agent/types";
  8. import { ChatHeader } from "./ChatHeader";
  9. import { ConversationSidebar } from "./ConversationSidebar";
  10. import { DashboardHeader } from "./DashboardHeader";
  11. import { MessageInput } from "./MessageInput";
  12. import { MessageList } from "./MessageList";
  13. import { NavigationSidebar } from "./NavigationSidebar";
  14. import { ProfileModal } from "./ProfileModal";
  15. import { VisitorDetailPanel } from "./VisitorDetailPanel";
  16. export function DashboardShell() {
  17. // 登录状态:负责从本地存储读取客服信息,并提供登出方法
  18. const { agent, loading: authLoading, logout } = useAuth();
  19. // 个人资料状态
  20. const [profileModalOpen, setProfileModalOpen] = useState(false);
  21. const {
  22. profile,
  23. loading: profileLoading,
  24. refresh: refreshProfile,
  25. update: updateProfile,
  26. upload: uploadAvatar,
  27. } = useProfile({
  28. userId: agent?.id ?? null,
  29. enabled: Boolean(agent?.id),
  30. });
  31. // 会话状态:包含会话列表、搜索关键字、选中的会话等
  32. const {
  33. conversations,
  34. filteredConversations,
  35. selectedConversationId,
  36. searchQuery,
  37. loading,
  38. isInitialLoad,
  39. setSearchQuery,
  40. selectConversation,
  41. updateConversation,
  42. } = useConversations();
  43. // 输入框内容与搜索高亮关键字
  44. const [messageInput, setMessageInput] = useState("");
  45. const [highlightKeyword, setHighlightKeyword] = useState("");
  46. // 当前选中的会话信息,供右侧访客详情展示
  47. const selectedConversation = useMemo(
  48. () =>
  49. conversations.find(
  50. (conversation) => conversation.id === selectedConversationId
  51. ) ?? null,
  52. [conversations, selectedConversationId]
  53. );
  54. // 消息层:负责消息列表、未读状态、访客详情以及 WebSocket
  55. const {
  56. messages,
  57. loadingMessages,
  58. sending,
  59. conversationDetail,
  60. refreshConversationDetail,
  61. refreshMessages,
  62. sendMessage,
  63. markMessagesAsRead,
  64. updateContactInfo,
  65. } = useMessages({
  66. conversationId: selectedConversationId,
  67. agentId: agent?.id ?? null,
  68. updateConversation,
  69. });
  70. // 左侧选择会话时,记录关键字用于消息高亮
  71. const handleConversationSelect = useCallback(
  72. (conversationId: number) => {
  73. if (searchQuery.trim()) {
  74. setHighlightKeyword(searchQuery.trim());
  75. } else {
  76. setHighlightKeyword("");
  77. }
  78. selectConversation(conversationId);
  79. },
  80. [searchQuery, selectConversation]
  81. );
  82. // 发送消息:调用 service 后清空输入框
  83. const handleSendMessage = useCallback(async () => {
  84. const content = messageInput.trim();
  85. if (!content) {
  86. return;
  87. }
  88. try {
  89. await sendMessage(content);
  90. setMessageInput("");
  91. } catch (error) {
  92. alert((error as Error).message);
  93. }
  94. }, [messageInput, sendMessage]);
  95. // 标记当前会话全部消息为已读
  96. const handleMarkAllRead = useCallback(() => {
  97. if (selectedConversationId) {
  98. markMessagesAsRead(selectedConversationId, true);
  99. }
  100. }, [markMessagesAsRead, selectedConversationId]);
  101. // 手动刷新消息与访客详情
  102. const handleRefreshChat = useCallback(() => {
  103. if (!selectedConversationId) return;
  104. refreshMessages(selectedConversationId);
  105. refreshConversationDetail(selectedConversationId);
  106. }, [refreshConversationDetail, refreshMessages, selectedConversationId]);
  107. // 单独刷新访客详情
  108. const handleRefreshVisitor = useCallback(() => {
  109. if (!selectedConversationId) return;
  110. refreshConversationDetail(selectedConversationId);
  111. }, [refreshConversationDetail, selectedConversationId]);
  112. // 当前会话未读数(优先使用详情返回的数据)
  113. const selectedUnreadCount =
  114. conversationDetail?.unread_count ??
  115. selectedConversation?.unread_count ??
  116. 0;
  117. // 3 秒后清除搜索高亮
  118. const clearHighlight = useCallback(() => {
  119. setHighlightKeyword("");
  120. }, []);
  121. // 处理个人资料更新
  122. const handleProfileUpdate = useCallback(
  123. (updated: Profile) => {
  124. // 个人资料更新后,刷新缓存(这里可以通过更新 agent 状态来触发UI更新)
  125. refreshProfile();
  126. },
  127. [refreshProfile]
  128. );
  129. if (authLoading || (loading && isInitialLoad)) {
  130. return (
  131. <div className="flex justify-center items-center min-h-screen bg-gray-50">
  132. <div className="text-lg text-gray-600">加载中...</div>
  133. </div>
  134. );
  135. }
  136. if (!agent) {
  137. return null;
  138. }
  139. return (
  140. <div className="flex h-screen bg-gray-50 overflow-hidden">
  141. <NavigationSidebar />
  142. <div className="flex-1 flex flex-col min-h-0">
  143. <DashboardHeader
  144. username={agent.username}
  145. role={agent.role}
  146. avatarUrl={profile?.avatar_url}
  147. onLogout={logout}
  148. onProfileClick={() => setProfileModalOpen(true)}
  149. />
  150. <div className="flex flex-1 min-h-0 overflow-hidden">
  151. <ConversationSidebar
  152. conversations={filteredConversations}
  153. selectedConversationId={selectedConversationId}
  154. searchQuery={searchQuery}
  155. onSearchChange={setSearchQuery}
  156. onSelectConversation={handleConversationSelect}
  157. />
  158. <div className="flex-1 flex flex-col bg-white min-h-0">
  159. {selectedConversationId ? (
  160. <>
  161. <ChatHeader
  162. conversationId={selectedConversationId}
  163. lastSeenAt={conversationDetail?.last_seen_at}
  164. unreadCount={selectedUnreadCount}
  165. onMarkAllRead={handleMarkAllRead}
  166. onRefresh={handleRefreshChat}
  167. />
  168. <MessageList
  169. messages={messages}
  170. loading={loadingMessages}
  171. highlightKeyword={highlightKeyword}
  172. onHighlightClear={clearHighlight}
  173. currentUserIsAgent={true}
  174. conversationId={selectedConversationId ?? null}
  175. onMarkMessagesRead={markMessagesAsRead}
  176. />
  177. <MessageInput
  178. value={messageInput}
  179. onChange={setMessageInput}
  180. onSubmit={handleSendMessage}
  181. sending={sending}
  182. />
  183. </>
  184. ) : (
  185. <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
  186. 选择一个对话开始聊天
  187. </div>
  188. )}
  189. </div>
  190. <VisitorDetailPanel
  191. conversation={selectedConversation}
  192. detail={conversationDetail}
  193. onRefresh={handleRefreshVisitor}
  194. onUpdateContact={updateContactInfo}
  195. />
  196. </div>
  197. </div>
  198. {/* 个人资料弹窗 */}
  199. <ProfileModal
  200. profile={profile}
  201. open={profileModalOpen}
  202. onClose={() => setProfileModalOpen(false)}
  203. onUpdate={handleProfileUpdate}
  204. />
  205. </div>
  206. );
  207. }