Просмотр исходного кода

Merge remote-tracking branch 'upstream/next'

X5ZJ 2 лет назад
Родитель
Сommit
36512a6cb9
41 измененных файлов с 643 добавлено и 172 удалено
  1. 8 2
      Admin.NET/Admin.NET.Application/Configuration/Database.json
  2. 10 10
      Admin.NET/Admin.NET.Core/Admin.NET.Core.csproj
  3. 69 0
      Admin.NET/Admin.NET.Core/Attribute/DictAttribute.cs
  4. 6 1
      Admin.NET/Admin.NET.Core/Const/CacheConst.cs
  5. 10 0
      Admin.NET/Admin.NET.Core/Const/CommonConst.cs
  6. 1 1
      Admin.NET/Admin.NET.Core/Entity/SysOrg.cs
  7. 41 0
      Admin.NET/Admin.NET.Core/Extension/HttpContextExtension.cs
  8. 11 25
      Admin.NET/Admin.NET.Core/Logging/DatabaseLoggingWriter.cs
  9. 2 2
      Admin.NET/Admin.NET.Core/Logging/ElasticSearchLoggingWriter.cs
  10. 18 16
      Admin.NET/Admin.NET.Core/SeedData/SysConfigSeedData.cs
  11. 5 0
      Admin.NET/Admin.NET.Core/Service/Auth/Dto/LoginUserOutput.cs
  12. 34 29
      Admin.NET/Admin.NET.Core/Service/Auth/SysAuthService.cs
  13. 1 0
      Admin.NET/Admin.NET.Core/Service/Config/SysConfigService.cs
  14. 2 1
      Admin.NET/Admin.NET.Core/Service/DataBase/SysDatabaseService.cs
  15. 46 11
      Admin.NET/Admin.NET.Core/Service/Dict/SysDictDataService.cs
  16. 11 1
      Admin.NET/Admin.NET.Core/Service/Dict/SysDictTypeService.cs
  17. 1 1
      Admin.NET/Admin.NET.Core/Service/File/SysFileService.cs
  18. 6 3
      Admin.NET/Admin.NET.Core/Service/Job/DbJobPersistence.cs
  19. 6 0
      Admin.NET/Admin.NET.Core/Service/Org/Dto/OrgInput.cs
  20. 1 0
      Admin.NET/Admin.NET.Core/Service/User/Dto/UserInput.cs
  21. 3 3
      Admin.NET/Admin.NET.Core/Service/User/SysUserService.cs
  22. 1 1
      Admin.NET/Admin.NET.Core/Service/Wechat/SysWechatService.cs
  23. 6 5
      Admin.NET/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs
  24. 45 0
      Admin.NET/Admin.NET.Core/Util/CommonUtil.cs
  25. 7 6
      Admin.NET/Admin.NET.Web.Core/Handlers/JwtHandler.cs
  26. 20 5
      Admin.NET/Admin.NET.Web.Core/Startup.cs
  27. 2 2
      Admin.NET/Admin.NET.Web.Entry/wwwroot/Template/Service.cs.vm
  28. 3 4
      Admin.NET/Admin.NET.Web.Entry/wwwroot/Template/editDialog.vue.vm
  29. 4 4
      Admin.NET/Admin.NET.Web.Entry/wwwroot/Template/index.vue.vm
  30. 7 1
      Admin.NET/Plugins/Admin.NET.Plugin.GoView/Entity/GoViewProData.cs
  31. 6 1
      Admin.NET/Plugins/Admin.NET.Plugin.GoView/Service/GoViewPro/Dto/GoViewProOutput.cs
  32. 72 1
      Admin.NET/Plugins/Admin.NET.Plugin.GoView/Service/GoViewPro/GoViewProService.cs
  33. 1 1
      Admin.NET/Plugins/Admin.NET.Plugin.ReZero/Admin.NET.Plugin.ReZero.csproj
  34. 13 13
      Web/package.json
  35. 55 13
      Web/src/components/table/index.vue
  36. 12 1
      Web/src/stores/userInfo.ts
  37. 3 3
      Web/src/utils/watermark.ts
  38. 70 0
      Web/src/views/home/widgets/components/commit.vue
  39. 6 2
      Web/src/views/home/widgets/index.vue
  40. 9 2
      Web/src/views/system/database/index.vue
  41. 9 1
      Web/src/views/system/job/component/editJobTrigger.vue

+ 8 - 2
Admin.NET/Admin.NET.Application/Configuration/Database.json

@@ -1,14 +1,20 @@
 {
   "$schema": "https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json",
 
-  // 详细数据库配置见SqlSugar官网(第一个为默认库)
+  // 详细数据库配置见SqlSugar官网(第一个为默认库),极力推荐 PostgreSQL 数据库
+  // 数据库连接字符串参考地址:https://www.connectionstrings.com/
   "DbConnection": {
     "EnableConsoleSql": true, // 启用控制台打印SQL
     "ConnectionConfigs": [
       {
         //"ConfigId": "1300000000001", // 默认库标识-禁止修改
         "DbType": "Sqlite", // MySql、SqlServer、Sqlite、Oracle、PostgreSQL、Dm、Kdbndp、Oscar、MySqlConnector、Access、OpenGauss、QuestDB、HG、ClickHouse、GBase、Odbc、Custom
-        "ConnectionString": "DataSource=./Admin.NET.db", // 库连接字符串
+        "ConnectionString": "DataSource=./Admin.NET.db", // Sqlite 库连接字符串
+        //"ConnectionString": "PORT=5432;DATABASE=xxx;HOST=localhost;PASSWORD=xxx;USER ID=xxx", // PostgreSQL 库连接字符串
+        //"ConnectionString": "Server=localhost;Database=xxx;Uid=xxx;Pwd=xxx;SslMode=None;", // MySql 库连接字符串",
+        //"ConnectionString": "User Id=xxx; Password=xxx; Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=localhost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)))", // Oracle 库连接字符串
+        //"ConnectionString": "Server=localhost;Database=xxx;User Id=xxx;Password=xxx;", // SqlServer 库连接字符串
+
         //"SlaveConnectionConfigs": [ // 读写分离/主从
         //	{
         //		"HitRate": 10,

+ 10 - 10
Admin.NET/Admin.NET.Core/Admin.NET.Core.csproj

@@ -13,14 +13,14 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="2.0.24" />
+    <PackageReference Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="3.0.0" />
     <PackageReference Include="AngleSharp" Version="1.1.2" />
     <PackageReference Include="AspectCore.Extensions.Reflection" Version="2.4.0" />
     <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
-    <PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.14.2" />
-    <PackageReference Include="Furion.Extras.Authentication.JwtBearer" Version="4.9.4.1" />
-    <PackageReference Include="Furion.Extras.ObjectMapper.Mapster" Version="4.9.4.1" />
-    <PackageReference Include="Furion.Pure" Version="4.9.4.1" />
+    <PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.14.3" />
+    <PackageReference Include="Furion.Extras.Authentication.JwtBearer" Version="4.9.4.2" />
+    <PackageReference Include="Furion.Extras.ObjectMapper.Mapster" Version="4.9.4.2" />
+    <PackageReference Include="Furion.Pure" Version="4.9.4.2" />
     <PackageReference Include="IPTools.China" Version="1.6.0" />
     <PackageReference Include="IPTools.International" Version="1.6.0" />
     <PackageReference Include="Magicodes.IE.Excel" Version="2.7.5.1" />
@@ -34,10 +34,10 @@
     <PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.2" />
     <PackageReference Include="SKIT.FlurlHttpClient.Wechat.Api" Version="3.3.0" />
     <PackageReference Include="SKIT.FlurlHttpClient.Wechat.TenpayV3" Version="3.5.0" />
-    <PackageReference Include="SqlSugarCore" Version="5.1.4.158" />
+    <PackageReference Include="SqlSugarCore" Version="5.1.4.159" />
     <PackageReference Include="SSH.NET" Version="2024.0.0" />
-    <PackageReference Include="System.Linq.Dynamic.Core" Version="1.4.0" />
-    <PackageReference Include="TencentCloudSDK.Sms" Version="3.0.1028" />
+    <PackageReference Include="System.Linq.Dynamic.Core" Version="1.4.1" />
+    <PackageReference Include="TencentCloudSDK.Sms" Version="3.0.1033" />
     <PackageReference Include="UAParser" Version="3.1.47" />
     <PackageReference Include="Yitter.IdGenerator" Version="1.0.14" />
   </ItemGroup>
@@ -53,8 +53,8 @@
   </ItemGroup>
 
   <ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
-    <PackageReference Include="AspNet.Security.OAuth.Gitee" Version="8.0.0" />
-    <PackageReference Include="AspNet.Security.OAuth.Weixin" Version="8.0.0" />
+    <PackageReference Include="AspNet.Security.OAuth.Gitee" Version="8.1.0" />
+    <PackageReference Include="AspNet.Security.OAuth.Weixin" Version="8.1.0" />
     <PackageReference Include="Lazy.Captcha.Core" Version="2.0.7" />
     <PackageReference Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="8.0.6" />
     <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.6" />

+ 69 - 0
Admin.NET/Admin.NET.Core/Attribute/DictAttribute.cs

@@ -0,0 +1,69 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+namespace Admin.NET.Core;
+
+/// <summary>
+/// 字典值合规性校验特性
+/// </summary>
+[SuppressSniffer]
+[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true, Inherited = true)]
+public class DictAttribute : ValidationAttribute, ITransient
+{
+    /// <summary>
+    /// 字典值合规性校验特性
+    /// </summary>
+    /// <param name="dictTypeCode"></param>
+    /// <param name="errorMessage"></param>
+    public DictAttribute(string dictTypeCode, string errorMessage = "字典值不合法!")
+    {
+        DictTypeCode = dictTypeCode;
+        ErrorMessage = errorMessage;
+    }
+
+    /// <summary>
+    /// 字典值合规性校验
+    /// </summary>
+    /// <param name="value"></param>
+    /// <param name="validationContext"></param>
+    /// <returns></returns>
+    protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
+    {
+        var valueAsString = value?.ToString();
+
+        // 判断是否允许空值
+        if (AllowNullValue && value == null) return ValidationResult.Success;
+
+        // 是否忽略空字符串
+        if (AllowEmptyStrings && string.IsNullOrEmpty(valueAsString)) return ValidationResult.Success;
+
+        var sysDictDataServiceProvider = validationContext.GetRequiredService<SysDictDataService>();
+        var dictDataList = sysDictDataServiceProvider.GetDataList(DictTypeCode).Result;
+
+        // 使用HashSet来提高查找效率
+        var dictCodes = new HashSet<string>(dictDataList.Select(u => u.Code));
+
+        if (!dictCodes.Contains(valueAsString))
+            return new ValidationResult($"提示:{ErrorMessage}|字典【{DictTypeCode}】不包含【{valueAsString}】!");
+        else
+            return ValidationResult.Success;
+    }
+
+    /// <summary>
+    /// 字典编码
+    /// </summary>
+    public string DictTypeCode { get; set; }
+
+    /// <summary>
+    /// 是否允许空字符串
+    /// </summary>
+    public bool AllowEmptyStrings { get; set; } = false;
+
+    /// <summary>
+    /// 允许空值,有值才验证,默认 false
+    /// </summary>
+    public bool AllowNullValue { get; set; } = false;
+}

+ 6 - 1
Admin.NET/Admin.NET.Core/Const/CacheConst.cs

@@ -44,7 +44,7 @@ public class CacheConst
     /// <summary>
     /// 密码错误次数缓存
     /// </summary>
-    public const string KeyErrorPasswordCount = "sys_errorPasswordCount:";
+    public const string KeyPasswordErrorTimes = "sys_password_error_times:";
 
     /// <summary>
     /// 租户缓存
@@ -85,4 +85,9 @@ public class CacheConst
     /// 系统配置缓存
     /// </summary>
     public const string KeyConfig = "sys_config:";
+
+    /// <summary>
+    /// 系统字典缓存
+    /// </summary>
+    public const string KeyDict = "sys_dict:";
 }

+ 10 - 0
Admin.NET/Admin.NET.Core/Const/CommonConst.cs

@@ -22,6 +22,11 @@ public class CommonConst
     /// </summary>
     public const string SysPassword = "sys_password";
 
+    /// <summary>
+    /// 密码最大错误次数
+    /// </summary>
+    public const string SysPasswordMaxErrorTimes = "sys_password_max_error_times";
+
     /// <summary>
     /// 登录二次验证
     /// </summary>
@@ -77,6 +82,11 @@ public class CommonConst
     /// </summary>
     public const string SysDomainLogin = "sys_domain_login";
 
+    /// <summary>
+    /// 开启记录数据校验日志
+    /// </summary>
+    public const string SysValidationLog = "sys_validation_log";
+
     /// <summary>
     /// 日志分组名称
     /// </summary>

+ 1 - 1
Admin.NET/Admin.NET.Core/Entity/SysOrg.cs

@@ -47,7 +47,7 @@ public partial class SysOrg : EntityTenant
     /// </summary>
     [SugarColumn(ColumnDescription = "机构类型", Length = 64)]
     [MaxLength(64)]
-    public string? Type { get; set; }
+    public virtual string? Type { get; set; }
 
     /// <summary>
     /// 负责人Id

+ 41 - 0
Admin.NET/Admin.NET.Core/Extension/HttpContextExtension.cs

@@ -0,0 +1,41 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+using System.Net;
+
+namespace Admin.NET.Core;
+
+/// <summary>
+/// HttpContext拓展
+/// </summary>
+[SuppressSniffer]
+public static partial class HttpContextExtension
+{
+    /// <summary>
+    /// 获取客户端真实 IP 地址
+    /// </summary>
+    /// <returns>bool</returns>
+    public static string GetRemoteIp(this HttpContext httpContext)
+    {
+        var ip = string.Empty;
+        try
+        {
+            // 从 X-Forwarded-For 头获取 IP
+            ip = httpContext.Request.Headers["X-Forwarded-For"];
+            if (string.IsNullOrEmpty(ip))
+            {
+                ip = httpContext.GetRemoteIpAddressToIPv4();
+            }
+            // 验证 IP 地址有效性
+            if (!IPAddress.TryParse(ip, out _))
+            {
+                ip = null;
+            }
+        }
+        catch { }
+        return ip;
+    }
+}

+ 11 - 25
Admin.NET/Admin.NET.Core/Logging/DatabaseLoggingWriter.cs

@@ -52,8 +52,8 @@ public class DatabaseLoggingWriter : IDatabaseLoggingWriter, IDisposable
         }
 
         var loggingMonitor = JSON.Deserialize<dynamic>(jsonStr);
-        // 记录数据校验日志
-        if (loggingMonitor.validation != null) return;
+        // 记录数据校验日志
+        if (loggingMonitor.validation != null && !await _sysConfigService.GetConfigValue<bool>(CommonConst.SysValidationLog)) return;
 
         // 获取当前操作者
         string account = "", realName = "", userId = "", tenantId = "";
@@ -72,8 +72,14 @@ public class DatabaseLoggingWriter : IDatabaseLoggingWriter, IDisposable
             }
         }
 
