DashboardShell.tsx 17 KB

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