更新websocket功能

This commit is contained in:
2026-02-18 18:05:33 +08:00
parent e679a9402f
commit a0c2350662
9 changed files with 1273 additions and 74 deletions

View File

@@ -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
View 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
- 实现顶部消息组件
- 支持中英文国际化

View File

@@ -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()
}
})

View File

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

View File

@@ -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',

View File

@@ -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: '系统设置',

View File

@@ -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;
}

View 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 方法
}
}
)

View File

@@ -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,
}
},
{