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.
1095 lines
41 KiB
1095 lines
41 KiB
/**
|
|
* DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器
|
|
*
|
|
* 设计要点:
|
|
* - 头部含机器人头像 + 在线状态,增强产品感
|
|
* - 消息气泡带头像,左右区分清晰
|
|
* - 无消息时显示欢迎空状态
|
|
* - 输入区采用圆角容器包裹文本框与发送按钮
|
|
*/
|
|
import { ResolvedConfig, RagSource } from './types';
|
|
import { debounce } from './utils';
|
|
import { t } from './i18n';
|
|
|
|
// ==================== 图标常量 ====================
|
|
|
|
/** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */
|
|
const BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`;
|
|
|
|
/** 用户头像图标 */
|
|
const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
|
|
|
// ==================== 悬浮按钮 ====================
|
|
|
|
/** 创建悬浮按钮 */
|
|
export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement {
|
|
const launcher = document.createElement('div');
|
|
launcher.id = 'csk-launcher';
|
|
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
|
|
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
|
|
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
|
|
launcher.setAttribute('title', config.title);
|
|
launcher.setAttribute('aria-label', config.title);
|
|
launcher.setAttribute('role', 'button');
|
|
launcher.setAttribute('tabindex', '0');
|
|
|
|
// 图标内容
|
|
launcher.innerHTML = config.launcherIcon;
|
|
|
|
// 未读徽章(初始隐藏,有未读消息时由 index.ts 显示)
|
|
const badge = document.createElement('div');
|
|
badge.className = 'csk-launcher__badge csk-launcher__badge--hidden';
|
|
launcher.appendChild(badge);
|
|
|
|
// 点击事件(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;
|
|
categorySelect: HTMLSelectElement | null;
|
|
historyPanel: HTMLElement;
|
|
welcomeEl: HTMLElement;
|
|
newMsgBtn: HTMLElement;
|
|
teaserEl: HTMLElement;
|
|
searchInput: HTMLInputElement | null;
|
|
ariaLiveEl: HTMLElement;
|
|
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${config.theme === 'dark' ? ' csk-dark' : ''}`;
|
|
windowEl.setAttribute('role', 'dialog');
|
|
windowEl.setAttribute('aria-label', config.title);
|
|
windowEl.setAttribute('aria-modal', 'false');
|
|
|
|
// === 头部 ===
|
|
const header = document.createElement('div');
|
|
header.className = 'csk-header';
|
|
|
|
const headerLeft = document.createElement('div');
|
|
headerLeft.className = 'csk-header__left';
|
|
|
|
const headerAvatar = document.createElement('div');
|
|
headerAvatar.className = 'csk-header__avatar';
|
|
headerAvatar.innerHTML = BOT_ICON;
|
|
|
|
const headerInfo = document.createElement('div');
|
|
headerInfo.className = 'csk-header__info';
|
|
|
|
const titleEl = document.createElement('span');
|
|
titleEl.className = 'csk-header__title';
|
|
titleEl.textContent = config.title;
|
|
|
|
const statusEl = document.createElement('span');
|
|
statusEl.className = 'csk-header__status';
|
|
statusEl.innerHTML = `<i class="csk-status-dot"></i>${t('status_online')}`;
|
|
|
|
headerInfo.appendChild(titleEl);
|
|
headerInfo.appendChild(statusEl);
|
|
headerLeft.appendChild(headerAvatar);
|
|
headerLeft.appendChild(headerInfo);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'csk-header__actions';
|
|
|
|
// 历史会话按钮(P2)
|
|
const historyBtn = document.createElement('button');
|
|
historyBtn.className = 'csk-history-btn';
|
|
historyBtn.setAttribute('title', t('history_title'));
|
|
historyBtn.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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
|
|
|
|
// 最小化按钮
|
|
const minimizeBtn = document.createElement('button');
|
|
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
|
|
minimizeBtn.setAttribute('title', t('minimize'));
|
|
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', t('close'));
|
|
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(historyBtn);
|
|
actions.appendChild(minimizeBtn);
|
|
actions.appendChild(closeBtn);
|
|
header.appendChild(headerLeft);
|
|
header.appendChild(actions);
|
|
|
|
// === 消息区 ===
|
|
const messagesContainer = document.createElement('div');
|
|
messagesContainer.id = 'csk-messages';
|
|
messagesContainer.className = 'csk-messages';
|
|
|
|
// === 欢迎空状态 ===
|
|
const welcomeEl = document.createElement('div');
|
|
welcomeEl.className = 'csk-welcome';
|
|
welcomeEl.innerHTML = `
|
|
<div class="csk-welcome__avatar">${BOT_ICON}</div>
|
|
<div class="csk-welcome__title">${t('welcome_title')}</div>
|
|
<div class="csk-welcome__desc">${t('welcome_desc')}</div>
|
|
`;
|
|
|
|
// 快捷问题芯片(点击即自动发送)
|
|
if (config.quickReplies.length > 0) {
|
|
const chips = document.createElement('div');
|
|
chips.className = 'csk-quick-replies';
|
|
for (const reply of config.quickReplies) {
|
|
const chip = document.createElement('button');
|
|
chip.type = 'button';
|
|
chip.className = 'csk-quick-reply';
|
|
chip.textContent = reply;
|
|
chip.addEventListener('click', () => {
|
|
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
|
|
});
|
|
chips.appendChild(chip);
|
|
}
|
|
welcomeEl.appendChild(chips);
|
|
}
|
|
|
|
messagesContainer.appendChild(welcomeEl);
|
|
|
|
// === 新消息提示按钮(用户上滑时新消息到达显示) ===
|
|
// 挂到 windowEl 而非 messagesContainer,绝对定位悬浮于输入区上方,避免随内容滚动
|
|
const newMsgBtn = document.createElement('button');
|
|
newMsgBtn.type = 'button';
|
|
newMsgBtn.className = 'csk-newmsg csk-newmsg--hidden';
|
|
newMsgBtn.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
|
<span>${t('new_message')}</span>
|
|
`;
|
|
windowEl.appendChild(newMsgBtn);
|
|
|
|
// === 会话管理面板(P2,默认隐藏) ===
|
|
const historyPanel = document.createElement('div');
|
|
historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
|
|
historyPanel.innerHTML = `
|
|
<div class="csk-history-panel__header">
|
|
<span class="csk-history-panel__title">${t('history_title')}</span>
|
|
<button class="csk-history-panel__back" id="csk-history-back">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
|
${t('close')}
|
|
</button>
|
|
</div>
|
|
<div class="csk-history-panel__search-wrap">
|
|
<input type="text" class="csk-history-panel__search" id="csk-history-search" placeholder="${t('history_search')}" />
|
|
</div>
|
|
<div class="csk-history-panel__list" id="csk-history-list"></div>
|
|
`;
|
|
messagesContainer.appendChild(historyPanel);
|
|
|
|
// 历史面板返回按钮
|
|
const backBtn = historyPanel.querySelector('#csk-history-back');
|
|
if (backBtn) {
|
|
backBtn.addEventListener('click', () => {
|
|
historyPanel.classList.add('csk-history-panel--hidden');
|
|
});
|
|
}
|
|
|
|
// 历史按钮点击
|
|
historyBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const isHidden = historyPanel.classList.contains('csk-history-panel--hidden');
|
|
historyPanel.classList.toggle('csk-history-panel--hidden');
|
|
if (isHidden) {
|
|
// 触发自定义事件,通知加载会话列表
|
|
windowEl.dispatchEvent(new CustomEvent('csk:loadHistory'));
|
|
}
|
|
});
|
|
|
|
// === 知识库分类下拉框(P1) ===
|
|
let categorySelect: HTMLSelectElement | null = null;
|
|
if (config.showCategorySwitch) {
|
|
const categoryBar = document.createElement('div');
|
|
categoryBar.className = 'csk-category-bar';
|
|
|
|
const categoryLabel = document.createElement('span');
|
|
categoryLabel.className = 'csk-category-bar__label';
|
|
categoryLabel.textContent = '📚';
|
|
|
|
categorySelect = document.createElement('select');
|
|
categorySelect.id = 'csk-category-select';
|
|
categorySelect.className = 'csk-category-select';
|
|
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
|
|
|
|
// onChange 触发自定义事件
|
|
categorySelect.addEventListener('change', () => {
|
|
const selectedId = categorySelect!.value;
|
|
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
|
|
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
|
|
}));
|
|
});
|
|
|
|
categoryBar.appendChild(categoryLabel);
|
|
categoryBar.appendChild(categorySelect);
|
|
|
|
// 插入到 messages 和 inputArea 之间
|
|
windowEl.appendChild(header);
|
|
windowEl.appendChild(messagesContainer);
|
|
windowEl.appendChild(categoryBar);
|
|
} else {
|
|
windowEl.appendChild(header);
|
|
windowEl.appendChild(messagesContainer);
|
|
}
|
|
|
|
// === 输入区 ===
|
|
const inputArea = document.createElement('div');
|
|
inputArea.className = 'csk-input-area';
|
|
|
|
const inputWrap = document.createElement('div');
|
|
inputWrap.className = 'csk-input-wrap';
|
|
|
|
const inputEl = document.createElement('textarea');
|
|
inputEl.id = 'csk-input';
|
|
inputEl.className = 'csk-input';
|
|
inputEl.setAttribute('placeholder', t('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', t('send'));
|
|
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>`;
|
|
|
|
inputWrap.appendChild(inputEl);
|
|
inputWrap.appendChild(sendBtn);
|
|
inputArea.appendChild(inputWrap);
|
|
windowEl.appendChild(inputArea);
|
|
|
|
// 清空按钮(可选)
|
|
let clearBtn: HTMLElement | null = null;
|
|
if (config.showClear) {
|
|
clearBtn = document.createElement('button');
|
|
clearBtn.className = 'csk-clear-btn';
|
|
clearBtn.textContent = t('clear');
|
|
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
|
|
// 插入到 categoryBar/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__avatar">${BOT_ICON}</div>
|
|
<div class="csk-loading__bubble">
|
|
<div class="csk-loading__dot"></div>
|
|
<div class="csk-loading__dot"></div>
|
|
<div class="csk-loading__dot"></div>
|
|
</div>
|
|
`;
|
|
messagesContainer.appendChild(el);
|
|
loadingEl = el;
|
|
return el;
|
|
}
|
|
|
|
function hideLoading(): void {
|
|
if (loadingEl && loadingEl.parentNode) {
|
|
loadingEl.parentNode.removeChild(loadingEl);
|
|
loadingEl = null;
|
|
}
|
|
}
|
|
|
|
// === a11y: aria-live 播报区域(视觉隐藏,供屏幕阅读器监听新消息) ===
|
|
const ariaLiveEl = document.createElement('div');
|
|
ariaLiveEl.className = 'csk-sr-only';
|
|
ariaLiveEl.setAttribute('aria-live', 'polite');
|
|
ariaLiveEl.setAttribute('aria-atomic', 'true');
|
|
windowEl.appendChild(ariaLiveEl);
|
|
|
|
// === 历史搜索输入框引用 ===
|
|
const searchInput = historyPanel.querySelector('#csk-history-search') as HTMLInputElement | null;
|
|
|
|
// === Launcher 提示气泡(首访引导,初始隐藏,由 index.ts 定时控制) ===
|
|
const teaserEl = document.createElement('div');
|
|
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`;
|
|
teaserEl.setAttribute('role', 'status');
|
|
teaserEl.innerHTML = `
|
|
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">×</button>
|
|
<span>${config.teaserText || t('teaser_text')}</span>
|
|
`;
|
|
|
|
return {
|
|
window: windowEl,
|
|
messagesContainer,
|
|
inputEl,
|
|
sendBtn,
|
|
clearBtn,
|
|
categorySelect,
|
|
historyPanel,
|
|
welcomeEl,
|
|
newMsgBtn,
|
|
teaserEl,
|
|
searchInput,
|
|
ariaLiveEl,
|
|
showLoading,
|
|
hideLoading,
|
|
};
|
|
}
|
|
|
|
// ==================== 拖拽支持 ====================
|
|
|
|
/** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */
|
|
export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragEnd?: (pos: { x: number; y: number }) => void): () => void {
|
|
let dragging = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
// 忽略头部按钮点击触发的拖拽
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('button')) return;
|
|
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);
|
|
// 拖拽结束回调:返回当前位置供持久化
|
|
if (onDragEnd) {
|
|
const rect = windowEl.getBoundingClientRect();
|
|
onDragEnd({ x: rect.left, y: rect.top });
|
|
}
|
|
};
|
|
|
|
headerEl.addEventListener('mousedown', onMouseDown);
|
|
|
|
// 清理函数
|
|
return () => {
|
|
headerEl.removeEventListener('mousedown', onMouseDown);
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
}
|
|
|
|
/** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */
|
|
const LAUNCHER_MIN_BOTTOM = 16;
|
|
/** Launcher 拖拽:计算当前视口下最大 bottom 值 */
|
|
function launcherMaxBottom(): number {
|
|
return window.innerHeight - 76; // 按钮高度 60 + 16 间距
|
|
}
|
|
|
|
/**
|
|
* 启用 Launcher 按钮的拖拽功能(边缘吸附式)
|
|
*
|
|
* 设计要点:
|
|
* - 仅允许垂直方向自由移动,水平方向释放时自动吸附到最近的左/右边缘
|
|
* - 移动距离 < 5px 视为点击(不干扰原有的 open/close 逻辑)
|
|
* - 拖拽中按钮微放大 + 隐藏未读徽章,释放后带缓动吸附动画
|
|
* - 支持鼠标 + 触摸事件,触摸拖拽时阻止页面滚动
|
|
*/
|
|
export function enableLauncherDrag(
|
|
launcherEl: HTMLElement,
|
|
config: Pick<ResolvedConfig, 'position'>,
|
|
onDragEnd?: (pos: { side: 'left' | 'right'; bottom: number }) => void,
|
|
): () => void {
|
|
let dragging = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let hasMoved = false;
|
|
let startBottom = 0;
|
|
let startRight = 0;
|
|
let startLeft = 0;
|
|
// 标记是否正在吸附过渡中,避免过渡期间的 mouseup 再次触发
|
|
let isSnapping = false;
|
|
|
|
/** 获取当前 bottom 值(纯数值) */
|
|
function getCurrentBottom(): number {
|
|
const val = parseFloat(getComputedStyle(launcherEl).bottom);
|
|
return isNaN(val) ? 24 : val;
|
|
}
|
|
|
|
function onPointerDown(clientX: number, clientY: number): void {
|
|
if (isSnapping) return;
|
|
dragging = true;
|
|
hasMoved = false;
|
|
startX = clientX;
|
|
startY = clientY;
|
|
startBottom = getCurrentBottom();
|
|
// 记录水平起始位置(用于判断当前在哪一侧)
|
|
const style = launcherEl.style;
|
|
const cs = getComputedStyle(launcherEl);
|
|
startRight = style.right ? parseFloat(style.right) : parseFloat(cs.right);
|
|
startLeft = style.left ? parseFloat(style.left) : parseFloat(cs.left);
|
|
}
|
|
|
|
function onPointerMove(clientX: number, clientY: number, e: Event): void {
|
|
if (!dragging) return;
|
|
|
|
const dx = clientX - startX;
|
|
const dy = clientY - startY;
|
|
|
|
if (!hasMoved) {
|
|
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
|
hasMoved = true;
|
|
// 进入拖拽态
|
|
launcherEl.classList.add('csk-launcher--dragging');
|
|
launcherEl.classList.remove('csk-launcher--right', 'csk-launcher--left');
|
|
launcherEl.style.transition = 'none';
|
|
// 同步当前水平位置(从吸附态的 right/left 转换为固定像素)
|
|
if (!isNaN(startRight) && startRight >= 0 && isNaN(startLeft)) {
|
|
launcherEl.style.right = `${startRight}px`;
|
|
launcherEl.style.left = 'auto';
|
|
} else if (!isNaN(startLeft) && startLeft >= 0) {
|
|
launcherEl.style.left = `${startLeft}px`;
|
|
launcherEl.style.right = 'auto';
|
|
}
|
|
}
|
|
|
|
if (hasMoved) {
|
|
e.preventDefault(); // 阻止触摸滚动
|
|
const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom()));
|
|
launcherEl.style.bottom = `${newBottom}px`;
|
|
}
|
|
}
|
|
|
|
function onPointerUp(): void {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
|
|
if (!hasMoved) return; // 移动距离不足 5px,视为点击,不处理
|
|
|
|
// 隐藏拖拽态(保留当前 bottom)
|
|
const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom();
|
|
const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom()));
|
|
|
|
// 判断吸附到哪一侧(取离哪边更近)
|
|
const rect = launcherEl.getBoundingClientRect();
|
|
const center = rect.left + rect.width / 2;
|
|
const snapSide: 'left' | 'right' = center <= window.innerWidth / 2 ? 'left' : 'right';
|
|
|
|
// 切换到吸附过渡动画
|
|
launcherEl.classList.remove('csk-launcher--dragging');
|
|
launcherEl.classList.add(`csk-launcher--${snapSide}`, 'csk-launcher--snap');
|
|
launcherEl.style.left = '';
|
|
launcherEl.style.right = '';
|
|
launcherEl.style.bottom = `${clampedBottom}px`;
|
|
|
|
// 过渡结束后清理(保留 bottom,否则 CSS 默认 24px 会覆盖拖拽位置)
|
|
isSnapping = true;
|
|
const onTransitionEnd = (): void => {
|
|
launcherEl.removeEventListener('transitionend', onTransitionEnd);
|
|
launcherEl.classList.remove('csk-launcher--snap');
|
|
launcherEl.style.transition = '';
|
|
// 注意:不清除 style.bottom,保留用户拖拽的垂直位置
|
|
isSnapping = false;
|
|
};
|
|
launcherEl.addEventListener('transitionend', onTransitionEnd);
|
|
// 兜底:350ms 后强制清理(transitionend 可能不触发)
|
|
setTimeout(() => {
|
|
if (isSnapping) onTransitionEnd();
|
|
}, 350);
|
|
|
|
// 恢复未读徽章
|
|
const badge = launcherEl.querySelector('.csk-launcher__badge');
|
|
if (badge) (badge as HTMLElement).style.display = '';
|
|
|
|
if (onDragEnd) {
|
|
onDragEnd({ side: snapSide, bottom: clampedBottom });
|
|
}
|
|
}
|
|
|
|
// --- 鼠标事件 ---
|
|
function onMouseDown(e: MouseEvent): void { onPointerDown(e.clientX, e.clientY); }
|
|
function onMouseMove(e: MouseEvent): void { onPointerMove(e.clientX, e.clientY, e); }
|
|
function onMouseUp(): void { onPointerUp(); }
|
|
|
|
// --- 触摸事件 ---
|
|
function onTouchStart(e: TouchEvent): void {
|
|
if (e.touches.length === 1) onPointerDown(e.touches[0].clientX, e.touches[0].clientY);
|
|
}
|
|
function onTouchMove(e: TouchEvent): void {
|
|
if (e.touches.length === 1) onPointerMove(e.touches[0].clientX, e.touches[0].clientY, e);
|
|
}
|
|
function onTouchEnd(): void { onPointerUp(); }
|
|
|
|
launcherEl.addEventListener('mousedown', onMouseDown);
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
launcherEl.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
document.addEventListener('touchend', onTouchEnd);
|
|
|
|
return () => {
|
|
launcherEl.removeEventListener('mousedown', onMouseDown);
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
launcherEl.removeEventListener('touchstart', onTouchStart);
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
};
|
|
}
|
|
|
|
// ==================== 消息渲染 ====================
|
|
|
|
/** 渲染用户消息气泡 */
|
|
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--user';
|
|
|
|
const avatar = document.createElement('div');
|
|
avatar.className = 'csk-msg__avatar csk-msg__avatar--user';
|
|
avatar.innerHTML = USER_ICON;
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'csk-msg__content';
|
|
|
|
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);
|
|
|
|
content.appendChild(bubble);
|
|
content.appendChild(time);
|
|
wrapper.appendChild(avatar);
|
|
wrapper.appendChild(content);
|
|
container.appendChild(wrapper);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/** 渲染 AI 消息气泡(支持 Markdown) */
|
|
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string, msgId?: string): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--ai';
|
|
if (msgId) wrapper.dataset.cskMsgId = msgId;
|
|
|
|
const avatar = document.createElement('div');
|
|
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
|
|
avatar.innerHTML = BOT_ICON;
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'csk-msg__content';
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'csk-msg__bubble';
|
|
// 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本
|
|
if (renderMd) {
|
|
bubble.innerHTML = renderMd(text);
|
|
} else {
|
|
bubble.textContent = text;
|
|
}
|
|
|
|
const time = document.createElement('div');
|
|
time.className = 'csk-msg__time';
|
|
time.textContent = formatTime(timestamp);
|
|
|
|
content.appendChild(bubble);
|
|
// AI 操作条(复制 / 重试),插入到气泡与时间戳之间
|
|
content.appendChild(buildAIActions(bubble, wrapper));
|
|
content.appendChild(time);
|
|
wrapper.appendChild(avatar);
|
|
wrapper.appendChild(content);
|
|
container.appendChild(wrapper);
|
|
|
|
// 代码块复制按钮
|
|
enhanceCodeBlocks(bubble);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/** 创建空的 AI 气泡(流式追加用) */
|
|
export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming';
|
|
if (msgId) wrapper.dataset.cskMsgId = msgId;
|
|
|
|
const avatar = document.createElement('div');
|
|
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
|
|
avatar.innerHTML = BOT_ICON;
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'csk-msg__content';
|
|
|
|
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);
|
|
|
|
content.appendChild(bubble);
|
|
content.appendChild(buildAIActions(bubble, wrapper));
|
|
content.appendChild(time);
|
|
wrapper.appendChild(avatar);
|
|
wrapper.appendChild(content);
|
|
container.appendChild(wrapper);
|
|
|
|
return { wrapper, bubble };
|
|
}
|
|
|
|
// ==================== AI 消息操作条 + 代码块复制 ====================
|
|
|
|
/** 复制图标 */
|
|
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
|
|
/** 重试图标 */
|
|
const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`;
|
|
|
|
/** 构建 AI 消息操作条(复制 / 重试 / 反馈) */
|
|
function buildAIActions(bubble: HTMLElement, wrapper: HTMLElement): HTMLElement {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'csk-msg__actions';
|
|
|
|
// 复制按钮
|
|
const copyBtn = document.createElement('button');
|
|
copyBtn.type = 'button';
|
|
copyBtn.className = 'csk-action-btn';
|
|
copyBtn.setAttribute('title', t('copy'));
|
|
copyBtn.setAttribute('aria-label', t('copy'));
|
|
copyBtn.innerHTML = COPY_ICON;
|
|
copyBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const text = bubble.textContent || '';
|
|
await copyToClipboard(text, copyBtn);
|
|
});
|
|
|
|
// 重试按钮
|
|
const retryBtn = document.createElement('button');
|
|
retryBtn.type = 'button';
|
|
retryBtn.className = 'csk-action-btn';
|
|
retryBtn.setAttribute('title', t('retry'));
|
|
retryBtn.setAttribute('aria-label', t('retry'));
|
|
retryBtn.innerHTML = RETRY_ICON;
|
|
retryBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const root = wrapper.closest('.csk-window');
|
|
if (root) {
|
|
root.dispatchEvent(new CustomEvent('csk:retry', {
|
|
detail: { msgId: wrapper.dataset.cskMsgId || '' }
|
|
}));
|
|
}
|
|
});
|
|
|
|
// 反馈按钮(👍 / 👎)
|
|
const THUMB_UP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
|
|
const THUMB_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>`;
|
|
|
|
const upBtn = document.createElement('button');
|
|
upBtn.type = 'button';
|
|
upBtn.className = 'csk-feedback-btn';
|
|
upBtn.setAttribute('title', t('feedback_up'));
|
|
upBtn.setAttribute('aria-label', t('feedback_up'));
|
|
upBtn.innerHTML = THUMB_UP;
|
|
upBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const root = wrapper.closest('.csk-window');
|
|
if (root) {
|
|
root.dispatchEvent(new CustomEvent('csk:feedback', {
|
|
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' }
|
|
}));
|
|
}
|
|
});
|
|
|
|
const downBtn = document.createElement('button');
|
|
downBtn.type = 'button';
|
|
downBtn.className = 'csk-feedback-btn';
|
|
downBtn.setAttribute('title', t('feedback_down'));
|
|
downBtn.setAttribute('aria-label', t('feedback_down'));
|
|
downBtn.innerHTML = THUMB_DOWN;
|
|
downBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const root = wrapper.closest('.csk-window');
|
|
if (root) {
|
|
root.dispatchEvent(new CustomEvent('csk:feedback', {
|
|
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' }
|
|
}));
|
|
}
|
|
});
|
|
|
|
bar.appendChild(copyBtn);
|
|
bar.appendChild(retryBtn);
|
|
bar.appendChild(upBtn);
|
|
bar.appendChild(downBtn);
|
|
return bar;
|
|
}
|
|
|
|
/**
|
|
* 更新反馈按钮的视觉状态(由 chat.ts 调用)
|
|
* @param wrapper 消息 wrapper 元素
|
|
* @param value 当前反馈值:'up' | 'down' | undefined
|
|
*/
|
|
export function updateFeedbackUI(wrapper: HTMLElement, value: 'up' | 'down' | undefined): void {
|
|
const btns = wrapper.querySelectorAll('.csk-feedback-btn');
|
|
if (btns.length < 2) return;
|
|
btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down'));
|
|
if (value === 'up' && btns[0]) btns[0].classList.add('csk-feedback-btn--active');
|
|
if (value === 'down' && btns[1]) btns[1].classList.add('csk-feedback-btn--active', 'down');
|
|
}
|
|
|
|
/** 复制文本到剪贴板,带"已复制"反馈 */
|
|
async function copyToClipboard(text: string, btn: HTMLElement): Promise<void> {
|
|
const original = btn.innerHTML;
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
// 降级方案:execCommand
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
|
btn.classList.add('csk-action-btn--done');
|
|
setTimeout(() => {
|
|
btn.innerHTML = original;
|
|
btn.classList.remove('csk-action-btn--done');
|
|
}, 1500);
|
|
} catch {
|
|
// 复制失败静默处理
|
|
}
|
|
}
|
|
|
|
/** 为气泡内所有代码块添加复制按钮 */
|
|
export function enhanceCodeBlocks(bubble: HTMLElement): void {
|
|
const blocks = bubble.querySelectorAll('.csk-md-code-block');
|
|
blocks.forEach((pre) => {
|
|
if (pre.querySelector('.csk-md-code-copy')) return;
|
|
const codeEl = pre.querySelector('code');
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'csk-md-code-copy';
|
|
btn.setAttribute('title', t('copy'));
|
|
btn.setAttribute('aria-label', t('copy'));
|
|
btn.innerHTML = COPY_ICON;
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await copyToClipboard(codeEl?.textContent || '', btn);
|
|
});
|
|
(pre as HTMLElement).appendChild(btn);
|
|
});
|
|
}
|
|
|
|
/** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */
|
|
export function appendStreamingCaret(bubble: HTMLElement): void {
|
|
if (bubble.querySelector('.csk-caret')) return;
|
|
const caret = document.createElement('span');
|
|
caret.className = 'csk-caret';
|
|
caret.setAttribute('aria-hidden', 'true');
|
|
bubble.appendChild(caret);
|
|
}
|
|
|
|
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */
|
|
export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void {
|
|
wrapper.classList.remove('csk-msg--streaming');
|
|
const caret = bubble.querySelector('.csk-caret');
|
|
if (caret) caret.remove();
|
|
enhanceCodeBlocks(bubble);
|
|
}
|
|
|
|
// ==================== P1: RAG 引用来源渲染 ====================
|
|
|
|
/** 渲染 RAG 引用来源卡片 */
|
|
export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void {
|
|
// 移除已有的来源卡片
|
|
const existing = wrapper.querySelector('.csk-sources');
|
|
if (existing) existing.remove();
|
|
|
|
if (!sources || sources.length === 0) return;
|
|
|
|
const sourcesEl = document.createElement('div');
|
|
sourcesEl.className = 'csk-sources csk-sources--collapsed';
|
|
|
|
// 头部
|
|
const header = document.createElement('div');
|
|
header.className = 'csk-sources__header';
|
|
|
|
const titleSpan = document.createElement('span');
|
|
titleSpan.className = 'csk-sources__title';
|
|
titleSpan.textContent = `📚 ${t('source_count', { n: sources.length })}`;
|
|
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'csk-sources__arrow';
|
|
arrow.textContent = '▼';
|
|
|
|
header.appendChild(titleSpan);
|
|
header.appendChild(arrow);
|
|
|
|
// 点击折叠/展开
|
|
header.addEventListener('click', () => {
|
|
sourcesEl.classList.toggle('csk-sources--collapsed');
|
|
});
|
|
|
|
// 内容
|
|
const body = document.createElement('div');
|
|
body.className = 'csk-sources__body';
|
|
|
|
for (const src of sources) {
|
|
const item = document.createElement('div');
|
|
item.className = 'csk-source-item';
|
|
|
|
const name = document.createElement('div');
|
|
name.className = 'csk-source-item__name';
|
|
name.textContent = src.title || src.sourceName || '未知文档';
|
|
|
|
if (src.snippet) {
|
|
const snippet = document.createElement('div');
|
|
snippet.className = 'csk-source-item__snippet';
|
|
snippet.textContent = src.snippet;
|
|
item.appendChild(snippet);
|
|
}
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'csk-source-item__meta';
|
|
const metaParts: string[] = [];
|
|
if (src.sourceName) metaParts.push(src.sourceName);
|
|
if (src.chunkIndex !== undefined) metaParts.push(`分块 #${src.chunkIndex}`);
|
|
if (src.score !== undefined) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`);
|
|
meta.textContent = metaParts.join(' · ');
|
|
|
|
item.appendChild(name);
|
|
item.appendChild(meta);
|
|
body.appendChild(item);
|
|
}
|
|
|
|
sourcesEl.appendChild(header);
|
|
sourcesEl.appendChild(body);
|
|
|
|
// 插入到气泡和时间戳之间(时间戳在 content 容器内,故用其父节点插入)
|
|
const timeEl = wrapper.querySelector('.csk-msg__time');
|
|
if (timeEl && timeEl.parentNode) {
|
|
timeEl.parentNode.insertBefore(sourcesEl, timeEl);
|
|
} else {
|
|
wrapper.appendChild(sourcesEl);
|
|
}
|
|
}
|
|
|
|
// ==================== P2: 会话管理面板渲染 ====================
|
|
|
|
/** 会话列表项数据 */
|
|
export interface HistoryItemData {
|
|
id: string;
|
|
chatId?: string;
|
|
messageCount?: number;
|
|
lastMessageTime?: string;
|
|
lastMessagePreview?: string;
|
|
createdAt?: string;
|
|
}
|
|
|
|
/** 按日期分组会话列表:今天 / 昨天 / 本周 / 更早 */
|
|
function groupHistoryByDate(items: HistoryItemData[]): { label: string; items: HistoryItemData[] }[] {
|
|
const now = new Date();
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const yesterdayStart = todayStart - 86400000;
|
|
const weekStart = todayStart - (now.getDay() || 7) * 86400000 + 86400000; // 本周一
|
|
|
|
const groups: Record<string, HistoryItemData[]> = {
|
|
today: [],
|
|
yesterday: [],
|
|
week: [],
|
|
earlier: [],
|
|
};
|
|
|
|
for (const item of items) {
|
|
// 尝试解析时间:优先 lastMessageTime,其次 createdAt
|
|
const timeStr = item.lastMessageTime || item.createdAt;
|
|
const ts = timeStr ? new Date(timeStr).getTime() : 0;
|
|
|
|
if (ts >= todayStart) {
|
|
groups.today.push(item);
|
|
} else if (ts >= yesterdayStart) {
|
|
groups.yesterday.push(item);
|
|
} else if (ts >= weekStart) {
|
|
groups.week.push(item);
|
|
} else {
|
|
groups.earlier.push(item);
|
|
}
|
|
}
|
|
|
|
const result: { label: string; items: HistoryItemData[] }[] = [];
|
|
if (groups.today.length) result.push({ label: t('history_group_today'), items: groups.today });
|
|
if (groups.yesterday.length) result.push({ label: t('history_group_yesterday'), items: groups.yesterday });
|
|
if (groups.week.length) result.push({ label: t('history_group_week'), items: groups.week });
|
|
if (groups.earlier.length) result.push({ label: t('history_group_earlier'), items: groups.earlier });
|
|
return result;
|
|
}
|
|
|
|
/** 渲染会话列表(带日期分组) */
|
|
export function renderHistoryList(
|
|
listEl: HTMLElement,
|
|
items: HistoryItemData[],
|
|
onSelect: (conversationId: string) => void,
|
|
onExport: (id: string) => void,
|
|
onDelete: (id: string) => void,
|
|
activeChatId?: string,
|
|
emptyText?: string
|
|
): void {
|
|
listEl.innerHTML = '';
|
|
|
|
if (items.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'csk-history-panel__empty';
|
|
empty.innerHTML = `
|
|
<div class="csk-history-panel__empty-icon">💬</div>
|
|
<div>${emptyText || t('history_empty')}</div>
|
|
`;
|
|
listEl.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
// 按日期分组渲染
|
|
const groups = groupHistoryByDate(items);
|
|
for (const group of groups) {
|
|
// 分组标题
|
|
if (groups.length > 1) {
|
|
const groupEl = document.createElement('div');
|
|
groupEl.className = 'csk-history-group';
|
|
const labelEl = document.createElement('div');
|
|
labelEl.className = 'csk-history-group__label';
|
|
labelEl.textContent = group.label;
|
|
groupEl.appendChild(labelEl);
|
|
listEl.appendChild(groupEl);
|
|
}
|
|
|
|
for (const item of group.items) {
|
|
const el = document.createElement('div');
|
|
el.className = 'csk-history-item';
|
|
// 高亮当前活跃会话
|
|
const convId = item.chatId || item.id;
|
|
if (activeChatId && convId === activeChatId) {
|
|
el.classList.add('csk-history-item--active');
|
|
}
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'csk-history-item__info';
|
|
|
|
const idEl = document.createElement('div');
|
|
idEl.className = 'csk-history-item__id';
|
|
// 显示最后一条消息预览,没有则显示 chatId
|
|
if (item.lastMessagePreview) {
|
|
idEl.textContent = item.lastMessagePreview.length > 60
|
|
? item.lastMessagePreview.substring(0, 60) + '...'
|
|
: item.lastMessagePreview;
|
|
} else {
|
|
idEl.textContent = convId;
|
|
}
|
|
|
|
const metaEl = document.createElement('div');
|
|
metaEl.className = 'csk-history-item__meta';
|
|
const metaParts: string[] = [];
|
|
if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`);
|
|
if (item.lastMessageTime) metaParts.push(item.lastMessageTime);
|
|
else if (item.createdAt) metaParts.push(item.createdAt);
|
|
metaEl.textContent = metaParts.join(' · ');
|
|
|
|
info.appendChild(idEl);
|
|
info.appendChild(metaEl);
|
|
|
|
const actionsEl = document.createElement('div');
|
|
actionsEl.className = 'csk-history-item__actions';
|
|
|
|
// 导出按钮
|
|
const exportBtn = document.createElement('button');
|
|
exportBtn.className = 'csk-history-action csk-history-action--export';
|
|
exportBtn.setAttribute('title', t('history_export'));
|
|
exportBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
|
exportBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
onExport(item.id);
|
|
});
|
|
|
|
// 删除按钮
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'csk-history-action csk-history-action--delete';
|
|
deleteBtn.setAttribute('title', t('history_delete'));
|
|
deleteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
|
deleteBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
onDelete(item.id);
|
|
});
|
|
|
|
actionsEl.appendChild(exportBtn);
|
|
actionsEl.appendChild(deleteBtn);
|
|
|
|
el.appendChild(info);
|
|
el.appendChild(actionsEl);
|
|
|
|
// 点击整行 → 切换到该会话
|
|
el.addEventListener('click', () => {
|
|
onSelect(convId);
|
|
});
|
|
|
|
listEl.appendChild(el);
|
|
}
|
|
} // 关闭 group 循环
|
|
}
|
|
|
|
/** 滚动消息区到底部 */
|
|
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}`;
|
|
}
|