667 lines
20 KiB
Vue
667 lines
20 KiB
Vue
<template>
|
|
<div class="chat-container">
|
|
<!-- 聊天按钮 -->
|
|
<div v-if="isCollapsed"
|
|
class="chat-button"
|
|
@mousedown="startDrag"
|
|
:style="position"
|
|
>
|
|
<div class="button-content" @click="toggleCollapse">
|
|
<i class="fa fa-comments"></i>
|
|
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 聊天面板 -->
|
|
<div v-else
|
|
class="chat-panel"
|
|
:style="panelPosition"
|
|
@mousedown="startDrag"
|
|
>
|
|
<div class="panel-title">
|
|
<i class="fa fa-comments"></i>
|
|
<i class="fa fa-times" @click="toggleCollapse"></i>
|
|
</div>
|
|
<div class="panel-content">
|
|
<div class="user-list">
|
|
</div>
|
|
<div class="chat-area">
|
|
<div class="message-list"></div>
|
|
<div class="input-area">
|
|
<el-input
|
|
v-model="messageInput"
|
|
type="textarea"
|
|
:rows="4"
|
|
resize="none"
|
|
placeholder="输入消息..."
|
|
@keyup.enter.native.exact="sendMessage"
|
|
></el-input>
|
|
<el-button type="primary" @click="sendMessage">发送</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- <div class="panel-content2" style="display: none;">
|
|
<div class="user-list">
|
|
<div class="panel-header">在线用户</div>
|
|
<div class="user-list-content">
|
|
<div
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
class="user-item"
|
|
:class="{ 'user-item--active': selectedUser?.id === user.id }"
|
|
@click="selectUser(user)"
|
|
>
|
|
<i class="fa fa-user"></i>
|
|
<span class="user-name">{{ user.name }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chat-area">
|
|
<div class="panel-header">
|
|
{{ selectedUser ? `与 ${selectedUser.name} 聊天` : '群聊' }}
|
|
</div>
|
|
<div class="message-list" ref="messageList">
|
|
<div
|
|
v-for="message in filteredMessages"
|
|
:key="message.id"
|
|
class="message-item"
|
|
:class="{
|
|
'message-item--self': message.from.id === currentUser.id,
|
|
'message-item--system': message.type === 'system'
|
|
}"
|
|
>
|
|
<template v-if="message.type === 'chat'">
|
|
<div class="message-header">
|
|
<span class="message-sender">{{ message.from.name }}</span>
|
|
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
|
|
</div>
|
|
<div class="message-content">{{ message.content }}</div>
|
|
</template>
|
|
<template v-else-if="message.type === 'system'">
|
|
<div class="system-message">
|
|
{{ formatSystemMessage(message) }}
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="input-area">
|
|
<el-input
|
|
v-model="messageInput"
|
|
type="textarea"
|
|
:rows="3"
|
|
placeholder="输入消息..."
|
|
@keyup.enter.native.exact="sendMessage"
|
|
></el-input>
|
|
<el-button type="primary" @click="sendMessage">发送</el-button>
|
|
</div>
|
|
</div>
|
|
</div> -->
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { useUserStore } from '@/store'
|
|
|
|
// 状态
|
|
const isCollapsed = ref(true)
|
|
const position = ref({ right: '20px', bottom: '20px' })
|
|
const panelPosition = ref({})
|
|
const messageInput = ref('')
|
|
const selectedUser = ref(null)
|
|
const users = ref([])
|
|
const messages = ref([])
|
|
const unreadCount = ref(0)
|
|
const ws = ref(null)
|
|
const messageList = ref(null)
|
|
const isDragging = ref(false)
|
|
const dragOffset = ref({ x: 0, y: 0 })
|
|
|
|
// 计算面板显示位置
|
|
const updatePanelPosition = () => {
|
|
const windowWidth = window.innerWidth
|
|
const windowHeight = window.innerHeight
|
|
const buttonRect = {
|
|
left: parseInt(position.value.left || windowWidth - parseInt(position.value.right || 0) - 60),
|
|
top: parseInt(position.value.top || windowHeight - parseInt(position.value.bottom || 0) - 60),
|
|
width: 60,
|
|
height: 60
|
|
}
|
|
|
|
// 计算面板的理想位置
|
|
const panelWidth = 800
|
|
const panelHeight = 600
|
|
|
|
// 根据按钮位置决定面板展开方向
|
|
if (buttonRect.left > windowWidth / 2) {
|
|
// 按钮在右半屏,面板向左展开
|
|
panelPosition.value.right = (windowWidth - buttonRect.left - buttonRect.width) + 'px'
|
|
panelPosition.value.left = 'auto'
|
|
} else {
|
|
// 按钮在左半屏,面板向右展开
|
|
panelPosition.value.left = buttonRect.left + 'px'
|
|
panelPosition.value.right = 'auto'
|
|
}
|
|
|
|
if (buttonRect.top > windowHeight / 2) {
|
|
// 按钮在下半屏,面板向上展开
|
|
panelPosition.value.bottom = (windowHeight - buttonRect.top) + 'px'
|
|
panelPosition.value.top = 'auto'
|
|
} else {
|
|
// 按钮在上半屏,面板向下展开
|
|
panelPosition.value.top = (buttonRect.top + buttonRect.height) + 'px'
|
|
panelPosition.value.bottom = 'auto'
|
|
}
|
|
}
|
|
|
|
// 获取当前用户信息
|
|
const userStore = useUserStore()
|
|
const currentUser = computed(() => ({
|
|
id: userStore.userId,
|
|
name: userStore.userName
|
|
}))
|
|
|
|
// 过滤消息列表
|
|
const filteredMessages = computed(() => {
|
|
return messages.value.filter(msg => {
|
|
if (msg.type === 'system') return true
|
|
if (!selectedUser.value) return !msg.to // 群聊消息
|
|
return (
|
|
(msg.from.id === selectedUser.value.id && msg.to?.id === currentUser.value.id) ||
|
|
(msg.from.id === currentUser.value.id && msg.to?.id === selectedUser.value.id)
|
|
)
|
|
})
|
|
})
|
|
|
|
// WebSocket连接
|
|
const ping_timer = ref(null)
|
|
const connectWebSocket = () => {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
// const wsUrl = `${protocol}//${window.location.host}/ws/chat`
|
|
const wsUrl = `${protocol}//localhost:3100`
|
|
// console.log('WebSocket连接地址:', wsUrl)
|
|
ws.value = new WebSocket(wsUrl)
|
|
|
|
ws.value.onopen = () => {
|
|
console.log('WebSocket连接已建立')
|
|
// 发送用户信息
|
|
sendUserInfo()
|
|
ping_timer.value = setInterval(() => {
|
|
ws.value.send(JSON.stringify({
|
|
type: 'ping'
|
|
}))
|
|
}, 29000) // 每30秒发送一次ping消息
|
|
}
|
|
|
|
ws.value.onmessage = (event) => {
|
|
const message = JSON.parse(event.data)
|
|
handleMessage(message)
|
|
}
|
|
|
|
ws.value.onclose = () => {
|
|
console.log('WebSocket连接已关闭')
|
|
clearInterval(ping_timer.value)
|
|
setTimeout(connectWebSocket, 3000) // 3秒后重连
|
|
}
|
|
|
|
ws.value.onerror = (error) => {
|
|
console.error('WebSocket错误:', error)
|
|
}
|
|
}
|
|
|
|
// 发送用户信息
|
|
const sendUserInfo = () => {
|
|
if (ws.value?.readyState === WebSocket.OPEN) {
|
|
ws.value.send(JSON.stringify({
|
|
type: 'user',
|
|
action: 'update',
|
|
name: currentUser.value.name
|
|
}))
|
|
}
|
|
}
|
|
|
|
// 处理收到的消息
|
|
const handleMessage = (message) => {
|
|
console.log('收到消息:', message)
|
|
switch (message.type) {
|
|
case 'chat':
|
|
messages.value.push(message)
|
|
if (isCollapsed.value) {
|
|
unreadCount.value++
|
|
}
|
|
scrollToBottom()
|
|
break
|
|
|
|
case 'system':
|
|
switch (message.action) {
|
|
case 'userList':
|
|
users.value = message.users.filter(u => u.id !== currentUser.value.id)
|
|
break
|
|
case 'history':
|
|
messages.value = message.messages
|
|
scrollToBottom()
|
|
break
|
|
case 'join':
|
|
case 'leave':
|
|
case 'userUpdate':
|
|
messages.value.push(message)
|
|
if (message.action !== 'userUpdate' || message.user.id !== currentUser.value.id) {
|
|
users.value = message.users?.filter(u => u.id !== currentUser.value.id) || users.value
|
|
}
|
|
scrollToBottom()
|
|
break
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// 发送消息
|
|
const sendMessage = () => {
|
|
const content = messageInput.value.trim()
|
|
if (!content || !ws.value) return
|
|
|
|
const message = {
|
|
type: 'chat',
|
|
content,
|
|
to: selectedUser.value
|
|
}
|
|
|
|
ws.value.send(JSON.stringify(message))
|
|
messageInput.value = ''
|
|
}
|
|
|
|
// 选择用户
|
|
const selectUser = (user) => {
|
|
selectedUser.value = selectedUser.value?.id === user.id ? null : user
|
|
}
|
|
|
|
// 切换折叠状态
|
|
const toggleCollapse = () => {
|
|
isCollapsed.value = !isCollapsed.value
|
|
if (!isCollapsed.value) {
|
|
unreadCount.value = 0
|
|
updatePanelPosition()
|
|
nextTick(() => {
|
|
scrollToBottom()
|
|
})
|
|
}
|
|
}
|
|
|
|
// 滚动到底部
|
|
const scrollToBottom = async () => {
|
|
await nextTick()
|
|
if (messageList.value) {
|
|
messageList.value.scrollTop = messageList.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
// 格式化时间
|
|
const formatTime = (timestamp) => {
|
|
const date = new Date(timestamp)
|
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
|
}
|
|
|
|
// 格式化系统消息
|
|
const formatSystemMessage = (message) => {
|
|
switch (message.action) {
|
|
case 'join':
|
|
return `${message.user.name} 加入了聊天`
|
|
case 'leave':
|
|
return `${message.user.name} 离开了聊天`
|
|
case 'userUpdate':
|
|
return `${message.user.name} 更新了个人信息`
|
|
default:
|
|
return '系统消息'
|
|
}
|
|
}
|
|
|
|
// 拖拽相关
|
|
const startDrag = (event) => {
|
|
// 如果点击的是按钮内容或最小化按钮,不启动拖拽
|
|
if (event.target.closest('.button-content') || event.target.closest('.minimize-button')) return
|
|
|
|
isDragging.value = true
|
|
const rect = event.target.getBoundingClientRect()
|
|
|
|
dragOffset.value = {
|
|
x: event.clientX - rect.left,
|
|
y: event.clientY - rect.top
|
|
}
|
|
document.addEventListener('mousemove', handleDrag)
|
|
document.addEventListener('mouseup', stopDrag)
|
|
}
|
|
|
|
const handleDrag = (event) => {
|
|
if (!isDragging.value) return
|
|
// 使用 requestAnimationFrame 优化性能
|
|
requestAnimationFrame(() => {
|
|
const x = event.clientX - dragOffset.value.x
|
|
const y = event.clientY - dragOffset.value.y
|
|
// 确保按钮不会超出屏幕边界
|
|
const buttonWidth = 60
|
|
const buttonHeight = 60
|
|
const maxX = window.innerWidth - buttonWidth
|
|
const maxY = window.innerHeight - buttonHeight
|
|
|
|
// 计算新位置
|
|
const newPosition = {}
|
|
if (x < window.innerWidth / 2) {
|
|
newPosition.left = `${Math.max(0, Math.min(maxX, x))}px`
|
|
newPosition.right = 'auto'
|
|
} else {
|
|
newPosition.right = `${Math.max(0, Math.min(maxX, window.innerWidth - x - buttonWidth))}px`
|
|
newPosition.left = 'auto'
|
|
}
|
|
|
|
if (y < window.innerHeight / 2) {
|
|
newPosition.top = `${Math.max(0, Math.min(maxY, y))}px`
|
|
newPosition.bottom = 'auto'
|
|
} else {
|
|
newPosition.bottom = `${Math.max(0, Math.min(maxY, window.innerHeight - y - buttonHeight))}px`
|
|
newPosition.top = 'auto'
|
|
}
|
|
|
|
// 一次性更新位置,避免多次触发重排
|
|
Object.assign(position.value, newPosition)
|
|
|
|
// 如果是面板,同时更新面板位置
|
|
if (!isCollapsed.value) {
|
|
updatePanelPosition()
|
|
}
|
|
})
|
|
}
|
|
|
|
const stopDrag = () => {
|
|
isDragging.value = false
|
|
document.removeEventListener('mousemove', handleDrag)
|
|
document.removeEventListener('mouseup', stopDrag)
|
|
}
|
|
|
|
// 生命周期钩子
|
|
onMounted(() => {
|
|
connectWebSocket()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(ping_timer.value)
|
|
if (ws.value) {
|
|
ws.value.close()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.chat-container {
|
|
position: fixed;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.chat-button {
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 30px;
|
|
background-color: #409eff;
|
|
color: white;
|
|
position: fixed;
|
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
transition: transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
|
|
cursor: move;
|
|
will-change: transform, left, top, right, bottom;
|
|
user-select: none;
|
|
touch-action: none;
|
|
|
|
.button-content {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
border-radius: inherit;
|
|
|
|
&:hover {
|
|
background-color: #66b1ff;
|
|
}
|
|
|
|
i {
|
|
font-size: 24px;
|
|
color: white;
|
|
}
|
|
}
|
|
}
|
|
|
|
.unread-badge {
|
|
position: absolute;
|
|
top: -5px;
|
|
right: -5px;
|
|
background-color: #f56c6c;
|
|
color: white;
|
|
border-radius: 10px;
|
|
padding: 2px 6px;
|
|
font-size: 12px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.chat-panel {
|
|
width: 800px;
|
|
height: 600px;
|
|
background-color: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
position: fixed;
|
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
cursor: move;
|
|
will-change: transform, left, top, right, bottom;
|
|
|
|
.panel-title {
|
|
height: 40px;
|
|
width: 100%;
|
|
background-color: #f5f7fa;
|
|
padding: 0 20px;
|
|
font-size: 16px;
|
|
color: #303133;
|
|
font-weight: bold;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.panel-content {
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 5px;;
|
|
display: grid;
|
|
grid-template-columns: 1fr 3fr;
|
|
gap: 10px;
|
|
.user-list {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: #909399 solid 1px;
|
|
}
|
|
.chat-area {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
.message-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px;
|
|
border: #909399 solid 1px;
|
|
}
|
|
.input-area {
|
|
padding: 5px 0;
|
|
border-top: 1px solid #E4E7ED;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
|
|
.el-textarea {
|
|
flex: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.panel-content2 {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
align-items: center;
|
|
.panel-header {
|
|
height: 50px;
|
|
line-height: 50px;
|
|
padding: 0 20px;
|
|
background-color: #F5F7FA;
|
|
border-bottom: 1px solid #E4E7ED;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.user-list {
|
|
width: 200px;
|
|
border-right: 1px solid #E4E7ED;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.user-list-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px;
|
|
|
|
.user-item {
|
|
padding: 10px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
|
|
&:hover {
|
|
background-color: #F5F7FA;
|
|
}
|
|
|
|
&--active {
|
|
background-color: #ECF5FF;
|
|
color: #409EFF;
|
|
}
|
|
|
|
.user-name {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.chat-area {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.message-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
|
|
.message-item {
|
|
margin-bottom: 20px;
|
|
max-width: 70%;
|
|
|
|
&--self {
|
|
margin-left: auto;
|
|
|
|
.message-content {
|
|
background-color: #409EFF;
|
|
color: white;
|
|
}
|
|
|
|
.message-header {
|
|
text-align: right;
|
|
}
|
|
}
|
|
|
|
&--system {
|
|
text-align: center;
|
|
max-width: 100%;
|
|
|
|
.system-message {
|
|
display: inline-block;
|
|
padding: 5px 10px;
|
|
background-color: #F5F7FA;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
color: #909399;
|
|
}
|
|
}
|
|
|
|
.message-header {
|
|
margin-bottom: 5px;
|
|
font-size: 12px;
|
|
color: #909399;
|
|
|
|
.message-time {
|
|
margin-left: 10px;
|
|
}
|
|
}
|
|
|
|
.message-content {
|
|
padding: 10px 15px;
|
|
background-color: #F5F7FA;
|
|
border-radius: 4px;
|
|
word-break: break-all;
|
|
}
|
|
}
|
|
}
|
|
|
|
.input-area {
|
|
padding: 20px;
|
|
border-top: 1px solid #E4E7ED;
|
|
display: flex;
|
|
gap: 10px;
|
|
|
|
.el-textarea {
|
|
flex: 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
.minimize-button {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 15px;
|
|
background-color: rgba(255, 255, 255, 0.2);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
z-index: 1;
|
|
|
|
&:hover {
|
|
background-color: rgba(255, 255, 255, 0.3);
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
i {
|
|
color: black;
|
|
font-size: 16px;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
}
|
|
</style> |