diff --git a/backend/application/asgi.py b/backend/application/asgi.py index 37e9f35951344b2b6485b57c7c6623b84bfaea51..2582f54a0f5dcb6aec03ff7a4eef42982aa99b2d 100644 --- a/backend/application/asgi.py +++ b/backend/application/asgi.py @@ -8,14 +8,25 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ """ import os -from channels.routing import ProtocolTypeRouter + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'application.settings') os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" http_application = get_asgi_application() +from application.ws_routing import websocket_urlpatterns application = ProtocolTypeRouter({ "http": http_application, + 'websocket': AllowedHostsOriginValidator( + AuthMiddlewareStack( + URLRouter( + websocket_urlpatterns # 指明路由文件是devops/routing.py + ) + ) + ), }) diff --git a/backend/application/urls.py b/backend/application/urls.py index d1902fcb8537500888857997144712f55c4ce825..874dbb5524b0ad954c633977774e779c12b2f919 100644 --- a/backend/application/urls.py +++ b/backend/application/urls.py @@ -15,6 +15,7 @@ Including another URLconf """ from django.conf.urls.static import static from django.urls import path, include, re_path +from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions diff --git a/backend/application/websocketConfig.py b/backend/application/websocketConfig.py new file mode 100644 index 0000000000000000000000000000000000000000..6c390f827baa5e2eb13db4b461b67dfe71853662 --- /dev/null +++ b/backend/application/websocketConfig.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +import urllib + +from asgiref.sync import sync_to_async, async_to_sync +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer, AsyncWebsocketConsumer +import json + +from channels.layers import get_channel_layer +from jwt import InvalidSignatureError +from rest_framework.request import Request + +from application import settings +from dvadmin.system.models import MessageCenter, Users, MessageCenterTargetUser +from dvadmin.system.views.message_center import MessageCenterTargetUserSerializer +from dvadmin.utils.serializers import CustomModelSerializer + +send_dict = {} + + +# 发送消息结构体 +def set_message(sender, msg_type, msg, unread=0): + text = { + 'sender': sender, + 'contentType': msg_type, + 'content': msg, + 'unread': unread + } + return text + + +# 异步获取消息中心的目标用户 +@database_sync_to_async +def _get_message_center_instance(message_id): + from dvadmin.system.models import MessageCenter + _MessageCenter = MessageCenter.objects.filter(id=message_id).values_list('target_user', flat=True) + if _MessageCenter: + return _MessageCenter + else: + return [] + + +@database_sync_to_async +def _get_message_unread(user_id): + """获取用户的未读消息数量""" + from dvadmin.system.models import MessageCenterTargetUser + count = MessageCenterTargetUser.objects.filter(users=user_id, is_read=False).count() + return count or 0 + + +def request_data(scope): + query_string = scope.get('query_string', b'').decode('utf-8') + qs = urllib.parse.parse_qs(query_string) + return qs + + +class DvadminWebSocket(AsyncJsonWebsocketConsumer): + async def connect(self): + try: + import jwt + self.service_uid = self.scope["url_route"]["kwargs"]["service_uid"] + decoded_result = jwt.decode(self.service_uid, settings.SECRET_KEY, algorithms=["HS256"]) + if decoded_result: + self.user_id = decoded_result.get('user_id') + self.chat_group_name = "user_" + str(self.user_id) + # 收到连接时候处理, + await self.channel_layer.group_add( + self.chat_group_name, + self.channel_name + ) + await self.accept() + # 主动推送消息 + unread_count = await _get_message_unread(self.user_id) + if unread_count == 0: + # 发送连接成功 + await self.send_json(set_message('system', 'SYSTEM', '您已上线')) + else: + await self.send_json( + set_message('system', 'SYSTEM', "请查看您的未读消息~", + unread=unread_count)) + except InvalidSignatureError: + await self.disconnect(None) + + async def disconnect(self, close_code): + # Leave room group + await self.channel_layer.group_discard(self.chat_group_name, self.channel_name) + print("连接关闭") + try: + await self.close(close_code) + except Exception: + pass + + +class MegCenter(DvadminWebSocket): + """ + 消息中心 + """ + + async def receive(self, text_data): + # 接受客户端的信息,你处理的函数 + text_data_json = json.loads(text_data) + message_id = text_data_json.get('message_id', None) + user_list = await _get_message_center_instance(message_id) + for send_user in user_list: + await self.channel_layer.group_send( + "user_" + str(send_user), + {'type': 'push.message', 'json': text_data_json} + ) + + async def push_message(self, event): + """消息发送""" + message = event['json'] + await self.send(text_data=json.dumps(message)) + + +class MessageCreateSerializer(CustomModelSerializer): + """ + 消息中心-新增-序列化器 + """ + class Meta: + model = MessageCenter + fields = "__all__" + read_only_fields = ["id"] + + +def websocket_push(user_id, message): + username = "user_" + str(user_id) + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + username, + { + "type": "push.message", + "json": message + } + ) + + +def create_message_push(title: str, content: str, target_type: int = 0, target_user: list = None, target_dept=None, + target_role=None, message: dict = None, request=Request): + if message is None: + message = {"contentType": "INFO", "content": None} + if target_role is None: + target_role = [] + if target_dept is None: + target_dept = [] + data = { + "title": title, + "content": content, + "target_type": target_type, + "target_user": target_user, + "target_dept": target_dept, + "target_role": target_role + } + message_center_instance = MessageCreateSerializer(data=data, request=request) + message_center_instance.is_valid(raise_exception=True) + message_center_instance.save() + users = target_user or [] + if target_type in [1]: # 按角色 + users = Users.objects.filter(role__id__in=target_role).values_list('id', flat=True) + if target_type in [2]: # 按部门 + users = Users.objects.filter(dept__id__in=target_dept).values_list('id', flat=True) + if target_type in [3]: # 系统通知 + users = Users.objects.values_list('id', flat=True) + targetuser_data = [] + for user in users: + targetuser_data.append({ + "messagecenter": message_center_instance.instance.id, + "users": user + }) + targetuser_instance = MessageCenterTargetUserSerializer(data=targetuser_data, many=True, request=request) + targetuser_instance.is_valid(raise_exception=True) + targetuser_instance.save() + for user in users: + username = "user_" + str(user) + unread_count = async_to_sync(_get_message_unread)(user) + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + username, + { + "type": "push.message", + "json": {**message, 'unread': unread_count} + } + ) \ No newline at end of file diff --git a/backend/application/ws_routing.py b/backend/application/ws_routing.py new file mode 100644 index 0000000000000000000000000000000000000000..83a9076f445da2079c570f7f4db28927de4f641b --- /dev/null +++ b/backend/application/ws_routing.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +from django.urls import path +from application.websocketConfig import MegCenter + +websocket_urlpatterns = [ + path('ws//', MegCenter.as_asgi()), # consumers.DvadminWebSocket 是该路由的消费者 +] \ No newline at end of file diff --git a/backend/dvadmin/system/models.py b/backend/dvadmin/system/models.py index 7a71c67ed8d2b7e9c651731d71777b26b6107869..4dca9c97e6f23cdaa25b1a094d2c12c0ddb65929 100644 --- a/backend/dvadmin/system/models.py +++ b/backend/dvadmin/system/models.py @@ -178,6 +178,23 @@ class Dept(CoreModel): cls.recursion_all_dept(ele.get("id"), dept_all_list, dept_list) return list(set(dept_list)) + @classmethod + def recursion_all_parent_dept(cls, dept_id: int, dept_list=None): + """ + 递归获取部门的所有上级部门 + :param dept_id: 需要获取的id + :param dept_list: 递归list + :return: + """ + if dept_list is None: + dept_list = [dept_id] + current_dept = Dept.objects.filter(id=dept_id).values('parent').first() + if current_dept and current_dept.get('parent'): + parent_id = current_dept.get('parent') + dept_list.append(parent_id) + cls.recursion_all_parent_dept(parent_id, dept_list) + return list(set(dept_list)) + class Meta: db_table = table_prefix + "system_dept" verbose_name = "部门表" diff --git a/backend/dvadmin/system/views/message_center.py b/backend/dvadmin/system/views/message_center.py index 26faa3f3261656af0c31fd6f4a6c84fbd9dfdeb7..52c065fa0c45bd1aec0bdac21a10eb55d8f45359 100644 --- a/backend/dvadmin/system/views/message_center.py +++ b/backend/dvadmin/system/views/message_center.py @@ -138,6 +138,19 @@ class MessageCenterTargetUserListSerializer(CustomModelSerializer): fields = "__all__" read_only_fields = ["id"] +def websocket_push(user_id, message): + """ + 主动推送消息 + """ + username = "user_" + str(user_id) + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + username, + { + "type": "push.message", + "json": message + } + ) class MessageCenterCreateSerializer(CustomModelSerializer): """ @@ -167,6 +180,10 @@ class MessageCenterCreateSerializer(CustomModelSerializer): targetuser_instance = MessageCenterTargetUserSerializer(data=targetuser_data, many=True, request=self.request) targetuser_instance.is_valid(raise_exception=True) targetuser_instance.save() + for user in users: + unread_count = MessageCenterTargetUser.objects.filter(users__id=user, is_read=False).count() + websocket_push(user, message={"sender": 'system', "contentType": 'SYSTEM', + "content": '您有一条新消息~', "unread": unread_count}) return data class Meta: @@ -206,6 +223,10 @@ class MessageCenterViewSet(CustomModelViewSet): queryset.save() instance = self.get_object() serializer = self.get_serializer(instance) + # 主动推送消息 + unread_count = MessageCenterTargetUser.objects.filter(users__id=user_id, is_read=False).count() + websocket_push(user_id, message={"sender": 'system', "contentType": 'TEXT', + "content": '您查看了一条消息~', "unread": unread_count}) return DetailResponse(data=serializer.data, msg="获取成功") @action(methods=['GET'], detail=False, permission_classes=[IsAuthenticated]) diff --git a/web/src/App.vue b/web/src/App.vue index 449b9658eb6209902cbdf96a09a9ebe4cb6b3129..018cc71b2d53cd3270df796d8e174e61701b30e0 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -20,6 +20,7 @@ import other from '/@/utils/other'; import { Local, Session } from '/@/utils/storage'; import mittBus from '/@/utils/mitt'; import setIntroduction from '/@/utils/setIconfont'; +import websocket from '/@/utils/websocket'; // 引入组件 const LockScreen = defineAsyncComponent(() => import('/@/layout/lockScreen/index.vue')); @@ -91,5 +92,63 @@ onMounted(() => { onUnmounted(() => { mittBus.off('openSetingsDrawer', () => {}); }); +// 监听路由的变化,设置网站标题 +watch( + () => route.path, + () => { + other.useTitle(); + other.useFavicon(); + if (!websocket.websocket) { + //websockt 模块 + try { + websocket.init(wsReceive) + } catch (e) { + console.log('websocket错误'); + } + } + }, + { + deep: true, + } +); + +// websocket相关代码 +import { messageCenterStore } from '/@/stores/messageCenter'; +const wsReceive = (message: any) => { + const data = JSON.parse(message.data); + const { unread } = data; + const messageCenter = messageCenterStore(); + messageCenter.setUnread(unread); + if (data.contentType === 'SYSTEM') { + ElNotification({ + title: '系统消息', + message: data.content, + type: 'success', + position: 'bottom-right', + duration: 5000, + }); + } else if (data.contentType === 'Content') { + ElMessageBox.confirm(data.content, data.notificationTitle, { + confirmButtonText: data.notificationButton, + dangerouslyUseHTMLString: true, + cancelButtonText: '关闭', + type: 'info', + closeOnClickModal: false, + }).then(() => { + ElMessageBox.close(); + const path = data.path; + if (route.path === path) { + core.bus.emit('onNewTask', { name: 'onNewTask' }); + } else { + router.push({ path}); + } + }) + .catch(() => {}); + } +}; +onBeforeUnmount(() => { + // 关闭连接 + websocket.close(); +}); diff --git a/web/src/layout/navBars/breadcrumb/user.vue b/web/src/layout/navBars/breadcrumb/user.vue index 5f167dc4f2182eb520b719b8279acd200cb1a9bf..e21ad959c536aa6bcd4d1af7cc41b76030cc298b 100644 --- a/web/src/layout/navBars/breadcrumb/user.vue +++ b/web/src/layout/navBars/breadcrumb/user.vue @@ -57,6 +57,26 @@ :class="!state.isScreenfull ? 'icon-fullscreen' : 'icon-tuichuquanping'" > +
+ + + + + +
@@ -95,6 +115,7 @@ import mittBus from '/@/utils/mitt'; import { Session, Local } from '/@/utils/storage'; import headerImage from '/@/assets/img/headerImage.png'; import { InfoFilled } from '@element-plus/icons-vue'; +import websocket from '/@/utils/websocket'; // 引入组件 const UserNews = defineAsyncComponent(() => import('/@/layout/navBars/breadcrumb/userNews.vue')); const Search = defineAsyncComponent(() => import('/@/layout/navBars/breadcrumb/search.vue')); @@ -123,6 +144,21 @@ const layoutUserFlexNum = computed(() => { return num; }); +// 定义变量内容 +const { isSocketOpen } = storeToRefs(useUserInfo()); + +// websocket状态 +const onlinePopoverRef = ref() +const onlineConfirmEvent = () => { + if (!isSocketOpen.value) { + websocket.is_reonnect = true + websocket.reconnect_current = 1 + websocket.reconnect() + } + // 手动隐藏弹出 + unref(onlinePopoverRef).popperRef?.delayHide?.() +} + // 全屏点击时 const onScreenfullClick = () => { if (!screenfull.isEnabled) { diff --git a/web/src/stores/interface/index.ts b/web/src/stores/interface/index.ts index 5083cf45b02138041181045d0a93dd07e2fba24f..9f73c9212b370fc1118808a5d05c6f24590dfd7f 100644 --- a/web/src/stores/interface/index.ts +++ b/web/src/stores/interface/index.ts @@ -23,6 +23,7 @@ export interface UserInfosState { } export interface UserInfosStates { userInfos: UserInfosState; + isSocketOpen: boolean } // 路由缓存列表 diff --git a/web/src/stores/userInfo.ts b/web/src/stores/userInfo.ts index bdaf4bc2a436173027fec2925437cef386b10e17..c896214ee628e70df5d7ffcfe9f17bc9cd120c5a 100644 --- a/web/src/stores/userInfo.ts +++ b/web/src/stores/userInfo.ts @@ -32,6 +32,7 @@ export const useUserInfo = defineStore('userInfo', { }, ], }, + isSocketOpen: false }), actions: { async setPwdChangeCount(count: number) { @@ -71,6 +72,9 @@ export const useUserInfo = defineStore('userInfo', { Session.set('userInfo', this.userInfos); } }, + async setWebSocketState(socketState: boolean) { + this.isSocketOpen = socketState; + }, async getApiUserInfo() { return request({ url: '/api/system/user/user_info/', diff --git a/web/src/views/system/role/crud.tsx b/web/src/views/system/role/crud.tsx index ce8ff5a2df30af39400f53fc36117635680c75a9..f725188e3c05551511b5d3d9630578a1029362e4 100644 --- a/web/src/views/system/role/crud.tsx +++ b/web/src/views/system/role/crud.tsx @@ -79,7 +79,7 @@ export const createCrudOptions = function ({ crudExpose, context }: CreateCrudOp permission: { type: 'primary', text: '权限配置', - show: auth('role:Permission'), + show: auth('role:SetMenu'), click: (clickContext: any): void => { const { row } = clickContext; context.RoleDrawer.handleDrawerOpen(row);