更新websocket功能
This commit is contained in:
@@ -160,6 +160,11 @@ class WebSocketHandler implements WebSocketHandlerInterface
|
||||
$data = $message['data'] ?? [];
|
||||
|
||||
switch ($type) {
|
||||
case 'auth':
|
||||
// Handle authentication confirmation
|
||||
$this->handleAuth($server, $fd, $data);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
// Respond to ping with pong
|
||||
$server->push($fd, json_encode([
|
||||
@@ -212,6 +217,55 @@ class WebSocketHandler implements WebSocketHandlerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
|
||||
444
docs/WEBSOCKET_FEATURE.md
Normal file
444
docs/WEBSOCKET_FEATURE.md
Normal file
@@ -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 { useLayoutStore } from './stores/modules/layout'
|
||||
import { useUserStore } from './stores/modules/user'
|
||||
import { useMessageStore } from './stores/modules/message'
|
||||
import { useWebSocket } from './composables/useWebSocket'
|
||||
import { theme } from 'ant-design-vue'
|
||||
import i18n from './i18n'
|
||||
@@ -27,6 +28,9 @@ const layoutStore = useLayoutStore()
|
||||
// user store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// message store
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
// WebSocket
|
||||
const { initWebSocket, closeWebSocket } = useWebSocket()
|
||||
|
||||
@@ -77,9 +81,26 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听用户信息变化,当用户信息完整时初始化 WebSocket
|
||||
watch(
|
||||
() => [userStore.token, userStore.userInfo],
|
||||
() => {
|
||||
if (userStore.isUserInfoComplete()) {
|
||||
initWebSocket()
|
||||
} else if (!userStore.isLoggedIn()) {
|
||||
// 用户未登录,关闭 WebSocket
|
||||
closeWebSocket()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
|
||||
// 恢复消息数据
|
||||
messageStore.restoreMessages()
|
||||
|
||||
// 从持久化的 store 中读取语言设置并同步到 i18n
|
||||
i18n.global.locale.value = i18nStore.currentLocale
|
||||
|
||||
@@ -91,8 +112,8 @@ onMounted(async () => {
|
||||
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
||||
}
|
||||
|
||||
// 初始化 WebSocket 连接
|
||||
if (userStore.isLoggedIn()) {
|
||||
// 尝试初始化 WebSocket 连接
|
||||
if (userStore.isUserInfoComplete()) {
|
||||
initWebSocket()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { getWebSocket } from '@/utils/websocket'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useMessageStore } from '@/stores/modules/message'
|
||||
import { useDictionaryStore } from '@/stores/modules/dictionary'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
import config from '@/config'
|
||||
|
||||
/**
|
||||
@@ -13,23 +14,40 @@ import config from '@/config'
|
||||
export function useWebSocket() {
|
||||
const ws = ref(null)
|
||||
const userStore = useUserStore()
|
||||
const messageStore = useMessageStore()
|
||||
const dictionaryStore = useDictionaryStore()
|
||||
const reconnectTimer = ref(null)
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 连接
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
if (!userStore.userInfo || !userStore.userInfo.id) {
|
||||
console.warn('用户信息不完整,无法初始化 WebSocket')
|
||||
// 如果已经连接,不再重复连接
|
||||
if (ws.value && ws.value.isConnected) {
|
||||
console.log('WebSocket 已连接')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('开始初始化 WebSocket...', {
|
||||
userId: userStore.userInfo.id,
|
||||
username: userStore.userInfo.username
|
||||
})
|
||||
|
||||
// 使用配置文件中的 WS_URL
|
||||
ws.value = getWebSocket(userStore.userInfo.id, userStore.token, {
|
||||
wsUrl: config.WS_URL,
|
||||
@@ -40,8 +58,7 @@ export function useWebSocket() {
|
||||
})
|
||||
|
||||
// 注册消息处理器
|
||||
ws.value.on('dictionary_update', handleDictionaryUpdate)
|
||||
ws.value.on('dictionary_item_update', handleDictionaryItemUpdate)
|
||||
registerMessageHandlers()
|
||||
|
||||
// 连接
|
||||
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) {
|
||||
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) {
|
||||
console.error('WebSocket 错误:', error)
|
||||
message.error('实时连接出现错误,正在重连...')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +147,84 @@ export function useWebSocket() {
|
||||
*/
|
||||
function handleClose(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) {
|
||||
console.log('字典分类已更新:', data)
|
||||
|
||||
const { action, resource_type, timestamp } = data
|
||||
|
||||
if (resource_type !== 'dictionary') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 刷新字典缓存
|
||||
await dictionaryStore.refresh(true)
|
||||
@@ -107,12 +250,6 @@ export function useWebSocket() {
|
||||
async function handleDictionaryItemUpdate(data) {
|
||||
console.log('字典项已更新:', data)
|
||||
|
||||
const { action, resource_type, timestamp } = data
|
||||
|
||||
if (resource_type !== 'dictionary_item') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 刷新字典缓存
|
||||
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 连接
|
||||
*/
|
||||
function closeWebSocket() {
|
||||
// 清除重试定时器
|
||||
if (reconnectTimer.value) {
|
||||
clearTimeout(reconnectTimer.value)
|
||||
reconnectTimer.value = null
|
||||
}
|
||||
|
||||
if (ws.value) {
|
||||
// 取消注册消息处理器
|
||||
ws.value.off('dictionary_update')
|
||||
ws.value.off('dictionary_item_update')
|
||||
unregisterMessageHandlers()
|
||||
|
||||
ws.value.disconnect()
|
||||
ws.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新连接 WebSocket
|
||||
*/
|
||||
function reconnect() {
|
||||
closeWebSocket()
|
||||
setTimeout(() => {
|
||||
initWebSocket()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
function isConnected() {
|
||||
return ws.value && ws.value.isConnected
|
||||
}
|
||||
|
||||
return {
|
||||
ws,
|
||||
initWebSocket,
|
||||
closeWebSocket
|
||||
closeWebSocket,
|
||||
reconnect,
|
||||
isConnected,
|
||||
send
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,22 @@ export default {
|
||||
clearCacheFailed: 'Failed to clear cache',
|
||||
messages: 'Messages',
|
||||
tasks: 'Tasks',
|
||||
notification: 'Notification',
|
||||
task: 'Task',
|
||||
warning: 'Warning',
|
||||
markAllAsRead: 'Mark All as Read',
|
||||
clearAll: 'Clear All',
|
||||
noMessages: 'No Messages',
|
||||
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',
|
||||
personalCenter: 'Personal Center',
|
||||
systemSettings: 'System Settings',
|
||||
|
||||
@@ -38,9 +38,22 @@ export default {
|
||||
clearCacheFailed: '清除缓存失败',
|
||||
messages: '消息',
|
||||
tasks: '任务',
|
||||
notification: '通知',
|
||||
task: '任务',
|
||||
warning: '警告',
|
||||
markAllAsRead: '全部标为已读',
|
||||
clearAll: '清空全部',
|
||||
noMessages: '暂无消息',
|
||||
noTasks: '暂无任务',
|
||||
confirmClear: '确认清空',
|
||||
confirmClearMessages: '确定要清空所有消息吗?',
|
||||
markedAsRead: '已标记为已读',
|
||||
realtimeConnected: '实时连接已建立',
|
||||
realtimeDisconnected: '实时连接已断开',
|
||||
realtimeError: '实时连接出现错误,正在重连...',
|
||||
dataUpdated: '数据已更新',
|
||||
dataCreated: '数据已创建',
|
||||
dataDeleted: '数据已删除',
|
||||
fullscreen: '全屏',
|
||||
personalCenter: '个人中心',
|
||||
systemSettings: '系统设置',
|
||||
|
||||
@@ -8,27 +8,76 @@
|
||||
</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-button type="text" class="action-btn">
|
||||
<BellOutlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<template #overlay>
|
||||
<a-card class="dropdown-card" :title="$t('common.messages')" :bordered="false">
|
||||
<template #extra>
|
||||
<a @click="clearMessages">{{ $t('common.clearAll') }}</a>
|
||||
<a-card class="dropdown-card" :bordered="false">
|
||||
<template #title>
|
||||
<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>
|
||||
|
||||
<!-- 消息类型筛选 -->
|
||||
<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 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-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>
|
||||
<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>
|
||||
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
|
||||
</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>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
@@ -111,6 +160,7 @@ import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
import search from './search.vue'
|
||||
@@ -125,20 +175,37 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const i18nStore = useI18nStore()
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
const searchVisible = ref(false)
|
||||
const taskVisible = ref(false)
|
||||
const messageVisible = ref(false)
|
||||
const currentMessageType = ref('all')
|
||||
const messagesPage = ref(1)
|
||||
const messagesPageSize = ref(10)
|
||||
|
||||
// 消息数据
|
||||
const messages = ref([
|
||||
{ id: 1, title: '系统通知:新版本已发布', time: '10分钟前', read: false },
|
||||
{ id: 2, title: '任务提醒:请完成待审核的用户', time: '30分钟前', read: false },
|
||||
{ id: 3, title: '安全警告:检测到异常登录', time: '1小时前', read: true },
|
||||
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true },
|
||||
])
|
||||
// 从 store 获取消息数据
|
||||
const messages = computed(() => {
|
||||
const result = messageStore.getMessages({
|
||||
page: messagesPage.value,
|
||||
pageSize: messagesPageSize.value,
|
||||
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([
|
||||
@@ -180,8 +247,45 @@ const showSearch = () => {
|
||||
|
||||
// 清除消息
|
||||
const clearMessages = () => {
|
||||
messages.value = []
|
||||
Modal.confirm({
|
||||
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 {
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
width: 380px;
|
||||
max-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
padding: 12px 16px;
|
||||
padding: 8px 16px;
|
||||
min-height: auto;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.ant-card-head-title {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px 16px;
|
||||
max-height: 320px;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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-list,
|
||||
.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 {
|
||||
|
||||
.message-item,
|
||||
.task-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
@@ -347,30 +570,7 @@ const handleLogout = () => {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
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 {
|
||||
&.completed {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
275
resources/admin/src/stores/modules/message.js
Normal file
275
resources/admin/src/stores/modules/message.js
Normal file
@@ -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
|
||||
}
|
||||
|
||||
// 检查用户信息是否完整(用于 WebSocket 初始化)
|
||||
function isUserInfoComplete() {
|
||||
return !!(token.value && userInfo.value && userInfo.value.id)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
@@ -97,6 +102,7 @@ import userRoutes from '@/config/routes'
|
||||
setPermissions,
|
||||
logout,
|
||||
isLoggedIn,
|
||||
isUserInfoComplete,
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user