DashboardShell.tsx 18 KB

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