diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..676bf9a Binary files /dev/null and b/.DS_Store differ diff --git a/README.en.md b/README.en.md index d8b27b7..c4968b1 100644 --- a/README.en.md +++ b/README.en.md @@ -147,11 +147,11 @@ docker-compose up -d --build ## Demo screenshot✅ -![image-01](https://foruda.gitee.com/images/1681118295661653899/12b2f755_5074988.jpeg) +![image-01](https://foruda.gitee.com/images/1682179942561449504/020863bb_5074988.jpeg) -![image-02](https://foruda.gitee.com/images/1681118320563350469/311f888b_5074988.jpeg) +![image-02](https://foruda.gitee.com/images/1682179701820334814/f20eb5e8_5074988.png) -![image-03](https://foruda.gitee.com/images/1681118339552860024/780a4e11_5074988.jpeg) +![image-03](https://foruda.gitee.com/images/1682179718209143602/e6b6a4b1_5074988.png) ![image-04](https://foruda.gitee.com/images/1681118349561624452/d917f8bc_5074988.jpeg) diff --git a/README.md b/README.md index f13c3b2..ed618b2 100644 --- a/README.md +++ b/README.md @@ -172,11 +172,11 @@ docker-compose up -d --build ## 演示图✅ -![image-01](https://foruda.gitee.com/images/1681118295661653899/12b2f755_5074988.jpeg) +![image-01](https://foruda.gitee.com/images/1682179942561449504/020863bb_5074988.jpeg) -![image-02](https://foruda.gitee.com/images/1681118320563350469/311f888b_5074988.jpeg) +![image-02](https://foruda.gitee.com/images/1682179701820334814/f20eb5e8_5074988.png) -![image-03](https://foruda.gitee.com/images/1681118339552860024/780a4e11_5074988.jpeg) +![image-03](https://foruda.gitee.com/images/1682179718209143602/e6b6a4b1_5074988.png) ![image-04](https://foruda.gitee.com/images/1681118349561624452/d917f8bc_5074988.jpeg) diff --git a/backend/application/dispatch.py b/backend/application/dispatch.py index 5f65137..101b3ab 100644 --- a/backend/application/dispatch.py +++ b/backend/application/dispatch.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db import connection from django.core.cache import cache +from dvadmin.utils.validator import CustomValidationError dispatch_db_type = getattr(settings, 'DISPATCH_DB_TYPE', 'memory') # redis @@ -159,7 +160,7 @@ def get_dictionary_config(schema_name=None): init_dictionary_data = cache.get(f"init_dictionary") if not init_dictionary_data: refresh_dictionary() - return cache.get(f"init_dictionary") or {} + return cache.get(f"init_dictionary") or {} if not settings.DICTIONARY_CONFIG: refresh_dictionary() if is_tenants_mode(): @@ -243,6 +244,22 @@ def get_system_config_values(key, schema_name=None): return system_config.get(key) +def get_system_config_values_to_dict(key, schema_name=None): + """ + 获取系统配置数据并转换为字典 **仅限于数组类型系统配置 + :param key: 对应系统配置的key值(字典编号) + :param schema_name: 对应系统配置的租户schema_name值 + :return: + """ + values_dict = {} + config_values = get_system_config_values(key, schema_name) + if not isinstance(config_values, list): + raise CustomValidationError("该方式仅限于数组类型系统配置") + for ele in get_system_config_values(key, schema_name): + values_dict[ele.get('key')] = ele.get('value') + return values_dict + + def get_system_config_label(key, name, schema_name=None): """ 获取获取系统配置label值 diff --git a/backend/application/settings.py b/backend/application/settings.py index 6946d38..6a3904d 100644 --- a/backend/application/settings.py +++ b/backend/application/settings.py @@ -255,21 +255,11 @@ LOGGING = { "level": "INFO", "propagate": False, }, - 'celery': { - 'handlers': ["console", "error", "file"], - 'propagate': False, - 'level': "INFO" - }, 'django.db.backends': { 'handlers': ["console", "error", "file"], 'propagate': False, 'level': "INFO" }, - 'django.request': { - 'handlers': ["console", "error", "file"], - 'propagate': False, - 'level': "DEBUG" - }, "uvicorn.error": { "level": "INFO", "handlers": ["console", "error", "file"], @@ -418,6 +408,7 @@ PLUGINS_URL_PATTERNS = [] # from dvadmin_uniapp.settings import * # UniApp后端 # from dvadmin_ak_sk.settings import * # 秘钥管理管理 # from dvadmin_tenants.settings import * # 租户管理 +# from dvadmin_cloud_storage.settings import * # 云存储 # ... # ********** 一键导入插件配置结束 ********** diff --git a/backend/conf/env.example.py b/backend/conf/env.example.py index 181d963..9ff3bcf 100644 --- a/backend/conf/env.example.py +++ b/backend/conf/env.example.py @@ -42,7 +42,7 @@ LOGIN_NO_CAPTCHA_AUTH = True # ================================================= # # ****************** 其他 配置 ******************* # # ================================================= # - +ENVIRONMENT = "local" # 环境,test 测试环境;prod线上环境;local本地环境 ALLOWED_HOSTS = ["*"] # 系统配置存放位置:redis/memory(默认) DISPATCH_DB_TYPE = 'redis' diff --git a/backend/dvadmin/system/models.py b/backend/dvadmin/system/models.py index 2326c83..8cac84c 100644 --- a/backend/dvadmin/system/models.py +++ b/backend/dvadmin/system/models.py @@ -1,5 +1,6 @@ import hashlib import os +from pathlib import PurePath, PureWindowsPath, PurePosixPath from django.contrib.auth.models import AbstractUser from django.db import models @@ -13,8 +14,11 @@ STATUS_CHOICES = ( ) -class Users(CoreModel,AbstractUser): - username = models.CharField(max_length=150, unique=True, db_index=True, verbose_name="用户账号", help_text="用户账号") +class Users(CoreModel, AbstractUser): + username = models.CharField(max_length=150, unique=True, db_index=True, verbose_name="用户账号", + help_text="用户账号") + employee_no = models.CharField(max_length=150, unique=True, db_index=True, null=True, blank=True, + verbose_name="工号", help_text="工号") email = models.EmailField(max_length=255, verbose_name="邮箱", null=True, blank=True, help_text="邮箱") mobile = models.CharField(max_length=255, verbose_name="电话", null=True, blank=True, help_text="电话") avatar = models.CharField(max_length=255, verbose_name="头像", null=True, blank=True, help_text="头像") @@ -34,8 +38,10 @@ class Users(CoreModel,AbstractUser): user_type = models.IntegerField( choices=USER_TYPE, default=0, verbose_name="用户类型", null=True, blank=True, help_text="用户类型" ) - post = models.ManyToManyField(to="Post",blank=True, verbose_name="关联岗位", db_constraint=False, help_text="关联岗位") - role = models.ManyToManyField(to="Role", blank=True,verbose_name="关联角色", db_constraint=False, help_text="关联角色") + post = models.ManyToManyField(to="Post", blank=True, verbose_name="关联岗位", db_constraint=False, + help_text="关联岗位") + role = models.ManyToManyField(to="Role", blank=True, verbose_name="关联角色", db_constraint=False, + help_text="关联角色") dept = models.ForeignKey( to="Dept", verbose_name="所属部门", @@ -45,7 +51,8 @@ class Users(CoreModel,AbstractUser): blank=True, help_text="关联部门", ) - last_token = models.CharField(max_length=255,null=True,blank=True, verbose_name="最后一次登录Token", help_text="最后一次登录Token") + last_token = models.CharField(max_length=255, null=True, blank=True, verbose_name="最后一次登录Token", + help_text="最后一次登录Token") def set_password(self, raw_password): super().set_password(hashlib.md5(raw_password.encode(encoding="UTF-8")).hexdigest()) @@ -87,9 +94,11 @@ class Role(CoreModel): (3, "全部数据权限"), (4, "自定数据权限"), ) - data_range = models.IntegerField(default=0, choices=DATASCOPE_CHOICES, verbose_name="数据权限范围", help_text="数据权限范围") + data_range = models.IntegerField(default=0, choices=DATASCOPE_CHOICES, verbose_name="数据权限范围", + help_text="数据权限范围") remark = models.TextField(verbose_name="备注", help_text="备注", null=True, blank=True) - dept = models.ManyToManyField(to="Dept", verbose_name="数据权限-关联部门", db_constraint=False, help_text="数据权限-关联部门") + dept = models.ManyToManyField(to="Dept", verbose_name="数据权限-关联部门", db_constraint=False, + help_text="数据权限-关联部门") menu = models.ManyToManyField(to="Menu", verbose_name="关联菜单", db_constraint=False, help_text="关联菜单") permission = models.ManyToManyField( to="MenuButton", verbose_name="关联菜单的接口按钮", db_constraint=False, help_text="关联菜单的接口按钮" @@ -104,7 +113,8 @@ class Role(CoreModel): class Dept(CoreModel): name = models.CharField(max_length=64, verbose_name="部门名称", help_text="部门名称") - key = models.CharField(max_length=64, unique=True,null=True,blank=True, verbose_name="关联字符", help_text="关联字符") + key = models.CharField(max_length=64, unique=True, null=True, blank=True, verbose_name="关联字符", + help_text="关联字符") sort = models.IntegerField(default=1, verbose_name="显示排序", help_text="显示排序") owner = models.CharField(max_length=32, verbose_name="负责人", null=True, blank=True, help_text="负责人") phone = models.CharField(max_length=32, verbose_name="联系电话", null=True, blank=True, help_text="联系电话") @@ -168,10 +178,12 @@ class Menu(CoreModel): is_catalog = models.BooleanField(default=False, verbose_name="是否目录", help_text="是否目录") web_path = models.CharField(max_length=128, verbose_name="路由地址", null=True, blank=True, help_text="路由地址") component = models.CharField(max_length=128, verbose_name="组件地址", null=True, blank=True, help_text="组件地址") - component_name = models.CharField(max_length=50, verbose_name="组件名称", null=True, blank=True, help_text="组件名称") + component_name = models.CharField(max_length=50, verbose_name="组件名称", null=True, blank=True, + help_text="组件名称") status = models.BooleanField(default=True, blank=True, verbose_name="菜单状态", help_text="菜单状态") cache = models.BooleanField(default=False, blank=True, verbose_name="是否页面缓存", help_text="是否页面缓存") - visible = models.BooleanField(default=True, blank=True, verbose_name="侧边栏中是否显示", help_text="侧边栏中是否显示") + visible = models.BooleanField(default=True, blank=True, verbose_name="侧边栏中是否显示", + help_text="侧边栏中是否显示") class Meta: db_table = table_prefix + "system_menu" @@ -198,7 +210,8 @@ class MenuButton(CoreModel): (2, "PUT"), (3, "DELETE"), ) - method = models.IntegerField(default=0, verbose_name="接口请求方法", null=True, blank=True, help_text="接口请求方法") + method = models.IntegerField(default=0, verbose_name="接口请求方法", null=True, blank=True, + help_text="接口请求方法") class Meta: db_table = table_prefix + "system_menu_button" @@ -219,7 +232,8 @@ class Dictionary(CoreModel): (7, "images"), ) label = models.CharField(max_length=100, blank=True, null=True, verbose_name="字典名称", help_text="字典名称") - value = models.CharField(max_length=200, blank=True, null=True, verbose_name="字典编号", help_text="字典编号/实际值") + value = models.CharField(max_length=200, blank=True, null=True, verbose_name="字典编号", + help_text="字典编号/实际值") parent = models.ForeignKey( to="self", related_name="sublist", @@ -232,7 +246,8 @@ class Dictionary(CoreModel): ) type = models.IntegerField(choices=TYPE_LIST, default=0, verbose_name="数据值类型", help_text="数据值类型") color = models.CharField(max_length=20, blank=True, null=True, verbose_name="颜色", help_text="颜色") - is_value = models.BooleanField(default=False, verbose_name="是否为value值", help_text="是否为value值,用来做具体值存放") + is_value = models.BooleanField(default=False, verbose_name="是否为value值", + help_text="是否为value值,用来做具体值存放") status = models.BooleanField(default=True, verbose_name="状态", help_text="状态") sort = models.IntegerField(default=1, verbose_name="显示排序", null=True, blank=True, help_text="显示排序") remark = models.CharField(max_length=2000, blank=True, null=True, verbose_name="备注", help_text="备注") @@ -254,14 +269,20 @@ class Dictionary(CoreModel): class OperationLog(CoreModel): - request_modular = models.CharField(max_length=64, verbose_name="请求模块", null=True, blank=True, help_text="请求模块") - request_path = models.CharField(max_length=400, verbose_name="请求地址", null=True, blank=True, help_text="请求地址") + request_modular = models.CharField(max_length=64, verbose_name="请求模块", null=True, blank=True, + help_text="请求模块") + request_path = models.CharField(max_length=400, verbose_name="请求地址", null=True, blank=True, + help_text="请求地址") request_body = models.TextField(verbose_name="请求参数", null=True, blank=True, help_text="请求参数") - request_method = models.CharField(max_length=8, verbose_name="请求方式", null=True, blank=True, help_text="请求方式") + request_method = models.CharField(max_length=8, verbose_name="请求方式", null=True, blank=True, + help_text="请求方式") request_msg = models.TextField(verbose_name="操作说明", null=True, blank=True, help_text="操作说明") - request_ip = models.CharField(max_length=32, verbose_name="请求ip地址", null=True, blank=True, help_text="请求ip地址") - request_browser = models.CharField(max_length=64, verbose_name="请求浏览器", null=True, blank=True, help_text="请求浏览器") - response_code = models.CharField(max_length=32, verbose_name="响应状态码", null=True, blank=True, help_text="响应状态码") + request_ip = models.CharField(max_length=32, verbose_name="请求ip地址", null=True, blank=True, + help_text="请求ip地址") + request_browser = models.CharField(max_length=64, verbose_name="请求浏览器", null=True, blank=True, + help_text="请求浏览器") + response_code = models.CharField(max_length=32, verbose_name="响应状态码", null=True, blank=True, + help_text="响应状态码") request_os = models.CharField(max_length=64, verbose_name="操作系统", null=True, blank=True, help_text="操作系统") json_result = models.TextField(verbose_name="返回信息", null=True, blank=True, help_text="返回信息") status = models.BooleanField(default=False, verbose_name="响应状态", help_text="响应状态") @@ -276,12 +297,16 @@ class OperationLog(CoreModel): def media_file_name(instance, filename): h = instance.md5sum basename, ext = os.path.splitext(filename) - return os.path.join("files", h[0:1], h[1:2], h + ext.lower()) + return PurePosixPath("files", h[:1], h[1:2], h + ext.lower()) class FileList(CoreModel): name = models.CharField(max_length=200, null=True, blank=True, verbose_name="名称", help_text="名称") - url = models.FileField(upload_to=media_file_name) + url = models.FileField(upload_to=media_file_name, null=True, blank=True,) + file_url = models.CharField(max_length=255, blank=True, verbose_name="文件地址", help_text="文件地址") + engine = models.CharField(max_length=100, default='local', blank=True, verbose_name="引擎", help_text="引擎") + mime_type = models.CharField(max_length=100, blank=True, verbose_name="Mime类型", help_text="Mime类型") + size = models.CharField(max_length=36, blank=True, verbose_name="文件大小", help_text="文件大小") md5sum = models.CharField(max_length=36, blank=True, verbose_name="文件md5", help_text="文件md5") def save(self, *args, **kwargs): @@ -290,6 +315,11 @@ class FileList(CoreModel): for chunk in self.url.chunks(): md5.update(chunk) self.md5sum = md5.hexdigest() + if not self.size: + self.size = self.url.size + if not self.file_url: + url = media_file_name(self,self.name) + self.file_url = f'media/{url}' super(FileList, self).save(*args, **kwargs) class Meta: @@ -302,7 +332,8 @@ class FileList(CoreModel): class Area(CoreModel): name = models.CharField(max_length=100, verbose_name="名称", help_text="名称") code = models.CharField(max_length=20, verbose_name="地区编码", help_text="地区编码", unique=True, db_index=True) - level = models.BigIntegerField(verbose_name="地区层级(1省份 2城市 3区县 4乡级)", help_text="地区层级(1省份 2城市 3区县 4乡级)") + level = models.BigIntegerField(verbose_name="地区层级(1省份 2城市 3区县 4乡级)", + help_text="地区层级(1省份 2城市 3区县 4乡级)") pinyin = models.CharField(max_length=255, verbose_name="拼音", help_text="拼音") initials = models.CharField(max_length=20, verbose_name="首字母", help_text="首字母") enable = models.BooleanField(default=True, verbose_name="是否启用", help_text="是否启用") @@ -335,8 +366,10 @@ class ApiWhiteList(CoreModel): (2, "PUT"), (3, "DELETE"), ) - method = models.IntegerField(default=0, verbose_name="接口请求方法", null=True, blank=True, help_text="接口请求方法") - enable_datasource = models.BooleanField(default=True, verbose_name="激活数据权限", help_text="激活数据权限", blank=True) + method = models.IntegerField(default=0, verbose_name="接口请求方法", null=True, blank=True, + help_text="接口请求方法") + enable_datasource = models.BooleanField(default=True, verbose_name="激活数据权限", help_text="激活数据权限", + blank=True) class Meta: db_table = table_prefix + "api_white_list" @@ -356,8 +389,8 @@ class SystemConfig(CoreModel): help_text="父级", ) title = models.CharField(max_length=50, verbose_name="标题", help_text="标题") - key = models.CharField(max_length=100, verbose_name="键", help_text="键", db_index=True) - value = models.JSONField(max_length=200, verbose_name="值", help_text="值", null=True, blank=True) + key = models.CharField(max_length=200, verbose_name="键", help_text="键", db_index=True) + value = models.JSONField(max_length=500, verbose_name="值", help_text="值", null=True, blank=True) sort = models.IntegerField(default=0, verbose_name="排序", help_text="排序", blank=True) status = models.BooleanField(default=True, verbose_name="启用状态", help_text="启用状态") data_options = models.JSONField(verbose_name="数据options", help_text="数据options", null=True, blank=True) @@ -383,7 +416,7 @@ class SystemConfig(CoreModel): choices=FORM_ITEM_TYPE_LIST, verbose_name="表单类型", help_text="表单类型", default=0, blank=True ) rule = models.JSONField(null=True, blank=True, verbose_name="校验规则", help_text="校验规则") - placeholder = models.CharField(max_length=50, null=True, blank=True, verbose_name="提示信息", help_text="提示信息") + placeholder = models.CharField(max_length=100, null=True, blank=True, verbose_name="提示信息", help_text="提示信息") setting = models.JSONField(null=True, blank=True, verbose_name="配置", help_text="配置") class Meta: @@ -415,8 +448,16 @@ class SystemConfig(CoreModel): class LoginLog(CoreModel): - LOGIN_TYPE_CHOICES = ((1, "普通登录"), (2, "微信扫码登录"), (3, "飞书扫码登录"), (4, "钉钉扫码登录")) - username = models.CharField(max_length=32, verbose_name="登录用户名", null=True, blank=True, help_text="登录用户名") + LOGIN_TYPE_CHOICES = ( + (1, "普通登录"), + (2, "普通扫码登录"), + (3, "微信扫码登录"), + (4, "飞书扫码登录"), + (5, "钉钉扫码登录"), + (6, "短信登录") + ) + username = models.CharField(max_length=150, verbose_name="登录用户名", null=True, blank=True, + help_text="登录用户名") ip = models.CharField(max_length=32, verbose_name="登录ip", null=True, blank=True, help_text="登录ip") agent = models.TextField(verbose_name="agent信息", null=True, blank=True, help_text="agent信息") browser = models.CharField(max_length=200, verbose_name="浏览器名", null=True, blank=True, help_text="浏览器名") @@ -428,11 +469,13 @@ class LoginLog(CoreModel): district = models.CharField(max_length=50, verbose_name="县区", null=True, blank=True, help_text="县区") isp = models.CharField(max_length=50, verbose_name="运营商", null=True, blank=True, help_text="运营商") area_code = models.CharField(max_length=50, verbose_name="区域代码", null=True, blank=True, help_text="区域代码") - country_english = models.CharField(max_length=50, verbose_name="英文全称", null=True, blank=True, help_text="英文全称") + country_english = models.CharField(max_length=50, verbose_name="英文全称", null=True, blank=True, + help_text="英文全称") country_code = models.CharField(max_length=50, verbose_name="简称", null=True, blank=True, help_text="简称") longitude = models.CharField(max_length=50, verbose_name="经度", null=True, blank=True, help_text="经度") latitude = models.CharField(max_length=50, verbose_name="纬度", null=True, blank=True, help_text="纬度") - login_type = models.IntegerField(default=1, choices=LOGIN_TYPE_CHOICES, verbose_name="登录类型", help_text="登录类型") + login_type = models.IntegerField(default=1, choices=LOGIN_TYPE_CHOICES, verbose_name="登录类型", + help_text="登录类型") class Meta: db_table = table_prefix + "system_login_log" @@ -442,14 +485,16 @@ class LoginLog(CoreModel): class MessageCenter(CoreModel): - title = models.CharField(max_length=100,verbose_name="标题",help_text="标题") - content = models.TextField(verbose_name="内容",help_text="内容") - target_type=models.IntegerField(default=0,verbose_name="目标类型",help_text="目标类型") - target_user = models.ManyToManyField(to=Users,related_name='user',through='MessageCenterTargetUser', through_fields=('messagecenter','users'),blank=True,verbose_name="目标用户",help_text="目标用户") - target_dept = models.ManyToManyField(to=Dept, blank=True, db_constraint=False, - verbose_name="目标部门", help_text="目标部门") - target_role = models.ManyToManyField(to=Role, blank=True, db_constraint=False, - verbose_name="目标角色", help_text="目标角色") + title = models.CharField(max_length=100, verbose_name="标题", help_text="标题") + content = models.TextField(verbose_name="内容", help_text="内容") + target_type = models.IntegerField(default=0, verbose_name="目标类型", help_text="目标类型") + target_user = models.ManyToManyField(to=Users, related_name='user', through='MessageCenterTargetUser', + through_fields=('messagecenter', 'users'), blank=True, verbose_name="目标用户", + help_text="目标用户") + target_dept = models.ManyToManyField(to=Dept, blank=True, db_constraint=False, + verbose_name="目标部门", help_text="目标部门") + target_role = models.ManyToManyField(to=Role, blank=True, db_constraint=False, + verbose_name="目标角色", help_text="目标角色") class Meta: db_table = table_prefix + "message_center" @@ -457,10 +502,13 @@ class MessageCenter(CoreModel): verbose_name_plural = verbose_name ordering = ("-create_datetime",) + class MessageCenterTargetUser(CoreModel): - users = models.ForeignKey(Users,related_name="target_user", on_delete=models.CASCADE,db_constraint=False,verbose_name="关联用户表",help_text="关联用户表") - messagecenter = models.ForeignKey(MessageCenter, on_delete=models.CASCADE,db_constraint=False,verbose_name="关联消息中心表",help_text="关联消息中心表") - is_read = models.BooleanField(default=False,blank=True,null=True,verbose_name="是否已读",help_text="是否已读") + users = models.ForeignKey(Users, related_name="target_user", on_delete=models.CASCADE, db_constraint=False, + verbose_name="关联用户表", help_text="关联用户表") + messagecenter = models.ForeignKey(MessageCenter, on_delete=models.CASCADE, db_constraint=False, + verbose_name="关联消息中心表", help_text="关联消息中心表") + is_read = models.BooleanField(default=False, blank=True, null=True, verbose_name="是否已读", help_text="是否已读") class Meta: db_table = table_prefix + "message_center_target_user" diff --git a/backend/dvadmin/system/urls.py b/backend/dvadmin/system/urls.py index fab79f9..65c9b31 100644 --- a/backend/dvadmin/system/urls.py +++ b/backend/dvadmin/system/urls.py @@ -4,6 +4,7 @@ from rest_framework import routers from dvadmin.system.views.api_white_list import ApiWhiteListViewSet from dvadmin.system.views.area import AreaViewSet from dvadmin.system.views.clause import PrivacyView, TermsServiceView +from dvadmin.system.views.datav import DataVViewSet from dvadmin.system.views.dept import DeptViewSet from dvadmin.system.views.dictionary import DictionaryViewSet from dvadmin.system.views.file_list import FileViewSet @@ -29,6 +30,7 @@ system_url.register(r'file', FileViewSet) system_url.register(r'api_white_list', ApiWhiteListViewSet) system_url.register(r'system_config', SystemConfigViewSet) system_url.register(r'message_center', MessageCenterViewSet) +system_url.register(r'datav', DataVViewSet) urlpatterns = [ path('system_config/save_content/', SystemConfigViewSet.as_view({'put': 'save_content'})), diff --git a/backend/dvadmin/system/views/datav.py b/backend/dvadmin/system/views/datav.py new file mode 100644 index 0000000..7ec88c7 --- /dev/null +++ b/backend/dvadmin/system/views/datav.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# @Time : 2023/4/14 15:49 +# @Author : harry +import datetime +import json +import re +import time + +from django.db.models import Count, Sum, Q +from django.db.models.functions import TruncMonth, TruncDay +from django.utils import timezone +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import GenericViewSet + +from conf.env import DATABASE_USER, DATABASE_NAME +from dvadmin.system.models import Users, LoginLog, FileList +from dvadmin.system.views.login_log import LoginLogSerializer +from dvadmin.utils.json_response import DetailResponse +from django.db import connection +from django.utils.timezone import now +from django.db.models import Count +from django.db.models.functions import TruncDate + +from dvadmin.utils.string_util import format_bytes + + +def jx_timestamp(): + cur_time = datetime.datetime.now() + a = datetime.datetime.strftime(cur_time, '%Y-%m-%d %H:%M:%S') + timeStamp = int(time.mktime(time.strptime(a, "%Y-%m-%d %H:%M:%S"))) + timeArray = time.localtime(timeStamp) + otherStyleTime = time.strftime("%Y-%m-%d %H:%M:%S", timeArray) + return otherStyleTime + + +class DataVViewSet(GenericViewSet): + queryset = LoginLog.objects.all() + serializer_class = LoginLogSerializer + extra_filter_backends = [] + ordering_fields = ['create_datetime'] + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def users_login_total(self, request): + """ + 用户登录总数数据 + :param request: + :return: + """ + login_total = LoginLog.objects.all().count() + return DetailResponse(data={"login_total": login_total}, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def users_total(self, request): + """ + 用户总数 + :param request: + :return: + """ + users_total = Users.objects.all().count() + return DetailResponse(data={"users_total": users_total, }, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def attachment_total(self, request): + """ + 附件统计数据 + :param request: + :return: + """ + count = FileList.objects.all().count() + data = FileList.objects.aggregate(sum_size=Sum('size')) + return DetailResponse(data={"count": count, "occupy_space": format_bytes(data.get('sum_size') or 0)}, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def database_total(self, request): + """ + 数据库统计数据 + :param request: + :return: + """ + count = len(connection.introspection.table_names()) + database_type = connection.settings_dict['ENGINE'] + sql = None + if 'mysql' in database_type: + sql = "SELECT SUM(data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = DATABASE()" + elif 'postgres' in database_type or 'psqlextra' in database_type: + sql = """SELECT SUM(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(tablename))) AS size FROM pg_tables WHERE schemaname = current_schema();""" + elif 'oracle' in database_type: + sql = "SELECT SUM(bytes) AS size FROM user_segments" + elif 'microsoft' in database_type: + sql = "SELECT SUM(size) * 8 AS size FROM sys.database_files" + else: + space = 0 + if sql: + with connection.cursor() as cursor: + try: + cursor.execute(sql) + result = cursor.fetchone() + space = result[0] + except Exception as e: + print(e) + space = '无权限' + return DetailResponse(data={"count": count, "space": format_bytes(space or 0)}, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def registered_user(self, request): + """ + 用户注册趋势 + :param request: + :return: + """ + today = datetime.datetime.today() + seven_days_ago = today - datetime.timedelta(days=30) + + users = Users.objects.filter(date_joined__gte=seven_days_ago).annotate(day=TruncDay('date_joined')).values( + 'day').annotate(count=Count('id')) + + result = [] + for i in range(30): + date = (today - datetime.timedelta(days=i)).strftime('%Y-%m-%d') + count = 0 + for user in users: + if user['day'] == date: + count = user['count'] + break + result.append({'day': date, 'count': count}) + + # users_last_month = Users.objects.filter(date_joined__gte=last_month).annotate(day=TruncDate('date_joined')).values('day').annotate(count=Count('id')) + return DetailResponse(data={"registered_user_list": result}, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def registered_user(self, request): + """ + 用户注册趋势 + :param request: + :return: + """ + day = 30 + today = datetime.datetime.today() + seven_days_ago = today - datetime.timedelta(days=day) + users = Users.objects.filter(create_datetime__gte=seven_days_ago).annotate( + day=TruncDay('create_datetime')).values( + 'day').annotate(count=Count('id')).order_by('-day') + result = [] + data_dict = {ele.get('day').strftime('%Y-%m-%d'): ele.get('count') for ele in users} + for i in range(day): + date = (today - datetime.timedelta(days=i)).strftime('%Y-%m-%d') + result.append({'day': date, 'count': data_dict[date] if date in data_dict else 0}) + result = sorted(result, key=lambda x: x['day']) + return DetailResponse(data={"registered_user_list": result}, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def login_user(self, request): + """ + 用户登录趋势 + :param request: + :return: + """ + day = 30 + today = datetime.datetime.today() + seven_days_ago = today - datetime.timedelta(days=day) + users = LoginLog.objects.filter(create_datetime__gte=seven_days_ago).annotate( + day=TruncDay('create_datetime')).values( + 'day').annotate(count=Count('id')).order_by('-day') + result = [] + data_dict = {ele.get('day').strftime('%Y-%m-%d'): ele.get('count') for ele in users} + for i in range(day): + date = (today - datetime.timedelta(days=i)).strftime('%Y-%m-%d') + result.append({'day': date, 'count': data_dict[date] if date in data_dict else 0}) + result = sorted(result, key=lambda x: x['day']) + return DetailResponse(data={"login_user": result}, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def users_active(self, request): + """ + 用户新增活跃数据统计 + :param request: + :return: + """ + today = datetime.date.today() + seven_days_ago = today - datetime.timedelta(days=6) + thirty_days_ago = today - datetime.timedelta(days=29) + + today_users = Users.objects.filter(date_joined__date=today).count() + today_logins = Users.objects.filter(last_login__date=today).count() + three_days_users = Users.objects.filter(date_joined__gte=seven_days_ago).count() + seven_days_users = Users.objects.filter(date_joined__gte=thirty_days_ago).count() + seven_days_active = Users.objects.filter(last_login__gte=seven_days_ago).values('last_login').annotate( + count=Count('id', distinct=True)).count() + monthly_active = Users.objects.filter(last_login__gte=thirty_days_ago).values('last_login').annotate( + count=Count('id', distinct=True)).count() + + data = { + 'today_users': today_users, + 'today_logins': today_logins, + 'three_days': three_days_users, + 'seven_days': seven_days_users, + 'seven_days_active': seven_days_active, + 'monthly_active': monthly_active + } + return DetailResponse(data=data, msg="获取成功") + + @action(methods=["GET"], detail=False, permission_classes=[IsAuthenticated]) + def login_region(self, request): + """ + 登录用户区域分布 + :param request: + :return: + """ + CHINA_PROVINCES = [ + {'name': '北京', 'code': '110000'}, + {'name': '天津', 'code': '120000'}, + {'name': '河北', 'code': '130000'}, + {'name': '山西', 'code': '140000'}, + {'name': '内蒙古', 'code': '150000'}, + {'name': '辽宁', 'code': '210000'}, + {'name': '吉林', 'code': '220000'}, + {'name': '黑龙江', 'code': '230000'}, + {'name': '上海', 'code': '310000'}, + {'name': '江苏', 'code': '320000'}, + {'name': '浙江', 'code': '330000'}, + {'name': '安徽', 'code': '340000'}, + {'name': '福建', 'code': '350000'}, + {'name': '江西', 'code': '360000'}, + {'name': '山东', 'code': '370000'}, + {'name': '河南', 'code': '410000'}, + {'name': '湖北', 'code': '420000'}, + {'name': '湖南', 'code': '430000'}, + {'name': '广东', 'code': '440000'}, + {'name': '广西', 'code': '450000'}, + {'name': '海南', 'code': '460000'}, + {'name': '重庆', 'code': '500000'}, + {'name': '四川', 'code': '510000'}, + {'name': '贵州', 'code': '520000'}, + {'name': '云南', 'code': '530000'}, + {'name': '西藏', 'code': '540000'}, + {'name': '陕西', 'code': '610000'}, + {'name': '甘肃', 'code': '620000'}, + {'name': '青海', 'code': '630000'}, + {'name': '宁夏', 'code': '640000'}, + {'name': '新疆', 'code': '650000'}, + {'name': '台湾', 'code': '710000'}, + {'name': '香港', 'code': '810000'}, + {'name': '澳门', 'code': '820000'}, + {'name': '未知区域', 'code': '000000'}, + ] + provinces = [x['name'] for x in CHINA_PROVINCES] + day = 30 + today = datetime.datetime.today() + seven_days_ago = today - datetime.timedelta(days=day) + province_data = LoginLog.objects.filter(create_datetime__gte=seven_days_ago).values('province').annotate( + count=Count('id')).order_by('-count') + province_dict = {p: 0 for p in provinces} + for ele in province_data: + if ele.get('province') in province_dict: + province_dict[ele.get('province')] += ele.get('count') + else: + province_dict['未知区域'] += ele.get('count') + data = [{'region': key, 'count': val} for key, val in province_dict.items()] + data = sorted(data, key=lambda x: x['count'], reverse=True) + return DetailResponse(data=data, msg="获取成功") diff --git a/backend/dvadmin/system/views/file_list.py b/backend/dvadmin/system/views/file_list.py index 82fc757..b845ba0 100644 --- a/backend/dvadmin/system/views/file_list.py +++ b/backend/dvadmin/system/views/file_list.py @@ -1,5 +1,9 @@ +import hashlib +import mimetypes + from rest_framework import serializers +from application import dispatch from dvadmin.system.models import FileList from dvadmin.utils.serializers import CustomModelSerializer from dvadmin.utils.viewset import CustomModelViewSet @@ -9,15 +13,49 @@ class FileSerializer(CustomModelSerializer): url = serializers.SerializerMethodField(read_only=True) def get_url(self, instance): - return 'media/' + str(instance.url) + # return 'media/' + str(instance.url) + return instance.file_url or (f'media/{str(instance.url)}') class Meta: model = FileList fields = "__all__" def create(self, validated_data): - validated_data['name'] = str(self.initial_data.get('file')) - validated_data['url'] = self.initial_data.get('file') + file_engine = dispatch.get_system_config_values("fileStorageConfig.file_engine") + file_backup = dispatch.get_system_config_values("fileStorageConfig.file_backup") + file = self.initial_data.get('file') + file_size = file.size + validated_data['name'] = file.name + validated_data['size'] = file_size + validated_data['md5sum'] = hashlib.md5().hexdigest() + validated_data['engine'] = file_engine + validated_data['mime_type'] = file.content_type + if file_backup: + validated_data['url'] = file + if file_engine =='oss': + from dvadmin_cloud_storage.views.aliyun import ali_oss_upload + file_path = ali_oss_upload(file) + if file_path: + validated_data['file_url'] = file_path + else: + raise ValueError("上传失败") + elif file_engine == 'cos': + from dvadmin_cloud_storage.views.tencent import tencent_cos_upload + file_path = tencent_cos_upload(file) + if file_path: + validated_data['file_url'] = file_path + else: + raise ValueError("上传失败") + else: + validated_data['url'] = file + # 审计字段 + try: + request_user = self.request.user + validated_data['dept_belong_id'] = request_user.dept.id + validated_data['creator'] = request_user.id + validated_data['modifier'] = request_user.id + except: + pass return super().create(validated_data) diff --git a/backend/dvadmin/system/views/login.py b/backend/dvadmin/system/views/login.py index 124f26b..a2258d3 100644 --- a/backend/dvadmin/system/views/login.py +++ b/backend/dvadmin/system/views/login.py @@ -70,6 +70,7 @@ class LoginSerializer(TokenObtainPairSerializer): default_error_messages = {"no_active_account": _("账号/密码错误")} def validate(self, attrs): + captcha = self.initial_data.get("captcha", None) if dispatch.get_system_config_values("base.captcha_state"): if captcha is None: diff --git a/backend/dvadmin/utils/models.py b/backend/dvadmin/utils/models.py index af7d71c..9f741b0 100644 --- a/backend/dvadmin/utils/models.py +++ b/backend/dvadmin/utils/models.py @@ -7,12 +7,15 @@ @Remark: 公共基础model类 """ import uuid +from datetime import date, timedelta from django.apps import apps -from django.db import models +from django.db import models, connection, ProgrammingError from django.db.models import QuerySet from application import settings +from application.dispatch import is_tenants_mode + table_prefix = settings.TABLE_PREFIX # 数据库表名前缀 @@ -40,10 +43,17 @@ class SoftDeleteManager(models.Manager): return SoftDeleteQuerySet(self.model, using=self._db).exclude(is_deleted=False) return SoftDeleteQuerySet(self.model).exclude(is_deleted=True) - def get_by_natural_key(self,name): + def get_by_natural_key(self, name): return SoftDeleteQuerySet(self.model).get(username=name) +def get_month_range(start_day, end_day): + months = (end_day.year - start_day.year) * 12 + end_day.month - start_day.month + month_range = ['%s-%s-01' % (start_day.year + mon // 12, str(mon % 12 + 1).zfill(2)) + for mon in range(start_day.month - 1, start_day.month + months)] + return month_range + + class SoftDeleteModel(models.Model): """ 软删除模型 @@ -73,10 +83,13 @@ class CoreModel(models.Model): id = models.BigAutoField(primary_key=True, help_text="Id", verbose_name="Id") description = models.CharField(max_length=255, verbose_name="描述", null=True, blank=True, help_text="描述") creator = models.ForeignKey(to=settings.AUTH_USER_MODEL, related_query_name='creator_query', null=True, - verbose_name='创建人', help_text="创建人", on_delete=models.SET_NULL, db_constraint=False) + verbose_name='创建人', help_text="创建人", on_delete=models.SET_NULL, + db_constraint=False) modifier = models.CharField(max_length=255, null=True, blank=True, help_text="修改人", verbose_name="修改人") - dept_belong_id = models.CharField(max_length=255, help_text="数据归属部门", null=True, blank=True, verbose_name="数据归属部门") - update_datetime = models.DateTimeField(auto_now=True, null=True, blank=True, help_text="修改时间", verbose_name="修改时间") + dept_belong_id = models.CharField(max_length=255, help_text="数据归属部门", null=True, blank=True, + verbose_name="数据归属部门") + update_datetime = models.DateTimeField(auto_now=True, null=True, blank=True, help_text="修改时间", + verbose_name="修改时间") create_datetime = models.DateTimeField(auto_now_add=True, null=True, blank=True, help_text="创建时间", verbose_name="创建时间") @@ -86,6 +99,113 @@ class CoreModel(models.Model): verbose_name_plural = verbose_name +class AddPostgresPartitionedBase: + """ + pgsql表分表基类 + """ + + @classmethod + def add_hash_partition(cls, number=36): + """ + 创建分区表 + :return: + """ + if cls.PartitioningMeta.method != 'hash': + raise ProgrammingError("表分区错误,无法进行分区") + schema_editor = connection.schema_editor() + if is_tenants_mode(): + schema_editor.sql_add_hash_partition = f'CREATE TABLE "{connection.tenant.schema_name}".%s PARTITION OF "{connection.tenant.schema_name}".%s FOR VALUES WITH (MODULUS %s, REMAINDER %s)' + for item in range(number): + try: + schema_editor.add_hash_partition( + model=cls, + name="_" + str(item), + modulus=number, + remainder=item, + ) + except ProgrammingError as e: + print(f"{cls.__name__}分表失败:" + str(e).rstrip('\n')) + return + + @classmethod + def add_range_day_partition(cls, day=7): + """ + 按照创建时间"天"分表 + :return: + """ + if cls.PartitioningMeta.method != 'range': + raise ProgrammingError("表分区错误,无法进行分区") + day_before = date.today().strftime("%Y-%m-%d") + schema_editor = connection.schema_editor() + if is_tenants_mode(): + schema_editor.sql_add_range_partition = ( + f'CREATE TABLE "{connection.tenant.schema_name}".%s PARTITION OF "{connection.tenant.schema_name}".%s FOR VALUES FROM (%s) TO (%s)' + ) + for index in range(day): + try: + day_following = (date.today() + timedelta(days=index + 1)).strftime("%Y-%m-%d") + schema_editor.add_range_partition( + model=cls, + name=f"{day_before}_{day_following}", + from_values=day_before, + to_values=day_following, + ) + day_before = day_following + except ProgrammingError as e: + print(f"{cls.__name__}分表失败:" + str(e).rstrip('\n')) + return + + @classmethod + def add_range_month_partition(cls, start_date, end_date): + """ + 按照创建时间"月"分表 + :return: + """ + if cls.PartitioningMeta.method != 'range': + raise ProgrammingError("表分区错误,无法进行分区") + range_month_partition_list = get_month_range(start_date, end_date) + schema_editor = connection.schema_editor() + if is_tenants_mode(): + schema_editor.sql_add_range_partition = ( + f'CREATE TABLE "{connection.tenant.schema_name}".%s PARTITION OF "{connection.tenant.schema_name}".%s FOR VALUES FROM (%s) TO (%s)' + ) + for index, ele in enumerate(range_month_partition_list): + if index == 0: + continue + try: + schema_editor.add_range_partition( + model=cls, + name=f"{range_month_partition_list[index - 1][:-3]}_{ele[:-3]}", + from_values=range_month_partition_list[index - 1], + to_values=ele, + ) + except ProgrammingError as e: + print(f"{cls.__name__}分表失败:" + str(e).rstrip('\n')) + return + + @classmethod + def add_list_partition(cls, unique_value): + """ + 按照某个值进行分区 + :param unique_value: + :return: + """ + if cls.PartitioningMeta.method != 'list': + raise ProgrammingError("表分区错误,无法进行分区") + schema_editor = connection.schema_editor() + if is_tenants_mode(): + schema_editor.sql_add_list_partition = ( + f'CREATE TABLE "{connection.tenant.schema_name}".%s PARTITION OF "{connection.tenant.schema_name}".%s FOR VALUES IN (%s)' + ) + try: + schema_editor.add_list_partition( + model=cls, + name=f"_{unique_value}", + values=[unique_value], + ) + except ProgrammingError as e: + print(f"{cls.__name__}分表失败:" + str(e).rstrip('\n')) + return def get_all_models_objects(model_name=None): @@ -111,4 +231,4 @@ def get_all_models_objects(model_name=None): settings.ALL_MODELS_OBJECTS.setdefault(item.__name__, {"table": table, "object": item}) if model_name: return settings.ALL_MODELS_OBJECTS[model_name] or {} - return settings.ALL_MODELS_OBJECTS or {} \ No newline at end of file + return settings.ALL_MODELS_OBJECTS or {} diff --git a/backend/dvadmin/utils/request_util.py b/backend/dvadmin/utils/request_util.py index a87ac27..441ab5d 100644 --- a/backend/dvadmin/utils/request_util.py +++ b/backend/dvadmin/utils/request_util.py @@ -202,7 +202,7 @@ def get_ip_analysis(ip): return data -def save_login_log(request): +def save_login_log(request, login_type=1): """ 保存登录日志 :return: @@ -214,6 +214,7 @@ def save_login_log(request): analysis_data['agent'] = str(parse(request.META['HTTP_USER_AGENT'])) analysis_data['browser'] = get_browser(request) analysis_data['os'] = get_os(request) + analysis_data['login_type'] = login_type analysis_data['creator_id'] = request.user.id analysis_data['dept_belong_id'] = getattr(request.user, 'dept_id', '') LoginLog.objects.create(**analysis_data) diff --git a/backend/dvadmin/utils/string_util.py b/backend/dvadmin/utils/string_util.py index d28a948..6efce7a 100644 --- a/backend/dvadmin/utils/string_util.py +++ b/backend/dvadmin/utils/string_util.py @@ -8,6 +8,7 @@ """ import hashlib import random +from decimal import Decimal CHAR_SET = ("2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H", @@ -40,3 +41,25 @@ def has_md5(str, salt='123456'): md.update(str.encode()) res = md.hexdigest() return res + + +def format_bytes(size, decimals=2): + """ + 格式化字节大小 + :param size: + :param decimals: + :return: + """ + if isinstance(size, (str)) and size.isnumeric(): + size = int(size) + elif not isinstance(size, (int, float, Decimal)): + return size + if size == 0: + return "0 Bytes" + units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + i = 0 + while size >= 1024: + size /= 1024 + i += 1 + + return f"{round(size, decimals)} {units[i]}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 17727f8..d07e365 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,8 +8,10 @@ django-comment-migrate==0.1.7 django-cors-headers==3.10.1 django-filter==22.1 django-ranged-response==0.2.0 +django-redis==5.2.0 django-restql==0.15.3 django-simple-captcha==0.5.17 +django-tenants==3.4.8 django-timezone-field==4.2.3 djangorestframework==3.14.0 djangorestframework-simplejwt==5.2.2 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ebaca02 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "django-vue-admin", + "lockfileVersion": 2, + "requires": true, + "packages": {} +} diff --git a/web/package.json b/web/package.json index 086e51b..b550393 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "django-vue-admin", - "version": "2.1.2", + "version": "2.1.3", "scripts": { "serve": "vue-cli-service serve --open", "start": "npm run serve", @@ -23,7 +23,7 @@ "d2-crud-x": "^2.17.9", "d2p-extends": "^2.17.9", "dayjs": "^1.8.17", - "echarts": "^5.1.2", + "echarts": "^5.4.2", "el-phone-number-input": "^1.1.5", "element-ui": "^2.15.5", "faker": "^4.1.0", @@ -40,7 +40,9 @@ "screenfull": "^5.0.2", "sortablejs": "^1.10.1", "ua-parser-js": "^0.7.20", + "viser-vue": "^2.4.8", "vue": "2.7.14", + "vue-echarts": "^6.5.4", "vue-grid-layout": "^2.4.0", "vue-i18n": "^8.15.1", "vue-infinite-scroll": "^2.0.2", diff --git a/web/src/libs/util.js b/web/src/libs/util.js index cf7e6cd..8c65c12 100644 --- a/web/src/libs/util.js +++ b/web/src/libs/util.js @@ -172,5 +172,22 @@ util.ArrayToTree = function (rootList, parentValue, parentName, list) { } return list } +// 格式化字节大小 +util.formatBytes = function (bytes, decimals = 2) { + if (isNaN(bytes)) { + return bytes + } + + if (bytes === 0) { + return '0 Bytes' + } + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] +} export default util diff --git a/web/src/main.js b/web/src/main.js index c5a81e0..fcdba4d 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -36,13 +36,15 @@ import md5 from 'js-md5' import websocket from '@/api/websocket' import util from '@/libs/util' +// 引入echarts +import * as echarts from 'echarts' // 注册echarts组件 // 核心插件 Vue.use(d2Admin) Vue.use(VXETable) Vue.prototype.$md5 = md5 Vue.prototype.$util = util Vue.prototype.$websocket = websocket - +Vue.prototype.$echarts = echarts new Vue({ router, store, diff --git a/web/src/views/dashboard/workbench/components/about.vue b/web/src/views/dashboard/workbench/components/about.vue index 624a268..2c274f6 100644 --- a/web/src/views/dashboard/workbench/components/about.vue +++ b/web/src/views/dashboard/workbench/components/about.vue @@ -1,6 +1,6 @@