-        string remoteIPv4 = loggingMonitor.remoteIPv4;
-        (string ipLocation, double? longitude, double? latitude) = GetIpAddress(remoteIPv4);
+        // 优先获取 X-Forwarded-For 头部信息携带的IP地址(如nginx代理配置转发)
+        var remoteIPv4 = ((JArray)loggingMonitor.requestHeaders).OfType<JObject>()
+            .FirstOrDefault(header => (string)header["key"] == "X-Forwarded-For")?["value"]?.ToString();
+
+        if (string.IsNullOrEmpty(remoteIPv4))
+            remoteIPv4 = loggingMonitor.remoteIPv4;
+
+        (string ipLocation, double? longitude, double? latitude) = CommonUtil.GetIpAddress(remoteIPv4);
 
         var browser = "";
         var os = "";
@@ -123,7 +129,7 @@ public class DatabaseLoggingWriter : IDatabaseLoggingWriter, IDisposable
                 // 将异常日志发送到邮件
                 if (await _sysConfigService.GetConfigValue<bool>(CommonConst.SysErrorMail))
                 {
-                    await App.GetRequiredService<IEventPublisher>().PublishAsync(CommonConst.SendErrorMail, loggingMonitor.exception);
+                    await App.GetRequiredService<IEventPublisher>().PublishAsync(CommonConst.SendErrorMail, logMsg.Exception ?? loggingMonitor.exception);
                 }
 
                 return;
@@ -195,26 +201,6 @@ public class DatabaseLoggingWriter : IDatabaseLoggingWriter, IDisposable
         }
     }
 
-    /// <summary>
-    /// 解析IP地址
-    /// </summary>
-    /// <param name="ip"></param>
-    /// <returns></returns>
-    internal static (string ipLocation, double? longitude, double? latitude) GetIpAddress(string ip)
-    {
-        try
-        {
-            var ipInfo = IpTool.SearchWithI18N(ip); // 国际化查询,默认中文 中文zh-CN、英文en
-            var addressList = new List<string>() { ipInfo.Country, ipInfo.Province, ipInfo.City, ipInfo.NetworkOperator };
-            return (string.Join(" ", addressList.Where(u => u != "0" && !string.IsNullOrWhiteSpace(u)).ToList()), ipInfo.Longitude, ipInfo.Latitude); // 去掉0及空并用空格连接
-        }
-        catch
-        {
-            // 不做处理
-        }
-        return ("未知", 0, 0);
-    }
-
     /// <summary>
     /// 释放服务作用域
     /// </summary>

+ 2 - 2
Admin.NET/Admin.NET.Core/Logging/ElasticSearchLoggingWriter.cs

@@ -1,4 +1,4 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
 //
 // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
 //
@@ -57,7 +57,7 @@ public class ElasticSearchLoggingWriter : IDatabaseLoggingWriter, IDisposable
         }
 
         string remoteIPv4 = loggingMonitor.remoteIPv4;
