| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- "use client";
- import { useCallback, useEffect, useMemo, useState } from "react";
- import { usePathname, useRouter, useSearchParams } from "next/navigation";
- import { useAuth } from "@/features/agent/hooks/useAuth";
- import { useConversations } from "@/features/agent/hooks/useConversations";
- import { useMessages } from "@/features/agent/hooks/useMessages";
- import { initInternalConversation } from "@/features/agent/services/conversationApi";
- import { toast } from "@/hooks/useToast";
- import { useProfile } from "@/features/agent/hooks/useProfile";
- import { Profile } from "@/features/agent/types";
- import { ResponsiveLayout } from "@/components/layout";
- import { LAYOUT } from "@/lib/constants/breakpoints";
- import {
- getPageFromSearchParams,
- getAgentPage,
- } from "@/lib/constants/agent-pages";
- import { Loader2 } from "lucide-react";
- import { ChatHeader } from "./ChatHeader";
- import { ConversationSidebar } from "./ConversationSidebar";
- import { MessageInput } from "./MessageInput";
- import { MessageList } from "./MessageList";
- import { Checkbox } from "@/components/ui/checkbox";
- import { Label } from "@/components/ui/label";
- import { NavigationSidebar, type NavigationPage } from "./NavigationSidebar";
- import { ProfileModal } from "./ProfileModal";
- import { VisitorDetailPanel } from "./VisitorDetailPanel";
- import { useSoundNotification } from "@/hooks/useSoundNotification";
- import { usePageTitle } from "@/hooks/usePageTitle";
- import { reportFrontendLog } from "@/features/agent/services/systemLogApi";
- export function DashboardShell() {
- const pathname = usePathname();
- const router = useRouter();
- const searchParams = useSearchParams();
- const currentPage = getPageFromSearchParams(searchParams);
- // 登录状态:负责从本地存储读取客服信息,并提供登出方法
- const { agent, loading: authLoading, logout } = useAuth();
- // 前端全局错误上报(最小可用:window error + promise rejection)
- useEffect(() => {
- const onError = (ev: ErrorEvent) => {
- void reportFrontendLog({
- level: "error",
- category: "frontend",
- event: "window_error",
- message: ev.message || "window error",
- meta: {
- filename: ev.filename,
- lineno: ev.lineno,
- colno: ev.colno,
- },
- });
- };
- const onRejection = (ev: PromiseRejectionEvent) => {
- const reason = String(ev.reason ?? "unhandled rejection");
- void reportFrontendLog({
- level: "error",
- category: "frontend",
- event: "unhandled_rejection",
- message: reason.slice(0, 500),
- });
- };
- window.addEventListener("error", onError);
- window.addEventListener("unhandledrejection", onRejection);
- return () => {
- window.removeEventListener("error", onError);
- window.removeEventListener("unhandledrejection", onRejection);
- };
- }, []);
- // 个人资料状态
- const [profileModalOpen, setProfileModalOpen] = useState(false);
- const {
- profile,
- loading: profileLoading,
- refresh: refreshProfile,
- update: updateProfile,
- upload: uploadAvatar,
- } = useProfile({
- userId: agent?.id ?? null,
- enabled: Boolean(agent?.id),
- });
- // 会话过滤状态
- const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
- // 声音通知开关(客服端)
- const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(false);
- const currentPageMeta = getAgentPage(currentPage);
- const isInternalChat = currentPage === "internal-chat";
- const isChatPage = currentPageMeta?.isChatPage ?? false;
- // 会话状态:访客对话或内部对话(知识库测试)根据 currentPage 切换
- const {
- conversations,
- filteredConversations,
- selectedConversationId,
- searchQuery,
- loading,
- isInitialLoad,
- setSearchQuery,
- selectConversation,
- updateConversation,
- refresh: refreshConversations,
- hasConversation,
- } = useConversations({
- agentId: agent?.id ?? null,
- filter: conversationFilter,
- listType: isInternalChat ? "internal" : "visitor",
- });
- // 计算总未读消息数
- const totalUnreadCount = useMemo(() => {
- return conversations.reduce((sum, conv) => sum + (conv.unread_count ?? 0), 0);
- }, [conversations]);
- // 更新页面标题显示未读消息数
- usePageTitle(totalUnreadCount, "AI-CS");
- // 输入框内容与搜索高亮关键字
- const [messageInput, setMessageInput] = useState("");
- const [highlightKeyword, setHighlightKeyword] = useState("");
- // 当前选中的会话信息,供右侧访客详情展示
- const selectedConversation = useMemo(
- () =>
- conversations.find(
- (conversation) => conversation.id === selectedConversationId
- ) ?? null,
- [conversations, selectedConversationId]
- );
- // 消息层:负责消息列表、未读状态、访客详情以及 WebSocket
- const {
- messages,
- loadingMessages,
- sending,
- conversationDetail,
- refreshConversationDetail,
- refreshMessages,
- sendMessage,
- markMessagesAsRead,
- updateContactInfo,
- includeAIMessages,
- toggleAIMessages,
- aiThinking,
- needWebSearch,
- setNeedWebSearch,
- } = useMessages({
- conversationId: selectedConversationId,
- agentId: agent?.id ?? null,
- updateConversation,
- refreshConversations,
- hasConversation,
- soundEnabled,
- forceIncludeAIMessages: isInternalChat,
- });
- // 左侧选择会话时,记录关键字用于消息高亮
- const handleConversationSelect = useCallback(
- (conversationId: number) => {
- if (searchQuery.trim()) {
- setHighlightKeyword(searchQuery.trim());
- } else {
- setHighlightKeyword("");
- }
- selectConversation(conversationId);
- },
- [searchQuery, selectConversation]
- );
- // 发送消息:调用 service 后清空输入框
- const handleSendMessage = useCallback(async (fileInfo?: { file_url: string; file_type: string; file_name: string; file_size: number; mime_type: string }) => {
- const content = messageInput.trim();
- try {
- await sendMessage(content, fileInfo);
- setMessageInput("");
- } catch (error) {
- toast.error((error as Error).message);
- }
- }, [messageInput, sendMessage]);
- // 标记当前会话全部消息为已读
- const handleMarkAllRead = useCallback(() => {
- if (selectedConversationId) {
- markMessagesAsRead(selectedConversationId, true);
- }
- }, [markMessagesAsRead, selectedConversationId]);
- // 手动刷新消息与访客详情
- const handleRefreshChat = useCallback(() => {
- if (!selectedConversationId) return;
- refreshMessages(selectedConversationId);
- refreshConversationDetail(selectedConversationId);
- }, [refreshConversationDetail, refreshMessages, selectedConversationId]);
- // 单独刷新访客详情
- const handleRefreshVisitor = useCallback(() => {
- if (!selectedConversationId) return;
- refreshConversationDetail(selectedConversationId);
- }, [refreshConversationDetail, selectedConversationId]);
- // 当前会话未读数(优先使用详情返回的数据)
- const selectedUnreadCount =
- conversationDetail?.unread_count ??
- selectedConversation?.unread_count ??
- 0;
- // 3 秒后清除搜索高亮
- const clearHighlight = useCallback(() => {
- setHighlightKeyword("");
- }, []);
- // 处理个人资料更新
- const handleProfileUpdate = useCallback(
- (updated: Profile) => {
- // 个人资料更新后,刷新缓存(这里可以通过更新 agent 状态来触发UI更新)
- refreshProfile();
- },
- [refreshProfile]
- );
- // 处理导航切换:更新 URL ?page=,与访客端路由一致,刷新后保留当前页
- const handleNavigate = useCallback((page: NavigationPage) => {
- router.push(pathname + "?page=" + page);
- if (page !== "dashboard" && page !== "internal-chat") {
- selectConversation(null);
- }
- }, [pathname, router, selectConversation]);
- // 新建内部对话(知识库测试)- 必须在条件 return 之前声明,保证 Hooks 顺序一致
- const handleNewInternalConversation = useCallback(async () => {
- if (!agent?.id) return;
- try {
- const { conversation_id } = await initInternalConversation(agent.id);
- refreshConversations();
- selectConversation(conversation_id);
- } catch (e) {
- console.error("创建内部对话失败:", e);
- toast.error((e as Error).message || "创建内部对话失败");
- }
- }, [agent?.id, refreshConversations, selectConversation]);
- if (authLoading || (loading && isInitialLoad)) {
- return (
- <div className="flex justify-center items-center min-h-screen bg-background">
- <div className="text-lg text-muted-foreground">加载中...</div>
- </div>
- );
- }
- if (!agent) {
- return null;
- }
- const sidebarContent = isChatPage ? (
- <div className="flex h-full">
- <NavigationSidebar
- currentPage={currentPage}
- onNavigate={handleNavigate}
- onProfileClick={() => setProfileModalOpen(true)}
- onLogout={logout}
- avatarUrl={profile?.avatar_url}
- unreadChatCount={totalUnreadCount}
- />
- <ConversationSidebar
- conversations={filteredConversations}
- selectedConversationId={selectedConversationId}
- searchQuery={searchQuery}
- onSearchChange={setSearchQuery}
- onSelectConversation={handleConversationSelect}
- filter={conversationFilter}
- onFilterChange={setConversationFilter}
- mode={isInternalChat ? "internal" : "visitor"}
- onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
- />
- </div>
- ) : (
- <div className="flex h-full">
- <NavigationSidebar
- currentPage={currentPage}
- onNavigate={handleNavigate}
- onProfileClick={() => setProfileModalOpen(true)}
- onLogout={logout}
- avatarUrl={profile?.avatar_url}
- unreadChatCount={totalUnreadCount}
- />
- </div>
- );
- const mainContent = (
- <div className="flex-1 flex flex-col bg-background min-h-0">
- {isChatPage ? (
- selectedConversationId ? (
- <>
- <ChatHeader
- conversationId={selectedConversationId}
- lastSeenAt={conversationDetail?.last_seen_at}
- unreadCount={selectedUnreadCount}
- onMarkAllRead={handleMarkAllRead}
- onRefresh={handleRefreshChat}
- includeAIMessages={includeAIMessages}
- onToggleAIMessages={toggleAIMessages}
- soundEnabled={soundEnabled}
- onToggleSound={toggleSound}
- hideAIToggle={isInternalChat}
- />
- <MessageList
- messages={messages}
- loading={loadingMessages}
- highlightKeyword={highlightKeyword}
- onHighlightClear={clearHighlight}
- currentUserIsAgent={true}
- conversationId={selectedConversationId ?? null}
- onMarkMessagesRead={markMessagesAsRead}
- internalChatMode={isInternalChat}
- bottomSlot={
- isInternalChat && aiThinking ? (
- <div className="flex justify-start mt-2">
- <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">
- <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
- <span>AI 正在思考...</span>
- </div>
- </div>
- ) : null
- }
- />
- {/* 知识库测试:联网选项 */}
- {isInternalChat && (
- <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">
- <div className="flex items-center gap-2">
- <Checkbox
- id="internal-need-web-search"
- checked={needWebSearch}
- onCheckedChange={(v) => setNeedWebSearch(Boolean(v))}
- />
- <Label htmlFor="internal-need-web-search" className="cursor-pointer font-normal">
- 本回合联网搜索
- </Label>
- </div>
- </div>
- )}
- <MessageInput
- value={messageInput}
- onChange={setMessageInput}
- onSubmit={handleSendMessage}
- sending={sending}
- conversationId={selectedConversationId ?? undefined}
- />
- </>
- ) : (
- <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
- {isInternalChat ? "选择或新建内部对话,测试知识库效果" : "选择一个对话开始聊天"}
- </div>
- )
- ) : (
- <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
- {(() => {
- const PageComponent = currentPageMeta?.component;
- return PageComponent != null ? (
- <PageComponent embedded={true} />
- ) : null;
- })()}
- </div>
- )}
- </div>
- );
- const rightPanelContent = currentPage === "dashboard" && selectedConversationId ? (
- <VisitorDetailPanel
- conversation={selectedConversation}
- detail={conversationDetail}
- onRefresh={handleRefreshVisitor}
- onUpdateContact={updateContactInfo}
- />
- ) : undefined;
- return (
- <>
- <ResponsiveLayout
- sidebar={sidebarContent}
- main={mainContent}
- rightPanel={rightPanelContent}
- sidebarWidth={isChatPage ? LAYOUT.dashboardSidebarWidth : LAYOUT.navigationWidth}
- />
- {/* 个人资料弹窗 */}
- <ProfileModal
- profile={profile}
- open={profileModalOpen}
- onClose={() => setProfileModalOpen(false)}
- onUpdate={handleProfileUpdate}
- />
- </>
- );
- }
|