xinsi_book/src/components/AIChatModuleVue2/AIFloatButton.vue
caoyuchun b3a47589d8 cyc
2025-10-23 10:39:42 +08:00

535 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>