-        (string ipLocation, double? longitude, double? latitude) = DatabaseLoggingWriter.GetIpAddress(remoteIPv4);
+        (string ipLocation, double? longitude, double? latitude) = CommonUtil.GetIpAddress(remoteIPv4);
 
         var sysLogOp = new SysLogOp
         {

+ 18 - 16
Admin.NET/Admin.NET.Core/SeedData/SysConfigSeedData.cs

@@ -21,22 +21,24 @@ public class SysConfigSeedData : ISqlSugarEntitySeedData<SysConfig>
         {
             new SysConfig{ Id=1300000000101, Name="演示环境", Code="sys_demo", Value="False", SysFlag=YesNoEnum.Y, Remark="演示环境", OrderNo=10, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
             new SysConfig{ Id=1300000000111, Name="默认密码", Code="sys_password", Value="123456", SysFlag=YesNoEnum.Y, Remark="默认密码", OrderNo=20, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000121, Name="记录操作日志", Code="sys_oplog", Value="True", SysFlag=YesNoEnum.Y, Remark="是否记录操作日志", OrderNo=30, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000131, Name="开启单设备登录", Code="sys_single_login", Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启单设备登录", OrderNo=40, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000141, Name="开启登录二次验证", Code="sys_second_ver", Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启登录二次验证", OrderNo=50, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000151, Name="开启图形验证码", Code="sys_captcha", Value="True", SysFlag=YesNoEnum.Y, Remark="是否开启图形验证码", OrderNo=60, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000161, Name="Token过期时间", Code="sys_token_expire", Value="10080", SysFlag=YesNoEnum.Y, Remark="Token过期时间(分钟)", OrderNo=70, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000171, Name="刷新Token过期时间", Code="sys_refresh_token_expire", Value="20160", SysFlag=YesNoEnum.Y, Remark="刷新Token过期时间(分钟)(一般 refresh_token 的有效时间 > 2 * access_token 的有效时间)", OrderNo=80, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000181, Name="发送异常日志邮件", Code="sys_error_mail", Value="True", SysFlag=YesNoEnum.Y, Remark="是否发送异常日志邮件", OrderNo=90, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000191, Name="开启域登录验证", Code="sys_domain_login", Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启域登录验证", OrderNo=100, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000211, Name="系统主标题", Code="sys_web_title", Value="Admin.NET", SysFlag=YesNoEnum.Y, Remark="系统主标题", OrderNo=110, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000221, Name="系统副标题", Code="sys_web_viceTitle", Value="Admin.NET", SysFlag=YesNoEnum.Y, Remark="系统副标题", OrderNo=120, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000231, Name="系统描述", Code="sys_web_viceDesc", Value="站在巨人肩膀上的 .NET 通用权限开发框架", SysFlag=YesNoEnum.Y, Remark="系统描述", OrderNo=130, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000241, Name="水印内容", Code="sys_web_watermark", Value="Admin.NET", SysFlag=YesNoEnum.Y, Remark="水印内容", OrderNo=140, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000251, Name="版权说明", Code="sys_web_copyright", Value="Copyright © 2021-present Admin.NET All rights reserved.", SysFlag=YesNoEnum.Y, Remark="版权说明", OrderNo=150, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000261, Name="系统图标", Code="sys_web_logo", Value="/Upload/logo.png", SysFlag=YesNoEnum.Y, Remark="系统图标", OrderNo=160, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000271, Name="ICP备案号", Code="sys_web_icp", Value="省ICP备12345678号", SysFlag=YesNoEnum.Y, Remark="ICP备案号", OrderNo=170, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000281, Name="ICP地址", Code="sys_web_icpUrl", Value="https://beian.miit.gov.cn", SysFlag=YesNoEnum.Y, Remark="ICP地址", OrderNo=180, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000121, Name="密码最大错误次数", Code="sys_password_max_error_times", Value="5", SysFlag=YesNoEnum.Y, Remark="允许密码最大输入错误次数", OrderNo=30, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000131, Name="记录操作日志", Code="sys_oplog", Value="True", SysFlag=YesNoEnum.Y, Remark="是否记录操作日志", OrderNo=40, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000141, Name="开启单设备登录", Code="sys_single_login", Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启单设备登录", OrderNo=50, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000151, Name="开启登录二次验证", Code="sys_second_ver", Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启登录二次验证", OrderNo=60, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000161, Name="开启图形验证码", Code="sys_captcha", Value="True", SysFlag=YesNoEnum.Y, Remark="是否开启图形验证码", OrderNo=+0, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000171, Name="Token过期时间", Code="sys_token_expire", Value="10080", SysFlag=YesNoEnum.Y, Remark="Token过期时间(分钟)", OrderNo=80, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000181, Name="刷新Token过期时间", Code="sys_refresh_token_expire", Value="20160", SysFlag=YesNoEnum.Y, Remark="刷新Token过期时间(分钟)(一般 refresh_token 的有效时间 > 2 * access_token 的有效时间)", OrderNo=90, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000191, Name="发送异常日志邮件", Code="sys_error_mail", Value="True", SysFlag=YesNoEnum.Y, Remark="是否发送异常日志邮件", OrderNo=100, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000201, Name="开启域登录验证", Code="sys_domain_login", Value="False", SysFlag=YesNoEnum.Y, Remark="是否开启域登录验证", OrderNo=110, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000211, Name="开启数据校验日志", Code="sys_validation_log", Value="True", SysFlag=YesNoEnum.Y, Remark="是否数据校验日志", OrderNo=120, GroupCode="Default", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000221, Name="系统主标题", Code="sys_web_title", Value="Admin.NET", SysFlag=YesNoEnum.Y, Remark="系统主标题", OrderNo=130, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000231, Name="系统副标题", Code="sys_web_viceTitle", Value="Admin.NET", SysFlag=YesNoEnum.Y, Remark="系统副标题", OrderNo=140, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000241, Name="系统描述", Code="sys_web_viceDesc", Value="站在巨人肩膀上的 .NET 通用权限开发框架", SysFlag=YesNoEnum.Y, Remark="系统描述", OrderNo=150, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000251, Name="水印内容", Code="sys_web_watermark", Value="Admin.NET", SysFlag=YesNoEnum.Y, Remark="水印内容", OrderNo=160, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000261, Name="版权说明", Code="sys_web_copyright", Value="Copyright © 2021-present Admin.NET All rights reserved.", SysFlag=YesNoEnum.Y, Remark="版权说明", OrderNo=170, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000271, Name="系统图标", Code="sys_web_logo", Value="/Upload/logo.png", SysFlag=YesNoEnum.Y, Remark="系统图标", OrderNo=180, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000281, Name="ICP备案号", Code="sys_web_icp", Value="省ICP备12345678号", SysFlag=YesNoEnum.Y, Remark="ICP备案号", OrderNo=190, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000291, Name="ICP地址", Code="sys_web_icpUrl", Value="https://beian.miit.gov.cn", SysFlag=YesNoEnum.Y, Remark="ICP地址", OrderNo=200, GroupCode="WebConfig", CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
         };
     }
 }

+ 5 - 0
Admin.NET/Admin.NET.Core/Service/Auth/Dto/LoginUserOutput.cs

@@ -95,4 +95,9 @@ public class LoginUserOutput
     /// 角色集合
     /// </summary>
     public List<long> RoleIds { get; set; }
+
+    /// <summary>
+    /// 水印文字
+    /// </summary>
+    public string WatermarkText { get; set; }
 }

+ 34 - 29
Admin.NET/Admin.NET.Core/Service/Auth/SysAuthService.cs

@@ -6,7 +6,6 @@
 
 using Furion.SpecificationDocument;
 using Lazy.Captcha.Core;
-using static SKIT.FlurlHttpClient.Wechat.Api.Models.ComponentTCBBatchCreateContainerServiceVersionRequest.Types;
 
 namespace Admin.NET.Core.Service;
 
@@ -63,10 +62,11 @@ public class SysAuthService : IDynamicApiController, ITransient
         //// 可以根据域名获取具体租户
         //var host = _httpContextAccessor.HttpContext.Request.Host;
 
-        // 判断密码错误次数(默认5次,缓存30分钟)
-        var keyErrorPasswordCount = $"{CacheConst.KeyErrorPasswordCount}{input.Account}";
-        var errorPasswordCount = _sysCacheService.Get<int>(keyErrorPasswordCount);
-        if (errorPasswordCount >= 5)
+        // 判断密码错误次数(缓存30分钟)
+        var keyPasswordErrorTimes = $"{CacheConst.KeyPasswordErrorTimes}{input.Account}";
+        var passwordErrorTimes = _sysCacheService.Get<int>(keyPasswordErrorTimes);
+        var passwordMaxErrorTimes = await _sysConfigService.GetConfigValue<int>(CommonConst.SysPasswordMaxErrorTimes);
+        if (passwordErrorTimes >= passwordMaxErrorTimes)
             throw Oops.Oh(ErrorCodeEnum.D1027);
 
         // 是否开启验证码
@@ -106,19 +106,19 @@ public class SysAuthService : IDynamicApiController, ITransient
             var userLdap = await _sysUserLdap.GetFirstAsync(u => u.UserId == user.Id && u.TenantId == tenant.Id);
             if (userLdap == null)
             {
-                VerifyPassword(input, keyErrorPasswordCount, errorPasswordCount, user);
+                VerifyPassword(input, keyPasswordErrorTimes, passwordErrorTimes, user);
             }
             else if (!await _sysLdapService.AuthAccount(tenant.Id, userLdap.Account, input.Password))
             {
-                _sysCacheService.Set(keyErrorPasswordCount, ++errorPasswordCount, TimeSpan.FromMinutes(30));
+                _sysCacheService.Set(keyPasswordErrorTimes, ++passwordErrorTimes, TimeSpan.FromMinutes(30));
                 throw Oops.Oh(ErrorCodeEnum.D1000);
             }
         }
         else
-            VerifyPassword(input, keyErrorPasswordCount, errorPasswordCount, user);
+            VerifyPassword(input, keyPasswordErrorTimes, passwordErrorTimes, user);
 
         // 登录成功则清空密码错误次数
-        _sysCacheService.Remove(keyErrorPasswordCount);
+        _sysCacheService.Remove(keyPasswordErrorTimes);
 
         return await CreateToken(user);
     }
@@ -127,16 +127,16 @@ public class SysAuthService : IDynamicApiController, ITransient
     /// 验证用户密码
     /// </summary>
     /// <param name="input"></param>
-    /// <param name="keyErrorPasswordCount"></param>
-    /// <param name="errorPasswordCount"></param>
+    /// <param name="keyPasswordErrorTimes"></param>
+    /// <param name="passwordErrorTimes"></param>
     /// <param name="user"></param>
