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.
291 lines
9.4 KiB
291 lines
9.4 KiB
/**
|
|
* DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器
|
|
*/
|
|
import { ResolvedConfig } from './types';
|
|
import { debounce } from './utils';
|
|
|
|
// ==================== 悬浮按钮 ====================
|
|
|
|
/** 创建悬浮按钮 */
|
|
export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement {
|
|
const launcher = document.createElement('div');
|
|
launcher.id = 'csk-launcher';
|
|
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
|
|
launcher.setAttribute('title', config.title);
|
|
launcher.setAttribute('aria-label', config.title);
|
|
launcher.setAttribute('role', 'button');
|
|
launcher.setAttribute('tabindex', '0');
|
|
|
|
// 图标内容
|
|
launcher.innerHTML = config.launcherIcon;
|
|
|
|
// 点击事件(300ms 防抖)
|
|
const debouncedClick = debounce(onClick, 300);
|
|
launcher.addEventListener('click', debouncedClick);
|
|
|
|
// 键盘支持
|
|
launcher.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
debouncedClick();
|
|
}
|
|
});
|
|
|
|
return launcher;
|
|
}
|
|
|
|
// ==================== 聊天弹窗 ====================
|
|
|
|
/** 创建聊天弹窗完整结构,返回各区域引用 */
|
|
export function createChatWindow(config: ResolvedConfig): {
|
|
window: HTMLElement;
|
|
messagesContainer: HTMLElement;
|
|
inputEl: HTMLTextAreaElement;
|
|
sendBtn: HTMLElement;
|
|
clearBtn: HTMLElement | null;
|
|
showLoading: () => HTMLElement;
|
|
hideLoading: () => void;
|
|
} {
|
|
// 最外层容器
|
|
const windowEl = document.createElement('div');
|
|
windowEl.id = 'csk-window';
|
|
windowEl.className = `csk-root csk-window csk-window--${config.position === 'left-bottom' ? 'left' : 'right'} csk-window--hidden`;
|
|
|
|
// === 头部 ===
|
|
const header = document.createElement('div');
|
|
header.className = 'csk-header';
|
|
|
|
const titleEl = document.createElement('span');
|
|
titleEl.className = 'csk-header__title';
|
|
titleEl.textContent = config.title;
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'csk-header__actions';
|
|
|
|
// 最小化按钮
|
|
const minimizeBtn = document.createElement('button');
|
|
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
|
|
minimizeBtn.setAttribute('title', '最小化');
|
|
minimizeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
|
|
minimizeBtn.addEventListener('click', () => {
|
|
windowEl.classList.add('csk-window--hidden');
|
|
});
|
|
|
|
// 关闭按钮
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.className = 'csk-header__btn csk-header__btn--close';
|
|
closeBtn.setAttribute('title', '关闭');
|
|
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
|
closeBtn.addEventListener('click', () => {
|
|
windowEl.classList.add('csk-window--hidden');
|
|
});
|
|
|
|
actions.appendChild(minimizeBtn);
|
|
actions.appendChild(closeBtn);
|
|
header.appendChild(titleEl);
|
|
header.appendChild(actions);
|
|
|
|
// === 消息区 ===
|
|
const messagesContainer = document.createElement('div');
|
|
messagesContainer.id = 'csk-messages';
|
|
messagesContainer.className = 'csk-messages';
|
|
|
|
// === 输入区 ===
|
|
const inputArea = document.createElement('div');
|
|
inputArea.className = 'csk-input-area';
|
|
|
|
const inputEl = document.createElement('textarea');
|
|
inputEl.id = 'csk-input';
|
|
inputEl.className = 'csk-input';
|
|
inputEl.setAttribute('placeholder', '输入您的问题...');
|
|
inputEl.setAttribute('rows', '1');
|
|
inputEl.setAttribute('autofocus', '');
|
|
|
|
const sendBtn = document.createElement('button');
|
|
sendBtn.id = 'csk-send-btn';
|
|
sendBtn.className = 'csk-send-btn';
|
|
sendBtn.setAttribute('title', '发送');
|
|
sendBtn.setAttribute('disabled', 'true');
|
|
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
|
|
|
|
inputArea.appendChild(inputEl);
|
|
inputArea.appendChild(sendBtn);
|
|
|
|
// === 组装 ===
|
|
windowEl.appendChild(header);
|
|
windowEl.appendChild(messagesContainer);
|
|
windowEl.appendChild(inputArea);
|
|
|
|
// 清空按钮(可选)
|
|
let clearBtn: HTMLElement | null = null;
|
|
if (config.showClear) {
|
|
clearBtn = document.createElement('button');
|
|
clearBtn.className = 'csk-clear-btn';
|
|
clearBtn.textContent = '清空对话';
|
|
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
|
|
// 插入到 messages 之后、inputArea 之前
|
|
windowEl.insertBefore(clearBtn, inputArea);
|
|
}
|
|
|
|
// === Loading 动画 ===
|
|
let loadingEl: HTMLElement | null = null;
|
|
|
|
function showLoading(): HTMLElement {
|
|
if (loadingEl) {
|
|
loadingEl.style.display = 'flex';
|
|
return loadingEl;
|
|
}
|
|
const el = document.createElement('div');
|
|
el.className = 'csk-loading';
|
|
el.innerHTML = `
|
|
<div class="csk-loading__dot"></div>
|
|
<div class="csk-loading__dot"></div>
|
|
<div class="csk-loading__dot"></div>
|
|
`;
|
|
messagesContainer.appendChild(el);
|
|
loadingEl = el;
|
|
return el;
|
|
}
|
|
|
|
function hideLoading(): void {
|
|
if (loadingEl && loadingEl.parentNode) {
|
|
loadingEl.parentNode.removeChild(loadingEl);
|
|
loadingEl = null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
window: windowEl,
|
|
messagesContainer,
|
|
inputEl,
|
|
sendBtn,
|
|
clearBtn,
|
|
showLoading,
|
|
hideLoading,
|
|
};
|
|
}
|
|
|
|
// ==================== 拖拽支持 ====================
|
|
|
|
/** 启用弹窗拖拽 */
|
|
export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement): () => void {
|
|
let dragging = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
dragging = true;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
const rect = windowEl.getBoundingClientRect();
|
|
offsetX = startX - rect.left;
|
|
offsetY = startY - rect.top;
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
};
|
|
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
if (!dragging) return;
|
|
const x = e.clientX - offsetX;
|
|
const y = e.clientY - offsetY;
|
|
// 边界限制,防止拖出视口
|
|
const maxX = window.innerWidth - windowEl.offsetWidth;
|
|
const maxY = window.innerHeight - windowEl.offsetHeight;
|
|
windowEl.style.right = 'auto';
|
|
windowEl.style.bottom = 'auto';
|
|
windowEl.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
|
|
windowEl.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
dragging = false;
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
|
|
headerEl.addEventListener('mousedown', onMouseDown);
|
|
|
|
// 清理函数
|
|
return () => {
|
|
headerEl.removeEventListener('mousedown', onMouseDown);
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
}
|
|
|
|
// ==================== 消息渲染 ====================
|
|
|
|
/** 渲染用户消息气泡 */
|
|
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--user';
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'csk-msg__bubble';
|
|
bubble.textContent = text;
|
|
|
|
const time = document.createElement('div');
|
|
time.className = 'csk-msg__time';
|
|
time.textContent = formatTime(timestamp);
|
|
|
|
wrapper.appendChild(bubble);
|
|
wrapper.appendChild(time);
|
|
container.appendChild(wrapper);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/** 渲染 AI 消息气泡 */
|
|
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--ai';
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'csk-msg__bubble';
|
|
bubble.textContent = text;
|
|
|
|
const time = document.createElement('div');
|
|
time.className = 'csk-msg__time';
|
|
time.textContent = formatTime(timestamp);
|
|
|
|
wrapper.appendChild(bubble);
|
|
wrapper.appendChild(time);
|
|
container.appendChild(wrapper);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/** 创建空的 AI 气泡(流式追加用) */
|
|
export function createEmptyAIBubble(container: HTMLElement, timestamp: number): { wrapper: HTMLElement; bubble: HTMLElement } {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--ai';
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'csk-msg__bubble';
|
|
bubble.innerHTML = '';
|
|
|
|
const time = document.createElement('div');
|
|
time.className = 'csk-msg__time';
|
|
time.textContent = formatTime(timestamp);
|
|
|
|
wrapper.appendChild(bubble);
|
|
wrapper.appendChild(time);
|
|
container.appendChild(wrapper);
|
|
|
|
return { wrapper, bubble };
|
|
}
|
|
|
|
/** 滚动消息区到底部 */
|
|
export function scrollToBottom(container: HTMLElement): void {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
/** 格式化时间戳 */
|
|
function formatTime(timestamp: number): string {
|
|
const d = new Date(timestamp);
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
return `${hh}:${mm}`;
|
|
}
|