/** * ChatbotSDK 入口文件 * 单例模式,IIFE 挂载 window.ChatbotSDK * * 参数映射: * integrateId → roleId(客服角色 ID) * userId → accountId(客户账号 ID) * chatId → 自动管理(从 /conversation/list 获取或自动生成) */ import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types'; import { parseConfig } from './config'; import { setDebug, logger } from './logger'; import { setApiConfig } from './api'; import { injectStyles, removeStyles } from './styles'; import { createLauncher, createChatWindow, enableDrag } from './dom'; import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback } from './chat'; import { clearMessages } from './storage'; import { setLocale } from './i18n'; // ==================== 单例状态 ==================== let config: ResolvedConfig | null = null; let isInitialized = false; let launcherEl: HTMLElement | null = null; let windowEl: HTMLElement | null = null; let messagesContainer: HTMLElement | null = null; let inputEl: HTMLTextAreaElement | null = null; let sendBtn: HTMLElement | null = null; let clearBtn: HTMLElement | null = null; let categorySelect: HTMLSelectElement | null = null; let historyPanel: HTMLElement | null = null; let showLoadingFn: (() => HTMLElement) | null = null; let hideLoadingFn: (() => void) | null = null; let dragCleanup: (() => void) | null = null; /** 提示气泡元素 */ let teaserEl: HTMLElement | null = null; /** 提示气泡定时器 */ let teaserTimer: ReturnType | null = null; /** 未读徽章元素(挂在 launcher 内部) */ let badgeEl: HTMLElement | null = null; // ==================== 公开 API ==================== /** 初始化 SDK */ function init(rawConfig: SDKConfig): void { if (isInitialized) { logger.warn('SDK 已初始化,请先调用 destroy() 再重新初始化'); return; } // 1. 配置解析与校验 const parsed = parseConfig(rawConfig); if (!parsed) return; config = parsed; // 2. 设置国际化语言 setLocale(config.locale); // 3. 设置日志级别 setDebug(config.debug); // 4. 设置 API 配置 setApiConfig(config); // 5. 注入样式 injectStyles(config); // 6. 创建悬浮按钮 launcherEl = createLauncher(config, toggle); document.body.appendChild(launcherEl); // 获取未读徽章引用(由 createLauncher 创建) badgeEl = launcherEl.querySelector('.csk-launcher__badge'); // 7. 创建聊天弹窗 const dom = createChatWindow(config); windowEl = dom.window; messagesContainer = dom.messagesContainer; inputEl = dom.inputEl; sendBtn = dom.sendBtn; clearBtn = dom.clearBtn; categorySelect = dom.categorySelect; historyPanel = dom.historyPanel; showLoadingFn = dom.showLoading; hideLoadingFn = dom.hideLoading; document.body.appendChild(windowEl); // 8. 安置提示气泡(挂在 launcher 同层,由配置控制显示) teaserEl = dom.teaserEl; document.body.appendChild(teaserEl); positionTeaser(); // 9. 启用拖拽 const headerEl = windowEl.querySelector('.csk-header') as HTMLElement; if (headerEl) { dragCleanup = enableDrag(headerEl, windowEl); } // 10. 初始化对话模块 initChat(config, { messagesContainer, inputEl, sendBtn, clearBtn, categorySelect, historyPanel, welcomeEl: dom.welcomeEl, newMsgBtn: dom.newMsgBtn, searchInput: dom.searchInput, ariaLiveEl: dom.ariaLiveEl, showLoading: showLoadingFn, hideLoading: hideLoadingFn, }); // 11. 监听知识库分类切换事件 windowEl.addEventListener('csk:categoryChange', ((e: CustomEvent) => { setCategory(e.detail.categoryId); }) as EventListener); // 12. 监听会话管理面板加载事件 windowEl.addEventListener('csk:loadHistory', () => { loadHistoryConversations(); }); // 13. 监听快捷问题芯片点击 windowEl.addEventListener('csk:quickReply', ((e: CustomEvent) => { sendQuickReply(e.detail.text); }) as EventListener); // 14. 监听 AI 消息重试事件 windowEl.addEventListener('csk:retry', ((e: CustomEvent) => { retryFromMessage(e.detail.msgId); }) as EventListener); // 15. 监听消息反馈事件(👍 / 👎) windowEl.addEventListener('csk:feedback', ((e: CustomEvent) => { handleFeedback(e.detail.msgId, e.detail.value); }) as EventListener); // 16. 新消息到达通知(用于未读徽章) windowEl.addEventListener('csk:newMessage', () => { notifyNewMessage(); }); // 17. ESC 键关闭弹窗 document.addEventListener('keydown', onKeyDown); // 17. 窗口 resize 时重新定位 teaser window.addEventListener('resize', onResize); isInitialized = true; logger.lifecycleInit(config.integrateId, config.requestDomain); // 18. 首访提示气泡(延迟显示,弹窗未打开时生效) if (config.showTeaser) { teaserTimer = setTimeout(() => { // 弹窗关闭时才显示 if (windowEl && windowEl.classList.contains('csk-window--hidden')) { showTeaser(); } }, 1500); } // 19. 异步初始化 chatId 和对话历史(不阻塞 UI) initChatHistory().catch(err => { logger.warn('chatId 初始化失败,将在发送消息时重试', err); }); } // ==================== 提示气泡控制 ==================== /** 根据 launcher 位置定位 teaser 气泡 */ function positionTeaser(): void { if (!teaserEl || !launcherEl) return; const rect = launcherEl.getBoundingClientRect(); const isLeft = config?.position === 'left-bottom'; if (isLeft) { teaserEl.style.left = `${rect.left}px`; } else { teaserEl.style.right = `${window.innerWidth - rect.right}px`; } teaserEl.style.bottom = `${window.innerHeight - rect.top + 8}px`; } function showTeaser(): void { if (!teaserEl) return; positionTeaser(); teaserEl.classList.remove('csk-teaser--hidden'); // 点击气泡本身 → 打开弹窗 teaserEl.addEventListener('click', onTeaserClick); // 关闭按钮(阻止冒泡,不打开弹窗) const closeBtn = teaserEl.querySelector('.csk-teaser__close'); if (closeBtn) { closeBtn.addEventListener('click', onTeaserClose); } } function hideTeaser(): void { if (!teaserEl) return; teaserEl.classList.add('csk-teaser--hidden'); teaserEl.removeEventListener('click', onTeaserClick); const closeBtn = teaserEl.querySelector('.csk-teaser__close'); if (closeBtn) closeBtn.removeEventListener('click', onTeaserClose); } function onTeaserClick(e: Event): void { // 关闭按钮点击不打开弹窗 if ((e.target as HTMLElement).closest('.csk-teaser__close')) return; hideTeaser(); open(); } function onTeaserClose(e: Event): void { e.stopPropagation(); hideTeaser(); } function onResize(): void { if (teaserEl && !teaserEl.classList.contains('csk-teaser--hidden')) { positionTeaser(); } } // ==================== 未读徽章 ==================== function showBadge(): void { if (badgeEl) badgeEl.classList.remove('csk-launcher__badge--hidden'); } function hideBadge(): void { if (badgeEl) badgeEl.classList.add('csk-launcher__badge--hidden'); } /** 新消息到达时弹窗关闭,则显示徽章 */ function notifyNewMessage(): void { if (windowEl && windowEl.classList.contains('csk-window--hidden')) { showBadge(); } } // ==================== a11y 焦点陷阱 ==================== /** 弹窗内可聚焦元素选择器 */ const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; function trapFocus(e: KeyboardEvent): void { if (!windowEl || windowEl.classList.contains('csk-window--hidden')) return; if (e.key !== 'Tab') return; const focusables = Array.from(windowEl.querySelectorAll(FOCUSABLE)) as HTMLElement[]; if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey) { // Shift+Tab:在第一个元素上按 → 跳到最后一个 if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { // Tab:在最后一个元素上按 → 跳到第一个 if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } // ==================== 全局键盘事件 ==================== function onKeyDown(e: KeyboardEvent): void { if (e.key === 'Escape' && windowEl && !windowEl.classList.contains('csk-window--hidden')) { close(); } trapFocus(e); } /** 销毁 SDK 实例 */ function destroy(): void { if (!isInitialized) return; if (teaserTimer) { clearTimeout(teaserTimer); teaserTimer = null; } hideTeaser(); if (teaserEl && teaserEl.parentNode) { teaserEl.parentNode.removeChild(teaserEl); teaserEl = null; } if (launcherEl && launcherEl.parentNode) { launcherEl.parentNode.removeChild(launcherEl); launcherEl = null; } if (windowEl && windowEl.parentNode) { windowEl.parentNode.removeChild(windowEl); windowEl = null; } if (dragCleanup) { dragCleanup(); dragCleanup = null; } removeStyles(); document.removeEventListener('keydown', onKeyDown); window.removeEventListener('resize', onResize); const oldIntegrateId = config?.integrateId; config = null; isInitialized = false; messagesContainer = null; inputEl = null; sendBtn = null; clearBtn = null; categorySelect = null; historyPanel = null; showLoadingFn = null; hideLoadingFn = null; badgeEl = null; logger.lifecycleDestroy(oldIntegrateId || ''); } function open(): void { if (!windowEl) return; windowEl.classList.remove('csk-window--hidden'); hideTeaser(); hideBadge(); setTimeout(() => { if (inputEl) inputEl.focus(); }, 80); } function close(): void { if (!windowEl) return; windowEl.classList.add('csk-window--hidden'); } function toggle(): void { if (!windowEl) return; if (windowEl.classList.contains('csk-window--hidden')) { open(); } else { close(); } } function clearHistory(): void { if (!config) return; if (clearBtn) { clearBtn.click(); } else if (confirm('确定清空所有对话记录?')) { clearMessages(config.integrateId); } } // ==================== 挂载到全局 ==================== const ChatbotSDK: ChatbotSDKInstance = { init, destroy, open, close, toggle, clearHistory, }; if (typeof window !== 'undefined') { (window as unknown as Record).ChatbotSDK = ChatbotSDK; } export default ChatbotSDK;