feat: 新增会话服务端,客户端组件。
This commit is contained in:
parent
11a2b34247
commit
760914d2dd
2
.env
2
.env
@ -1,7 +1,7 @@
|
||||
# 系统配置
|
||||
VITE_APP_TITLE=维机云供应链
|
||||
VITE_APP_API_BASE_URL=http://localhost:3000
|
||||
VITE_APP_AUTO_LOGOUT_TIME=30
|
||||
VITE_APP_AUTO_LOGOUT_TIME=3600
|
||||
VITE_APP_TOKEN_KEY=jifuyun_token
|
||||
VITE_APP_USER_KEY=jifuyun_user
|
||||
|
||||
|
8
chat-server/.env
Normal file
8
chat-server/.env
Normal file
@ -0,0 +1,8 @@
|
||||
# 服务器配置
|
||||
CHAT_SERVER_PORT=3100
|
||||
|
||||
# 允许的前端域名,多个域名用逗号分隔
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# 调试模式
|
||||
DEBUG=false
|
8
chat-server/.env.example
Normal file
8
chat-server/.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# 服务器配置
|
||||
CHAT_SERVER_PORT=3100
|
||||
|
||||
# 允许的前端域名,多个域名用逗号分隔
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# 调试模式
|
||||
DEBUG=false
|
110
chat-server/README.md
Normal file
110
chat-server/README.md
Normal file
@ -0,0 +1,110 @@
|
||||
# WebSocket 聊天服务器
|
||||
|
||||
基于 Node.js、Express 和 WebSocket 实现的实时聊天服务器。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持群聊和私聊
|
||||
- 在线用户列表
|
||||
- 消息历史记录
|
||||
- 心跳检测
|
||||
- 自动断开超时连接
|
||||
- CORS 跨域支持
|
||||
- 健康检查接口
|
||||
|
||||
## 安装依赖
|
||||
|
||||
1. 进入项目目录:
|
||||
```bash
|
||||
cd chat-server
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
1. 复制环境变量示例文件:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. 根据需要修改 `.env` 文件中的配置:
|
||||
- `CHAT_SERVER_PORT`:服务器监听端口(默认3100)
|
||||
- `ALLOWED_ORIGINS`:允许连接的前端域名,多个域名用逗号分隔
|
||||
- `DEBUG`:是否启用调试模式
|
||||
|
||||
## 启动服务器
|
||||
|
||||
### 开发模式
|
||||
|
||||
使用 nodemon 启动服务器,支持代码修改自动重启:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 生产模式
|
||||
|
||||
直接启动服务器:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
|
||||
服务器启动后,可以通过访问 `http://localhost:3100/health` 检查服务器状态。
|
||||
|
||||
## WebSocket 客户端集成
|
||||
|
||||
1. 连接服务器:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:3100')
|
||||
```
|
||||
|
||||
2. 消息格式:
|
||||
```javascript
|
||||
// 发送聊天消息(群发)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'chat',
|
||||
content: '消息内容'
|
||||
}))
|
||||
|
||||
// 发送私聊消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'chat',
|
||||
to: { id: '目标用户ID', name: '目标用户名称' },
|
||||
content: '私聊消息内容'
|
||||
}))
|
||||
|
||||
// 更新用户信息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'user',
|
||||
action: 'update',
|
||||
name: '新的用户名'
|
||||
}))
|
||||
|
||||
// 发送心跳
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ping'
|
||||
}))
|
||||
```
|
||||
|
||||
3. 接收消息:
|
||||
```javascript
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
switch (message.type) {
|
||||
case 'chat':
|
||||
// 处理聊天消息
|
||||
break
|
||||
case 'system':
|
||||
// 处理系统消息(用户加入/离开、用户列表更新等)
|
||||
break
|
||||
case 'pong':
|
||||
// 处理心跳响应
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
164
chat-server/app/chat.js
Normal file
164
chat-server/app/chat.js
Normal file
@ -0,0 +1,164 @@
|
||||
const WebSocket = require('ws')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
class ChatServer {
|
||||
constructor(server) {
|
||||
this.wss = new WebSocket.Server({ server })
|
||||
this.clients = new Map() // 存储所有连接的客户端
|
||||
this.messageHistory = [] // 存储消息历史
|
||||
this.setupWebSocket()
|
||||
this.setupHeartbeat()
|
||||
}
|
||||
|
||||
setupWebSocket() {
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
const clientId = uuidv4()
|
||||
const userInfo = { id: clientId, name: '未命名用户' }
|
||||
|
||||
// 存储客户端连接
|
||||
this.clients.set(clientId, { ws, userInfo, lastPing: Date.now() })
|
||||
|
||||
// 广播新用户加入
|
||||
this.broadcast({
|
||||
type: 'system',
|
||||
action: 'join',
|
||||
user: userInfo,
|
||||
users: Array.from(this.clients.values()).map(client => client.userInfo),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// 发送在线用户列表
|
||||
ws.send(JSON.stringify({
|
||||
type: 'system',
|
||||
action: 'userList',
|
||||
users: Array.from(this.clients.values()).map(client => client.userInfo),
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// 发送历史消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'system',
|
||||
action: 'history',
|
||||
messages: this.messageHistory.slice(-50), // 最近50条消息
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// 处理消息
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
const client = this.clients.get(clientId)
|
||||
if (client) {
|
||||
client.lastPing = Date.now() // 更新最后活动时间
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'chat':
|
||||
// 处理聊天消息
|
||||
const chatMessage = {
|
||||
id: uuidv4(),
|
||||
type: 'chat',
|
||||
from: userInfo,
|
||||
to: message.to, // null表示群发
|
||||
content: message.content,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.messageHistory.push(chatMessage)
|
||||
if (message.to) {
|
||||
// 私聊消息
|
||||
const targetClient = this.clients.get(message.to.id)
|
||||
if (targetClient) {
|
||||
targetClient.ws.send(JSON.stringify(chatMessage))
|
||||
ws.send(JSON.stringify(chatMessage)) // 发送给自己
|
||||
}
|
||||
} else {
|
||||
// 群发消息
|
||||
this.broadcast(chatMessage)
|
||||
}
|
||||
break
|
||||
|
||||
case 'user':
|
||||
// 更新用户信息
|
||||
if (message.action === 'update') {
|
||||
userInfo.name = message.name
|
||||
this.broadcast({
|
||||
type: 'system',
|
||||
action: 'userUpdate',
|
||||
user: userInfo,
|
||||
users: Array.from(this.clients.values()).map(client => client.userInfo),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'ping':
|
||||
// 处理心跳消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理消息时出错:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// 处理连接关闭
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(clientId)
|
||||
this.broadcast({
|
||||
type: 'system',
|
||||
action: 'leave',
|
||||
user: userInfo,
|
||||
users: Array.from(this.clients.values()).map(client => client.userInfo),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
// 处理错误
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket错误:', error)
|
||||
this.clients.delete(clientId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 设置心跳检测
|
||||
setupHeartbeat() {
|
||||
const HEARTBEAT_INTERVAL = 30000 // 30秒检查一次
|
||||
const CLIENT_TIMEOUT = 60000 // 60秒超时
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
this.clients.forEach((client, clientId) => {
|
||||
if (now - client.lastPing > CLIENT_TIMEOUT) {
|
||||
console.log(`客户端 ${clientId} 超时断开`)
|
||||
client.ws.terminate()
|
||||
this.clients.delete(clientId)
|
||||
}
|
||||
})
|
||||
}, HEARTBEAT_INTERVAL)
|
||||
}
|
||||
|
||||
// 广播消息给所有客户端
|
||||
broadcast(message) {
|
||||
const messageStr = JSON.stringify(message)
|
||||
this.clients.forEach(client => {
|
||||
if (client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.send(messageStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清理历史消息
|
||||
cleanHistory() {
|
||||
const oneDay = 24 * 60 * 60 * 1000 // 一天的毫秒数
|
||||
const now = Date.now()
|
||||
this.messageHistory = this.messageHistory.filter(msg => {
|
||||
return (now - msg.timestamp) < oneDay
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatServer
|
38
chat-server/app/index.js
Normal file
38
chat-server/app/index.js
Normal file
@ -0,0 +1,38 @@
|
||||
const express = require('express')
|
||||
const http = require('http')
|
||||
const cors = require('cors')
|
||||
const ChatServer = require('./chat')
|
||||
|
||||
class AppServer {
|
||||
constructor() {
|
||||
this.app = express()
|
||||
|
||||
// 配置CORS
|
||||
this.app.use(cors({
|
||||
origin: process.env.ALLOWED_ORIGINS ?
|
||||
process.env.ALLOWED_ORIGINS.split(',') :
|
||||
'http://localhost:5173', // Vite默认开发端口
|
||||
methods: ['GET', 'POST']
|
||||
}))
|
||||
|
||||
// 创建HTTP服务器
|
||||
this.server = http.createServer(this.app)
|
||||
|
||||
// 初始化WebSocket服务器
|
||||
this.chatServer = new ChatServer(this.server)
|
||||
|
||||
// 添加健康检查接口
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
})
|
||||
}
|
||||
|
||||
start(port) {
|
||||
this.server.listen(port, () => {
|
||||
console.log(`聊天服务器已启动,监听端口 ${port}`)
|
||||
console.log(`健康检查接口: http://localhost:${port}/health`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AppServer
|
1279
chat-server/package-lock.json
generated
Normal file
1279
chat-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
chat-server/package.json
Normal file
20
chat-server/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "chat-server",
|
||||
"version": "1.0.0",
|
||||
"description": "WebSocket聊天服务器",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.13.0",
|
||||
"uuid": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22"
|
||||
}
|
||||
}
|
14
chat-server/server.js
Normal file
14
chat-server/server.js
Normal file
@ -0,0 +1,14 @@
|
||||
const AppServer = require('./app')
|
||||
const dotenv = require('dotenv')
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config()
|
||||
|
||||
// 创建服务器实例
|
||||
const server = new AppServer()
|
||||
|
||||
// 获取端口配置,默认为3100(避免与前端开发服务器冲突)
|
||||
const port = process.env.CHAT_SERVER_PORT || 3100
|
||||
|
||||
// 启动服务器
|
||||
server.start(port)
|
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import AutoLogout from './components/AutoLogout.vue';
|
||||
import AutoLogout from './components/AutoLogout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -13,7 +13,7 @@
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 设置无操作超时时间(毫秒)
|
||||
const TIMEOUT = ref(5 * 60 * 1000); // 从配置文件读取
|
||||
const TIMEOUT = ref(1800*1000); // 从配置文件读取
|
||||
let timer;
|
||||
|
||||
const resetTimer = () => {
|
||||
@ -29,10 +29,10 @@
|
||||
|
||||
onMounted(() => {
|
||||
// 监听用户活动事件
|
||||
let time = envUtils.getAutoLogoutTime()
|
||||
let time = envUtils.getRuntimeConfig('VITE_APP_AUTO_LOGOUT_TIME',envUtils.getAutoLogoutTime())
|
||||
if (time) {
|
||||
TIMEOUT.value = time * 60 * 1000
|
||||
console.log("TIMEOUT:", TIMEOUT.value)
|
||||
TIMEOUT.value = time * 1000
|
||||
// console.log("TIMEOUT:", TIMEOUT.value)
|
||||
}
|
||||
window.addEventListener("mousemove", resetTimer);
|
||||
window.addEventListener("keypress", resetTimer);
|
||||
|
664
src/components/ChatWindow.vue
Normal file
664
src/components/ChatWindow.vue
Normal file
@ -0,0 +1,664 @@
|
||||
<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 connectWebSocket = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/chat`
|
||||
ws.value = new WebSocket(wsUrl)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
// 发送用户信息
|
||||
sendUserInfo()
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
handleMessage(message)
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
console.log('WebSocket连接已关闭')
|
||||
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) => {
|
||||
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(() => {
|
||||
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>
|
@ -114,11 +114,11 @@
|
||||
},
|
||||
logo: {
|
||||
type: String,
|
||||
default: envUtils.getLogoPath()
|
||||
default: envUtils.getRuntimeConfig('VITE_APP_LOGO_PATH', envUtils.getLogoPath())
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: envUtils.getAppTitle()
|
||||
default: envUtils.getRuntimeConfig('VITE_APP_TITLE', envUtils.getAppTitle())
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
<chat-window></chat-window>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -26,14 +27,16 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store'
|
||||
import { removeToken } from '@/utils'
|
||||
import SidePanel from '../components/SidePanel.vue'
|
||||
import SidePanel from '@/components/SidePanel.vue'
|
||||
import HeaderBar from '@/components/HeaderBar.vue'
|
||||
import ChatWindow from '@/components/ChatWindow.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const userInfo = userStore.userInfo
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
// 菜单配置
|
||||
const menuItems = [
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user