更新websocket功能
This commit is contained in:
@@ -160,6 +160,11 @@ protected function handleMessage(Server $server, int $fd, array $message): void
|
|||||||
$data = $message['data'] ?? [];
|
$data = $message['data'] ?? [];
|
||||||
|
|
||||||
switch ($type) {
|
switch ($type) {
|
||||||
|
case 'auth':
|
||||||
|
// Handle authentication confirmation
|
||||||
|
$this->handleAuth($server, $fd, $data);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'ping':
|
case 'ping':
|
||||||
// Respond to ping with pong
|
// Respond to ping with pong
|
||||||
$server->push($fd, json_encode([
|
$server->push($fd, json_encode([
|
||||||
@@ -212,6 +217,55 @@ protected function handleMessage(Server $server, int $fd, array $message): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle authentication confirmation
|
||||||
|
*
|
||||||
|
* @param Server $server
|
||||||
|
* @param int $fd
|
||||||
|
* @param array $data
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function handleAuth(Server $server, int $fd, array $data): void
|
||||||
|
{
|
||||||
|
$userId = $data['user_id'] ?? null;
|
||||||
|
$token = $data['token'] ?? null;
|
||||||
|
|
||||||
|
// Get the user ID from wsTable (set during connection)
|
||||||
|
$storedUserId = $server->wsTable->get('fd:' . $fd)['value'] ?? null;
|
||||||
|
|
||||||
|
if ($storedUserId && $storedUserId == $userId) {
|
||||||
|
// Authentication confirmed, send success response
|
||||||
|
$server->push($fd, json_encode([
|
||||||
|
'type' => 'connected',
|
||||||
|
'data' => [
|
||||||
|
'user_id' => $storedUserId,
|
||||||
|
'message' => 'Authentication confirmed',
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
Log::info('WebSocket authentication confirmed', [
|
||||||
|
'fd' => $fd,
|
||||||
|
'user_id' => $userId
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Authentication failed
|
||||||
|
$server->push($fd, json_encode([
|
||||||
|
'type' => 'error',
|
||||||
|
'data' => [
|
||||||
|
'message' => 'Authentication failed. User ID mismatch.',
|
||||||
|
'code' => 401
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
Log::warning('WebSocket authentication failed', [
|
||||||
|
'fd' => $fd,
|
||||||
|
'stored_user_id' => $storedUserId,
|
||||||
|
'provided_user_id' => $userId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle chat message
|
* Handle chat message
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
# WebSocket 功能文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本系统使用 WebSocket 实现实时通信功能,支持消息推送、数据更新通知等实时功能。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端**: 原生 WebSocket API + Vue 3 Composable
|
||||||
|
- **后端**: Laravel-S (Swoole) WebSocket Server
|
||||||
|
- **协议**: WS/WSS
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 自动连接管理
|
||||||
|
|
||||||
|
- 登录后自动建立 WebSocket 连接
|
||||||
|
- 用户信息加载完成后自动重试连接
|
||||||
|
- 支持断线自动重连(最多 5 次)
|
||||||
|
- 退出登录时自动关闭连接
|
||||||
|
|
||||||
|
### 2. 消息推送
|
||||||
|
|
||||||
|
- 系统通知推送
|
||||||
|
- 数据更新通知
|
||||||
|
- 字典数据更新通知
|
||||||
|
- 任务提醒
|
||||||
|
|
||||||
|
### 3. 心跳机制
|
||||||
|
|
||||||
|
- 客户端每 30 秒发送心跳
|
||||||
|
- 保持连接活跃状态
|
||||||
|
- 检测连接状态
|
||||||
|
|
||||||
|
### 4. 消息管理
|
||||||
|
|
||||||
|
- 消息持久化存储(localStorage)
|
||||||
|
- 未读消息计数
|
||||||
|
- 消息分类筛选
|
||||||
|
- 消息分页显示
|
||||||
|
- 标记已读/删除消息
|
||||||
|
|
||||||
|
## 前端使用
|
||||||
|
|
||||||
|
### 1. 在组件中使用 WebSocket
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
|
|
||||||
|
const { initWebSocket, closeWebSocket, isConnected, send } = useWebSocket()
|
||||||
|
|
||||||
|
// 初始化连接
|
||||||
|
onMounted(() => {
|
||||||
|
initWebSocket()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查连接状态
|
||||||
|
if (isConnected()) {
|
||||||
|
console.log('WebSocket 已连接')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
send('message_type', { data: 'your data' })
|
||||||
|
|
||||||
|
// 关闭连接
|
||||||
|
onUnmounted(() => {
|
||||||
|
closeWebSocket()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 在 App.vue 中自动初始化
|
||||||
|
|
||||||
|
WebSocket 已在 App.vue 中自动集成,无需手动调用:
|
||||||
|
|
||||||
|
- 监听用户信息变化,自动初始化连接
|
||||||
|
- 组件卸载时自动关闭连接
|
||||||
|
- 消息数据自动恢复
|
||||||
|
|
||||||
|
### 3. 使用消息 Store
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
|
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
|
// 添加消息
|
||||||
|
messageStore.addMessage({
|
||||||
|
type: 'notification',
|
||||||
|
title: '系统通知',
|
||||||
|
content: '这是一条通知'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标记已读
|
||||||
|
messageStore.markAsRead(messageId)
|
||||||
|
|
||||||
|
// 标记所有已读
|
||||||
|
messageStore.markAllAsRead()
|
||||||
|
|
||||||
|
// 删除消息
|
||||||
|
messageStore.removeMessage(messageId)
|
||||||
|
|
||||||
|
// 清空所有消息
|
||||||
|
messageStore.clearAll()
|
||||||
|
|
||||||
|
// 获取消息列表(分页)
|
||||||
|
const { list, total, page } = messageStore.getMessages({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
type: 'notification' // 可选:按类型过滤
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化消息时间
|
||||||
|
const timeStr = messageStore.formatMessageTime(timestamp)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 消息类型
|
||||||
|
|
||||||
|
### 支持的消息类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 枚举值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 系统通知 | 系统级别的通知消息 | `notification` |
|
||||||
|
| 任务提醒 | 任务相关提醒 | `task` |
|
||||||
|
| 警告消息 | 警告类消息 | `warning` |
|
||||||
|
| 错误消息 | 错误类消息 | `error` |
|
||||||
|
| 成功消息 | 成功类消息 | `success` |
|
||||||
|
| 信息消息 | 一般信息 | `info` |
|
||||||
|
|
||||||
|
### 消息优先级
|
||||||
|
|
||||||
|
| 优先级 | 说明 | 枚举值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 低 | 低优先级消息 | `low` |
|
||||||
|
| 中 | 中等优先级消息 | `medium` |
|
||||||
|
| 高 | 高优先级消息 | `high` |
|
||||||
|
| 紧急 | 紧急消息 | `urgent` |
|
||||||
|
|
||||||
|
## WebSocket 消息格式
|
||||||
|
|
||||||
|
### 客户端发送格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message_type",
|
||||||
|
"data": {
|
||||||
|
"key": "value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务端推送格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "notification",
|
||||||
|
"data": {
|
||||||
|
"title": "消息标题",
|
||||||
|
"content": "消息内容",
|
||||||
|
"type": "success",
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据更新消息格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "data_update",
|
||||||
|
"data": {
|
||||||
|
"resource_type": "dictionary",
|
||||||
|
"action": "update",
|
||||||
|
"data": {},
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后端使用
|
||||||
|
|
||||||
|
### 1. 发送消息给特定用户
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Services\WebSocket\WebSocketService;
|
||||||
|
|
||||||
|
$wsService = app(WebSocketService::class);
|
||||||
|
|
||||||
|
// 发送给单个用户
|
||||||
|
$wsService->sendToUser($userId, [
|
||||||
|
'type' => 'notification',
|
||||||
|
'data' => [
|
||||||
|
'title' => '系统通知',
|
||||||
|
'content' => '这是一条通知',
|
||||||
|
'type' => 'info',
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 发送给多个用户
|
||||||
|
$userIds = [1, 2, 3];
|
||||||
|
$sentTo = $wsService->sendToUsers($userIds, $data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 广播消息
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 广播给所有在线用户
|
||||||
|
$count = $wsService->broadcast([
|
||||||
|
'type' => 'notification',
|
||||||
|
'data' => [
|
||||||
|
'title' => '系统维护通知',
|
||||||
|
'content' => '系统将在 10 分钟后进行维护',
|
||||||
|
'type' => 'warning',
|
||||||
|
'timestamp' => time()
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 广播给所有在线用户(排除指定用户)
|
||||||
|
$count = $wsService->broadcast($data, $excludeUserId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送系统通知
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 发送系统通知
|
||||||
|
$count = $wsService->sendSystemNotification(
|
||||||
|
'新版本发布',
|
||||||
|
'系统已更新到 v2.0',
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发送通知给特定用户
|
||||||
|
$count = $wsService->sendNotificationToUsers(
|
||||||
|
[1, 2, 3],
|
||||||
|
'任务分配',
|
||||||
|
'您有新的待处理任务',
|
||||||
|
'task'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 发送数据更新通知
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 发送数据更新给特定用户
|
||||||
|
$userIds = [1, 2, 3];
|
||||||
|
$sentTo = $wsService->pushDataUpdate(
|
||||||
|
$userIds,
|
||||||
|
'dictionary',
|
||||||
|
'update',
|
||||||
|
['id' => 1, 'name' => 'test']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发送数据更新到频道
|
||||||
|
$count = $wsService->pushDataUpdateToChannel(
|
||||||
|
'system_admin',
|
||||||
|
'user',
|
||||||
|
'create',
|
||||||
|
['id' => 10, 'username' => 'newuser']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 检查用户在线状态
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 检查用户是否在线
|
||||||
|
$isOnline = $wsService->isUserOnline($userId);
|
||||||
|
|
||||||
|
// 获取在线用户数量
|
||||||
|
$count = $wsService->getOnlineUserCount();
|
||||||
|
|
||||||
|
// 获取所有在线用户 ID
|
||||||
|
$userIds = $wsService->getOnlineUserIds();
|
||||||
|
|
||||||
|
// 强制断开用户连接
|
||||||
|
$wsService->disconnectUser($userId);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 顶部消息组件
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
顶部消息组件位于 `layouts/components/userbar.vue`,提供以下功能:
|
||||||
|
|
||||||
|
1. **消息通知铃铛**
|
||||||
|
- 显示未读消息数量
|
||||||
|
- 点击打开消息列表
|
||||||
|
|
||||||
|
2. **消息列表**
|
||||||
|
- 按类型筛选消息(全部/通知/任务/警告)
|
||||||
|
- 显示消息标题、内容、时间
|
||||||
|
- 点击消息标记为已读
|
||||||
|
- 悬浮显示删除按钮
|
||||||
|
- 分页浏览
|
||||||
|
|
||||||
|
3. **操作按钮**
|
||||||
|
- 全部标为已读
|
||||||
|
- 清空全部消息
|
||||||
|
|
||||||
|
### 消息样式
|
||||||
|
|
||||||
|
- **未读消息**: 蓝色背景 + 左侧蓝条 + 红点标记
|
||||||
|
- **已读消息**: 普通样式
|
||||||
|
- **删除按钮**: 悬浮时显示
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### WebSocket URL 配置
|
||||||
|
|
||||||
|
在 `resources/admin/src/config/index.js` 中配置:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export default {
|
||||||
|
// WebSocket URL(如果不配置则使用当前域名)
|
||||||
|
WS_URL: 'ws://localhost:8080',
|
||||||
|
|
||||||
|
// 其他配置...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端 WebSocket 配置
|
||||||
|
|
||||||
|
在 `config/laravels.php` 中配置:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'swoole' => [
|
||||||
|
'enable_coroutine' => true,
|
||||||
|
'worker_num' => 4,
|
||||||
|
'max_request' => 5000,
|
||||||
|
'max_request_grace' => 500,
|
||||||
|
// ... 其他配置
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 1. WebSocket 连接失败
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- WebSocket 服务未启动
|
||||||
|
- 端口被占用
|
||||||
|
- 防火墙阻止连接
|
||||||
|
- URL 配置错误
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
```bash
|
||||||
|
# 检查 Laravel-S 是否运行
|
||||||
|
php bin/laravels status
|
||||||
|
|
||||||
|
# 启动 Laravel-S
|
||||||
|
php bin/laravels start
|
||||||
|
|
||||||
|
# 检查端口是否被占用
|
||||||
|
netstat -ano | findstr :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 登录后 WebSocket 未连接
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 用户信息未加载完成
|
||||||
|
- token 无效
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 检查控制台日志
|
||||||
|
- 确认 `userStore.isUserInfoComplete()` 返回 true
|
||||||
|
- 查看 `getWebSocket` 调用参数
|
||||||
|
|
||||||
|
### 3. 消息未收到
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
- 消息处理器未注册
|
||||||
|
- 消息类型不匹配
|
||||||
|
- 网络问题
|
||||||
|
|
||||||
|
**解决方法**:
|
||||||
|
- 检查 `useWebSocket.js` 中的消息处理器注册
|
||||||
|
- 确认消息类型格式正确
|
||||||
|
- 查看网络面板 WebSocket 帧
|
||||||
|
|
||||||
|
## 开发建议
|
||||||
|
|
||||||
|
### 1. 测试 WebSocket 功能
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在浏览器控制台测试
|
||||||
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
|
|
||||||
|
const { send } = useWebSocket()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
|
// 手动添加测试消息
|
||||||
|
messageStore.addMessage({
|
||||||
|
type: 'notification',
|
||||||
|
title: '测试消息',
|
||||||
|
content: '这是一条测试消息',
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送测试消息到服务器
|
||||||
|
send('test', { message: 'hello' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 添加新的消息类型
|
||||||
|
|
||||||
|
1. 在 `message.js` store 中添加类型枚举
|
||||||
|
2. 在 `userbar.vue` 中添加对应的 Tab
|
||||||
|
3. 在 `i18n` 中添加翻译
|
||||||
|
4. 在 `useWebSocket.js` 中添加处理逻辑
|
||||||
|
|
||||||
|
### 3. 自定义消息处理
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在 useWebSocket.js 中注册自定义处理器
|
||||||
|
ws.value.on('custom_event', handleCustomEvent)
|
||||||
|
|
||||||
|
function handleCustomEvent(data) {
|
||||||
|
console.log('收到自定义消息:', data)
|
||||||
|
// 处理逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
1. **消息限制**: 最多存储 100 条消息,超出后自动删除旧消息
|
||||||
|
2. **分页加载**: 消息列表使用分页,避免一次性加载过多数据
|
||||||
|
3. **心跳机制**: 保持连接活跃,减少不必要的重连
|
||||||
|
4. **延迟加载**: 用户信息加载完成后才初始化连接
|
||||||
|
|
||||||
|
## 安全考虑
|
||||||
|
|
||||||
|
1. **Token 验证**: WebSocket 连接时发送 token 进行验证
|
||||||
|
2. **用户隔离**: 每个用户只能接收自己的消息
|
||||||
|
3. **消息过滤**: 根据权限过滤敏感消息
|
||||||
|
4. **连接限制**: 限制单个用户的连接数量
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.0.0 (2024-01-18)
|
||||||
|
- 初始版本
|
||||||
|
- 实现基础 WebSocket 连接功能
|
||||||
|
- 实现消息推送和接收
|
||||||
|
- 实现消息管理 Store
|
||||||
|
- 实现顶部消息组件
|
||||||
|
- 支持中英文国际化
|
||||||
@@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { useI18nStore } from './stores/modules/i18n'
|
import { useI18nStore } from './stores/modules/i18n'
|
||||||
import { useLayoutStore } from './stores/modules/layout'
|
import { useLayoutStore } from './stores/modules/layout'
|
||||||
import { useUserStore } from './stores/modules/user'
|
import { useUserStore } from './stores/modules/user'
|
||||||
|
import { useMessageStore } from './stores/modules/message'
|
||||||
import { useWebSocket } from './composables/useWebSocket'
|
import { useWebSocket } from './composables/useWebSocket'
|
||||||
import { theme } from 'ant-design-vue'
|
import { theme } from 'ant-design-vue'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
@@ -27,6 +28,9 @@ const layoutStore = useLayoutStore()
|
|||||||
// user store
|
// user store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// message store
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
const { initWebSocket, closeWebSocket } = useWebSocket()
|
const { initWebSocket, closeWebSocket } = useWebSocket()
|
||||||
|
|
||||||
@@ -77,9 +81,26 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听用户信息变化,当用户信息完整时初始化 WebSocket
|
||||||
|
watch(
|
||||||
|
() => [userStore.token, userStore.userInfo],
|
||||||
|
() => {
|
||||||
|
if (userStore.isUserInfoComplete()) {
|
||||||
|
initWebSocket()
|
||||||
|
} else if (!userStore.isLoggedIn()) {
|
||||||
|
// 用户未登录,关闭 WebSocket
|
||||||
|
closeWebSocket()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
|
// 恢复消息数据
|
||||||
|
messageStore.restoreMessages()
|
||||||
|
|
||||||
// 从持久化的 store 中读取语言设置并同步到 i18n
|
// 从持久化的 store 中读取语言设置并同步到 i18n
|
||||||
i18n.global.locale.value = i18nStore.currentLocale
|
i18n.global.locale.value = i18nStore.currentLocale
|
||||||
|
|
||||||
@@ -91,8 +112,8 @@ onMounted(async () => {
|
|||||||
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 WebSocket 连接
|
// 尝试初始化 WebSocket 连接
|
||||||
if (userStore.isLoggedIn()) {
|
if (userStore.isUserInfoComplete()) {
|
||||||
initWebSocket()
|
initWebSocket()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getWebSocket } from '@/utils/websocket'
|
import { getWebSocket } from '@/utils/websocket'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
import { useDictionaryStore } from '@/stores/modules/dictionary'
|
import { useDictionaryStore } from '@/stores/modules/dictionary'
|
||||||
import { message } from 'ant-design-vue'
|
import { message, notification } from 'ant-design-vue'
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,23 +14,40 @@ import config from '@/config'
|
|||||||
export function useWebSocket() {
|
export function useWebSocket() {
|
||||||
const ws = ref(null)
|
const ws = ref(null)
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const dictionaryStore = useDictionaryStore()
|
const dictionaryStore = useDictionaryStore()
|
||||||
|
const reconnectTimer = ref(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 WebSocket 连接
|
* 初始化 WebSocket 连接
|
||||||
*/
|
*/
|
||||||
function initWebSocket() {
|
function initWebSocket() {
|
||||||
if (!userStore.token) {
|
// 检查用户信息是否完整
|
||||||
console.warn('未登录,无法初始化 WebSocket')
|
if (!userStore.isUserInfoComplete()) {
|
||||||
|
console.warn('用户信息不完整,等待用户信息加载...')
|
||||||
|
|
||||||
|
// 延迟重试,等待用户信息加载
|
||||||
|
if (reconnectTimer.value) {
|
||||||
|
clearTimeout(reconnectTimer.value)
|
||||||
|
}
|
||||||
|
reconnectTimer.value = setTimeout(() => {
|
||||||
|
initWebSocket()
|
||||||
|
}, 1000)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userStore.userInfo || !userStore.userInfo.id) {
|
// 如果已经连接,不再重复连接
|
||||||
console.warn('用户信息不完整,无法初始化 WebSocket')
|
if (ws.value && ws.value.isConnected) {
|
||||||
|
console.log('WebSocket 已连接')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('开始初始化 WebSocket...', {
|
||||||
|
userId: userStore.userInfo.id,
|
||||||
|
username: userStore.userInfo.username
|
||||||
|
})
|
||||||
|
|
||||||
// 使用配置文件中的 WS_URL
|
// 使用配置文件中的 WS_URL
|
||||||
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
|
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
|
||||||
wsUrl: config.WS_URL,
|
wsUrl: config.WS_URL,
|
||||||
@@ -40,8 +58,7 @@ export function useWebSocket() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 注册消息处理器
|
// 注册消息处理器
|
||||||
ws.value.on('dictionary_update', handleDictionaryUpdate)
|
registerMessageHandlers()
|
||||||
ws.value.on('dictionary_item_update', handleDictionaryItemUpdate)
|
|
||||||
|
|
||||||
// 连接
|
// 连接
|
||||||
ws.value.connect()
|
ws.value.connect()
|
||||||
@@ -50,11 +67,64 @@ export function useWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册所有消息处理器
|
||||||
|
*/
|
||||||
|
function registerMessageHandlers() {
|
||||||
|
if (!ws.value) return
|
||||||
|
|
||||||
|
// 字典更新
|
||||||
|
ws.value.on('dictionary_update', handleDictionaryUpdate)
|
||||||
|
ws.value.on('dictionary_item_update', handleDictionaryItemUpdate)
|
||||||
|
|
||||||
|
// 系统通知
|
||||||
|
ws.value.on('notification', handleNotification)
|
||||||
|
|
||||||
|
// 数据更新
|
||||||
|
ws.value.on('data_update', handleDataUpdate)
|
||||||
|
|
||||||
|
// 心跳响应
|
||||||
|
ws.value.on('heartbeat_response', handleHeartbeatResponse)
|
||||||
|
|
||||||
|
// 连接确认
|
||||||
|
ws.value.on('connected', handleConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消注册所有消息处理器
|
||||||
|
*/
|
||||||
|
function unregisterMessageHandlers() {
|
||||||
|
if (!ws.value) return
|
||||||
|
|
||||||
|
ws.value.off('dictionary_update')
|
||||||
|
ws.value.off('dictionary_item_update')
|
||||||
|
ws.value.off('notification')
|
||||||
|
ws.value.off('data_update')
|
||||||
|
ws.value.off('heartbeat_response')
|
||||||
|
ws.value.off('connected')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理连接打开
|
* 处理连接打开
|
||||||
*/
|
*/
|
||||||
function handleOpen(event) {
|
function handleOpen(event) {
|
||||||
console.log('WebSocket 连接已建立', event)
|
console.log('WebSocket 连接已建立', event)
|
||||||
|
|
||||||
|
// 发送连接确认
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.send('auth', {
|
||||||
|
token: userStore.token,
|
||||||
|
user_id: userStore.userInfo.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理连接确认
|
||||||
|
*/
|
||||||
|
function handleConnected(data) {
|
||||||
|
console.log('WebSocket 连接已确认', data)
|
||||||
|
message.success('实时连接已建立')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +139,7 @@ export function useWebSocket() {
|
|||||||
*/
|
*/
|
||||||
function handleError(error) {
|
function handleError(error) {
|
||||||
console.error('WebSocket 错误:', error)
|
console.error('WebSocket 错误:', error)
|
||||||
|
message.error('实时连接出现错误,正在重连...')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +147,84 @@ export function useWebSocket() {
|
|||||||
*/
|
*/
|
||||||
function handleClose(event) {
|
function handleClose(event) {
|
||||||
console.log('WebSocket 连接已关闭', event)
|
console.log('WebSocket 连接已关闭', event)
|
||||||
|
|
||||||
|
// 如果不是手动关闭,显示提示
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
message.warning('实时连接已断开')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理心跳响应
|
||||||
|
*/
|
||||||
|
function handleHeartbeatResponse(data) {
|
||||||
|
console.log('收到心跳响应:', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理系统通知
|
||||||
|
*/
|
||||||
|
function handleNotification(data) {
|
||||||
|
console.log('收到系统通知:', data)
|
||||||
|
|
||||||
|
const { title, message: content, type, timestamp } = data
|
||||||
|
|
||||||
|
// 添加到消息 store
|
||||||
|
messageStore.addMessage({
|
||||||
|
type: type || 'notification',
|
||||||
|
title: title || '系统通知',
|
||||||
|
content: content || '',
|
||||||
|
timestamp: timestamp || Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示通知(根据类型)
|
||||||
|
const notificationConfig = {
|
||||||
|
message: title || '系统通知',
|
||||||
|
description: content,
|
||||||
|
duration: 4.5,
|
||||||
|
placement: 'topRight'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据类型设置不同样式
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
notification.success(notificationConfig)
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
notification.error(notificationConfig)
|
||||||
|
break
|
||||||
|
case 'warning':
|
||||||
|
notification.warning(notificationConfig)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
notification.info(notificationConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理数据更新
|
||||||
|
*/
|
||||||
|
async function handleDataUpdate(data) {
|
||||||
|
console.log('收到数据更新:', data)
|
||||||
|
|
||||||
|
const { resource_type, action, timestamp } = data
|
||||||
|
|
||||||
|
// 添加到消息 store
|
||||||
|
messageStore.handleDataUpdate(data)
|
||||||
|
|
||||||
|
// 根据资源类型执行相应操作
|
||||||
|
switch (resource_type) {
|
||||||
|
case 'dictionary':
|
||||||
|
case 'dictionary_item':
|
||||||
|
// 刷新字典缓存
|
||||||
|
try {
|
||||||
|
await dictionaryStore.refresh(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新字典缓存失败:', error)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
// 可以添加其他资源类型的处理
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,12 +233,6 @@ export function useWebSocket() {
|
|||||||
async function handleDictionaryUpdate(data) {
|
async function handleDictionaryUpdate(data) {
|
||||||
console.log('字典分类已更新:', data)
|
console.log('字典分类已更新:', data)
|
||||||
|
|
||||||
const { action, resource_type, timestamp } = data
|
|
||||||
|
|
||||||
if (resource_type !== 'dictionary') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 刷新字典缓存
|
// 刷新字典缓存
|
||||||
await dictionaryStore.refresh(true)
|
await dictionaryStore.refresh(true)
|
||||||
@@ -107,12 +250,6 @@ export function useWebSocket() {
|
|||||||
async function handleDictionaryItemUpdate(data) {
|
async function handleDictionaryItemUpdate(data) {
|
||||||
console.log('字典项已更新:', data)
|
console.log('字典项已更新:', data)
|
||||||
|
|
||||||
const { action, resource_type, timestamp } = data
|
|
||||||
|
|
||||||
if (resource_type !== 'dictionary_item') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 刷新字典缓存
|
// 刷新字典缓存
|
||||||
await dictionaryStore.refresh(true)
|
await dictionaryStore.refresh(true)
|
||||||
@@ -124,23 +261,59 @@ export function useWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息到服务器
|
||||||
|
*/
|
||||||
|
function send(type, data) {
|
||||||
|
if (ws.value && ws.value.isConnected) {
|
||||||
|
ws.value.send(type, data)
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket 未连接,无法发送消息')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭 WebSocket 连接
|
* 关闭 WebSocket 连接
|
||||||
*/
|
*/
|
||||||
function closeWebSocket() {
|
function closeWebSocket() {
|
||||||
|
// 清除重试定时器
|
||||||
|
if (reconnectTimer.value) {
|
||||||
|
clearTimeout(reconnectTimer.value)
|
||||||
|
reconnectTimer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
if (ws.value) {
|
if (ws.value) {
|
||||||
// 取消注册消息处理器
|
// 取消注册消息处理器
|
||||||
ws.value.off('dictionary_update')
|
unregisterMessageHandlers()
|
||||||
ws.value.off('dictionary_item_update')
|
|
||||||
|
|
||||||
ws.value.disconnect()
|
ws.value.disconnect()
|
||||||
ws.value = null
|
ws.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新连接 WebSocket
|
||||||
|
*/
|
||||||
|
function reconnect() {
|
||||||
|
closeWebSocket()
|
||||||
|
setTimeout(() => {
|
||||||
|
initWebSocket()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查连接状态
|
||||||
|
*/
|
||||||
|
function isConnected() {
|
||||||
|
return ws.value && ws.value.isConnected
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ws,
|
ws,
|
||||||
initWebSocket,
|
initWebSocket,
|
||||||
closeWebSocket
|
closeWebSocket,
|
||||||
|
reconnect,
|
||||||
|
isConnected,
|
||||||
|
send
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,22 @@ export default {
|
|||||||
clearCacheFailed: 'Failed to clear cache',
|
clearCacheFailed: 'Failed to clear cache',
|
||||||
messages: 'Messages',
|
messages: 'Messages',
|
||||||
tasks: 'Tasks',
|
tasks: 'Tasks',
|
||||||
|
notification: 'Notification',
|
||||||
|
task: 'Task',
|
||||||
|
warning: 'Warning',
|
||||||
|
markAllAsRead: 'Mark All as Read',
|
||||||
clearAll: 'Clear All',
|
clearAll: 'Clear All',
|
||||||
noMessages: 'No Messages',
|
noMessages: 'No Messages',
|
||||||
noTasks: 'No Tasks',
|
noTasks: 'No Tasks',
|
||||||
|
confirmClear: 'Confirm Clear',
|
||||||
|
confirmClearMessages: 'Are you sure you want to clear all messages?',
|
||||||
|
markedAsRead: 'Marked as Read',
|
||||||
|
realtimeConnected: 'Real-time connection established',
|
||||||
|
realtimeDisconnected: 'Real-time connection disconnected',
|
||||||
|
realtimeError: 'Real-time connection error, reconnecting...',
|
||||||
|
dataUpdated: 'Data Updated',
|
||||||
|
dataCreated: 'Data Created',
|
||||||
|
dataDeleted: 'Data Deleted',
|
||||||
fullscreen: 'Fullscreen',
|
fullscreen: 'Fullscreen',
|
||||||
personalCenter: 'Personal Center',
|
personalCenter: 'Personal Center',
|
||||||
systemSettings: 'System Settings',
|
systemSettings: 'System Settings',
|
||||||
|
|||||||
@@ -38,9 +38,22 @@ export default {
|
|||||||
clearCacheFailed: '清除缓存失败',
|
clearCacheFailed: '清除缓存失败',
|
||||||
messages: '消息',
|
messages: '消息',
|
||||||
tasks: '任务',
|
tasks: '任务',
|
||||||
|
notification: '通知',
|
||||||
|
task: '任务',
|
||||||
|
warning: '警告',
|
||||||
|
markAllAsRead: '全部标为已读',
|
||||||
clearAll: '清空全部',
|
clearAll: '清空全部',
|
||||||
noMessages: '暂无消息',
|
noMessages: '暂无消息',
|
||||||
noTasks: '暂无任务',
|
noTasks: '暂无任务',
|
||||||
|
confirmClear: '确认清空',
|
||||||
|
confirmClearMessages: '确定要清空所有消息吗?',
|
||||||
|
markedAsRead: '已标记为已读',
|
||||||
|
realtimeConnected: '实时连接已建立',
|
||||||
|
realtimeDisconnected: '实时连接已断开',
|
||||||
|
realtimeError: '实时连接出现错误,正在重连...',
|
||||||
|
dataUpdated: '数据已更新',
|
||||||
|
dataCreated: '数据已创建',
|
||||||
|
dataDeleted: '数据已删除',
|
||||||
fullscreen: '全屏',
|
fullscreen: '全屏',
|
||||||
personalCenter: '个人中心',
|
personalCenter: '个人中心',
|
||||||
systemSettings: '系统设置',
|
systemSettings: '系统设置',
|
||||||
|
|||||||
@@ -8,27 +8,76 @@
|
|||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
|
||||||
<!-- 消息通知 -->
|
<!-- 消息通知 -->
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
<a-dropdown v-model:open="messageVisible" :trigger="['click']" placement="bottomRight">
|
||||||
<a-badge :count="messageCount" :offset="[-5, 5]">
|
<a-badge :count="messageCount" :offset="[-5, 5]">
|
||||||
<a-button type="text" class="action-btn">
|
<a-button type="text" class="action-btn">
|
||||||
<BellOutlined />
|
<BellOutlined />
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-badge>
|
</a-badge>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
<a-card class="dropdown-card" :title="$t('common.messages')" :bordered="false">
|
<a-card class="dropdown-card" :bordered="false">
|
||||||
<template #extra>
|
<template #title>
|
||||||
<a @click="clearMessages">{{ $t('common.clearAll') }}</a>
|
<div class="message-header">
|
||||||
|
<span>{{ $t('common.messages') }}</span>
|
||||||
|
<a-space size="small">
|
||||||
|
<a-button v-if="messageCount > 0" type="link" size="small" @click="markAllAsRead">
|
||||||
|
{{ $t('common.markAllAsRead') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button type="link" size="small" @click="clearMessages">
|
||||||
|
{{ $t('common.clearAll') }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 消息类型筛选 -->
|
||||||
|
<div class="message-tabs">
|
||||||
|
<a-tabs v-model:activeKey="currentMessageType" size="small" @change="changeMessageType">
|
||||||
|
<a-tab-pane key="all" :tab="$t('common.all')" />
|
||||||
|
<a-tab-pane key="notification" :tab="$t('common.notification')" />
|
||||||
|
<a-tab-pane key="task" :tab="$t('common.task')" />
|
||||||
|
<a-tab-pane key="warning" :tab="$t('common.warning')" />
|
||||||
|
</a-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="message-list">
|
<div class="message-list">
|
||||||
<div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ unread: !msg.read }">
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="message-item"
|
||||||
|
:class="{ unread: !msg.read }"
|
||||||
|
@click="handleMessageRead(msg)"
|
||||||
|
>
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<div class="message-title">{{ msg.title }}</div>
|
<div class="message-title">{{ msg.title }}</div>
|
||||||
<div class="message-time">{{ msg.time }}</div>
|
<div class="message-content-text">{{ msg.content }}</div>
|
||||||
|
<div class="message-time">{{ messageStore.formatMessageTime(msg.timestamp) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<a-badge v-if="!msg.read" dot />
|
<a-badge v-if="!msg.read" dot />
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
class="delete-btn"
|
||||||
|
@click.stop="handleDeleteMessage(msg.id)"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
|
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div v-if="messagesTotal > messagesPageSize" class="message-pagination">
|
||||||
|
<a-pagination
|
||||||
|
v-model:current="messagesPage"
|
||||||
|
v-model:pageSize="messagesPageSize"
|
||||||
|
:total="messagesTotal"
|
||||||
|
size="small"
|
||||||
|
:show-size-changer="false"
|
||||||
|
@change="handleMessagePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
@@ -111,6 +160,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
import { useI18nStore } from '@/stores/modules/i18n'
|
import { useI18nStore } from '@/stores/modules/i18n'
|
||||||
|
import { useMessageStore, MessageType } from '@/stores/modules/message'
|
||||||
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import search from './search.vue'
|
import search from './search.vue'
|
||||||
@@ -125,20 +175,37 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const i18nStore = useI18nStore()
|
const i18nStore = useI18nStore()
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const searchVisible = ref(false)
|
const searchVisible = ref(false)
|
||||||
const taskVisible = ref(false)
|
const taskVisible = ref(false)
|
||||||
|
const messageVisible = ref(false)
|
||||||
|
const currentMessageType = ref('all')
|
||||||
|
const messagesPage = ref(1)
|
||||||
|
const messagesPageSize = ref(10)
|
||||||
|
|
||||||
// 消息数据
|
// 从 store 获取消息数据
|
||||||
const messages = ref([
|
const messages = computed(() => {
|
||||||
{ id: 1, title: '系统通知:新版本已发布', time: '10分钟前', read: false },
|
const result = messageStore.getMessages({
|
||||||
{ id: 2, title: '任务提醒:请完成待审核的用户', time: '30分钟前', read: false },
|
page: messagesPage.value,
|
||||||
{ id: 3, title: '安全警告:检测到异常登录', time: '1小时前', read: true },
|
pageSize: messagesPageSize.value,
|
||||||
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true },
|
type: currentMessageType.value === 'all' ? null : currentMessageType.value
|
||||||
])
|
})
|
||||||
|
return result.list
|
||||||
|
})
|
||||||
|
|
||||||
const messageCount = computed(() => messages.value.filter((m) => !m.read).length)
|
// 消息总数(用于分页)
|
||||||
|
const messagesTotal = computed(() => {
|
||||||
|
return messageStore.getMessages({
|
||||||
|
page: messagesPage.value,
|
||||||
|
pageSize: messagesPageSize.value,
|
||||||
|
type: currentMessageType.value === 'all' ? null : currentMessageType.value
|
||||||
|
}).total
|
||||||
|
})
|
||||||
|
|
||||||
|
// 未读消息数量
|
||||||
|
const messageCount = computed(() => messageStore.unreadCount)
|
||||||
|
|
||||||
// 任务数据
|
// 任务数据
|
||||||
const tasks = ref([
|
const tasks = ref([
|
||||||
@@ -180,8 +247,45 @@ const showSearch = () => {
|
|||||||
|
|
||||||
// 清除消息
|
// 清除消息
|
||||||
const clearMessages = () => {
|
const clearMessages = () => {
|
||||||
messages.value = []
|
Modal.confirm({
|
||||||
message.success(t('common.cleared'))
|
title: t('common.confirmClear'),
|
||||||
|
content: t('common.confirmClearMessages'),
|
||||||
|
okText: t('common.confirm'),
|
||||||
|
cancelText: t('common.cancel'),
|
||||||
|
onOk: () => {
|
||||||
|
messageStore.clearAll()
|
||||||
|
message.success(t('common.cleared'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记消息为已读
|
||||||
|
const handleMessageRead = (msg) => {
|
||||||
|
if (!msg.read) {
|
||||||
|
messageStore.markAsRead(msg.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记所有消息为已读
|
||||||
|
const markAllAsRead = () => {
|
||||||
|
messageStore.markAllAsRead()
|
||||||
|
message.success(t('common.markedAsRead'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除消息
|
||||||
|
const handleDeleteMessage = (msgId) => {
|
||||||
|
messageStore.removeMessage(msgId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换消息类型
|
||||||
|
const changeMessageType = (type) => {
|
||||||
|
currentMessageType.value = type
|
||||||
|
messagesPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
const handleMessagePageChange = (page) => {
|
||||||
|
messagesPage.value = page
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示任务抽屉
|
// 显示任务抽屉
|
||||||
@@ -318,26 +422,145 @@ const handleLogout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-card {
|
.dropdown-card {
|
||||||
width: 320px;
|
width: 380px;
|
||||||
max-height: 400px;
|
max-height: 500px;
|
||||||
overflow: auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
:deep(.ant-card-head) {
|
:deep(.ant-card-head) {
|
||||||
padding: 12px 16px;
|
padding: 8px 16px;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-card-body) {
|
:deep(.ant-card-body) {
|
||||||
padding: 12px 16px;
|
padding: 0;
|
||||||
max-height: 320px;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list,
|
.message-header {
|
||||||
.task-list {
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.message-item,
|
.message-tabs {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
:deep(.ant-tabs) {
|
||||||
|
.ant-tabs-nav {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unread {
|
||||||
|
background-color: rgba(24, 144, 255, 0.04);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #1890ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(24, 144, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 24px;
|
||||||
|
|
||||||
|
.message-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-pagination {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
.task-item {
|
.task-item {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
@@ -347,30 +570,7 @@ const handleLogout = () => {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unread {
|
&.completed {
|
||||||
background-color: rgba(24, 144, 255, 0.04);
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0 -10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
.message-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
.completed {
|
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { customStorage } from '../persist'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息类型枚举
|
||||||
|
*/
|
||||||
|
export const MessageType = {
|
||||||
|
NOTIFICATION: 'notification', // 系统通知
|
||||||
|
TASK: 'task', // 任务提醒
|
||||||
|
WARNING: 'warning', // 警告
|
||||||
|
ERROR: 'error', // 错误
|
||||||
|
SUCCESS: 'success', // 成功
|
||||||
|
INFO: 'info' // 信息
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息优先级枚举
|
||||||
|
*/
|
||||||
|
export const MessagePriority = {
|
||||||
|
LOW: 'low',
|
||||||
|
MEDIUM: 'medium',
|
||||||
|
HIGH: 'high',
|
||||||
|
URGENT: 'urgent'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMessageStore = defineStore(
|
||||||
|
'message',
|
||||||
|
() => {
|
||||||
|
// 消息列表
|
||||||
|
const messages = ref([])
|
||||||
|
|
||||||
|
// 最大消息数量
|
||||||
|
const maxMessages = 100
|
||||||
|
|
||||||
|
// 获取未读消息数量
|
||||||
|
const unreadCount = computed(() => {
|
||||||
|
return messages.value.filter((m) => !m.read).length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有消息数量
|
||||||
|
const totalCount = computed(() => {
|
||||||
|
return messages.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据类型获取消息数量
|
||||||
|
const getCountByType = (type) => {
|
||||||
|
return messages.value.filter((m) => m.type === type).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加消息
|
||||||
|
function addMessage(message) {
|
||||||
|
const newMessage = {
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
type: MessageType.NOTIFICATION,
|
||||||
|
priority: MessagePriority.MEDIUM,
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
read: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到列表开头
|
||||||
|
messages.value.unshift(newMessage)
|
||||||
|
|
||||||
|
// 限制消息数量
|
||||||
|
if (messages.value.length > maxMessages) {
|
||||||
|
messages.value = messages.value.slice(0, maxMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到 localStorage
|
||||||
|
persistMessages()
|
||||||
|
|
||||||
|
return newMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量添加消息
|
||||||
|
function addMessages(newMessages) {
|
||||||
|
newMessages.forEach((msg) => addMessage(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记消息为已读
|
||||||
|
function markAsRead(messageId) {
|
||||||
|
const message = messages.value.find((m) => m.id === messageId)
|
||||||
|
if (message) {
|
||||||
|
message.read = true
|
||||||
|
persistMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记所有消息为已读
|
||||||
|
function markAllAsRead() {
|
||||||
|
messages.value.forEach((m) => {
|
||||||
|
m.read = true
|
||||||
|
})
|
||||||
|
persistMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除消息
|
||||||
|
function removeMessage(messageId) {
|
||||||
|
const index = messages.value.findIndex((m) => m.id === messageId)
|
||||||
|
if (index !== -1) {
|
||||||
|
messages.value.splice(index, 1)
|
||||||
|
persistMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有消息
|
||||||
|
function clearAll() {
|
||||||
|
messages.value = []
|
||||||
|
persistMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空已读消息
|
||||||
|
function clearRead() {
|
||||||
|
messages.value = messages.value.filter((m) => !m.read)
|
||||||
|
persistMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据类型清空消息
|
||||||
|
function clearByType(type) {
|
||||||
|
messages.value = messages.value.filter((m) => m.type !== type)
|
||||||
|
persistMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化消息时间
|
||||||
|
function formatMessageTime(timestamp) {
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - timestamp
|
||||||
|
|
||||||
|
const minute = 60 * 1000
|
||||||
|
const hour = 60 * minute
|
||||||
|
const day = 24 * hour
|
||||||
|
|
||||||
|
if (diff < minute) {
|
||||||
|
return '刚刚'
|
||||||
|
} else if (diff < hour) {
|
||||||
|
return `${Math.floor(diff / minute)}分钟前`
|
||||||
|
} else if (diff < day) {
|
||||||
|
return `${Math.floor(diff / hour)}小时前`
|
||||||
|
} else if (diff < 7 * day) {
|
||||||
|
return `${Math.floor(diff / day)}天前`
|
||||||
|
} else {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化消息到 localStorage
|
||||||
|
function persistMessages() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('message-store', JSON.stringify(messages.value))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('持久化消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 localStorage 恢复消息
|
||||||
|
function restoreMessages() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('message-store')
|
||||||
|
if (stored) {
|
||||||
|
messages.value = JSON.parse(stored)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('恢复消息失败:', error)
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 WebSocket 消息
|
||||||
|
function handleWebSocketMessage(data) {
|
||||||
|
const { type, title, message, content, ...extra } = data
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
type: type || MessageType.NOTIFICATION,
|
||||||
|
title: title || '系统通知',
|
||||||
|
content: message || content || '',
|
||||||
|
...extra
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数据更新消息
|
||||||
|
function handleDataUpdate(data) {
|
||||||
|
const { resource_type, action } = data
|
||||||
|
|
||||||
|
let title = '数据更新'
|
||||||
|
let content = ''
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'create':
|
||||||
|
title = '新建成功'
|
||||||
|
content = `新的${resource_type}已创建`
|
||||||
|
break
|
||||||
|
case 'update':
|
||||||
|
title = '更新成功'
|
||||||
|
content = `${resource_type}数据已更新`
|
||||||
|
break
|
||||||
|
case 'delete':
|
||||||
|
title = '删除成功'
|
||||||
|
content = `${resource_type}数据已删除`
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
content = `${resource_type}数据已${action}`
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.SUCCESS,
|
||||||
|
title,
|
||||||
|
content
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取消息列表(带分页)
|
||||||
|
function getMessages(options = {}) {
|
||||||
|
const { page = 1, pageSize = 20, type = null, read = null } = options
|
||||||
|
|
||||||
|
let filtered = [...messages.value]
|
||||||
|
|
||||||
|
// 按类型过滤
|
||||||
|
if (type) {
|
||||||
|
filtered = filtered.filter((m) => m.type === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按已读状态过滤
|
||||||
|
if (read !== null) {
|
||||||
|
filtered = filtered.filter((m) => m.read === read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
const end = start + pageSize
|
||||||
|
const list = filtered.slice(start, end)
|
||||||
|
const total = filtered.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
unreadCount,
|
||||||
|
totalCount,
|
||||||
|
MessageType,
|
||||||
|
MessagePriority,
|
||||||
|
addMessage,
|
||||||
|
addMessages,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
removeMessage,
|
||||||
|
clearAll,
|
||||||
|
clearRead,
|
||||||
|
clearByType,
|
||||||
|
formatMessageTime,
|
||||||
|
handleWebSocketMessage,
|
||||||
|
handleDataUpdate,
|
||||||
|
getMessages,
|
||||||
|
getCountByType,
|
||||||
|
restoreMessages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: {
|
||||||
|
key: 'message-store',
|
||||||
|
storage: customStorage,
|
||||||
|
pick: [] // 不自动持久化,使用自定义 persistMessages 方法
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -85,6 +85,11 @@ import userRoutes from '@/config/routes'
|
|||||||
return !!token.value
|
return !!token.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查用户信息是否完整(用于 WebSocket 初始化)
|
||||||
|
function isUserInfoComplete() {
|
||||||
|
return !!(token.value && userInfo.value && userInfo.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
userInfo,
|
userInfo,
|
||||||
@@ -97,6 +102,7 @@ import userRoutes from '@/config/routes'
|
|||||||
setPermissions,
|
setPermissions,
|
||||||
logout,
|
logout,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
isUserInfoComplete,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user