-    private void VerifyPassword(LoginInput input, string keyErrorPasswordCount, int errorPasswordCount, SysUser user)
+    private void VerifyPassword(LoginInput input, string keyPasswordErrorTimes, int passwordErrorTimes, SysUser user)
     {
         if (CryptogramUtil.CryptoType == CryptogramEnum.MD5.ToString())
         {
             if (!user.Password.Equals(MD5Encryption.Encrypt(input.Password)))
             {
-                _sysCacheService.Set(keyErrorPasswordCount, ++errorPasswordCount, TimeSpan.FromMinutes(30));
+                _sysCacheService.Set(keyPasswordErrorTimes, ++passwordErrorTimes, TimeSpan.FromMinutes(30));
                 throw Oops.Oh(ErrorCodeEnum.D1000);
             }
         }
@@ -144,7 +144,7 @@ public class SysAuthService : IDynamicApiController, ITransient
         {
             if (!CryptogramUtil.Decrypt(user.Password).Equals(input.Password))
             {
-                _sysCacheService.Set(keyErrorPasswordCount, ++errorPasswordCount, TimeSpan.FromMinutes(30));
+                _sysCacheService.Set(keyPasswordErrorTimes, ++passwordErrorTimes, TimeSpan.FromMinutes(30));
                 throw Oops.Oh(ErrorCodeEnum.D1000);
             }
         }
@@ -208,7 +208,7 @@ public class SysAuthService : IDynamicApiController, ITransient
     /// <param name="user"></param>
     /// <returns></returns>
     [NonAction]
-    public virtual async Task<LoginOutput> CreateToken(SysUser user)
+    internal virtual async Task<LoginOutput> CreateToken(SysUser user)
     {
         // 单用户登录
         await _sysOnlineUserService.SingleLogin(user.Id);
@@ -237,6 +237,19 @@ public class SysAuthService : IDynamicApiController, ITransient
         // Swagger Knife4UI-AfterScript登录脚本
         // ke.global.setAllHeader('Authorization', 'Bearer ' + ke.response.headers['access-token']);
 
+        // 更新用户登录信息
+        user.LastLoginIp = _httpContextAccessor.HttpContext.GetRemoteIp();
+        (user.LastLoginAddress, _, _) = CommonUtil.GetIpAddress(user.LastLoginIp);
+        user.LastLoginTime = DateTime.Now;
+        user.LastLoginDevice = CommonUtil.GetClientDeviceInfo(_httpContextAccessor.HttpContext?.Request?.Headers?.UserAgent);
+        await _sysUserRep.AsUpdateable(user).UpdateColumns(it => new
+        {
+            it.LastLoginIp,
+            it.LastLoginAddress,
+            it.LastLoginTime,
+            it.LastLoginDevice,
+        }).ExecuteCommandAsync();
+
         return new LoginOutput
         {
             AccessToken = accessToken,
@@ -261,7 +274,10 @@ public class SysAuthService : IDynamicApiController, ITransient
         // 获取角色集合
         var roleIds = await _sysUserRep.ChangeRepository<SqlSugarRepository<SysUserRole>>().AsQueryable()
             .Where(u => u.UserId == user.Id).Select(u => u.RoleId).ToListAsync();
-
+        // 获取水印文字(若系统水印为空则全局为空)
+        var watermarkText = await _sysConfigService.GetConfigValue<string>("sys_web_watermark");
+        if (!string.IsNullOrWhiteSpace(watermarkText))
+            watermarkText += $"-{user.RealName}-{_httpContextAccessor.HttpContext.GetRemoteIp()}-{DateTime.Now}";
         return new LoginUserOutput
         {
             Id = user.Id,
@@ -279,7 +295,8 @@ public class SysAuthService : IDynamicApiController, ITransient
             OrgType = org?.Type,
             PosName = pos?.Name,
             Buttons = buttons,
-            RoleIds = roleIds
+            RoleIds = roleIds,
+            WatermarkText = watermarkText
         };
     }
 
@@ -321,18 +338,6 @@ public class SysAuthService : IDynamicApiController, ITransient
         return new { SecondVerEnabled = secondVerEnabled, CaptchaEnabled = captchaEnabled };
     }
 
-    /// <summary>
-    /// 获取水印配置 🔖
-    /// </summary>
-    /// <returns></returns>
-    [SuppressMonitor]
-    [DisplayName("获取水印配置")]
-    public async Task<dynamic> GetWatermarkConfig()
-    {
-        var watermarkEnabled = await _sysConfigService.GetConfigValue<bool>(CommonConst.SysWatermark);
-        return new { WatermarkEnabled = watermarkEnabled };
-    }
-
     /// <summary>
     /// 获取验证码 🔖
     /// </summary>

+ 1 - 0
Admin.NET/Admin.NET.Core/Service/Config/SysConfigService.cs

@@ -245,6 +245,7 @@ public class SysConfigService : IDynamicApiController, ITransient
         var sysCopyright = await GetConfigValue<string>("sys_web_copyright");
         var sysIcp = await GetConfigValue<string>("sys_web_icp");
         var sysIcpUrl = await GetConfigValue<string>("sys_web_icpUrl");
+
         return new
         {
             SysLogo = sysLogo,

+ 2 - 1
Admin.NET/Admin.NET.Core/Service/DataBase/SysDatabaseService.cs

@@ -254,7 +254,8 @@ public class SysDatabaseService : IDynamicApiController, ITransient
         {
             if (db.DbMaintenance.IsAnyTableRemark(input.TableName))
                 db.DbMaintenance.DeleteTableRemark(input.TableName);
-            else
+
+            if (!string.IsNullOrWhiteSpace(input.Description))
                 db.DbMaintenance.AddTableRemark(input.TableName, input.Description);
         }
         catch (NotSupportedException ex)

+ 46 - 11
Admin.NET/Admin.NET.Core/Service/Dict/SysDictDataService.cs

@@ -1,4 +1,4 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
 //
 // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
 //
@@ -13,11 +13,14 @@ namespace Admin.NET.Core.Service;
 [AllowAnonymous]
 public class SysDictDataService : IDynamicApiController, ITransient
 {
+    private readonly SysCacheService _sysCacheService;
     private readonly SqlSugarRepository<SysDictData> _sysDictDataRep;
 
-    public SysDictDataService(SqlSugarRepository<SysDictData> sysDictDataRep)
+    public SysDictDataService(SqlSugarRepository<SysDictData> sysDictDataRep
+        , SysCacheService sysCacheService)
     {
         _sysDictDataRep = sysDictDataRep;
+        _sysCacheService = sysCacheService;
     }
 
     /// <summary>
@@ -40,6 +43,7 @@ public class SysDictDataService : IDynamicApiController, ITransient
     /// 获取字典值列表 🔖
     /// </summary>
     /// <returns></returns>
+    [UnitOfWork]
     [DisplayName("获取字典值列表")]
     public async Task<List<SysDictData>> GetList([FromQuery] GetDataDictDataInput input)
     {
@@ -67,6 +71,7 @@ public class SysDictDataService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [ApiDescriptionSettings(Name = "Update"), HttpPost]
     [DisplayName("更新字典值")]
     public async Task UpdateDictData(UpdateDictDataInput input)
@@ -77,6 +82,9 @@ public class SysDictDataService : IDynamicApiController, ITransient
         isExist = await _sysDictDataRep.IsAnyAsync(u => u.Code == input.Code && u.DictTypeId == input.DictTypeId && u.Id != input.Id);
         if (isExist) throw Oops.Oh(ErrorCodeEnum.D3003);
 
+        var dictTypeCode = await _sysDictDataRep.AsQueryable().Where(u => u.DictTypeId == input.DictTypeId).Select(u => u.DictType.Code).FirstAsync();
+        _sysCacheService.Remove($"{CacheConst.KeyDict}{dictTypeCode}");
+
         await _sysDictDataRep.UpdateAsync(input.Adapt<SysDictData>());
     }
 
@@ -85,6 +93,7 @@ public class SysDictDataService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [ApiDescriptionSettings(Name = "Delete"), HttpPost]
     [DisplayName("删除字典值")]
     public async Task DeleteDictData(DeleteDictDataInput input)
@@ -93,6 +102,9 @@ public class SysDictDataService : IDynamicApiController, ITransient
         if (dictData == null)
             throw Oops.Oh(ErrorCodeEnum.D3004);
 
+        var dictTypeCode = await _sysDictDataRep.AsQueryable().Where(u => u.DictTypeId == dictData.Id).Select(u => u.DictType.Code).FirstAsync();
+        _sysCacheService.Remove($"{CacheConst.KeyDict}{dictTypeCode}");
+
         await _sysDictDataRep.DeleteAsync(dictData);
     }
 
@@ -112,6 +124,7 @@ public class SysDictDataService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [DisplayName("修改字典值状态")]
     public async Task SetStatus(DictDataInput input)
     {
@@ -122,6 +135,9 @@ public class SysDictDataService : IDynamicApiController, ITransient
         if (!Enum.IsDefined(typeof(StatusEnum), input.Status))
             throw Oops.Oh(ErrorCodeEnum.D3005);
 
+        var dictTypeCode = await _sysDictDataRep.AsQueryable().Where(u => u.DictTypeId == dictData.Id).Select(u => u.DictType.Code).FirstAsync();
+        _sysCacheService.Remove($"{CacheConst.KeyDict}{dictTypeCode}");
+
         dictData.Status = input.Status;
         await _sysDictDataRep.UpdateAsync(dictData);
     }
@@ -134,10 +150,20 @@ public class SysDictDataService : IDynamicApiController, ITransient
     [NonAction]
     public async Task<List<SysDictData>> GetDictDataListByDictTypeId(long dictTypeId)
     {
-        return await _sysDictDataRep.AsQueryable()
-            .Where(u => u.DictTypeId == dictTypeId)
-            .OrderBy(u => new { u.OrderNo, u.Code })
-            .ToListAsync();
+        var dictType = await _sysDictDataRep.ChangeRepository<SqlSugarRepository<SysDictType>>().GetByIdAsync(dictTypeId);
+        var dictDataList = _sysCacheService.Get<List<SysDictData>>($"{CacheConst.KeyDict}{dictTypeId}");
+
+        if (dictDataList == null)
+        {
+            dictDataList = await _sysDictDataRep.AsQueryable()
+                .Where(u => u.DictTypeId == dictTypeId)
+                .OrderBy(u => new { u.OrderNo, u.Code })
+                .ToListAsync();
+
+            _sysCacheService.Set($"{CacheConst.KeyDict}{dictType.Code}", dictDataList);
+        }
+
+        return dictDataList;
     }
 
     /// <summary>
@@ -148,11 +174,17 @@ public class SysDictDataService : IDynamicApiController, ITransient
     [DisplayName("根据字典类型编码获取字典值集合")]
     public async Task<List<SysDictData>> GetDataList(string code)
     {
-        return await _sysDictDataRep.Context.Queryable<SysDictType>()
-            .LeftJoin<SysDictData>((u, a) => u.Id == a.DictTypeId)
-            .Where((u, a) => u.Code == code && u.Status == StatusEnum.Enable && a.Status == StatusEnum.Enable)
-            .OrderBy((u, a) => new { a.OrderNo, a.Code })
-            .Select((u, a) => a).ToListAsync();
+        var dictDataList = _sysCacheService.Get<List<SysDictData>>($"{CacheConst.KeyDict}{code}");
+        if (dictDataList == null)
+        {
+            dictDataList = await _sysDictDataRep.Context.Queryable<SysDictType>()
+                .LeftJoin<SysDictData>((u, a) => u.Id == a.DictTypeId)
+                .Where((u, a) => u.Code == code && u.Status == StatusEnum.Enable && a.Status == StatusEnum.Enable)
+                .OrderBy((u, a) => new { a.OrderNo, a.Code })
+                .Select((u, a) => a).ToListAsync();
+            _sysCacheService.Set($"{CacheConst.KeyDict}{code}", dictDataList);
+        }
+        return dictDataList;
     }
 
     /// <summary>
@@ -179,6 +211,9 @@ public class SysDictDataService : IDynamicApiController, ITransient
     [NonAction]
     public async Task DeleteDictData(long dictTypeId)
     {
+        var dictTypeCode = await _sysDictDataRep.AsQueryable().Where(u => u.DictTypeId == dictTypeId).Select(u => u.DictType.Code).FirstAsync();
+        _sysCacheService.Remove($"{CacheConst.KeyDict}{dictTypeCode}");
+
         await _sysDictDataRep.DeleteAsync(u => u.DictTypeId == dictTypeId);
     }
 }

+ 11 - 1
Admin.NET/Admin.NET.Core/Service/Dict/SysDictTypeService.cs

@@ -15,12 +15,15 @@ public class SysDictTypeService : IDynamicApiController, ITransient
 {
     private readonly SqlSugarRepository<SysDictType> _sysDictTypeRep;
     private readonly SysDictDataService _sysDictDataService;
+    private readonly SysCacheService _sysCacheService;
 
     public SysDictTypeService(SqlSugarRepository<SysDictType> sysDictTypeRep,
-        SysDictDataService sysDictDataService)
+        SysDictDataService sysDictDataService,
+        SysCacheService sysCacheService)
     {
         _sysDictTypeRep = sysDictTypeRep;
         _sysDictDataService = sysDictDataService;
+        _sysCacheService = sysCacheService;
     }
 
     /// <summary>
@@ -52,6 +55,7 @@ public class SysDictTypeService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [AllowAnonymous]
     [DisplayName("获取字典类型-值列表")]
     public async Task<List<SysDictData>> GetDataList([FromQuery] GetDataDictTypeInput input)
@@ -84,6 +88,7 @@ public class SysDictTypeService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [ApiDescriptionSettings(Name = "Update"), HttpPost]
     [DisplayName("更新字典类型")]
     public async Task UpdateDictType(UpdateDictTypeInput input)
@@ -96,6 +101,7 @@ public class SysDictTypeService : IDynamicApiController, ITransient
         if (isExist)
             throw Oops.Oh(ErrorCodeEnum.D3001);
 
+        _sysCacheService.Remove($"{CacheConst.KeyDict}{input.Code}");
         await _sysDictTypeRep.UpdateAsync(input.Adapt<SysDictType>());
     }
 
