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:
535
src/components/ai-chat/index.css
Normal file
535
src/components/ai-chat/index.css
Normal file
@@ -0,0 +1,535 @@
|
||||
/* AI 聊天对话框组件样式 */
|
||||
|
||||
.bim-ai-chat {
|
||||
position: absolute;
|
||||
width: 440px;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bim-ai-panel, rgba(11, 18, 32, 0.95));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
box-shadow: 0 12px 32px var(--bim-ai-shadow, rgba(0, 0, 0, 0.4));
|
||||
z-index: 1000;
|
||||
font-family: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
min-width: 360px;
|
||||
min-height: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.bim-ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.bim-ai-chat-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
.bim-ai-chat-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bim-ai-chat-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
background: transparent;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bim-ai-chat-action-btn:hover {
|
||||
background: var(--bim-ai-subtle-fill, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.bim-ai-chat-action-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 消息区域 */
|
||||
.bim-ai-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* AI 消息气泡 */
|
||||
.bim-ai-msg-ai {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.bim-ai-msg-ai .bim-ai-bubble {
|
||||
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.bim-ai-msg-ai .bim-ai-bubble-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
/* 用户消息气泡 */
|
||||
.bim-ai-msg-user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bim-ai-msg-user .bim-ai-bubble {
|
||||
max-width: 300px;
|
||||
background: var(--bim-ai-user-bubble, rgba(29, 78, 216, 0.8));
|
||||
border-radius: 16px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.bim-ai-msg-user .bim-ai-bubble-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-user-text, #EEF2FF);
|
||||
}
|
||||
|
||||
/* 步骤卡片 */
|
||||
.bim-ai-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.bim-ai-step-running {
|
||||
background: var(--bim-ai-info-soft, rgba(56, 189, 248, 0.1));
|
||||
border: 1px solid rgba(56, 189, 248, 0.33);
|
||||
}
|
||||
|
||||
.bim-ai-step-done {
|
||||
background: var(--bim-ai-success-soft, rgba(52, 211, 153, 0.1));
|
||||
border: 1px solid rgba(52, 211, 153, 0.33);
|
||||
}
|
||||
|
||||
.bim-ai-step-error {
|
||||
background: var(--bim-ai-danger-soft, rgba(239, 68, 68, 0.1));
|
||||
border: 1px solid var(--bim-ai-danger-stroke, rgba(239, 68, 68, 0.33));
|
||||
}
|
||||
|
||||
.bim-ai-step-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bim-ai-step-running .bim-ai-step-icon {
|
||||
color: var(--bim-ai-info, #38BDF8);
|
||||
}
|
||||
|
||||
.bim-ai-step-done .bim-ai-step-icon {
|
||||
color: var(--bim-ai-success, #34D399);
|
||||
}
|
||||
|
||||
.bim-ai-step-error .bim-ai-step-icon {
|
||||
color: var(--bim-ai-danger, #EF4444);
|
||||
}
|
||||
|
||||
.bim-ai-step-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
/* 思考中卡片 */
|
||||
.bim-ai-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.bim-ai-thinking-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--bim-ai-muted, #94A3B8);
|
||||
animation: bim-ai-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.bim-ai-thinking-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bim-ai-muted, #94A3B8);
|
||||
}
|
||||
|
||||
@keyframes bim-ai-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 问答卡片 - 未回答状态 */
|
||||
.bim-ai-question-active {
|
||||
width: 360px;
|
||||
border-radius: 14px;
|
||||
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
|
||||
border: 1px solid var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
|
||||
box-shadow: 0 4px 16px var(--bim-ai-shadow, rgba(0, 0, 0, 0.4));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bim-ai-question-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.bim-ai-question-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: var(--bim-ai-accent-soft, rgba(59, 130, 246, 0.1));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bim-ai-accent, #3B82F6);
|
||||
}
|
||||
|
||||
.bim-ai-question-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.bim-ai-question-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
.bim-ai-question-content {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bim-ai-question-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
.bim-ai-question-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bim-ai-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bim-ai-subtle-fill, rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bim-ai-option:hover {
|
||||
background: var(--bim-ai-subtle-fill-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.bim-ai-option.selected {
|
||||
background: var(--bim-ai-accent-soft, rgba(59, 130, 246, 0.1));
|
||||
border-color: var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
|
||||
}
|
||||
|
||||
.bim-ai-option-radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--bim-ai-muted, #94A3B8);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bim-ai-option.selected .bim-ai-option-radio {
|
||||
background: var(--bim-ai-accent, #3B82F6);
|
||||
border-color: var(--bim-ai-accent, #3B82F6);
|
||||
}
|
||||
|
||||
.bim-ai-option-radio-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bim-ai-accent-ink, #FFFFFF);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bim-ai-option.selected .bim-ai-option-radio-dot {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bim-ai-option-text {
|
||||
font-size: 12px;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
.bim-ai-option-input {
|
||||
flex: 1;
|
||||
background: var(--bim-ai-textbox-fill, rgba(15, 23, 42, 0.6));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
outline: none;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bim-ai-option-input:focus {
|
||||
border-color: var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
|
||||
}
|
||||
|
||||
.bim-ai-question-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.bim-ai-question-submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bim-ai-accent, #3B82F6);
|
||||
color: var(--bim-ai-accent-ink, #FFFFFF);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bim-ai-question-submit:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.bim-ai-question-submit svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* 问答卡片 - 已回答状态 */
|
||||
.bim-ai-question-answered {
|
||||
max-width: 340px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bim-ai-qa-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bim-ai-qa-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bim-ai-qa-badge-q {
|
||||
background: var(--bim-ai-accent-soft, rgba(59, 130, 246, 0.1));
|
||||
color: var(--bim-ai-accent, #3B82F6);
|
||||
}
|
||||
|
||||
.bim-ai-qa-badge-a {
|
||||
background: var(--bim-ai-success-soft, rgba(52, 211, 153, 0.1));
|
||||
color: var(--bim-ai-success, #34D399);
|
||||
}
|
||||
|
||||
.bim-ai-qa-question {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-muted, #94A3B8);
|
||||
}
|
||||
|
||||
.bim-ai-qa-answer {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
}
|
||||
|
||||
/* 底部输入区域 */
|
||||
.bim-ai-chat-composer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bim-ai-quick-prompts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bim-ai-quick-prompt {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bim-ai-subtle-fill, rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bim-ai-quick-prompt:hover {
|
||||
background: var(--bim-ai-subtle-fill-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.bim-ai-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bim-ai-textbox {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
background: var(--bim-ai-textbox-fill, rgba(15, 23, 42, 0.6));
|
||||
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.bim-ai-textbox:focus-within {
|
||||
border-color: var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
|
||||
}
|
||||
|
||||
.bim-ai-textarea {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bim-ai-text, #E5E7EB);
|
||||
min-height: 20px;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.bim-ai-textarea::placeholder {
|
||||
color: var(--bim-ai-muted, #94A3B8);
|
||||
}
|
||||
|
||||
.bim-ai-helper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--bim-ai-muted, #94A3B8);
|
||||
}
|
||||
|
||||
.bim-ai-send-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
background: var(--bim-ai-accent, #3B82F6);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bim-ai-accent-ink, #FFFFFF);
|
||||
transition: background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bim-ai-send-btn:hover {
|
||||
background: var(--bim-ai-accent-hover, #2563EB);
|
||||
}
|
||||
|
||||
.bim-ai-send-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 浅色主题覆盖 */
|
||||
.bim-ai-chat.light {
|
||||
--bim-ai-panel: rgba(255, 255, 255, 0.95);
|
||||
--bim-ai-panel2: #FFFFFF;
|
||||
--bim-ai-border: rgba(15, 23, 42, 0.1);
|
||||
--bim-ai-text: #0F172A;
|
||||
--bim-ai-muted: #475569;
|
||||
--bim-ai-shadow: rgba(11, 18, 32, 0.1);
|
||||
--bim-ai-subtle-fill: rgba(15, 23, 42, 0.05);
|
||||
--bim-ai-subtle-fill-hover: rgba(15, 23, 42, 0.08);
|
||||
--bim-ai-textbox-fill: #F1F5F9;
|
||||
--bim-ai-user-bubble: #2563EB;
|
||||
--bim-ai-user-text: #FFFFFF;
|
||||
--bim-ai-accent: #2563EB;
|
||||
--bim-ai-accent-soft: rgba(37, 99, 235, 0.08);
|
||||
--bim-ai-accent-stroke: rgba(37, 99, 235, 0.2);
|
||||
--bim-ai-success: #059669;
|
||||
--bim-ai-success-soft: rgba(5, 150, 105, 0.08);
|
||||
--bim-ai-danger: #DC2626;
|
||||
--bim-ai-danger-soft: rgba(220, 38, 38, 0.08);
|
||||
--bim-ai-danger-stroke: rgba(220, 38, 38, 0.2);
|
||||
--bim-ai-info: #0284C7;
|
||||
--bim-ai-info-soft: rgba(2, 132, 199, 0.08);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
146
src/components/ai-chat/types.ts
Normal file
146
src/components/ai-chat/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* AI 聊天组件类型定义
|
||||
* 包含消息类型、问答卡片、组件配置等
|
||||
*/
|
||||
|
||||
/**
|
||||
* 消息类型枚举
|
||||
*/
|
||||
export type MessageType = 'user' | 'ai' | 'step' | 'thinking' | 'question';
|
||||
|
||||
/**
|
||||
* 步骤状态枚举
|
||||
*/
|
||||
export type StepStatus = 'running' | 'done' | 'error';
|
||||
|
||||
/**
|
||||
* 基础消息接口
|
||||
*/
|
||||
export interface BaseMessage {
|
||||
/** 消息唯一标识 */
|
||||
id: string;
|
||||
/** 消息类型 */
|
||||
type: MessageType;
|
||||
/** 创建时间 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息
|
||||
*/
|
||||
export interface UserMessage extends BaseMessage {
|
||||
type: 'user';
|
||||
/** 消息内容 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 回复消息
|
||||
*/
|
||||
export interface AiMessage extends BaseMessage {
|
||||
type: 'ai';
|
||||
/** 消息内容 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤消息(执行中/完成/失败)
|
||||
*/
|
||||
export interface StepMessage extends BaseMessage {
|
||||
type: 'step';
|
||||
/** 步骤状态 */
|
||||
status: StepStatus;
|
||||
/** 步骤描述 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 思考中消息
|
||||
*/
|
||||
export interface ThinkingMessage extends BaseMessage {
|
||||
type: 'thinking';
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答选项
|
||||
*/
|
||||
export interface QuestionOption {
|
||||
/** 选项唯一标识 */
|
||||
id: string;
|
||||
/** 选项文本 */
|
||||
label: string;
|
||||
/** 是否为"其他"选项 */
|
||||
isOther?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答卡片消息
|
||||
*/
|
||||
export interface QuestionMessage extends BaseMessage {
|
||||
type: 'question';
|
||||
/** 问题标题 */
|
||||
title: string;
|
||||
/** 问题内容 */
|
||||
question: string;
|
||||
/** 选项列表 */
|
||||
options: QuestionOption[];
|
||||
/** 是否已回答 */
|
||||
answered: boolean;
|
||||
/** 选中的选项 ID */
|
||||
selectedOptionId?: string;
|
||||
/** 自定义回答(当选择"其他"时) */
|
||||
customAnswer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息联合类型
|
||||
*/
|
||||
export type Message = UserMessage | AiMessage | StepMessage | ThinkingMessage | QuestionMessage;
|
||||
|
||||
/**
|
||||
* 快捷提示配置
|
||||
*/
|
||||
export interface QuickPrompt {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
/** 显示文本(翻译键) */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 聊天组件配置
|
||||
*/
|
||||
export interface AiChatOptions {
|
||||
/** 挂载容器 */
|
||||
container: HTMLElement;
|
||||
/** 抽屉宽度,默认 440 */
|
||||
width?: number;
|
||||
/** 标题(翻译键) */
|
||||
title?: string;
|
||||
/** 输入框占位符(翻译键) */
|
||||
placeholder?: string;
|
||||
/** 快捷提示列表 */
|
||||
quickPrompts?: QuickPrompt[];
|
||||
/** 发送消息回调 */
|
||||
onSend?: (message: string) => void;
|
||||
/** 问答提交回调 */
|
||||
onQuestionSubmit?: (questionId: string, optionId: string, customAnswer?: string) => void;
|
||||
/** 新建对话回调 */
|
||||
onNewChat?: () => void;
|
||||
/** 打开设置回调 */
|
||||
onSettings?: () => void;
|
||||
/** 打开历史回调 */
|
||||
onHistory?: () => void;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已回答问答显示数据
|
||||
*/
|
||||
export interface AnsweredQuestion {
|
||||
/** 问题 */
|
||||
question: string;
|
||||
/** 回答 */
|
||||
answer: string;
|
||||
}
|
||||
28
src/components/button-group/toolbar/buttons/ai-chat/index.ts
Normal file
28
src/components/button-group/toolbar/buttons/ai-chat/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ButtonConfig } from '../../../index.type';
|
||||
import { getIcon } from '../../../../../utils/icon-manager';
|
||||
import { ManagerRegistry } from '../../../../../core/manager-registry';
|
||||
|
||||
export const createAiChatButton = (): ButtonConfig => {
|
||||
const registry = ManagerRegistry.getInstance();
|
||||
|
||||
registry.on('aiChat:opened', () => {
|
||||
registry.toolbar?.setBtnActive('aiChat', true);
|
||||
});
|
||||
|
||||
registry.on('aiChat:closed', () => {
|
||||
registry.toolbar?.setBtnActive('aiChat', false);
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'aiChat',
|
||||
groupId: 'group-2',
|
||||
type: 'button',
|
||||
label: 'aiChat.title',
|
||||
align: 'vertical',
|
||||
keepActive: true,
|
||||
icon: getIcon('bot'),
|
||||
onClick: () => {
|
||||
registry.aiChat?.toggle();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export class Toolbar extends BimButtonGroup {
|
||||
const { createSectionPlaneButton } = await import('./buttons/section/section-plane');
|
||||
const { createSectionAxisButton } = await import('./buttons/section/section-axis');
|
||||
const { createSectionBoxButton } = await import('./buttons/section/section-box');
|
||||
const { createAiChatButton } = await import('./buttons/ai-chat');
|
||||
|
||||
this.addGroup('group-1');
|
||||
|
||||
@@ -31,6 +32,7 @@ export class Toolbar extends BimButtonGroup {
|
||||
this.addButton(createMapButton());
|
||||
this.addButton(createPropertyButton());
|
||||
this.addGroup('group-2');
|
||||
this.addButton(createAiChatButton());
|
||||
this.addButton(createSettingButton());
|
||||
this.addButton(createInfoButton());
|
||||
this.addButton(createFullscreenButton());
|
||||
|
||||
@@ -261,16 +261,23 @@ export class SectionBoxPanel implements IBimComponent {
|
||||
|
||||
const stop = (e: PointerEvent) => {
|
||||
if (this.dragState.isDragging && this.dragState.pointerId === e.pointerId) {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
if (handle.hasPointerCapture(e.pointerId)) {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
(handle.closest('.section-box-slider') as HTMLElement).style.zIndex = '';
|
||||
handle.classList.remove('dragging');
|
||||
this.dragState.isDragging = false;
|
||||
this.dragState.pointerId = null;
|
||||
this.dragState = {
|
||||
isDragging: false,
|
||||
axis: null,
|
||||
handleType: null,
|
||||
pointerId: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
handle.addEventListener('lostpointercapture', stop);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export class SectionPlanePanel implements IBimComponent {
|
||||
public element: HTMLElement;
|
||||
private options: SectionPlanePanelOptions;
|
||||
|
||||
private isHidden: boolean = false;
|
||||
|
||||
// DOM 引用
|
||||
private hideBtn!: HTMLButtonElement;
|
||||
private reverseBtn!: HTMLButtonElement;
|
||||
@@ -28,9 +30,23 @@ export class SectionPlanePanel implements IBimComponent {
|
||||
|
||||
constructor(options: SectionPlanePanelOptions = {}) {
|
||||
this.options = options;
|
||||
this.isHidden = options.defaultHidden ?? false;
|
||||
this.element = this.createDom();
|
||||
}
|
||||
|
||||
public setHiddenState(isHidden: boolean): void {
|
||||
this.isHidden = isHidden;
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
public getHiddenState(): boolean {
|
||||
return this.isHidden;
|
||||
}
|
||||
|
||||
private updateButtonStates(): void {
|
||||
this.hideBtn?.classList.toggle('active', this.isHidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化组件
|
||||
*/
|
||||
@@ -105,9 +121,9 @@ export class SectionPlanePanel implements IBimComponent {
|
||||
'hide',
|
||||
getIcon('隐藏'),
|
||||
() => {
|
||||
if (this.options.onHide) {
|
||||
this.options.onHide();
|
||||
}
|
||||
this.isHidden = !this.isHidden;
|
||||
this.updateButtonStates();
|
||||
this.options.onHideToggle?.(this.isHidden);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
*/
|
||||
export interface SectionPlanePanelOptions {
|
||||
/**
|
||||
* 隐藏按钮回调
|
||||
* 初始隐藏状态
|
||||
*/
|
||||
onHide?: () => void;
|
||||
defaultHidden?: boolean;
|
||||
|
||||
/**
|
||||
* 隐藏状态切换回调
|
||||
* @param isHidden 是否隐藏
|
||||
*/
|
||||
onHideToggle?: (isHidden: boolean) => void;
|
||||
|
||||
/**
|
||||
* 反向按钮回调
|
||||
|
||||
Reference in New Issue
Block a user