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:
yuding
2026-02-02 16:36:17 +08:00
parent 41abd9ed67
commit 4a09d52283
44 changed files with 17877 additions and 10807 deletions

View 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;
}
}