@@ -104,6 +110,7 @@ public class SysDictTypeService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [ApiDescriptionSettings(Name = "Delete"), HttpPost]
     [DisplayName("删除字典类型")]
     public async Task DeleteDictType(DeleteDictTypeInput input)
@@ -133,6 +140,7 @@ public class SysDictTypeService : IDynamicApiController, ITransient
     /// </summary>
     /// <param name="input"></param>
     /// <returns></returns>
+    [UnitOfWork]
     [DisplayName("修改字典类型状态")]
     public async Task SetStatus(DictTypeInput input)
     {
@@ -143,6 +151,8 @@ public class SysDictTypeService : IDynamicApiController, ITransient
         if (!Enum.IsDefined(typeof(StatusEnum), input.Status))
             throw Oops.Oh(ErrorCodeEnum.D3005);
 
+        _sysCacheService.Remove($"{CacheConst.KeyDict}{dictType.Code}");
+
         dictType.Status = input.Status;
         await _sysDictTypeRep.UpdateAsync(dictType);
     }

+ 1 - 1
Admin.NET/Admin.NET.Core/Service/File/SysFileService.cs

@@ -21,7 +21,7 @@ public class SysFileService : IDynamicApiController, ITransient
     private readonly OSSProviderOptions _OSSProviderOptions;
     private readonly UploadOptions _uploadOptions;
     private readonly IOSSService _OSSService;
-    private readonly string _imageType = ".jpg.png.bmp.gif.tif";
+    private readonly string _imageType = ".jpeg.jpg.png.bmp.gif.tif";
 
     public SysFileService(UserManager userManager,
         SqlSugarRepository<SysFile> sysFileRep,

+ 6 - 3
Admin.NET/Admin.NET.Core/Service/Job/DbJobPersistence.cs

@@ -128,6 +128,7 @@ public class DbJobPersistence : IJobPersistence
     /// 作业计划Scheduler的JobDetail变化时
     /// </summary>
     /// <param name="context"></param>
+    /// <returns></returns>
     public async Task OnChangedAsync(PersistenceContext context)
     {
         using (var scope = _serviceScopeFactory.CreateScope())
@@ -156,6 +157,7 @@ public class DbJobPersistence : IJobPersistence
     /// 作业计划Scheduler的触发器Trigger变化时
     /// </summary>
     /// <param name="context"></param>
+    /// <returns></returns>
     public async Task OnTriggerChangedAsync(PersistenceTriggerContext context)
     {
         using (var scope = _serviceScopeFactory.CreateScope())
@@ -183,14 +185,15 @@ public class DbJobPersistence : IJobPersistence
     /// <summary>
     /// 作业触发器运行记录
     /// </summary>
-    /// <param name="timeline"></param>
-    public async Task OnExecutionRecordAsync(TriggerTimeline timeline)
+    /// <param name="context"></param>
+    /// <returns></returns>
+    public async Task OnExecutionRecordAsync(PersistenceExecutionRecordContext context)
     {
         using (var scope = _serviceScopeFactory.CreateScope())
         {
             var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>().CopyNew();
 
-            var jobTriggerRecord = timeline.Adapt<SysJobTriggerRecord>();
+            var jobTriggerRecord = context.Timeline.Adapt<SysJobTriggerRecord>();
             await db.Insertable(jobTriggerRecord).ExecuteCommandAsync();
         }
     }

+ 6 - 0
Admin.NET/Admin.NET.Core/Service/Org/Dto/OrgInput.cs

@@ -31,6 +31,12 @@ public class AddOrgInput : SysOrg
     /// </summary>
     [Required(ErrorMessage = "机构名称不能为空")]
     public override string Name { get; set; }
+
+    /// <summary>
+    /// 机构类型
+    /// </summary>
+    [Dict("org_type", ErrorMessage = "机构类型不能合法", AllowNullValue = true, AllowEmptyStrings = true)]
+    public override string? Type { get; set; }
 }
 
 public class UpdateOrgInput : AddOrgInput

+ 1 - 0
Admin.NET/Admin.NET.Core/Service/User/Dto/UserInput.cs

@@ -14,6 +14,7 @@ public class UserInput : BaseIdInput
     /// <summary>
     /// 状态
     /// </summary>
+    [Dict("StatusEnum")]
     public StatusEnum Status { get; set; }
 }
 

+ 3 - 3
Admin.NET/Admin.NET.Core/Service/User/SysUserService.cs

@@ -315,7 +315,7 @@ public class SysUserService : IDynamicApiController, ITransient
         await _sysUserRep.AsUpdateable(user).UpdateColumns(u => u.Password).ExecuteCommandAsync();
 
         // 清空密码错误次数
-        var keyErrorPasswordCount = $"{CacheConst.KeyErrorPasswordCount}{user.Account}";
+        var keyErrorPasswordCount = $"{CacheConst.KeyPasswordErrorTimes}{user.Account}";
         _sysCacheService.Remove(keyErrorPasswordCount);
 
         return password;
@@ -332,8 +332,8 @@ public class SysUserService : IDynamicApiController, ITransient
         var user = await _sysUserRep.GetFirstAsync(u => u.Id == input.Id) ?? throw Oops.Oh(ErrorCodeEnum.D0009);
 
         // 清空密码错误次数
-        var keyErrorPasswordCount = $"{CacheConst.KeyErrorPasswordCount}{user.Account}";
-        _sysCacheService.Remove(keyErrorPasswordCount);
+        var keyPasswordErrorTimes = $"{CacheConst.KeyPasswordErrorTimes}{user.Account}";
+        _sysCacheService.Remove(keyPasswordErrorTimes);
     }
 
     /// <summary>

+ 1 - 1
Admin.NET/Admin.NET.Core/Service/Wechat/SysWechatService.cs

@@ -168,7 +168,7 @@ public class SysWechatService : IDynamicApiController, ITransient
             Url = input.Url,
             MiniProgram = new CgibinMessageTemplateSendRequest.Types.MiniProgram
             {
-                AppId = _wechatApiClientFactory._wechatOptions.WechatAppId,
+                AppId = _wechatApiClientFactory._wechatOptions.WxOpenAppId,
                 PagePath = input.MiniProgramPagePath,
             },
             Data = messageData

+ 6 - 5
Admin.NET/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs

@@ -185,33 +185,34 @@ public static class SqlSugarSetup
                 // 若当前用户非空(web线程时)
                 if (App.User != null)
                 {
+                    dynamic entityValue = entityInfo.EntityValue;
                     if (entityInfo.PropertyName == nameof(EntityTenantId.TenantId))
                     {
-                        var tenantId = ((dynamic)entityInfo.EntityValue).TenantId;
+                        var tenantId = entityValue.TenantId;
                         if (tenantId == null || tenantId == 0)
                             entityInfo.SetValue(App.User.FindFirst(ClaimConst.TenantId)?.Value);
                     }
                     else if (entityInfo.PropertyName == nameof(EntityBase.CreateUserId))
                     {
-                        var createUserId = ((dynamic)entityInfo.EntityValue).CreateUserId;
+                        var createUserId = entityValue.CreateUserId;
                         if (createUserId == 0 || createUserId == null)
                             entityInfo.SetValue(App.User.FindFirst(ClaimConst.UserId)?.Value);
                     }
                     else if (entityInfo.PropertyName == nameof(EntityBase.CreateUserName))
                     {
-                        var createUserName = ((dynamic)entityInfo.EntityValue).CreateUserName;
+                        var createUserName = entityValue.CreateUserName;
                         if (string.IsNullOrEmpty(createUserName))
                             entityInfo.SetValue(App.User.FindFirst(ClaimConst.RealName)?.Value);
                     }
                     else if (entityInfo.PropertyName == nameof(EntityBaseData.CreateOrgId))
                     {
-                        var createOrgId = ((dynamic)entityInfo.EntityValue).CreateOrgId;
+                        var createOrgId = entityValue.CreateOrgId;
                         if (createOrgId == 0 || createOrgId == null)
                             entityInfo.SetValue(App.User.FindFirst(ClaimConst.OrgId)?.Value);
                     }
                     else if (entityInfo.PropertyName == nameof(EntityBaseData.CreateOrgName))
                     {
-                        var createOrgName = ((dynamic)entityInfo.EntityValue).CreateOrgName;
+                        var createOrgName = entityValue.CreateOrgName;
                         if (string.IsNullOrEmpty(createOrgName))
                             entityInfo.SetValue(App.User.FindFirst(ClaimConst.OrgName)?.Value);
                     }

+ 45 - 0
Admin.NET/Admin.NET.Core/Util/CommonUtil.cs

@@ -4,10 +4,12 @@
 //
 // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
 
+using IPTools.Core;
 using Magicodes.ExporterAndImporter.Core.Models;
 using System.Xml;
 using System.Xml.Linq;
 using System.Xml.Serialization;
+using UAParser;
 
 namespace Admin.NET.Core;
 
@@ -369,4 +371,47 @@ public static class CommonUtil
 
         return propMappings;
     }
+
+    /// <summary>
+    /// 解析IP地址
+    /// </summary>
+    /// <param name="ip"></param>
+    /// <returns></returns>
+    public static (string ipLocation, double? longitude, double? latitude) GetIpAddress(string ip)
+    {
+        try
+        {
+            var ipInfo = IpTool.SearchWithI18N(ip); // 国际化查询,默认中文 中文zh-CN、英文en
+            var addressList = new List<string>() { ipInfo.Country, ipInfo.Province, ipInfo.City, ipInfo.NetworkOperator };
+            return (string.Join(" ", addressList.Where(u => u != "0" && !string.IsNullOrWhiteSpace(u)).ToList()), ipInfo.Longitude, ipInfo.Latitude); // 去掉0及空并用空格连接
+        }
+        catch
+        {
+            // 不做处理
+        }
+        return ("未知", 0, 0);
+    }
+
+    /// <summary>
+    /// 获取客户端设备信息(操作系统+浏览器)
+    /// </summary>
+    /// <param name="userAgent"></param>
+    /// <returns></returns>
+    public static string GetClientDeviceInfo(string userAgent)
+    {
+        try
+        {
+            if (userAgent != null)
+            {
+                var client = Parser.GetDefault().Parse(userAgent);
+                if (client.Device.IsSpider)
+                    return "爬虫";
+                return $"{client.OS.Family} {client.OS.Major} {client.OS.Minor}" +
+                    $"|{client.UA.Family} {client.UA.Major}.{client.UA.Minor} / {client.Device.Family}";
+            }
+        }
+        catch
+        { }
+        return "未知";
+    }
 }

+ 7 - 6
Admin.NET/Admin.NET.Web.Core/Handlers/JwtHandler.cs

