DashboardShell.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. "use client";
  2. import { useCallback, useMemo, useState } from "react";
  3. import { usePathname, useRouter, useSearchParams } from "next/navigation";
  4. import { useAuth } from "@/features/agent/hooks/useAuth";
  5. import { useConversations } from "@/features/agent/hooks/useConversations";
  6. import { useMessages } from "@/features/agent/hooks/useMessages";
  7. import { initInternalConversation } from "@/features/agent/services/conversationApi";
  8. import { toast } from "@/hooks/useToast";
  9. import { useProfile } from "@/features/agent/hooks/useProfile";
  10. import { Profile } from "@/features/agent/types";
  11. import { ResponsiveLayout } from "@/components/layout";
  12. import { LAYOUT } from "@/lib/constants/breakpoints";
  13. import {
  14. getPageFromSearchParams,
  15. getAgentPage,
  16. } from "@/lib/constants/agent-pages";
  17. import { Loader2 } from "lucide-react";
  18. import { ChatHeader } from "./ChatHeader";
  19. import { ConversationSidebar } from "./ConversationSidebar";
  20. import { MessageInput } from "./MessageInput";
  21. import { MessageList } from "./MessageList";
  22. import { NavigationSidebar, type NavigationPage } from "./NavigationSidebar";
  23. import { ProfileModal } from "./ProfileModal";
  24. import { VisitorDetailPanel } from "./VisitorDetailPanel";
  25. import { useSoundNotification } from "@/hooks/useSoundNotification";
  26. import { usePageTitle } from "@/hooks/usePageTitle";
  27. export function DashboardShell() {
  28. const pathname = usePathname();
  29. const router = useRouter();
  30. const searchParams = useSearchParams();
  31. const currentPage = getPageFromSearchParams(searchParams);
  32. // 登录状态:负责从本地存储读取客服信息,并提供登出方法
  33. const { agent, loading: authLoading, logout } = useAuth();
  34. // 个人资料状态
  35. const [profileModalOpen, setProfileModalOpen] = useState(false);
  36. const {
  37. profile,
  38. loading: profileLoading,
  39. refresh: refreshProfile,
  40. update: updateProfile,
  41. upload: uploadAvatar,
  42. } = useProfile({
  43. userId: agent?.id ?? null,
  44. enabled: Boolean(agent?.id),
  45. });
  46. // 会话过滤状态
  47. const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
  48. // 声音通知开关(客服端)
  49. const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(false);
  50. const currentPageMeta = getAgentPage(currentPage);
  51. const isInternalChat = currentPage === "internal-chat";
  52. const isChatPage = currentPageMeta?.isChatPage ?? false;
  53. // 会话状态:访客对话或内部对话(知识库测试)根据 currentPage 切换
  54. const {
  55. conversations,
  56. filteredConversations,
  57. selectedConversationId,
  58. searchQuery,
  59. loading,
  60. isInitialLoad,
  61. setSearchQuery,
  62. selectConversation,
  63. updateConversation,
  64. refresh: refreshConversations,
  65. hasConversation,
  66. } = useConversations({
  67. agentId: agent?.id ?? null,
  68. filter: conversationFilter,
  69. listType: isInternalChat ? "internal" : "visitor",
  70. });
  71. // 计算总未读消息数
  72. const totalUnreadCount = useMemo(() => {
  73. return conversations.reduce((sum, conv) => sum + (conv.unread_count ?? 0), 0);
  74. }, [conversations]);
  75. // 更新页面标题显示未读消息数
  76. usePageTitle(totalUnreadCount, "AI-CS");
  77. // 输入框内容与搜索高亮关键字
  78. const [messageInput, setMessageInput] = useState("");
  79. const [highlightKeyword, setHighlightKeyword] = useState("");
  80. // 当前选中的会话信息,供右侧访客详情展示
  81. const selectedConversation = useMemo(
  82. () =>
  83. conversations.find(
  84. (conversation) => conversation.id === selectedConversationId
  85. ) ?? null,
  86. [conversations, selectedConversationId]
  87. );
  88. // 消息层:负责消息列表、未读状态、访客详情以及 WebSocket
  89. const {
  90. messages,
  91. loadingMessages,
  92. sending,
  93. conversationDetail,
  94. refreshConversationDetail,
  95. refreshMessages,
  96. sendMessage,
  97. markMessagesAsRead,
  98. updateContactInfo,
  99. includeAIMessages,
  100. toggleAIMessages,
  101. aiThinking,
  102. } = useMessages({
  103. conversationId: selectedConversationId,
  104. agentId: agent?.id ?? null,
  105. updateConversation,
  106. refreshConversations,
  107. hasConversation,
  108. soundEnabled,
  109. forceIncludeAIMessages: isInternalChat,
  110. });
  111. // 左侧选择会话时,记录关键字用于消息高亮
  112. const handleConversationSelect = useCallback(
  113. (conversationId: number) => {
  114. if (searchQuery.trim()) {
  115. setHighlightKeyword(searchQuery.trim());
  116. } else {
  117. setHighlightKeyword("");
  118. }
  119. selectConversation(conversationId);
  120. },
  121. [searchQuery, selectConversation]
  122. );
  123. // 发送消息:调用 service 后清空输入框
  124. const handleSendMessage = useCallback(async (fileInfo?: { file_url: string; file_type: string; file_name: string; file_size: number; mime_type: string }) => {
  125. const content = messageInput.trim();
  126. try {
  127. await sendMessage(content, fileInfo);
  128. setMessageInput("");
  129. } catch (error) {
  130. toast.error((error as Error).message);
  131. }
  132. }, [messageInput, sendMessage]);
  133. // 标记当前会话全部消息为已读
  134. const handleMarkAllRead = useCallback(() => {
  135. if (selectedConversationId) {
  136. markMessagesAsRead(selectedConversationId, true);
  137. }
  138. }, [markMessagesAsRead, selectedConversationId]);
  139. // 手动刷新消息与访客详情
  140. const handleRefreshChat = useCallback(() => {
  141. if (!selectedConversationId) return;
  142. refreshMessages(selectedConversationId);
  143. refreshConversationDetail(selectedConversationId);
  144. }, [refreshConversationDetail, refreshMessages, selectedConversationId]);
  145. // 单独刷新访客详情
  146. const handleRefreshVisitor = useCallback(() => {
  147. if (!selectedConversationId) return;
  148. refreshConversationDetail(selectedConversationId);
  149. }, [refreshConversationDetail, selectedConversationId]);
  150. // 当前会话未读数(优先使用详情返回的数据)
  151. const selectedUnreadCount =
  152. conversationDetail?.unread_count ??
  153. selectedConversation?.unread_count ??
  154. 0;
  155. // 3 秒后清除搜索高亮
  156. const clearHighlight = useCallback(() => {
  157. setHighlightKeyword("");
  158. }, []);
  159. // 处理个人资料更新
  160. const handleProfileUpdate = useCallback(
  161. (updated: Profile) => {
  162. // 个人资料更新后,刷新缓存(这里可以通过更新 agent 状态来触发UI更新)
  163. refreshProfile();
  164. },
  165. [refreshProfile]
  166. );
  167. // 处理导航切换:更新 URL ?page=,与访客端路由一致,刷新后保留当前页
  168. const handleNavigate = useCallback((page: NavigationPage) => {
  169. router.push(pathname + "?page=" + page);
  170. if (page !== "dashboard" && page !== "internal-chat") {
  171. selectConversation(null);
  172. }
  173. }, [pathname, router, selectConversation]);
  174. // 新建内部对话(知识库测试)- 必须在条件 return 之前声明,保证 Hooks 顺序一致
  175. const handleNewInternalConversation = useCallback(async () => {
  176. if (!agent?.id) return;
  177. try {
  178. const { conversation_id } = await initInternalConversation(agent.id);
  179. refreshConversations();
  180. selectConversation(conversation_id);
  181. } catch (e) {
  182. console.error("创建内部对话失败:", e);
  183. toast.error((e as Error).message || "创建内部对话失败");
  184. }
  185. }, [agent?.id, refreshConversations, selectConversation]);
  186. if (authLoading || (loading && isInitialLoad)) {
  187. return (
  188. <div className="flex justify-center items-center min-h-screen bg-background">
  189. <div className="text-lg text-muted-foreground">加载中...</div>
  190. </div>
  191. );
  192. }
  193. if (!agent) {
  194. return null;
  195. }
  196. const sidebarContent = isChatPage ? (
  197. <div className="flex h-full">
  198. <NavigationSidebar
  199. currentPage={currentPage}
  200. onNavigate={handleNavigate}
  201. onProfileClick={() => setProfileModalOpen(true)}
  202. onLogout={logout}
  203. avatarUrl={profile?.avatar_url}
  204. />
  205. <ConversationSidebar
  206. conversations={filteredConversations}
  207. selectedConversationId={selectedConversationId}
  208. searchQuery={searchQuery}
  209. onSearchChange={setSearchQuery}
  210. onSelectConversation={handleConversationSelect}
  211. filter={conversationFilter}
  212. onFilterChange={setConversationFilter}
  213. mode={isInternalChat ? "internal" : "visitor"}
  214. onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
  215. />
  216. </div>
  217. ) : (
  218. <div className="flex h-full">
  219. <NavigationSidebar
  220. currentPage={currentPage}
  221. onNavigate={handleNavigate}
  222. onProfileClick={() => setProfileModalOpen(true)}
  223. onLogout={logout}
  224. avatarUrl={profile?.avatar_url}
  225. />
  226. </div>
  227. );
  228. const mainContent = (
  229. <div className="flex-1 flex flex-col bg-background min-h-0">
  230. {isChatPage ? (
  231. selectedConversationId ? (
  232. <>
  233. <ChatHeader
  234. conversationId={selectedConversationId}
  235. lastSeenAt={conversationDetail?.last_seen_at}
  236. unreadCount={selectedUnreadCount}
  237. onMarkAllRead={handleMarkAllRead}
  238. onRefresh={handleRefreshChat}
  239. includeAIMessages={includeAIMessages}
  240. onToggleAIMessages={toggleAIMessages}
  241. soundEnabled={soundEnabled}
  242. onToggleSound={toggleSound}
  243. hideAIToggle={isInternalChat}
  244. />
  245. <MessageList
  246. messages={messages}
  247. loading={loadingMessages}
  248. highlightKeyword={highlightKeyword}
  249. onHighlightClear={clearHighlight}
  250. currentUserIsAgent={true}
  251. conversationId={selectedConversationId ?? null}
  252. onMarkMessagesRead={markMessagesAsRead}
  253. internalChatMode={isInternalChat}
  254. bottomSlot={
  255. isInternalChat && aiThinking ? (
  256. <div className="flex justify-start mt-2">
  257. <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
  258. <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
  259. <span>AI 正在思考...</span>
  260. </div>
  261. </div>
  262. ) : null
  263. }
  264. />
  265. <MessageInput
  266. value={messageInput}
  267. onChange={setMessageInput}
  268. onSubmit={handleSendMessage}
  269. sending={sending}
  270. conversationId={selectedConversationId ?? undefined}
  271. />
  272. </>
  273. ) : (
  274. <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
  275. {isInternalChat ? "选择或新建内部对话,测试知识库效果" : "选择一个对话开始聊天"}
  276. </div>
  277. )
  278. ) : (
  279. <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
  280. {(() => {
  281. const PageComponent = currentPageMeta?.component;
  282. return PageComponent != null ? (
  283. <PageComponent embedded={true} />
  284. ) : null;
  285. })()}
  286. </div>
  287. )}
  288. </div>
  289. );
  290. const rightPanelContent = currentPage === "dashboard" && selectedConversationId ? (
  291. <VisitorDetailPanel
  292. conversation={selectedConversation}
  293. detail={conversationDetail}
  294. onRefresh={handleRefreshVisitor}
  295. onUpdateContact={updateContactInfo}
  296. />
  297. ) : undefined;
  298. return (
  299. <>
  300. <ResponsiveLayout
  301. sidebar={sidebarContent}
  302. main={mainContent}
  303. rightPanel={rightPanelContent}
  304. sidebarWidth={isChatPage ? LAYOUT.dashboardSidebarWidth : LAYOUT.navigationWidth}
  305. />
  306. {/* 个人资料弹窗 */}
  307. <ProfileModal
  308. profile={profile}
  309. open={profileModalOpen}
  310. onClose={() => setProfileModalOpen(false)}
  311. onUpdate={handleProfileUpdate}
  312. />
  313. </>
  314. );
  315. }