DashboardShell.tsx 14 KB

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