@@ -92,16 +92,17 @@ namespace Admin.NET.Web.Core
                 ? httpContext.Request.Path.Value[5..].Replace("/", ":")
                 : httpContext.Request.Path.Value[1..].Replace("/", ":");
 
-            var sysMenuService = App.GetRequiredService<SysMenuService>();
+            var serviceScope = httpContext.RequestServices.CreateScope();
+            var sysMenuService = serviceScope.ServiceProvider.GetRequiredService<SysMenuService>();
+
             // 获取用户拥有按钮权限集合
             var ownBtnPermList = await sysMenuService.GetOwnBtnPermList();
+            if (ownBtnPermList.Exists(u => routeName.Equals(u, StringComparison.CurrentCultureIgnoreCase)))
+                return true;
+
             // 获取系统所有按钮权限集合
             var allBtnPermList = await sysMenuService.GetAllBtnPermList();
-
-            // 已拥有该按钮权限或者所有按钮集合里面不存在
-            var exist1 = ownBtnPermList.Exists(u => routeName.Equals(u, StringComparison.CurrentCultureIgnoreCase));
-            var exist2 = allBtnPermList.TrueForAll(u => !routeName.Equals(u, StringComparison.CurrentCultureIgnoreCase));
-            return exist1 || exist2;
+            return allBtnPermList.TrueForAll(u => !routeName.Equals(u, StringComparison.CurrentCultureIgnoreCase));
         }
     }
 }

+ 20 - 5
Admin.NET/Admin.NET.Web.Core/Startup.cs

@@ -12,6 +12,7 @@ using Furion.SpecificationDocument;
 using Furion.VirtualFileServer;
 using IGeekFan.AspNetCore.Knife4jUI;
 using IPTools.Core;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Http;
@@ -22,6 +23,7 @@ using Newtonsoft.Json;
 using OnceMi.AspNetCore.OSS;
 using SixLabors.ImageSharp.Web.DependencyInjection;
 using System;
+using System.Threading.Tasks;
 
 namespace Admin.NET.Web.Core;
 
@@ -37,12 +39,25 @@ public class Startup : AppStartup
         // SqlSugar
         services.AddSqlSugar();
         // JWT
-        services.AddJwt<JwtHandler>(enableGlobalAuthorize: true)
-            // 添加 Signature 身份验证
-            .AddSignatureAuthentication(options =>
+        services.AddJwt<JwtHandler>(enableGlobalAuthorize: true, jwtBearerConfigure: options =>
+        {
+            // 实现 JWT 身份验证过程控制
+            options.Events = new JwtBearerEvents
             {
-                options.Events = SysOpenAccessService.GetSignatureAuthenticationEventImpl();
-            });
+                OnMessageReceived = context =>
+                {
+                    var httpContext = context.HttpContext;
+                    // 若请求 Url 包含 token 参数,则设置 Token 值
+                    if (httpContext.Request.Query.ContainsKey("token"))
+                        context.Token = httpContext.Request.Query["token"];
+                    return Task.CompletedTask;
+                }
+            };
+        }).AddSignatureAuthentication(options =>  // 添加 Signature 身份验证
+        {
+            options.Events = SysOpenAccessService.GetSignatureAuthenticationEventImpl();
+        });
+
         // 允许跨域
         services.AddCorsAccessor();
         // 远程请求

+ 2 - 2
Admin.NET/Admin.NET.Web.Entry/wwwroot/Template/Service.cs.vm

@@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Http;
     }
 }
 namespace @Model.NameSpace;
+
 /// <summary>
 /// @(@Model.BusName)服务
 /// </summary>
@@ -109,7 +110,7 @@ if (@column.QueryWhether == "Y"){
 } else {
             @:.Select<@(@Model.ClassName)Output>();
 }
-        return await query.OrderBuilder(input).ToPagedListAsync(input.Page, input.PageSize);
+		return await query.OrderBuilder(input).ToPagedListAsync(input.Page, input.PageSize);
     }
 
     /// <summary>
@@ -190,7 +191,6 @@ if(@column.EffectType == "fk" && (@column.WhetherAddUpdate == "Y" || column.Quer
     @:/// <summary>
     @:/// 获取@(@column.ColumnComment)列表
     @:/// </summary>
-    @:/// <param name="input"></param>
     @:/// <returns></returns>
     @:[ApiDescriptionSettings(Name = "@(@column.FkEntityName)@(@column.PropertyName)Dropdown"), HttpGet]
     @:public async Task<dynamic> @(@column.FkEntityName)@(@column.PropertyName)Dropdown()

+ 3 - 4
Admin.NET/Admin.NET.Web.Entry/wwwroot/Template/editDialog.vue.vm

@@ -135,7 +135,7 @@
 					@:<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
 						@:<el-form-item label="@column.ColumnComment" prop="@(@column.LowerPropertyName)">
 							@:<el-select clearable v-model="ruleForm.@(@column.LowerPropertyName)" placeholder="请选择@(@column.ColumnComment)">
-								@:<el-option v-for="(item,index) in dl('@(@column.PropertyName)Enum')" :key="index" :value="@(@column.NetType == "string?" ? "item.value" : "Number(item.value)")" :label="`${item.name} (${item.code}) [${item.value}]`"></el-option>
+								@:<el-option v-for="(item,index) in dl('@(@column.DictTypeCode)')" :key="index" :value="@(@column.NetType == "string?" ? "item.value" : "Number(item.value)")" :label="`${item.name} (${item.code}) [${item.value}]`"></el-option>
 								@:
 							</el-select>
 							@:
@@ -167,13 +167,12 @@
 </style>
 <script lang="ts" setup>
 	import { ref,onMounted } from "vue";
-	import { getDictDataItem as di, getDictDataList as dl } from '/@@/utils/dict-utils';
 	import { ElMessage } from "element-plus";
 	import type { FormRules } from "element-plus";
 @if(@Model.TableField.Any(x=>x.EffectType == "ConstSelector")){
 	@:import { getConstType } from "/@@/utils/constHelper";
-}
-@if(@Model.TableField.Any(x=>x.EffectType == "Select")){
+} 
+@if(@Model.TableField.Any(x=>x.EffectType == "Select") || @Model.TableField.Any(x=>x.EffectType == "EnumSelector")){
 	@:import { getDictDataItem as di, getDictDataList as dl } from '/@@/utils/dict-utils';
 }
 @if(@Model.TableField.Any(x=>x.EffectType == "EnumSelector")){

+ 4 - 4
Admin.NET/Admin.NET.Web.Entry/wwwroot/Template/index.vue.vm

@@ -63,7 +63,7 @@
             }else if(@column.EffectType == "EnumSelector"){
             @:<el-form-item label="@column.ColumnComment">
               @:<el-select clearable="" v-model="queryParams.@(@column.LowerPropertyName)" placeholder="请选择@(@column.ColumnComment)">
-                @:<el-option v-for="(item,index) in dl('@(@column.PropertyName)Enum')" :key="index" :value="item.value" :label="`${item.name} (${item.code}) [${item.value}] `" />
+                @:<el-option v-for="(item,index) in dl('@(@column.DictTypeCode)')" :key="index" :value="item.value" :label="`${item.name} (${item.code}) [${item.value}] `" />
                 @:
               </el-select>
               @:
@@ -83,9 +83,9 @@
             } 
       }
           @<el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="4" class="mb10">
-            @<el-form-item>
+            @<el-form-item @(Model.QueryWhetherList.Count > 0?"":"label-width=\"0px\"")>
               @<el-button-group style="display: flex; align-items: center;">
-                @<el-button type="primary"  icon="ele-Search" @@click="handleQuery" v-auth="'@(@Model.LowerClassName):page'"> 查询 </el-button>
+                @<el-button type="primary"  icon="ele-Search" @@click="handleQuery" v-auth="'@(@Model.LowerClassName):page'"> @(Model.QueryWhetherList.Count > 0?"查询":"刷新") </el-button>
                    @if(Model.QueryWhetherList.Count > 0){
                       @:<el-button icon="ele-Refresh" @@click="() => queryParams = {}"> 重置 </el-button>
                         @if(haveLikeCdt){
@@ -165,7 +165,7 @@
         else if(@column.EffectType == "EnumSelector"){
           @:<el-table-column prop="@column.LowerPropertyName" label="@column.ColumnComment" @(column.WhetherSortable == "Y" ? "sortable='custom'" : "") show-overflow-tooltip="" >
             @:<template #default="scope">
-              @:<el-tag :type="dv('@(@column.PropertyName)Enum', scope.row.@(@column.LowerPropertyName))?.tagType"> {{dv('@(@column.PropertyName)Enum', scope.row.@column.LowerPropertyName)?.name}}</el-tag>
+              @:<el-tag :type="dv('@(@column.DictTypeCode)', scope.row.@(@column.LowerPropertyName))?.tagType"> {{dv('@(@column.DictTypeCode)', scope.row.@column.LowerPropertyName)?.name}}</el-tag>
             @:</template>
           @:</el-table-column>
         }

+ 7 - 1
Admin.NET/Plugins/Admin.NET.Plugin.GoView/Entity/GoViewProData.cs

@@ -1,4 +1,4 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
 //
 // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
 //
@@ -23,4 +23,10 @@ public class GoViewProData : EntityTenant
     /// </summary>
     [SugarColumn(ColumnDescription = "预览图片", ColumnDataType = StaticConfig.CodeFirst_BigString)]
     public string? IndexImageData { get; set; }
+
+    /// <summary>
+    /// 背景图片
+    /// </summary>
+    [SugarColumn(ColumnDescription = "背景图片", ColumnDataType = StaticConfig.CodeFirst_BigString)]
+    public string? BackGroundImageData { get; set; }
 }

+ 6 - 1
Admin.NET/Plugins/Admin.NET.Plugin.GoView/Service/GoViewPro/Dto/GoViewProOutput.cs

@@ -1,4 +1,4 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
 //
 // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
 //
@@ -36,6 +36,11 @@ public class GoViewProItemOutput
     /// </summary>
     public string IndexImage { get; set; }
 
+    /// <summary>
+    /// 背景图片url
+    /// </summary>
+    public string BackGroundImage { get; set; }
+
     /// <summary>
     /// 创建者Id
     /// </summary>

+ 72 - 1
Admin.NET/Plugins/Admin.NET.Plugin.GoView/Service/GoViewPro/GoViewProService.cs

@@ -1,4 +1,4 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
 //
 // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
 //
@@ -249,4 +249,75 @@ public class GoViewProService : IDynamicApiController
         var bytes = Convert.FromBase64String(projectData.IndexImageData);
         return new FileStreamResult(new MemoryStream(bytes), "image/png");
     }
+
+    /// <summary>
+    /// 上传背景图
+    /// </summary>
+    [DisplayName("上传背景图")]
+    public async Task<GoViewProUploadOutput> UploadBackGround(IFormFile @object)
+    {
+        // 文件名格式示例 13414795568325_index_preview.png
+        var fileNameSplit = @object.FileName.Split('_');
+        var idStr = fileNameSplit[0];
+        if (!long.TryParse(idStr, out var id)) return new GoViewProUploadOutput();
+
+        // 将预览图转换成 Base64
+        var ms = new MemoryStream();
+        await @object.CopyToAsync(ms);
+        var base64Image = Convert.ToBase64String(ms.ToArray());
+
+        // 保存
+        if (await _goViewProDataRep.IsAnyAsync(u => u.Id == id))
+        {
+            await _goViewProDataRep.AsUpdateable()
+                .SetColumns(u => new GoViewProData
+                {
+                    BackGroundImageData = base64Image
+                })
+                .Where(u => u.Id == id)
+                .ExecuteCommandAsync();
+        }
+        else
+        {
+            await _goViewProDataRep.InsertAsync(new GoViewProData
+            {
+                Id = id,
+                BackGroundImageData = base64Image,
+            });
+        }
+
+        var output = new GoViewProUploadOutput
+        {
+            Id = id,
+            BucketName = null,
+            CreateTime = null,
+            CreateUserId = null,
+            FileName = null,
+            FileSize = 0,
+            FileSuffix = "png",
+            FileUrl = $"api/goview/project/getBackGroundImage/{id}",
+            UpdateTime = null,
+            UpdateUserId = null
+        };
+
+        return output;
+    }
+
+    /// <summary>
+    /// 获取背景图
+    /// </summary>
+    /// <returns></returns>
+    [AllowAnonymous]
+    [NonUnify]
+    [ApiDescriptionSettings(Name = "GetBackGroundImage")]
+    [DisplayName("获取背景图")]
+    public async Task<IActionResult> GetBackGroundImage(long id)
+    {
+        var projectData = await _goViewProDataRep.AsQueryable().IgnoreColumns(u => u.Content).FirstAsync(u => u.Id == id);
+        if (projectData?.BackGroundImageData == null)
+            return new NoContentResult();
+
+        var bytes = Convert.FromBase64String(projectData.BackGroundImageData);
+        return new FileStreamResult(new MemoryStream(bytes), "image/png");
+    }
 }

