"use client" import { CheckCircle2Icon, Maximize2Icon, Minimize2Icon, MinusIcon, RotateCwIcon, XIcon, } from "lucide-react" import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, } from "react" import { useShallow } from "zustand/react/shallow" import { KefuConnectionStatus } from "@/components/kefu/connection-status" import { CustomerMessageEditor } from "@/components/kefu/customer-message-editor" import { KefuMessageList, type KefuMessageListHandle, } from "@/components/kefu/message-list" import { bindKefuHostBridge, requestKefuHostClose, requestKefuHostMinimize, requestKefuHostToggleMaximize, } from "@/lib/kefu-host-bridge" import { useKefuChatStore } from "@/lib/stores/kefu-chat" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" function useKefuSystemTheme() { useLayoutEffect(() => { if (typeof window === "undefined") { return } const root = document.documentElement const query = window.matchMedia("(prefers-color-scheme: dark)") const previousDarkClass = root.classList.contains("dark") const previousColorScheme = root.style.colorScheme const syncTheme = () => { const isDark = query.matches root.classList.toggle("dark", isDark) root.style.colorScheme = isDark ? "dark" : "light" } syncTheme() query.addEventListener("change", syncTheme) return () => { query.removeEventListener("change", syncTheme) root.classList.toggle("dark", previousDarkClass) root.style.colorScheme = previousColorScheme } }, []) } function isEmbeddedInHost() { if (typeof window === "undefined") { return false } try { return window.parent !== window } catch { return false } } export function KefuChatShell() { useKefuSystemTheme() const messageListRef = useRef(null) const [isEmbedded] = useState(() => isEmbeddedInHost()) const [isMaximized, setIsMaximized] = useState(false) const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false) const [isClosingConversation, setIsClosingConversation] = useState(false) const [isConversationClosed, setIsConversationClosed] = useState(false) const { title, subtitle, themeColor, conversation, messages, messagesHasMore, messagesLoadingMore, loadOlderMessages, status, error, isOpen, isVisible, setIsOpen, setIsVisible, bootstrap, handleSendMessage, uploadMessageImage, sendAttachment, retry, disconnectSocket, markConversationRead, closeConversation, } = useKefuChatStore( useShallow((state) => ({ title: state.title, subtitle: state.subtitle, themeColor: state.themeColor, conversation: state.conversation, messages: state.messages, messagesHasMore: state.messagesHasMore, messagesLoadingMore: state.messagesLoadingMore, loadOlderMessages: state.loadOlderMessages, status: state.status, error: state.error, isOpen: state.isOpen, isVisible: state.isVisible, setIsOpen: state.setIsOpen, setIsVisible: state.setIsVisible, bootstrap: state.bootstrap, handleSendMessage: state.handleSendMessage, uploadMessageImage: state.uploadMessageImage, sendAttachment: state.sendAttachment, retry: state.retry, disconnectSocket: state.disconnectSocket, markConversationRead: state.markConversationRead, closeConversation: state.closeConversation, })) ) const safeMessages = Array.isArray(messages) ? messages : [] const maybeMarkConversationRead = useCallback(() => { if (!isVisible || !conversation || typeof document === "undefined") { return } if (document.visibilityState !== "visible") { return } void markConversationRead().catch((readError) => { console.error("Failed to mark kefu conversation read", readError) }) }, [conversation?.id, isVisible, markConversationRead]) useEffect(() => { return bindKefuHostBridge({ onOpen: () => { setIsOpen(true) setIsVisible(true) }, onMinimize: () => { setIsVisible(false) }, onMaximizedChange: (nextIsMaximized) => { setIsMaximized(nextIsMaximized) }, }) }, [setIsOpen, setIsVisible]) useEffect(() => { bootstrap() return () => { if (!isOpen) { disconnectSocket() } } }, [isOpen, bootstrap, disconnectSocket]) useEffect(() => { maybeMarkConversationRead() }, [maybeMarkConversationRead, safeMessages.length]) useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { maybeMarkConversationRead() } } const handleFocus = () => { maybeMarkConversationRead() } document.addEventListener("visibilitychange", handleVisibilityChange) window.addEventListener("focus", handleFocus) return () => { document.removeEventListener("visibilitychange", handleVisibilityChange) window.removeEventListener("focus", handleFocus) } }, [maybeMarkConversationRead]) async function handleSend(content: string) { await handleSendMessage(content) messageListRef.current?.scrollToBottom() } function handleMinimize() { setIsVisible(false) requestKefuHostMinimize() } function handleToggleMaximize() { requestKefuHostToggleMaximize() } function handleRestartConversation() { window.location.reload() } async function confirmCloseConversation() { if (isClosingConversation) { return } setIsClosingConversation(true) try { if (conversation?.id) { await closeConversation() } setIsCloseDialogOpen(false) if (isEmbedded) { requestKefuHostClose() } else { setIsConversationClosed(true) } } catch (closeError) { window.alert(closeError instanceof Error ? closeError.message : "关闭会话失败") } finally { setIsClosingConversation(false) } } useEffect(() => { if (!isCloseDialogOpen) { return } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && !isClosingConversation) { setIsCloseDialogOpen(false) } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [isCloseDialogOpen, isClosingConversation]) if (isConversationClosed) { return (
{title}
{subtitle ? (
{subtitle}
) : null}
会话已结束

当前客服会话已关闭,如需继续咨询可以重新发起对话。

) } return (
{title}
{subtitle}
{status !== "connected" ? ( ) : null} {isEmbedded ? ( <> ) : null}
{error ? (
{error}
) : null}
{ if (!isClosingConversation) { setIsCloseDialogOpen(open) } }} > 结束当前对话? 结束会话,客服将无法再查看您的消息记录,如需再次联系请重新发起对话。
) }