From a0c2350662503961f2fd469ca4363756581433a6 Mon Sep 17 00:00:00 2001 From: molong Date: Wed, 18 Feb 2026 18:05:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0websocket=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/WebSocket/WebSocketHandler.php | 54 +++ docs/WEBSOCKET_FEATURE.md | 444 ++++++++++++++++++ resources/admin/src/App.vue | 25 +- .../admin/src/composables/useWebSocket.js | 217 ++++++++- resources/admin/src/i18n/locales/en-US.js | 13 + resources/admin/src/i18n/locales/zh-CN.js | 13 + .../admin/src/layouts/components/userbar.vue | 300 ++++++++++-- resources/admin/src/stores/modules/message.js | 275 +++++++++++ resources/admin/src/stores/modules/user.js | 6 + 9 files changed, 1273 insertions(+), 74 deletions(-) create mode 100644 docs/WEBSOCKET_FEATURE.md create mode 100644 resources/admin/src/stores/modules/message.js diff --git a/app/Services/WebSocket/WebSocketHandler.php b/app/Services/WebSocket/WebSocketHandler.php index 165a174..5aa6551 100644 --- a/app/Services/WebSocket/WebSocketHandler.php +++ b/app/Services/WebSocket/WebSocketHandler.php @@ -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 * diff --git a/docs/WEBSOCKET_FEATURE.md b/docs/WEBSOCKET_FEATURE.md new file mode 100644 index 0000000..fb93e31 --- /dev/null +++ b/docs/WEBSOCKET_FEATURE.md @@ -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 +- 实现顶部消息组件 +- 支持中英文国际化 diff --git a/resources/admin/src/App.vue b/resources/admin/src/App.vue index da0b0a2..a926024 100644 --- a/resources/admin/src/App.vue +++ b/resources/admin/src/App.vue @@ -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() } }) diff --git a/resources/admin/src/composables/useWebSocket.js b/resources/admin/src/composables/useWebSocket.js index c12d505..caaa465 100644 --- a/resources/admin/src/composables/useWebSocket.js +++ b/resources/admin/src/composables/useWebSocket.js @@ -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 } } diff --git a/resources/admin/src/i18n/locales/en-US.js b/resources/admin/src/i18n/locales/en-US.js index 8955705..91996ef 100644 --- a/resources/admin/src/i18n/locales/en-US.js +++ b/resources/admin/src/i18n/locales/en-US.js @@ -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', diff --git a/resources/admin/src/i18n/locales/zh-CN.js b/resources/admin/src/i18n/locales/zh-CN.js index 7f6e6ff..bf84c78 100644 --- a/resources/admin/src/i18n/locales/zh-CN.js +++ b/resources/admin/src/i18n/locales/zh-CN.js @@ -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: '系统设置', diff --git a/resources/admin/src/layouts/components/userbar.vue b/resources/admin/src/layouts/components/userbar.vue index cb2f9f2..4a379fc 100644 --- a/resources/admin/src/layouts/components/userbar.vue +++ b/resources/admin/src/layouts/components/userbar.vue @@ -8,27 +8,76 @@ - +