+ 1 - 1
Admin.NET/Plugins/Admin.NET.Plugin.ReZero/Admin.NET.Plugin.ReZero.csproj

@@ -24,7 +24,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Rezero.Api" Version="1.7.1" />
+    <PackageReference Include="Rezero.Api" Version="1.7.5" />
   </ItemGroup>
 
   <ItemGroup>

+ 13 - 13
Web/package.json

@@ -2,7 +2,7 @@
 	"name": "admin.net",
 	"type": "module",
 	"version": "2.4.33",
-	"lastBuildTime": "2024.6.16",
+	"lastBuildTime": "2024.6.25",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",
 	"author": "zuohuaijun",
 	"license": "MIT",
@@ -14,8 +14,8 @@
 	},
 	"dependencies": {
 		"@element-plus/icons-vue": "^2.3.1",
-		"@logicflow/core": "^1.2.27",
-		"@logicflow/extension": "^1.2.27",
+		"@logicflow/core": "^2.0.0-beta.0",
+		"@logicflow/extension": "^2.0.0-beta.0",
 		"@microsoft/signalr": "^8.0.0",
 		"@vue-office/docx": "^1.6.2",
 		"@vue-office/excel": "^1.7.8",
@@ -31,13 +31,13 @@
 		"echarts": "^5.5.0",
 		"echarts-gl": "^2.0.9",
 		"echarts-wordcloud": "^2.1.0",
-		"element-plus": "^2.7.5",
+		"element-plus": "^2.7.6",
 		"js-cookie": "^3.0.5",
 		"js-table2excel": "^1.1.2",
 		"jsplumb": "^2.15.6",
 		"lodash-es": "^4.17.21",
 		"mitt": "^3.0.1",
-		"monaco-editor": "^0.49.0",
+		"monaco-editor": "^0.50.0",
 		"nprogress": "^0.2.0",
 		"pinia": "^2.1.7",
 		"print-js": "^1.6.0",
@@ -51,14 +51,14 @@
 		"splitpanes": "^3.1.5",
 		"vcrontab-3": "^3.3.22",
 		"vform3-builds": "^3.0.10",
-		"vue": "^3.4.29",
+		"vue": "^3.4.30",
 		"vue-clipboard3": "^2.0.0",
 		"vue-demi": "^0.14.8",
 		"vue-grid-layout": "3.0.0-beta1",
 		"vue-i18n": "^9.13.1",
 		"vue-json-pretty": "^2.4.0",
 		"vue-plugin-hiprint": "0.0.57-beta20",
-		"vue-router": "^4.3.3",
+		"vue-router": "^4.4.0",
 		"vue-signature-pad": "^3.0.2",
 		"vue3-tree-org": "^4.2.2",
 		"vuedraggable": "4.1.0",
@@ -67,23 +67,23 @@
 	"devDependencies": {
 		"@plugin-web-update-notification/vite": "^1.7.1",
 		"@types/lodash-es": "^4.17.12",
-		"@types/node": "^20.14.2",
+		"@types/node": "^20.14.9",
 		"@types/nprogress": "^0.2.3",
 		"@types/sortablejs": "^1.15.8",
-		"@typescript-eslint/eslint-plugin": "^7.13.0",
-		"@typescript-eslint/parser": "^7.13.0",
+		"@typescript-eslint/eslint-plugin": "^7.14.1",
+		"@typescript-eslint/parser": "^7.14.1",
 		"@vitejs/plugin-vue": "^5.0.5",
 		"@vitejs/plugin-vue-jsx": "^4.0.0",
-		"@vue/compiler-sfc": "^3.4.29",
+		"@vue/compiler-sfc": "^3.4.30",
 		"code-inspector-plugin": "^0.14.2",
 		"eslint": "^9.5.0",
 		"eslint-plugin-vue": "^9.26.0",
 		"less": "^4.2.0",
 		"prettier": "^3.3.2",
 		"rollup-plugin-visualizer": "^5.12.0",
-		"sass": "^1.77.5",
+		"sass": "^1.77.6",
 		"terser": "^5.31.1",
-		"typescript": "^5.4.5",
+		"typescript": "^5.5.2",
 		"vite": "^5.3.1",
 		"vite-plugin-cdn-import": "^1.0.1",
 		"vite-plugin-compression2": "^1.1.1",

+ 55 - 13
Web/src/components/table/index.vue

@@ -6,6 +6,9 @@
 			</div>
 			<div v-loading="state.exportLoading" class="table-footer-tool">
 				<SvgIcon v-if="!config.hideRefresh" name="iconfont icon-shuaxin" :size="22" title="刷新" @click="onRefreshTable" />
+				<el-tooltip effect="light" :content="state.switchFixedContent" placement="bottom-start" :show-after="200" v-if="state.haveFixed">
+					<el-icon :style="{color: state.fixedIconColor}" @click="switchFixed"><ele-Switch /></el-icon>
+				</el-tooltip>
 				<el-dropdown v-if="!config.hideExport" trigger="click">
 					<SvgIcon name="iconfont icon-yunxiazai_o" :size="22" title="导出" />
 					<template #dropdown>
@@ -31,7 +34,7 @@
 						</div>
 						<el-scrollbar>
 							<div ref="toolSetRef" class="tool-sortable">
-								<div class="tool-sortable-item" v-for="v in columns" :key="v.prop" v-show="!v.hideCheck && !v.fixed" :data-key="v.prop">
+								<div class="tool-sortable-item" v-for="v in columns" :key="v.prop" v-show="!v.hideCheck" :data-key="v.prop">
 									<i class="fa fa-arrows-alt handle cursor-pointer"></i>
 									<el-checkbox v-model="v.isCheck" size="default" class="ml12 mr8" :label="v.label" @change="onCheckChange" />
 								</div>
@@ -56,7 +59,7 @@
 			@sort-change="sortChange"
 		>
 			<el-table-column type="selection" :reserve-selection="true" :width="30" v-if="config.isSelection && config.showSelection" />
-			<el-table-column type="index" label="序号" align="center" :width="60" v-if="config.isSerialNo" />
+			<el-table-column type="index" :fixed="state.currentFixed && state.serialNoFixed" label="序号" align="center" :width="60" v-if="config.isSerialNo" />
 			<el-table-column v-for="(item, index) in setHeader" :key="index" v-bind="item">
 				<template #header v-if="!item.children && $slots[item.prop]">
 					<slot :name="`${item.prop}header`" />
@@ -68,7 +71,6 @@
 				</template>
 				<template v-else-if="!item.children" v-slot="scope">
 					<formatter v-if="item.formatter" :fn="item.formatter(scope.row, scope.column, scope.cellValue, scope.index)"> </formatter>
-					<!-- <span v-if="item.formatter">{{ item.formatter(scope.row,scope.column,scope.cellValue,scope.index)  }}</span> -->
 					<template v-else-if="item.type === 'image'">
 						<el-image
 							:style="{ width: `${item.width}px`, height: `${item.height}px` }"
@@ -87,12 +89,10 @@
 					<!-- 自定义列插槽,插槽名为columns属性的prop -->
 					<template #default="scope" v-if="$slots[childrenItem.prop]">
 						<formatter v-if="childrenItem.formatter" :fn="childrenItem.formatter(scope.row, scope.column, scope.cellValue, scope.index)"> </formatter>
-						<!-- <span v-if="childrenItem.formatter">{{ childrenItem.formatter(scope.row,scope.column,scope.cellValue,scope.index) }}</span> -->
 						<slot v-else :name="childrenItem.prop" v-bind="scope"></slot>
 					</template>
 					<template v-else v-slot="scope">
 						<formatter v-if="childrenItem.formatter" :fn="childrenItem.formatter(scope.row, scope.column, scope.cellValue, scope.index)"> </formatter>
-						<!-- <span v-if="childrenItem.formatter">{{ childrenItem.formatter(scope.row,scope.column,scope.cellValue,scope.index) }}</span> -->
 						<template v-else-if="childrenItem.type === 'image'">
 							<el-image
 								:style="{ width: `${childrenItem.width}px`, height: `${childrenItem.height}px` }"
@@ -203,13 +203,20 @@ const state = reactive({
 	selectlist: [] as EmptyObjectType[],
 	checkListAll: true,
 	checkListIndeterminate: false,
+	oldColumns: [] as EmptyObjectType[],
+	columns: [] as EmptyObjectType[],
+	haveFixed: false,
+	currentFixed: false,
+	serialNoFixed: false,
+	switchFixedContent: '取消固定列',
+	fixedIconColor: themeConfig.value.primary,
 });
 
 const hideTool = computed(() => {
 	return props.config.hideTool ?? false;
 });
 
-const getProperty = (obj, property) => {
+const getProperty = (obj: any, property: any) => {
 	const keys = property.split('.');
 	let value = obj;
 	for (const key of keys) {
@@ -232,19 +239,19 @@ const getConfig = computed(() => {
 });
 // 设置 tool header 数据
 const setHeader = computed(() => {
-	return props.columns.filter((v) => v.isCheck);
+	return state.columns.filter((v) => v.isCheck);
 });
 // tool 列显示全选改变时
 const onCheckAllChange = <T,>(val: T) => {
-	if (val) props.columns.forEach((v) => (v.isCheck = true));
-	else props.columns.forEach((v) => (v.isCheck = false));
+	if (val) state.columns.forEach((v) => (v.isCheck = true));
+	else state.columns.forEach((v) => (v.isCheck = false));
 	state.checkListIndeterminate = false;
 };
 // tool 列显示当前项改变时
 const onCheckChange = () => {
-	const headers = props.columns.filter((v) => v.isCheck).length;
-	state.checkListAll = headers === props.columns.length;
-	state.checkListIndeterminate = headers > 0 && headers < props.columns.length;
+	const headers = state.columns.filter((v) => v.isCheck).length;
+	state.checkListAll = headers === state.columns.length;
+	state.checkListIndeterminate = headers > 0 && headers < state.columns.length;
 };
 // 表格多选改变时
 const onSelectionChange = (val: EmptyObjectType[]) => {
@@ -362,7 +369,7 @@ const onSetTable = () => {
 			onEnd: () => {
 				const headerList: EmptyObjectType[] = [];
 				sortable.toArray().forEach((val: any) => {
-					props.columns.forEach((v) => {
+					state.columns.forEach((v) => {
 						if (v.prop === val) headerList.push({ ...v });
 					});
 				});
@@ -419,12 +426,46 @@ const setTableData = (data: Array<EmptyObjectType>, add: boolean = false) => {
 	}
 };
 
+const clearFixed = () => {
+	for (let item of state.columns) {
+		delete item['fixed']
+	}
+};
+
+const switchFixed = () => {
+	state.currentFixed = !state.currentFixed;
+	state.switchFixedContent = state.currentFixed ? '取消固定列' : '启用固定列';
+	if (state.currentFixed) {
+		state.fixedIconColor = themeConfig.value.primary;
+		state.columns = JSON.parse(JSON.stringify(state.oldColumns));
+	} else {
+		state.fixedIconColor = '';
+		clearFixed();
+	}
+};
+
+const refreshColumns = () => {
+	state.oldColumns = JSON.parse(JSON.stringify(props.columns));
+	state.columns = props.columns;
+	for (let item of state.columns) {
+		if (item.fixed !== undefined) {
+			state.haveFixed = true;
+			state.currentFixed = true;
+			if (item.fixed == 'left') {
+				state.serialNoFixed = true;
+				break;
+			}
+		}
+	}
+};
+
 onMounted(() => {
 	if (props.defaultSort) {
 		state.page.field = props.defaultSort.prop;
 		state.page.order = props.defaultSort.order;
 	}
 	state.page.pageSize = props.config.pageSize ?? 10;
+	refreshColumns();
 	handleList();
 });
 
@@ -435,6 +476,7 @@ defineExpose({
 	toggleSelection,
 	getTableData,
 	setTableData,
+	refreshColumns,
 });
 </script>
 

+ 12 - 1
Web/src/stores/userInfo.ts

@@ -1,5 +1,7 @@
 import { defineStore } from 'pinia';
-import { Session } from '/@/utils/storage';
+import { Local, Session } from '/@/utils/storage';
+import Watermark from '/@/utils/watermark';
+import { useThemeConfig } from '/@/stores/themeConfig';
 
 import { getAPI } from '/@/utils/axios-utils';
 import { SysAuthApi, SysConstApi, SysDictTypeApi } from '/@/api-services/api';
@@ -88,6 +90,15 @@ export const useUserInfo = defineStore('userInfo', {
 						// 增加了下面代码,引起当前会话的用户信息不会刷新,如:重新提交的头像不更新,需要新开一个页面才能正确显示
 						// Session.set('userInfo', userInfos);
 
+						// 用户水印
+						const storesThemeConfig = useThemeConfig();
+						storesThemeConfig.themeConfig.watermarkText = d.watermarkText ?? '';
+						if (storesThemeConfig.themeConfig.isWatermark) Watermark.set(storesThemeConfig.themeConfig.watermarkText);
+						else Watermark.del();
+
+						Local.remove('themeConfig');
+						Local.set('themeConfig', storesThemeConfig.themeConfig);
+
 						resolve(userInfos);
 					});
 			});

+ 3 - 3
Web/src/utils/watermark.ts

@@ -3,14 +3,14 @@ const setWatermark = (str: string) => {
 	const id = '1.23452384164.123412416';
 	if (document.getElementById(id) !== null) document.body.removeChild(<HTMLElement>document.getElementById(id));
 	const can = document.createElement('canvas');
-	can.width = 200;
-	can.height = 130;
+	can.width = 400;
+	can.height = 200;
 	const cans = <CanvasRenderingContext2D>can.getContext('2d');
 	cans.rotate((-20 * Math.PI) / 180);
 	cans.font = '12px Vedana';
 	cans.fillStyle = 'rgba(200, 200, 200, 0.30)';
 	cans.textBaseline = 'middle';
-	cans.fillText(str, can.width / 10, can.height / 2);
+	cans.fillText(str, can.width / 10, can.height, can.width);
 	const div = document.createElement('div');
 	div.id = id;
 	div.style.pointerEvents = 'none';

+ 70 - 0
Web/src/views/home/widgets/components/commit.vue

@@ -0,0 +1,70 @@
+<template>
+	<el-card shadow="hover" header="更新记录">
+		<template #header>
+			<el-button type="primary" icon="ele-Refresh" round @click="refresh">更新记录</el-button>
+		</template>
+		<div class="commit" v-loading="state.loading">
+			<el-timeline style="max-width: 600px" v-if="state.list.length > 0">
+				<el-timeline-item v-for="(item, index) in state.list" :key="index" :timestamp="formatDate(new Date(item.commit.committer.date), 'YYYY-mm-dd HH:MM:SS')">
+					<el-link style="white-space: pre-line" :href="item.html_url" target="_blank"> {{ item.commit.message }}</el-link>
+				</el-timeline-item>
+			</el-timeline>
+			<el-empty v-else description="空"></el-empty>
+		</div>
+	</el-card>
+</template>
+
+<script lang="ts">
+export default {
+	title: '更新记录',
+	icon: 'ele-Message',
+	description: '当前项目更新记录',
+};
+</script>
+
+<script setup lang="ts" name="commit">
+import { reactive, onMounted } from 'vue';
+import { formatDate } from '/@/utils/formatTime';
+
+const state = reactive({
+	loading: false,
+	list: [] as any,
+});
+
+const getList = () => {
+	axios({
+		method: 'get',
+		url: 'https://gitee.com/api/v5/repos/zuohuaijun/Admin.NET/commits',
+		params: {
+			page: 1,
+			per_page: 10,
+		},
+	}).then((res: any) => {
+		state.list = res.data;
+		state.loading = false;
+	});
+};
+
+const refresh = () => {
+	state.loading = true;
+	getList();
+};
+
+onMounted(() => {
+	state.loading = true;
+	getList();
+});
+</script>
+
+<style scoped>
+.progress {
+	text-align: center;
+}
+.progress .percentage-value {
+	font-size: 28px;
+}
+.progress .percentage-label {
+	font-size: 12px;
+	margin-top: 10px;
+}
+</style>

+ 6 - 2
Web/src/views/home/widgets/index.vue

@@ -144,7 +144,11 @@ interface Grid {
 }
 const defaultGrid = {
 	layout: [12, 6, 6],
-	copmsList: [['welcome'], ['about', 'ver'], ['timeing', 'progressing']],
+	copmsList: [
+		['welcome', 'commit'],
+		['about', 'ver'],
+		['timeing', 'progressing'],
+	],
 };
 
 const customizing = ref<boolean>(false);
@@ -178,7 +182,7 @@ const availableCompsList = computed(() => {
 });
 
 const myCompsList = computed(() => {
-	const myGrid = Local.get('DASHBOARDGRID') || ['welcome', 'myapp', 'ver', 'timeing', 'progressing', 'echarts', 'about'];
+	const myGrid = Local.get('DASHBOARDGRID') || ['welcome', 'myapp', 'ver', 'timeing', 'progressing', 'echarts', 'about', 'commit'];
 	return availableCompsList.value.filter((comp) => !comp.disabled && myGrid.includes(comp.key));
 });
 

+ 9 - 2
Web/src/views/system/database/index.vue

@@ -134,16 +134,23 @@ const handleQueryTable = async () => {
 	state.loading = true;
 
 	var res = await getAPI(SysDatabaseApi).apiSysDatabaseTableListConfigIdGet(state.configId);
-	state.tableData = res.data.result ?? [];
+	let tableData = res.data.result ?? [];
+	tableData.forEach((element: any) => {
+		//排除zero_开头的表
+		if (!element.name.startsWith('zero_')) {
+			state.tableData.push(element);
+		}
+	});
 	state.loading = false;
 };
 
 // 列查询操作
 const handleQueryColumn = async () => {
 	state.columnData = [];
-	if (state.tableName == '') return;
+	if (state.tableName == '' || typeof state.tableName == 'undefined') return;
 
 	state.loading1 = true;
+	state.tableData = [];
 	var res = await getAPI(SysDatabaseApi).apiSysDatabaseColumnListTableNameConfigIdGet(state.tableName, state.configId);
 	state.columnData = res.data.result ?? [];
 	state.loading1 = false;

+ 9 - 1
Web/src/views/system/job/component/editJobTrigger.vue

@@ -135,7 +135,7 @@
 					<span> Cron表达式生成器 </span>
 				</div>
 			</template>
-			<vcrontab @hide="state.showCronDialog = false" @fill="crontabFill" :expression="cronValue"></vcrontab>
+			<vcrontab id="vcrontab" @hide="state.showCronDialog = false" @fill="crontabFill" :expression="cronValue"></vcrontab>
 		</el-dialog>
 	</div>
 </template>
@@ -286,3 +286,11 @@ const macroDropDownCommand = (item: MacroData) => {
 // 导出对象
 defineExpose({ openDialog });
 </script>
+
+<style lang="scss" scoped>
+#vcrontab {
+	:deep(.el-select) {
+		min-width: 300px;
+	}
+}
+</style>