You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
10 KiB
356 lines
10 KiB
/**
|
|
* 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<typeof setTimeout> | 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<string, unknown>).ChatbotSDK = ChatbotSDK;
|
|
}
|
|
|
|
export default ChatbotSDK;
|