feat(clipping): implement hide/recover toggle for all section dialogs
Update all three section dialogs to support hide/show toggle: SectionAxisDialogManager: - onHideToggle now calls hideSection()/recoverSection() SectionBoxDialogManager: - onHideToggle now calls hideSection()/recoverSection() SectionPlanePanel: - Add isHidden state tracking - Change onHide to onHideToggle(isHidden) - Add setHiddenState/getHiddenState methods - Update button to toggle active state SectionPlaneDialogManager: - Switch to onHideToggle callback - Call hideSection()/recoverSection() based on toggle state Behavior: Click hide button to hide section, click again to recover.
This commit is contained in:
814
src/components/ai-chat/index.ts
Normal file
814
src/components/ai-chat/index.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* AI 聊天对话框组件
|
||||
* 支持拖拽、消息展示、问答卡片等功能
|
||||
*/
|
||||
import './index.css';
|
||||
import type { AiChatOptions, Message, QuestionMessage, QuestionOption, StepStatus } from './types';
|
||||
import type { ThemeConfig } from '../../themes/types';
|
||||
import { IBimComponent } from '../../types/component';
|
||||
import { themeManager } from '../../services/theme';
|
||||
import { t, localeManager } from '../../services/locale';
|
||||
import { getIcon } from '../../utils/icon-manager';
|
||||
|
||||
/**
|
||||
* AI 聊天组件类
|
||||
* 实现 IBimComponent 接口,支持主题切换和国际化
|
||||
*/
|
||||
export class AiChat implements IBimComponent {
|
||||
/** 组件根元素 */
|
||||
private element: HTMLElement;
|
||||
/** 组件配置选项 */
|
||||
private options: AiChatOptions;
|
||||
/** 挂载容器 */
|
||||
private container: HTMLElement;
|
||||
/** 消息列表容器 */
|
||||
private messagesContainer: HTMLElement | null = null;
|
||||
/** 输入框元素 */
|
||||
private textarea: HTMLTextAreaElement | null = null;
|
||||
/** 消息数据列表 */
|
||||
private messages: Message[] = [];
|
||||
/** 是否已销毁 */
|
||||
private _isDestroyed = false;
|
||||
/** 是否可见 */
|
||||
private _isVisible = false;
|
||||
/** 主题订阅取消函数 */
|
||||
private unsubscribeTheme: (() => void) | null = null;
|
||||
/** 语言订阅取消函数 */
|
||||
private unsubscribeLocale: (() => void) | null = null;
|
||||
/** requestAnimationFrame ID,用于拖拽性能优化 */
|
||||
private rafId: number | null = null;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param options 组件配置选项
|
||||
*/
|
||||
constructor(options: AiChatOptions) {
|
||||
// 合并默认配置
|
||||
this.options = {
|
||||
width: 440,
|
||||
title: 'aiChat.title',
|
||||
placeholder: 'aiChat.placeholder',
|
||||
quickPrompts: [
|
||||
{ id: 'summarize', label: 'aiChat.quickPrompt.summarize' },
|
||||
{ id: 'explain', label: 'aiChat.quickPrompt.explain' },
|
||||
{ id: 'generate', label: 'aiChat.quickPrompt.generate' }
|
||||
],
|
||||
...options
|
||||
};
|
||||
this.container = options.container;
|
||||
this.element = this.createDom();
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化组件
|
||||
* 挂载到容器并订阅主题/语言变更
|
||||
*/
|
||||
public init(): void {
|
||||
this.container.appendChild(this.element);
|
||||
this.hide();
|
||||
|
||||
// 订阅主题变更
|
||||
this.unsubscribeTheme = themeManager.subscribe((theme) => {
|
||||
this.setTheme(theme);
|
||||
});
|
||||
|
||||
// 订阅语言变更
|
||||
this.unsubscribeLocale = localeManager.subscribe(() => {
|
||||
this.setLocales();
|
||||
});
|
||||
|
||||
this.setTheme(themeManager.getTheme());
|
||||
this.setLocales();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
* @param theme 主题配置对象
|
||||
*/
|
||||
public setTheme(theme: ThemeConfig): void {
|
||||
// 根据主题名称判断深色/浅色模式
|
||||
const isDark = theme.name === 'dark' || theme.name?.includes('dark');
|
||||
this.element.classList.toggle('light', !isDark);
|
||||
|
||||
// 设置 CSS 变量
|
||||
const style = this.element.style;
|
||||
style.setProperty('--bim-ai-accent', theme.primary);
|
||||
style.setProperty('--bim-ai-text', theme.textPrimary);
|
||||
style.setProperty('--bim-ai-muted', theme.textTertiary);
|
||||
style.setProperty('--bim-ai-border', theme.borderDefault);
|
||||
style.setProperty('--bim-ai-shadow', theme.shadowLg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置国际化文本
|
||||
* 更新标题、占位符、快捷提示等文本
|
||||
*/
|
||||
public setLocales(): void {
|
||||
// 更新标题
|
||||
const titleEl = this.element.querySelector('.bim-ai-chat-title');
|
||||
if (titleEl && this.options.title) {
|
||||
titleEl.textContent = t(this.options.title);
|
||||
}
|
||||
|
||||
// 更新输入框占位符
|
||||
const placeholderEl = this.element.querySelector('.bim-ai-textarea') as HTMLTextAreaElement;
|
||||
if (placeholderEl && this.options.placeholder) {
|
||||
placeholderEl.placeholder = t(this.options.placeholder);
|
||||
}
|
||||
|
||||
// 更新底部提示文字
|
||||
const helperL = this.element.querySelector('.bim-ai-helper-left');
|
||||
if (helperL) helperL.textContent = t('aiChat.helper.newline');
|
||||
|
||||
const helperR = this.element.querySelector('.bim-ai-helper-right');
|
||||
if (helperR) helperR.textContent = t('aiChat.helper.send');
|
||||
|
||||
// 更新快捷提示按钮和消息列表
|
||||
this.updateQuickPrompts();
|
||||
this.renderMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示对话框
|
||||
*/
|
||||
public show(): void {
|
||||
this._isVisible = true;
|
||||
this.element.style.display = 'flex';
|
||||
this.initPosition();
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化对话框位置
|
||||
* 首次显示时定位到右上角(距边缘 50px)
|
||||
*/
|
||||
private initPosition(): void {
|
||||
// 如果已有位置则不重置,保留用户拖拽后的位置
|
||||
if (this.element.style.left) return;
|
||||
const containerW = this.container.clientWidth;
|
||||
const elW = this.element.offsetWidth;
|
||||
// 计算左边距:容器宽度 - 元素宽度 - 右边距(50px)
|
||||
const left = Math.max(50, containerW - elW - 50);
|
||||
this.element.style.left = `${left}px`;
|
||||
this.element.style.top = '50px';
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏对话框
|
||||
*/
|
||||
public hide(): void {
|
||||
this._isVisible = false;
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换对话框显示/隐藏状态
|
||||
*/
|
||||
public toggle(): void {
|
||||
if (this._isVisible) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话框是否可见
|
||||
*/
|
||||
public isVisible(): boolean {
|
||||
return this._isVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息
|
||||
* @param message 消息对象
|
||||
*/
|
||||
public addMessage(message: Message): void {
|
||||
this.messages.push(message);
|
||||
this.renderMessages();
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息
|
||||
* @param id 消息 ID
|
||||
* @param updates 要更新的字段
|
||||
*/
|
||||
public updateMessage(id: string, updates: Partial<Message>): void {
|
||||
const index = this.messages.findIndex(m => m.id === id);
|
||||
if (index !== -1) {
|
||||
this.messages[index] = { ...this.messages[index], ...updates } as Message;
|
||||
this.renderMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param id 消息 ID
|
||||
*/
|
||||
public removeMessage(id: string): void {
|
||||
this.messages = this.messages.filter(m => m.id !== id);
|
||||
this.renderMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有消息
|
||||
*/
|
||||
public clearMessages(): void {
|
||||
this.messages = [];
|
||||
this.renderMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加用户消息
|
||||
* @param content 消息内容
|
||||
* @returns 消息 ID
|
||||
*/
|
||||
public addUserMessage(content: string): string {
|
||||
const id = `user-${Date.now()}`;
|
||||
this.addMessage({
|
||||
id,
|
||||
type: 'user',
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 AI 回复消息
|
||||
* @param content 消息内容
|
||||
* @returns 消息 ID
|
||||
*/
|
||||
public addAiMessage(content: string): string {
|
||||
const id = `ai-${Date.now()}`;
|
||||
this.addMessage({
|
||||
id,
|
||||
type: 'ai',
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加步骤消息(执行中/完成/失败)
|
||||
* @param status 步骤状态
|
||||
* @param content 步骤描述
|
||||
* @returns 消息 ID
|
||||
*/
|
||||
public addStepMessage(status: StepStatus, content: string): string {
|
||||
const id = `step-${Date.now()}`;
|
||||
this.addMessage({
|
||||
id,
|
||||
type: 'step',
|
||||
status,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加思考中消息
|
||||
* @returns 消息 ID
|
||||
*/
|
||||
public addThinkingMessage(): string {
|
||||
const id = `thinking-${Date.now()}`;
|
||||
this.addMessage({
|
||||
id,
|
||||
type: 'thinking',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加问答卡片消息
|
||||
* @param title 问题标题
|
||||
* @param question 问题内容
|
||||
* @param options 选项列表
|
||||
* @returns 消息 ID
|
||||
*/
|
||||
public addQuestionMessage(title: string, question: string, options: QuestionOption[]): string {
|
||||
const id = `question-${Date.now()}`;
|
||||
this.addMessage({
|
||||
id,
|
||||
type: 'question',
|
||||
title,
|
||||
question,
|
||||
options,
|
||||
answered: false,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 DOM 结构
|
||||
* @returns 组件根元素
|
||||
*/
|
||||
private createDom(): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'bim-ai-chat';
|
||||
el.style.width = `${this.options.width}px`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="bim-ai-chat-header">
|
||||
<span class="bim-ai-chat-title"></span>
|
||||
<div class="bim-ai-chat-actions">
|
||||
<button class="bim-ai-chat-action-btn" data-action="new" title="${t('aiChat.action.new')}">
|
||||
${getIcon('plus')}
|
||||
</button>
|
||||
<button class="bim-ai-chat-action-btn" data-action="history" title="${t('aiChat.action.history')}">
|
||||
${getIcon('history')}
|
||||
</button>
|
||||
<button class="bim-ai-chat-action-btn" data-action="settings" title="${t('aiChat.action.settings')}">
|
||||
${getIcon('settings')}
|
||||
</button>
|
||||
<button class="bim-ai-chat-action-btn" data-action="close" title="${t('aiChat.action.close')}">
|
||||
${getIcon('close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bim-ai-chat-messages"></div>
|
||||
<div class="bim-ai-chat-composer">
|
||||
<div class="bim-ai-quick-prompts"></div>
|
||||
<div class="bim-ai-input-row">
|
||||
<div class="bim-ai-textbox">
|
||||
<textarea class="bim-ai-textarea" rows="1"></textarea>
|
||||
<div class="bim-ai-helper">
|
||||
<span class="bim-ai-helper-left"></span>
|
||||
<span class="bim-ai-helper-right"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="bim-ai-send-btn">
|
||||
${getIcon('arrowUpBold')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 获取子元素引用
|
||||
this.messagesContainer = el.querySelector('.bim-ai-chat-messages');
|
||||
this.textarea = el.querySelector('.bim-ai-textarea');
|
||||
|
||||
// 绑定事件
|
||||
this.bindEvents(el);
|
||||
this.updateQuickPrompts(el);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件监听
|
||||
* @param el 组件根元素
|
||||
*/
|
||||
private bindEvents(el: HTMLElement): void {
|
||||
// 顶部操作按钮事件
|
||||
el.querySelectorAll('.bim-ai-chat-action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const action = (e.currentTarget as HTMLElement).dataset.action;
|
||||
switch (action) {
|
||||
case 'new':
|
||||
this.options.onNewChat?.();
|
||||
break;
|
||||
case 'history':
|
||||
this.options.onHistory?.();
|
||||
break;
|
||||
case 'settings':
|
||||
this.options.onSettings?.();
|
||||
break;
|
||||
case 'close':
|
||||
this.hide();
|
||||
this.options.onClose?.();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 初始化拖拽功能
|
||||
const header = el.querySelector('.bim-ai-chat-header') as HTMLElement;
|
||||
if (header) {
|
||||
this.initDrag(header);
|
||||
}
|
||||
|
||||
// 发送按钮点击事件
|
||||
const sendBtn = el.querySelector('.bim-ai-send-btn');
|
||||
sendBtn?.addEventListener('click', () => this.handleSend());
|
||||
|
||||
// 输入框键盘事件:Enter 发送,Shift+Enter 换行
|
||||
this.textarea?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.handleSend();
|
||||
}
|
||||
});
|
||||
|
||||
// 输入框自动调整高度
|
||||
this.textarea?.addEventListener('input', () => {
|
||||
if (this.textarea) {
|
||||
this.textarea.style.height = 'auto';
|
||||
this.textarea.style.height = Math.min(this.textarea.scrollHeight, 100) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
// 阻止事件冒泡,避免影响底层 3D 场景
|
||||
const stopPropagation = (e: Event) => e.stopPropagation();
|
||||
const events = ['click', 'dblclick', 'mousedown', 'mouseup', 'mousemove', 'wheel', 'touchstart', 'touchend', 'touchmove'];
|
||||
events.forEach(eventType => {
|
||||
el.addEventListener(eventType, stopPropagation, { passive: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送消息
|
||||
*/
|
||||
private handleSend(): void {
|
||||
if (!this.textarea) return;
|
||||
const content = this.textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
// 清空输入框并重置高度
|
||||
this.textarea.value = '';
|
||||
this.textarea.style.height = 'auto';
|
||||
// 触发回调
|
||||
this.options.onSend?.(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化拖拽功能
|
||||
* 使用 requestAnimationFrame 优化性能,避免拖拽卡顿
|
||||
* @param header 标题栏元素(拖拽手柄)
|
||||
*/
|
||||
private initDrag(header: HTMLElement): void {
|
||||
// 拖拽状态变量
|
||||
let startX = 0; // 鼠标起始 X 坐标
|
||||
let startY = 0; // 鼠标起始 Y 坐标
|
||||
let startLeft = 0; // 元素起始 left 值
|
||||
let startTop = 0; // 元素起始 top 值
|
||||
let containerW = 0; // 容器宽度
|
||||
let containerH = 0; // 容器高度
|
||||
let elW = 0; // 元素宽度
|
||||
let elH = 0; // 元素高度
|
||||
|
||||
/**
|
||||
* 鼠标按下事件处理
|
||||
*/
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
// 如果点击的是操作按钮,不触发拖拽
|
||||
if ((e.target as HTMLElement).closest('.bim-ai-chat-action-btn')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 记录起始状态
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startLeft = this.element.offsetLeft;
|
||||
startTop = this.element.offsetTop;
|
||||
|
||||
// 记录容器和元素尺寸(用于边界检测)
|
||||
containerW = this.container.clientWidth;
|
||||
containerH = this.container.clientHeight;
|
||||
elW = this.element.offsetWidth;
|
||||
elH = this.element.offsetHeight;
|
||||
|
||||
// 添加全局鼠标事件监听(使用 capture 确保优先处理)
|
||||
document.addEventListener('mousemove', onMouseMove, { capture: true });
|
||||
document.addEventListener('mouseup', onMouseUp, { capture: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* 鼠标移动事件处理
|
||||
* 使用 requestAnimationFrame 节流,提升性能
|
||||
*/
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 如果上一帧还没处理完,跳过本次
|
||||
if (this.rafId) return;
|
||||
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
// 计算鼠标移动的增量
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
|
||||
// 计算新位置
|
||||
let newLeft = startLeft + dx;
|
||||
let newTop = startTop + dy;
|
||||
|
||||
// 边界检测:限制在容器范围内
|
||||
const maxLeft = containerW - elW;
|
||||
const maxTop = containerH - elH;
|
||||
|
||||
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
|
||||
newTop = Math.max(0, Math.min(newTop, maxTop));
|
||||
|
||||
// 应用新位置
|
||||
this.element.style.left = `${newLeft}px`;
|
||||
this.element.style.top = `${newTop}px`;
|
||||
|
||||
this.rafId = null;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 鼠标释放事件处理
|
||||
*/
|
||||
const onMouseUp = () => {
|
||||
// 取消未完成的动画帧
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
// 移除全局事件监听
|
||||
document.removeEventListener('mousemove', onMouseMove, { capture: true });
|
||||
document.removeEventListener('mouseup', onMouseUp, { capture: true });
|
||||
};
|
||||
|
||||
// 绑定标题栏鼠标按下事件
|
||||
header.addEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新快捷提示按钮
|
||||
* @param el 可选的根元素(初始化时使用)
|
||||
*/
|
||||
private updateQuickPrompts(el?: HTMLElement): void {
|
||||
const root = el || this.element;
|
||||
const container = root.querySelector('.bim-ai-quick-prompts');
|
||||
if (!container || !this.options.quickPrompts) return;
|
||||
|
||||
// 渲染快捷提示按钮
|
||||
container.innerHTML = this.options.quickPrompts.map(prompt => `
|
||||
<button class="bim-ai-quick-prompt" data-prompt-id="${prompt.id}">
|
||||
${t(prompt.label)}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
// 绑定点击事件:点击后填充到输入框
|
||||
container.querySelectorAll('.bim-ai-quick-prompt').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const promptId = (e.currentTarget as HTMLElement).dataset.promptId;
|
||||
const prompt = this.options.quickPrompts?.find(p => p.id === promptId);
|
||||
if (prompt && this.textarea) {
|
||||
this.textarea.value = t(prompt.label);
|
||||
this.textarea.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染消息列表
|
||||
* 根据消息类型渲染不同的消息卡片
|
||||
*/
|
||||
private renderMessages(): void {
|
||||
if (!this.messagesContainer) return;
|
||||
|
||||
this.messagesContainer.innerHTML = this.messages.map(msg => {
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
return this.renderUserMessage(msg.content);
|
||||
case 'ai':
|
||||
return this.renderAiMessage(msg.content);
|
||||
case 'step':
|
||||
return this.renderStepMessage(msg.status, msg.content);
|
||||
case 'thinking':
|
||||
return this.renderThinkingMessage();
|
||||
case 'question':
|
||||
return msg.answered
|
||||
? this.renderAnsweredQuestion(msg)
|
||||
: this.renderActiveQuestion(msg);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}).join('');
|
||||
|
||||
// 重新绑定问答卡片的事件
|
||||
this.bindQuestionEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染用户消息气泡
|
||||
*/
|
||||
private renderUserMessage(content: string): string {
|
||||
return `
|
||||
<div class="bim-ai-msg-user">
|
||||
<div class="bim-ai-bubble">
|
||||
<div class="bim-ai-bubble-content">${this.escapeHtml(content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 AI 消息气泡
|
||||
*/
|
||||
private renderAiMessage(content: string): string {
|
||||
return `
|
||||
<div class="bim-ai-msg-ai">
|
||||
<div class="bim-ai-bubble">
|
||||
<div class="bim-ai-bubble-content">${this.escapeHtml(content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染步骤消息卡片
|
||||
* @param status 状态:running/done/error
|
||||
* @param content 步骤描述
|
||||
*/
|
||||
private renderStepMessage(status: StepStatus, content: string): string {
|
||||
// 状态对应的图标映射
|
||||
const iconMap: Record<StepStatus, string> = {
|
||||
running: 'loader',
|
||||
done: 'check',
|
||||
error: 'error'
|
||||
};
|
||||
return `
|
||||
<div class="bim-ai-step bim-ai-step-${status}">
|
||||
<span class="bim-ai-step-icon">${getIcon(iconMap[status])}</span>
|
||||
<span class="bim-ai-step-text">${this.escapeHtml(content)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染思考中卡片
|
||||
*/
|
||||
private renderThinkingMessage(): string {
|
||||
return `
|
||||
<div class="bim-ai-thinking">
|
||||
<span class="bim-ai-thinking-icon">${getIcon('loader')}</span>
|
||||
<span class="bim-ai-thinking-text">${t('aiChat.thinking')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染未回答的问答卡片
|
||||
* @param msg 问答消息对象
|
||||
*/
|
||||
private renderActiveQuestion(msg: QuestionMessage): string {
|
||||
// 渲染选项列表
|
||||
const optionsHtml = msg.options.map(opt => `
|
||||
<div class="bim-ai-option ${msg.selectedOptionId === opt.id ? 'selected' : ''}"
|
||||
data-question-id="${msg.id}" data-option-id="${opt.id}" data-is-other="${opt.isOther || false}">
|
||||
<div class="bim-ai-option-radio">
|
||||
<div class="bim-ai-option-radio-dot"></div>
|
||||
</div>
|
||||
<span class="bim-ai-option-text">${opt.isOther ? t('aiChat.other') : this.escapeHtml(opt.label)}</span>
|
||||
</div>
|
||||
${opt.isOther && msg.selectedOptionId === opt.id ? `
|
||||
<input type="text" class="bim-ai-option-input"
|
||||
data-question-id="${msg.id}"
|
||||
placeholder="${t('aiChat.otherPlaceholder')}"
|
||||
value="${msg.customAnswer || ''}">
|
||||
` : ''}
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="bim-ai-question-active" data-question-id="${msg.id}">
|
||||
<div class="bim-ai-question-header">
|
||||
<div class="bim-ai-question-icon">${getIcon('bot')}</div>
|
||||
<span class="bim-ai-question-title">${this.escapeHtml(msg.title)}</span>
|
||||
</div>
|
||||
<div class="bim-ai-question-content">
|
||||
<div class="bim-ai-question-text">${this.escapeHtml(msg.question)}</div>
|
||||
<div class="bim-ai-question-options">${optionsHtml}</div>
|
||||
</div>
|
||||
<div class="bim-ai-question-footer">
|
||||
<button class="bim-ai-question-submit" data-question-id="${msg.id}">
|
||||
<span>${t('aiChat.submit')}</span>
|
||||
${getIcon('send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染已回答的问答卡片(简洁模式)
|
||||
* @param msg 问答消息对象
|
||||
*/
|
||||
private renderAnsweredQuestion(msg: QuestionMessage): string {
|
||||
const selectedOption = msg.options.find(o => o.id === msg.selectedOptionId);
|
||||
const answer = selectedOption?.isOther ? msg.customAnswer : selectedOption?.label;
|
||||
|
||||
return `
|
||||
<div class="bim-ai-question-answered">
|
||||
<div class="bim-ai-qa-row">
|
||||
<div class="bim-ai-qa-badge bim-ai-qa-badge-q">Q</div>
|
||||
<span class="bim-ai-qa-question">${this.escapeHtml(msg.question)}</span>
|
||||
</div>
|
||||
<div class="bim-ai-qa-row">
|
||||
<div class="bim-ai-qa-badge bim-ai-qa-badge-a">A</div>
|
||||
<span class="bim-ai-qa-answer">${this.escapeHtml(answer || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定问答卡片的交互事件
|
||||
* 包括:选项点击、自定义输入、提交按钮
|
||||
*/
|
||||
private bindQuestionEvents(): void {
|
||||
// 选项点击事件
|
||||
this.messagesContainer?.querySelectorAll('.bim-ai-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const questionId = target.dataset.questionId;
|
||||
const optionId = target.dataset.optionId;
|
||||
|
||||
if (questionId && optionId) {
|
||||
const msg = this.messages.find(m => m.id === questionId) as QuestionMessage;
|
||||
if (msg && !msg.answered) {
|
||||
msg.selectedOptionId = optionId;
|
||||
this.renderMessages();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 自定义输入框事件
|
||||
this.messagesContainer?.querySelectorAll('.bim-ai-option-input').forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const questionId = target.dataset.questionId;
|
||||
|
||||
if (questionId) {
|
||||
const msg = this.messages.find(m => m.id === questionId) as QuestionMessage;
|
||||
if (msg) {
|
||||
msg.customAnswer = target.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 提交按钮事件
|
||||
this.messagesContainer?.querySelectorAll('.bim-ai-question-submit').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const questionId = target.dataset.questionId;
|
||||
|
||||
if (questionId) {
|
||||
const msg = this.messages.find(m => m.id === questionId) as QuestionMessage;
|
||||
if (msg && msg.selectedOptionId) {
|
||||
msg.answered = true;
|
||||
this.renderMessages();
|
||||
this.options.onQuestionSubmit?.(questionId, msg.selectedOptionId, msg.customAnswer);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动消息列表到底部
|
||||
*/
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义,防止 XSS 攻击
|
||||
* @param str 原始字符串
|
||||
* @returns 转义后的字符串
|
||||
*/
|
||||
private escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁组件
|
||||
* 取消订阅并移除 DOM 元素
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this._isDestroyed) return;
|
||||
|
||||
// 取消主题订阅
|
||||
if (this.unsubscribeTheme) {
|
||||
this.unsubscribeTheme();
|
||||
this.unsubscribeTheme = null;
|
||||
}
|
||||
|
||||
// 取消语言订阅
|
||||
if (this.unsubscribeLocale) {
|
||||
this.unsubscribeLocale();
|
||||
this.unsubscribeLocale = null;
|
||||
}
|
||||
|
||||
// 移除 DOM 元素
|
||||
this.element.remove();
|
||||
this._isDestroyed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user