xinsi_book/src/components/AIChatModuleVue2/AIFloatButton.vue

535 lines
14 KiB
Vue
Raw Normal View History

2025-10-23 02:39:42 +00:00
<template>
<div class="ai-float-wrapper">
<div class="ai-float-container" ref="containerRef">
<!-- 悬浮按钮 -->
<button ref="buttonRef" @click.stop="toggleChat" @mousedown="startDrag" class="ai-float-button"
:class="{ 'pulse-animation': isNewMessage, 'active': showChat }" :style="buttonStyle">
<span v-if="isNewMessage" class="notification-dot"></span>
<div class="button-content">
<slot name="button-icon" :isChatOpen="showChat">
<svg v-if="!showChat" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</slot>
<span class="button-text">{{ showChat ? closeText : openText }}</span>
</div>
</button>
</div>
<!-- 聊天面板 -->
<div v-if="showChat" class="chat-panel">
<div class="chat-panel-content">
<div class="chat-panel-header">
<div class="header-title">
<slot name="header-icon">
<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">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
</slot>
<h3>{{ headerTitle }}</h3>
</div>
<button @click="closeChat" class="close-button">&times;</button>
</div>
<div class="chat-panel-body">
<slot :closeChat="closeChat">
<!-- 默认插槽内容如果未提供则使用AIChat组件 -->
<AIChat v-if="useDefaultAIChat" v-bind="aiChatProps" @new-message="handleNewMessage" />
</slot>
</div>
</div>
</div>
</div>
</template>
<script>
import AIChat from './AIChat.vue';
export default {
name: 'AIFloatButton',
components: {
AIChat
},
props: {
// AIChat 组件相关属性
baseUrl: {
type: String,
default: null
},
modelAuthKey: {
type: String,
default: null
},
knowledgeBaseId: {
type: String,
default: null
},
knowledgeBaseUrl: {
type: String,
default: '/api/knowledgebases/query'
},
knowledgeBaseAuthKey: {
type: String,
default: ''
},
// UI 相关属性
openText: {
type: String,
default: 'AI助手'
},
closeText: {
type: String,
default: '关闭'
},
headerTitle: {
type: String,
default: 'AI 助手'
},
// 行为相关属性
useDefaultAIChat: {
type: Boolean,
default: true
},
// 位置相关属性
initialPosition: {
type: Object,
default: function () { return { x: null, y: null }; } // 如果为null则使用默认位置
}
},
data() {
return {
showChat: false,
isNewMessage: false,
notificationTimeout: null,
isDragging: false,
dragState: {
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0
},
buttonPosition: {
x: this.initialPosition.x,
y: this.initialPosition.y
}
};
},
computed: {
// 计算传递给AIChat组件的属性
aiChatProps() {
return {
baseUrl: this.baseUrl,
modelAuthKey: this.modelAuthKey,
knowledgeBaseId: this.knowledgeBaseId,
knowledgeBaseUrl: this.knowledgeBaseUrl,
knowledgeBaseAuthKey: this.knowledgeBaseAuthKey
};
},
// 计算按钮样式
buttonStyle() {
// 如果有自定义位置,则使用自定义位置
if (this.buttonPosition.x !== null && this.buttonPosition.y !== null) {
return {
left: `${this.buttonPosition.x}px`,
top: `${this.buttonPosition.y}px`,
};
}
// 否则使用默认的右下角位置
return {
bottom: '20px',
right: '20px',
};
}
},
mounted() {
document.addEventListener('keydown', this.handleEscapeKey);
window.addEventListener('resize', this.handleWindowResize);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleEscapeKey);
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
window.removeEventListener('resize', this.handleWindowResize);
if (this.notificationTimeout) {
clearTimeout(this.notificationTimeout);
}
},
methods: {
// 开始拖动
startDrag(event) {
// 如果点击的是按钮内容(图标或文本),则不触发拖动
if (event.target.closest('.button-content')) {
return;
}
event.preventDefault();
this.isDragging = true;
// 获取按钮当前位置
const buttonRect = this.$refs.buttonRef.getBoundingClientRect();
this.dragState = {
startX: event.clientX,
startY: event.clientY,
startLeft: buttonRect.left,
startTop: buttonRect.top
};
// 如果聊天面板打开,在拖动开始时立即更新其位置
if (this.showChat) {
// 强制重新计算buttonStyle
this.$forceUpdate();
}
// 添加全局事件监听器
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('mouseup', this.stopDrag);
},
// 拖动中
onDrag(event) {
if (!this.isDragging) return;
event.preventDefault();
// 计算新位置
const deltaX = event.clientX - this.dragState.startX;
const deltaY = event.clientY - this.dragState.startY;
const newLeft = this.dragState.startLeft + deltaX;
const newTop = this.dragState.startTop + deltaY;
// 确保按钮不会被拖出屏幕边界
const boundedLeft = Math.max(0, Math.min(newLeft, window.innerWidth - 64));
const boundedTop = Math.max(0, Math.min(newTop, window.innerHeight - 64));
// 更新按钮位置状态
this.buttonPosition = {
x: boundedLeft,
y: boundedTop
};
// 如果聊天面板打开,在拖动过程中更新其位置
if (this.showChat) {
// 强制触发buttonStyle重新计算
this.$forceUpdate();
}
},
// 停止拖动
stopDrag() {
this.isDragging = false;
// 移除全局事件监听器
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
},
// 切换聊天面板显示状态
toggleChat(event) {
// 如果正在拖动,则不打开聊天面板
if (this.isDragging) {
this.isDragging = false;
return;
}
event.stopPropagation();
this.showChat = !this.showChat;
this.isNewMessage = false;
// 清除可能存在的定时器
if (this.notificationTimeout) {
clearTimeout(this.notificationTimeout);
this.notificationTimeout = null;
}
this.$emit('toggle', this.showChat);
},
// 关闭聊天面板
closeChat() {
this.showChat = false;
this.$emit('close');
},
// 处理新消息提醒
handleNewMessage() {
// 如果聊天窗口未打开,则显示新消息提醒
if (!this.showChat) {
this.isNewMessage = true;
// 3秒后自动清除提醒
if (this.notificationTimeout) {
clearTimeout(this.notificationTimeout);
}
this.notificationTimeout = setTimeout(() => {
this.isNewMessage = false;
}, 3000);
}
this.$emit('new-message');
},
// 点击 Escape 键关闭聊天面板
handleEscapeKey(event) {
if (this.showChat && event.key === 'Escape') {
this.closeChat();
}
},
// 处理窗口大小变化
handleWindowResize() {
// 窗口大小变化时,强制重新计算面板位置
if (this.showChat) {
// 触发响应式更新
this.$forceUpdate();
}
}
}
};
</script>
<style scoped>
.ai-float-wrapper {
position: relative;
z-index: 1000;
}
.ai-float-container {
position: fixed;
bottom: 20px;
right: 20px;
}
.ai-float-button {
position: fixed;
background: linear-gradient(135deg, #4a6cf7 0%, #6a11cb 100%);
color: white;
border: none;
border-radius: 50px;
width: 64px;
height: 64px;
font-weight: bold;
cursor: move;
/* 显示拖动光标 */
box-shadow: 0 6px 20px rgba(74, 108, 247, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
overflow: hidden;
z-index: 1000;
}
.ai-float-button:hover:not(:active) {
transform: translateY(-3px) scale(1.05);
box-shadow: 0 10px 25px rgba(74, 108, 247, 0.5);
}
.ai-float-button:active {
transform: translateY(0) scale(1);
}
.ai-float-button.active {
display: none;
}
.button-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
pointer-events: none;
/* 防止内容区域影响拖动 */
}
.button-text {
font-size: 10px;
font-weight: 600;
}
.notification-dot {
position: absolute;
top: -2px;
right: -2px;
width: 16px;
height: 16px;
background: #ff4757;
border: 2px solid white;
border-radius: 50%;
animation: pulse 1.5s infinite;
z-index: 1;
pointer-events: none;
/* 防止通知点影响拖动 */
}
.pulse-animation {
animation: pulse-button 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 8px rgba(255, 71, 87, 0);
}
100% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(255, 71, 87, 0);
}
}
@keyframes pulse-button {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.chat-panel {
width: 450px;
min-height: 400px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
border-radius: 16px;
overflow: hidden;
background: white;
z-index: 1001;
animation: slideIn 0.3s ease-out;
position: fixed;
bottom: 20px;
right: 20px;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-5%) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.chat-panel-content {
display: flex;
flex-direction: column;
height: 100%;
max-height: 80vh;
}
.chat-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
background: linear-gradient(120deg, #4a6cf7 0%, #6a11cb 100%);
color: white;
flex-shrink: 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
}
.header-title h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.close-button {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
color: white;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.chat-panel-body {
flex: 1;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
}
@media (max-width: 600px) {
.chat-panel {
width: calc(100vw - 20px);
max-height: 80vh;
max-width: 350px;
bottom: 10px;
right: 10px;
}
.chat-panel-content {
max-height: 80vh;
}
.chat-panel-body {
min-height: 300px;
}
.ai-float-button {
width: 56px;
height: 56px;
}
.ai-float-button.active {
border-radius: 20px 20px 6px 6px;
}
.button-text {
font-size: 9px;
}
}
</style>